Files
Agent-System/internal/app/docgov_owner.go
T
weichen dbae19e80d fix(intentflow): bind resource versions for U10/U11 at plan create
Mutate/docgov live Authorize requires ExpectedResourceVersion; populate it
(and frame slots) when HandleTurn drafts confirm-class steps so confirm
no longer fails closed with PLAN_ACTION_VERSION_CONFLICT.
2026-09-23 15:44:48 +00:00

161 lines
4.3 KiB
Go

package app
import (
"context"
"fmt"
"strconv"
"sync"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/docgov"
)
const frozenProject143 = int64(143)
// DocGovRoleResolver maps a user to a docgov role for a document action.
type DocGovRoleResolver func(ctx context.Context, docID string, userID int64) docgov.UserRole
// DocGovOwner executes live U11 keys via the docgov engine; other keys stay dry-run.
type DocGovOwner struct {
Gov *docgov.GovernanceEngine
Access ProjectAccess
Resolve DocGovRoleResolver
Denied bool
dryRun *intentflow.DryRunAdapter
mu sync.Mutex
receipts map[string]intentflow.Receipt
}
func NewDocGovOwner(gov *docgov.GovernanceEngine, access ProjectAccess, resolve DocGovRoleResolver) *DocGovOwner {
return &DocGovOwner{
Gov: gov,
Access: access,
Resolve: resolve,
dryRun: intentflow.NewDocGovDryRunAdapter(),
receipts: map[string]intentflow.Receipt{},
}
}
func (d *DocGovOwner) isLiveKey(key string) bool {
_, ok := intentflow.DocGovLiveActionKeys[key]
return ok
}
func (d *DocGovOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
if d == nil || d.Denied {
return intentflow.ErrPlanActionForbidden
}
if !d.isLiveKey(exec.ActionKey) {
return d.dryRun.Authorize(ctx, exec)
}
if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" {
return intentflow.ErrPlanActionForbidden
}
if exec.Principal.ProjectID == frozenProject143 {
return intentflow.ErrPlanActionForbidden
}
if d.Access != nil {
if err := d.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil {
return intentflow.ErrPlanActionForbidden
}
}
if exec.ExpectedResourceVersion == "" {
return intentflow.ErrPlanActionVersionConflict
}
docID := docIDFromExec(exec)
if docID == "" || d.Gov == nil {
return intentflow.ErrPlanActionForbidden
}
doc, err := d.Gov.GetDocument(ctx, docID)
if err != nil || doc == nil {
return intentflow.ErrPlanActionForbidden
}
if docVersion(doc) != exec.ExpectedResourceVersion {
return intentflow.ErrPlanActionVersionConflict
}
return nil
}
func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
if !d.isLiveKey(exec.ActionKey) {
return d.dryRun.Execute(ctx, exec)
}
if err := d.Authorize(ctx, exec); err != nil {
return intentflow.Receipt{}, err
}
d.mu.Lock()
if rec, ok := d.receipts[exec.IdempotencyKey]; ok {
d.mu.Unlock()
if rec.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return rec, nil
}
d.mu.Unlock()
docID := docIDFromExec(exec)
role := docgov.RoleAuthor
if d.Resolve != nil {
role = d.Resolve(ctx, docID, exec.Principal.UserID)
}
name := "user"
var updated *docgov.DocumentRecord
var err error
switch exec.ActionKey {
case "document.claim":
updated, err = d.Gov.RecordDocumentViewed(ctx, docID, exec.Principal.UserID, role, name)
case "document.review.submit":
updated, err = d.Gov.SubmitForReview(ctx, docID, exec.Principal.UserID, role, name)
default:
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
}
if err != nil {
return intentflow.Receipt{}, err
}
rec := intentflow.Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: "docgov:" + docID,
ResultDigest: fmt.Sprintf("docgov:%s:ver=%s", exec.ActionKey, docVersion(updated)),
SafeSummary: exec.ActionKey + " accepted",
}
d.mu.Lock()
defer d.mu.Unlock()
if existing, ok := d.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return existing, nil
}
d.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
if d == nil {
return intentflow.Receipt{}, false, nil
}
d.mu.Lock()
rec, ok := d.receipts[idempotencyKey]
d.mu.Unlock()
if ok {
return rec, true, nil
}
return d.dryRun.LookupReceipt(ctx, idempotencyKey)
}
func docIDFromExec(exec intentflow.StepExec) string {
if id := docIDFromSlotsOrSummary(exec.Slots, exec.SafeSummary); id != "" {
return id
}
return ""
}
func docVersion(doc *docgov.DocumentRecord) string {
if doc == nil {
return ""
}
return strconv.Itoa(doc.Version)
}