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.
This commit is contained in:
+2
-1
@@ -306,7 +306,8 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
taskOwner,
|
||||
liveOwners,
|
||||
),
|
||||
Events: events.PGSink{},
|
||||
Events: events.PGSink{},
|
||||
ResourceVersionBinder: newResourceVersionBinder(projectBlackboardPort(pool, parts.Ctx), parts.Gov),
|
||||
})
|
||||
}
|
||||
svc.SetAgentMaterializer(rt.Docflow)
|
||||
|
||||
@@ -2,10 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
@@ -148,15 +146,10 @@ func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string)
|
||||
}
|
||||
|
||||
func docIDFromExec(exec intentflow.StepExec) string {
|
||||
if len(exec.Slots) > 0 {
|
||||
var slots map[string]string
|
||||
if json.Unmarshal(exec.Slots, &slots) == nil {
|
||||
if id := strings.TrimSpace(slots["doc_id"]); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
if id := docIDFromSlotsOrSummary(exec.Slots, exec.SafeSummary); id != "" {
|
||||
return id
|
||||
}
|
||||
return strings.TrimSpace(exec.SafeSummary)
|
||||
return ""
|
||||
}
|
||||
|
||||
func docVersion(doc *docgov.DocumentRecord) string {
|
||||
|
||||
@@ -24,20 +24,35 @@ type Plans struct {
|
||||
owner ActionOwner
|
||||
authz StepAuthorizer
|
||||
events EventSink
|
||||
bind ResourceVersionBinder
|
||||
now func() time.Time
|
||||
newID func(prefix string) string
|
||||
lease time.Duration
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// ResourceVersionBindInput carries enough context to resolve optimistic versions
|
||||
// for mutate/docgov steps at plan-create time (U10/U11).
|
||||
type ResourceVersionBindInput struct {
|
||||
Principal Principal
|
||||
ActionKey string
|
||||
SafeSummary string
|
||||
Slots []byte
|
||||
}
|
||||
|
||||
// ResourceVersionBinder returns the expected resource version for a draft step.
|
||||
// Empty string means the action key does not require a version binding.
|
||||
type ResourceVersionBinder func(ctx context.Context, in ResourceVersionBindInput) (string, error)
|
||||
|
||||
type Options struct {
|
||||
Owner ActionOwner
|
||||
Authorizer StepAuthorizer
|
||||
Events EventSink
|
||||
Now func() time.Time
|
||||
NewID func(prefix string) string
|
||||
Lease time.Duration
|
||||
PlanTTL time.Duration
|
||||
Owner ActionOwner
|
||||
Authorizer StepAuthorizer
|
||||
Events EventSink
|
||||
ResourceVersionBinder ResourceVersionBinder
|
||||
Now func() time.Time
|
||||
NewID func(prefix string) string
|
||||
Lease time.Duration
|
||||
PlanTTL time.Duration
|
||||
}
|
||||
|
||||
func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans {
|
||||
@@ -63,7 +78,10 @@ func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans {
|
||||
authz = a
|
||||
}
|
||||
}
|
||||
return &Plans{uow: unit, store: store, owner: opt.Owner, authz: authz, events: opt.Events, now: now, newID: newID, lease: lease, ttl: ttl}
|
||||
return &Plans{
|
||||
uow: unit, store: store, owner: opt.Owner, authz: authz, events: opt.Events,
|
||||
bind: opt.ResourceVersionBinder, now: now, newID: newID, lease: lease, ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plans) stepAuthorizer() StepAuthorizer {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -118,11 +119,27 @@ func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResu
|
||||
if tier == TierShadow {
|
||||
mode = ModeValidatedOnly
|
||||
}
|
||||
slots := slotsJSONFromSegment(seg)
|
||||
expectedRev := ""
|
||||
if p != nil && p.bind != nil && mode == ModeLive {
|
||||
bound, bindErr := p.bind(ctx, ResourceVersionBindInput{
|
||||
Principal: in.Principal,
|
||||
ActionKey: key,
|
||||
SafeSummary: summary,
|
||||
Slots: slots,
|
||||
})
|
||||
if bindErr != nil {
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
expectedRev = bound
|
||||
}
|
||||
drafts = append(drafts, StepDraft{
|
||||
ActionKey: key,
|
||||
ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key),
|
||||
SafeSummary: summary,
|
||||
ExecutionMode: mode,
|
||||
ActionKey: key,
|
||||
ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key),
|
||||
SafeSummary: summary,
|
||||
ExecutionMode: mode,
|
||||
Slots: slots,
|
||||
ExpectedResourceVersion: expectedRev,
|
||||
})
|
||||
}
|
||||
turnID := in.ClientTurnID
|
||||
@@ -202,6 +219,27 @@ func counterfactualRoute(proposal *intent.PlanProposal) string {
|
||||
}
|
||||
}
|
||||
|
||||
func slotsJSONFromSegment(seg intent.ProposedSegment) []byte {
|
||||
if seg.Frame == nil || len(seg.Frame.Slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(seg.Frame.Slots))
|
||||
for k, sv := range seg.Frame.Slots {
|
||||
v := strings.TrimSpace(sv.Value)
|
||||
if v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func shortDigest(text string) string {
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
"chat-agent-system/internal/docgov"
|
||||
)
|
||||
|
||||
// newResourceVersionBinder binds optimistic versions for U10/U11 live keys at
|
||||
// plan-create time so confirm-time Authorize sees ExpectedResourceVersion.
|
||||
func newResourceVersionBinder(bb ProjectBlackboardPort, gov *docgov.GovernanceEngine) intentflow.ResourceVersionBinder {
|
||||
return func(ctx context.Context, in intentflow.ResourceVersionBindInput) (string, error) {
|
||||
switch in.ActionKey {
|
||||
case mutateActionKey:
|
||||
if bb.CurrentVersion == nil {
|
||||
return "0", nil
|
||||
}
|
||||
return bb.CurrentVersion(ctx, in.Principal.ProjectID)
|
||||
case "document.claim", "document.review.submit":
|
||||
if gov == nil {
|
||||
return "", intentflow.ErrPlanActionForbidden
|
||||
}
|
||||
docID := docIDFromSlotsOrSummary(in.Slots, in.SafeSummary)
|
||||
if docID == "" {
|
||||
return "", intentflow.ErrPlanActionForbidden
|
||||
}
|
||||
doc, err := gov.GetDocument(ctx, docID)
|
||||
if err != nil || doc == nil {
|
||||
return "", intentflow.ErrPlanActionForbidden
|
||||
}
|
||||
return docVersion(doc), nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func docIDFromSlotsOrSummary(slots []byte, summary string) string {
|
||||
if len(slots) > 0 {
|
||||
var m map[string]string
|
||||
if json.Unmarshal(slots, &m) == nil {
|
||||
if id := strings.TrimSpace(m["doc_id"]); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
summary = strings.TrimSpace(summary)
|
||||
// Prefer explicit doc_proj* tokens embedded in the utterance summary.
|
||||
for _, tok := range strings.Fields(summary) {
|
||||
tok = strings.Trim(tok, ",。,.;;::")
|
||||
if strings.HasPrefix(tok, "doc_") {
|
||||
return tok
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
Reference in New Issue
Block a user