From 08aa56b16c1ca8c86c0fecb3461025c5e24f41bd Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 01:01:39 +0000 Subject: [PATCH 1/3] feat(intentflow): re-authorize steps before confirm and execute Confirmation stays a command receipt; FakeActionOwner now fail-closes on revoke and resource version drift. Co-authored-by: Cursor --- .doc/system-design/module-boundaries.yaml | 1 + internal/app/app.go | 1 + internal/app/intentflow/authorize_test.go | 126 ++++++++++++++++ internal/app/intentflow/owner.go | 37 ++++- internal/app/intentflow/plans.go | 175 ++++++++++++++++++++-- 5 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 internal/app/intentflow/authorize_test.go diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 770beb63..c87d3d00 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -164,6 +164,7 @@ imports: - app - app/chatflow - app/docflow + - app/intentflow - app/stageflow - app/uow - auth diff --git a/internal/app/app.go b/internal/app/app.go index 25bdce9f..52934929 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -193,6 +193,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { rt.Docflow.UoW = uow.NewPG(pool) rt.Docflow.Events = events.PGSink{} rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{ + Owner: intentflow.NewFakeActionOwner(), Events: events.PGSink{}, }) } diff --git a/internal/app/intentflow/authorize_test.go b/internal/app/intentflow/authorize_test.go new file mode 100644 index 00000000..77bb135b --- /dev/null +++ b/internal/app/intentflow/authorize_test.go @@ -0,0 +1,126 @@ +package intentflow_test + +import ( + "context" + "errors" + "testing" + + "chat-agent-system/internal/app/intentflow" +) + +func TestConfirmDeniedDoesNotMutatePlan(t *testing.T) { + p, _, owner := testPlans() + ctx := context.Background() + created, err := p.CreatePlan(ctx, draft("digest-a")) + if err != nil { + t.Fatal(err) + } + owner.Denied = true + _, err = p.DecidePlan(ctx, intentflow.DecisionCommand{ + Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision, + DecisionNonce: "n-deny", DecisionDigest: "d-deny", Kind: intentflow.DecisionConfirm, + }) + if !errors.Is(err, intentflow.ErrPlanActionForbidden) { + t.Fatalf("want forbidden, got %v", err) + } + got, err := p.GetPlan(ctx, principal(), created.PlanID) + if err != nil { + t.Fatal(err) + } + if got.Revision != created.Revision || got.Status != intentflow.PlanAwaitingConfirmation { + t.Fatalf("confirm must be side-effect free on deny: %+v", got) + } +} + +func TestResourceVersionConflictOnConfirm(t *testing.T) { + p, _, owner := testPlans() + ctx := context.Background() + cmd := draft("digest-ver") + cmd.Steps[1].ExpectedResourceVersion = "v1" + created, err := p.CreatePlan(ctx, cmd) + if err != nil { + t.Fatal(err) + } + owner.RequiredVersion = map[string]string{"task.run": "v2"} + _, 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, got %v", err) + } +} + +func TestRevokeBetweenConfirmAndExecute(t *testing.T) { + p, _, owner := testPlans() + ctx := context.Background() + created, err := p.CreatePlan(ctx, draft("digest-rev")) + if err != nil { + t.Fatal(err) + } + confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{ + Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision, + DecisionNonce: "n-ok", DecisionDigest: "d-ok", Kind: intentflow.DecisionConfirm, + }) + if err != nil { + t.Fatal(err) + } + owner.Denied = true + stepID := confirmed.Steps[0].StepID + claimed, err := p.ClaimStep(ctx, "w1", confirmed.PlanID, stepID) + if err != nil { + t.Fatal(err) + } + if _, err := p.ExecuteClaimed(ctx, claimed); !errors.Is(err, intentflow.ErrPlanActionForbidden) { + t.Fatalf("revoked execute must fail closed, got %v", err) + } +} + +func TestEditBumpsRevisionAndInvalidatesOldCard(t *testing.T) { + p, _, _ := testPlans() + ctx := context.Background() + created, err := p.CreatePlan(ctx, draft("digest-edit")) + if err != nil { + t.Fatal(err) + } + edited, err := p.DecidePlan(ctx, intentflow.DecisionCommand{ + Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision, + DecisionNonce: "n-edit", DecisionDigest: "d-edit", Kind: intentflow.DecisionEdit, + }) + if err != nil { + t.Fatal(err) + } + if edited.Revision <= created.Revision { + t.Fatalf("edit must bump revision, got %d", edited.Revision) + } + if edited.Status != intentflow.PlanAwaitingConfirmation { + t.Fatalf("edit stays awaiting, got %s", edited.Status) + } + _, err = p.DecidePlan(ctx, intentflow.DecisionCommand{ + Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision, + DecisionNonce: "n-old", DecisionDigest: "d-old", Kind: intentflow.DecisionConfirm, + }) + if !errors.Is(err, intentflow.ErrPlanStaleRevision) { + t.Fatalf("old card must be stale, got %v", err) + } +} + +func TestIllegalPartialConfirmMissingDependency(t *testing.T) { + p, _, _ := testPlans() + ctx := context.Background() + cmd := draft("digest-dep") + cmd.Steps[1].DependsOn = []string{"stp_missing"} + created, err := p.CreatePlan(ctx, cmd) + if err != nil { + t.Fatal(err) + } + writeID := created.Steps[1].StepID + _, err = p.DecidePlan(ctx, intentflow.DecisionCommand{ + Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision, + DecisionNonce: "n-part", DecisionDigest: "d-part", Kind: intentflow.DecisionConfirm, + StepIDs: []string{writeID}, + }) + if !errors.Is(err, intentflow.ErrPlanDependencyInvalid) { + t.Fatalf("partial without closure must be invalid, got %v", err) + } +} diff --git a/internal/app/intentflow/owner.go b/internal/app/intentflow/owner.go index c5e1fd5c..d1790251 100644 --- a/internal/app/intentflow/owner.go +++ b/internal/app/intentflow/owner.go @@ -20,11 +20,20 @@ type FenceObserver interface { // FakeActionOwner is the U3 test double. It records executions and receipts // without touching business tables. type FakeActionOwner struct { - mu sync.Mutex - execCount map[string]int - receipts map[string]Receipt - fences map[string]int64 - FailWrite bool + mu sync.Mutex + execCount map[string]int + receipts map[string]Receipt + fences map[string]int64 + FailWrite bool + Denied bool + DeniedKeys map[string]bool + RequiredVersion map[string]string +} + +// StepAuthorizer re-checks the current principal before confirm and execute. +// Confirmation is not authorization. +type StepAuthorizer interface { + Authorize(ctx context.Context, exec StepExec) error } func NewFakeActionOwner() *FakeActionOwner { @@ -44,10 +53,28 @@ func (f *FakeActionOwner) ObserveFence(stepID string, token int64) { f.fences[stepID] = token } +func (f *FakeActionOwner) Authorize(ctx context.Context, exec StepExec) error { + if f == nil { + return ErrPlanActionForbidden + } + f.mu.Lock() + defer f.mu.Unlock() + if f.Denied || f.DeniedKeys[exec.ActionKey] { + return ErrPlanActionForbidden + } + if want, ok := f.RequiredVersion[exec.ActionKey]; ok && exec.ExpectedResourceVersion != "" && exec.ExpectedResourceVersion != want { + return ErrPlanActionVersionConflict + } + return nil +} + func (f *FakeActionOwner) Execute(ctx context.Context, exec StepExec) (Receipt, error) { if f == nil { return Receipt{}, ErrPlanActionForbidden } + if err := f.Authorize(ctx, exec); err != nil { + return Receipt{}, err + } f.mu.Lock() defer f.mu.Unlock() if cur, ok := f.fences[exec.StepID]; ok && exec.FencingToken != cur { diff --git a/internal/app/intentflow/plans.go b/internal/app/intentflow/plans.go index 28c9bcd3..f5136e1a 100644 --- a/internal/app/intentflow/plans.go +++ b/internal/app/intentflow/plans.go @@ -22,6 +22,7 @@ type Plans struct { uow uow.UnitOfWork store Store owner ActionOwner + authz StepAuthorizer events EventSink now func() time.Time newID func(prefix string) string @@ -30,12 +31,13 @@ type Plans struct { } type Options struct { - Owner ActionOwner - Events EventSink - Now func() time.Time - NewID func(prefix string) string - Lease time.Duration - PlanTTL time.Duration + Owner ActionOwner + Authorizer StepAuthorizer + Events EventSink + Now func() time.Time + NewID func(prefix string) string + Lease time.Duration + PlanTTL time.Duration } func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans { @@ -55,7 +57,26 @@ func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans { if ttl <= 0 { ttl = defaultPlanTTL } - return &Plans{uow: unit, store: store, owner: opt.Owner, events: opt.Events, now: now, newID: newID, lease: lease, ttl: ttl} + authz := opt.Authorizer + if authz == nil { + if a, ok := opt.Owner.(StepAuthorizer); ok { + authz = a + } + } + return &Plans{uow: unit, store: store, owner: opt.Owner, authz: authz, events: opt.Events, now: now, newID: newID, lease: lease, ttl: ttl} +} + +func (p *Plans) stepAuthorizer() StepAuthorizer { + if p == nil { + return nil + } + if p.authz != nil { + return p.authz + } + if a, ok := p.owner.(StepAuthorizer); ok { + return a + } + return nil } func randomID(prefix string) string { @@ -296,6 +317,9 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView, if in.ExpectedRevision != plan.Revision { return ErrPlanStaleRevision } + if err := p.authorizeDecision(ctx, plan, in); err != nil { + return err + } dec := &Decision{ DecisionID: p.newID("dec_"), PlanID: plan.PlanID, @@ -329,8 +353,12 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView, from := []PlanStatus{plan.Status} var to PlanStatus switch in.Kind { - case DecisionConfirm: - if plan.Status != PlanAwaitingConfirmation && plan.Status != PlanAwaitingClarification && plan.Status != PlanProposed { + case DecisionConfirm, DecisionConfirmRetry, DecisionContinue: + if in.Kind == DecisionConfirm { + if plan.Status != PlanAwaitingConfirmation && plan.Status != PlanAwaitingClarification && plan.Status != PlanProposed { + return ErrPlanStaleRevision + } + } else if plan.Status != PlanPartiallyCompleted && plan.Status != PlanExecuting { return ErrPlanStaleRevision } if err := applyConfirm(plan, in.StepIDs, in.ExcludeUnselected, now); err != nil { @@ -343,6 +371,22 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView, } cancelUnstarted(plan, now) to = PlanCancelled + case DecisionExclude: + if !isWaitingStatus(plan.Status) && plan.Status != PlanPartiallyCompleted { + return ErrPlanStaleRevision + } + if err := applyExclude(plan, in.StepIDs, now); err != nil { + return err + } + to = plan.Status + if allStepsTerminal(plan) { + to = PlanCancelled + } + case DecisionEdit: + if !isWaitingStatus(plan.Status) { + return ErrPlanStaleRevision + } + to = plan.Status default: return ErrPlanDependencyInvalid } @@ -364,6 +408,106 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView, return view, err } +func (p *Plans) authorizeDecision(ctx context.Context, plan *Plan, in DecisionCommand) error { + switch in.Kind { + case DecisionConfirm, DecisionConfirmRetry, DecisionContinue: + default: + return nil + } + auth := p.stepAuthorizer() + if auth == nil { + return nil + } + ids, err := selectedStepIDs(plan, in.StepIDs) + if err != nil { + return err + } + byID := map[string]Step{} + for _, s := range plan.Steps { + byID[s.StepID] = s + } + for _, id := range ids { + s := byID[id] + if err := auth.Authorize(ctx, StepExec{ + Principal: in.Principal, + PlanID: plan.PlanID, + StepID: s.StepID, + ActionKey: s.ActionKey, + ActionDigest: s.ActionDigest, + ExpectedResourceVersion: s.ExpectedResourceVersion, + }); err != nil { + return err + } + } + return nil +} + +func selectedStepIDs(plan *Plan, selected []string) ([]string, error) { + byID := map[string]*Step{} + for i := range plan.Steps { + byID[plan.Steps[i].StepID] = &plan.Steps[i] + } + if len(selected) == 0 { + ids := make([]string, 0, len(plan.Steps)) + for i := range plan.Steps { + ids = append(ids, plan.Steps[i].StepID) + } + return ids, nil + } + chosen := map[string]bool{} + for _, id := range selected { + if _, ok := byID[id]; !ok { + return nil, ErrPlanDependencyInvalid + } + chosen[id] = true + } + for id := range chosen { + for _, dep := range byID[id].DependsOn { + depStep, ok := byID[dep] + if !ok || (!chosen[dep] && depStep.Status != StepSucceeded && depStep.Status != StepSkipped) { + return nil, ErrPlanDependencyInvalid + } + } + } + ids := make([]string, 0, len(chosen)) + for id := range chosen { + ids = append(ids, id) + } + return ids, nil +} + +func applyExclude(plan *Plan, stepIDs []string, now time.Time) error { + if len(stepIDs) == 0 { + return ErrPlanDependencyInvalid + } + byID := map[string]*Step{} + for i := range plan.Steps { + byID[plan.Steps[i].StepID] = &plan.Steps[i] + } + for _, id := range stepIDs { + s, ok := byID[id] + if !ok { + return ErrPlanDependencyInvalid + } + if s.Status == StepPending || s.Status == StepEligible { + s.Status = StepCancelled + s.UpdatedAt = now + } + } + return nil +} + +func allStepsTerminal(plan *Plan) bool { + for _, s := range plan.Steps { + switch s.Status { + case StepSucceeded, StepFailed, StepDenied, StepSkipped, StepCancelled: + default: + return false + } + } + return true +} + func applyConfirm(plan *Plan, selected []string, excludeUnselected bool, now time.Time) error { byID := map[string]*Step{} for i := range plan.Steps { @@ -383,7 +527,8 @@ func applyConfirm(plan *Plan, selected []string, excludeUnselected bool, now tim } for id := range chosen { for _, dep := range byID[id].DependsOn { - if !chosen[dep] && byID[dep].Status != StepSucceeded && byID[dep].Status != StepSkipped { + depStep, ok := byID[dep] + if !ok || (!chosen[dep] && depStep.Status != StepSucceeded && depStep.Status != StepSkipped) { return ErrPlanDependencyInvalid } } @@ -462,7 +607,7 @@ func (p *Plans) ExecuteClaimed(ctx context.Context, claimed *ClaimedStep) (*Plan if p.owner == nil { return nil, ErrPlanActionForbidden } - rec, err := p.owner.Execute(ctx, StepExec{ + exec := StepExec{ Principal: claimed.Plan.Principal, PlanID: claimed.Plan.PlanID, StepID: claimed.Step.StepID, @@ -471,7 +616,13 @@ func (p *Plans) ExecuteClaimed(ctx context.Context, claimed *ClaimedStep) (*Plan ActionDigest: claimed.Step.ActionDigest, ExpectedResourceVersion: claimed.Step.ExpectedResourceVersion, FencingToken: claimed.FencingToken, - }) + } + if auth := p.stepAuthorizer(); auth != nil { + if err := auth.Authorize(ctx, exec); err != nil { + return nil, err + } + } + rec, err := p.owner.Execute(ctx, exec) if err != nil { return nil, err } From 3d444fa5e441d6df5ebc6acc97bebb8a02bebaaf Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 01:01:39 +0000 Subject: [PATCH 2/3] feat(api): mount authenticated intent plan decision routes Principal comes from the session and query scope; body identifiers are ignored and admins cannot skip membership. Co-authored-by: Cursor --- internal/server/intent_plan_handlers.go | 185 +++++++++++++++++ internal/server/intent_plan_handlers_test.go | 196 +++++++++++++++++++ internal/server/server.go | 117 ++++++----- 3 files changed, 444 insertions(+), 54 deletions(-) create mode 100644 internal/server/intent_plan_handlers.go create mode 100644 internal/server/intent_plan_handlers_test.go diff --git a/internal/server/intent_plan_handlers.go b/internal/server/intent_plan_handlers.go new file mode 100644 index 00000000..70ced247 --- /dev/null +++ b/internal/server/intent_plan_handlers.go @@ -0,0 +1,185 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/authz" +) + +type intentPlanDecisionBody struct { + ExpectedRevision int64 `json:"expected_revision"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest"` + Kind string `json:"kind"` + StepIDs []string `json:"step_ids"` + ExcludeUnselected bool `json:"exclude_unselected"` + UserID int64 `json:"user_id"` + ProjectID int64 `json:"project_id"` + SessionID string `json:"session_id"` +} + +func (s *Server) handleGetActiveIntentPlan(w http.ResponseWriter, r *http.Request) { + plans, prin, ok := s.planRequestScope(w, r) + if !ok { + return + } + view, err := plans.GetActivePlan(r.Context(), prin) + if err != nil { + writeIntentPlanError(w, r, plans, prin, "", err) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view}) +} + +func (s *Server) handleGetIntentPlan(w http.ResponseWriter, r *http.Request) { + plans, prin, ok := s.planRequestScope(w, r) + if !ok { + return + } + planID := r.PathValue("plan_id") + view, err := plans.GetPlan(r.Context(), prin, planID) + if err != nil { + writeIntentPlanError(w, r, plans, prin, planID, err) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view}) +} + +func (s *Server) handleIntentPlanDecision(w http.ResponseWriter, r *http.Request) { + plans, prin, ok := s.planRequestScope(w, r) + if !ok { + return + } + var body intentPlanDecisionBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]string{ + "error_code": "PLAN_DEPENDENCY_INVALID", + "message": "无效的决策请求", + }) + return + } + kind := intentflow.DecisionKind(strings.TrimSpace(body.Kind)) + view, err := plans.DecidePlan(r.Context(), intentflow.DecisionCommand{ + Principal: prin, + PlanID: r.PathValue("plan_id"), + ExpectedRevision: body.ExpectedRevision, + DecisionNonce: body.DecisionNonce, + DecisionDigest: body.DecisionDigest, + Kind: kind, + StepIDs: body.StepIDs, + ExcludeUnselected: body.ExcludeUnselected, + }) + if err != nil { + writeIntentPlanError(w, r, plans, prin, r.PathValue("plan_id"), err) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view}) +} + +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{ + "error_code": "PLAN_NOT_FOUND_OR_HIDDEN", + "message": "计划服务不可用", + }) + return nil, intentflow.Principal{}, false + } + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return nil, intentflow.Principal{}, false + } + projectID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("project_id")), 10, 64) + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + if err != nil || projectID <= 0 || sessionID == "" { + writeJSONResponse(w, http.StatusBadRequest, map[string]string{ + "error_code": "PLAN_DEPENDENCY_INVALID", + "message": "缺少 project_id 或 session_id", + }) + return nil, intentflow.Principal{}, false + } + if err := s.planProjectAccess(r.Context(), projectID, user.ID); err != nil { + if errors.Is(err, authz.ErrUnauthenticated) { + writeJSONResponse(w, http.StatusUnauthorized, map[string]string{ + "error_code": "ERR_UNAUTHENTICATED", + "message": "请先登录", + }) + return nil, intentflow.Principal{}, false + } + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该项目", + }) + return nil, intentflow.Principal{}, false + } + ownerID, ownerErr := s.planSessionOwner(r.Context(), sessionID) + if ownerErr != nil || ownerID <= 0 || ownerID != user.ID { + writeJSONResponse(w, http.StatusNotFound, map[string]string{ + "error_code": "PLAN_NOT_FOUND_OR_HIDDEN", + "message": "计划不存在或不可见", + }) + return nil, intentflow.Principal{}, false + } + env := "dev" + if s.cfg != nil && strings.TrimSpace(s.cfg.Env) != "" { + env = s.cfg.Env + } + return s.intentPlans, intentflow.Principal{ + UserID: user.ID, + Environment: env, + ProjectID: projectID, + SessionID: sessionID, + }, true +} + +func (s *Server) planProjectAccess(ctx context.Context, projectID, userID int64) error { + if s.planAccessFn != nil { + return s.planAccessFn(ctx, projectID, userID) + } + if s.authorizer == nil { + return authz.ErrForbidden + } + return s.authorizer.RequireProjectAccess(ctx, projectID, userID) +} + +func (s *Server) planSessionOwner(ctx context.Context, sessionID string) (int64, error) { + if s.planSessionOwnerFn != nil { + return s.planSessionOwnerFn(ctx, sessionID) + } + if s.ctxMgr == nil { + return 0, intentflow.ErrPlanNotFoundOrHidden + } + return s.ctxMgr.GetSessionOwnerID(ctx, sessionID) +} + +func writeIntentPlanError(w http.ResponseWriter, r *http.Request, plans intentflow.Service, prin intentflow.Principal, planID string, err error) { + code := intentflow.ErrorCode(err) + status := http.StatusInternalServerError + switch { + case errors.Is(err, intentflow.ErrUnauthenticated): + status = http.StatusUnauthorized + case errors.Is(err, intentflow.ErrPlanNotFoundOrHidden): + status = http.StatusNotFound + case errors.Is(err, intentflow.ErrPlanActionForbidden): + status = http.StatusForbidden + case errors.Is(err, intentflow.ErrPlanStaleRevision), + errors.Is(err, intentflow.ErrPlanExpired), + errors.Is(err, intentflow.ErrPlanDecisionConflict), + errors.Is(err, intentflow.ErrPlanActionVersionConflict): + status = http.StatusConflict + case errors.Is(err, intentflow.ErrPlanDependencyInvalid): + status = http.StatusBadRequest + } + body := map[string]any{"error_code": code, "message": code} + if plans != nil && (errors.Is(err, intentflow.ErrPlanStaleRevision) || errors.Is(err, intentflow.ErrPlanDecisionConflict)) && planID != "" { + if latest, getErr := plans.GetPlan(r.Context(), prin, planID); getErr == nil && latest != nil { + body["plan"] = latest + } + } + writeJSONResponse(w, status, body) +} diff --git a/internal/server/intent_plan_handlers_test.go b/internal/server/intent_plan_handlers_test.go new file mode 100644 index 00000000..82d8ad61 --- /dev/null +++ b/internal/server/intent_plan_handlers_test.go @@ -0,0 +1,196 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" +) + +func u5Server(t *testing.T) (*Server, *intentflow.Plans, *intentflow.FakeActionOwner, string, int64) { + t.Helper() + cfg := &config.Config{ + DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "dev", + ContextDbHost: "127.0.0.1", AdminUsername: "admin", AdminPassword: "admin-secret-password-123", + } + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + owner := intentflow.NewFakeActionOwner() + plans := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{Owner: owner}) + srv.intentPlans = plans + token, userID := u5Login(t, srv) + srv.planAccessFn = func(ctx context.Context, projectID, userID int64) error { + if projectID == 99 { + return authz.ErrForbidden + } + return nil + } + srv.planSessionOwnerFn = func(ctx context.Context, sessionID string) (int64, error) { + switch sessionID { + case "sess-u5", "sess-other": + return userID, nil + case "sess-foreign": + return userID + 99, nil + default: + return 0, intentflow.ErrPlanNotFoundOrHidden + } + } + return srv, plans, owner, token, userID +} + +func u5Login(t *testing.T, srv *Server) (string, int64) { + t.Helper() + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin-secret-password-123"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login %d %s", rec.Code, rec.Body.String()) + } + var sess auth.SessionInfo + if err := json.NewDecoder(rec.Body).Decode(&sess); err != nil || sess.Token == "" || sess.User == nil { + t.Fatalf("login decode: %v body=%s", err, rec.Body.String()) + } + return sess.Token, sess.User.ID +} + +func u5Do(srv *Server, method, path, token string, body any) *httptest.ResponseRecorder { + var r *http.Request + if body != nil { + raw, _ := json.Marshal(body) + r = httptest.NewRequest(method, path, bytes.NewReader(raw)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequest(method, path, nil) + } + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, r) + return rec +} + +func u5Create(t *testing.T, plans *intentflow.Plans, userID int64, projectID int64, sessionID, turn string) *intentflow.PlanView { + t.Helper() + view, err := plans.CreatePlan(context.Background(), intentflow.CreateCommand{ + Principal: intentflow.Principal{UserID: userID, Environment: "dev", ProjectID: projectID, SessionID: sessionID}, + ClientTurnID: turn, + RequestDigest: turn + "-digest", + WritePlan: true, + SafeSummary: "query then generate", + Steps: []intentflow.StepDraft{ + {ActionKey: "regulation.query", ActionDigest: "q1"}, + {ActionKey: "task.run", ActionDigest: "w1"}, + }, + }) + if err != nil { + t.Fatal(err) + } + return view +} + +func TestIntentPlanHTTP_Unauthenticated(t *testing.T) { + srv, _, _, _, _ := u5Server(t) + rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-u5", "", nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestIntentPlanHTTP_ActiveWithoutProjection(t *testing.T) { + srv, plans, _, token, userID := u5Server(t) + created := u5Create(t, plans, userID, 42, "sess-u5", "turn-active") + rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-u5", token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("active %d %s", rec.Code, rec.Body.String()) + } + var out struct { + Plan *intentflow.PlanView `json:"plan"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Plan == nil || out.Plan.PlanID != created.PlanID { + t.Fatalf("want stored plan, got %s err=%v", rec.Body.String(), err) + } +} + +func TestIntentPlanHTTP_ScopeNegatives(t *testing.T) { + srv, plans, _, token, userID := u5Server(t) + created := u5Create(t, plans, userID, 42, "sess-u5", "turn-scope") + + if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=99&session_id=sess-u5", token, nil); rec.Code != http.StatusForbidden { + t.Fatalf("non-member want 403, got %d %s", rec.Code, rec.Body.String()) + } + if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/"+created.PlanID+"?project_id=7&session_id=sess-u5", token, nil); rec.Code != http.StatusNotFound { + t.Fatalf("cross-project want 404, got %d %s", rec.Code, rec.Body.String()) + } + if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/"+created.PlanID+"?project_id=42&session_id=sess-other", token, nil); rec.Code != http.StatusNotFound { + t.Fatalf("cross-session want 404, got %d %s", rec.Code, rec.Body.String()) + } + if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-foreign", token, nil); rec.Code != http.StatusNotFound { + t.Fatalf("foreign session want 404, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt(t *testing.T) { + srv, plans, _, token, userID := u5Server(t) + created := u5Create(t, plans, userID, 42, "sess-u5", "turn-dec") + path := "/api/v1/intent-plans/" + created.PlanID + "/decisions?project_id=42&session_id=sess-u5" + body := map[string]any{ + "expected_revision": created.Revision, + "decision_nonce": "nonce-same", + "decision_digest": "digest-same", + "kind": "confirm", + "user_id": userID + 8, + "project_id": 99, + "session_id": "injected", + } + first := u5Do(srv, http.MethodPost, path, token, body) + if first.Code != http.StatusOK { + t.Fatalf("confirm %d %s", first.Code, first.Body.String()) + } + again := u5Do(srv, http.MethodPost, path, token, body) + if again.Code != http.StatusOK { + t.Fatalf("replay %d %s", again.Code, again.Body.String()) + } + var a, b struct { + Plan *intentflow.PlanView `json:"plan"` + } + _ = 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 { + t.Fatalf("same nonce must share receipt a=%+v b=%+v", a.Plan, b.Plan) + } +} + +func TestIntentPlanHTTP_StaleRevision(t *testing.T) { + srv, plans, _, token, userID := u5Server(t) + created := u5Create(t, plans, userID, 42, "sess-u5", "turn-stale") + path := "/api/v1/intent-plans/" + created.PlanID + "/decisions?project_id=42&session_id=sess-u5" + rec := u5Do(srv, http.MethodPost, path, token, map[string]any{ + "expected_revision": created.Revision + 3, + "decision_nonce": "n-stale", + "decision_digest": "d-stale", + "kind": "confirm", + }) + if rec.Code != http.StatusConflict { + t.Fatalf("stale want 409, got %d %s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("PLAN_STALE_REVISION")) { + t.Fatalf("want stale code, got %s", rec.Body.String()) + } +} + +func TestIntentPlanHTTP_AdminDoesNotBypassMembership(t *testing.T) { + srv, _, _, token, _ := u5Server(t) + rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=99&session_id=sess-u5", token, nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("admin must not bypass membership, got %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 68570335..5411cf6b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "chat-agent-system/internal/app" "chat-agent-system/internal/app/chatflow" + "chat-agent-system/internal/app/intentflow" "chat-agent-system/internal/app/stageflow" "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" @@ -38,35 +39,38 @@ import ( // Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务 type Server struct { - rt *app.Runtime - cfg *config.Config - svc *bizdev.Service - pwSvc *projectworkflow.Service - rec intent.Recognizer - engine *coordinator.CoordinatorEngine - authSvc *auth.Service - sessions map[string]*chat.Session - sessionsMu sync.RWMutex - taskRuns *taskrun.Manager - authorizer *authz.Authorizer - authzSvc *authz.Service - wfDefSvc *workflowdef.Service - instSvc *projectinstance.Service - notifySvc *notification.Service - resolver *workspace.Resolver - wsCap *workspace.Capability - cmds *taskrun.Commands - chatLog chatflow.ChatLog - assembler chatflow.ContextAssembler - stageflow *stageflow.UseCase - gov *docgov.GovernanceEngine - rev *docrevision.Service - ctxMgr *ctxmgr.ContextManager - cas *storage.CAS - pool *pgxpool.Pool - freeDoc *freedoc.Service - kb *kb.Client - httpServer *http.Server + rt *app.Runtime + cfg *config.Config + svc *bizdev.Service + pwSvc *projectworkflow.Service + rec intent.Recognizer + engine *coordinator.CoordinatorEngine + authSvc *auth.Service + sessions map[string]*chat.Session + sessionsMu sync.RWMutex + taskRuns *taskrun.Manager + authorizer *authz.Authorizer + authzSvc *authz.Service + wfDefSvc *workflowdef.Service + instSvc *projectinstance.Service + notifySvc *notification.Service + resolver *workspace.Resolver + wsCap *workspace.Capability + cmds *taskrun.Commands + chatLog chatflow.ChatLog + assembler chatflow.ContextAssembler + stageflow *stageflow.UseCase + gov *docgov.GovernanceEngine + rev *docrevision.Service + ctxMgr *ctxmgr.ContextManager + cas *storage.CAS + pool *pgxpool.Pool + freeDoc *freedoc.Service + kb *kb.Client + httpServer *http.Server + intentPlans intentflow.Service + planAccessFn func(ctx context.Context, projectID, userID int64) error + planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error) } // NewFromRuntime attaches the HTTP adapter to an already-wired composition root. @@ -75,31 +79,32 @@ func NewFromRuntime(rt *app.Runtime) *Server { panic("server: nil runtime") } s := &Server{ - rt: rt, - cfg: rt.Cfg, - svc: rt.Biz, - pwSvc: rt.PW, - rec: rt.Rec, - engine: rt.Engine, - authSvc: rt.Auth, - sessions: make(map[string]*chat.Session), - taskRuns: rt.TaskRuns, - authorizer: rt.Authorizer, - authzSvc: rt.Authz, - wfDefSvc: rt.WFDef, - instSvc: rt.Inst, - notifySvc: rt.Notify, - resolver: rt.Resolver, - wsCap: rt.WSCap, - cmds: rt.Commands, - stageflow: rt.Stageflow, - gov: rt.Gov, - rev: rt.Rev, - ctxMgr: rt.Ctx, - cas: rt.CAS, - pool: rt.Pool, - freeDoc: rt.FreeDoc, - kb: rt.KB, + rt: rt, + cfg: rt.Cfg, + svc: rt.Biz, + pwSvc: rt.PW, + rec: rt.Rec, + engine: rt.Engine, + authSvc: rt.Auth, + sessions: make(map[string]*chat.Session), + taskRuns: rt.TaskRuns, + authorizer: rt.Authorizer, + authzSvc: rt.Authz, + wfDefSvc: rt.WFDef, + instSvc: rt.Inst, + notifySvc: rt.Notify, + resolver: rt.Resolver, + wsCap: rt.WSCap, + cmds: rt.Commands, + stageflow: rt.Stageflow, + gov: rt.Gov, + rev: rt.Rev, + ctxMgr: rt.Ctx, + cas: rt.CAS, + pool: rt.Pool, + freeDoc: rt.FreeDoc, + kb: rt.KB, + intentPlans: rt.IntentPlans, } s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz()) s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog) @@ -187,6 +192,10 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/chat/stream", s.handleChatStream) mux.HandleFunc("GET /api/v1/chat/turns/{client_turn_id}", s.handleGetChatTurn) + mux.HandleFunc("GET "+intentflow.PathActivePlan, s.handleGetActiveIntentPlan) + mux.HandleFunc("GET "+intentflow.PathPlanByID, s.handleGetIntentPlan) + mux.HandleFunc("POST "+intentflow.PathPlanDecisions, s.handleIntentPlanDecision) + // 5. 独立任务 / 阶段执行 mux.HandleFunc("POST /api/v1/tasks/run", s.handleRunTask) mux.HandleFunc("POST /api/v1/tasks/run/stream", s.handleRunTaskStream) From e16fb7eff2bde669114803a2244042d391c6926c Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 01:01:39 +0000 Subject: [PATCH 3/3] docs(test): record U5 decision API and authorization report Close the U5 checklist with HTTP scope negatives and same-nonce receipts. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 6 +- ...2026-09-16-multi-intent-u5-decision-api.md | 20 ++++++ .../2026-09-15-multi-intent-hitl.md | 4 +- .../2026-09-15-promote-dev-to-test.md | 46 ++++++++++++ ...2026-09-16-multi-intent-u5-decision-api.md | 54 ++++++++++++++ .doc/status/FEATURE-MATRIX.md | 4 +- .doc/system-design/README.md | 1 + ...ulti-intent-u5-decision-api-test-report.md | 66 ++++++++++++++++++ .../__pycache__/dual-tab-live.cpython-314.pyc | Bin 0 -> 27082 bytes .../authorize-verbose.txt | 12 ++++ .../http-verbose.txt | 20 ++++++ .../short.txt | 61 ++++++++++++++++ 12 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 .doc/ops/checklists/2026-09-16-multi-intent-u5-decision-api.md create mode 100644 .doc/ops/short-term/2026-09-15-promote-dev-to-test.md create mode 100644 .doc/ops/short-term/2026-09-16-multi-intent-u5-decision-api.md create mode 100644 .doc/test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-15-conversation-intent-dual-tab/__pycache__/dual-tab-live.cpython-314.pyc create mode 100644 .doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt create mode 100644 .doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt create mode 100644 .doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 29d07afc..a126ee16 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **MULTI-INTENT HITL**:U4 已合入本地 `dev`([U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md));下一单元 U5 decision API 待认领 +1. **MULTI-INTENT HITL**:U5 合入中;U6 Web 待认领 2. **发布门禁**:CI `TEST_DATABASE_URL`、真实 PI 三工作流 E2E、前端人工抽检仍开放([gap-matrix](../remediation/gap-matrix.md)) 3. **CHAT-INTENT**:双标签 busy 已签(项目 **6275**);不删 `sess-p143-u9`;项目 143 只读 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-16-multi-intent-u5-decision-api | feature/multi-intent-u5-decision-api | - | intent-plan | done | | Cursor | 2026-09-16-multi-intent-u4-outbox-projection | feature/multi-intent-u4-outbox-projection | - | events | done | | Cursor | 2026-09-15-multi-intent-u3-intentflow-pg | feature/multi-intent-u3-intentflow-pg | - | intent-plan | done | | Cursor | 2026-09-15-multi-intent-u2-segmenter | feature/multi-intent-u2-segmenter | - | intent | done | @@ -88,7 +89,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Cursor | merging | 2026-09-16 | U5 decision API merge | - | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -126,6 +127,7 @@ | 多意图 HITL U2 分段 | [../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) | | 多意图 HITL U3 PG | [../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | | 多意图 HITL U4 投影 | [../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) | +| 多意图 HITL U5 API | [../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) | | 文档可靠性 M1 报告 | [../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | | 文档可靠性 M1 活验 | [../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) | | 文档可靠性 M2 | [../test-reports/2026-09-14-document-reliability-m2-test-report.md](../test-reports/2026-09-14-document-reliability-m2-test-report.md) | diff --git a/.doc/ops/checklists/2026-09-16-multi-intent-u5-decision-api.md b/.doc/ops/checklists/2026-09-16-multi-intent-u5-decision-api.md new file mode 100644 index 00000000..76c908d0 --- /dev/null +++ b/.doc/ops/checklists/2026-09-16-multi-intent-u5-decision-api.md @@ -0,0 +1,20 @@ +--- +title: "多意图 HITL U5 decision API checklist" +status: done +created: 2026-09-16 +updated: 2026-09-16 +related_task: "2026-09-16-multi-intent-u5-decision-api" +--- + +# 多意图 HITL U5 decision API checklist + +- [x] 冻结 REST 路径挂到认证 mux — 证据:[http-verbose.txt](../../test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt) +- [x] Principal 不从 body 复制 — 证据:`TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt` +- [x] 非成员/跨作用域/管理员负例 — 证据:`TestIntentPlanHTTP_ScopeNegatives` / `AdminDoesNotBypassMembership` +- [x] StepAuthorizer 在 confirm 与 execute 前调用 — 证据:[authorize-verbose.txt](../../test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt) +- [x] stale/nonce/edit/非法部分确认 — 证据:同上 +- [x] arch-check / short 绿 — 证据:[short.txt](../../test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt) + +## 完成定义 + +- [x] 全部勾选且有证据链接 diff --git a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md index 656e0103..605b0495 100644 --- a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md +++ b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md @@ -55,7 +55,7 @@ mutex: "-" 2. [x] U2 characterization-first 建 golden/hard-negative 与 shadow 基线([独立任务](./2026-09-15-multi-intent-u2-segmenter.md);[报告](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md)); 3. [x] U3 建 intentflow owner、PG schema、CAS/lease/fencing/retention([独立任务](./2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md)); 4. [x] U4 接 Track E outbox/dispatcher,只投影状态事实([独立任务](./2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md)); -5. [ ] U5 建统一 decision command 与动作 owner 授权端口; +5. [x] U5 建统一 decision command 与动作 owner 授权端口([独立任务](./2026-09-16-multi-intent-u5-decision-api.md);[报告](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md)); 6. [ ] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复; 7. [ ] U7 只开放独立低风险查询; 8. [ ] U8 只接 task generate,完成首版 real PI 闭环; @@ -94,7 +94,7 @@ mutex: "-" - Checklist: [U1](../checklists/2026-09-15-multi-intent-u1-contracts.md);[U2](../checklists/2026-09-15-multi-intent-u2-segmenter.md);[U3](../checklists/2026-09-15-multi-intent-u3-intentflow-pg.md) - Handoff: 每个 U-ID 独立建立 -- Test report: [U1](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) +- Test report: [U1](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) ## 证据 / 备注 diff --git a/.doc/ops/short-term/2026-09-15-promote-dev-to-test.md b/.doc/ops/short-term/2026-09-15-promote-dev-to-test.md new file mode 100644 index 00000000..91ecf3eb --- /dev/null +++ b/.doc/ops/short-term/2026-09-15-promote-dev-to-test.md @@ -0,0 +1,46 @@ +--- +title: "晋升 dev → test 并部署测试环境" +status: done +created: 2026-09-15 +updated: 2026-09-15 +owner: Cursor +branch: feature/promote-dev-to-test-2026-09-15 +worktree: "-" +mutex: infra +--- + +# 晋升 dev → test 并部署测试环境 + +## 目标 + +把已完成的 Phase 2 代码从本地 `dev` 合入 `test`,重建并冒烟测试环境,供人工实测。不实施多意图 HITL。 + +## 范围 + +- `test` 分支 `--no-ff` 合入当前 `dev` +- `./scripts/env-service.sh test rebuild` +- test 库 notify/workflow_advance/projection 切 live(L4 去 hook 后 off 会停写);FAV 保持 off + +## 非目标 + +- 不实施多意图 HITL U1+ +- 不合入 `main`、不启用 FAV +- 不写项目 143 + +## 步骤 + +1. [x] 确认 Phase 2 已在本地 `dev` +2. [x] occupy 集成槽并合入 `test` +3. [x] rebuild 测试服务 +4. [x] health/ready + schema 冒烟 + +## 验收标准 + +- [x] `:8081` health/ready PASS +- [x] events `:8089` health PASS +- [x] FAV 仍 off + +## 相关链接 + +- Tag: `rc-v1.0.0-beta.1` +- Merge: `test@2e826e7` diff --git a/.doc/ops/short-term/2026-09-16-multi-intent-u5-decision-api.md b/.doc/ops/short-term/2026-09-16-multi-intent-u5-decision-api.md new file mode 100644 index 00000000..d8cb8309 --- /dev/null +++ b/.doc/ops/short-term/2026-09-16-multi-intent-u5-decision-api.md @@ -0,0 +1,54 @@ +--- +title: "多意图 HITL U5:决策 API 与授权端口" +status: done +created: 2026-09-16 +updated: 2026-09-16 +owner: Cursor +branch: feature/multi-intent-u5-decision-api +worktree: "-" +mutex: intent-plan +--- + +# 多意图 HITL U5:决策 API 与授权端口 + +## 目标 + +按钮与自然语言走同一 `DecidePlan` 命令;GET 发现活动计划不依赖投影;执行前由动作属主重新授权。确认不是授权。 + +## 范围 + +- 冻结路径:`GET /api/v1/intent-plans/active`、`GET /api/v1/intent-plans/{plan_id}`、`POST /api/v1/intent-plans/{plan_id}/decisions` +- Principal 只从认证会话 + query 的 project/session 组装,忽略 body 中的 user/project/session +- 四元作用域;非成员 403;跨 session/user 隐藏为 404 +- 系统管理员不得绕过项目成员检查 +- `StepAuthorizer` 端口 + Fake/dry-run;确认与执行前鉴权 +- expected revision、nonce、依赖闭包、edit 升 revision + +## 非目标 + +- 不接真实业务属主 adapter(U7/U8) +- 不改 Web(U6)、不改默认 legacy HandleTurn +- 不 rebuild 共享 `:8080` + +## 步骤 + +1. [x] 认领 CURRENT(mutex `intent-plan`) +2. [x] 授权端口与 confirm/execute 即时鉴权 +3. [x] HTTP handlers + 权限负例 +4. [x] 报告并合回本地 dev + +## 验收标准 + +- [ ] 投影缺失仍能 GET active +- [ ] 未登录 401;非成员 403;跨项目/会话/用户隐藏 +- [ ] stale revision 409;重复 nonce 同 digest 同 receipt +- [ ] 非法部分确认拒绝;edit 使旧 revision 失效 +- [ ] 确认前零副作用;权限撤销/资源版本 fail closed +- [ ] 无系统身份绕过 +- [ ] `go test -short` 绿 + +## 相关链接 + +- 总览: [2026-09-15-multi-intent-hitl.md](./2026-09-15-multi-intent-hitl.md) +- 设计: [../../system-design/2026-09-15-multi-intent-hitl-plan.md](../../system-design/2026-09-15-multi-intent-hitl-plan.md) +- Checklist: [../checklists/2026-09-16-multi-intent-u5-decision-api.md](../checklists/2026-09-16-multi-intent-u5-decision-api.md) diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 9df4ce28..f696ebde 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -15,8 +15,8 @@ | 模块 | 能力 | 状态 | 证据 | 缺口/后续 | mutex | |------|------|------|------|-----------|-------| -| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 分段](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 PG](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1–U3 已完成(契约/shadow 提案/PG CAS);U4–U8 未实施;intent 纯依赖边界不得回退 | `intent` | -| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) | U1–U4 已完成。下一单元 U5 决策 API;U6 Web;U7–U8 只读执行与 task.generate。共享环境 schema 45 待晋升 | `intent-plan` | +| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 分段](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 PG](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1–U5 已完成;U6–U8 未实施;intent 纯依赖边界不得回退 | `intent` | +| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5 报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) | U1–U5 已完成。下一单元 U6 Web;U7–U8 只读执行与 task.generate。共享环境 schema 45 待晋升 | `intent-plan` | | 对话 / ctxmgr | 多轮上下文、记忆、摘要 | partial | [system-design/multi-turn-dialogue](../system-design/multi-turn-dialogue/);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md);[U10 端口化](../archive/ops/2026-09-15-chatlog-contextasm-u10.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) | `CHAT-HISTORY-REFRESH` 闲聊刷新活验已补。U10 原地端口 **停止扩面**(未物理拆包)。语义摘要(D5 backlog) | `chat` | | 智能体小队 | 角色-任务-工作区-黑板 | done | [system-design/agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6 backlog) | `squad` | | 工作流治理 | 三正式工作流 + 阶段门禁 | partial | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md);[冻结态/职责可选整改](../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md);[Wave 5 页面矩阵](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[职责-动作鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md) | 设置页/DutyMode + 动作级鉴权/未激活 202-blocked/关闭创建直通已落地。后续:none 文档真实 PI 审批长链路、CI 三工作流 E2E | `workflow` | diff --git a/.doc/system-design/README.md b/.doc/system-design/README.md index 29369446..04bef869 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -130,6 +130,7 @@ | U2 分段 | [../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md](../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md);[报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) | | U3 PG | [../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md](../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | | U4 投影 | [../ops/short-term/2026-09-16-multi-intent-u4-outbox-projection.md](../ops/short-term/2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) | +| U5 API | [../ops/short-term/2026-09-16-multi-intent-u5-decision-api.md](../ops/short-term/2026-09-16-multi-intent-u5-decision-api.md);[报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) | | 一句话总结 | intent 保持纯规则,intentflow 持久化计划并统一确认、授权、幂等、恢复和逐动作执行 | **硬边界**:确认不等于授权;PG 是计划真相,卡片/SSE 是投影;写动作按 owner 端口逐项接入;LLM 只能提出不可信分段建议。 diff --git a/.doc/test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md b/.doc/test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md new file mode 100644 index 00000000..9344d729 --- /dev/null +++ b/.doc/test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md @@ -0,0 +1,66 @@ +# 多意图 HITL U5 决策 API 与授权端口 测试报告 + +## 一、 测试背景 (Background) + +U4 已把计划事实写入 outbox。U5 要把按钮和自然语言接到同一 `DecidePlan` 命令,并在确认/执行前由动作属主重新授权。确认不是授权;系统管理员不得绕过项目成员检查。 + +## 二、 测试目标 (Test Objectives) + +- 冻结 REST:active / `{plan_id}` / decisions +- Principal 只来自认证会话,忽略 body 中的 user/project/session +- 未登录 401;非成员 403;跨项目/会话隐藏 404 +- 投影缺失仍能 GET active +- 权限撤销、资源版本、stale revision、非法部分确认、edit 升 revision +- 重复 nonce 同 digest 返回同一 receipt + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| OS | Linux 7.0.0-31-generic | +| Go | go test -short | +| 分支 | `feature/multi-intent-u5-decision-api` | +| 后端 | 内存计划账本 + FakeActionOwner(dry-run) | +| 项目 143 | 只读;不 rebuild `:8080` | +| 证据 | `.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/` | + +## 四、 预期结果 (Expected Results) + +确认被拒绝时计划 revision/status 不变;执行前撤销权限 fail closed;HTTP 作用域负例全绿;arch-check 与全仓 short 绿。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 授权端口 + +- **测试输入**:`go test -v ./internal/app/intentflow -run 'TestConfirmDenied|TestResourceVersion|TestRevokeBetween|TestEditBumps|TestIllegalPartial'` +- **实际输出**:见 [authorize-verbose.txt](assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt)。五用例 PASS。 +- **评价判定**:PASS。确认前零副作用;旧卡 edit 后 409。 +- **性能与耗时**:4ms + +### 5.2 HTTP 作用域与幂等 + +- **测试输入**:`go test -v ./internal/server -run TestIntentPlanHTTP` +- **实际输出**:见 [http-verbose.txt](assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt)。六用例 PASS(含管理员不得绕过成员检查、body principal 忽略、同 nonce receipt)。 +- **评价判定**:PASS。 +- **性能与耗时**:0.77s + +### 5.3 全仓 short / arch-check + +- **测试输入**:`./scripts/arch-check.sh --mode block`;`go test -short -count=1 ./...` +- **实际输出**:`arch-check block: production gate passed`。[short.txt](assets/2026-09-16-multi-intent-u5-decision-api/short.txt) +- **评价判定**:PASS。 +- **性能与耗时**:约 13s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go test ./internal/app/intentflow ./internal/server ./internal/archtest` | PASS | +| `go test -short ./...` | PASS | +| `./scripts/arch-check.sh --mode block` | PASS | + +通过率 100%。真实业务 adapter 与 Web 不在本单元。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +U5 达到合入标准。后续 U6 以 PlanView 渲染卡片并 GET reconcile;U7 才开放 auto_read 查询执行。 diff --git a/.doc/test-reports/assets/2026-09-15-conversation-intent-dual-tab/__pycache__/dual-tab-live.cpython-314.pyc b/.doc/test-reports/assets/2026-09-15-conversation-intent-dual-tab/__pycache__/dual-tab-live.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7310a84cc4ec6aa3c60f2387b286e0a0af9d7bf8 GIT binary patch literal 27082 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�^pFRf!gEf59>^hV=vAfSTeQ#%(ryP#`K>HpQ4w}Uj*ailBiXDAI>d` z+Qjg7K-?+*U+Aq<3`V3C9@v68BJKsN{d z+Vnss3N~M_3RDIAL&bM9P4^NMV8>_@kRnb1J0_0+1u8>&lcd0*!t|bEN&!bB;hfP$ zX(r}1k$?M&2Ckn9Yx01CwFSZAu(lLzHeF$`Hmut`+4l3?!m#1w)uRDX(00>t!x3tI zZFJVRSkmxv!MyEV#}6F~9pP4rIo`L}+IOYEUvTy42pRkhS6VK&1j^n^DZb@cOs-w*ITqe|9Kr?LoZ$zl zZ>P>(d~a6=ye~p(@^KIfain1sg!%0!-&HRiwlj`1%ZJb2IeZpIdk&C{(G8f+yVV*t zlDAre=VonhIlkqXYyI}72ckTZPPkw0nAGf_$>&K@uU;zv%TlmZUkqPD;5_e!2Wg)#a*SZaB#_ z*%{Gi1d175{$$%P6-kqqR&2?E1@HU)~)|zMEVelHN_K2Qzl};BH1)$b2__TR6FTO8HBD zb|gE0>fjpv2zS|~-%E_wkH8Ry>wULJz*OL5Z7}0*dKtzca2J|mOhmL>XE&pug5x+u zHuqE-#BmystyzyEdDazzwM)`=kVLOPFdYB}0%pd?x0pqY7NP1D40;_SwY@WkddT}DaHFRiGlk|QeSopR#oBMoC>)2B)c}8 zUKek}#>D5Qpa%)g)QA$8_g=DEC!cJ6kcOnE+uyn*&0dkJUM!v|f4V9`;=kectswE0~AfZ`86q-fuec)nwzcizvVW0F@(q#O|k)y{4@~nnNsq}*qc&Owb z$uk-nbr~NO${cgq6K9utF~GP zz4&S;D=mg^oPQNNbe}=5g%H4&0DS#>75Kr&X*v%j>7q%|3*oUln=V66HsrMi?-V$9 zoi4fn)scgeb5W3of_xMpZ!rzKjbS;|*6+sGxEUYg3Tx}qF^T~ zg*@VGJz@ssb`)7k453Gu%doh(Xgm! zm@f(|S|_D27nnIdeH>k%&g4{t&W01}C)H~bhvBMoNK8yZH#=R?UDh%A)w7-9#Ql>A zYZ4EH6Zb))N;8u+oi&p;ofnZBB5-?8+cRy?9e(DpzY$#rdgbut!@>4&N@ZADbzde? ziznsazkq(8dFIS>W6zBFj|Y=(rr$`vnR6p2G&p-UT+kSnH%&?+N;SI52i-I3rviOp zW%;Bml9J94h6nOAQIqJ?YME%)r-?F={?o)nk>S%46$BX?QPQV5$)fF_R!Bu@pX&N$ eqS|{0G=x+;g3QY*~VrCIn=ss9Hl31nOV literal 0 HcmV?d00001 diff --git a/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt new file mode 100644 index 00000000..a1141596 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt @@ -0,0 +1,12 @@ +=== RUN TestConfirmDeniedDoesNotMutatePlan +--- PASS: TestConfirmDeniedDoesNotMutatePlan (0.00s) +=== RUN TestResourceVersionConflictOnConfirm +--- PASS: TestResourceVersionConflictOnConfirm (0.00s) +=== RUN TestRevokeBetweenConfirmAndExecute +--- PASS: TestRevokeBetweenConfirmAndExecute (0.00s) +=== RUN TestEditBumpsRevisionAndInvalidatesOldCard +--- PASS: TestEditBumpsRevisionAndInvalidatesOldCard (0.00s) +=== RUN TestIllegalPartialConfirmMissingDependency +--- PASS: TestIllegalPartialConfirmMissingDependency (0.00s) +PASS +ok chat-agent-system/internal/app/intentflow 0.004s diff --git a/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt new file mode 100644 index 00000000..d1cff64e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt @@ -0,0 +1,20 @@ +=== RUN TestIntentPlanHTTP_Unauthenticated +2026/09/16 00:59:18 [kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式 +--- PASS: TestIntentPlanHTTP_Unauthenticated (0.13s) +=== RUN TestIntentPlanHTTP_ActiveWithoutProjection +time=2026-09-16T00:59:18.421Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式" +--- PASS: TestIntentPlanHTTP_ActiveWithoutProjection (0.13s) +=== RUN TestIntentPlanHTTP_ScopeNegatives +time=2026-09-16T00:59:18.550Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式" +--- PASS: TestIntentPlanHTTP_ScopeNegatives (0.13s) +=== RUN TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt +time=2026-09-16T00:59:18.678Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式" +--- PASS: TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt (0.12s) +=== RUN TestIntentPlanHTTP_StaleRevision +time=2026-09-16T00:59:18.800Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式" +--- PASS: TestIntentPlanHTTP_StaleRevision (0.14s) +=== RUN TestIntentPlanHTTP_AdminDoesNotBypassMembership +time=2026-09-16T00:59:18.938Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式" +--- PASS: TestIntentPlanHTTP_AdminDoesNotBypassMembership (0.12s) +PASS +ok chat-agent-system/internal/server 0.774s diff --git a/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt new file mode 100644 index 00000000..9449e826 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt @@ -0,0 +1,61 @@ +ok chat-agent-system/cmd/bizdev-cli 1.940s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.003s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/recategorize [no test files] +? chat-agent-system/cmd/server [no test files] +ok chat-agent-system/internal/agent 5.029s +ok chat-agent-system/internal/app 0.014s +ok chat-agent-system/internal/app/chatflow 0.004s +ok chat-agent-system/internal/app/docadvance 0.003s +ok chat-agent-system/internal/app/docflow 0.004s +ok chat-agent-system/internal/app/docnotify 0.003s +ok chat-agent-system/internal/app/docproject 0.003s +ok chat-agent-system/internal/app/intentflow 0.003s +ok chat-agent-system/internal/app/stageflow 0.004s +ok chat-agent-system/internal/app/uow 0.003s +ok chat-agent-system/internal/archtest 0.182s +ok chat-agent-system/internal/artifactvault 0.005s +ok chat-agent-system/internal/auth 0.822s +ok chat-agent-system/internal/authz 0.003s +ok chat-agent-system/internal/bizdev 0.170s +ok chat-agent-system/internal/chat 0.018s +ok chat-agent-system/internal/config 0.002s +ok chat-agent-system/internal/coordinator 0.016s +ok chat-agent-system/internal/ctxmgr 0.087s +ok chat-agent-system/internal/dataset 0.005s +ok chat-agent-system/internal/db 0.005s +ok chat-agent-system/internal/doccontent 0.002s +ok chat-agent-system/internal/docduty 0.005s +ok chat-agent-system/internal/docgov 0.018s +ok chat-agent-system/internal/docquery 0.005s +ok chat-agent-system/internal/docrevision 0.007s +ok chat-agent-system/internal/events 0.006s +ok chat-agent-system/internal/freedoc 0.027s +ok chat-agent-system/internal/fsutil 0.003s +ok chat-agent-system/internal/httpx 0.004s +ok chat-agent-system/internal/intent 0.030s +ok chat-agent-system/internal/kb 0.005s +ok chat-agent-system/internal/maturity 0.002s +ok chat-agent-system/internal/notification 0.079s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.005s +ok chat-agent-system/internal/projectinstance 0.007s +ok chat-agent-system/internal/projectworkflow 0.004s +ok chat-agent-system/internal/quote 0.009s +ok chat-agent-system/internal/replyexport 0.003s +ok chat-agent-system/internal/server 10.686s +ok chat-agent-system/internal/squad 0.031s +ok chat-agent-system/internal/storage 0.016s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/stream/transport 0.002s +ok chat-agent-system/internal/taskrun 0.147s +ok chat-agent-system/internal/template 2.338s +ok chat-agent-system/internal/upload 0.002s +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.002s +ok chat-agent-system/internal/workflowdef 0.003s +ok chat-agent-system/internal/workspace 0.003s +? chat-agent-system/templates [no test files]