Files
Agent-System/internal/docgov/duty_slots.go
T
WeiCHEN 9c7a5be209 feat(arch): clear G1 U3/U4 sql_write and path exceptions
Move document_records duty-slot writes into docgov, project ledger
heads through docgov/freedoc ports, and workspace deliverable I/O
through DeliverableStore so arch-check baseline drops to sql_write:1
path:13.
2026-09-22 21:40:12 +08:00

195 lines
6.0 KiB
Go

package docgov
import (
"context"
"fmt"
"chat-agent-system/internal/docduty"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ClearDutySlots clears reviewer/approver columns on document_records for the given revocations.
// Author columns are never written. Skips duty_locked rows.
func ClearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
if tx == nil || projectID <= 0 {
return nil
}
for _, rev := range revs {
q, ok := clearDutySlotSQL(rev.Duty)
if !ok {
continue
}
if _, err := tx.Exec(ctx, q, projectID, rev.UserID); err != nil {
return fmt.Errorf("docgov: clear %s for user %d: %w", rev.Duty, rev.UserID, err)
}
}
return nil
}
func clearDutySlotSQL(duty string) (string, bool) {
switch duty {
case docduty.DutyReviewer:
return `
UPDATE document_records
SET reviewer_id = NULL, reviewer_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2 AND NOT duty_locked
`, true
case docduty.DutyApprover:
return `
UPDATE document_records
SET approver_id = NULL, approver_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2 AND NOT duty_locked
`, true
default:
return "", false
}
}
// DutySlotWriter adapts ClearDutySlots for authz injection (authz must not import docgov).
type DutySlotWriter struct{}
func (DutySlotWriter) ClearSlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
return ClearDutySlots(ctx, tx, projectID, revs)
}
type storedDocDuties struct {
DocID string
Author docduty.Holder
Reviewer docduty.Holder
Approver docduty.Holder
}
// BackfillDocumentDuties aligns controlled document duty columns to assigned project duties.
// Empty assigned slots are not invented or cleared.
func BackfillDocumentDuties(ctx context.Context, db docduty.DB, projectID int64, assigned docduty.Binding) (int, error) {
if db == nil || projectID <= 0 {
return 0, nil
}
members, creator, err := docduty.LoadProjectMembersDB(ctx, db, projectID)
if err != nil {
return 0, err
}
memberIDs := docduty.ActiveMemberIDs(members, creator)
rows, err := db.Query(ctx, `
SELECT doc_id,
COALESCE(author_id, 0), COALESCE(author_name, ''),
COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''),
COALESCE(approver_id, 0), COALESCE(approver_name, '')
FROM document_records
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND NOT duty_locked
`, projectID)
if err != nil {
return 0, fmt.Errorf("docgov: list documents: %w", err)
}
defer rows.Close()
pending := make([]storedDocDuties, 0)
for rows.Next() {
var rec storedDocDuties
if err := rows.Scan(
&rec.DocID,
&rec.Author.UserID, &rec.Author.Name,
&rec.Reviewer.UserID, &rec.Reviewer.Name,
&rec.Approver.UserID, &rec.Approver.Name,
); err != nil {
return 0, err
}
current := docduty.Binding{Author: rec.Author, Reviewer: rec.Reviewer, Approver: rec.Approver}
next, changed := docduty.HealBinding(current, assigned, memberIDs)
if !changed {
continue
}
rec.Author, rec.Reviewer, rec.Approver = next.Author, next.Reviewer, next.Approver
pending = append(pending, rec)
}
if err := rows.Err(); err != nil {
return 0, err
}
updated := 0
for _, rec := range pending {
tag, err := db.Exec(ctx, `
UPDATE document_records
SET author_id = $2, author_name = $3,
reviewer_id = $4, reviewer_name = $5,
approver_id = $6, approver_name = $7,
updated_at = now()
WHERE doc_id = $1 AND project_id = $8
`, rec.DocID, nullIfZeroDuty(rec.Author.UserID), rec.Author.Name,
nullIfZeroDuty(rec.Reviewer.UserID), rec.Reviewer.Name,
nullIfZeroDuty(rec.Approver.UserID), rec.Approver.Name,
projectID)
if err != nil {
return updated, fmt.Errorf("docgov: backfill %s: %w", rec.DocID, err)
}
if tag.RowsAffected() > 0 {
updated++
}
}
return updated, nil
}
func nullIfZeroDuty(id int64) any {
if id <= 0 {
return nil
}
return id
}
// HealProjectDocuments runs project advisory lock, duty resolve, and document_records backfill in one txn.
func HealProjectDocuments(ctx context.Context, pool *pgxpool.Pool, projectID int64) (docduty.Binding, int, error) {
if pool == nil || projectID <= 0 {
return docduty.Binding{}, 0, nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return docduty.Binding{}, 0, fmt.Errorf("docgov: begin heal: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
return docduty.Binding{}, 0, err
}
assigned, n, err := healProjectDocumentsTx(ctx, tx, projectID)
if err != nil {
return docduty.Binding{}, 0, err
}
if err := tx.Commit(ctx); err != nil {
return docduty.Binding{}, 0, fmt.Errorf("docgov: commit heal: %w", err)
}
return assigned, n, nil
}
func healProjectDocumentsTx(ctx context.Context, tx pgx.Tx, projectID int64) (docduty.Binding, int, error) {
assigned, err := docduty.ResolveProjectTx(ctx, tx, projectID, docduty.RequestDuties{})
if err != nil {
return docduty.Binding{}, 0, err
}
n, err := (Repository{}).BackfillDuties(ctx, tx, projectID, assigned)
if err != nil {
return docduty.Binding{}, 0, err
}
return assigned, n, nil
}
// HealProjectDocuments aligns controlled document duties for a project under the governance engine.
func (e *GovernanceEngine) HealProjectDocuments(ctx context.Context, projectID int64) (docduty.Binding, int, error) {
if e == nil {
return docduty.Binding{}, 0, nil
}
return HealProjectDocuments(ctx, e.pool, projectID)
}
// ClearDutySlots is the repository write port for reviewer/approver slot revocation.
func (Repository) ClearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
return ClearDutySlots(ctx, tx, projectID, revs)
}
// BackfillDuties is the repository write port for duty column healing.
func (Repository) BackfillDuties(ctx context.Context, tx pgx.Tx, projectID int64, assigned docduty.Binding) (int, error) {
return BackfillDocumentDuties(ctx, tx, projectID, assigned)
}