Add notification digests with ON CONFLICT DO UPDATE, migrations 00068-00070, supplementimpact module (analyze → confirm → claim-aware split), and wire coordinator/mutate hooks plus proposal_required digests.
163 lines
4.6 KiB
Go
163 lines
4.6 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"chat-agent-system/internal/app/intentflow"
|
|
)
|
|
|
|
const mutateActionKey = "project.fact.supplement"
|
|
|
|
// ProjectBlackboardPort reads/writes the same project blackboard as SupplementInfo.
|
|
type ProjectBlackboardPort struct {
|
|
CurrentVersion func(ctx context.Context, projectID int64) (string, error)
|
|
MergeFacts func(ctx context.Context, projectID int64, facts map[string]string) (newVersion string, err error)
|
|
}
|
|
|
|
// MutateOwner is the U10 live adapter for project.fact.supplement only.
|
|
type MutateOwner struct {
|
|
Access ProjectAccess
|
|
Blackboard ProjectBlackboardPort
|
|
Denied bool
|
|
// OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync.
|
|
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
|
|
|
|
mu sync.Mutex
|
|
receipts map[string]intentflow.Receipt
|
|
}
|
|
|
|
func NewMutateOwner(access ProjectAccess, bb ProjectBlackboardPort) *MutateOwner {
|
|
return &MutateOwner{
|
|
Access: access,
|
|
Blackboard: bb,
|
|
receipts: map[string]intentflow.Receipt{},
|
|
}
|
|
}
|
|
|
|
func (m *MutateOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
|
|
if m == nil || m.Denied {
|
|
return intentflow.ErrPlanActionForbidden
|
|
}
|
|
if exec.ActionKey != mutateActionKey {
|
|
return intentflow.ErrPlanActionForbidden
|
|
}
|
|
if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" {
|
|
return intentflow.ErrPlanActionForbidden
|
|
}
|
|
if m.Access != nil {
|
|
if err := m.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil {
|
|
return intentflow.ErrPlanActionForbidden
|
|
}
|
|
}
|
|
if exec.ExpectedResourceVersion == "" {
|
|
return intentflow.ErrPlanActionVersionConflict
|
|
}
|
|
if m.Blackboard.CurrentVersion != nil {
|
|
got, err := m.Blackboard.CurrentVersion(ctx, exec.Principal.ProjectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if got != exec.ExpectedResourceVersion {
|
|
return intentflow.ErrPlanActionVersionConflict
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MutateOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
|
|
if err := m.Authorize(ctx, exec); err != nil {
|
|
return intentflow.Receipt{}, err
|
|
}
|
|
m.mu.Lock()
|
|
if rec, ok := m.receipts[exec.IdempotencyKey]; ok {
|
|
m.mu.Unlock()
|
|
if rec.ActionDigest != exec.ActionDigest {
|
|
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
|
|
}
|
|
return rec, nil
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
facts := supplementFactsFromExec(exec)
|
|
if len(facts) == 0 {
|
|
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
|
|
}
|
|
newVer := exec.ExpectedResourceVersion
|
|
if m.Blackboard.MergeFacts != nil {
|
|
got, err := m.Blackboard.MergeFacts(ctx, exec.Principal.ProjectID, facts)
|
|
if err != nil {
|
|
return intentflow.Receipt{}, err
|
|
}
|
|
newVer = got
|
|
if m.OnSupplement != nil {
|
|
summary := strings.TrimSpace(exec.SafeSummary)
|
|
if summary == "" {
|
|
summary = "project facts supplemented"
|
|
}
|
|
markdown := summary
|
|
if len(facts) > 0 {
|
|
parts := make([]string, 0, len(facts))
|
|
for k, v := range facts {
|
|
parts = append(parts, k+": "+v)
|
|
}
|
|
markdown = strings.Join(parts, "\n")
|
|
}
|
|
m.OnSupplement(ctx, exec.Principal.ProjectID, exec.Principal.UserID, exec.Principal.SessionID, summary, markdown)
|
|
}
|
|
}
|
|
|
|
rec := intentflow.Receipt{
|
|
IdempotencyKey: exec.IdempotencyKey,
|
|
ActionDigest: exec.ActionDigest,
|
|
ResultRef: fmt.Sprintf("blackboard:%d", exec.Principal.ProjectID),
|
|
ResultDigest: fmt.Sprintf("supplement:ver=%s", newVer),
|
|
SafeSummary: "project facts supplemented",
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if existing, ok := m.receipts[exec.IdempotencyKey]; ok {
|
|
if existing.ActionDigest != exec.ActionDigest {
|
|
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
|
|
}
|
|
return existing, nil
|
|
}
|
|
m.receipts[exec.IdempotencyKey] = rec
|
|
return rec, nil
|
|
}
|
|
|
|
func (m *MutateOwner) LookupReceipt(_ context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
|
|
if m == nil {
|
|
return intentflow.Receipt{}, false, nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
rec, ok := m.receipts[idempotencyKey]
|
|
return rec, ok, nil
|
|
}
|
|
|
|
func supplementFactsFromExec(exec intentflow.StepExec) map[string]string {
|
|
if len(exec.Slots) > 0 {
|
|
var slots map[string]string
|
|
if json.Unmarshal(exec.Slots, &slots) == nil && len(slots) > 0 {
|
|
out := map[string]string{}
|
|
for k, v := range slots {
|
|
if strings.TrimSpace(v) != "" {
|
|
out[k] = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
text := strings.TrimSpace(exec.SafeSummary)
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
return map[string]string{"supplement_doc": text}
|
|
}
|