Files
Agent-System/internal/docgov/engine_pending.go
T
weichen b5a782bb38 feat(chat): dialogue triggers for comments/reopen/pending-supplements with mistouch guards
Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
2026-09-25 20:14:10 +00:00

210 lines
7.0 KiB
Go

package docgov
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
PendingSupplementPending = "pending"
PendingSupplementDigested = "digested"
PendingSupplementDiscarded = "discarded"
)
// PendingSupplement is one locked-period fact supplement deferred for later digestion.
type PendingSupplement struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
ProjectID int64 `json:"project_id"`
SourceSummary string `json:"source_summary"`
ImpactReason string `json:"impact_reason"`
InitiatorUserID int64 `json:"initiator_user_id"`
BatchID string `json:"batch_id,omitempty"`
Status string `json:"status"`
DigestedAt *time.Time `json:"digested_at,omitempty"`
DecidedBy int64 `json:"decided_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PendingSupplementNotify is invoked after pending rows are marked digested.
type PendingSupplementNotify func(ctx context.Context, doc *DocumentRecord, pending []PendingSupplement)
// RecordPendingSupplementInput captures a supplement that cannot be applied while locked.
type RecordPendingSupplementInput struct {
DocID string
ProjectID int64
InitiatorUserID int64
SourceSummary string
ImpactReason string
BatchID string
}
// RecordPendingSupplement persists a deferred supplement for a locked document.
func (e *GovernanceEngine) RecordPendingSupplement(ctx context.Context, in RecordPendingSupplementInput) (*PendingSupplement, error) {
if e == nil {
return nil, fmt.Errorf("docgov: engine required")
}
docID := strings.TrimSpace(in.DocID)
if docID == "" || in.ProjectID <= 0 {
return nil, fmt.Errorf("docgov: pending supplement requires doc_id and project_id")
}
now := time.Now().UTC()
row := &PendingSupplement{
DocID: docID, ProjectID: in.ProjectID, InitiatorUserID: in.InitiatorUserID,
SourceSummary: strings.TrimSpace(in.SourceSummary),
ImpactReason: strings.TrimSpace(in.ImpactReason),
BatchID: strings.TrimSpace(in.BatchID),
Status: PendingSupplementPending,
CreatedAt: now,
}
if e.pool == nil {
return row, nil
}
err := e.pool.QueryRow(ctx, `
INSERT INTO document_pending_supplements (
doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id, status, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)
RETURNING id, created_at
`, row.DocID, row.ProjectID, row.SourceSummary, row.ImpactReason, row.InitiatorUserID, row.BatchID, row.Status, now).
Scan(&row.ID, &row.CreatedAt)
if err != nil {
return nil, fmt.Errorf("docgov: record pending supplement: %w", err)
}
return row, nil
}
// ListPendingSupplements returns pending rows for a document.
func (e *GovernanceEngine) ListPendingSupplements(ctx context.Context, docID string) ([]PendingSupplement, error) {
if e == nil || e.pool == nil {
return nil, nil
}
docID = strings.TrimSpace(docID)
if docID == "" {
return nil, nil
}
rows, err := e.pool.Query(ctx, `
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
status, digested_at, decided_by, created_at
FROM document_pending_supplements
WHERE doc_id = $1 AND status = $2
ORDER BY created_at ASC, id ASC
`, docID, PendingSupplementPending)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PendingSupplement, 0)
for rows.Next() {
var p PendingSupplement
if err := rows.Scan(
&p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID,
&p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ListPendingSupplementsByProject returns undigested pending rows for a project (optional doc filter).
func (e *GovernanceEngine) ListPendingSupplementsByProject(ctx context.Context, projectID int64, docID string) ([]PendingSupplement, error) {
if e == nil || e.pool == nil || projectID <= 0 {
return nil, nil
}
docID = strings.TrimSpace(docID)
var rows pgx.Rows
var err error
if docID != "" {
rows, err = e.pool.Query(ctx, `
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
status, digested_at, decided_by, created_at
FROM document_pending_supplements
WHERE project_id = $1 AND doc_id = $2 AND status = $3
ORDER BY created_at ASC, id ASC
`, projectID, docID, PendingSupplementPending)
} else {
rows, err = e.pool.Query(ctx, `
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
status, digested_at, decided_by, created_at
FROM document_pending_supplements
WHERE project_id = $1 AND status = $2
ORDER BY created_at ASC, id ASC
`, projectID, PendingSupplementPending)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PendingSupplement, 0)
for rows.Next() {
var p PendingSupplement
if err := rows.Scan(
&p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID,
&p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// DigestPendingSupplements marks all pending rows digested when a document returns to drafting.
// It returns the digested rows so callers can notify the author.
func (e *GovernanceEngine) DigestPendingSupplements(ctx context.Context, docID string, decidedBy int64) ([]PendingSupplement, error) {
if e == nil {
return nil, fmt.Errorf("docgov: engine required")
}
docID = strings.TrimSpace(docID)
if docID == "" {
return nil, nil
}
pending, err := e.ListPendingSupplements(ctx, docID)
if err != nil || len(pending) == 0 {
return pending, err
}
if e.pool == nil {
for i := range pending {
pending[i].Status = PendingSupplementDigested
now := time.Now().UTC()
pending[i].DigestedAt = &now
pending[i].DecidedBy = decidedBy
}
return pending, nil
}
now := time.Now().UTC()
_, err = e.pool.Exec(ctx, `
UPDATE document_pending_supplements
SET status = $2, digested_at = $3, decided_by = $4, updated_at = $3
WHERE doc_id = $1 AND status = $5
`, docID, PendingSupplementDigested, now, decidedBy, PendingSupplementPending)
if err != nil {
return nil, fmt.Errorf("docgov: digest pending supplements: %w", err)
}
for i := range pending {
pending[i].Status = PendingSupplementDigested
pending[i].DigestedAt = &now
pending[i].DecidedBy = decidedBy
}
return pending, nil
}
// DigestPendingOnReturnToDrafting digests pending supplements after a transition back to drafting.
func (e *GovernanceEngine) DigestPendingOnReturnToDrafting(ctx context.Context, doc *DocumentRecord, actorID int64, notify PendingSupplementNotify) {
if e == nil || doc == nil || doc.CurrentState != StateDrafting {
return
}
pending, err := e.DigestPendingSupplements(ctx, doc.DocID, actorID)
if err != nil || len(pending) == 0 {
return
}
if notify != nil {
notify(ctx, doc, pending)
}
}