merge(dev): sync track-s1 shadow onto current local dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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{},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -238,4 +238,6 @@ type StepExec struct {
|
||||
ActionDigest string
|
||||
ExpectedResourceVersion string
|
||||
FencingToken int64
|
||||
Slots []byte
|
||||
SafeSummary string
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 创建上下文管理器
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+4
-2
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "随机抽取") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,7 +18,9 @@ type rdevOverrideStep struct {
|
||||
|
||||
var rdevStageHints = map[string]rdevStageHint{
|
||||
"rd_proposal": {
|
||||
Hint: "按模板编制《项目建议书》(策划)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。",
|
||||
Hint: "按模板编制《项目建议书》(策划)。## 标题必须与 template.md 完全一致:" +
|
||||
"「项目背景与立项依据」「产品定义与范围」「目标、里程碑与资源假设」「主要风险与对策概览」;" +
|
||||
"禁止改写为「风险清单」等自拟标题。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。",
|
||||
Aliases: []string{"项目建议书", "立项建议"},
|
||||
},
|
||||
"rd_design_plan": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user