From f1e87fb2e7e38d1ad80f6e0a404833fd4abfd9a8 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 10:42:20 +0000 Subject: [PATCH] test(server): add Wave 2 real-PI evidence for U8 and full/none Replace the U8 stop-gate with a PG+PI confirm/generate harness and add a mixed-duty governance long chain covering cross-user approve and none non-blocking advance. Co-authored-by: Cursor --- internal/app/plan_owner_test.go | 8 +- .../server/multi_intent_u8_piagent_pg_test.go | 322 ++++++++++++++++++ internal/server/server.go | 8 + ...ow_governance_full_none_piagent_pg_test.go | 315 +++++++++++++++++ 4 files changed, 648 insertions(+), 5 deletions(-) create mode 100644 internal/server/multi_intent_u8_piagent_pg_test.go create mode 100644 internal/server/workflow_governance_full_none_piagent_pg_test.go diff --git a/internal/app/plan_owner_test.go b/internal/app/plan_owner_test.go index 9dafe4cd..81e8fef6 100644 --- a/internal/app/plan_owner_test.go +++ b/internal/app/plan_owner_test.go @@ -2,7 +2,6 @@ package app import ( "context" - "os" "testing" "time" @@ -121,8 +120,7 @@ func TestLiveTaskStarterReplaysIdempotencyKey(t *testing.T) { } func TestTaskGenerateRealPIStopGate(t *testing.T) { - if os.Getenv("PI_AGENT_RUNNER_URL") == "" { - t.Skip("stop-gate: PI_AGENT_RUNNER_URL not set; real PI generate is not claimed") - } - t.Skip("live PI generate path is gated; unit evidence is TaskRunAdapter + ClaimRun idempotency") + // Real PI + PG + revoke/restart evidence lives in + // TestMultiIntentU8_RealPI_ConfirmGenerate_PG (internal/server). + t.Skip("redirect: run TestMultiIntentU8_RealPI_ConfirmGenerate_PG with TEST_DATABASE_URL + runner") } diff --git a/internal/server/multi_intent_u8_piagent_pg_test.go b/internal/server/multi_intent_u8_piagent_pg_test.go new file mode 100644 index 00000000..ff84be1f --- /dev/null +++ b/internal/server/multi_intent_u8_piagent_pg_test.go @@ -0,0 +1,322 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "chat-agent-system/internal/app" + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/app/uow" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/events" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/taskrun" + "chat-agent-system/internal/workflowdef" +) + +// TestMultiIntentU8_RealPI_ConfirmGenerate_PG closes the U8 real-PI evidence gap: +// confirm-before-start, live pi-agent generate via TaskRunAdapter, idempotent replay, +// revoke, and PG plan recovery after a fresh Plans wiring (restart simulation). +func TestMultiIntentU8_RealPI_ConfirmGenerate_PG(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow U8 real-PI PG E2E in short mode") + } + if !piAgentRunnerHealthy() { + t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health") + } + if os.Getenv("PI_AGENT_RUNNER_URL") == "" { + t.Setenv("PI_AGENT_RUNNER_URL", "http://127.0.0.1:18865") + } + + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, srv := setupPGPiAgentServer(t, pool) + + seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain) + + creatorToken := loginTestUser(t, handler, "zhanggong", "123456") + reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456") + approverToken := loginTestUser(t, handler, "wangzong", "123456") + creatorID := fetchAuthUserID(t, handler, creatorToken) + reviewerID := fetchAuthUserID(t, handler, reviewerToken) + approverID := fetchAuthUserID(t, handler, approverToken) + + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{ + Name: fmt.Sprintf("U8 Wave2 PI %d", time.Now().UnixNano()%1_000_000), + DeviceType: "Ultrasonic Device", + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + }) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) + withBearerAuth(reqCreate, creatorToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + if recCreate.Code != http.StatusCreated { + t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String()) + } + var proj projectworkflow.Project + if err := json.Unmarshal(recCreate.Body.Bytes(), &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) + projectID := proj.ID + + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), nil) + withBearerAuth(reqDefault, creatorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + if recDefault.Code != http.StatusOK { + t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String()) + } + var wfPlan projectinstance.WorkflowPlan + _ = json.Unmarshal(recDefault.Body.Bytes(), &wfPlan) + ensureProjectMembers(t, pool, projectID, map[int64]string{ + creatorID: "owner", + reviewerID: "member", + approverID: "member", + }) + for i := range wfPlan.Stages { + for j := range wfPlan.Stages[i].DocTasks { + wfPlan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + } + } + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: wfPlan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, creatorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + if recSave.Code != http.StatusOK { + t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String()) + } + + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil) + withBearerAuth(reqPreview, creatorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + var preview projectinstance.ActivationPreview + _ = json.Unmarshal(recPreview.Body.Bytes(), &preview) + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, creatorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + if recActivate.Code != http.StatusOK { + t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String()) + } + + sessionID := fmt.Sprintf("sess-u8-wave2-%d", projectID) + sessBody, _ := json.Marshal(map[string]any{ + "session_id": sessionID, + "project_id": projectID, + "user_id": creatorID, + "title": "U8 Wave2 intent plan", + }) + reqSess := httptest.NewRequest(http.MethodPost, "/api/v1/chat/sessions", bytes.NewReader(sessBody)) + withBearerAuth(reqSess, creatorToken) + recSess := httptest.NewRecorder() + handler.ServeHTTP(recSess, reqSess) + if recSess.Code != http.StatusOK && recSess.Code != http.StatusCreated { + t.Fatalf("upsert chat session: %d %s", recSess.Code, recSess.Body.String()) + } + + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm}) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + + taskOwner := app.NewTaskRunAdapter() + var runID string + taskReq := bizdev.TaskRunRequest{ + TaskKey: "intake", + ProjectName: proj.Name, + DeviceType: proj.DeviceType, + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + Backend: "pi-agent", + ProjectID: projectID, + UserID: creatorID, + AuthorID: creatorID, + ReviewerID: reviewerID, + ApproverID: approverID, + SessionID: sessionID, + Fields: map[string]string{ + "customer_name": "U8 Wave2 真实 PI 客户", + "device_type": proj.DeviceType, + "intended_use": "Tissue resection", + "target_market": "NMPA Class III", + }, + Materials: []bizdev.MaterialInput{{ + Filename: "project_brief.md", + Content: []byte("# U8 Wave2\n\n- 产品:超声软组织手术系统\n- 目标:确认后真实 PI generate\n"), + }}, + } + taskOwner.Starter = func(ctx context.Context, exec intentflow.StepExec) (string, error) { + if existing, err := srv.TaskRuns().FindByIdempotencyKey(ctx, exec.IdempotencyKey); err != nil { + return "", err + } else if existing != nil && existing.RunID != "" { + runID = existing.RunID + return existing.RunID, nil + } + rec := &taskrun.RunRecord{ + SessionID: exec.Principal.SessionID, + ProjectID: exec.Principal.ProjectID, + UserID: exec.Principal.UserID, + IdempotencyKey: exec.IdempotencyKey, + TaskKey: "intake", + Status: "pending", + } + req := taskReq + req.UserID = exec.Principal.UserID + req.ProjectID = exec.Principal.ProjectID + req.SessionID = exec.Principal.SessionID + created, err := srv.TaskRuns().CreateAndStart(ctx, "", rec, req, bizdev.StageRunRequest{}, "") + if err != nil { + return "", err + } + runID = created.RunID + return created.RunID, nil + } + + plans := intentflow.NewPlans(uow.NewPG(pool), intentflow.NewRepository(), intentflow.Options{ + Owner: intentflow.OwnerMux{ + Query: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()}, + Task: taskOwner, + }, + Events: events.PGSink{}, + }) + prin := intentflow.Principal{ + UserID: creatorID, Environment: "test", ProjectID: projectID, SessionID: sessionID, + } + created, err := plans.CreatePlan(ctx, intentflow.CreateCommand{ + Principal: prin, ClientTurnID: fmt.Sprintf("u8-turn-%d", projectID), RequestDigest: "u8-wave2-d1", + WritePlan: true, ExecutionMode: intentflow.ModeLive, + Steps: []intentflow.StepDraft{ + {ActionKey: "regulation.query", ActionDigest: "u8-q", ExecutionMode: intentflow.ModeLive}, + {ActionKey: "task.run", ActionDigest: "u8-w", ExecutionMode: intentflow.ModeLive}, + }, + }) + if err != nil || created == nil { + t.Fatalf("create plan: %+v %v", created, err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM intent_plan_decisions WHERE plan_id=$1`, created.PlanID) + _, _ = pool.Exec(context.Background(), `DELETE FROM intent_plan_steps WHERE plan_id=$1`, created.PlanID) + _, _ = pool.Exec(context.Background(), `DELETE FROM intent_plans WHERE plan_id=$1`, created.PlanID) + }) + if created.Status != intentflow.PlanAwaitingConfirmation { + t.Fatalf("status before confirm = %s", created.Status) + } + if taskOwner.StartCount() != 0 { + t.Fatal("generate must not start before confirm") + } + t.Logf("✓ [1. awaiting] plan_id=%s revision=%d starts=0", created.PlanID, created.Revision) + + confirmed, err := plans.DecidePlan(ctx, intentflow.DecisionCommand{ + Principal: prin, PlanID: created.PlanID, ExpectedRevision: created.Revision, + DecisionNonce: fmt.Sprintf("u8-n-%d", projectID), DecisionDigest: "u8-d", + Kind: intentflow.DecisionConfirm, + }) + if err != nil { + t.Fatal(err) + } + if taskOwner.StartCount() != 0 { + t.Fatal("confirm alone must not start generate") + } + + piStart := time.Now() + view, err := plans.AdvanceEligible(ctx, "u8-wave2-worker", confirmed.PlanID) + if err != nil { + t.Fatalf("advance: %v", err) + } + if view == nil { + t.Fatal("expected advanced plan view") + } + if taskOwner.StartCount() != 1 { + t.Fatalf("generate starts = %d, plan=%+v", taskOwner.StartCount(), view) + } + if runID == "" { + t.Fatal("starter did not capture run id") + } + waitCtx, cancel := context.WithTimeout(ctx, 45*time.Minute) + defer cancel() + if err := srv.TaskRuns().WaitRunDone(waitCtx, runID); err != nil { + t.Fatalf("wait PI run %s: %v", runID, err) + } + doneRec, err := srv.TaskRuns().GetRun(runID) + if err != nil || doneRec == nil { + t.Fatalf("get run: %v", err) + } + if doneRec.Status != taskrun.StatusCompleted { + t.Fatalf("PI run status=%s want %s", doneRec.Status, taskrun.StatusCompleted) + } + t.Logf("✓ [2. real PI generate] run_id=%s status=%s elapsed=%s", runID, doneRec.Status, time.Since(piStart).Round(time.Second)) + + _, err = plans.AdvanceEligible(ctx, "u8-wave2-worker", confirmed.PlanID) + if err != nil { + t.Fatal(err) + } + if taskOwner.StartCount() != 1 { + t.Fatalf("replay must not restart generate, starts=%d", taskOwner.StartCount()) + } + t.Log("✓ [3. idempotent replay] starts still 1") + + taskOwner.Denied = true + _, err = taskOwner.Execute(ctx, intentflow.StepExec{ + ActionKey: "task.run", IdempotencyKey: "post-revoke", ActionDigest: "x", + Principal: prin, + }) + if err != intentflow.ErrPlanActionForbidden { + t.Fatalf("revoke want forbidden, got %v", err) + } + t.Log("✓ [4. revoke] TaskRunAdapter denied") + + restarted := intentflow.NewPlans(uow.NewPG(pool), intentflow.NewRepository(), intentflow.Options{ + Owner: intentflow.OwnerMux{ + Query: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()}, + Task: app.NewTaskRunAdapter(), + }, + Events: events.PGSink{}, + }) + reloaded, err := restarted.GetPlan(ctx, prin, created.PlanID) + if err != nil || reloaded == nil { + t.Fatalf("reload plan after restart wiring: %v", err) + } + var taskOK bool + for _, s := range reloaded.Steps { + if s.ActionKey == "task.run" && s.Status == intentflow.StepSucceeded { + taskOK = true + break + } + } + if !taskOK { + t.Fatalf("restart recovery missing succeeded task.run: %+v", reloaded.Steps) + } + t.Logf("✓ [5. PG restart recovery] plan_id=%s status=%s", reloaded.PlanID, reloaded.Status) + + srv.SetIntentPlansForTest(restarted) + reqGet := httptest.NewRequest(http.MethodGet, fmt.Sprintf("%s?project_id=%d&session_id=%s", + fmt.Sprintf("/api/v1/intent-plans/%s", created.PlanID), projectID, sessionID), nil) + withBearerAuth(reqGet, creatorToken) + recGet := httptest.NewRecorder() + handler.ServeHTTP(recGet, reqGet) + if recGet.Code != http.StatusOK { + t.Fatalf("GET plan after re-login: %d %s", recGet.Code, recGet.Body.String()) + } + t.Logf("✓ [6. HTTP refresh] GET plan %d bytes", recGet.Body.Len()) +} diff --git a/internal/server/server.go b/internal/server/server.go index 370f9674..a459929b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -141,6 +141,14 @@ func (s *Server) TaskRuns() *taskrun.Manager { return s.taskRuns } +// SetIntentPlansForTest swaps the intent-plan service (Wave 2 restart/refresh harness only). +func (s *Server) SetIntentPlansForTest(plans intentflow.Service) { + if s == nil { + return + } + s.intentPlans = plans +} + // ProjectWorkflowService 获取项目多工作流控制服务 func (s *Server) ProjectWorkflowService() *projectworkflow.Service { return s.pwSvc diff --git a/internal/server/workflow_governance_full_none_piagent_pg_test.go b/internal/server/workflow_governance_full_none_piagent_pg_test.go new file mode 100644 index 00000000..a74f042c --- /dev/null +++ b/internal/server/workflow_governance_full_none_piagent_pg_test.go @@ -0,0 +1,315 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/server" + "chat-agent-system/internal/workflowdef" +) + +// TestWorkflowGovernance_PiAgent_PG_FullNoneMixedChain validates: +// - intake = full (cross-user approve required) +// - research = none (generate OK; submit/review/approve forbidden; does not block stage) +// after advancing V0.1, research generate + stage readiness + advance V0.5. +func TestWorkflowGovernance_PiAgent_PG_FullNoneMixedChain(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow pi-agent full/none PG E2E in short mode") + } + if !piAgentRunnerHealthy() { + t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health") + } + + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, srv := setupPGPiAgentServer(t, pool) + ts := httptest.NewServer(handler) + defer ts.Close() + client := &http.Client{Timeout: 50 * time.Minute} + + seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain) + + creatorToken := loginTestUser(t, handler, "zhanggong", "123456") + reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456") + approverToken := loginTestUser(t, handler, "wangzong", "123456") + creatorID := fetchAuthUserID(t, handler, creatorToken) + reviewerID := fetchAuthUserID(t, handler, reviewerToken) + approverID := fetchAuthUserID(t, handler, approverToken) + + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{ + Name: fmt.Sprintf("FullNone Wave2 %d", time.Now().UnixNano()%1_000_000), + DeviceType: "Ultrasonic Device", + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + }) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) + withBearerAuth(reqCreate, creatorToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + if recCreate.Code != http.StatusCreated { + t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String()) + } + var proj projectworkflow.Project + if err := json.Unmarshal(recCreate.Body.Bytes(), &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) + projectID := proj.ID + + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), nil) + withBearerAuth(reqDefault, creatorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + if recDefault.Code != http.StatusOK { + t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String()) + } + var plan projectinstance.WorkflowPlan + _ = json.Unmarshal(recDefault.Body.Bytes(), &plan) + ensureProjectMembers(t, pool, projectID, map[int64]string{ + creatorID: "owner", + reviewerID: "member", + approverID: "member", + }) + + for i := range plan.Stages { + stage := &plan.Stages[i] + for j := range stage.DocTasks { + dt := &stage.DocTasks[j] + switch { + case stage.StageKey == "V0.1_triage" && dt.TaskKey == "intake": + dt.Enabled = true + dt.Duties = projectinstance.DocDuty{ + AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + case stage.StageKey == "V0.5_feasibility" && dt.TaskKey == "research": + dt.Enabled = true + dt.Duties = projectinstance.DocDuty{} // none + default: + dt.Enabled = false + dt.Duties = projectinstance.DocDuty{} + } + } + } + + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, creatorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + if recSave.Code != http.StatusOK { + t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String()) + } + + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil) + withBearerAuth(reqPreview, creatorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + if recPreview.Code != http.StatusOK { + t.Fatalf("activation preview: %d %s", recPreview.Code, recPreview.Body.String()) + } + var preview projectinstance.ActivationPreview + _ = json.Unmarshal(recPreview.Body.Bytes(), &preview) + if preview.Summary.GovernedDocTasks < 1 { + t.Fatalf("expected at least one full doc, summary=%+v", preview.Summary) + } + if preview.Summary.UnmanagedDocTasks < 1 { + t.Fatalf("expected research as unmanaged none, summary=%+v", preview.Summary) + } + + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, creatorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + if recActivate.Code != http.StatusOK { + t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String()) + } + var activated projectinstance.ActivateResult + _ = json.Unmarshal(recActivate.Body.Bytes(), &activated) + instanceID := activated.Instance.ID + t.Logf("✓ [1. activate] instance=%d full=intake none=research(disabled until stage)", instanceID) + + runPI := func(taskKey string, materials []bizdev.MaterialInput) string { + t.Helper() + start := time.Now() + taskBody, _ := json.Marshal(server.TaskRunRequestDTO{ + TaskKey: taskKey, + ProjectName: proj.Name, + DeviceType: proj.DeviceType, + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + Backend: "pi-agent", + ProjectID: projectID, + AuthorID: creatorID, + ReviewerID: reviewerID, + ApproverID: approverID, + Fields: map[string]string{ + "customer_name": "FullNone Wave2 客户", + "device_type": proj.DeviceType, + "intended_use": "Tissue resection", + "target_market": "NMPA Class III", + }, + Materials: materials, + }) + reqTask, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run", bytes.NewReader(taskBody)) + withBearerAuth(reqTask, creatorToken) + respTask, err := client.Do(reqTask) + if err != nil { + t.Fatalf("pi-agent %s: %v", taskKey, err) + } + defer respTask.Body.Close() + if respTask.StatusCode != http.StatusOK { + var buf bytes.Buffer + _, _ = buf.ReadFrom(respTask.Body) + t.Fatalf("pi-agent %s HTTP %d: %s", taskKey, respTask.StatusCode, buf.String()) + } + var taskRes struct { + Success bool `json:"success"` + DocRecord *struct { + DocID string `json:"doc_id"` + } `json:"doc_record"` + } + _ = json.NewDecoder(respTask.Body).Decode(&taskRes) + if !taskRes.Success { + t.Fatalf("pi-agent %s failed", taskKey) + } + docID := fmt.Sprintf("doc_proj%d_%s", projectID, taskKey) + if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" { + docID = taskRes.DocRecord.DocID + } + t.Logf("✓ pi-agent %s doc=%s elapsed=%s", taskKey, docID, time.Since(start).Round(time.Second)) + return docID + } + + intakeDoc := runPI("intake", []bizdev.MaterialInput{{ + Filename: "project_brief.md", + Content: []byte("# FullNone\n\nintake full duty chain\n"), + }}) + docEngine := bizdev.Snapshot(srv.BizdevService()).Gov + _ = docEngine.MarkDocumentContentUpdated(ctx, intakeDoc, "hash_fullnone_intake", false) + if _, err := docEngine.SubmitForReview(ctx, intakeDoc, creatorID, "author", "Author"); err != nil { + t.Fatalf("submit intake: %v", err) + } + if _, err := docEngine.ReviewApprove(ctx, intakeDoc, reviewerID, "reviewer", "Reviewer", "ok"); err != nil { + t.Fatalf("review intake: %v", err) + } + if _, err := docEngine.ApproverApprove(ctx, intakeDoc, approverID, "approver", "Approver", "ok"); err != nil { + t.Fatalf("approve intake: %v", err) + } + t.Log("✓ [2. full intake] cross-user approve complete") + + advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.1_triage/advance", instanceID), bytes.NewReader(advanceBody)) + withBearerAuth(reqAdvance, creatorToken) + recAdvance := httptest.NewRecorder() + handler.ServeHTTP(recAdvance, reqAdvance) + if recAdvance.Code != http.StatusOK { + t.Fatalf("advance V0.1: %d %s", recAdvance.Code, recAdvance.Body.String()) + } + t.Log("✓ [3. advance V0.1] ok") + + researchDoc := runPI("research", []bizdev.MaterialInput{{ + Filename: "research_scope.md", + Content: []byte("# Research none\n\n生成后不走人工审批\n"), + }}) + + // none: HTTP submit-review must be denied by duty gate (engine alone is not the gate) + submitBody, _ := json.Marshal(map[string]any{"doc_id": researchDoc}) + reqSubmit := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBody)) + withBearerAuth(reqSubmit, creatorToken) + recSubmit := httptest.NewRecorder() + handler.ServeHTTP(recSubmit, reqSubmit) + if recSubmit.Code == http.StatusOK { + t.Fatalf("none doc must reject submit-review, got 200: %s", recSubmit.Body.String()) + } + if !bytes.Contains(recSubmit.Body.Bytes(), []byte("DOC_DUTY_NOT_MANAGED")) && + !bytes.Contains(recSubmit.Body.Bytes(), []byte("未纳入人工审批")) && + recSubmit.Code != http.StatusForbidden { + t.Fatalf("none submit want forbidden/duty-not-managed, got %d %s", recSubmit.Code, recSubmit.Body.String()) + } + t.Logf("✓ [4. none research] generate ok; submit-review denied HTTP %d", recSubmit.Code) + + reqReady := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.5_feasibility/readiness", instanceID), nil) + withBearerAuth(reqReady, creatorToken) + recReady := httptest.NewRecorder() + handler.ServeHTTP(recReady, reqReady) + if recReady.Code != http.StatusOK { + t.Fatalf("readiness: %d %s", recReady.Code, recReady.Body.String()) + } + var readiness projectinstance.StageReadiness + if err := json.Unmarshal(recReady.Body.Bytes(), &readiness); err != nil { + t.Fatalf("decode readiness: %v body=%s", err, recReady.Body.String()) + } + var researchItem *projectinstance.DocTaskReadiness + for i := range readiness.DocTasks { + if readiness.DocTasks[i].TaskKey == "research" { + researchItem = &readiness.DocTasks[i] + break + } + } + if researchItem == nil { + t.Fatalf("research missing in readiness: %+v", readiness) + } + if researchItem.ApprovalManaged || researchItem.Required { + t.Fatalf("none research must not be approval-managed/required: %+v", researchItem) + } + if !readiness.Ready { + t.Fatalf("stage must be ready without approving none doc: %+v", readiness) + } + t.Log("✓ [5. readiness] none does not block") + + // Config revision may bump after V0.1 advance — reload instance. + reqInst := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workflow-instances/%d", instanceID), nil) + withBearerAuth(reqInst, creatorToken) + recInst := httptest.NewRecorder() + handler.ServeHTTP(recInst, reqInst) + if recInst.Code != http.StatusOK { + t.Fatalf("get instance: %d %s", recInst.Code, recInst.Body.String()) + } + var detail struct { + Instance projectinstance.WorkflowInstance `json:"instance"` + } + if err := json.Unmarshal(recInst.Body.Bytes(), &detail); err != nil { + t.Fatalf("decode instance: %v", err) + } + advanceBody2, _ := json.Marshal(projectinstance.AdvanceStageInput{ + StageKey: "V0.5_feasibility", ExpectedConfigRevision: detail.Instance.ConfigRevision, + }) + reqAdvance2 := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.5_feasibility/advance", instanceID), bytes.NewReader(advanceBody2)) + withBearerAuth(reqAdvance2, creatorToken) + recAdvance2 := httptest.NewRecorder() + handler.ServeHTTP(recAdvance2, reqAdvance2) + if recAdvance2.Code != http.StatusOK { + t.Fatalf("advance V0.5: %d %s", recAdvance2.Code, recAdvance2.Body.String()) + } + t.Log("✓ [6. advance V0.5] none non-blocking PASS") + + // Refresh analogue: re-fetch settings after stage advance. + reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", projectID), nil) + withBearerAuth(reqSettings, creatorToken) + recSettings := httptest.NewRecorder() + handler.ServeHTTP(recSettings, reqSettings) + if recSettings.Code != http.StatusOK { + t.Fatalf("settings refresh: %d %s", recSettings.Code, recSettings.Body.String()) + } + t.Log("✓ [7. refresh] settings still readable") +}