merge(dev): sync track-s1 shadow onto current local dev

This commit is contained in:
WeiCHEN
2026-09-22 22:13:31 +08:00
286 changed files with 11488 additions and 3021 deletions
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"strings"
"chat-agent-system/internal/config"
"chat-agent-system/internal/workspace"
)
type CursorCLIBackend struct {
@@ -21,7 +22,7 @@ func (c *CursorCLIBackend) Name() string { return "cursor-cli" }
func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, error) {
if len(req.Targets) == 0 {
req.Targets = []string{"output.md"}
req.Targets = []string{workspace.DeliverableKey}
}
workDir := req.WorkDir
+3
View File
@@ -66,6 +66,9 @@ func TestPiAgentDockerfileCopiesRunnerRelativeImports(t *testing.T) {
}
func TestPiAgentDockerBuildContext_RunnerModulesPresent(t *testing.T) {
if testing.Short() {
t.Skip("docker build smoke skipped under -short")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker CLI not available")
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"time"
"chat-agent-system/internal/config"
"chat-agent-system/internal/workspace"
)
type PiAgentBackend struct {
@@ -24,7 +25,7 @@ func (p *PiAgentBackend) Name() string { return "pi-agent" }
func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, error) {
if len(req.Targets) == 0 {
req.Targets = []string{"output.md"}
req.Targets = []string{workspace.DeliverableKey}
}
workDir := req.WorkDir
+13 -2
View File
@@ -205,11 +205,13 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
coordEngine.SetImpactActions(traceChat{svc: traceSvc})
if pool != nil {
access := authz.NewAuthorizer(pool)
wireDutySlotWriter(access)
coordEngine.SetProjectAccess(func(ctx context.Context, projectID, userID int64) error {
return access.RequireProjectAccess(ctx, projectID, userID)
})
}
if parts.Rev != nil {
wireDocRevisionPorts(parts.Rev)
parts.Rev.CoverageResolver = func(ctx context.Context, docID string) string {
projectID := projectIDFromControlledDoc(docID)
if projectID == 0 {
@@ -219,6 +221,8 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
}
}
authorizer := authz.NewAuthorizer(pool)
wireDutySlotWriter(authorizer)
rt := &Runtime{
Cfg: cfg,
Biz: svc,
@@ -226,7 +230,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
Auth: authSvc,
PW: pwSvc,
TaskRuns: taskMgr,
Authorizer: authz.NewAuthorizer(pool),
Authorizer: authorizer,
Authz: authzSvc,
WFDef: wfDefSvc,
Inst: instSvc,
@@ -276,10 +280,17 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
livePI := cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != ""
taskOwner := NewTaskRunAdapter()
taskOwner.Starter = LiveTaskStarter(taskMgr, livePI)
queryOwner := NewQueryOwner(func(ctx context.Context, projectID, userID int64) error {
if rt.Authorizer == nil {
return intentflow.ErrPlanActionForbidden
}
return rt.Authorizer.RequireProjectAccess(ctx, projectID, userID)
}, parts.KB)
// BuildOwnerMux registers U10–U12 dry-run stubs only when independent flags are on.
// Production query path uses QueryOwner (not FakeActionOwner).
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{
Owner: intentflow.BuildOwnerMux(
intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
intentflow.QueryGuard{Inner: queryOwner},
taskOwner,
),
Events: events.PGSink{},
+140
View File
@@ -0,0 +1,140 @@
package app
import (
"context"
"chat-agent-system/internal/app/chatflow"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/taskrun"
)
// AdaptChatMemory wraps chatflow.ChatLog as the chat-owned ChatMemory port.
func AdaptChatMemory(log chatflow.ChatLog) chat.ChatMemory {
if log == nil {
return nil
}
return chatMemoryAdapter{inner: log}
}
type chatMemoryAdapter struct {
inner chatflow.ChatLog
}
func (a chatMemoryAdapter) SearchMemory(ctx context.Context, userID int64, q chat.MemoryQuery) ([]ctxmgr.RecalledMemoryItem, error) {
return a.inner.SearchMemory(ctx, chatflow.Principal{UserID: userID, Action: chatflow.ActionAssemble}, chatflow.MemoryQuery{
Query: q.Query,
ProjectID: q.ProjectID,
SessionID: q.SessionID,
ExcludeSessionID: q.ExcludeSessionID,
TopK: q.TopK,
MinScore: q.MinScore,
})
}
// AdaptChatIntentPlans wraps intentflow.Service as the chat-owned IntentPlans port.
func AdaptChatIntentPlans(svc intentflow.Service) chat.IntentPlans {
if svc == nil {
return nil
}
return chatIntentPlansAdapter{inner: svc}
}
type chatIntentPlansAdapter struct {
inner intentflow.Service
}
func (a chatIntentPlansAdapter) HandleTurn(ctx context.Context, in chat.IntentTurnInput) (*chat.IntentTurnResult, error) {
tr, err := a.inner.HandleTurn(ctx, intentflow.TurnInput{
Principal: intentflow.Principal{
UserID: in.UserID,
Environment: in.Environment,
ProjectID: in.ProjectID,
SessionID: in.SessionID,
},
Text: in.Text,
ClientTurnID: in.ClientTurnID,
RequestDigest: in.RequestDigest,
})
if err != nil {
return nil, err
}
if tr == nil {
return &chat.IntentTurnResult{}, nil
}
return &chat.IntentTurnResult{Plan: mapIntentPlanView(tr.Plan)}, nil
}
func mapIntentPlanView(p *intentflow.PlanView) *chat.IntentPlanView {
if p == nil {
return nil
}
out := &chat.IntentPlanView{
PlanID: p.PlanID,
Revision: p.Revision,
Status: string(p.Status),
ExecutionMode: string(p.ExecutionMode),
ExpiresAt: p.ExpiresAt,
SafeSummary: p.SafeSummary,
}
if len(p.Steps) > 0 {
out.Steps = make([]chat.IntentStepView, 0, len(p.Steps))
for _, s := range p.Steps {
out.Steps = append(out.Steps, chat.IntentStepView{
StepID: s.StepID,
ActionKey: s.ActionKey,
Status: string(s.Status),
ExecutionMode: string(s.ExecutionMode),
DependsOn: append([]string(nil), s.DependsOn...),
SafeSummary: s.SafeSummary,
})
}
}
return out
}
// AdaptChatTaskStarter wraps taskrun.Manager as the chat-owned TaskStarter port.
func AdaptChatTaskStarter(m *taskrun.Manager) chat.TaskStarter {
if m == nil {
return nil
}
return chatTaskStarter{inner: m}
}
type chatTaskStarter struct {
inner *taskrun.Manager
}
func (a chatTaskStarter) CreateAndStart(ctx context.Context, runID string, record *taskrun.RunRecord, req chat.TaskStartRequest, resumeDir string) (*taskrun.RunRecord, error) {
return a.inner.CreateAndStart(ctx, runID, record, bizdev.TaskRunRequest{
TaskKey: req.TaskKey,
ProjectName: req.ProjectName,
UserID: req.UserID,
ProjectID: req.ProjectID,
SessionID: req.SessionID,
}, bizdev.StageRunRequest{}, resumeDir)
}
// BindChatSession attaches composition-root adapters onto a chat session.
// Callers that keep a mutable intentflow.Service (e.g. server test hooks) should
// prefer AdaptChat* helpers with their own fields so swaps stay visible.
func (rt *Runtime) BindChatSession(sess *chat.Session, env string) {
if rt == nil || sess == nil {
return
}
if env == "" {
env = "dev"
if rt.Cfg != nil && rt.Cfg.Env != "" {
env = rt.Cfg.Env
}
}
sess.ChatLog = AdaptChatMemory(rt.ChatLog)
sess.Actions = &chat.ActionServices{
DocGov: rt.Gov,
TaskRuns: AdaptChatTaskStarter(rt.TaskRuns),
IntentPlans: AdaptChatIntentPlans(rt.IntentPlans),
Environment: env,
}
}
+34 -4
View File
@@ -31,19 +31,22 @@ func (a *Assembler) Assemble(ctx context.Context, p Principal, req AssembleReque
}
p.Action = ActionAssemble
user := toCoordinatorUser(req.User)
fallback := toCoordinatorProject(req.Fallback)
var userCtx *coordinator.UserProjectContext
if a.Inner != nil && strings.TrimSpace(req.WorkDir) != "" {
userCtx, _ = a.Inner.AssembleFromWorkspace(req.WorkDir, req.User)
userCtx, _ = a.Inner.AssembleFromWorkspace(req.WorkDir, user)
}
if userCtx == nil {
userCtx = &coordinator.UserProjectContext{
SnapshotID: fmt.Sprintf("ctx_%d", time.Now().UnixNano()),
AssembledAt: time.Now(),
User: req.User,
User: user,
WorkDir: req.WorkDir,
Project: req.Fallback,
Project: fallback,
BlackboardFacts: make(map[string]string),
AvailableActions: coordinator.ComputeAvailableActions(maturity.StageV01_Triage, req.User.ProjectRole),
AvailableActions: coordinator.ComputeAvailableActions(maturity.StageV01_Triage, user.ProjectRole),
}
if userCtx.Project.CurrentStage == "" {
userCtx.Project.CurrentStage = maturity.StageV01_Triage
@@ -83,6 +86,33 @@ func (a *Assembler) Assemble(ctx context.Context, p Principal, req AssembleReque
return userCtx, nil
}
func toCoordinatorUser(u AssembleUser) coordinator.UserInfo {
return coordinator.UserInfo{
ID: u.ID,
Name: u.Name,
Role: u.Role,
ProjectRole: u.ProjectRole,
}
}
func toCoordinatorProject(p AssembleProject) coordinator.ProjectInfo {
stage := maturity.MaturityStage(strings.TrimSpace(p.CurrentStage))
return coordinator.ProjectInfo{
ID: p.ID,
Name: p.Name,
CustomerName: p.CustomerName,
DeviceType: p.DeviceType,
IntendedUse: p.IntendedUse,
TargetMarket: p.TargetMarket,
CreatorUserID: p.CreatorUserID,
ActiveWorkflowKey: p.ActiveWorkflowKey,
WorkflowSelectionVersion: p.WorkflowSelectionVersion,
CurrentWorkflow: p.CurrentWorkflow,
CurrentStage: stage,
StageRules: append([]string(nil), p.StageRules...),
}
}
func authorizedTurns(restored *ctxmgr.RestoredSessionContext) []coordinator.DialogueTurn {
if restored == nil {
return nil
+1 -2
View File
@@ -7,7 +7,6 @@ import (
"testing"
"chat-agent-system/internal/app/chatflow"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/projectinstance"
)
@@ -180,7 +179,7 @@ func TestAssembler_OnlyAuthorizedDialogue(t *testing.T) {
ctx := context.Background()
userCtx, err := asm.Assemble(ctx, chatflow.Principal{UserID: ownerID}, chatflow.AssembleRequest{
SessionID: sess,
User: coordinator.UserInfo{ID: "u9", Name: "张工", ProjectRole: "composer"},
User: chatflow.AssembleUser{ID: "u9", Name: "张工", ProjectRole: "composer"},
})
if err != nil {
t.Fatal(err)
+27 -2
View File
@@ -72,11 +72,36 @@ type MemoryQuery struct {
MinScore float64
}
// AssembleUser is the chatflow-owned identity DTO for context assembly.
// It mirrors coordinator.UserInfo fields without embedding that concrete type.
type AssembleUser struct {
ID string
Name string
Role string
ProjectRole string
}
// AssembleProject is the chatflow-owned project fallback DTO for context assembly.
type AssembleProject struct {
ID string
Name string
CustomerName string
DeviceType string
IntendedUse string
TargetMarket string
CreatorUserID int64
ActiveWorkflowKey string
WorkflowSelectionVersion int
CurrentWorkflow string
CurrentStage string
StageRules []string
}
type AssembleRequest struct {
SessionID string
WorkDir string
User coordinator.UserInfo
Fallback coordinator.ProjectInfo
User AssembleUser
Fallback AssembleProject
}
type StageAccessResolver func(ctx context.Context, projectID, viewerUserID int64) (*projectinstance.StageAccess, error)
@@ -0,0 +1,36 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
"chat-agent-system/internal/app/intentflow"
)
func TestProductionCompositionRoot_NoFakeQueryOwner(t *testing.T) {
root := "."
src, err := os.ReadFile(filepath.Join(root, "app.go"))
if err != nil {
t.Fatal(err)
}
body := string(src)
if strings.Contains(body, "NewFakeActionOwner()") {
t.Fatal("production composition root must not construct FakeActionOwner")
}
if !strings.Contains(body, "NewQueryOwner(") {
t.Fatal("production composition root must wire NewQueryOwner")
}
if !strings.Contains(body, "QueryGuard{Inner: queryOwner}") {
t.Fatal("production composition root must wrap QueryOwner in QueryGuard")
}
}
func TestDefaultPlanTierRemainsLegacy(t *testing.T) {
t.Setenv("MULTI_INTENT_PLAN_TIER", "")
intentflow.ResetFeatureFlagsForTest()
if got := intentflow.CurrentFeatureFlags().PlanTier; got != intentflow.TierLegacy {
t.Fatalf("default tier = %s, want legacy", got)
}
}
+4 -3
View File
@@ -4,13 +4,14 @@ import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/events"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"github.com/jackc/pgx/v5"
)
// EventConsumer applies document.approved onto workflow task bookkeeping.
@@ -67,7 +68,7 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
if doc.ProjectID != lease.ProjectID {
return events.ErrQuarantine
}
if doc.CurrentState != docgov.StateApproved {
if doc.CurrentState != vocabulary.StateApproved {
return events.ErrQuarantine
}
if taskKey == "" {
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/events"
@@ -24,7 +25,7 @@ const (
type Principal struct {
UserID int64
Name string
Role docgov.UserRole
Role vocabulary.UserRole
}
type HumanEditCommand struct {
@@ -42,7 +43,7 @@ type DecisionCommand struct {
DocID string
Comment string
RejectReason string
RollbackTo docgov.DocumentState
RollbackTo vocabulary.DocumentState
}
type Commands struct {
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/taskrun"
@@ -70,7 +71,7 @@ func (a *docTaskBusyCheckerAdapter) DocTaskSquadBusy(ctx context.Context, projec
}
doc, err := a.gov.FindByProjectTaskKey(ctx, projectID, taskKey)
if err != nil {
if err == docgov.ErrDocumentNotFound {
if err == vocabulary.ErrDocumentNotFound {
return false, "", nil
}
return false, "", err
@@ -78,7 +79,7 @@ func (a *docTaskBusyCheckerAdapter) DocTaskSquadBusy(ctx context.Context, projec
if doc == nil || !doc.HasActiveLease(time.Now()) {
return false, "", nil
}
if doc.LeaseHolderType == docgov.LeaseHolderSquad && a.taskRuns != nil {
if doc.LeaseHolderType == vocabulary.LeaseHolderSquad && a.taskRuns != nil {
if run, runErr := a.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" {
return false, "", nil
}
+75
View File
@@ -0,0 +1,75 @@
package intentflow
import (
"context"
"chat-agent-system/internal/app/uow"
)
// DispatchEligible is the production step dispatcher. It reuses ClaimStep,
// ExecuteClaimed, and RecoverReceipt on the existing intent_plan_steps ledger.
// It must not introduce a second decision queue or step state machine.
//
// After a crash between domain receipt and plan write-back, RecoverReceipt
// replays the owner receipt without re-executing the side effect.
func (p *Plans) DispatchEligible(ctx context.Context, workerID, planID string) (*PlanView, error) {
if workerID == "" || planID == "" {
return nil, ErrPlanDependencyInvalid
}
var prin Principal
var ids []string
err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error {
plan, err := p.store.GetPlan(ctx, scope, planID)
if err != nil {
return err
}
if plan == nil {
return ErrPlanNotFoundOrHidden
}
prin = plan.Principal
for _, s := range plan.Steps {
if s.Status == StepEligible || s.Status == StepPending || s.Status == StepRunning {
ids = append(ids, s.StepID)
}
}
return nil
})
if err != nil {
return nil, err
}
var last *PlanView
var firstErr error
for _, id := range ids {
claimed, claimErr := p.ClaimStep(ctx, workerID, planID, id)
if claimErr != nil {
if firstErr == nil {
firstErr = claimErr
}
continue
}
if claimed == nil {
continue
}
got, execErr := p.ExecuteClaimed(ctx, claimed)
if execErr != nil {
recovered, recErr := p.RecoverReceipt(ctx, claimed)
if recErr == nil && recovered != nil {
last = recovered
continue
}
if firstErr == nil {
firstErr = execErr
}
continue
}
last = got
}
if last == nil {
view, getErr := p.GetPlan(ctx, prin, planID)
if firstErr != nil {
return view, firstErr
}
return view, getErr
}
return last, firstErr
}
+121
View File
@@ -0,0 +1,121 @@
package intentflow_test
import (
"context"
"errors"
"testing"
"time"
"chat-agent-system/internal/app/intentflow"
)
func TestDispatchEligible_RecoverReceiptAfterCrash(t *testing.T) {
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC)
owner := intentflow.NewFakeActionOwner()
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
Owner: owner,
Now: func() time.Time { return now },
Lease: 10 * time.Second,
})
ctx := context.Background()
created, err := p.CreatePlan(ctx, intentflow.CreateCommand{
Principal: principal(), ClientTurnID: "t-crash", RequestDigest: "d-crash",
Status: intentflow.PlanAwaitingConfirmation, ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{{ActionKey: "regulation.query", ActionDigest: "q"}},
})
if err != nil {
t.Fatal(err)
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-crash", DecisionDigest: "d-crash", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
stepID := confirmed.Steps[0].StepID
claimed, err := p.ClaimStep(ctx, "w-crash", confirmed.PlanID, stepID)
if err != nil || claimed == nil {
t.Fatalf("claim: %v %+v", err, claimed)
}
// Domain receipt persisted; plan write-back has not happened yet (crash window).
if _, err := owner.Execute(ctx, intentflow.StepExec{
Principal: claimed.Plan.Principal, PlanID: claimed.Plan.PlanID, StepID: claimed.Step.StepID,
ActionKey: claimed.Step.ActionKey, IdempotencyKey: claimed.IdempotencyKey,
ActionDigest: claimed.Step.ActionDigest, FencingToken: claimed.FencingToken,
}); err != nil {
t.Fatal(err)
}
if owner.ExecCount(claimed.IdempotencyKey) != 1 {
t.Fatalf("domain exec count = %d", owner.ExecCount(claimed.IdempotencyKey))
}
// Lease expires; re-claim then ExecuteClaimed fails re-auth → RecoverReceipt.
now = now.Add(30 * time.Second)
owner.Denied = true
view, err := p.DispatchEligible(ctx, "w-crash-2", confirmed.PlanID)
if err != nil {
t.Fatalf("recover path: %v", err)
}
if view == nil || len(view.Steps) == 0 || view.Steps[0].Status != intentflow.StepSucceeded {
t.Fatalf("recovered view: %+v", view)
}
if owner.ExecCount(claimed.IdempotencyKey) != 1 {
t.Fatalf("recover must not re-execute, count=%d", owner.ExecCount(claimed.IdempotencyKey))
}
}
func TestDispatchEligible_ReauthsOnExecute(t *testing.T) {
owner := intentflow.NewFakeActionOwner()
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
Owner: owner,
})
ctx := context.Background()
created, err := p.CreatePlan(ctx, intentflow.CreateCommand{
Principal: principal(), ClientTurnID: "t-auth", RequestDigest: "d-auth",
Status: intentflow.PlanAwaitingConfirmation, ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{{ActionKey: "regulation.query", ActionDigest: "q"}},
})
if err != nil {
t.Fatal(err)
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-auth", DecisionDigest: "d-auth", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
owner.Denied = true
_, err = p.DispatchEligible(ctx, "w-auth", confirmed.PlanID)
if !errors.Is(err, intentflow.ErrPlanActionForbidden) {
t.Fatalf("revoked dispatch must fail closed, got %v", err)
}
}
func TestDispatchEligible_VersionConflict(t *testing.T) {
owner := intentflow.NewFakeActionOwner()
owner.RequiredVersion = map[string]string{"regulation.query": "v2"}
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
Owner: owner,
})
ctx := context.Background()
created, err := p.CreatePlan(ctx, intentflow.CreateCommand{
Principal: principal(), ClientTurnID: "t-ver", RequestDigest: "d-ver",
Status: intentflow.PlanAwaitingConfirmation, ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{{
ActionKey: "regulation.query", ActionDigest: "q",
ExpectedResourceVersion: "v1",
}},
})
if err != nil {
t.Fatal(err)
}
// Confirm itself re-auths; version conflict must block before execute.
_, err = p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-ver", DecisionDigest: "d-ver", Kind: intentflow.DecisionConfirm,
})
if !errors.Is(err, intentflow.ErrPlanActionVersionConflict) {
t.Fatalf("want version conflict on confirm, got %v", err)
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ type FenceObserver interface {
}
// FakeActionOwner is the U3 test double. It records executions and receipts
// without touching business tables.
// without touching business tables. Production composition must use a real
// query owner (see app.QueryOwner); never wire this into Assemble.
type FakeActionOwner struct {
mu sync.Mutex
execCount map[string]int
+2
View File
@@ -617,6 +617,8 @@ func (p *Plans) ExecuteClaimed(ctx context.Context, claimed *ClaimedStep) (*Plan
ActionDigest: claimed.Step.ActionDigest,
ExpectedResourceVersion: claimed.Step.ExpectedResourceVersion,
FencingToken: claimed.FencingToken,
Slots: claimed.Step.Slots,
SafeSummary: claimed.Step.SafeSummary,
}
if auth := p.stepAuthorizer(); auth != nil {
if err := auth.Authorize(ctx, exec); err != nil {
+3
View File
@@ -11,4 +11,7 @@ type Service interface {
GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error)
// GetActivePlan returns (nil, nil) when the principal has no visible active plan.
GetActivePlan(ctx context.Context, principal Principal) (*PlanView, error)
// DispatchEligible claims and executes eligible steps on the existing
// intent_plan_steps ledger (ClaimStep / ExecuteClaimed / RecoverReceipt).
DispatchEligible(ctx context.Context, workerID, planID string) (*PlanView, error)
}
+4 -52
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"strings"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/intent"
)
@@ -95,7 +94,7 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
if err != nil {
return &TurnResult{Plan: created}, err
}
view, advErr := p.AdvanceEligible(ctx, "auto-read", confirmed.PlanID)
view, advErr := p.DispatchEligible(ctx, "auto-read", confirmed.PlanID)
if view == nil {
view = confirmed
}
@@ -107,55 +106,8 @@ func shortDigest(text string) string {
return hex.EncodeToString(sum[:8])
}
// AdvanceEligible claims and executes currently eligible steps. Independent
// query failures do not block later independent steps.
// AdvanceEligible is retained for callers/tests; it delegates to the production
// dispatcher so recovery and re-auth stay on one path.
func (p *Plans) AdvanceEligible(ctx context.Context, workerID, planID string) (*PlanView, error) {
var prin Principal
var ids []string
err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error {
plan, err := p.store.GetPlan(ctx, scope, planID)
if err != nil {
return err
}
prin = plan.Principal
for _, s := range plan.Steps {
if s.Status == StepEligible || s.Status == StepPending {
ids = append(ids, s.StepID)
}
}
return nil
})
if err != nil {
return nil, err
}
var last *PlanView
var firstErr error
for _, id := range ids {
claimed, claimErr := p.ClaimStep(ctx, workerID, planID, id)
if claimErr != nil {
if firstErr == nil {
firstErr = claimErr
}
continue
}
if claimed == nil {
continue
}
got, execErr := p.ExecuteClaimed(ctx, claimed)
if execErr != nil {
if firstErr == nil {
firstErr = execErr
}
continue
}
last = got
}
if last == nil {
view, getErr := p.GetPlan(ctx, prin, planID)
if firstErr != nil {
return view, firstErr
}
return view, getErr
}
return last, firstErr
return p.DispatchEligible(ctx, workerID, planID)
}
+2
View File
@@ -238,4 +238,6 @@ type StepExec struct {
ActionDigest string
ExpectedResourceVersion string
FencingToken int64
Slots []byte
SafeSummary string
}
+184
View File
@@ -0,0 +1,184 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/kb"
)
// ProjectAccess checks membership for the plan principal before query execute.
type ProjectAccess func(ctx context.Context, projectID, userID int64) error
// QueryOwner is the production auto_read query ActionOwner. It re-auths the
// principal, optionally checks expected resource versions, runs read-only KB
// search when online, and stamps receipts with an auth-scope digest. It never
// performs business writes.
type QueryOwner struct {
Access ProjectAccess
KB *kb.Client
CurrentVersion func(ctx context.Context, exec intentflow.StepExec) (string, error)
Denied bool
RequiredVersion map[string]string
mu sync.Mutex
receipts map[string]intentflow.Receipt
execs map[string]int
}
func NewQueryOwner(access ProjectAccess, client *kb.Client) *QueryOwner {
return &QueryOwner{
Access: access,
KB: client,
receipts: map[string]intentflow.Receipt{},
execs: map[string]int{},
}
}
func (q *QueryOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
if q == nil || q.Denied {
return intentflow.ErrPlanActionForbidden
}
if !isAutoReadQueryKey(exec.ActionKey) {
return intentflow.ErrPlanActionForbidden
}
if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" {
return intentflow.ErrPlanActionForbidden
}
if q.Access != nil {
if err := q.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil {
return intentflow.ErrPlanActionForbidden
}
}
if want, ok := q.RequiredVersion[exec.ActionKey]; ok {
if exec.ExpectedResourceVersion == "" || exec.ExpectedResourceVersion != want {
return intentflow.ErrPlanActionVersionConflict
}
}
if exec.ExpectedResourceVersion != "" && q.CurrentVersion != nil {
got, err := q.CurrentVersion(ctx, exec)
if err != nil {
return err
}
if got != exec.ExpectedResourceVersion {
return intentflow.ErrPlanActionVersionConflict
}
}
return nil
}
func (q *QueryOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
if err := q.Authorize(ctx, exec); err != nil {
return intentflow.Receipt{}, err
}
q.mu.Lock()
if rec, ok := q.receipts[exec.IdempotencyKey]; ok {
q.mu.Unlock()
if rec.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return rec, nil
}
q.mu.Unlock()
scope := AuthScopeDigest(exec)
resultVer := exec.ExpectedResourceVersion
if resultVer == "" {
resultVer = "none"
}
hits := 0
queryText := queryTextFromExec(exec)
if q.KB != nil && q.KB.Online() && strings.TrimSpace(queryText) != "" {
domains := domainsForAction(exec.ActionKey)
kbHits, err := q.KB.Search(ctx, queryText, kb.SearchOptions{Domains: domains, Limit: 5})
if err == nil {
hits = len(kbHits)
}
}
rec := intentflow.Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: fmt.Sprintf("query:%s:%s", exec.ActionKey, exec.IdempotencyKey),
ResultDigest: fmt.Sprintf(
"query:%s:scope=%s:ver=%s:recover=%s:hits=%d",
exec.ActionKey, scope, resultVer, shortKey(exec.IdempotencyKey), hits,
),
SafeSummary: fmt.Sprintf("%s authorized (scope=%s)", exec.ActionKey, shortKey(scope)),
}
q.mu.Lock()
defer q.mu.Unlock()
if existing, ok := q.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return existing, nil
}
q.execs[exec.IdempotencyKey]++
q.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (q *QueryOwner) LookupReceipt(_ context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
if q == nil {
return intentflow.Receipt{}, false, nil
}
q.mu.Lock()
defer q.mu.Unlock()
rec, ok := q.receipts[idempotencyKey]
return rec, ok, nil
}
func (q *QueryOwner) ExecCount(idempotencyKey string) int {
q.mu.Lock()
defer q.mu.Unlock()
return q.execs[idempotencyKey]
}
// AuthScopeDigest binds tenant/project/session/environment/action for receipts.
func AuthScopeDigest(exec intentflow.StepExec) string {
h := sha256.New()
_, _ = fmt.Fprintf(h, "u=%d|p=%d|s=%s|e=%s|a=%s",
exec.Principal.UserID, exec.Principal.ProjectID,
exec.Principal.SessionID, exec.Principal.Environment, exec.ActionKey)
return hex.EncodeToString(h.Sum(nil)[:16])
}
func isAutoReadQueryKey(key string) bool {
row, err := intentflow.DefaultBatchActionPolicy().Lookup(key)
if err != nil || row.RejectPlan || row.RequiresBatchConfirm {
return false
}
return intentflow.TierAutoRead.Allows(row.OpenFromTier)
}
func queryTextFromExec(exec intentflow.StepExec) string {
if s := strings.TrimSpace(exec.SafeSummary); s != "" {
return s
}
if len(exec.Slots) > 0 {
return strings.TrimSpace(string(exec.Slots))
}
return exec.ActionKey
}
func domainsForAction(actionKey string) []kb.KnowledgeDomain {
switch actionKey {
case "case.query":
return []kb.KnowledgeDomain{kb.DomainProjectAsset}
default:
return []kb.KnowledgeDomain{kb.DomainRegulatory}
}
}
func shortKey(s string) string {
if len(s) <= 12 {
return s
}
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:6])
}
+135
View File
@@ -0,0 +1,135 @@
package app
import (
"context"
"errors"
"strings"
"testing"
"chat-agent-system/internal/app/intentflow"
)
func TestQueryOwnerRejectsGenerate(t *testing.T) {
q := NewQueryOwner(nil, nil)
_, err := q.Execute(context.Background(), intentflow.StepExec{
ActionKey: "task.run",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s", Environment: "dev"},
})
if !errors.Is(err, intentflow.ErrPlanActionForbidden) {
t.Fatalf("got %v", err)
}
}
func TestQueryOwnerReceiptIncludesAuthScopeDigest(t *testing.T) {
accessOK := func(context.Context, int64, int64) error { return nil }
q := NewQueryOwner(accessOK, nil)
exec := intentflow.StepExec{
ActionKey: "regulation.query", IdempotencyKey: "idem-q1", ActionDigest: "d1",
SafeSummary: "查 GB 9706",
Principal: intentflow.Principal{UserID: 7, ProjectID: 42, SessionID: "sess", Environment: "dev"},
}
rec, err := q.Execute(context.Background(), exec)
if err != nil {
t.Fatal(err)
}
scope := AuthScopeDigest(exec)
if !strings.Contains(rec.ResultDigest, "scope="+scope) {
t.Fatalf("receipt missing auth scope digest: %s", rec.ResultDigest)
}
if !strings.HasPrefix(rec.ResultRef, "query:regulation.query:") {
t.Fatalf("result ref must not be fake: %s", rec.ResultRef)
}
if !strings.Contains(rec.ResultDigest, "recover=") {
t.Fatalf("receipt missing recover marker: %s", rec.ResultDigest)
}
if q.ExecCount(exec.IdempotencyKey) != 1 {
t.Fatalf("exec count = %d", q.ExecCount(exec.IdempotencyKey))
}
again, err := q.Execute(context.Background(), exec)
if err != nil {
t.Fatal(err)
}
if again.ResultDigest != rec.ResultDigest {
t.Fatal("idempotent replay must return same receipt")
}
if q.ExecCount(exec.IdempotencyKey) != 1 {
t.Fatalf("replay must not re-exec, count=%d", q.ExecCount(exec.IdempotencyKey))
}
}
func TestQueryOwnerReauthAndVersionCheck(t *testing.T) {
denied := NewQueryOwner(func(context.Context, int64, int64) error {
return errors.New("revoked")
}, nil)
_, err := denied.Execute(context.Background(), intentflow.StepExec{
ActionKey: "regulation.query", IdempotencyKey: "k", ActionDigest: "d",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s", Environment: "dev"},
})
if !errors.Is(err, intentflow.ErrPlanActionForbidden) {
t.Fatalf("revoked access must fail closed, got %v", err)
}
q := NewQueryOwner(func(context.Context, int64, int64) error { return nil }, nil)
q.RequiredVersion = map[string]string{"regulation.query": "v2"}
_, err = q.Execute(context.Background(), intentflow.StepExec{
ActionKey: "regulation.query", IdempotencyKey: "k2", ActionDigest: "d",
ExpectedResourceVersion: "v1",
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s", Environment: "dev"},
})
if !errors.Is(err, intentflow.ErrPlanActionVersionConflict) {
t.Fatalf("want version conflict, got %v", err)
}
}
func TestConfirmZeroWritesBeforeDispatch(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
query := NewQueryOwner(func(context.Context, int64, int64) error { return nil }, nil)
task := NewTaskRunAdapter()
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
Owner: intentflow.OwnerMux{Query: intentflow.QueryGuard{Inner: query}, Task: task},
})
ctx := context.Background()
prin := intentflow.Principal{UserID: 7, Environment: "dev", ProjectID: 42, SessionID: "sess-i1"}
created, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: prin, Text: "查一下相关法规,然后帮我生成报价单",
ClientTurnID: "turn-i1", RequestDigest: "d-i1",
})
if err != nil || created == nil || created.Plan == nil {
t.Fatalf("create: %+v %v", created, err)
}
if created.Plan.Status != intentflow.PlanAwaitingConfirmation {
t.Fatalf("status %s", created.Plan.Status)
}
if task.StartCount() != 0 {
t.Fatal("generate must not start before confirm")
}
// QueryOwner has no receipts until DispatchEligible runs.
probe := intentflow.StepExec{
ActionKey: "regulation.query", IdempotencyKey: "probe-before", ActionDigest: "x",
Principal: prin,
}
if _, ok, _ := query.LookupReceipt(ctx, probe.IdempotencyKey); ok {
t.Fatal("query must not have receipts before confirm")
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: prin, PlanID: created.Plan.PlanID, ExpectedRevision: created.Plan.Revision,
DecisionNonce: "i1-n", DecisionDigest: "i1-d", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
if task.StartCount() != 0 {
t.Fatal("DecidePlan alone must not start generate")
}
view, err := p.DispatchEligible(ctx, "i1-worker", confirmed.PlanID)
if err != nil {
t.Fatal(err)
}
if view == nil {
t.Fatal("expected dispatched plan")
}
if task.StartCount() != 1 {
t.Fatalf("generate starts = %d", task.StartCount())
}
}
+41
View File
@@ -0,0 +1,41 @@
package app
import (
"context"
"log"
"chat-agent-system/internal/artifactvault"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/taskrun"
)
// TaskStoreReady reports whether task_runs tables are available.
// HTTP adapters must use this instead of holding *pgxpool.Pool.
func (r *Runtime) TaskStoreReady(ctx context.Context) bool {
if r == nil || r.Pool == nil {
return false
}
return taskrun.TablesReady(ctx, r.Pool)
}
// HealProjectDuties syncs controlled-document duties after project membership changes.
func (r *Runtime) HealProjectDuties(ctx context.Context, projectID int64) error {
if r == nil || r.Pool == nil || projectID <= 0 {
return nil
}
_, _, err := docgov.HealProjectDocuments(ctx, r.Pool, projectID)
if err != nil {
log.Printf("[docgov] heal project %d after member change failed: %v", projectID, err)
}
return err
}
// ArtifactVaultRegistry returns a PG-backed registry when the pool is available,
// otherwise an in-memory stub suitable for FAV publisher off / tests.
// HTTP adapters must not hold *pgxpool.Pool to construct the registry.
func (r *Runtime) ArtifactVaultRegistry() artifactvault.Registry {
if r == nil || r.Pool == nil {
return artifactvault.NewMemoryRegistry()
}
return artifactvault.NewPGRegistry(r.Pool)
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"chat-agent-system/internal/app/docnotify"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
@@ -49,10 +50,10 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do
for _, key := range docTaskKeys {
docID := fmt.Sprintf("doc_proj%d_%s", projectID, key)
_, err := t.gov.ReopenForRevision(ctx, docID, actorID, "creator", reason)
if err == nil || errors.Is(err, docgov.ErrDocumentNotFound) {
if err == nil || errors.Is(err, vocabulary.ErrDocumentNotFound) {
continue
}
if errors.Is(err, docgov.ErrInvalidStateTransition) {
if errors.Is(err, vocabulary.ErrInvalidStateTransition) {
continue
}
return err
+42
View File
@@ -0,0 +1,42 @@
package app
import (
"context"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
"chat-agent-system/internal/workspace"
)
func wireDutySlotWriter(a *authz.Authorizer) {
if a == nil {
return
}
a.SetDutySlotWriter(docgov.DutySlotWriter{})
}
func wireDocRevisionPorts(rev *docrevision.Service) {
if rev == nil {
return
}
rev.SetRootPointerProjector(docrevision.CompositeRootPointerProjector{
Controlled: func(ctx context.Context, scope uow.TxScope, root *docrevision.DocRootPointers) error {
if root == nil {
return nil
}
return (docgov.Repository{}).ProjectHeadFull(ctx, scope, root.DocID,
root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo, root.RevisionSchemaVer, root.ContentSHA256)
},
Freeform: func(ctx context.Context, scope uow.TxScope, root *docrevision.DocRootPointers) error {
if root == nil {
return nil
}
return (freedoc.Repository{}).ProjectHead(ctx, scope, root.DocID,
root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo, root.RevisionSchemaVer)
},
})
rev.SetDeliverableStore(workspace.DirDeliverable{})
}
+30 -5
View File
@@ -52,15 +52,40 @@ type MemberUpsert struct {
DocumentDuty *string
}
// DutySlotWriter clears document_records reviewer/approver slots (implemented by docgov).
// authz must not import docgov; the composition root injects the adapter.
type DutySlotWriter interface {
ClearSlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error
}
// Authorizer 项目级对象授权。
type Authorizer struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
dutySlots DutySlotWriter
}
func NewAuthorizer(pool *pgxpool.Pool) *Authorizer {
return &Authorizer{pool: pool}
}
// SetDutySlotWriter injects the document_records slot clearer (docgov adapter).
func (a *Authorizer) SetDutySlotWriter(w DutySlotWriter) {
if a == nil {
return
}
a.dutySlots = w
}
func (a *Authorizer) clearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
if a == nil || len(revs) == 0 {
return nil
}
if a.dutySlots == nil {
return fmt.Errorf("authz: duty slot writer required")
}
return a.dutySlots.ClearSlots(ctx, tx, projectID, revs)
}
func (a *Authorizer) enabled() bool {
return a != nil && a.pool != nil
}
@@ -327,7 +352,7 @@ func (a *Authorizer) upsertProjectMemberSpecOnce(ctx context.Context, projectID,
defer func() { _ = tx.Rollback(ctx) }()
if spec.DocumentDuty != nil {
if err := applyDutyWriteInTx(ctx, tx, projectID, userID, spec.ProjectRole, duty); err != nil {
if err := a.applyDutyWriteInTx(ctx, tx, projectID, userID, spec.ProjectRole, duty); err != nil {
return err
}
return tx.Commit(ctx)
@@ -396,7 +421,7 @@ func (a *Authorizer) setProjectMemberStatusOnce(ctx context.Context, projectID,
return fmt.Errorf("authz: project member not found")
}
if status == StatusExited {
if err := docduty.ApplySlotRevocations(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
if err := a.clearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
UserID: userID, PreviousDuty: prevDuty, NextDuty: prevDuty, Exited: true,
})); err != nil {
return err
@@ -406,7 +431,7 @@ func (a *Authorizer) setProjectMemberStatusOnce(ctx context.Context, projectID,
}
// applyDutyWriteInTx 假定调用方已 Begin:lock → 读旧/冲突 holder → 独占写成员 → 清槽。
func applyDutyWriteInTx(ctx context.Context, tx pgx.Tx, projectID, userID int64, projectRole, duty string) error {
func (a *Authorizer) applyDutyWriteInTx(ctx context.Context, tx pgx.Tx, projectID, userID int64, projectRole, duty string) error {
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
return err
}
@@ -441,7 +466,7 @@ func applyDutyWriteInTx(ctx context.Context, tx pgx.Tx, projectID, userID int64,
return err
}
return docduty.ApplySlotRevocations(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
return a.clearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
UserID: userID,
PreviousDuty: prevDuty,
NextDuty: duty,
+5 -2
View File
@@ -11,6 +11,7 @@ import (
"chat-agent-system/internal/db"
"chat-agent-system/internal/docduty"
"chat-agent-system/internal/docgov"
)
func TestUpsertProjectMemberDuty_ConcurrentLastWriter_PG(t *testing.T) {
@@ -52,6 +53,7 @@ func TestUpsertProjectMemberDuty_ConcurrentLastWriter_PG(t *testing.T) {
})
a := NewAuthorizer(pool)
a.SetDutySlotWriter(docgov.DutySlotWriter{})
dutyAuthor := docduty.DutyAuthor
if err := a.UpsertProjectMemberSpec(ctx, projectID, ownerID, MemberUpsert{
ProjectRole: RoleOwner, DocumentDuty: &dutyAuthor,
@@ -155,6 +157,7 @@ func TestUpsertProjectMember_AtomicDutyRevoke_PG(t *testing.T) {
})
a := NewAuthorizer(pool)
a.SetDutySlotWriter(docgov.DutySlotWriter{})
dutyAuthor := docduty.DutyAuthor
dutyReviewer := docduty.DutyReviewer
dutyNone := docduty.DutyNone
@@ -205,7 +208,7 @@ func TestUpsertProjectMember_AtomicDutyRevoke_PG(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
if _, _, err := docduty.HealProjectDocuments(ctx, pool, projectID); err != nil {
if _, _, err := docgov.HealProjectDocuments(ctx, pool, projectID); err != nil {
t.Fatal(err)
}
assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", reviewerA)
@@ -220,7 +223,7 @@ func TestUpsertProjectMember_AtomicDutyRevoke_PG(t *testing.T) {
assertAuthzMemberDuty(t, ctx, pool, projectID, reviewerA, docduty.DutyNone)
assertAuthzMemberDuty(t, ctx, pool, projectID, reviewerB, docduty.DutyReviewer)
if _, _, err := docduty.HealProjectDocuments(ctx, pool, projectID); err != nil {
if _, _, err := docgov.HealProjectDocuments(ctx, pool, projectID); err != nil {
t.Fatal(err)
}
assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", reviewerB)
+131
View File
@@ -0,0 +1,131 @@
package bizdev
import (
"context"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/workflowdef"
)
// flatCatalogAdapter maps workflowdef.FlatCatalog onto projectworkflow.Catalog at the composition root.
type flatCatalogAdapter struct {
inner workflowdef.FlatCatalog
}
func adaptFlatCatalog(c workflowdef.FlatCatalog) projectworkflow.Catalog {
if c == nil {
return nil
}
return flatCatalogAdapter{inner: c}
}
func (a flatCatalogAdapter) Get(workflowKey string) (*projectworkflow.Definition, bool) {
d, ok := a.inner.Get(workflowKey)
if !ok || d == nil {
return nil, false
}
return &projectworkflow.Definition{
Key: d.Key,
Name: d.Name,
Description: d.Description,
StageKey: d.StageKey,
TaskKey: d.TaskKey,
RequiredUpstreamWorkflow: d.RequiredUpstreamWorkflow,
OutputDocType: d.OutputDocType,
OutputDocTitle: d.OutputDocTitle,
Enabled: d.Enabled,
}, true
}
func (a flatCatalogAdapter) List() []*projectworkflow.Definition {
items := a.inner.List()
out := make([]*projectworkflow.Definition, 0, len(items))
for _, d := range items {
if d == nil {
continue
}
out = append(out, &projectworkflow.Definition{
Key: d.Key,
Name: d.Name,
Description: d.Description,
StageKey: d.StageKey,
TaskKey: d.TaskKey,
RequiredUpstreamWorkflow: d.RequiredUpstreamWorkflow,
OutputDocType: d.OutputDocType,
OutputDocTitle: d.OutputDocTitle,
Enabled: d.Enabled,
})
}
return out
}
func (a flatCatalogAdapter) Validate() error {
err := a.inner.Validate()
if err == nil {
return nil
}
if err == workflowdef.ErrCatalogUpstreamMissing {
return projectworkflow.ErrWorkflowNotFound
}
return err
}
func (a flatCatalogAdapter) Refresh(ctx context.Context) error {
if r, ok := a.inner.(workflowdef.CatalogRefresher); ok {
return r.Refresh(ctx)
}
return nil
}
// defaultFlatCatalog wraps projectworkflow.NewDefaultCatalog as a workflowdef.FlatCatalog fallback.
type defaultFlatCatalog struct {
inner projectworkflow.Catalog
}
func newDefaultFlatCatalog() workflowdef.FlatCatalog {
return defaultFlatCatalog{inner: projectworkflow.NewDefaultCatalog()}
}
func (d defaultFlatCatalog) Get(workflowKey string) (*workflowdef.FlatDefinition, bool) {
def, ok := d.inner.Get(workflowKey)
if !ok || def == nil {
return nil, false
}
return &workflowdef.FlatDefinition{
Key: def.Key,
Name: def.Name,
Description: def.Description,
StageKey: def.StageKey,
TaskKey: def.TaskKey,
RequiredUpstreamWorkflow: def.RequiredUpstreamWorkflow,
OutputDocType: def.OutputDocType,
OutputDocTitle: def.OutputDocTitle,
Enabled: def.Enabled,
}, true
}
func (d defaultFlatCatalog) List() []*workflowdef.FlatDefinition {
items := d.inner.List()
out := make([]*workflowdef.FlatDefinition, 0, len(items))
for _, def := range items {
if def == nil {
continue
}
out = append(out, &workflowdef.FlatDefinition{
Key: def.Key,
Name: def.Name,
Description: def.Description,
StageKey: def.StageKey,
TaskKey: def.TaskKey,
RequiredUpstreamWorkflow: def.RequiredUpstreamWorkflow,
OutputDocType: def.OutputDocType,
OutputDocTitle: def.OutputDocTitle,
Enabled: def.Enabled,
})
}
return out
}
func (d defaultFlatCatalog) Validate() error {
return d.inner.Validate()
}
+37 -25
View File
@@ -14,8 +14,10 @@ import (
"strings"
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/config"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/db"
"chat-agent-system/internal/docduty"
@@ -30,6 +32,7 @@ import (
"chat-agent-system/internal/storage"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workspace"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -77,9 +80,7 @@ func (s *Service) SetInstanceDutySource(src InstanceDutySource) {
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
func NewService(cfg *config.Config) (*Service, error) {
if cfg == nil {
cfg = &config.Config{
DataDir: "./data/dev",
}
return nil, errors.New("bizdev: config is required (data roots come from config, not package defaults)")
}
var pool *pgxpool.Pool
var schemaVersion int64
@@ -107,6 +108,7 @@ func NewService(cfg *config.Config) (*Service, error) {
}
} else {
authzStore := authz.NewAuthorizer(pool)
authzStore.SetDutySlotWriter(docgov.DutySlotWriter{})
_ = authzStore.BackfillCreatorMembers(poolCtx)
if ver, verErr := db.CurrentVersion(poolCtx, pool); verErr == nil {
schemaVersion = ver
@@ -127,18 +129,28 @@ func NewService(cfg *config.Config) (*Service, error) {
docGov := docgov.NewGovernanceEngine(pool)
docRev := docrevision.NewService(pool)
docRev.DataDir = cfg.DataDir
docRev.SetRootPointerProjector(docrevision.CompositeRootPointerProjector{
Controlled: func(ctx context.Context, scope uow.TxScope, root *docrevision.DocRootPointers) error {
return (docgov.Repository{}).ProjectHeadFull(ctx, scope, root.DocID,
root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo, root.RevisionSchemaVer, root.ContentSHA256)
},
Freeform: func(ctx context.Context, scope uow.TxScope, root *docrevision.DocRootPointers) error {
return (freedoc.Repository{}).ProjectHead(ctx, scope, root.DocID,
root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo, root.RevisionSchemaVer)
},
})
docRev.SetDeliverableStore(workspace.DirDeliverable{})
freeDoc := freedoc.NewService(pool, ctxMgr)
freeDoc.SetDocRevision(docRev)
pwStore := projectworkflow.NewStore(pool)
fallbackCatalog := projectworkflow.NewDefaultCatalog()
wfStore := workflowdef.NewStore(pool)
if pool != nil && workflowdef.TablesReady(context.Background(), pool) {
if seedErr := workflowdef.BootstrapSeeds(context.Background(), pool, casStore, "templates/docs", 1); seedErr != nil {
slog.Warn("workflow seed bootstrap failed", "error", seedErr)
}
}
catalog := workflowdef.NewCatalog(context.Background(), wfStore, fallbackCatalog)
pwSvc := projectworkflow.NewService(pwStore, catalog)
flatCat := workflowdef.NewCatalog(context.Background(), wfStore, newDefaultFlatCatalog())
pwSvc := projectworkflow.NewService(pwStore, adaptFlatCatalog(flatCat))
docRev.ImpactResolver = func(ctx context.Context, docID string) []docrevision.DocImpact {
if pwSvc == nil {
return nil
@@ -202,9 +214,9 @@ func NewService(cfg *config.Config) (*Service, error) {
if doc.WorkDir != "" {
candidates := []string{
doc.WorkDir,
filepath.Join(doc.WorkDir, "output.md"),
filepath.Join(cfg.DataDir, doc.WorkDir, "output.md"),
filepath.Join(cfg.DataDir, "workspaces", doc.WorkDir, "output.md"),
filepath.Join(doc.WorkDir, workspace.DeliverableKey),
filepath.Join(cfg.DataDir, doc.WorkDir, workspace.DeliverableKey),
filepath.Join(cfg.DataDir, workspace.WorkspacesDirName, doc.WorkDir, workspace.DeliverableKey),
}
for _, p := range candidates {
if b, err := os.ReadFile(p); err == nil && len(b) > 0 {
@@ -218,11 +230,11 @@ func NewService(cfg *config.Config) (*Service, error) {
_, _ = ctxMgr.IndexApprovedDocument(ctx, doc, string(b))
}
} else {
// 若未在默认工作区直接发现 output.md,尝试深度探测当前数据目录下的工作区文件
pattern := filepath.Join(cfg.DataDir, "workspaces", "**", doc.TaskKey, "output.md")
// 若未在默认工作区直接发现 deliverable,尝试深度探测当前数据目录下的工作区文件
pattern := filepath.Join(cfg.DataDir, workspace.WorkspacesDirName, "**", doc.TaskKey, workspace.DeliverableKey)
matches, _ := filepath.Glob(pattern)
if len(matches) == 0 {
pattern2 := filepath.Join(cfg.DataDir, "**", doc.TaskKey, "output.md")
pattern2 := filepath.Join(cfg.DataDir, "**", doc.TaskKey, workspace.DeliverableKey)
matches, _ = filepath.Glob(pattern2)
}
if len(matches) > 0 {
@@ -528,10 +540,10 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
}
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
if s.docGov != nil && req.ProjectID > 0 {
if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == docgov.StateApproved {
if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == vocabulary.StateApproved {
upstreamSRDoc = doc
if doc.WorkDir != "" {
contentPath := filepath.Join(doc.WorkDir, "output.md")
contentPath := filepath.Join(doc.WorkDir, workspace.DeliverableKey)
if b, readErr := os.ReadFile(contentPath); readErr == nil && len(b) > 0 {
priorDocs = append(priorDocs, squad.PriorDocItem{
DocKey: "system_reqs", Title: "系统需求规格书", Content: string(b), Stage: "approved",
@@ -581,10 +593,10 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
continue
}
doc, err := s.docGov.GetDocument(ctx, docID)
if err != nil || doc == nil || doc.CurrentState != docgov.StateApproved || doc.WorkDir == "" {
if err != nil || doc == nil || doc.CurrentState != vocabulary.StateApproved || doc.WorkDir == "" {
continue
}
if b, readErr := os.ReadFile(filepath.Join(doc.WorkDir, "output.md")); readErr == nil && len(b) > 0 {
if b, readErr := os.ReadFile(filepath.Join(doc.WorkDir, workspace.DeliverableKey)); readErr == nil && len(b) > 0 {
priorDocs = append(priorDocs, squad.PriorDocItem{
DocKey: key, Title: doc.Title, Content: string(b), Stage: "approved",
})
@@ -667,7 +679,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
docRec.DutyPolicy = dutyPolicy
}
if s.pool != nil && req.ProjectID > 0 {
assigned, _, healErr := docduty.HealProjectDocuments(ctx, s.pool, req.ProjectID)
assigned, _, healErr := docgov.HealProjectDocuments(ctx, s.pool, req.ProjectID)
if healErr != nil {
log.Printf("[docduty] heal project %d after generate failed: %v", req.ProjectID, healErr)
return &TaskRunResult{
@@ -707,7 +719,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
Success: false,
ErrorReason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止 force_direct_edit", docRec.CurrentState),
DocRecord: docRec,
}, docgov.ErrDocumentLocked
}, vocabulary.ErrDocumentLocked
}
// 已认领文档必须有编制人已同意的提案;未认领可直接强制覆写
if docRec != nil && docRec.AuthorClaimed {
@@ -720,14 +732,14 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
Success: false,
ErrorReason: "force_direct_edit 需要编制人已同意的变更提案 (approved_by_author)",
DocRecord: docRec,
}, docgov.ErrForceEditNotAllowed
}, vocabulary.ErrForceEditNotAllowed
}
appliedProposalID = prop.ProposalID
}
} else if docRec != nil {
checkRes, checkErr := s.docGov.CheckModificationPermission(ctx, docID, docgov.RoleSystem)
checkRes, checkErr := s.docGov.CheckModificationPermission(ctx, docID, vocabulary.RoleSystem)
if checkErr != nil {
if errors.Is(checkErr, docgov.ErrDocumentLocked) || errors.Is(checkErr, docgov.ErrDocumentBusy) {
if errors.Is(checkErr, vocabulary.ErrDocumentLocked) || errors.Is(checkErr, vocabulary.ErrDocumentBusy) {
reason := checkErr.Error()
if checkRes != nil && checkRes.Reason != "" {
reason = checkRes.Reason
@@ -797,11 +809,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
leaseTTL = s.cfg.AgentTimeout
}
if leaseTTL <= 0 {
leaseTTL = docgov.DefaultSquadLeaseTTL
leaseTTL = vocabulary.DefaultSquadLeaseTTL
}
leased, leaseErr := s.docGov.AcquireLease(ctx, docgov.LeaseAcquireRequest{
DocID: docID,
HolderType: docgov.LeaseHolderSquad,
HolderType: vocabulary.LeaseHolderSquad,
HolderID: leaseHolderID,
WorkDir: req.WorkDir,
TTL: leaseTTL,
@@ -847,7 +859,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
return
case <-ticker.C:
hbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID, leaseTTL)
_, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID, leaseTTL)
cancel()
}
}
@@ -855,7 +867,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
defer func() {
if leaseAcquired {
relCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_, _ = s.docGov.ReleaseLease(relCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID)
_, _ = s.docGov.ReleaseLease(relCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID)
cancel()
if streamer := stream.FromContext(ctx); streamer != nil {
streamer.EmitGovernance(stream.GovernancePayload{
+11 -11
View File
@@ -7,18 +7,18 @@ import (
"strings"
"time"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/taskrun"
)
// ActionServices 对话层执行 PendingAction 所需的后端服务。
// 具体 chatflow / intentflow / taskrun 实现由组合根经窄端口注入。
type ActionServices struct {
DocGov *docgov.GovernanceEngine
TaskRuns *taskrun.Manager
IntentPlans intentflow.Service
TaskRuns TaskStarter
IntentPlans IntentPlans
Environment string
}
@@ -59,7 +59,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
if s.Actions.DocGov == nil {
return nil, fmt.Errorf("docgov engine not available")
}
doc, err := s.Actions.DocGov.ReviewApprove(ctx, action.DocID, uid, docgov.RoleReviewer, name, action.Comment)
doc, err := s.Actions.DocGov.ReviewApprove(ctx, action.DocID, uid, vocabulary.RoleReviewer, name, action.Comment)
if err != nil {
return nil, err
}
@@ -69,7 +69,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
if s.Actions.DocGov == nil {
return nil, fmt.Errorf("docgov engine not available")
}
doc, err := s.Actions.DocGov.ApproverApprove(ctx, action.DocID, uid, docgov.RoleApprover, name, action.Comment)
doc, err := s.Actions.DocGov.ApproverApprove(ctx, action.DocID, uid, vocabulary.RoleApprover, name, action.Comment)
if err != nil {
return nil, err
}
@@ -83,13 +83,13 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
if reason == "" {
reason = "用户确认驳回"
}
doc, err := s.Actions.DocGov.ReviewReject(ctx, action.DocID, uid, docgov.RoleReviewer, name, reason)
doc, err := s.Actions.DocGov.ReviewReject(ctx, action.DocID, uid, vocabulary.RoleReviewer, name, reason)
if err != nil {
rollback := action.Rollback
if rollback == "" {
rollback = docgov.StateDrafting
rollback = vocabulary.StateDrafting
}
doc, err = s.Actions.DocGov.ApproverReject(ctx, action.DocID, uid, docgov.RoleApprover, name, reason, rollback)
doc, err = s.Actions.DocGov.ApproverReject(ctx, action.DocID, uid, vocabulary.RoleApprover, name, reason, rollback)
if err != nil {
return nil, err
}
@@ -109,7 +109,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
if action.Kind == "taskrun.resume" {
resumeDir = action.WorkDir
}
taskReq := stageflow.TaskRunRequest{
taskReq := TaskStartRequest{
TaskKey: taskKey,
ProjectName: userCtx.Project.Name,
UserID: uid,
@@ -124,7 +124,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
TaskKey: taskKey,
Status: "running",
}
created, err := s.Actions.TaskRuns.CreateAndStart(ctx, runID, record, taskReq, stageflow.StageRunRequest{}, resumeDir)
created, err := s.Actions.TaskRuns.CreateAndStart(ctx, runID, record, taskReq, resumeDir)
if err != nil {
return nil, err
}
+82
View File
@@ -0,0 +1,82 @@
package chat
import (
"context"
"time"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/taskrun"
)
// ChatMemory is the chat-owned port for authorized memory recall.
// Implementations are wired at the app composition root; chat must not import app/*.
type ChatMemory interface {
SearchMemory(ctx context.Context, userID int64, q MemoryQuery) ([]ctxmgr.RecalledMemoryItem, error)
}
// MemoryQuery is the chat-side memory search input.
type MemoryQuery struct {
Query string
ProjectID int64
SessionID string
ExcludeSessionID string
TopK int
MinScore float64
}
// IntentPlans is the chat-owned port for multi-intent plan persistence on a turn.
type IntentPlans interface {
HandleTurn(ctx context.Context, in IntentTurnInput) (*IntentTurnResult, error)
}
// IntentTurnInput is the chat-owned turn command (no app/intentflow types).
type IntentTurnInput struct {
UserID int64
Environment string
ProjectID int64
SessionID string
Text string
ClientTurnID string
RequestDigest string
}
// IntentTurnResult is the chat-facing HandleTurn projection.
type IntentTurnResult struct {
Plan *IntentPlanView
}
// IntentPlanView is the desensitized plan projection returned on a chat turn.
// JSON field names match the prior intentflow.PlanView envelope.
type IntentPlanView struct {
PlanID string `json:"plan_id"`
Revision int64 `json:"revision"`
Status string `json:"status"`
ExecutionMode string `json:"execution_mode"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Steps []IntentStepView `json:"steps,omitempty"`
SafeSummary string `json:"safe_summary,omitempty"`
}
// IntentStepView is one desensitized step in IntentPlanView.
type IntentStepView struct {
StepID string `json:"step_id"`
ActionKey string `json:"action_key"`
Status string `json:"status"`
ExecutionMode string `json:"execution_mode"`
DependsOn []string `json:"depends_on,omitempty"`
SafeSummary string `json:"safe_summary,omitempty"`
}
// TaskStarter starts a task run without exposing bizdev/stageflow request types to chat.
type TaskStarter interface {
CreateAndStart(ctx context.Context, runID string, record *taskrun.RunRecord, start TaskStartRequest, resumeDir string) (*taskrun.RunRecord, error)
}
// TaskStartRequest carries the chat-owned fields needed to start or resume a run.
type TaskStartRequest struct {
TaskKey string
ProjectName string
UserID int64
ProjectID int64
SessionID string
}
+6 -10
View File
@@ -8,8 +8,6 @@ import (
"sync"
"time"
"chat-agent-system/internal/app/chatflow"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
@@ -40,7 +38,7 @@ type ChatResponse struct {
AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"`
InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"`
PendingExpectation *intent.PendingExpectation `json:"pending_expectation,omitempty"`
IntentPlan *intentflow.PlanView `json:"intent_plan,omitempty"`
IntentPlan *IntentPlanView `json:"intent_plan,omitempty"`
}
// Session 多轮对话与协同会话管理器
@@ -56,7 +54,7 @@ type Session struct {
Recognizer intent.Recognizer `json:"-"`
Coordinator *coordinator.CoordinatorEngine `json:"-"`
CtxMgr *ctxmgr.ContextManager `json:"-"`
ChatLog chatflow.ChatLog `json:"-"`
ChatLog ChatMemory `json:"-"`
Actions *ActionServices `json:"-"`
Messages []Message `json:"messages"`
CreatedAt time.Time `json:"created_at"`
@@ -276,7 +274,7 @@ func (s *Session) syncRecalledMemories(ctx context.Context, userInput string, re
var items []ctxmgr.RecalledMemoryItem
var err error
if s.ChatLog != nil {
items, err = s.ChatLog.SearchMemory(ctx, chatflow.Principal{UserID: s.UserID, Action: chatflow.ActionAssemble}, chatflow.MemoryQuery{
items, err = s.ChatLog.SearchMemory(ctx, s.UserID, MemoryQuery{
Query: userInput,
ProjectID: s.ProjectID,
ExcludeSessionID: s.ID,
@@ -385,7 +383,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
return nil, fmt.Errorf("intent recognition failed: %w", err)
}
var persistedPlan *intentflow.PlanView
var persistedPlan *IntentPlanView
if s.Actions != nil && s.Actions.IntentPlans != nil && s.UserID > 0 && s.ProjectID > 0 {
env := s.Actions.Environment
if env == "" {
@@ -395,10 +393,8 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
if turnID == "" {
turnID = fmt.Sprintf("turn-%d", time.Now().UnixNano())
}
tr, planErr := s.Actions.IntentPlans.HandleTurn(ctx, intentflow.TurnInput{
Principal: intentflow.Principal{
UserID: s.UserID, Environment: env, ProjectID: s.ProjectID, SessionID: s.ID,
},
tr, planErr := s.Actions.IntentPlans.HandleTurn(ctx, IntentTurnInput{
UserID: s.UserID, Environment: env, ProjectID: s.ProjectID, SessionID: s.ID,
Text: userInput, ClientTurnID: turnID, RequestDigest: turnID + ":" + userInput,
})
if planErr == nil && tr != nil && tr.Plan != nil {
+8 -1
View File
@@ -84,12 +84,19 @@ type Config struct {
VaultMasterKey string `json:"vault_master_key"`
FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false
FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false
HTTPAccessLogEnabled bool `json:"http_access_log_enabled"` // HTTP_ACCESS_LOG; default on for dev/test
}
// DefaultDataDir is the only production default for the environment data root.
// Other packages must call this (or receive an injected DataDir) instead of embedding path literals.
func DefaultDataDir() string {
return "./data/dev"
}
func Load(path string) (*Config, error) {
cfg := &Config{
Env: "dev",
DataDir: "./data/dev",
DataDir: DefaultDataDir(),
HTTPHost: "127.0.0.1",
HTTPPort: 8080,
AgentBackend: "pi-agent",
@@ -0,0 +1,87 @@
// Package vocabulary holds stable, behavior-free shared enums and sentinel values.
// Domain packages keep invariants and typed error wrappers; consumers import this
// leaf instead of pulling docgov solely for state/role/error strings.
package vocabulary
import (
"errors"
"time"
)
// DocumentState is the controlled-document lifecycle vocabulary.
type DocumentState string
const (
StateDrafting DocumentState = "drafting" // 编制中 (草稿)
StateReviewing DocumentState = "reviewing" // 审核中 (待审核, 锁定)
StateApproving DocumentState = "approving" // 批准中 (待批准, 锁定)
StateApproved DocumentState = "approved" // 已批准 (归档生效, 终态锁定)
StateRejected DocumentState = "rejected" // 已驳回 (中间状态或流转标记)
)
// UserRole is the document-governance duty role vocabulary.
type UserRole string
const (
RoleAuthor UserRole = "author" // 编制人
RoleReviewer UserRole = "reviewer" // 审核人
RoleApprover UserRole = "approver" // 批准人
RoleSystem UserRole = "system" // 系统/智能体小队
)
// LeaseHolderType is the edit-lease holder vocabulary (orthogonal to IsLocked).
type LeaseHolderType string
const (
LeaseHolderNone LeaseHolderType = ""
LeaseHolderSquad LeaseHolderType = "squad"
LeaseHolderHuman LeaseHolderType = "human"
)
// Default lease TTLs shared by squad/human writers.
const (
DefaultSquadLeaseTTL = 30 * time.Minute
DefaultHumanLeaseTTL = 15 * time.Minute
)
// CommentType is the document comment-kind vocabulary.
type CommentType string
const (
CommentGeneral CommentType = "comment" // 普通批注
CommentSuggestion CommentType = "suggestion" // 修改建议
CommentRejection CommentType = "reject_reason" // 驳回原因
)
// Cross-module sentinel errors. Wire/HTTP mapping stays in server; wrappers with
// extra fields (e.g. DocumentBusyError) remain in the owning domain package.
var (
ErrDocumentNotFound = errors.New("docgov: document not found")
ErrDocumentLocked = errors.New("docgov: document is locked, direct modification prohibited")
ErrOnlyAuthorCanEdit = errors.New("docgov: only the designated author is allowed to edit or modify document content")
ErrReviewerCannotEdit = errors.New("docgov: reviewers are not allowed to edit document content, only comments and review decisions are permitted")
ErrApproverCannotEdit = errors.New("docgov: approvers are not allowed to edit document content, only comments and approval decisions are permitted")
ErrInvalidStateTransition = errors.New("docgov: invalid document lifecycle state transition")
ErrRejectReasonRequired = errors.New("docgov: rejection reason must not be empty")
ErrUnauthorizedActor = errors.New("docgov: actor is not authorized for this role or action")
ErrProposalNotFound = errors.New("docgov: change proposal not found")
ErrProposalAlreadyDecided = errors.New("docgov: change proposal has already been decided")
ErrChangeProposalPending = errors.New("docgov: document is claimed and has pending change proposal waiting for author approval")
ErrDocumentBusy = errors.New("docgov: document edit lease is held by another writer")
ErrStaleFencing = errors.New("docgov: stale lease fencing token")
ErrLeaseExpired = errors.New("docgov: document edit lease has expired")
ErrLeaseNotHeld = errors.New("docgov: caller does not hold the document edit lease")
ErrForceEditNotAllowed = errors.New("docgov: force_direct_edit requires an approved_by_author proposal and unlocked drafting document")
ErrContentProjectionMissing = errors.New("docgov: document content projection missing")
ErrContentProjectionEmpty = errors.New("docgov: document content projection empty")
ErrContentIntegrityMismatch = errors.New("docgov: document content integrity mismatch")
)
// Stable machine-readable content-error codes (HTTP status mapping stays in server).
const (
ErrCodeDocumentNotFound = "DOCUMENT_NOT_FOUND"
ErrCodeProjectionMissing = "DOCUMENT_CONTENT_PROJECTION_MISSING"
ErrCodeProjectionEmpty = "DOCUMENT_CONTENT_PROJECTION_EMPTY"
ErrCodeIntegrityMismatch = "DOCUMENT_CONTENT_INTEGRITY_MISMATCH"
ErrCodeProjectionDrift = "DOCUMENT_CONTENT_PROJECTION_DRIFT"
)
+3 -2
View File
@@ -17,6 +17,7 @@ import (
"chat-agent-system/internal/kb"
"chat-agent-system/internal/maturity"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/workspace"
)
// UserInfo 用户在系统与项目内的身份
@@ -453,9 +454,9 @@ func (a *ContextAssembler) AssembleFromWorkspace(workDir string, user UserInfo)
}
}
// 5. 若无 advisor_brief.json 但有 output.md,尝试从文本抽取
// 5. 若无 advisor_brief.json 但有交付正文,尝试从文本抽取
if ctx.LatestAdvisorBrief == nil {
outPath := filepath.Join(workDir, "output.md")
outPath := filepath.Join(workDir, workspace.DeliverableKey)
if outBytes, err := os.ReadFile(outPath); err == nil && len(outBytes) > 0 {
ctx.LatestAdvisorBrief = squad.ExtractAdvisorBriefing(ctx.Project.CurrentWorkflow, string(outBytes), ctx.BlackboardFacts)
ctx.UserDocs.Composed = append(ctx.UserDocs.Composed, ctx.Project.CurrentWorkflow)
+3 -2
View File
@@ -35,9 +35,10 @@ func (e *CoordinatorEngine) generateDialogueReply(
return "", ReplySourceTemplate
}
workRoot := e.dataDir
workRoot := strings.TrimSpace(e.dataDir)
if workRoot == "" {
workRoot = "./data/dev"
// U5: runtime roots come from config/workspace; never invent a data/dev fallback.
return "", ReplySourceTemplate
}
sessionKey := "anon"
if userCtx != nil && userCtx.SnapshotID != "" {
+3 -2
View File
@@ -105,9 +105,10 @@ func (e *CoordinatorEngine) generateDocumentSummaryReply(ctx context.Context, do
%s
【受控文档正文结束】`, doc.Title, doc.DocID, doc.TaskKey, doc.LifecycleState, doc.Version, markdown)
workRoot := e.dataDir
workRoot := strings.TrimSpace(e.dataDir)
if workRoot == "" {
workRoot = "./data/dev"
// U5: runtime roots come from config/workspace; never invent a data/dev fallback.
return ""
}
sessionKey := "anon"
if userCtx != nil && userCtx.SnapshotID != "" {
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
package coordinator
import (
"context"
"fmt"
"strings"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/maturity"
)
// handleAmbiguousConsult 处理用户犹豫或征询建议 (分支 3 模糊分忧)
func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) {
currentStage := userCtx.Project.CurrentStage
projName := userCtx.Project.Name
if projName == "" {
projName = "当前项目"
}
// 只在低置信度的模糊咨询里,才用关键词改走测试分析。高置信度保持原意图。
if cand.Confidence < 0.6 && (strings.Contains(rawInput, "测试") || strings.Contains(rawInput, "验证") || strings.Contains(rawInput, "型检") ||
strings.Contains(rawInput, "难点") || strings.Contains(rawInput, "注意") || strings.Contains(rawInput, "比较难") ||
strings.Contains(rawInput, "方案")) {
if cand.FilledSlots == nil {
cand.FilledSlots = make(map[string]string)
}
cand.FilledSlots[intent.SlotQueryTopic] = "testing_analysis"
return e.handleQueryProgress(ctx, cand, userCtx, rawInput)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("完全理解您想再掂量一下。针对「%s」目前的节点,我帮您快速对照一下:\n\n", projName))
if currentStage == maturity.StageV01_Triage {
sb.WriteString("• 若推进 V0.5(方案成型与法规对齐):\n")
sb.WriteString(" 我可帮您梳理初步架构与注册分类路径,并预估型检成本,便于与客户深入沟通;\n")
sb.WriteString("• 若先放一放 / 多做调研:\n")
sb.WriteString(" 可先补齐预算、目标市场或使用场景等信息,降低后续返工风险。\n\n")
sb.WriteString("💡 如果您接下来更希望先有一份可审阅的材料,我可以先整理一版 V0.5 方案框架与法规路径;节奏仍由您决定。\n\n")
sb.WriteString("您更倾向于:\n")
sb.WriteString("[1] 先做 V0.5 方案框架(可直接回复“好”或“1”)\n")
sb.WriteString("[2] 先说明您当前最关心的细节,我做针对性分析")
} else {
sb.WriteString("当前各项工作进展平稳。您可以把零散想法随时告诉我,我来帮您沉淀;时机成熟时,再一起推进下一阶段也完全可以。")
}
reply := sb.String()
source := ReplySourceTemplate
if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" {
reply = llmReply
source = llmSource
}
nextExp := BuildNextExpectation(currentStage, projName)
// 路由层:复杂自由任务画像命中时,先给即时初答,再附升级建议(复用挂起期望机制,含每会话冷却)
reply, nextExp = maybeAttachUpgradeHint(rawInput, userCtx, reply, nextExp)
userCtx.applyPending(nextExp)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: intent.BranchAmbiguousFallback,
NaturalReply: reply,
AdvisorBrief: userCtx.LatestAdvisorBrief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: source,
}, nil
}
func (e *CoordinatorEngine) handleCasualChat(ctx context.Context, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) {
projName := userCtx.Project.Name
if projName == "" {
projName = "当前医疗器械研发项目"
}
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
nameSuffix := ""
if userCtx.User.Name != "" {
nameSuffix = "," + userCtx.User.Name
}
reply := fmt.Sprintf("您好%s!我是您的项目助手,很高兴协助您推进医疗器械立项与研发协同。\n\n针对「%s」,我可以帮您:\n• 查询进展、成熟度与风险;\n• 沉淀客户需求与技术指标;\n• 起草沟通话术与方案要点;\n• 推进方案论证与报价核算。\n\n如果您愿意,我们可以先从您当前最关心的一块开始——您想先看进展、补事实,还是直接聊方案?",
nameSuffix, projName)
source := ReplySourceTemplate
if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" {
reply = llmReply
source = llmSource
}
// 路由层:复杂自由任务画像命中时,先给即时初答,再附升级建议(复用挂起期望机制,含每会话冷却)
reply, nextExp = maybeAttachUpgradeHint(rawInput, userCtx, reply, nextExp)
userCtx.applyPending(nextExp)
return &CoordinationResult{
Status: StatusAccepted,
Intent: intent.IntentCasualChat,
Branch: intent.BranchNewIntentPreempt,
NaturalReply: reply,
AdvisorBrief: userCtx.LatestAdvisorBrief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: source,
}, nil
}
// handleSecurityForbidden 处理安全红线与合规拦截 (ISO 13485 / 21 CFR Part 11)
func (e *CoordinatorEngine) handleSecurityForbidden(userCtx *UserProjectContext) (*CoordinationResult, error) {
reply := "⚠️ **合规权限拦截**:根据医疗器械质量管理规范(ISO 13485 / 21 CFR Part 11),企业人员的法定身份(姓名、工号、职位、部门)与系统控制台属于基线受控资产,不支持通过对话动态修改。您可通过‘查询我的权限’查看您在当前各文档中的法定职责。"
return &CoordinationResult{
Status: StatusRejected,
Intent: intent.IntentSecurityForbidden,
Branch: intent.BranchNewIntentPreempt,
RejectReason: "SECURITY_FORBIDDEN_BASELINE_ASSET",
NaturalReply: reply,
UpdatedContext: userCtx,
}, nil
}
+398
View File
@@ -0,0 +1,398 @@
package coordinator
import (
"context"
"fmt"
"strings"
"time"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
)
// handleDocGovReject 处理受控文档审核/批准驳回与二次确认交互卡片挂载(角色硬门禁)
func (e *CoordinatorEngine) handleDocGovReject(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
if userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" {
docName = userCtx.InteractiveCard.TargetDocID
} else {
docName = "当前受控文档"
}
}
rawReason := cand.FilledSlots[intent.SlotRejectReason]
section := cand.FilledSlots[intent.SlotTargetSection]
if section == "" {
section = extractSectionClues(rawReason)
}
role := effectiveDocRole(userCtx)
isFinal := cand.Intent == intent.IntentDocGovFinalReject
// 与批准对称的硬门禁
if isFinal {
if !isApproverAuthorized(userCtx.User) && role != "approver" {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_REJECT_ROLE",
NaturalReply: fmt.Sprintf("权限不足:终审驳回需批准人角色。当前您的身份为「%s」。若需了解驳回原因,可直接询问。", roleLabelCN(role)),
UpdatedContext: userCtx,
}, nil
}
} else {
if !isReviewerAuthorized(userCtx.User) && role != "reviewer" {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_REJECT_ROLE",
NaturalReply: fmt.Sprintf("权限不足:审核驳回需审核人角色。当前您的身份为「%s」。若需了解驳回原因,可直接询问「被驳回的原因是什么」。", roleLabelCN(role)),
UpdatedContext: userCtx,
}, nil
}
}
// 规范化策略角色(授权通过后按意图选审核/批准确认卡)
policyRole := role
if isFinal {
policyRole = "approver"
} else if policyRole != "reviewer" && isReviewerAuthorized(userCtx.User) {
policyRole = "reviewer"
}
lifecycle := "reviewing"
if isFinal {
lifecycle = "approving"
}
resolved := ResolveRejectCard(policyRole, lifecycle, docName, docName, section, rawReason, RejectUtteranceExecute)
if resolved.Kind == "deny_text" || resolved.Card == nil {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: resolved.DenyReason,
NaturalReply: resolved.Reply,
UpdatedContext: userCtx,
}, nil
}
userCtx.applyCard(resolved.Card)
userCtx.applyPending(resolved.Expectation)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: resolved.Reply,
InteractiveCard: resolved.Card,
NextExpectation: resolved.Expectation,
UpdatedContext: userCtx,
}, nil
}
// isReviewerAuthorized 校验审核人或主导工程师权限
func isReviewerAuthorized(u UserInfo) bool {
r := strings.ToLower(strings.TrimSpace(u.Role))
pr := strings.ToLower(strings.TrimSpace(u.ProjectRole))
return r == "reviewer" || pr == "reviewer" ||
r == "lead_engineer" || pr == "lead_engineer" ||
strings.Contains(pr, "审核") || strings.Contains(r, "审核") ||
strings.Contains(pr, "主导") || strings.Contains(r, "主导")
}
// isApproverAuthorized 校验批准人或项目经理权限
func isApproverAuthorized(u UserInfo) bool {
r := strings.ToLower(strings.TrimSpace(u.Role))
pr := strings.ToLower(strings.TrimSpace(u.ProjectRole))
return r == "approver" || pr == "approver" ||
r == "project_manager" || pr == "project_manager" ||
strings.Contains(pr, "批准") || strings.Contains(r, "批准") ||
strings.Contains(pr, "总监") || strings.Contains(r, "总监") ||
r == "director"
}
// handleDocGovApprove 处理受控文档审核/批准通过与终审批准二次确认
func (e *CoordinatorEngine) handleDocGovApprove(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
if userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" {
docName = userCtx.InteractiveCard.TargetDocID
} else {
docName = "当前受控文档"
}
}
currentRole := userCtx.User.ProjectRole
if currentRole == "" {
currentRole = userCtx.User.Role
}
if currentRole == "" {
currentRole = "未知角色"
}
isFinal := cand.Intent == intent.IntentDocGovFinalApprove
if isFinal {
// 校验终审批准权限: approver 或 project_manager
if !isApproverAuthorized(userCtx.User) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_APPROVAL_ROLE",
NaturalReply: fmt.Sprintf("权限不足:受控文档终审批准需要批准人 (approver) 或项目经理 (project_manager) 权限。当前您的身份为「%s」,无权执行终审批准操作。", currentRole),
UpdatedContext: userCtx,
}, nil
}
cardID := fmt.Sprintf("card_final_appr_%d", time.Now().UnixNano())
cardPayload := &intent.InteractiveCardPayload{
CardID: cardID,
CardType: "CONFIRMATION",
Title: "受控文档终审批准生效确认",
TargetDocID: docName,
TargetDocTitle: fmt.Sprintf("《%s》", docName),
ProposedContent: "正式终审批准受控文档,签署电子签名并受控归档生效",
ImpactRisk: "终审批准后文档正文将永久锁定为基线版本,后续非受控修改将被严格拦截,任何变更必须走变更审批流程",
CurrentStatus: "active",
TTLTurns: 3,
CreatedAt: time.Now(),
Actions: []intent.CardAction{
{
ActionID: "confirm_final_approve",
Label: "确认终审批准生效",
Style: "primary",
IsAffirmativeDefault: true,
Payload: map[string]string{
"target_doc": docName,
"target_action": "confirm_final_approve",
},
},
{
ActionID: "cancel",
Label: "取消",
Style: "subtle",
Payload: map[string]string{
"action": "cancel",
},
},
},
}
nextExp := &intent.PendingExpectation{
ExpectationID: "exp_" + cardPayload.CardID,
ExpectedIntent: intent.IntentDocGovProposalDecide,
TargetAction: "confirm_final_approve",
Prompt: "请确认是否执行终审批准操作",
AffirmativeWord: "确认",
DeclineWord: "取消",
ExpiresAt: time.Now().Add(5 * time.Minute),
Payload: cardPayload.Actions[0].Payload,
AllowAffirmative: true,
CreatedAt: time.Now(),
DefaultOption: &intent.ExpectationOption{
OptionID: "confirm_final_approve",
Title: "确认终审批准生效",
Intent: intent.IntentDocGovProposalDecide,
Slots: cardPayload.Actions[0].Payload,
},
}
userCtx.applyCard(cardPayload)
userCtx.applyPending(nextExp)
reply := fmt.Sprintf("已为您起草针对《%s》的终审批准确认单。终审批准将使文档正式归档并永久锁定,请审阅下方卡片并在确认无误后点击确认或回复‘确认’。", docName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
InteractiveCard: cardPayload,
NextExpectation: nextExp,
UpdatedContext: userCtx,
}, nil
}
// 校验审核通过权限: reviewer 或 lead_engineer
if !isReviewerAuthorized(userCtx.User) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "UNAUTHORIZED_REVIEW_ROLE",
NaturalReply: fmt.Sprintf("权限不足:受控文档审核通过需要审核人 (reviewer) 或主导工程师 (lead_engineer) 权限。当前您的身份为「%s」,无权执行审核通过操作。", currentRole),
UpdatedContext: userCtx,
}, nil
}
reply := fmt.Sprintf("正在处理您对《%s》的审核通过请求…", docName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
PendingAction: &PendingAction{
Kind: "docgov.review_approve",
DocID: docName,
Comment: cand.FilledSlots[intent.SlotRejectReason],
},
}, nil
}
// handleDocGovClaim 处理受控文档认领
func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
docName = "当前受控文档"
}
// 记录在用户的已认领清单中
found := false
for _, d := range userCtx.UserDocs.Claimed {
if d == docName {
found = true
break
}
}
if !found {
userCtx.UserDocs.Claimed = append(userCtx.UserDocs.Claimed, docName)
}
reply := fmt.Sprintf("您已成功认领《%s》。系统已锁定您作为该文档的法定责任人,您可以开始在线就地修订正文或提交审核。", docName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
}, nil
}
// handleDocGovSubmitReview 处理受控文档提交审核
func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
docName = "当前受控文档"
}
reply := fmt.Sprintf("《%s》已成功提交审核!文档正文已进入受控锁定状态,并已通知审核责任人进行合规审查与批注。", docName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
}, nil
}
// handleDocGovProposalDecide 处理智能体修改建议与提案裁决,以及驳回/终审批准确认执行
func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
targetAction := cand.FilledSlots["target_action"]
if targetAction == "" {
targetAction = cand.FilledSlots["action"]
}
if targetAction == "" && cand.MatchedOption != nil {
targetAction = cand.MatchedOption.OptionID
}
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" && userCtx.InteractiveCard != nil && userCtx.InteractiveCard.TargetDocID != "" {
docName = userCtx.InteractiveCard.TargetDocID
}
if docName == "" {
docName = "当前受控文档"
}
if targetAction == "confirm_reject" {
reason := cand.FilledSlots[intent.SlotRejectReason]
if reason == "" && userCtx.InteractiveCard != nil {
reason = userCtx.InteractiveCard.ProposedContent
}
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
rollback := docgov.StateDrafting
if userCtx.InteractiveCard != nil && strings.Contains(userCtx.InteractiveCard.ImpactRisk, "reviewing") {
rollback = docgov.StateDrafting
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在处理您对《%s》的驳回确认…", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.reject",
DocID: docName,
Reason: reason,
Rollback: rollback,
},
}, nil
}
if targetAction == "confirm_final_approve" {
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在处理您对《%s》的终审批准…", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.approver_approve",
DocID: docName,
},
}, nil
}
if targetAction == "cancel" {
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("cancelled")
}
userCtx.applyPending(nil)
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已取消针对《%s》的操作,文档状态保持不变。", docName),
UpdatedContext: userCtx,
ClearExpectation: true,
}, nil
}
action := cand.FilledSlots[intent.SlotProposalAction]
var reply string
if action == "reject" {
reply = "已拒绝该修改变更提案。文档正文保持原样,提案记录已归档。"
} else {
reply = "已采纳该修改变更提案,补充内容与参数已合并入受控文档。"
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
}, nil
}
+477
View File
@@ -0,0 +1,477 @@
package coordinator
import (
"context"
"fmt"
"regexp"
"strings"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/squad"
)
// enrichAdvisorBriefWithCases 结合上下文与意图槽位检索相似标杆案例并注入参谋锦囊
func (e *CoordinatorEngine) enrichAdvisorBriefWithCases(ctx context.Context, brief *squad.AdvisorBriefing, userCtx *UserProjectContext, slots map[string]string) {
if brief == nil || e.ctxMgr == nil {
return
}
ctxMgr := e.ctxMgr
if ctxMgr == nil {
return
}
// 构造搜索语句
queryParts := []string{userCtx.Project.DeviceType, userCtx.Project.IntendedUse}
if topic, ok := slots[intent.SlotQueryTopic]; ok && topic != "" {
queryParts = append(queryParts, topic)
}
query := strings.TrimSpace(strings.Join(queryParts, " "))
if query == "" {
query = userCtx.Project.Name
}
if query == "" {
return
}
cases, err := ctxMgr.SearchSimilarCases(ctx, ctxmgr.CaseSearchOptions{
Query: query,
Limit: 2,
MinScore: 0.10,
})
if err != nil || len(cases) == 0 {
return
}
for _, c := range cases {
brief.RecommendedCases = append(brief.RecommendedCases, squad.AdvisorCaseRecommendation{
DocID: c.DocID,
ProjectName: c.ProjectName,
TaskKey: c.TaskKey,
ArtifactTitle: c.ArtifactTitle,
SectionTitle: c.SectionTitle,
SummarySnippet: c.SummarySnippet,
KeyRegulations: c.KeyRegulations,
SimilarityScore: c.SimilarityScore,
ApprovedAt: c.ApprovedAt,
})
}
}
func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) {
brief := userCtx.LatestAdvisorBrief
topic := cand.FilledSlots[intent.SlotQueryTopic]
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
// 1. 若为测试方案/难点/验证咨询,直接调取测试经理与参谋专业知识库解答
if topic == "testing_analysis" {
if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" {
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: llmReply,
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: llmSource,
}, nil
}
projName := userCtx.Project.Name
if projName == "" || projName == "医疗器械研发项目" {
projName = "肌骨超声AI辅助诊断系统"
}
greet := strings.TrimSpace(userCtx.User.Name)
if greet == "" {
greet = "您好"
} else {
greet += ",您好"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s!针对「%s」的系统验证方案(SVP)与型式检验要求,我为您梳理以下核心难点项目与特别注意事项:\n\n", greet, projName))
sb.WriteString("### 一、 核心测试难点项目\n\n")
sb.WriteString("1. **声学输出安全与探头表面温升极限(GB 9706.237-2020)**\n")
sb.WriteString(" • **难点分析**:肌骨超声常需配合高频线阵探头(10-18MHz)进行浅表滑膜微血流彩色多普勒(CDFI/Power Doppler)检测。连续高占空比发射脉冲会导致探头声透镜声吸收发热。根据强标要求,探头施加于人体的表面温升严禁超过 **41℃**,同时机械指数(MI)需严格低于 1.9,热指数(TIS/TIB)需低于 1.0。\n")
sb.WriteString(" • **试验考量**:需在 33℃ 恒温仿生水槽中进行连续 30 分钟满功率复合工况热电偶布点实测,温控算法防呆测试难度较高。\n\n")
sb.WriteString("2. **高频浅表微小解剖量测公差与体模标定(GB 10188 / KS107BG 仿组织体模)**\n")
sb.WriteString(" • **难点分析**:肌腱撕裂缝隙及滑膜增厚量测精度要求达到 **±0.3mm 或 ±3%**。在 1~3cm 浅表盲区内,探头晶片近场声束干涉容易造成线靶伪影或横向展宽。\n")
sb.WriteString(" • **试验考量**:对组织仿生体模内部靶群的纵向、横向分辨力及几何位置示值误差进行多批次盲态复测,对探头声阻抗匹配层与声透镜一致性要求极其苛刻。\n\n")
sb.WriteString("3. **AI 算法病灶识别与量化分割的泛化鲁棒性(YY/T 0664 / 软件性能测试)**\n")
sb.WriteString(" • **难点分析**:超声影像固有的斑点噪声(Speckle Noise)、声影及不同患者皮下脂肪厚度差异极大。滑膜边界 Dice 系数(≥0.85)在骨皮质高回声边缘极易出现伪阳性粘连;血流微信号半定量分级在探头晃动时易出现运动伪影误判。\n")
sb.WriteString(" • **试验考量**:需要建立多中心、不同机型/探头采集的独立盲测黄金数据集(由 3 名资深超声主任医师独立背对背标定一致性),验证测试集与训练集严禁污染交叉。\n\n")
sb.WriteString("### 二、 需要特别注意的关键地方\n\n")
sb.WriteString("• **标准体模计量有效期与测试环境温湿度**:仿生超声体模(如 KS107BG)的声速(标准 1540 m/s)对环境温度极为敏感,必须在 **23℃ ± 2℃** 严格恒温实验室实施,且体模必须在有效计量标定期内,否则测距误差将产生系统性偏移;\n")
sb.WriteString("• **电磁兼容(EMC GB 9706.102-2021)抗扰度储备**:高频微弱回波信号接收通道抗射频电磁场辐射敏感度高,测试方案中需提前验证探头线缆双层屏蔽接地与机箱屏蔽缝隙,避免正式型检时辐射发射(RE)超标返工;\n")
sb.WriteString("• **DICOM 与数据脱敏合规**:软件系统测试中,所有导出样本、测试影像的患者个人隐私标识(PII)脱敏规则需执行 100% 覆盖率验证;\n")
sb.WriteString("• **极端异常输入与容错安全防呆**:探头带电热插拔、探头声透镜声胶脱落阻抗异常检测、长时间无操作探头自动降频防发热休眠逻辑,均需设计破坏性负向测试用例。\n\n")
sb.WriteString("💡 **建议行动**:若您需要,我可以为您起草该模块对应的《系统验证方案 (SVP) 关键测试用例矩阵》,列出详细的测试条件、前置配置与合格判据。")
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: ReplySourceTemplate,
}, nil
}
// 2. 若为特定技术/架构/功耗方案咨询,直接调取参谋专业知识库解答,不强依赖阶段简报
if topic == "technical_design" {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("关于「%s」的技术与系统架构考量,我为您梳理以下核心要点:\n", userCtx.Project.Name))
sb.WriteString("• 供电与功耗设计:建议选用高能效前端模拟芯片与智能休眠调度,兼顾便携连续工作时间与温升控制;\n")
sb.WriteString("• 电气安全与标准:设备需符合 GB 9706.1-2020 通用安全与医用电气专项要求,重点评估外壳防护等级与绝缘耐压;\n")
sb.WriteString("• 供应链选型:关键芯片与传感器优先考虑国内高成熟度元器件,缩短备料周期与型检验证周期。\n\n")
sb.WriteString("这项考量已收录。进入方案成型(V0.5)时,我会作为核心约束一并带入论证。")
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
// 2. 无参谋简报的降级应答(修复:曾直接返回 V0.1 推销模板导致答非所问)
// 2a. 优先真实对话 LLM 结合项目上下文直接回答本轮问题(Phase A 机制延伸至 QUERY_PROGRESS 降级路径);
// 2b. LLM 不可用时,基于黑板事实合成 intake 基准简报,仍按查询主题给出实质研判,末尾附轻量摸底引导。
synthesized := false
replySource := ""
if brief == nil {
if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" {
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: llmReply,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: llmSource,
}, nil
}
brief = synthesizeBaselineBrief(userCtx)
synthesized = true
replySource = ReplySourceTemplate
}
var sb strings.Builder
switch topic {
case "risk":
sb.WriteString(fmt.Sprintf("关于「%s」的潜在风险,我为您提前预警以下关键卡点:\n", userCtx.Project.Name))
for _, r := range brief.PreemptiveRisks {
sb.WriteString(fmt.Sprintf("• ⚠️ %s\n", r))
}
sb.WriteString("\n建议您在后续与客户沟通时预先留出安全边际。")
case "talk_script":
sb.WriteString("这是为您草拟的对外商务沟通话术,您可以直接使用或微调:\n\n")
sb.WriteString(brief.CommunicationKit)
case "progress":
sb.WriteString(fmt.Sprintf("【进展汇报】 项目当前成熟度等级为 %s(评分 %d/100)。\n", brief.MaturityLevel, brief.MaturityScore))
sb.WriteString(fmt.Sprintf("诊断结论:%s\n\n", brief.MaturityHeadline))
if len(brief.NextBestActions) > 0 {
sb.WriteString("建议下一步选项:\n")
for idx, a := range brief.NextBestActions {
sb.WriteString(fmt.Sprintf("[%d] %s\n", idx+1, a))
}
}
default: // all
sb.WriteString(fmt.Sprintf("您好!项目「%s」当前处于【%s】阶段(成熟度 %d/100)。\n",
userCtx.Project.Name, brief.MaturityLevel, brief.MaturityScore))
sb.WriteString(fmt.Sprintf("核心研判:%s\n\n", brief.MaturityHeadline))
if len(brief.PreemptiveRisks) > 0 {
sb.WriteString("⚠️ 关键风险预警:\n")
for _, r := range brief.PreemptiveRisks {
sb.WriteString(fmt.Sprintf(" • %s\n", r))
}
sb.WriteString("\n")
}
if len(brief.NextBestActions) > 0 {
sb.WriteString("您可以选择下一步动作:\n")
for idx, a := range brief.NextBestActions {
sb.WriteString(fmt.Sprintf(" [%d] %s\n", idx+1, a))
}
}
}
// 3. 合成简报场景:先给出实质回答,再轻量附上摸底引导(保留引导、不再答非所问、不强推)
if synthesized {
sb.WriteString("\n\n💡 说明:项目尚未启动过任何交付任务,以上研判基于行业基准假设。")
sb.WriteString("若希望沉淀更精准的第一版立项画像与沟通话术,您可以吩咐我「生成一版 V0.1 需求收集表(立项摸底)」,或直接补充器械类型、目标市场、预算等关键细节。")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
ReplySource: replySource,
}, nil
}
// synthesizeBaselineBrief 在用户尚未执行过任何阶段/任务(无持久化参谋简报)时,
// 基于黑板事实与项目信息合成 intake 级基准参谋简报,确保查询类意图始终有实质研判可用。
func synthesizeBaselineBrief(userCtx *UserProjectContext) *squad.AdvisorBriefing {
fields := make(map[string]string)
for k, v := range userCtx.BlackboardFacts {
fields[k] = v
}
fields["project_name"] = userCtx.Project.Name
fields["customer_name"] = userCtx.Project.CustomerName
if userCtx.Project.DeviceType != "" {
fields["device_type"] = userCtx.Project.DeviceType
}
if userCtx.Project.IntendedUse != "" {
fields["intended_use"] = userCtx.Project.IntendedUse
}
if userCtx.Project.TargetMarket != "" {
fields["target_market"] = userCtx.Project.TargetMarket
}
return squad.ExtractAdvisorBriefing("intake", "", fields)
}
// handleQueryRejectReason 处理编制人等对驳回原因的查询(信息卡,无危险按钮)
func (e *CoordinatorEngine) handleQueryRejectReason(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
docName := cand.FilledSlots[intent.SlotTargetDoc]
if docName == "" {
docName = "当前受控文档"
}
role := effectiveDocRole(userCtx)
if role == "" {
role = "author"
}
// 优先从黑板或近期事件中取驳回意见
reason := ""
if userCtx.BlackboardFacts != nil {
reason = userCtx.BlackboardFacts["last_reject_reason"]
if reason == "" {
reason = userCtx.BlackboardFacts["reject_reason"]
}
}
if reason == "" && len(userCtx.RecentEvents) > 0 {
for i := len(userCtx.RecentEvents) - 1; i >= 0; i-- {
ev := userCtx.RecentEvents[i]
if strings.Contains(ev, "驳回") || strings.Contains(ev, "退回") {
reason = ev
break
}
}
}
resolved := ResolveRejectCard(role, "", docName, docName, "", reason, RejectUtteranceQuery)
if resolved.Card != nil {
userCtx.applyCard(resolved.Card)
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: intent.IntentQueryRejectReason,
Branch: cand.Branch,
NaturalReply: resolved.Reply,
InteractiveCard: resolved.Card,
UpdatedContext: userCtx,
}, nil
}
// extractSectionClues 从驳回意见中进一步抽取章节线索 (如 "第3节", "竞品对比", "抗扰度")
func extractSectionClues(text string) string {
clues := []string{}
// 正则提取 "第X节" 或 "第X章"
reSec := regexp.MustCompile(`(第[0-9一二三四五六七八九十]+[章节条部分]+|[0-9]+\.[0-9]+[节章]*)`)
if m := reSec.FindString(text); m != "" {
clues = append(clues, m)
}
keywords := []string{"竞品对比", "抗扰度", "电气安全", "电磁兼容", "EMC", "风险分析", "临床评价", "BOM预算", "型检标准"}
for _, kw := range keywords {
if strings.Contains(text, kw) {
clues = append(clues, kw)
}
}
if len(clues) > 0 {
return strings.Join(clues, " / ")
}
return ""
}
// handleQueryRegulations 处理强标与审评指导原则查询
func (e *CoordinatorEngine) handleQueryRegulations(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
searchQuery := ""
if userCtx != nil && userCtx.RewrittenQuery != "" {
searchQuery = userCtx.RewrittenQuery
} else if userCtx != nil && userCtx.TaskState != nil && userCtx.TaskState.TopicAnchor != "" {
searchQuery = userCtx.TaskState.TopicAnchor
} else if userCtx != nil {
searchQuery = userCtx.Project.Name
}
if userCtx != nil && userCtx.Project.DeviceType != "" && !strings.Contains(searchQuery, userCtx.Project.DeviceType) {
searchQuery += " " + userCtx.Project.DeviceType
}
var hits []kb.HitItem
if e != nil && e.kbClient != nil && strings.TrimSpace(searchQuery) != "" {
cacheKey := ctxmgr.HashKey("regulatory_search", searchQuery)
cache := ctxmgr.GetGlobalCache()
if cached, ok := cache.Get(ctxmgr.LayerC1Search, cacheKey); ok {
if ch, ok := cached.([]kb.HitItem); ok {
hits = ch
}
} else {
kbHits, err := e.kbClient.Search(ctx, searchQuery, kb.SearchOptions{
Domains: []kb.KnowledgeDomain{kb.DomainRegulatory},
Limit: 5,
})
if err == nil && len(kbHits) > 0 {
hits = kbHits
cache.Set(ctxmgr.LayerC1Search, cacheKey, hits, 30)
}
}
}
var sb strings.Builder
projectName := "医疗器械项目"
if userCtx != nil && userCtx.Project.Name != "" {
projectName = userCtx.Project.Name
}
if len(hits) > 0 {
if userCtx != nil {
userCtx.RegulationHits = hits
}
sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,权威依据梳理如下:\n\n", projectName))
for i, h := range hits {
cite := h.CiteKey
if cite == "" {
cite = fmt.Sprintf("〔%s〕", h.DocCode)
}
title := h.Title
if title == "" {
title = h.DocCode
}
sb.WriteString(fmt.Sprintf("%d. **%s** %s\n", i+1, title, cite))
if h.HeadingPath != "" {
sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath))
}
if h.Content != "" {
cText := strings.TrimSpace(h.Content)
if len([]rune(cText)) > 150 {
cText = string([]rune(cText)[:150]) + "..."
}
sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText))
}
}
sb.WriteString("\n上述标准与条款可作为型检与注册申报的权威技术支撑。如需深入比对专标或预估测试周期,请随时吩咐我。")
} else {
// 降级基准模板
sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,我为您梳理如下:\n\n", projectName))
sb.WriteString("1. **通用安全标准**:GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》;\n")
sb.WriteString("2. **电磁兼容强标**:YY 9706.102-2021 / YY 0505-2012《医用电气设备 第1-2部分:基本安全和基本性能的通用要求 并列标准:电磁兼容》;\n")
sb.WriteString("3. **专项性能指导原则**:需遵循 NMPA 医疗器械技术审评中心现行审评要点,并重点完善风险管理报告(依据 GB/T 42062-2020)。\n\n")
sb.WriteString("如需查看具体条款对比或型检预估,请随时吩咐我。")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: userCtx.LatestAdvisorBrief,
UpdatedContext: userCtx,
}, nil
}
// handleQuerySimilarCases 处理标杆案例检索
func (e *CoordinatorEngine) handleQuerySimilarCases(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
brief := userCtx.LatestAdvisorBrief
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已为您检索与「%s」相似的往期成功注册与过会标杆案例:\n\n", userCtx.Project.Name))
if brief != nil && len(brief.RecommendedCases) > 0 {
for i, c := range brief.RecommendedCases {
sb.WriteString(fmt.Sprintf("• **案例 %d: %s**\n", i+1, c.ArtifactTitle))
if c.SectionTitle != "" {
sb.WriteString(fmt.Sprintf(" - 关联章节: %s\n", c.SectionTitle))
}
if c.SummarySnippet != "" {
sb.WriteString(fmt.Sprintf(" - 标杆经验: %s\n", c.SummarySnippet))
}
if len(c.KeyRegulations) > 0 {
sb.WriteString(fmt.Sprintf(" - 关键法规: %s\n", strings.Join(c.KeyRegulations, ", ")))
}
}
} else {
sb.WriteString("• **标杆案例 1: 超高清内窥镜摄像系统注册获批经验 (PRJ-2025-008)**\n")
sb.WriteString(" - 方案特点: 采用高抗扰度硬件隔离拓扑,型检一次性通过 EMC 实验;\n")
sb.WriteString(" - 关键标准: 符合 GB 9706.1-2020 及 GB 9706.218 医用内窥镜专标。\n")
sb.WriteString("• **标杆案例 2: 便携式微创诊断设备快速过会经验 (PRJ-2024-019)**\n")
sb.WriteString(" - 方案特点: 完备同品种器械实质等同临床比对,免除大规模临床试验。\n")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: brief,
UpdatedContext: userCtx,
}, nil
}
// handleQueryUserRights 处理用户法定权责查询
func (e *CoordinatorEngine) handleQueryUserRights(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
var sb strings.Builder
userName := userCtx.User.Name
if userName == "" {
userName = "当前用户"
}
role := userCtx.User.ProjectRole
if role == "" {
role = userCtx.User.Role
}
sb.WriteString(fmt.Sprintf("您好,%s!您在项目「%s」中的法定受控职责如下:\n\n", userName, userCtx.Project.Name))
sb.WriteString(fmt.Sprintf("• **系统/项目身份**:%s (角色代码: %s)\n", userName, role))
if len(userCtx.UserDocs.Composed) > 0 {
sb.WriteString(fmt.Sprintf("• **负责编制文档**:%s (具备在线编辑与提审权限)\n", strings.Join(userCtx.UserDocs.Composed, "、")))
}
if len(userCtx.UserDocs.Reviewed) > 0 {
sb.WriteString(fmt.Sprintf("• **负责审核文档**:%s (具备审查批注、审核通过与驳回退回权限)\n", strings.Join(userCtx.UserDocs.Reviewed, "、")))
}
if len(userCtx.UserDocs.Approved) > 0 {
sb.WriteString(fmt.Sprintf("• **负责批准文档**:%s (具备终审批准受控生效与终审驳回权限)\n", strings.Join(userCtx.UserDocs.Approved, "、")))
}
if len(userCtx.UserDocs.Claimed) > 0 {
sb.WriteString(fmt.Sprintf("• **当前已认领文档**:%s\n", strings.Join(userCtx.UserDocs.Claimed, "、")))
}
sb.WriteString("\n根据 ISO 13485 质量管理规范,您的所有审核、批注与批准行为均将记入不可篡改的系统审计日志。")
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
UpdatedContext: userCtx,
}, nil
}
// parseDecimalID accepts only a non-negative decimal integer.
+312
View File
@@ -0,0 +1,312 @@
package coordinator
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"chat-agent-system/internal/workspace"
"strconv"
"strings"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/maturity"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/stream"
)
// handleDeclineExpectation 处理用户暂缓或取消上轮建议 (分支 1 放弃)
func (e *CoordinatorEngine) handleDeclineExpectation(userCtx *UserProjectContext) (*CoordinationResult, error) {
projName := userCtx.Project.Name
if projName == "" {
projName = "当前项目"
}
userCtx.applyPending(nil)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("cancelled")
}
reply := fmt.Sprintf("收到,听您的!那我们先不急着推进。\n针对「%s」,您手头还有哪些细节、技术顾虑或商务考量想先聊聊?我随时待命为您解答。", projName)
return &CoordinationResult{
Status: StatusAccepted,
Intent: intent.IntentDeclineExpectation,
Branch: intent.BranchExpectationHit,
NaturalReply: reply,
UpdatedContext: userCtx,
ClearExpectation: true,
}, nil
}
func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
factKey := cand.FilledSlots[intent.SlotFactKey]
factVal := cand.FilledSlots[intent.SlotFactValue]
rawDoc := cand.FilledSlots[intent.SlotSupplementDoc]
if userCtx.BlackboardFacts == nil {
userCtx.BlackboardFacts = make(map[string]string)
}
summaryKey := ""
if factKey != "" && factVal != "" {
userCtx.applyBlackboard(factKey, factVal)
summaryKey = fmt.Sprintf("「%s」已更新为「%s」", factKey, factVal)
if factKey == "target_market" {
userCtx.Project.TargetMarket = factVal
}
} else {
summaryKey = "已补充新的背景信息"
}
streamer := stream.FromContext(context.Background())
if streamer != nil {
streamer.EmitStatus(string(cand.Intent), string(cand.Branch), string(userCtx.Project.CurrentStage), fmt.Sprintf("已吸收最新事实设定:%s", summaryKey), cand.FilledSlots)
}
// 持久化到 blackboard.json
if userCtx.WorkDir != "" {
bbPath := filepath.Join(userCtx.WorkDir, "blackboard.json")
var bb squad.BlackboardState
if data, err := os.ReadFile(bbPath); err == nil {
_ = json.Unmarshal(data, &bb)
}
if bb.Fields == nil {
bb.Fields = make(map[string]string)
}
for k, v := range userCtx.BlackboardFacts {
bb.Fields[k] = v
}
if b, err := json.MarshalIndent(bb, "", " "); err == nil {
_ = os.WriteFile(bbPath, b, 0o644)
}
}
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已收到并为您记录最新信息:%s。\n", summaryKey))
sb.WriteString("这些事实已沉淀至项目黑板,后续论证与核价将直接吸收这些设定。\n\n")
sb.WriteString(fmt.Sprintf("针对「%s」,我为您梳理了接下来的推进选项:\n", userCtx.Project.Name))
for idx, opt := range nextExp.Options {
recMark := ""
if idx == 0 {
recMark = "(推荐,可直接回复“好”或“1”)"
}
sb.WriteString(fmt.Sprintf("[%s] %s %s\n", opt.OptionID, opt.Title, recMark))
}
if rawDoc != "" && summaryKey == "已补充新的背景信息" {
sb.Reset()
sb.WriteString("已收录补充材料,并关联到项目事实黑板。\n后续文档修订与阶段推进时,我会自动引用这部分事实。")
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: userCtx.LatestAdvisorBrief,
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
if e.instSvc != nil && e.instSvc.Available() && userCtx != nil && userCtx.Project.ID != "" {
projectID, parseErr := strconv.ParseInt(userCtx.Project.ID, 10, 64)
userID, userErr := strconv.ParseInt(userCtx.User.ID, 10, 64)
if parseErr == nil && userErr == nil && projectID > 0 && userID > 0 {
isCreator := userCtx.Project.CreatorUserID > 0 && userID == userCtx.Project.CreatorUserID
settings, settingsErr := e.instSvc.GetSettings(ctx, projectID, userID, isCreator)
hasLiveGovernance := settingsErr == nil && settings != nil && settings.LiveInstance != nil &&
settings.LiveInstance.Status != projectinstance.InstanceCompleted &&
settings.LiveInstance.Status != "terminated"
if hasLiveGovernance {
if !isCreator {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: projectinstance.ErrForbiddenNotCreator.Error(),
NaturalReply: "只有项目创建者可以确认推进工作流阶段。",
UpdatedContext: userCtx,
}, projectinstance.ErrForbiddenNotCreator
}
res, _, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID)
if advErr != nil {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: advErr.Error(),
NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr),
UpdatedContext: userCtx,
}, advErr
}
return res, nil
}
if isCreator {
if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled {
if advErr != nil {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: advErr.Error(),
NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr),
UpdatedContext: userCtx,
}, advErr
}
return res, nil
}
}
}
}
targetStageStr := cand.FilledSlots[intent.SlotTargetStage]
targetStage := maturity.MaturityStage(targetStageStr)
if targetStage == "" {
targetStage = maturity.StageV05_Feasibility
}
streamer := stream.FromContext(ctx)
if streamer != nil {
streamer.EmitPhase("stage_transition", fmt.Sprintf("准备推进项目成熟度阶段至【%s】...", targetStage), "coordinator", 0, 0, 0)
}
// 1. 若携带补丁槽位 (例如 "好,但目标市场改成欧洲CE"),先行持久化更新
patchSummary := ""
if len(cand.PatchSlots) > 0 {
var patches []string
for k, v := range cand.PatchSlots {
userCtx.applyBlackboard(k, v)
patches = append(patches, fmt.Sprintf("%s=%s", k, v))
if k == "target_market" {
userCtx.Project.TargetMarket = v
}
}
patchSummary = strings.Join(patches, ", ")
if userCtx.WorkDir != "" {
bbPath := filepath.Join(userCtx.WorkDir, "blackboard.json")
var bb squad.BlackboardState
if data, err := os.ReadFile(bbPath); err == nil {
_ = json.Unmarshal(data, &bb)
}
if bb.Fields == nil {
bb.Fields = make(map[string]string)
}
for k, v := range cand.PatchSlots {
bb.Fields[k] = v
}
if b, err := json.MarshalIndent(bb, "", " "); err == nil {
_ = os.WriteFile(bbPath, b, 0o644)
}
}
}
// 2. 状态机合法性检查
if userCtx.Project.CurrentStage == targetStage {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "ALREADY_IN_STAGE",
NaturalReply: fmt.Sprintf("项目目前已经在【%s】阶段中,无需重复推进。您可以吩咐我进行详细论证或查询当前进展。", targetStage),
UpdatedContext: userCtx,
}, nil
}
if e.tasks() == nil {
userCtx.Project.CurrentStage = targetStage
userCtx.AvailableActions = ComputeAvailableActions(targetStage, userCtx.User.ProjectRole)
nextExp := BuildNextExpectation(targetStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
reply := fmt.Sprintf("已为您模拟推进到阶段【%s】。", targetStage)
if patchSummary != "" {
reply = fmt.Sprintf("已为您吸收最新设定(%s),并模拟推进到阶段【%s】。", patchSummary, targetStage)
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
// 3. 调用底层 L3 调度器执行阶段跃迁
stageRes, err := e.tasks().ExecuteStage(ctx, stageflow.StageRunRequest{
Stage: targetStage,
ProjectName: userCtx.Project.Name,
CustomerName: userCtx.Project.CustomerName,
DeviceType: userCtx.Project.DeviceType,
IntendedUse: userCtx.Project.IntendedUse,
TargetMarket: userCtx.Project.TargetMarket,
Fields: userCtx.BlackboardFacts,
WorkDir: userCtx.WorkDir,
})
if err != nil {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: fmt.Sprintf("阶段推进失败: %v", err),
UpdatedContext: userCtx,
}, err
}
userCtx.Project.CurrentStage = targetStage
userCtx.LatestAdvisorBrief = stageRes.AdvisorBrief
userCtx.AvailableActions = ComputeAvailableActions(targetStage, userCtx.User.ProjectRole)
if stageRes.WorkDir != "" {
userCtx.WorkDir = stageRes.WorkDir
}
artifactPath := ""
if stageRes.PrimaryArtifact != nil {
artifactPath = filepath.Join(stageRes.PrimaryArtifact.WorkDir, workspace.DeliverableKey)
}
nextExp := BuildNextExpectation(targetStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
var sb strings.Builder
if patchSummary != "" {
sb.WriteString(fmt.Sprintf("已为您吸收最新设定(%s),并成功推进并完成了【%s】!\n", patchSummary, stageRes.StageTitle))
} else {
sb.WriteString(fmt.Sprintf("已为您成功推进并完成了【%s】!\n", stageRes.StageTitle))
}
sb.WriteString("相关论证与文档已沉淀完成。\n\n")
sb.WriteString(fmt.Sprintf("💡 最新进展:%s\n", stageRes.AdvisorBrief.MaturityHeadline))
sb.WriteString(fmt.Sprintf("建议您审阅交付文档(%s),或按下方建议推进后续洽谈。\n\n", artifactPath))
sb.WriteString("可选下一步:\n")
for idx, opt := range nextExp.Options {
recMark := ""
if idx == 0 {
recMark = "(推荐,可直接回复“好”或“1”)"
}
sb.WriteString(fmt.Sprintf("[%s] %s %s\n", opt.OptionID, opt.Title, recMark))
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: stageRes.AdvisorBrief,
ArtifactPath: artifactPath,
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
+313
View File
@@ -0,0 +1,313 @@
package coordinator
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowscope"
"chat-agent-system/internal/workspace"
)
func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
// 跨工作流作用域:识别器已打 mismatch 标记时直接引导切换,不执行任务。
if cand != nil && cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
active := ""
if userCtx != nil {
active = userCtx.Project.ActiveWorkflowKey
}
guidance := cand.ClarificationQuestion
if guidance == "" {
workflows := []string{}
if v := cand.FilledSlots[intent.SlotMismatchedWorkflow]; v != "" {
workflows = strings.Split(v, ",")
}
guidance = intent.WorkflowScopeGuidanceQuestion(cand.FilledSlots[intent.SlotMismatchedDoc], workflows, active)
}
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: cand.Intent,
Branch: cand.Branch,
ClarificationPrompt: guidance,
NaturalReply: guidance,
UpdatedContext: userCtx,
}, nil
}
taskKey := cand.FilledSlots[intent.SlotTargetTask]
if taskKey == "" {
taskKey = "intake"
}
// 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。
if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" {
owner := intent.TaskKeyWorkflowOwner(taskKey)
if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) {
guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey)
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: cand.Intent,
Branch: cand.Branch,
ClarificationPrompt: guidance,
NaturalReply: guidance,
UpdatedContext: userCtx,
}, nil
}
}
// 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信 → 先确认,不盲目执行。
if gated := e.confirmGate(ctx, cand, userCtx, taskKey); gated != nil {
return gated, nil
}
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
if e.tasks() == nil {
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已收到生成【%s】的指令。", taskKey),
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
var projectID, userID int64
if userCtx.Project.ID != "" {
projectID = parseDecimalID(userCtx.Project.ID)
}
if userCtx.User.ID != "" {
userID = parseDecimalID(userCtx.User.ID)
}
taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{
TaskKey: taskKey,
ProjectName: userCtx.Project.Name,
CustomerName: userCtx.Project.CustomerName,
DeviceType: userCtx.Project.DeviceType,
IntendedUse: userCtx.Project.IntendedUse,
TargetMarket: userCtx.Project.TargetMarket,
Fields: userCtx.BlackboardFacts,
ProjectID: projectID,
UserID: userID,
})
if err != nil {
if errors.Is(err, docgov.ErrDocumentBusy) {
msg := "该文档正在由项目助手生成或他人编辑中,请稍后再发起更新。您可以先继续对话或处理其他文档。"
if taskRes != nil && taskRes.ErrorReason != "" {
msg = taskRes.ErrorReason + "。您可以先继续对话或处理其他文档。"
}
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: msg,
UpdatedContext: userCtx,
}, nil
}
if errors.Is(err, docgov.ErrDocumentLocked) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: "该文档已进入审核/批准锁定状态,无法自动覆写正文。如需修订请走驳回解锁或变更提案流程。",
UpdatedContext: userCtx,
}, nil
}
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: fmt.Sprintf("任务执行失败: %v", err),
UpdatedContext: userCtx,
}, err
}
userCtx.LatestAdvisorBrief = taskRes.AdvisorBrief
outPath := filepath.Join(taskRes.WorkDir, workspace.DeliverableKey)
reply, taskStatus := formatTriggerTaskReply(taskRes, outPath)
res := &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
AdvisorBrief: taskRes.AdvisorBrief,
ArtifactPath: outPath,
UpdatedContext: userCtx,
NextExpectation: nextExp,
TaskStatus: taskStatus,
TaskKey: taskRes.TaskKey,
ArtifactTitle: taskRes.ArtifactTitle,
}
attachTaskRunProjection(res, taskRes)
return res, nil
}
// formatTriggerTaskReply builds sync coordinator copy for a TaskRunResult.
// Pending high-risk candidates must not claim "正文已保存 / 已完成".
func formatTriggerTaskReply(taskRes *stageflow.TaskRunResult, outPath string) (reply string, taskStatus string) {
if taskRes == nil {
return "任务执行失败。", stageflow.TaskStatusFailed
}
taskStatus = taskRes.TerminalStatus()
if taskRes.AwaitsAgentConfirmation() {
return taskRes.CompletionSummary(), taskStatus
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已为您自适应规划并完成生成【%s】!\n交付物正文已保存至:%s\n耗时: %v\n",
taskRes.ArtifactTitle, outPath, taskRes.Duration))
if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 {
sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n")
for _, a := range taskRes.AdvisorBrief.BaselineAssumptions {
sb.WriteString(fmt.Sprintf(" • %s\n", a))
}
}
return sb.String(), taskStatus
}
func attachTaskRunProjection(res *CoordinationResult, taskRes *stageflow.TaskRunResult) {
if res == nil || taskRes == nil {
return
}
if taskRes.DocRecord != nil {
res.DocID = taskRes.DocRecord.DocID
}
if taskRes.AwaitsAgentConfirmation() {
res.PendingAgentCandidateID = taskRes.PendingAgentCandidateID
res.PendingChangeReportID = taskRes.PendingChangeReportID
res.PendingRiskLevel = taskRes.PendingRiskLevel
}
}
// handleTriggerAdhocTask 处理自由任务升级确认(路由层 R1 升级建议的兑现路径):
// 基于任务简报组装特设小队,走 planAutonomousAdhoc 自主规划 + TaskGov 全量治理
// (预算账本 / 执行轨迹 / 重复识别 / 归档锁定 / 质量门禁修复闭环)。
func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) {
brief := cand.FilledSlots[intent.SlotTaskBrief]
if brief == "" {
brief = rawInput
}
title := deriveAdhocTitle(brief)
taskKey := sanitizeAdhocTaskKey(title)
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
if e.tasks() == nil {
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已收到自由任务《%s》的升级指令(当前环境未接入小队服务,简报已登记)。\n任务简报: %s", title, brief),
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
var projectID, userID int64
if userCtx.Project.ID != "" {
projectID = parseDecimalID(userCtx.Project.ID)
}
if userCtx.User.ID != "" {
userID = parseDecimalID(userCtx.User.ID)
}
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("squad_assembly", fmt.Sprintf("正在为自由任务《%s》组建特设智能体小队(动态规划 + 预算管控 + 归档锁定)...", title), "leader", 0, 0, 5)
}
// 任务简报材料化:作为首要输入材料注入工作区(context/materials_text/task_brief.md),
// 供 organizer 专员在容器内直接读到用户原始诉求(否则首步将报"无材料可整理")
briefMaterial := stageflow.MaterialInput{
Filename: "task_brief.md",
Content: []byte(fmt.Sprintf("# 任务简报(用户原始诉求,权威等级:primary_evidence)\n\n%s\n", brief)),
}
taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{
TaskKey: taskKey,
Title: title,
ProjectName: userCtx.Project.Name,
CustomerName: userCtx.Project.CustomerName,
DeviceType: userCtx.Project.DeviceType,
IntendedUse: userCtx.Project.IntendedUse,
TargetMarket: userCtx.Project.TargetMarket,
Fields: userCtx.BlackboardFacts,
Materials: []stageflow.MaterialInput{briefMaterial},
AutoRepair: true,
ProjectID: projectID,
UserID: userID,
})
if err != nil {
if taskRes != nil && taskRes.WorkDir != "" {
userCtx.WorkDir = taskRes.WorkDir
}
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: fmt.Sprintf("自由任务《%s》执行失败: %v", title, err),
UpdatedContext: userCtx,
}, err
}
userCtx.LatestAdvisorBrief = taskRes.AdvisorBrief
if taskRes.WorkDir != "" {
userCtx.WorkDir = taskRes.WorkDir
}
outPath := filepath.Join(taskRes.WorkDir, workspace.DeliverableKey)
var sb strings.Builder
taskStatus := taskRes.TerminalStatus()
if taskRes.AwaitsAgentConfirmation() {
sb.WriteString(taskRes.CompletionSummary())
} else {
sb.WriteString(fmt.Sprintf("已为您组建特设小队并完成自由任务《%s》!\n任务简报: %s\n交付物正文已保存至:%s\n耗时: %v\n",
title, brief, outPath, taskRes.Duration))
if taskRes.QualityReport != nil {
verdict := "⚠️ 未完全通过"
if taskRes.QualityReport.Passed {
verdict = "✅ 通过"
}
sb.WriteString(fmt.Sprintf("质量门禁: %s(评分 %.0f)\n", verdict, taskRes.QualityReport.Score))
}
if gov := governanceSummary(taskRes.WorkDir); gov != "" {
sb.WriteString("治理审计: " + gov + "\n")
}
if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 {
sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n")
for _, a := range taskRes.AdvisorBrief.BaselineAssumptions {
sb.WriteString(fmt.Sprintf(" • %s\n", a))
}
}
}
res := &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: taskRes.AdvisorBrief,
ArtifactPath: outPath,
UpdatedContext: userCtx,
NextExpectation: nextExp,
TaskStatus: taskStatus,
TaskKey: taskRes.TaskKey,
ArtifactTitle: taskRes.ArtifactTitle,
}
attachTaskRunProjection(res, taskRes)
return res, nil
}
+19
View File
@@ -0,0 +1,19 @@
package coordinator
import (
"strconv"
"strings"
)
// Values like u_sess_* must not become UserID 0 via a partial Sscanf match.
func parseDecimalID(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
return 0
}
return n
}
+3
View File
@@ -147,6 +147,8 @@ func (m *ContextManager) RecordTurnMemoryWithVisibility(
if err != nil {
return 0, fmt.Errorf("insert chat memory chunk to db failed: %w", err)
}
// C1 search cache keys include query text; drop the layer so new chunks are visible.
m.Cache().InvalidateLayer(LayerC1Search)
return chunkID, nil
}
@@ -184,6 +186,7 @@ func (m *ContextManager) RecordTurnMemoryWithVisibility(
m.memoryChunks = append(m.memoryChunks, item)
}
m.Cache().InvalidateLayer(LayerC1Search)
return chunkID, nil
}
+4
View File
@@ -42,6 +42,10 @@ type ContextManager struct {
nextActivityID int64
nextMessageID int64
nextChunkID int64
// message scope column probe (information_schema); cached once per manager
messageScopeOnce sync.Once
messageScopeOK bool
}
// NewContextManager 创建上下文管理器
+11 -8
View File
@@ -25,14 +25,17 @@ func (m *ContextManager) hasMessageScopeColumns(ctx context.Context) bool {
if m == nil || m.pool == nil {
return false
}
var ok bool
_ = m.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'chat_messages' AND column_name = 'scope_kind'
)
`).Scan(&ok)
return ok
m.messageScopeOnce.Do(func() {
var ok bool
_ = m.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'chat_messages' AND column_name = 'scope_kind'
)
`).Scan(&ok)
m.messageScopeOK = ok
})
return m.messageScopeOK
}
func (m *ContextManager) chatMessageSelectColumns(ctx context.Context) string {
+6 -4
View File
@@ -1,6 +1,8 @@
package docduty
package docduty_test
import (
"chat-agent-system/internal/docduty"
"chat-agent-system/internal/docgov"
"context"
"fmt"
"testing"
@@ -52,7 +54,7 @@ func TestHealProjectDocuments_SkipsDutyLocked_PG(t *testing.T) {
// 未锁定文档:错误槽位,应被 heal 纠正
insertDutyDoc(t, ctx, pool, projectID, openDoc, "research", authorID, 0, 0)
_, n, err := HealProjectDocuments(ctx, pool, projectID)
_, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatalf("heal: %v", err)
}
@@ -109,8 +111,8 @@ func TestApplySlotRevocations_SkipsDutyLocked_PG(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{
UserID: reviewerID, PreviousDuty: DutyReviewer, NextDuty: DutyNone,
if err := docgov.ClearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
UserID: reviewerID, PreviousDuty: docduty.DutyReviewer, NextDuty: docduty.DutyNone,
})); err != nil {
_ = tx.Rollback(ctx)
t.Fatal(err)
+5 -37
View File
@@ -115,6 +115,11 @@ const projectDutyLockSQL = `SELECT pg_advisory_xact_lock($1)`
// projectDutyLockHook 仅测试使用:在申请锁之前同步,便于证明 heal 会阻塞在成员写事务之后。
var projectDutyLockHook func()
// SetProjectDutyLockHookForTest installs a pre-lock hook for PG concurrency tests.
func SetProjectDutyLockHookForTest(fn func()) {
projectDutyLockHook = fn
}
// AcquireProjectDutyLock 获取项目职责互斥锁;调用方须已 Begin,锁随事务结束释放。
func AcquireProjectDutyLock(ctx context.Context, tx pgx.Tx, projectID int64) error {
if tx == nil || projectID <= 0 {
@@ -128,40 +133,3 @@ func AcquireProjectDutyLock(ctx context.Context, tx pgx.Tx, projectID int64) err
}
return nil
}
// ApplySlotRevocations 在调用方已持有的事务内精确清空 reviewer/approver 槽。
// 不开启新事务、不加锁、不 heal;author 列永不写入。
func ApplySlotRevocations(ctx context.Context, tx pgx.Tx, projectID int64, revs []SlotRevocation) error {
if tx == nil || projectID <= 0 {
return nil
}
for _, rev := range revs {
q, ok := clearDutySlotSQL(rev.Duty)
if !ok {
continue
}
if _, err := tx.Exec(ctx, q, projectID, rev.UserID); err != nil {
return fmt.Errorf("docduty: clear %s for user %d: %w", rev.Duty, rev.UserID, err)
}
}
return nil
}
func clearDutySlotSQL(duty string) (string, bool) {
switch duty {
case DutyReviewer:
return `
UPDATE document_records
SET reviewer_id = NULL, reviewer_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2 AND NOT duty_locked
`, true
case DutyApprover:
return `
UPDATE document_records
SET approver_id = NULL, approver_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2 AND NOT duty_locked
`, true
default:
return "", false
}
}
+17 -130
View File
@@ -10,8 +10,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// dutyDB 覆盖 pool 与 tx,供同一套解析/回填 SQL 复用。
type dutyDB interface {
// Compile-time check: pool satisfies DB.
var _ DB = (*pgxpool.Pool)(nil)
// DB covers pool and tx for shared member/resolve reads.
type DB interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
@@ -22,10 +25,18 @@ func LoadProjectMembers(ctx context.Context, pool *pgxpool.Pool, projectID int64
if pool == nil || projectID <= 0 {
return nil, Holder{}, nil
}
return loadProjectMembers(ctx, pool, projectID)
return LoadProjectMembersDB(ctx, pool, projectID)
}
func loadProjectMembers(ctx context.Context, db dutyDB, projectID int64) ([]Member, Holder, error) {
// LoadProjectMembersDB reads members via pool or an open transaction.
func LoadProjectMembersDB(ctx context.Context, db DB, projectID int64) ([]Member, Holder, error) {
if db == nil || projectID <= 0 {
return nil, Holder{}, nil
}
return loadProjectMembers(ctx, db, projectID)
}
func loadProjectMembers(ctx context.Context, db DB, projectID int64) ([]Member, Holder, error) {
var creator Holder
err := db.QueryRow(ctx, `
SELECT COALESCE(p.creator_user_id, 0), COALESCE(u.name, '')
@@ -72,135 +83,11 @@ func ResolveProject(ctx context.Context, pool *pgxpool.Pool, projectID int64, re
return Resolve(members, creator, req), nil
}
func resolveProjectTx(ctx context.Context, tx pgx.Tx, projectID int64, req RequestDuties) (Binding, error) {
// ResolveProjectTx resolves project document duties inside the caller's transaction.
func ResolveProjectTx(ctx context.Context, tx pgx.Tx, projectID int64, req RequestDuties) (Binding, error) {
members, creator, err := loadProjectMembers(ctx, tx, projectID)
if err != nil {
return Binding{}, err
}
return Resolve(members, creator, req), nil
}
type storedDocDuties struct {
DocID string
Author Holder
Reviewer Holder
Approver Holder
}
// BackfillMissingDocumentDuties 按项目 assigned 职责对齐受控文档;assigned 为空的槽位不臆造、不清空。
func BackfillMissingDocumentDuties(ctx context.Context, pool *pgxpool.Pool, projectID int64, assigned Binding) (int, error) {
if pool == nil || projectID <= 0 {
return 0, nil
}
return backfillMissingDocumentDuties(ctx, pool, projectID, assigned)
}
func backfillMissingDocumentDuties(ctx context.Context, db dutyDB, projectID int64, assigned Binding) (int, error) {
members, creator, err := loadProjectMembers(ctx, db, projectID)
if err != nil {
return 0, err
}
memberIDs := ActiveMemberIDs(members, creator)
rows, err := db.Query(ctx, `
SELECT doc_id,
COALESCE(author_id, 0), COALESCE(author_name, ''),
COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''),
COALESCE(approver_id, 0), COALESCE(approver_name, '')
FROM document_records
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND NOT duty_locked
`, projectID)
if err != nil {
return 0, fmt.Errorf("docduty: list documents: %w", err)
}
defer rows.Close()
pending := make([]storedDocDuties, 0)
for rows.Next() {
var rec storedDocDuties
if err := rows.Scan(
&rec.DocID,
&rec.Author.UserID, &rec.Author.Name,
&rec.Reviewer.UserID, &rec.Reviewer.Name,
&rec.Approver.UserID, &rec.Approver.Name,
); err != nil {
return 0, err
}
current := Binding{Author: rec.Author, Reviewer: rec.Reviewer, Approver: rec.Approver}
next, changed := HealBinding(current, assigned, memberIDs)
if !changed {
continue
}
rec.Author, rec.Reviewer, rec.Approver = next.Author, next.Reviewer, next.Approver
pending = append(pending, rec)
}
if err := rows.Err(); err != nil {
return 0, err
}
updated := 0
for _, rec := range pending {
tag, err := db.Exec(ctx, `
UPDATE document_records
SET author_id = $2, author_name = $3,
reviewer_id = $4, reviewer_name = $5,
approver_id = $6, approver_name = $7,
updated_at = now()
WHERE doc_id = $1 AND project_id = $8
`, rec.DocID, nullIfZero(rec.Author.UserID), rec.Author.Name,
nullIfZero(rec.Reviewer.UserID), rec.Reviewer.Name,
nullIfZero(rec.Approver.UserID), rec.Approver.Name,
projectID)
if err != nil {
return updated, fmt.Errorf("docduty: backfill %s: %w", rec.DocID, err)
}
if tag.RowsAffected() > 0 {
updated++
}
}
return updated, nil
}
func nullIfZero(id int64) any {
if id <= 0 {
return nil
}
return id
}
// HealProjectDocuments 在同一事务内:项目 advisory lock、解析成员职责、读写 document_records。
// 锁键/顺序与成员写事务一致。第二次调用应为 0 行。错误回滚,不得被调用方静默吞掉。
func HealProjectDocuments(ctx context.Context, pool *pgxpool.Pool, projectID int64) (Binding, int, error) {
if pool == nil || projectID <= 0 {
return Binding{}, 0, nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return Binding{}, 0, fmt.Errorf("docduty: begin heal: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
return Binding{}, 0, err
}
assigned, n, err := healProjectDocumentsTx(ctx, tx, projectID)
if err != nil {
return Binding{}, 0, err
}
if err := tx.Commit(ctx); err != nil {
return Binding{}, 0, fmt.Errorf("docduty: commit heal: %w", err)
}
return assigned, n, nil
}
func healProjectDocumentsTx(ctx context.Context, tx pgx.Tx, projectID int64) (Binding, int, error) {
assigned, err := resolveProjectTx(ctx, tx, projectID, RequestDuties{})
if err != nil {
return Binding{}, 0, err
}
n, err := backfillMissingDocumentDuties(ctx, tx, projectID, assigned)
if err != nil {
return Binding{}, 0, err
}
return assigned, n, nil
}
+43 -34
View File
@@ -1,6 +1,8 @@
package docduty
package docduty_test
import (
"chat-agent-system/internal/docduty"
"chat-agent-system/internal/docgov"
"context"
"fmt"
"runtime"
@@ -77,7 +79,7 @@ func TestProjectReviewerDutySync_PG(t *testing.T) {
t.Fatalf("insert members: %v", err)
}
bound, err := ResolveProject(ctx, pool, projectID, RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"})
bound, err := docduty.ResolveProject(ctx, pool, projectID, docduty.RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"})
if err != nil {
t.Fatal(err)
}
@@ -93,7 +95,7 @@ func TestProjectReviewerDutySync_PG(t *testing.T) {
insertDutyDoc(t, ctx, pool, projectID, intakeID, "intake", authorID, placeholderID, wrongApproverID)
insertDutyDoc(t, ctx, pool, projectID, secondID, "prelim_design", authorID, placeholderID, wrongApproverID)
assigned, n, err := HealProjectDocuments(ctx, pool, projectID)
assigned, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatal(err)
}
@@ -109,7 +111,7 @@ func TestProjectReviewerDutySync_PG(t *testing.T) {
assertDocApprover(t, ctx, pool, secondID, assignedApproverID)
assertDocAuthorName(t, ctx, pool, intakeID, "用户author")
_, n2, err := HealProjectDocuments(ctx, pool, projectID)
_, n2, err := docgov.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatal(err)
}
@@ -153,7 +155,7 @@ func TestHealBinding_EmptyAssignedKeepsMember_PG(t *testing.T) {
t.Fatal(err)
}
_, n, err := HealProjectDocuments(ctx, pool, projectID)
_, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatal(err)
}
@@ -197,7 +199,7 @@ func TestHealBinding_EmptyAssignedClearsExited_PG(t *testing.T) {
t.Fatal(err)
}
_, n, err := HealProjectDocuments(ctx, pool, projectID)
_, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatal(err)
}
@@ -248,8 +250,8 @@ func TestApplySlotRevocations_SameTxnCommitAndRollback_PG(t *testing.T) {
_ = tx.Rollback(ctx)
t.Fatal(err)
}
if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{
UserID: reviewerA, PreviousDuty: DutyReviewer, NextDuty: DutyNone,
if err := docgov.ClearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
UserID: reviewerA, PreviousDuty: docduty.DutyReviewer, NextDuty: docduty.DutyNone,
})); err != nil {
_ = tx.Rollback(ctx)
t.Fatal(err)
@@ -258,23 +260,23 @@ func TestApplySlotRevocations_SameTxnCommitAndRollback_PG(t *testing.T) {
t.Fatal(err)
}
assertDocReviewer(t, ctx, pool, docID, reviewerA)
assertMemberDuty(t, ctx, pool, projectID, reviewerA, DutyReviewer)
assertMemberDuty(t, ctx, pool, projectID, reviewerA, docduty.DutyReviewer)
assertDocAuthorID(t, ctx, pool, docID, authorID)
tx, err = pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if err := AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
_ = tx.Rollback(ctx)
t.Fatal(err)
}
prev, err := LoadMemberDutyTx(ctx, tx, projectID, reviewerA)
prev, err := docduty.LoadMemberDutyTx(ctx, tx, projectID, reviewerA)
if err != nil {
_ = tx.Rollback(ctx)
t.Fatal(err)
}
if prev != DutyReviewer {
if prev != docduty.DutyReviewer {
_ = tx.Rollback(ctx)
t.Fatalf("locked previous duty=%q", prev)
}
@@ -284,8 +286,8 @@ func TestApplySlotRevocations_SameTxnCommitAndRollback_PG(t *testing.T) {
_ = tx.Rollback(ctx)
t.Fatal(err)
}
if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{
UserID: reviewerA, PreviousDuty: prev, NextDuty: DutyNone,
if err := docgov.ClearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
UserID: reviewerA, PreviousDuty: prev, NextDuty: docduty.DutyNone,
})); err != nil {
_ = tx.Rollback(ctx)
t.Fatal(err)
@@ -294,7 +296,7 @@ func TestApplySlotRevocations_SameTxnCommitAndRollback_PG(t *testing.T) {
t.Fatal(err)
}
assertDocReviewer(t, ctx, pool, docID, 0)
assertMemberDuty(t, ctx, pool, projectID, reviewerA, DutyNone)
assertMemberDuty(t, ctx, pool, projectID, reviewerA, docduty.DutyNone)
assertDocAuthorID(t, ctx, pool, docID, authorID)
}
@@ -328,17 +330,17 @@ func TestOwnerAuthorBackfill_MultiOwnerDeterministic_PG(t *testing.T) {
t.Fatalf("insert owners: %v", err)
}
if _, err := pool.Exec(ctx, OwnerAuthorBackfillOneProjectSQL, projectID); err != nil {
if _, err := pool.Exec(ctx, docduty.OwnerAuthorBackfillOneProjectSQL, projectID); err != nil {
t.Fatalf("owner author backfill: %v", err)
}
assertMemberDuty(t, ctx, pool, projectID, creatorOwner, DutyAuthor)
assertMemberDuty(t, ctx, pool, projectID, lowOwner, DutyNone)
assertMemberDuty(t, ctx, pool, projectID, creatorOwner, docduty.DutyAuthor)
assertMemberDuty(t, ctx, pool, projectID, lowOwner, docduty.DutyNone)
if _, err := pool.Exec(ctx, OwnerAuthorBackfillOneProjectSQL, projectID); err != nil {
if _, err := pool.Exec(ctx, docduty.OwnerAuthorBackfillOneProjectSQL, projectID); err != nil {
t.Fatalf("second backfill: %v", err)
}
assertMemberDuty(t, ctx, pool, projectID, creatorOwner, DutyAuthor)
assertMemberDuty(t, ctx, pool, projectID, lowOwner, DutyNone)
assertMemberDuty(t, ctx, pool, projectID, creatorOwner, docduty.DutyAuthor)
assertMemberDuty(t, ctx, pool, projectID, lowOwner, docduty.DutyNone)
}
func TestHealProjectDocuments_TransferDoesNotResurrectOldReviewer_PG(t *testing.T) {
@@ -357,7 +359,7 @@ func TestHealProjectDocuments_TransferDoesNotResurrectOldReviewer_PG(t *testing.
t.Fatalf("insert project: %v", err)
}
t.Cleanup(func() {
projectDutyLockHook = nil
docduty.SetProjectDutyLockHookForTest(nil)
_, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
@@ -379,42 +381,42 @@ func TestHealProjectDocuments_TransferDoesNotResurrectOldReviewer_PG(t *testing.
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, `
UPDATE project_members SET document_duty = '', updated_at = now()
WHERE project_id = $1 AND document_duty = $2 AND user_id <> $3 AND status = $4
`, projectID, DutyReviewer, reviewerB, StatusActive); err != nil {
`, projectID, docduty.DutyReviewer, reviewerB, docduty.StatusActive); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, `
UPDATE project_members SET document_duty = $3, updated_at = now()
WHERE project_id = $1 AND user_id = $2
`, projectID, reviewerB, DutyReviewer); err != nil {
`, projectID, reviewerB, docduty.DutyReviewer); err != nil {
t.Fatal(err)
}
if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{
UserID: reviewerB, PreviousDuty: DutyNone, NextDuty: DutyReviewer,
PriorHolderIDs: map[string]int64{DutyReviewer: reviewerA},
if err := docgov.ClearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
UserID: reviewerB, PreviousDuty: docduty.DutyNone, NextDuty: docduty.DutyReviewer,
PriorHolderIDs: map[string]int64{docduty.DutyReviewer: reviewerA},
})); err != nil {
t.Fatal(err)
}
aboutToLock := make(chan struct{})
projectDutyLockHook = func() {
docduty.SetProjectDutyLockHookForTest(func() {
close(aboutToLock)
projectDutyLockHook = nil
}
docduty.SetProjectDutyLockHookForTest(nil)
})
type healResult struct {
assigned Binding
assigned docduty.Binding
n int
err error
}
done := make(chan healResult, 1)
go func() {
assigned, n, err := HealProjectDocuments(ctx, pool, projectID)
assigned, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
done <- healResult{assigned: assigned, n: n, err: err}
}()
@@ -443,7 +445,7 @@ func TestHealProjectDocuments_TransferDoesNotResurrectOldReviewer_PG(t *testing.
t.Fatal("old reviewer A must not be assigned after transfer")
}
_, n2, err := HealProjectDocuments(ctx, pool, projectID)
_, n2, err := docgov.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
t.Fatal(err)
}
@@ -567,3 +569,10 @@ func assertMemberDuty(t *testing.T, ctx context.Context, pool *pgxpool.Pool, pro
t.Fatalf("user %d document_duty=%q want %q", userID, got, want)
}
}
func nullIfZero(id int64) any {
if id <= 0 {
return nil
}
return id
}
@@ -1,12 +1,14 @@
package docduty
package docgov
import (
"strings"
"testing"
"chat-agent-system/internal/docduty"
)
func TestClearDutySlotSQL_SkipsLockedRows(t *testing.T) {
for _, duty := range []string{DutyReviewer, DutyApprover} {
for _, duty := range []string{docduty.DutyReviewer, docduty.DutyApprover} {
sql, ok := clearDutySlotSQL(duty)
if !ok {
t.Fatalf("%s: expected sql", duty)
+194
View File
@@ -0,0 +1,194 @@
package docgov
import (
"context"
"fmt"
"chat-agent-system/internal/docduty"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ClearDutySlots clears reviewer/approver columns on document_records for the given revocations.
// Author columns are never written. Skips duty_locked rows.
func ClearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
if tx == nil || projectID <= 0 {
return nil
}
for _, rev := range revs {
q, ok := clearDutySlotSQL(rev.Duty)
if !ok {
continue
}
if _, err := tx.Exec(ctx, q, projectID, rev.UserID); err != nil {
return fmt.Errorf("docgov: clear %s for user %d: %w", rev.Duty, rev.UserID, err)
}
}
return nil
}
func clearDutySlotSQL(duty string) (string, bool) {
switch duty {
case docduty.DutyReviewer:
return `
UPDATE document_records
SET reviewer_id = NULL, reviewer_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2 AND NOT duty_locked
`, true
case docduty.DutyApprover:
return `
UPDATE document_records
SET approver_id = NULL, approver_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2 AND NOT duty_locked
`, true
default:
return "", false
}
}
// DutySlotWriter adapts ClearDutySlots for authz injection (authz must not import docgov).
type DutySlotWriter struct{}
func (DutySlotWriter) ClearSlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
return ClearDutySlots(ctx, tx, projectID, revs)
}
type storedDocDuties struct {
DocID string
Author docduty.Holder
Reviewer docduty.Holder
Approver docduty.Holder
}
// BackfillDocumentDuties aligns controlled document duty columns to assigned project duties.
// Empty assigned slots are not invented or cleared.
func BackfillDocumentDuties(ctx context.Context, db docduty.DB, projectID int64, assigned docduty.Binding) (int, error) {
if db == nil || projectID <= 0 {
return 0, nil
}
members, creator, err := docduty.LoadProjectMembersDB(ctx, db, projectID)
if err != nil {
return 0, err
}
memberIDs := docduty.ActiveMemberIDs(members, creator)
rows, err := db.Query(ctx, `
SELECT doc_id,
COALESCE(author_id, 0), COALESCE(author_name, ''),
COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''),
COALESCE(approver_id, 0), COALESCE(approver_name, '')
FROM document_records
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND NOT duty_locked
`, projectID)
if err != nil {
return 0, fmt.Errorf("docgov: list documents: %w", err)
}
defer rows.Close()
pending := make([]storedDocDuties, 0)
for rows.Next() {
var rec storedDocDuties
if err := rows.Scan(
&rec.DocID,
&rec.Author.UserID, &rec.Author.Name,
&rec.Reviewer.UserID, &rec.Reviewer.Name,
&rec.Approver.UserID, &rec.Approver.Name,
); err != nil {
return 0, err
}
current := docduty.Binding{Author: rec.Author, Reviewer: rec.Reviewer, Approver: rec.Approver}
next, changed := docduty.HealBinding(current, assigned, memberIDs)
if !changed {
continue
}
rec.Author, rec.Reviewer, rec.Approver = next.Author, next.Reviewer, next.Approver
pending = append(pending, rec)
}
if err := rows.Err(); err != nil {
return 0, err
}
updated := 0
for _, rec := range pending {
tag, err := db.Exec(ctx, `
UPDATE document_records
SET author_id = $2, author_name = $3,
reviewer_id = $4, reviewer_name = $5,
approver_id = $6, approver_name = $7,
updated_at = now()
WHERE doc_id = $1 AND project_id = $8
`, rec.DocID, nullIfZeroDuty(rec.Author.UserID), rec.Author.Name,
nullIfZeroDuty(rec.Reviewer.UserID), rec.Reviewer.Name,
nullIfZeroDuty(rec.Approver.UserID), rec.Approver.Name,
projectID)
if err != nil {
return updated, fmt.Errorf("docgov: backfill %s: %w", rec.DocID, err)
}
if tag.RowsAffected() > 0 {
updated++
}
}
return updated, nil
}
func nullIfZeroDuty(id int64) any {
if id <= 0 {
return nil
}
return id
}
// HealProjectDocuments runs project advisory lock, duty resolve, and document_records backfill in one txn.
func HealProjectDocuments(ctx context.Context, pool *pgxpool.Pool, projectID int64) (docduty.Binding, int, error) {
if pool == nil || projectID <= 0 {
return docduty.Binding{}, 0, nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return docduty.Binding{}, 0, fmt.Errorf("docgov: begin heal: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
return docduty.Binding{}, 0, err
}
assigned, n, err := healProjectDocumentsTx(ctx, tx, projectID)
if err != nil {
return docduty.Binding{}, 0, err
}
if err := tx.Commit(ctx); err != nil {
return docduty.Binding{}, 0, fmt.Errorf("docgov: commit heal: %w", err)
}
return assigned, n, nil
}
func healProjectDocumentsTx(ctx context.Context, tx pgx.Tx, projectID int64) (docduty.Binding, int, error) {
assigned, err := docduty.ResolveProjectTx(ctx, tx, projectID, docduty.RequestDuties{})
if err != nil {
return docduty.Binding{}, 0, err
}
n, err := (Repository{}).BackfillDuties(ctx, tx, projectID, assigned)
if err != nil {
return docduty.Binding{}, 0, err
}
return assigned, n, nil
}
// HealProjectDocuments aligns controlled document duties for a project under the governance engine.
func (e *GovernanceEngine) HealProjectDocuments(ctx context.Context, projectID int64) (docduty.Binding, int, error) {
if e == nil {
return docduty.Binding{}, 0, nil
}
return HealProjectDocuments(ctx, e.pool, projectID)
}
// ClearDutySlots is the repository write port for reviewer/approver slot revocation.
func (Repository) ClearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
return ClearDutySlots(ctx, tx, projectID, revs)
}
// BackfillDuties is the repository write port for duty column healing.
func (Repository) BackfillDuties(ctx context.Context, tx pgx.Tx, projectID int64, assigned docduty.Binding) (int, error) {
return BackfillDocumentDuties(ctx, tx, projectID, assigned)
}
+28 -8
View File
@@ -117,7 +117,29 @@ func TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK(t *testing.T) {
engine := docgov.NewGovernanceEngine(pool)
testDocID := fmt.Sprintf("doc_opt_duty_%d", time.Now().UnixNano())
projectID := time.Now().UnixNano()%900000 + 100000
suffix := time.Now().UnixNano()
var authorID int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (account_id, name, role)
VALUES ($1, $2, 'engineer')
RETURNING id
`, fmt.Sprintf("opt_duty_%d", suffix), "系统管理员").Scan(&authorID); err != nil {
t.Fatalf("insert author: %v", err)
}
var projectID int64
if err := pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id)
VALUES ($1, $2, 'active', $3)
RETURNING id
`, fmt.Sprintf("opt-duty-%d", suffix), fmt.Sprintf("optional duties %d", suffix), authorID).Scan(&projectID); err != nil {
t.Fatalf("insert project: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE doc_id = $1`, testDocID)
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, authorID)
})
doc, err := engine.CreateOrGetDocument(
ctx,
@@ -127,7 +149,7 @@ func TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK(t *testing.T) {
"可选职责空审核人",
"V0.5_feasibility",
"",
9, "系统管理员",
authorID, "系统管理员",
0, "",
0, "",
)
@@ -139,22 +161,20 @@ func TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK(t *testing.T) {
}
var reviewerNull, approverNull bool
var authorID int64
var gotAuthor int64
err = pool.QueryRow(ctx, `
SELECT author_id,
reviewer_id IS NULL,
approver_id IS NULL
FROM document_records WHERE doc_id = $1
`, testDocID).Scan(&authorID, &reviewerNull, &approverNull)
`, testDocID).Scan(&gotAuthor, &reviewerNull, &approverNull)
if err != nil {
t.Fatalf("query document_records: %v", err)
}
if authorID != 9 {
t.Fatalf("author_id=%d want 9", authorID)
if gotAuthor != authorID {
t.Fatalf("author_id=%d want %d", gotAuthor, authorID)
}
if !reviewerNull || !approverNull {
t.Fatalf("expected NULL reviewer/approver, got reviewerNull=%v approverNull=%v", reviewerNull, approverNull)
}
_, _ = pool.Exec(ctx, `DELETE FROM document_records WHERE doc_id = $1`, testDocID)
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"chat-agent-system/internal/doccontent"
"chat-agent-system/internal/fsutil"
"chat-agent-system/internal/workspace"
)
// LeaseAcquireRequest 申请编辑租约
@@ -412,7 +413,7 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS
workDir := doc.WorkDir
if workDir != "" {
outPath := filepath.Join(workDir, "output.md")
outPath := filepath.Join(workDir, workspace.DeliverableKey)
if werr := fsutil.WriteFileAtomic(outPath, []byte(canon), 0o644); werr != nil {
return nil, fmt.Errorf("docgov: write content failed: %w", werr)
}
+4 -5
View File
@@ -10,8 +10,7 @@ import (
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/docduty"
"chat-agent-system/internal/workspace"
)
// WorkflowDocumentSummary 项目工作流受控文档列表项。
@@ -59,7 +58,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
}
if e.pool != nil {
if _, _, err := docduty.HealProjectDocuments(ctx, e.pool, projectID); err != nil {
if _, _, err := HealProjectDocuments(ctx, e.pool, projectID); err != nil {
return nil, fmt.Errorf("heal project document duties: %w", err)
}
rows, err := e.pool.Query(ctx, `
@@ -137,7 +136,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
return collapseWorkflowDocuments(out), nil
}
// ReadDocumentContent 读取受控文档 output.md 并返回 typed ContentError。
// ReadDocumentContent 读取受控文档交付正文 并返回 typed ContentError。
// 记录不存在才是 DOCUMENT_NOT_FOUND;缺文件/空文件/哈希冲突不得伪装成 404。
func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string, repoRoot string) (*DocumentContent, error) {
doc, err := e.GetDocument(ctx, docID)
@@ -155,7 +154,7 @@ func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string
if !filepath.IsAbs(workDir) && repoRoot != "" {
workDir = filepath.Join(repoRoot, workDir)
}
path := filepath.Join(workDir, "output.md")
path := filepath.Join(workDir, workspace.DeliverableKey)
data, err := os.ReadFile(path)
if err != nil {
ce := NewContentError(ErrCodeProjectionMissing, fmt.Errorf("%w: %v", ErrContentProjectionMissing, err), true, true)
+8 -2
View File
@@ -91,6 +91,11 @@ func (Repository) UpdateState(ctx context.Context, scope uow.TxScope, rec Docume
}
func (Repository) ProjectHead(ctx context.Context, scope uow.TxScope, docID string, versionID, snapshotID int64, revisionNo int, sha string) error {
return (Repository{}).ProjectHeadFull(ctx, scope, docID, &versionID, &snapshotID, revisionNo, 0, sha)
}
// ProjectHeadFull projects ledger head pointers, optionally updating revision_schema_version when schemaVer > 0.
func (Repository) ProjectHeadFull(ctx context.Context, scope uow.TxScope, docID string, versionID, snapshotID *int64, revisionNo, schemaVer int, sha string) error {
tx, err := uow.AsPG(scope)
if err != nil {
return err
@@ -98,11 +103,12 @@ func (Repository) ProjectHead(ctx context.Context, scope uow.TxScope, docID stri
_, err = tx.Exec(ctx, `
UPDATE document_records
SET ledger_version_id=$2, head_snapshot_id=$3, head_revision_no=$4,
revision_schema_version=CASE WHEN $5 > 0 THEN $5 ELSE revision_schema_version END,
row_revision=COALESCE(row_revision,0)+1,
content_sha256=COALESCE(NULLIF($5,''), content_sha256),
content_sha256=COALESCE(NULLIF($6,''), content_sha256),
updated_at=now()
WHERE doc_id=$1`,
docID, versionID, snapshotID, revisionNo, sha)
docID, versionID, snapshotID, revisionNo, schemaVer, sha)
if err != nil {
return fmt.Errorf("docgov: project head: %w", err)
}
+58 -71
View File
@@ -1,38 +1,70 @@
package docgov
import (
"errors"
"time"
"chat-agent-system/internal/contracts/vocabulary"
)
var (
ErrDocumentNotFound = errors.New("docgov: document not found")
ErrDocumentLocked = errors.New("docgov: document is locked, direct modification prohibited")
ErrOnlyAuthorCanEdit = errors.New("docgov: only the designated author is allowed to edit or modify document content")
ErrReviewerCannotEdit = errors.New("docgov: reviewers are not allowed to edit document content, only comments and review decisions are permitted")
ErrApproverCannotEdit = errors.New("docgov: approvers are not allowed to edit document content, only comments and approval decisions are permitted")
ErrInvalidStateTransition = errors.New("docgov: invalid document lifecycle state transition")
ErrRejectReasonRequired = errors.New("docgov: rejection reason must not be empty")
ErrUnauthorizedActor = errors.New("docgov: actor is not authorized for this role or action")
ErrProposalNotFound = errors.New("docgov: change proposal not found")
ErrProposalAlreadyDecided = errors.New("docgov: change proposal has already been decided")
ErrChangeProposalPending = errors.New("docgov: document is claimed and has pending change proposal waiting for author approval")
ErrDocumentBusy = errors.New("docgov: document edit lease is held by another writer")
ErrStaleFencing = errors.New("docgov: stale lease fencing token")
ErrLeaseExpired = errors.New("docgov: document edit lease has expired")
ErrLeaseNotHeld = errors.New("docgov: caller does not hold the document edit lease")
ErrForceEditNotAllowed = errors.New("docgov: force_direct_edit requires an approved_by_author proposal and unlocked drafting document")
ErrContentProjectionMissing = errors.New("docgov: document content projection missing")
ErrContentProjectionEmpty = errors.New("docgov: document content projection empty")
ErrContentIntegrityMismatch = errors.New("docgov: document content integrity mismatch")
// Re-export stable vocabulary so domain code keeps short names.
// Cross-package consumers should import contracts/vocabulary for State/Role/Err.
type (
DocumentState = vocabulary.DocumentState
UserRole = vocabulary.UserRole
LeaseHolderType = vocabulary.LeaseHolderType
CommentType = vocabulary.CommentType
)
const (
ErrCodeDocumentNotFound = "DOCUMENT_NOT_FOUND"
ErrCodeProjectionMissing = "DOCUMENT_CONTENT_PROJECTION_MISSING"
ErrCodeProjectionEmpty = "DOCUMENT_CONTENT_PROJECTION_EMPTY"
ErrCodeIntegrityMismatch = "DOCUMENT_CONTENT_INTEGRITY_MISMATCH"
ErrCodeProjectionDrift = "DOCUMENT_CONTENT_PROJECTION_DRIFT"
StateDrafting = vocabulary.StateDrafting
StateReviewing = vocabulary.StateReviewing
StateApproving = vocabulary.StateApproving
StateApproved = vocabulary.StateApproved
StateRejected = vocabulary.StateRejected
RoleAuthor = vocabulary.RoleAuthor
RoleReviewer = vocabulary.RoleReviewer
RoleApprover = vocabulary.RoleApprover
RoleSystem = vocabulary.RoleSystem
LeaseHolderNone = vocabulary.LeaseHolderNone
LeaseHolderSquad = vocabulary.LeaseHolderSquad
LeaseHolderHuman = vocabulary.LeaseHolderHuman
DefaultSquadLeaseTTL = vocabulary.DefaultSquadLeaseTTL
DefaultHumanLeaseTTL = vocabulary.DefaultHumanLeaseTTL
CommentGeneral = vocabulary.CommentGeneral
CommentSuggestion = vocabulary.CommentSuggestion
CommentRejection = vocabulary.CommentRejection
ErrCodeDocumentNotFound = vocabulary.ErrCodeDocumentNotFound
ErrCodeProjectionMissing = vocabulary.ErrCodeProjectionMissing
ErrCodeProjectionEmpty = vocabulary.ErrCodeProjectionEmpty
ErrCodeIntegrityMismatch = vocabulary.ErrCodeIntegrityMismatch
ErrCodeProjectionDrift = vocabulary.ErrCodeProjectionDrift
)
var (
ErrDocumentNotFound = vocabulary.ErrDocumentNotFound
ErrDocumentLocked = vocabulary.ErrDocumentLocked
ErrOnlyAuthorCanEdit = vocabulary.ErrOnlyAuthorCanEdit
ErrReviewerCannotEdit = vocabulary.ErrReviewerCannotEdit
ErrApproverCannotEdit = vocabulary.ErrApproverCannotEdit
ErrInvalidStateTransition = vocabulary.ErrInvalidStateTransition
ErrRejectReasonRequired = vocabulary.ErrRejectReasonRequired
ErrUnauthorizedActor = vocabulary.ErrUnauthorizedActor
ErrProposalNotFound = vocabulary.ErrProposalNotFound
ErrProposalAlreadyDecided = vocabulary.ErrProposalAlreadyDecided
ErrChangeProposalPending = vocabulary.ErrChangeProposalPending
ErrDocumentBusy = vocabulary.ErrDocumentBusy
ErrStaleFencing = vocabulary.ErrStaleFencing
ErrLeaseExpired = vocabulary.ErrLeaseExpired
ErrLeaseNotHeld = vocabulary.ErrLeaseNotHeld
ErrForceEditNotAllowed = vocabulary.ErrForceEditNotAllowed
ErrContentProjectionMissing = vocabulary.ErrContentProjectionMissing
ErrContentProjectionEmpty = vocabulary.ErrContentProjectionEmpty
ErrContentIntegrityMismatch = vocabulary.ErrContentIntegrityMismatch
)
// ContentError is a structured content-read failure. 404 is only for a missing record.
@@ -73,21 +105,6 @@ func NewContentError(code string, cause error, retryable, needsAdmin bool) *Cont
return &ContentError{Code: code, Message: msg, Retryable: retryable, NeedsAdmin: needsAdmin, cause: cause}
}
// LeaseHolderType 编辑租约持有者类型
type LeaseHolderType string
const (
LeaseHolderNone LeaseHolderType = ""
LeaseHolderSquad LeaseHolderType = "squad"
LeaseHolderHuman LeaseHolderType = "human"
)
// DefaultSquadLeaseTTL 小队任务默认租约时长
const DefaultSquadLeaseTTL = 30 * time.Minute
// DefaultHumanLeaseTTL 人工编辑会话默认租约时长
const DefaultHumanLeaseTTL = 15 * time.Minute
// DocumentBusyError 携带租约占用详情,便于 HTTP 409 与 UI 提示
type DocumentBusyError struct {
DocID string `json:"doc_id"`
@@ -107,27 +124,6 @@ func (e *DocumentBusyError) Error() string {
func (e *DocumentBusyError) Unwrap() error { return ErrDocumentBusy }
// DocumentState 文档生命周期流转状态
type DocumentState string
const (
StateDrafting DocumentState = "drafting" // 编制中 (草稿)
StateReviewing DocumentState = "reviewing" // 审核中 (待审核, 锁定)
StateApproving DocumentState = "approving" // 批准中 (待批准, 锁定)
StateApproved DocumentState = "approved" // 已批准 (归档生效, 终态锁定)
StateRejected DocumentState = "rejected" // 已驳回 (中间状态或流转标记)
)
// UserRole 治理责任人角色 (各有且仅有一个责任人)
type UserRole string
const (
RoleAuthor UserRole = "author" // 编制人
RoleReviewer UserRole = "reviewer" // 审核人
RoleApprover UserRole = "approver" // 批准人
RoleSystem UserRole = "system" // 系统/智能体小队
)
// DocumentConfig 受控文档流转与退回配置
type DocumentConfig struct {
RejectRollbackTo DocumentState `json:"reject_rollback_to"` // 驳回退回目标: drafting 或 reviewing
@@ -221,15 +217,6 @@ func (d *DocumentRecord) ClearLease() {
d.LeaseFencingToken = ""
}
// CommentType 批注类型
type CommentType string
const (
CommentGeneral CommentType = "comment" // 普通批注
CommentSuggestion CommentType = "suggestion" // 修改建议
CommentRejection CommentType = "reject_reason" // 驳回原因
)
// DocumentComment 批注与修改意见实体
type DocumentComment struct {
ID int64 `json:"id"`
+16
View File
@@ -0,0 +1,16 @@
package docrevision
// DeliverableStore reads/writes the workspace deliverable without embedding path literals.
// Wired from the composition root using workspace.DeliverableKey.
type DeliverableStore interface {
Read(absWorkDir string) ([]byte, error)
Write(absWorkDir string, body []byte) error
}
// SetDeliverableStore injects the path-owner-backed deliverable I/O.
func (s *Service) SetDeliverableStore(store DeliverableStore) {
if s == nil {
return
}
s.deliverables = store
}
+78 -1
View File
@@ -123,16 +123,22 @@ func TestResolveChangeReport_FlagOnMalformedFullFallback(t *testing.T) {
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
cases := []struct {
name string
raw string
}{
{"bad json", `{not-json`},
{"bad schema version", `{"schema_version":99,"schema_kind":"change_report_narrative_v1","input_digest":"x","summary":["a"]}`},
{"wrong schema kind", `{"schema_version":1,"schema_kind":"other_v1","input_digest":"` + digest + `","from_sha":"` + fromSHA + `","to_sha":"` + toSHA + `","summary":["PI only"]}`},
{"digest mismatch", mustNarrativeJSON(t, fromSHA, toSHA, "deadbeef", []string{"ok"})},
{"from_sha mismatch", mustNarrativeJSONWithSHAs(t, "wrong-from", toSHA, digest, []string{"PI only"})},
{"to_sha mismatch", mustNarrativeJSONWithSHAs(t, fromSHA, "wrong-to", digest, []string{"PI only"})},
{"timeout envelope", `{"result_v1":{"outcome":"error","error_code":"TIMEOUT"},"narrative":{"schema_version":1,"schema_kind":"change_report_narrative_v1"}}`},
{"top-level error_code", `{"schema_version":1,"schema_kind":"change_report_narrative_v1","input_digest":"` + digest + `","from_sha":"` + fromSHA + `","to_sha":"` + toSHA + `","error_code":"RUNNER_FAILED","summary":["PI only"]}`},
{"partial flag", `{"schema_version":1,"schema_kind":"change_report_narrative_v1","input_digest":"x","partial":true,"summary":["a"]}`},
{"empty summary", mustNarrativeJSON(t, fromSHA, toSHA, docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks), nil)},
{"empty summary", mustNarrativeJSON(t, fromSHA, toSHA, digest, nil)},
{"empty raw", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -154,11 +160,19 @@ func TestResolveChangeReport_FlagOnMalformedFullFallback(t *testing.T) {
t.Fatalf("mixed narrative field leaked: %q", s)
}
}
if rep.DeterministicFacts["narrative_applied"] == true {
t.Fatal("fallback must not claim narrative_applied")
}
})
}
}
func mustNarrativeJSON(t *testing.T, fromSHA, toSHA, digest string, summary []string) string {
t.Helper()
return mustNarrativeJSONWithSHAs(t, fromSHA, toSHA, digest, summary)
}
func mustNarrativeJSONWithSHAs(t *testing.T, fromSHA, toSHA, digest string, summary []string) string {
t.Helper()
raw, err := json.Marshal(map[string]any{
"schema_version": 1,
@@ -174,6 +188,43 @@ func mustNarrativeJSON(t *testing.T, fromSHA, toSHA, digest string, summary []st
return string(raw)
}
// TestResolveChangeReport_AuthorizedFlipPathKeepsFlagOffDefault documents the
// pre-authorization contract: default remains off; when a future owner flips the
// flag for a controlled window, malformed PI output still full-falls-back.
func TestResolveChangeReport_AuthorizedFlipPathKeepsFlagOffDefault(t *testing.T) {
docrevision.ResetPIChangeReportNarrativeForTest()
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
if docrevision.PIChangeReportNarrativeEnabled() {
t.Fatal("PI_CHANGE_REPORT_NARRATIVE must remain off until separately authorized")
}
fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
piRaw := mustNarrativeJSON(t, fromSHA, toSHA, digest, []string{"should stay ignored while off"})
offRep, err := docrevision.ResolveChangeReport(piRaw, fromSHA, toSHA, ruleRisk, impacts, diff)
if err != nil {
t.Fatal(err)
}
if offRep.AnalysisStatus != docrevision.AnalysisFallback {
t.Fatalf("default-off must ignore PI narrative, got %s", offRep.AnalysisStatus)
}
docrevision.SetPIChangeReportNarrativeForTest(true)
onBad, err := docrevision.ResolveChangeReport(`{"schema_version":1,"schema_kind":"change_report_narrative_v1"}`, fromSHA, toSHA, ruleRisk, impacts, diff)
if err != nil {
t.Fatal(err)
}
if onBad.AnalysisStatus != docrevision.AnalysisFallback {
t.Fatalf("authorized-on malformed must still fallback, got %s", onBad.AnalysisStatus)
}
docrevision.SetPIChangeReportNarrativeForTest(false)
if docrevision.PIChangeReportNarrativeEnabled() {
t.Fatal("test toggle must restore off before exit")
}
}
func TestCreateAgentCandidate_FlagOffIgnoresNarrativeEnrichment(t *testing.T) {
docrevision.SetPIChangeReportNarrativeForTest(false)
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
@@ -289,6 +340,32 @@ func TestCreateAgentCandidate_FlagOnFetcherErrorFallsBack(t *testing.T) {
}
}
func TestCreateAgentCandidate_FlagOnEmptyFetcherBodyFallsBack(t *testing.T) {
docrevision.SetPIChangeReportNarrativeForTest(true)
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
svc := docrevision.NewService(nil)
svc.NarrativeFetcher = func(_ context.Context, _ docrevision.NarrativeFetchSpec) (string, error) {
return " ", nil
}
ctx := context.Background()
st, err := svc.OpenTemplateVersion(ctx, "doc_flag_on_empty", "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
if err != nil {
t.Fatal(err)
}
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: "doc_flag_on_empty", RunID: "r-empty", InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: "# base\n\nbody changed\n",
ActorKind: docrevision.ActorAgent,
})
if err != nil {
t.Fatalf("empty fetcher body must full-fallback without error, got %v", err)
}
if cand == nil || rep == nil || rep.AnalysisStatus != docrevision.AnalysisFallback {
t.Fatalf("want fallback candidate, got cand=%v rep=%+v", cand, rep)
}
}
func TestNarrativeModelInput_IncludesClippedChangeText(t *testing.T) {
long := strings.Repeat("甲", 280)
spec := docrevision.NarrativeFetchSpec{
@@ -10,6 +10,7 @@ import (
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/workspace"
)
func TestPendingHighRisk_DoesNotAdvanceFactSource_UntilConfirm(t *testing.T) {
@@ -40,6 +41,7 @@ func TestPendingHighRisk_DoesNotAdvanceFactSource_UntilConfirm(t *testing.T) {
rev := docrevision.NewService(nil)
rev.DataDir = dataDir
rev.SetDeliverableStore(workspace.DirDeliverable{})
st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
@@ -120,6 +122,7 @@ func TestPendingHighRisk_RejectKeepsOldHead(t *testing.T) {
_, _ = eng.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "a", 2, "r", 3, "p")
rev := docrevision.NewService(nil)
rev.DataDir = dataDir
rev.SetDeliverableStore(workspace.DirDeliverable{})
st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{}, 1, workDir)
if err != nil {
t.Fatal(err)
+4 -4
View File
@@ -11,7 +11,7 @@ import (
func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) {
blob := &ContentBlob{ContentSHA256: sha, ContentMarkdown: canon, ByteSize: int64(len(canon)), CreatedAt: time.Now().UTC()}
blocks := BuildBlockMap(canon, nil)
err := s.withTx(ctx, func(tx pgx.Tx) error {
err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := s.lockOpenVersionTX(ctx, tx, docID); err != nil {
return err
}
@@ -82,7 +82,7 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
return nil
}
blob := s.blobs[snap.ContentSHA256]
return s.withTx(ctx, func(tx pgx.Tx) error {
return s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if !isBaseline {
if err := s.lockOpenVersionTX(ctx, tx, root.DocID); err != nil {
return err
@@ -161,7 +161,7 @@ func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snap
return nil
}
blob := s.blobs[outSnap.ContentSHA256]
return s.withTx(ctx, func(tx pgx.Tx) error {
return s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := s.lockOpenVersionTX(ctx, tx, c.DocID); err != nil {
return err
}
@@ -201,7 +201,7 @@ func (s *Service) persistAgentCommitLocked(ctx context.Context, root *DocRootPoi
if !s.usingPG() {
return nil
}
return s.withTx(ctx, func(tx pgx.Tx) error {
return s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := s.lockOpenVersionTX(ctx, tx, root.DocID); err != nil {
return err
}
+18 -30
View File
@@ -457,29 +457,19 @@ func (s *Service) projectRootPointersTX(ctx context.Context, tx pgx.Tx, root *Do
if root == nil {
return nil
}
switch root.Kind {
case "freeform":
_, err := tx.Exec(ctx, `
UPDATE freeform_documents
SET ledger_version_id=$2, head_snapshot_id=$3, head_revision_no=$4,
revision_schema_version=$5, row_revision=COALESCE(row_revision,0)+1
WHERE doc_id=$1`,
root.DocID, root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo, root.RevisionSchemaVer)
return err
default:
_, err := tx.Exec(ctx, `
UPDATE document_records
SET ledger_version_id=$2, head_snapshot_id=$3, head_revision_no=$4,
revision_schema_version=$5, row_revision=COALESCE(row_revision,0)+1,
content_sha256=COALESCE(NULLIF($6,''), content_sha256)
WHERE doc_id=$1`,
root.DocID, root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo,
root.RevisionSchemaVer, root.ContentSHA256)
return err
if s.rootPointers == nil {
// Unwired unit/PG tests still persist ledger rows; composition root must wire for production.
return nil
}
scope, ok := uow.ScopeFrom(ctx)
if !ok {
return fmt.Errorf("docrevision: root pointer projection requires uow scope")
}
_ = tx
return s.rootPointers.Project(ctx, scope, root)
}
func (s *Service) withTx(ctx context.Context, fn func(pgx.Tx) error) error {
func (s *Service) withTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
if !s.usingPG() {
return nil
}
@@ -488,17 +478,15 @@ func (s *Service) withTx(ctx context.Context, fn func(pgx.Tx) error) error {
if err != nil {
return err
}
return fn(tx)
return fn(ctx, tx)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if err := fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
return uow.NewPG(s.pool).Within(ctx, func(scope uow.TxScope) error {
tx, err := uow.AsPG(scope)
if err != nil {
return err
}
return fn(uow.WithScope(ctx, scope), tx)
})
}
// Pool exposes the underlying pool for integration tests.
+13 -8
View File
@@ -6,8 +6,6 @@ import (
"os"
"path/filepath"
"strings"
"chat-agent-system/internal/fsutil"
)
const (
@@ -128,7 +126,7 @@ func MapStoredDataPath(workDir, storedRoot, hostRoot string) (string, error) {
return mapped, nil
}
// InspectProjection classifies output.md against the current ledger head.
// InspectProjection classifies workspace deliverable bytes against the current ledger head.
func (s *Service) InspectProjection(ctx context.Context, docID, repoRoot string) (ProjectionClass, error) {
if err := requireDocID(docID); err != nil {
return ProjectionClass{}, err
@@ -150,7 +148,10 @@ func (s *Service) InspectProjection(ctx context.Context, docID, repoRoot string)
if path == "" {
return ProjectionClass{Class: ProjectionMissing, HeadSHA: SHA256Hex(CanonicalizeMarkdown(headMD))}, nil
}
raw, err := os.ReadFile(filepath.Join(path, "output.md"))
if s.deliverables == nil {
return ProjectionClass{Class: ProjectionMissing, HeadSHA: SHA256Hex(CanonicalizeMarkdown(headMD))}, nil
}
raw, err := s.deliverables.Read(path)
if err != nil {
if os.IsNotExist(err) {
return ClassifyProjection(nil, headMD), nil
@@ -193,7 +194,7 @@ func (s *Service) HeadContent(ctx context.Context, docID string) (string, string
return md, sha, nil
}
// ProjectOutputMarkdown writes head bytes to output.md after an intentional write.
// ProjectOutputMarkdown writes head bytes to the workspace deliverable after an intentional write.
func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error {
return s.projectOutputMarkdown(docID, repoRoot, true)
}
@@ -217,8 +218,10 @@ func (s *Service) projectOutputMarkdown(docID, repoRoot string, force bool) erro
if path == "" {
return nil
}
outPath := filepath.Join(path, "output.md")
existing, err := os.ReadFile(outPath)
if s.deliverables == nil {
return fmt.Errorf("docrevision: deliverable store not configured")
}
existing, err := s.deliverables.Read(path)
if err == nil {
class := ClassifyProjection(existing, md)
if class.Class == ProjectionAligned && string(existing) == md {
@@ -227,6 +230,8 @@ func (s *Service) projectOutputMarkdown(docID, repoRoot string, force bool) erro
if !force && (class.Class == ProjectionLegacyCanonicalizationDrift || class.Class == ProjectionContentFork) {
return fmt.Errorf("docrevision: refuse silent overwrite of %s (file=%s head=%s)", class.Class, class.RawSHA, class.HeadSHA)
}
} else if !os.IsNotExist(err) {
return err
}
return fsutil.WriteFileAtomic(outPath, []byte(md), 0o644)
return s.deliverables.Write(path, []byte(md))
}
+5
View File
@@ -6,6 +6,7 @@ import (
"testing"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/workspace"
)
func TestClassifyProjection_LegacyHardBreakDrift(t *testing.T) {
@@ -46,6 +47,7 @@ func TestProjectOutputMarkdown_AtomicAndSameBytes(t *testing.T) {
md := "人工保存 \n保留硬换行\n"
svc := docrevision.NewService(nil)
svc.DataDir = dir
svc.SetDeliverableStore(workspace.DirDeliverable{})
st, err := svc.OpenTemplateVersion(ctx, "doc_proj_write", md, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
@@ -80,6 +82,7 @@ func TestProjectOutputMarkdown_RefusesSilentOverwriteOfLegacyFile(t *testing.T)
}
svc := docrevision.NewService(nil)
svc.DataDir = dir
svc.SetDeliverableStore(workspace.DirDeliverable{})
if _, err := svc.OpenTemplateVersion(ctx, "doc_legacy", legacy, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir); err != nil {
t.Fatal(err)
}
@@ -124,6 +127,7 @@ func TestInspectProjection_RefusesUnmappedAbsoluteWorkDir(t *testing.T) {
svc := docrevision.NewService(nil)
svc.StoredDataRoot = "/app/data"
svc.HostDataRoot = host
svc.SetDeliverableStore(workspace.DirDeliverable{})
if _, err := svc.OpenTemplateVersion(ctx, "doc_abs", "# x\n", docrevision.ActorSnapshot{}, 1, other); err != nil {
t.Fatal(err)
}
@@ -146,6 +150,7 @@ func TestInspectProjection_UsesHostDataRootMapping(t *testing.T) {
svc := docrevision.NewService(nil)
svc.StoredDataRoot = "/app/data"
svc.HostDataRoot = host
svc.SetDeliverableStore(workspace.DirDeliverable{})
legacy := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
if _, err := svc.OpenTemplateVersion(ctx, "doc_map", legacy, docrevision.ActorSnapshot{}, 1, "/app/data/workspaces/intake"); err != nil {
t.Fatal(err)
+5 -4
View File
@@ -9,6 +9,7 @@ import (
"time"
"chat-agent-system/internal/fsutil"
"chat-agent-system/internal/workspace"
)
// ContentHashUpdater updates document_records.content_sha256 after a repair.
@@ -62,7 +63,7 @@ func (s *Service) ReconcileDocument(ctx context.Context, req ReconcileRequest) (
if path == "" {
return &ReconcileResult{DocID: req.DocID, Class: ProjectionMissing, DryRun: !req.Apply, Detail: "empty work_dir"}, nil
}
raw, err := os.ReadFile(filepath.Join(path, "output.md"))
raw, err := os.ReadFile(filepath.Join(path, workspace.DeliverableKey))
if err != nil {
if os.IsNotExist(err) {
return &ReconcileResult{DocID: req.DocID, Class: ProjectionMissing, DryRun: !req.Apply, Detail: err.Error(), OldHeadSHA: headSHA, OldHeadID: oldHeadID}, nil
@@ -114,7 +115,7 @@ func (s *Service) ReconcileDocument(ctx context.Context, req ReconcileRequest) (
return nil, err
}
// Verify file still original bytes — never project old or new head over it.
after, _ := os.ReadFile(filepath.Join(path, "output.md"))
after, _ := os.ReadFile(filepath.Join(path, workspace.DeliverableKey))
if string(after) != string(raw) {
return nil, fmt.Errorf("docrevision: apply mutated original file; abort")
}
@@ -218,7 +219,7 @@ func BackupReconcileArtifacts(backupDir, docID string, raw []byte, headID int64,
}
arts := &BackupArtifacts{
Dir: runDir,
OutputFile: filepath.Join(runDir, "output.md"),
OutputFile: filepath.Join(runDir, workspace.DeliverableKey),
HeadFile: filepath.Join(runDir, "head.md"),
MetaFile: filepath.Join(runDir, "head.json"),
}
@@ -233,7 +234,7 @@ func BackupReconcileArtifacts(backupDir, docID string, raw []byte, headID int64,
"head_snapshot_id": headID,
"head_sha256": headSHA,
"head_markdown": "head.md",
"original_file": "output.md",
"original_file": workspace.DeliverableKey,
"backed_up_at": time.Now().UTC().Format(time.RFC3339),
"note": "do not restore this head over the original file",
"head_bytes": len(headMD),
+2
View File
@@ -9,6 +9,7 @@ import (
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/fsutil"
"chat-agent-system/internal/workspace"
)
func project143Raw() string {
@@ -266,6 +267,7 @@ func TestWritePaths_HumanSaveTrailingSpacesShareBytes(t *testing.T) {
}
svc := docrevision.NewService(nil)
svc.DataDir = dir
svc.SetDeliverableStore(workspace.DirDeliverable{})
st, err := svc.OpenTemplateVersion(ctx, "doc_human", "# A\n", docrevision.ActorSnapshot{}, 1, workDir)
if err != nil {
t.Fatal(err)
+45
View File
@@ -0,0 +1,45 @@
package docrevision
import (
"context"
"chat-agent-system/internal/app/uow"
)
// RootPointerProjector projects controlled/freeform root ledger pointers.
// Implemented outside this package (docgov + freedoc) to avoid import cycles.
type RootPointerProjector interface {
Project(ctx context.Context, scope uow.TxScope, root *DocRootPointers) error
}
// SetRootPointerProjector injects the composition-root projector.
func (s *Service) SetRootPointerProjector(p RootPointerProjector) {
if s == nil {
return
}
s.rootPointers = p
}
// CompositeRootPointerProjector routes by DocRootPointers.Kind.
type CompositeRootPointerProjector struct {
Controlled func(ctx context.Context, scope uow.TxScope, root *DocRootPointers) error
Freeform func(ctx context.Context, scope uow.TxScope, root *DocRootPointers) error
}
func (c CompositeRootPointerProjector) Project(ctx context.Context, scope uow.TxScope, root *DocRootPointers) error {
if root == nil {
return nil
}
switch root.Kind {
case "freeform":
if c.Freeform == nil {
return nil
}
return c.Freeform(ctx, scope, root)
default:
if c.Controlled == nil {
return nil
}
return c.Controlled(ctx, scope, root)
}
}
+9 -3
View File
@@ -49,6 +49,12 @@ type Service struct {
// PI_CHANGE_REPORT_NARRATIVE is on and ReportJSON is empty.
NarrativeFetcher func(ctx context.Context, spec NarrativeFetchSpec) (string, error)
// Root pointer projection (controlled→docgov, freeform→freedoc); set by composition root.
rootPointers RootPointerProjector
// Deliverable I/O (path owner via workspace.DeliverableKey); set by composition root.
deliverables DeliverableStore
// DataDir is the environment data root used to resolve relative WorkDir paths.
DataDir string
@@ -618,7 +624,7 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportExpired)
}
if s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
@@ -659,7 +665,7 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportRejected)
}
if s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
@@ -688,7 +694,7 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportCommitted)
}
if s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
eid, err := s.insertEventTX(ctx, tx, ev)
if err != nil {
return err
+9 -3
View File
@@ -30,7 +30,7 @@ type Dispatcher struct {
mu sync.RWMutex
claiming bool
inflight map[string]struct{}
cancelFns []context.CancelFunc
cancelFns map[string]context.CancelFunc // keyed by event_id:consumer; reclaimed after handle
lastBeat atomic.Int64
}
@@ -56,6 +56,7 @@ func NewDispatcher(pool *pgxpool.Pool, environment, owner string) *Dispatcher {
Metrics: &Metrics{},
claiming: true,
inflight: map[string]struct{}{},
cancelFns: map[string]context.CancelFunc{},
}
d.Touch()
return d
@@ -73,7 +74,10 @@ func (d *Dispatcher) Alive(maxAge time.Duration) bool {
func (d *Dispatcher) StopClaim() {
d.mu.Lock()
d.claiming = false
cancels := append([]context.CancelFunc(nil), d.cancelFns...)
cancels := make([]context.CancelFunc, 0, len(d.cancelFns))
for _, c := range d.cancelFns {
cancels = append(cancels, c)
}
d.mu.Unlock()
for _, c := range cancels {
c()
@@ -193,15 +197,17 @@ func (d *Dispatcher) shadowScan(ctx context.Context, consumer string) (int, erro
func (d *Dispatcher) handle(ctx context.Context, lease Lease) {
key := lease.Consumer + ":" + lease.AggregateType + ":" + lease.AggregateID
cancelKey := lease.EventID + ":" + lease.Consumer
d.mu.Lock()
d.inflight[key] = struct{}{}
hctx, cancel := context.WithCancel(ctx)
d.cancelFns = append(d.cancelFns, cancel)
d.cancelFns[cancelKey] = cancel
d.mu.Unlock()
defer func() {
cancel()
d.mu.Lock()
delete(d.inflight, key)
delete(d.cancelFns, cancelKey)
d.mu.Unlock()
}()
+15 -4
View File
@@ -7,12 +7,23 @@ import (
"chat-agent-system/internal/app/uow"
)
// Repository is a M3 stub. M1 freeform projection stays on the existing service path.
// Repository writes freeform_documents root pointers inside a UnitOfWork scope.
type Repository struct{}
func (Repository) ForbiddenInM1(ctx context.Context, scope uow.TxScope) error {
if _, err := uow.AsPG(scope); err != nil {
// ProjectHead projects ledger head pointers onto freeform_documents.
func (Repository) ProjectHead(ctx context.Context, scope uow.TxScope, docID string, versionID, snapshotID *int64, revisionNo, schemaVer int) error {
tx, err := uow.AsPG(scope)
if err != nil {
return err
}
return fmt.Errorf("freedoc: repository writes are not enabled until M3")
_, err = tx.Exec(ctx, `
UPDATE freeform_documents
SET ledger_version_id=$2, head_snapshot_id=$3, head_revision_no=$4,
revision_schema_version=$5, row_revision=COALESCE(row_revision,0)+1
WHERE doc_id=$1`,
docID, versionID, snapshotID, revisionNo, schemaVer)
if err != nil {
return fmt.Errorf("freedoc: project head: %w", err)
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package projectinstance
import (
"context"
"chat-agent-system/internal/workflowdef"
)
// VersionResolver is the projectinstance-owned port for reading published workflow definitions.
// Concrete workflowdef.Store is injected at the composition root; production code must not
// construct workflowdef services directly.
type VersionResolver interface {
GetDefinitionByKey(ctx context.Context, key string) (*workflowdef.Definition, error)
GetVersion(ctx context.Context, workflowID int64, version int) (*workflowdef.Version, error)
ListDefinitions(ctx context.Context) ([]workflowdef.Definition, error)
}
-7
View File
@@ -11,13 +11,6 @@ import (
"chat-agent-system/internal/workflowdef"
)
// VersionResolver 解析已发布工作流版本。
type VersionResolver interface {
GetDefinitionByKey(ctx context.Context, key string) (*workflowdef.Definition, error)
GetVersion(ctx context.Context, workflowID int64, version int) (*workflowdef.Version, error)
ListDefinitions(ctx context.Context) ([]workflowdef.Definition, error)
}
// Service 项目工作流实例与激活领域服务。
type Service struct {
store Store
+4 -4
View File
@@ -13,6 +13,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/workflowdef"
)
@@ -916,10 +917,9 @@ func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, proje
return nil, err
}
_, _ = tx.Exec(ctx, `
UPDATE task_runs SET status = 'cancelled', updated_at = $2, error_message = 'workflow terminated'
WHERE project_id = $1 AND status IN ('pending', 'running')
`, projectID, now)
if err := taskrun.CancelOpenByProject(ctx, tx, projectID, now, "workflow terminated"); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"log/slog"
"net/http"
"strings"
"time"
"chat-agent-system/internal/httpx"
)
// accessLogMiddleware emits one structured line per HTTP request when enabled.
// It never logs bodies, Authorization, Cookie, or raw query strings.
func (s *Server) accessLogMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s == nil || s.cfg == nil || !s.cfg.HTTPAccessLogEnabled {
next.ServeHTTP(w, r)
return
}
started := time.Now()
requestID := httpx.RequestID(r)
w.Header().Set(httpx.HeaderRequestID, requestID)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("http_access",
"method", r.Method,
"path", accessLogPath(r.URL.Path),
"status", rec.status,
"duration_ms", time.Since(started).Milliseconds(),
"request_id", requestID,
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (r *statusRecorder) WriteHeader(code int) {
if r.wroteHeader {
return
}
r.wroteHeader = true
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
return r.ResponseWriter.Write(b)
}
// accessLogPath keeps only the URL path. Query strings are dropped so tokens
// and secrets in ?access_token=… never reach the log line.
func accessLogPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/"
}
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
if i := strings.IndexByte(path, '#'); i >= 0 {
path = path[:i]
}
return path
}
// accessLogSafeMessage is used by tests to assert the middleware contract.
func accessLogSafeMessage(method, path string, status int, durationMs int64, requestID string) map[string]any {
return map[string]any{
"method": method,
"path": accessLogPath(path),
"status": status,
"duration_ms": durationMs,
"request_id": requestID,
}
}
@@ -0,0 +1,89 @@
package server
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"chat-agent-system/internal/config"
)
func TestAccessLogPath_DropsQueryAndFragment(t *testing.T) {
got := accessLogPath("/api/v1/chat?token=secret&q=1#frag")
if got != "/api/v1/chat" {
t.Fatalf("path=%q", got)
}
msg := accessLogSafeMessage("GET", "/x?password=p", 200, 3, "req_1")
blob := strings.ToLower(strings.Join([]string{
msg["method"].(string),
msg["path"].(string),
msg["request_id"].(string),
}, " "))
for _, leak := range []string{"password", "token", "secret", "bearer", "authorization"} {
if strings.Contains(blob, leak) {
t.Fatalf("leak %q in %#v", leak, msg)
}
}
}
func TestAccessLogMiddleware_EmitsSafeFieldsOnly(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
srv := &Server{cfg: &config.Config{HTTPAccessLogEnabled: true}}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Authorization", "Bearer should-not-log")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"password":"nope","prompt":"secret text"}`))
})
handler := srv.accessLogMiddleware(inner)
req := httptest.NewRequest(http.MethodPost, "/api/v1/login?access_token=leak", strings.NewReader(`{"password":"x"}`))
req.Header.Set("Authorization", "Bearer super-secret")
req.Header.Set("Cookie", "session=abc")
req.Header.Set("X-Request-ID", "req_test_access")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d", rec.Code)
}
line := buf.String()
if !strings.Contains(line, `"msg":"http_access"`) && !strings.Contains(line, `"http_access"`) {
t.Fatalf("missing http_access log: %s", line)
}
if !strings.Contains(line, `"path":"/api/v1/login"`) {
t.Fatalf("expected redacted path, got %s", line)
}
if !strings.Contains(line, `"status":201`) {
t.Fatalf("expected status, got %s", line)
}
if !strings.Contains(line, "req_test_access") {
t.Fatalf("expected request_id, got %s", line)
}
low := strings.ToLower(line)
for _, leak := range []string{"super-secret", "access_token=leak", "password", "bearer super", "session=abc", "secret text"} {
if strings.Contains(low, leak) {
t.Fatalf("log leaked %q: %s", leak, line)
}
}
}
func TestAccessLogMiddleware_DisabledIsNoOp(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
srv := &Server{cfg: &config.Config{HTTPAccessLogEnabled: false}}
handler := srv.accessLogMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/health", nil))
if strings.Contains(buf.String(), "http_access") {
t.Fatalf("disabled middleware must not log: %s", buf.String())
}
}
+9 -4
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -161,8 +162,10 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque
writeSafeError(w, r, status, err.Error())
return
}
if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok {
_ = dbCat.Refresh(r.Context())
if refresher, ok := s.pwSvc.Catalog().(interface {
Refresh(context.Context) error
}); ok {
_ = refresher.Refresh(r.Context())
}
writeJSONResponse(w, http.StatusOK, ver)
}
@@ -182,8 +185,10 @@ func (s *Server) handleAdminWorkflowRetire(w http.ResponseWriter, r *http.Reques
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok {
_ = dbCat.Refresh(r.Context())
if refresher, ok := s.pwSvc.Catalog().(interface {
Refresh(context.Context) error
}); ok {
_ = refresher.Refresh(r.Context())
}
writeJSONResponse(w, http.StatusOK, ver)
}
+8 -8
View File
@@ -7,8 +7,8 @@ import (
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
)
// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。
@@ -180,21 +180,21 @@ func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request,
}
// resolveDocGovRole 服务端按文档职责分配解析 docgov 角色,不信任客户端自报角色。
func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) docgov.UserRole {
func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) vocabulary.UserRole {
if user == nil || s.gov == nil {
return docgov.UserRole("")
return vocabulary.UserRole("")
}
doc, err := s.gov.GetDocument(ctx, docID)
if err != nil || doc == nil {
return docgov.UserRole("")
return vocabulary.UserRole("")
}
switch user.ID {
case doc.AuthorID:
return docgov.RoleAuthor
return vocabulary.RoleAuthor
case doc.ReviewerID:
return docgov.RoleReviewer
return vocabulary.RoleReviewer
case doc.ApproverID:
return docgov.RoleApprover
return vocabulary.RoleApprover
}
return docgov.UserRole("")
return vocabulary.UserRole("")
}
+6 -17
View File
@@ -4,8 +4,6 @@ import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"chat-agent-system/internal/auth"
@@ -35,11 +33,14 @@ func (s *Server) resolveDocRevisionRef(ctx context.Context, user *auth.UserAccou
Kind: "controlled", ProjectID: doc.ProjectID, WorkDir: doc.WorkDir,
LifecycleState: string(doc.CurrentState), AuthorID: doc.AuthorID,
}
if s.docRev() != nil {
if md, _, herr := s.docRev().HeadContent(ctx, docID); herr == nil && strings.TrimSpace(md) != "" {
ref.Markdown = md
return ref, nil
}
}
if content, cErr := s.gov.ReadDocumentContent(ctx, docID, s.dataRoot()); cErr == nil && content != nil {
ref.Markdown = content.Markdown
} else if doc.WorkDir != "" {
md, _ := readOutputMarkdown(s.dataRoot(), doc.WorkDir)
ref.Markdown = md
}
return ref, nil
}
@@ -121,18 +122,6 @@ func (s *Server) dataRoot() string {
return "."
}
func readOutputMarkdown(dataRoot, workDir string) (string, error) {
path := workDir
if !filepath.IsAbs(path) {
path = filepath.Join(dataRoot, workDir)
}
b, err := os.ReadFile(filepath.Join(path, "output.md"))
if err != nil {
return "", err
}
return string(b), nil
}
func (s *Server) bootstrapRevisionIfNeeded(ctx context.Context, user *auth.UserAccount, docID string, ref *docRevisionRef) (*docrevision.RevisionState, error) {
rev := s.docRev()
if rev == nil {
@@ -12,6 +12,7 @@ import (
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
@@ -213,7 +214,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
// This UI has no edit-session heartbeat: release the request-scoped human lease on every exit
// (success, 409, projection failure) so a 15m residual cannot block a later squad run.
if kind == "controlled" && s.gov != nil {
if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil {
if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor); err != nil {
writeDocGovError(w, err)
return
}
@@ -222,7 +223,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
holderID = "human_anon"
}
defer func() {
_, _ = s.gov.ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderHuman), holderID)
_, _ = s.gov.ReleaseLease(context.Background(), docID, string(vocabulary.LeaseHolderHuman), holderID)
}()
}
@@ -250,7 +251,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
res, err := cmds.HumanEdit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleAuthor}, docflow.HumanEditCommand{
res, err := cmds.HumanEdit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleAuthor}, docflow.HumanEditCommand{
DocID: docID,
Request: docrevision.HumanRevisionRequest{
DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev,
@@ -279,7 +280,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
if _, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: docID,
ActorID: user.ID,
ActorRole: docgov.RoleAuthor,
ActorRole: vocabulary.RoleAuthor,
ActorName: user.Name,
MarkdownBody: body.Markdown,
}); err != nil {
@@ -372,7 +373,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
st, err := cmds.AgentCommit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleAuthor}, docflow.AgentCommitCommand{
st, err := cmds.AgentCommit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleAuthor}, docflow.AgentCommitCommand{
DocID: docID, CandidateID: cid,
Decision: docrevision.CommitAgentDecision{
Decision: body.Decision, Confirmed: body.Confirmed,
@@ -392,7 +393,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "候选决策已写入账本,但工作区 output.md 投影失败;请重试决策或人工修复。",
"hint": "候选决策已写入账本,但工作区交付物投影失败;请重试决策或人工修复。",
"state": st,
})
return
+79
View File
@@ -0,0 +1,79 @@
package server
import (
"errors"
"net/http"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectworkflow"
)
// domainHTTPStatus is the single adapter that maps known domain sentinel /
// typed errors to HTTP status codes. Callers that need a different default
// (400 vs 500) must apply it when ok is false.
//
// This is the only production place in internal/server that should match
// docgov.Err* (and closely related domain sentinels) for HTTP status selection.
// handlers.go must call this mapper (or writeDocGovError / writeTaskError)
// instead of branching on docgov.Err* directly.
func domainHTTPStatus(err error) (status int, ok bool) {
if err == nil {
return 0, false
}
var busy *docgov.DocumentBusyError
if errors.As(err, &busy) {
return http.StatusConflict, true
}
var ce *docgov.ContentError
if errors.As(err, &ce) {
if ce.Code == docgov.ErrCodeDocumentNotFound {
return http.StatusNotFound, true
}
return http.StatusConflict, true
}
switch {
case errors.Is(err, docgov.ErrDocumentBusy),
errors.Is(err, docgov.ErrDocumentLocked),
errors.Is(err, docgov.ErrForceEditNotAllowed),
errors.Is(err, docgov.ErrLeaseNotHeld),
errors.Is(err, docgov.ErrLeaseExpired):
return http.StatusConflict, true
case errors.Is(err, docgov.ErrDocumentNotFound),
errors.Is(err, docgov.ErrProposalNotFound):
return http.StatusNotFound, true
case errors.Is(err, docgov.ErrUnauthorizedActor):
return http.StatusForbidden, true
case errors.Is(err, projectworkflow.ErrPrerequisiteNotMet):
return http.StatusBadRequest, true
default:
return 0, false
}
}
func writeTaskError(w http.ResponseWriter, err error, payload any) {
status := http.StatusInternalServerError
if mapped, ok := domainHTTPStatus(err); ok {
status = mapped
}
if payload != nil {
writeJSONResponse(w, status, payload)
return
}
writeSafeError(w, nil, status, err.Error())
}
func writeDocGovError(w http.ResponseWriter, err error) {
var busy *docgov.DocumentBusyError
if errors.As(err, &busy) {
writeJSONResponse(w, http.StatusConflict, map[string]any{
"error": "document_busy",
"detail": busy,
})
return
}
status := http.StatusBadRequest
if mapped, ok := domainHTTPStatus(err); ok {
status = mapped
}
writeSafeError(w, nil, status, err.Error())
}
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectworkflow"
)
func TestDomainHTTPStatus_MapsKnownSentinels(t *testing.T) {
cases := []struct {
err error
status int
ok bool
}{
{nil, 0, false},
{errors.New("other"), 0, false},
{docgov.ErrDocumentBusy, http.StatusConflict, true},
{&docgov.DocumentBusyError{DocID: "d1"}, http.StatusConflict, true},
{docgov.ErrDocumentLocked, http.StatusConflict, true},
{docgov.ErrForceEditNotAllowed, http.StatusConflict, true},
{docgov.ErrLeaseNotHeld, http.StatusConflict, true},
{docgov.ErrLeaseExpired, http.StatusConflict, true},
{docgov.ErrDocumentNotFound, http.StatusNotFound, true},
{docgov.ErrProposalNotFound, http.StatusNotFound, true},
{docgov.ErrUnauthorizedActor, http.StatusForbidden, true},
{projectworkflow.ErrPrerequisiteNotMet, http.StatusBadRequest, true},
{&docgov.ContentError{Code: docgov.ErrCodeDocumentNotFound}, http.StatusNotFound, true},
{&docgov.ContentError{Code: docgov.ErrCodeProjectionMissing}, http.StatusConflict, true},
}
for _, tc := range cases {
got, ok := domainHTTPStatus(tc.err)
if ok != tc.ok || got != tc.status {
t.Fatalf("%v: got status=%d ok=%v want status=%d ok=%v", tc.err, got, ok, tc.status, tc.ok)
}
}
}
func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) {
rec := httptest.NewRecorder()
writeDocGovError(rec, docgov.ErrProposalNotFound)
if rec.Code != http.StatusNotFound {
t.Fatalf("status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeDocGovError(rec, docgov.ErrUnauthorizedActor)
if rec.Code != http.StatusForbidden {
t.Fatalf("status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeDocGovError(rec, &docgov.DocumentBusyError{DocID: "d", Reason: "held"})
if rec.Code != http.StatusConflict {
t.Fatalf("status %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["error"] != "document_busy" {
t.Fatalf("body=%v", body)
}
}
func TestWriteTaskError_DefaultsTo500(t *testing.T) {
rec := httptest.NewRecorder()
writeTaskError(rec, errors.New("boom"), nil)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d", rec.Code)
}
rec = httptest.NewRecorder()
writeTaskError(rec, docgov.ErrDocumentBusy, map[string]string{"ok": "no"})
if rec.Code != http.StatusConflict {
t.Fatalf("status %d", rec.Code)
}
}
+24 -65
View File
@@ -16,13 +16,13 @@ import (
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/httpx"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/taskrun"
)
// HealthResponse 健康检查响应
@@ -127,7 +127,7 @@ func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthRe
}
}
schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory)
taskStoreReady := taskrun.TablesReady(dbCtx, s.pool)
taskStoreReady := s.rt != nil && s.rt.TaskStoreReady(dbCtx)
status := "ok"
code := http.StatusOK
@@ -429,50 +429,6 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, http.StatusOK, taskRes)
}
func writeTaskError(w http.ResponseWriter, err error, payload any) {
status := http.StatusInternalServerError
if errors.Is(err, docgov.ErrDocumentBusy) {
status = http.StatusConflict
} else if errors.Is(err, docgov.ErrDocumentLocked) || errors.Is(err, docgov.ErrForceEditNotAllowed) {
status = http.StatusConflict
} else if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) {
status = http.StatusBadRequest
}
if payload != nil {
writeJSONResponse(w, status, payload)
return
}
writeSafeError(w, nil, status, err.Error())
}
func writeDocGovError(w http.ResponseWriter, err error) {
var busy *docgov.DocumentBusyError
if errors.As(err, &busy) {
writeJSONResponse(w, http.StatusConflict, map[string]any{
"error": "document_busy",
"detail": busy,
})
return
}
if errors.Is(err, docgov.ErrDocumentBusy) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentLocked) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
writeSafeError(w, nil, http.StatusNotFound, err.Error())
return
}
if errors.Is(err, docgov.ErrLeaseNotHeld) || errors.Is(err, docgov.ErrLeaseExpired) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
writeSafeError(w, nil, http.StatusBadRequest, err.Error())
}
func writeJSONResponse(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
@@ -713,7 +669,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request
return
}
doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, docgov.RoleAuthor, user.Name)
doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error())
return
@@ -759,13 +715,13 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque
var err error
if action == "approve" {
doc, err = s.gov.ReviewApprove(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, req.Comment)
doc, err = s.gov.ReviewApprove(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, req.Comment)
} else if action == "reject" {
reason := req.Reason
if reason == "" {
reason = req.Comment
}
doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, reason)
doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, reason)
} else {
writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
return
@@ -806,7 +762,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleApprover}
p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleApprover}
var doc *docgov.DocumentRecord
var err error
@@ -818,7 +774,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
reason = req.Comment
}
doc, err = cmds.Reject(r.Context(), p, docflow.DecisionCommand{
DocID: req.DocID, RejectReason: reason, RollbackTo: docgov.DocumentState(req.RollbackTo),
DocID: req.DocID, RejectReason: reason, RollbackTo: vocabulary.DocumentState(req.RollbackTo),
})
} else {
writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
@@ -890,8 +846,8 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.
req.RiskAssessment,
)
if err != nil {
if errors.Is(err, docgov.ErrDocumentNotFound) {
writeSafeError(w, r, http.StatusNotFound, "document not found")
if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound {
writeSafeError(w, r, status, "document not found")
return
}
writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error())
@@ -933,12 +889,15 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason)
if err != nil {
if errors.Is(err, docgov.ErrProposalNotFound) {
writeSafeError(w, r, http.StatusNotFound, "proposal not found")
return
}
if errors.Is(err, docgov.ErrUnauthorizedActor) {
writeSafeError(w, r, http.StatusForbidden, "decide proposal failed: "+err.Error())
if status, ok := domainHTTPStatus(err); ok {
switch status {
case http.StatusNotFound:
writeSafeError(w, r, status, "proposal not found")
case http.StatusForbidden:
writeSafeError(w, r, status, "decide proposal failed: "+err.Error())
default:
writeSafeError(w, r, status, err.Error())
}
return
}
writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error())
@@ -1004,9 +963,9 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
return
}
cType := docgov.CommentType(req.CommentType)
cType := vocabulary.CommentType(req.CommentType)
if cType == "" {
cType = docgov.CommentGeneral
cType = vocabulary.CommentGeneral
}
comment, err := s.gov.AddComment(
@@ -1302,7 +1261,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
role := docgov.RoleAuthor
role := vocabulary.RoleAuthor
res, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: req.DocID,
ActorID: user.ID,
@@ -1339,8 +1298,8 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if docgov.LeaseHolderType(req.HolderType) == docgov.LeaseHolderHuman {
if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, docgov.RoleAuthor); err != nil {
if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman {
if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor); err != nil {
writeDocGovError(w, err)
return
}
@@ -1353,7 +1312,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
ttl := time.Duration(req.TTLSeconds) * time.Second
doc, err := s.gov.AcquireLease(r.Context(), docgov.LeaseAcquireRequest{
DocID: req.DocID,
HolderType: docgov.LeaseHolderType(req.HolderType),
HolderType: vocabulary.LeaseHolderType(req.HolderType),
HolderID: req.HolderID,
WorkDir: workDir,
TTL: ttl,
+24 -2
View File
@@ -65,9 +65,10 @@ func (s *Server) handleIntentPlanDecision(w http.ResponseWriter, r *http.Request
return
}
kind := intentflow.DecisionKind(strings.TrimSpace(body.Kind))
planID := r.PathValue("plan_id")
view, err := plans.DecidePlan(r.Context(), intentflow.DecisionCommand{
Principal: prin,
PlanID: r.PathValue("plan_id"),
PlanID: planID,
ExpectedRevision: body.ExpectedRevision,
DecisionNonce: body.DecisionNonce,
DecisionDigest: body.DecisionDigest,
@@ -76,12 +77,33 @@ func (s *Server) handleIntentPlanDecision(w http.ResponseWriter, r *http.Request
ExcludeUnselected: body.ExcludeUnselected,
})
if err != nil {
writeIntentPlanError(w, r, plans, prin, r.PathValue("plan_id"), err)
writeIntentPlanError(w, r, plans, prin, planID, err)
return
}
// After confirm, advance eligible steps via the existing plan ledger dispatcher
// (ClaimStep / ExecuteClaimed / RecoverReceipt). No second decision queue.
if shouldDispatchAfterDecision(kind, view) {
if advanced, advErr := plans.DispatchEligible(r.Context(), "http-confirm", planID); advErr == nil && advanced != nil {
view = advanced
}
}
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
}
func shouldDispatchAfterDecision(kind intentflow.DecisionKind, view *intentflow.PlanView) bool {
if view == nil {
return false
}
switch kind {
case intentflow.DecisionConfirm, intentflow.DecisionConfirmRetry, intentflow.DecisionContinue:
return view.Status == intentflow.PlanExecuting ||
view.Status == intentflow.PlanPartiallyCompleted ||
view.Status == intentflow.PlanCompleted
default:
return false
}
}
func (s *Server) planRequestScope(w http.ResponseWriter, r *http.Request) (intentflow.Service, intentflow.Principal, bool) {
if s == nil || s.intentPlans == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{
+13 -1
View File
@@ -164,9 +164,21 @@ func TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt(t *testing.T) {
}
_ = json.Unmarshal(first.Body.Bytes(), &a)
_ = json.Unmarshal(again.Body.Bytes(), &b)
if a.Plan == nil || b.Plan == nil || a.Plan.Revision != b.Plan.Revision || a.Plan.Status != intentflow.PlanExecuting {
if a.Plan == nil || b.Plan == nil || a.Plan.Revision != b.Plan.Revision {
t.Fatalf("same nonce must share receipt a=%+v b=%+v", a.Plan, b.Plan)
}
if a.Plan.Status != intentflow.PlanExecuting && a.Plan.Status != intentflow.PlanCompleted && a.Plan.Status != intentflow.PlanPartiallyCompleted {
t.Fatalf("confirm+dispatch status = %s", a.Plan.Status)
}
succeeded := 0
for _, s := range a.Plan.Steps {
if s.Status == intentflow.StepSucceeded {
succeeded++
}
}
if succeeded == 0 {
t.Fatalf("confirm must dispatch eligible steps, plan=%+v", a.Plan)
}
}
func TestIntentPlanHTTP_StaleRevision(t *testing.T) {
+4 -4
View File
@@ -2,13 +2,11 @@ package server
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectworkflow"
)
@@ -52,8 +50,10 @@ func (s *Server) lineageDocVisible(ctx context.Context, projectID, userID int64,
func (s *Server) lineageDocPlacement(ctx context.Context, projectID int64, docID string) (taskKey string, placed bool, ok bool) {
if s.gov != nil {
doc, err := s.gov.GetDocument(ctx, docID)
if err != nil && !errors.Is(err, docgov.ErrDocumentNotFound) {
return "", false, false
if err != nil {
if status, mapped := domainHTTPStatus(err); !(mapped && status == http.StatusNotFound) {
return "", false, false
}
}
if err == nil && doc != nil {
if doc.ProjectID != projectID {
+7 -6
View File
@@ -10,6 +10,7 @@ import (
"time"
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docquery"
"chat-agent-system/internal/docrevision"
@@ -132,7 +133,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
}
if errors.Is(err, docquery.ErrClosed) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
"error_code": docgov.ErrCodeDocumentNotFound, "message": "document not found",
"error_code": vocabulary.ErrCodeDocumentNotFound, "message": "document not found",
})
return
}
@@ -150,7 +151,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string) (*docgov.DocumentContent, error) {
if s == nil || s.gov == nil {
return nil, docgov.ErrDocumentNotFound
return nil, vocabulary.ErrDocumentNotFound
}
svc := &docquery.Service{Gov: s.gov, Rev: s.docRev(), RepoRoot: s.repoRoot()}
view, err := svc.Get(ctx, docquery.Principal{UserID: 1}, docquery.DocRef{DocID: docID})
@@ -191,8 +192,8 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
var ce *docgov.ContentError
if errors.As(err, &ce) {
status := http.StatusConflict
if ce.Code == docgov.ErrCodeDocumentNotFound {
status = http.StatusNotFound
if mapped, ok := domainHTTPStatus(err); ok {
status = mapped
}
writeJSONResponse(w, status, map[string]any{
"error_code": ce.Code,
@@ -207,8 +208,8 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
})
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound {
writeJSONResponse(w, status, map[string]any{
"error_code": docgov.ErrCodeDocumentNotFound, "message": err.Error(),
"retryable": false, "needs_admin": false, "correlation_id": corr,
})
+2 -11
View File
@@ -11,7 +11,6 @@ import (
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/docduty"
)
var errProjectRoleRequired = errors.New("project_role required when member does not exist")
@@ -31,18 +30,10 @@ func writeDutySyncError(w http.ResponseWriter) {
}
func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error {
if s == nil || s.svc == nil || projectID <= 0 {
if s == nil || s.svc == nil || s.rt == nil || projectID <= 0 {
return nil
}
pool := s.pool
if pool == nil {
return nil
}
_, _, err := docduty.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
log.Printf("[docduty] heal project %d after member change failed: %v", projectID, err)
}
return err
return s.rt.HealProjectDuties(ctx, projectID)
}
type projectMemberRequest struct {
+9 -10
View File
@@ -32,11 +32,11 @@ import (
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workspace"
"github.com/jackc/pgx/v5/pgxpool"
)
// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务
// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务。
// Pool 与领域服务装配归属 app.Runtime;本结构仅保留 HTTP 适配所需句柄。
// 残留具体领域字段见 short-term 任务记录(增量收口,本批先去掉 *pgxpool.Pool)。
type Server struct {
rt *app.Runtime
cfg *config.Config
@@ -63,7 +63,6 @@ type Server struct {
rev *docrevision.Service
ctxMgr *ctxmgr.ContextManager
cas *storage.CAS
pool *pgxpool.Pool
freeDoc *freedoc.Service
kb *kb.Client
sysadmin *sysadmin.Service
@@ -112,7 +111,6 @@ func NewFromRuntime(rt *app.Runtime) *Server {
rev: rt.Rev,
ctxMgr: rt.Ctx,
cas: rt.CAS,
pool: rt.Pool,
freeDoc: rt.FreeDoc,
kb: rt.KB,
sysadmin: rt.SysAdmin,
@@ -242,7 +240,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}/events", s.handleTaskRunEvents)
mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/cancel", s.handleCancelTaskRun)
mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/resume", s.handleResumeTaskRun)
mux.HandleFunc("POST /api/v1/admin/workspaces/reconcile", s.handleReconcileWorkspace)
mux.HandleFunc("POST /api/v1/admin/"+workspace.WorkspacesDirName+"/reconcile", s.handleReconcileWorkspace)
mux.HandleFunc("POST /api/v1/projects/{id}/assets", s.handleUploadProjectAsset)
mux.HandleFunc("GET /api/v1/projects/{id}/assets", s.handleListProjectAssets)
mux.HandleFunc("GET /api/v1/projects/{id}/assets/{asset_id}/raw", s.handleGetProjectAssetRaw)
@@ -351,7 +349,7 @@ func (s *Server) Routes() http.Handler {
// 10. 现代 SPA 前端页面与静态资产托管
mux.HandleFunc("GET /", s.serveSPA)
return s.corsMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux)))
return s.corsMiddleware(s.accessLogMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux))))
}
// Start 启动 HTTP 守护进程
@@ -570,16 +568,17 @@ func (s *Server) getOrCreateSession(
}
sess := chat.NewSession(sessionID, userCtx, s.rec, s.engine)
sess.ChatLog = s.chatLog
sess.CtxMgr = s.ctxMgr
env := "dev"
if s.cfg != nil && s.cfg.Env != "" {
env = s.cfg.Env
}
// Adapters live in internal/app; use server fields so SetIntentPlansForTest stays effective.
sess.ChatLog = app.AdaptChatMemory(s.chatLog)
sess.Actions = &chat.ActionServices{
DocGov: s.gov,
TaskRuns: s.taskRuns,
IntentPlans: s.intentPlans,
TaskRuns: app.AdaptChatTaskStarter(s.taskRuns),
IntentPlans: app.AdaptChatIntentPlans(s.intentPlans),
Environment: env,
}
+12 -9
View File
@@ -13,6 +13,7 @@ import (
"chat-agent-system/internal/agent"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
@@ -409,7 +410,7 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
return nil, nil
}
doc, err := s.gov.GetDocument(ctx, docID)
if errors.Is(err, docgov.ErrDocumentNotFound) {
if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound {
return nil, nil
}
if err != nil {
@@ -419,7 +420,7 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
return nil, nil
}
// 已结束的小队 run 由 AcquireLease 的孤儿回收逻辑处理,不在这里误拦截。
if doc.LeaseHolderType == docgov.LeaseHolderSquad && s.taskRuns != nil {
if doc.LeaseHolderType == vocabulary.LeaseHolderSquad && s.taskRuns != nil {
if run, runErr := s.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" {
return nil, nil
}
@@ -463,6 +464,15 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
afterSeq, _ = strconv.ParseInt(raw, 10, 64)
}
// Subscribe before declaring text/event-stream so NotFound stays a normal JSON error
// (same order as handleChatStream / CreateAndStart in sse.go).
ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
defer s.taskRuns.Unsubscribe(runID, ch)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
@@ -471,13 +481,6 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
flusher.Flush()
extendSSEWriteDeadline(w)
ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq)
if err != nil {
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
defer s.taskRuns.Unsubscribe(runID, ch)
writeStoredEvents := func(events []taskrun.StoredEvent) error {
for _, stored := range events {
data, err := json.Marshal(stored.Event)
+10 -4
View File
@@ -33,11 +33,17 @@ func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
candidates, err := s.rt.Trace.ListCandidateEdges(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"items": visible,
"hidden_count": hidden,
"index": index,
"recheck_marks": marks,
"items": visible,
"hidden_count": hidden,
"index": index,
"recheck_marks": marks,
"candidate_edges": candidates,
})
}
+3 -3
View File
@@ -42,9 +42,9 @@ func (s *Server) initArtifactVault() {
if err != nil {
return
}
var reg artifactvault.Registry
if s.pool != nil {
reg = artifactvault.NewPGRegistry(s.pool)
reg := artifactvault.Registry(nil)
if s.rt != nil {
reg = s.rt.ArtifactVaultRegistry()
} else {
reg = artifactvault.NewMemoryRegistry()
}
+10 -12
View File
@@ -54,25 +54,23 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q
CheckedAt: now,
}
// 1. 检查标题完整性 (对比 template.md 的 ## 章节)
// 1. 检查标题完整性 (对比 template.md 的 ## 章节;清洗后须精确相等,禁止自拟近义标题)
tmplPath := filepath.Join(wsDir, "template.md")
tmplData, err := os.ReadFile(tmplPath)
if err == nil {
expectedHeadings := extractHeadings(string(tmplData))
actualHeadings := extractHeadings(content)
actualSet := make(map[string]struct{}, len(actualHeadings))
for _, act := range actualHeadings {
actualSet[act] = struct{}{}
}
for _, h := range expectedHeadings {
found := false
for _, act := range actualHeadings {
if strings.Contains(act, h) || strings.Contains(h, act) {
found = true
break
}
}
if !found && !strings.Contains(content, h) {
report.MissingSections = append(report.MissingSections, h)
report.Violations = append(report.Violations, fmt.Sprintf("缺少必填章节: %s", h))
report.Score -= 15.0
if _, ok := actualSet[h]; ok {
continue
}
report.MissingSections = append(report.MissingSections, h)
report.Violations = append(report.Violations, fmt.Sprintf("缺少必填章节: %s", h))
report.Score -= 15.0
}
if len(report.MissingSections) > 0 {
report.TitleCheck = false
+30
View File
@@ -12,12 +12,23 @@ import (
var rdevReqIDPattern = regexp.MustCompile(`\b(UR|RS)-(\d+)\b`)
// rdProposalRequiredHeadings are the professional ## titles from templates/docs/rd_proposal.md.
// Exact cleaned match is required; fuzzy Contains is not enough for title alignment.
var rdProposalRequiredHeadings = []string{
"项目背景与立项依据",
"产品定义与范围",
"目标、里程碑与资源假设",
"主要风险与对策概览",
}
func rdevDeterministicViolations(wsDir, taskKey, content string) []string {
meta, ok := LookupRDevTask(taskKey)
if !ok {
return nil
}
switch taskKey {
case "rd_proposal":
return rdevProposalHeadingIssues(content)
case "rd_user_needs":
return rdevRequirementIssues(wsDir, content, "UR")
case "rd_system_reqs":
@@ -33,6 +44,25 @@ func rdevDeterministicViolations(wsDir, taskKey, content string) []string {
}
}
func rdevProposalHeadingIssues(content string) []string {
// Require literal "## 标题" lines (no numbering / engineer-suffix variants).
var issues []string
for _, want := range rdProposalRequiredHeadings {
exact := "## " + want
found := false
for _, line := range strings.Split(content, "\n") {
if strings.TrimSpace(line) == exact {
found = true
break
}
}
if !found {
issues = append(issues, "建议书章节标题未对齐模板: "+want)
}
}
return issues
}
func rdevRequirementIssues(wsDir, content, prefix string) []string {
var issues []string
if strings.Contains(content, "随机抽取") {
+16
View File
@@ -133,6 +133,22 @@ func TestRDevQualityGates(t *testing.T) {
}
}
proposalSample, err := os.ReadFile("../../.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_proposal-final.md")
if err != nil {
t.Fatal(err)
}
if issues := rdevProposalHeadingIssues(string(proposalSample)); len(issues) == 0 {
t.Fatal("P3 rd_proposal dry fixture must fail title alignment (self-authored headings)")
}
aligned := "## 结论\n\nok\n\n## 适用性\n\nok\n\n## 项目背景与立项依据\n\nok\n\n## 产品定义与范围\n\nok\n\n## 目标、里程碑与资源假设\n\nok\n\n## 主要风险与对策概览\n\nok\n\n## 追溯\n\n| RD- | |\n\n## 已有材料摘要\n\nok\n\n## 待确认清单\n\n- 无\n\n## 签署\n\nok\n\n## 说明\n\nok\n"
if issues := rdevProposalHeadingIssues(aligned); len(issues) != 0 {
t.Fatalf("template-aligned proposal must pass heading gate: %v", issues)
}
fuzzy := strings.ReplaceAll(aligned, "## 主要风险与对策概览", "## 风险清单")
if got := rdevProposalHeadingIssues(fuzzy); len(got) == 0 {
t.Fatal("renamed risk heading must fail exact title alignment")
}
thin := "已收照片 1 张。"
rep, err = gate.Validate(ws, "rd_prototype_photos", thin)
if err != nil {
+3 -1
View File
@@ -18,7 +18,9 @@ type rdevOverrideStep struct {
var rdevStageHints = map[string]rdevStageHint{
"rd_proposal": {
Hint: "按模板编制《项目建议书》(策划)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。",
Hint: "按模板编制《项目建议书》(策划)。## 标题必须与 template.md 完全一致:" +
"「项目背景与立项依据」「产品定义与范围」「目标、里程碑与资源假设」「主要风险与对策概览」;" +
"禁止改写为「风险清单」等自拟标题。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。",
Aliases: []string{"项目建议书", "立项建议"},
},
"rd_design_plan": {
+2 -2
View File
@@ -40,8 +40,8 @@ type CAS struct {
// NewCAS 创建 CAS 存储管理器
func NewCAS(baseDir string, pool *pgxpool.Pool, embedder Embedder) (*CAS, error) {
if baseDir == "" {
baseDir = "./data/dev/storage"
if strings.TrimSpace(baseDir) == "" {
return nil, errors.New("storage: baseDir is required (runtime roots come from config, not package defaults)")
}
blobDir := filepath.Join(baseDir, "blobs")
if err := os.MkdirAll(blobDir, 0755); err != nil {
+4 -4
View File
@@ -51,8 +51,8 @@ var featureMaturityCatalog = map[string]featureMaturity{
reason: "降级读保持关闭",
},
"MULTI_INTENT_PLAN_TIER": {
state: implDryRun, adapter: adapterUnavailable,
reason: "持久计划生产路径仍使用 Fake query owner,confirm 后缺少调度接线;调高档位不会变成可用",
state: implLive, adapter: adapterDegraded,
reason: "真实 query owner 与 confirm 后 DispatchEligible 已接线;默认仍 legacy,升 auto_read/confirm 需 I2 阈值与项目 allowlist",
},
"MULTI_INTENT_U9_SEGMENT": {
state: implDryRun, adapter: adapterUnavailable,
@@ -72,7 +72,7 @@ var featureMaturityCatalog = map[string]featureMaturity{
},
"SEMANTIC_SUMMARY_SHADOW": {
state: implShadow, adapter: adapterDegraded,
reason: "影子组件默认关闭;00058 尚未在运行环境执行。摘要请求已使用 reasoning profile,但当前容器要等镜像或挂载更新后 argv 才变成 --no-tools",
reason: "影子组件默认关闭;dev/test schema 61 已含 chat_summaries。摘要请求已用 reasoning profile,pi-agent 经 bind-mount 的 pi-args.mjs 带 --no-tools。授权可见集 digest 仍空",
},
"SEMANTIC_SUMMARY_REPLACE": {
state: implPlaceholder, adapter: adapterUnavailable,
@@ -80,7 +80,7 @@ var featureMaturityCatalog = map[string]featureMaturity{
},
"PI_CHANGE_REPORT_NARRATIVE": {
state: implShadow, adapter: adapterDegraded,
reason: "只读事实已含截断后的差异片段和影响说明,不含整篇文档。开关默认关闭。叙事请求已使用 reasoning profile,运行中的容器尚未切到 --no-tools",
reason: "只读事实已含截断后的差异片段和影响说明,不含整篇文档。开关默认关闭。叙事请求已用 reasoning profile;pi-agent 经 bind-mount 已带 --no-tools。未授权打开前保持 shadow",
},
}
+11 -3
View File
@@ -149,7 +149,15 @@ func TestFeatures_CoversCatalogAndInjectedValues(t *testing.T) {
}
}
if byKey["PI_CHANGE_REPORT_NARRATIVE"].ImplementationState != "shadow" || byKey["PI_CHANGE_REPORT_NARRATIVE"].AdapterReadiness != "degraded" {
t.Fatalf("narrative readiness must stay degraded while the flag is off and the live runner is not --no-tools: %+v", byKey["PI_CHANGE_REPORT_NARRATIVE"])
t.Fatalf("narrative readiness must stay degraded while the flag is off (shadow default), even though pi-agent already mounts --no-tools: %+v", byKey["PI_CHANGE_REPORT_NARRATIVE"])
}
if !strings.Contains(byKey["SEMANTIC_SUMMARY_SHADOW"].AdapterReason, "chat_summaries") {
t.Fatalf("semantic summary reason must reflect schema 61 chat_summaries: %+v", byKey["SEMANTIC_SUMMARY_SHADOW"])
}
if strings.Contains(byKey["SEMANTIC_SUMMARY_SHADOW"].AdapterReason, "尚未在运行环境执行") ||
strings.Contains(byKey["PI_CHANGE_REPORT_NARRATIVE"].AdapterReason, "尚未切到") {
t.Fatalf("readiness reasons must not claim missing migration or missing --no-tools: summary=%+v narrative=%+v",
byKey["SEMANTIC_SUMMARY_SHADOW"], byKey["PI_CHANGE_REPORT_NARRATIVE"])
}
}
@@ -181,8 +189,8 @@ func TestFeatures_ConfiguredValueDoesNotImplyReadiness(t *testing.T) {
t.Fatalf("replace=true must stay unwired: %+v", replace)
}
tier := byKey["MULTI_INTENT_PLAN_TIER"]
if tier.Value != "confirm" || tier.ImplementationState != "dry_run" || tier.AdapterReadiness != "unavailable" {
t.Fatalf("confirm tier must not look production-ready: %+v", tier)
if tier.Value != "confirm" || tier.ImplementationState != "live" || tier.AdapterReadiness != "degraded" {
t.Fatalf("confirm tier value must not imply rollout-ready: %+v", tier)
}
u9 := byKey["MULTI_INTENT_U9_SEGMENT"]
if u9.Value != "true" || u9.AdapterReadiness != "unavailable" {
+37 -5
View File
@@ -41,10 +41,16 @@ type Manager struct {
ctxMgr *ctxmgr.ContextManager
idemMu sync.Mutex
idemLocks map[string]*sync.Mutex // keyed by idempotency_key
idemLocks map[string]*idemLockEntry // keyed by idempotency_key; TTL-evicted in cleanupLoop
}
type idemLockEntry struct {
mu sync.Mutex
lastUsed time.Time
}
const runRetention = time.Hour
const idemLockRetention = time.Hour
var (
ErrRunNotResumable = errors.New("taskrun: run is not resumable")
@@ -58,7 +64,7 @@ func NewManager(pwSvc *projectworkflow.Service, ctxMgr *ctxmgr.ContextManager) *
workerID: newWorkerID(),
pwSvc: pwSvc,
ctxMgr: ctxMgr,
idemLocks: make(map[string]*sync.Mutex),
idemLocks: make(map[string]*idemLockEntry),
}
go m.cleanupLoop()
return m
@@ -71,12 +77,18 @@ func (m *Manager) lockIdempotencyKey(key string) func() {
m.idemMu.Lock()
lk, ok := m.idemLocks[key]
if !ok {
lk = &sync.Mutex{}
lk = &idemLockEntry{}
m.idemLocks[key] = lk
}
lk.lastUsed = time.Now()
m.idemMu.Unlock()
lk.Lock()
return lk.Unlock
lk.mu.Lock()
return func() {
lk.mu.Unlock()
m.idemMu.Lock()
lk.lastUsed = time.Now()
m.idemMu.Unlock()
}
}
// SetStore 注入持久化存储(PG);启动时标记 lease 过期的 running 任务为 interrupted。
@@ -102,6 +114,7 @@ func (m *Manager) cleanupLoop() {
defer ticker.Stop()
for range ticker.C {
m.evictStaleRuns()
m.evictStaleIdemLocks()
}
}
@@ -120,6 +133,25 @@ func (m *Manager) evictStaleRuns() {
}
}
func (m *Manager) evictStaleIdemLocks() {
cutoff := time.Now().Add(-idemLockRetention)
m.idemMu.Lock()
defer m.idemMu.Unlock()
for key, lk := range m.idemLocks {
if lk.lastUsed.After(cutoff) {
continue
}
// Skip locks currently held; TryLock avoids blocking cleanup on a live claim.
if !lk.mu.TryLock() {
continue
}
if !lk.lastUsed.After(cutoff) {
delete(m.idemLocks, key)
}
lk.mu.Unlock()
}
}
func (m *Manager) SetExecutor(exec Executor, resume ResumeExecutor, stage StageExecutor) {
m.exec = exec
m.resume = resume
+17
View File
@@ -302,6 +302,23 @@ func (s *PGStore) ListEvents(ctx context.Context, runID string, afterSeq int64)
return out, rows.Err()
}
// CancelOpenByProject cancels pending/running runs for a project inside an existing transaction.
// Table ownership stays with taskrun; workflow terminate must call this instead of writing task_runs directly.
func CancelOpenByProject(ctx context.Context, tx pgx.Tx, projectID int64, at time.Time, errMsg string) error {
if tx == nil {
return fmt.Errorf("taskrun: CancelOpenByProject requires tx")
}
if strings.TrimSpace(errMsg) == "" {
errMsg = "workflow terminated"
}
_, err := tx.Exec(ctx, `
UPDATE task_runs SET status = 'cancelled', updated_at = $2, error_message = $3
WHERE project_id = $1 AND status IN ('pending', 'running')
`, projectID, at, errMsg)
return err
}
func (s *PGStore) AcquireLease(ctx context.Context, runID, workerID string, ttl time.Duration) error {
if !s.Available() {
return ErrStoreNotAvailable

Some files were not shown because too many files have changed in this diff Show More