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.
161 lines
4.3 KiB
Go
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)
|
|
}
|