From b74441fb4f7ca6591fc487346ac718434ff1bcf1 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:20:01 +0000 Subject: [PATCH 1/5] fix(docgov): materialize enabled workflow docs as template records Activation and list healing need a server-side document row per enabled task, without overwriting documents that already have content. Co-authored-by: Cursor --- internal/docgov/materialize.go | 157 ++++++++++++++++++++++++++++ internal/docgov/materialize_test.go | 67 ++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 internal/docgov/materialize.go create mode 100644 internal/docgov/materialize_test.go diff --git a/internal/docgov/materialize.go b/internal/docgov/materialize.go new file mode 100644 index 00000000..be733c65 --- /dev/null +++ b/internal/docgov/materialize.go @@ -0,0 +1,157 @@ +package docgov + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。 +type TemplateDocSpec struct { + TaskKey string + Title string + StageKey string + DutyPolicy string + Enabled bool + AuthorUserID int64 + ReviewerUserID int64 + ApproverUserID int64 + AuthorName string + ReviewerName string + ApproverName string +} + +// ControlledDocID 是项目受控文档的权威 doc_id。 +func ControlledDocID(projectID int64, taskKey string) string { + return fmt.Sprintf("doc_proj%d_%s", projectID, strings.TrimSpace(taskKey)) +} + +// MaterializeTemplateDocs 为已启用的文档任务幂等写入模板台账。 +// 已脱离模板(is_template_only=false)的正文与职责不被覆盖。 +func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectID int64, specs []TemplateDocSpec) error { + if e == nil || projectID <= 0 { + return nil + } + for _, spec := range specs { + if !spec.Enabled || strings.TrimSpace(spec.TaskKey) == "" { + continue + } + if err := e.upsertTemplateDoc(ctx, projectID, spec); err != nil { + return err + } + } + return nil +} + +func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error { + taskKey := strings.TrimSpace(spec.TaskKey) + docID := ControlledDocID(projectID, taskKey) + title := strings.TrimSpace(spec.Title) + if title == "" { + title = taskKey + } + authorName := dutyDisplayName(spec.AuthorUserID, spec.AuthorName) + reviewerName := dutyDisplayName(spec.ReviewerUserID, spec.ReviewerName) + approverName := dutyDisplayName(spec.ApproverUserID, spec.ApproverName) + policy := strings.TrimSpace(spec.DutyPolicy) + now := time.Now() + + if e.pool != nil { + cfgJSON, err := json.Marshal(DefaultDocumentConfig()) + if err != nil { + return fmt.Errorf("docgov: marshal template config: %w", err) + } + _, err = e.pool.Exec(ctx, ` + INSERT INTO document_records ( + doc_id, project_id, task_key, title, stage, version, + current_state, is_locked, is_template_only, work_dir, + author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name, + config, duty_locked, duty_policy, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, 1, + $6, false, true, '', + $7, $8, $9, $10, $11, $12, + $13, true, $14, $15, $15 + ) + ON CONFLICT (doc_id) DO UPDATE SET + title = CASE WHEN document_records.is_template_only THEN EXCLUDED.title ELSE document_records.title END, + stage = CASE WHEN document_records.is_template_only THEN EXCLUDED.stage ELSE document_records.stage END, + author_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_id ELSE document_records.author_id END, + author_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_name ELSE document_records.author_name END, + reviewer_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_id ELSE document_records.reviewer_id END, + reviewer_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_name ELSE document_records.reviewer_name END, + approver_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_id ELSE document_records.approver_id END, + approver_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_name ELSE document_records.approver_name END, + duty_policy = CASE WHEN document_records.is_template_only THEN EXCLUDED.duty_policy ELSE document_records.duty_policy END, + duty_locked = CASE WHEN document_records.is_template_only THEN TRUE ELSE document_records.duty_locked END, + updated_at = EXCLUDED.updated_at + `, docID, projectID, taskKey, title, strings.TrimSpace(spec.StageKey), + string(StateDrafting), + nullIfZero(spec.AuthorUserID), authorName, + nullIfZero(spec.ReviewerUserID), reviewerName, + nullIfZero(spec.ApproverUserID), approverName, + cfgJSON, policy, now) + if err != nil { + return fmt.Errorf("docgov: materialize template doc %s: %w", docID, err) + } + } + + e.mu.Lock() + defer e.mu.Unlock() + if existing, ok := e.memDocs[docID]; ok && existing != nil { + if !existing.IsTemplateOnly { + return nil + } + existing.Title = title + existing.Stage = strings.TrimSpace(spec.StageKey) + existing.TaskKey = taskKey + existing.AuthorID = spec.AuthorUserID + existing.AuthorName = authorName + existing.ReviewerID = spec.ReviewerUserID + existing.ReviewerName = reviewerName + existing.ApproverID = spec.ApproverUserID + existing.ApproverName = approverName + existing.DutyLocked = true + existing.DutyPolicy = policy + existing.UpdatedAt = now + return nil + } + if e.memDocs == nil { + e.memDocs = map[string]*DocumentRecord{} + } + e.memDocs[docID] = &DocumentRecord{ + DocID: docID, + ProjectID: projectID, + TaskKey: taskKey, + Title: title, + Stage: strings.TrimSpace(spec.StageKey), + Version: 1, + CurrentState: StateDrafting, + IsTemplateOnly: true, + AuthorID: spec.AuthorUserID, + AuthorName: authorName, + ReviewerID: spec.ReviewerUserID, + ReviewerName: reviewerName, + ApproverID: spec.ApproverUserID, + ApproverName: approverName, + DutyLocked: true, + DutyPolicy: policy, + Config: DefaultDocumentConfig(), + CreatedAt: now, + UpdatedAt: now, + } + return nil +} + +func dutyDisplayName(userID int64, name string) string { + name = strings.TrimSpace(name) + if name != "" { + return name + } + if userID > 0 { + return fmt.Sprintf("user-%d", userID) + } + return "" +} diff --git a/internal/docgov/materialize_test.go b/internal/docgov/materialize_test.go new file mode 100644 index 00000000..56cf21b2 --- /dev/null +++ b/internal/docgov/materialize_test.go @@ -0,0 +1,67 @@ +package docgov_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/docgov" +) + +func TestMaterializeTemplateDocsIdempotentAndSkipsRealContent(t *testing.T) { + ctx := context.Background() + engine := docgov.NewGovernanceEngine(nil) + specs := []docgov.TemplateDocSpec{{ + TaskKey: "bd_intake", + Title: "用户初始需求收集表", + StageKey: "V0.1_triage", + Enabled: true, + DutyPolicy: "author_only", + AuthorUserID: 398, + AuthorName: "陈威", + ReviewerUserID: 398, + ApproverUserID: 398, + }, { + TaskKey: "bd_research", + Title: "调研报告", + Enabled: false, + }} + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + specs[0].AuthorName = "陈威" + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + + docs, err := engine.ListWorkflowDocumentsByProject(ctx, 6579) + if err != nil { + t.Fatal(err) + } + if len(docs) != 1 { + t.Fatalf("documents=%d, want 1 enabled template", len(docs)) + } + if docs[0].DocID != "doc_proj6579_bd_intake" || !docs[0].IsTemplateOnly || docs[0].AuthorID != 398 { + t.Fatalf("unexpected template doc: %+v", docs[0]) + } + if docs[0].DutyPolicy != "author_only" { + t.Fatalf("duty policy=%q", docs[0].DutyPolicy) + } + + const realID = "doc_proj6579_bd_intake" + if err := engine.MarkDocumentContentUpdated(ctx, realID, "abc", false); err != nil { + t.Fatal(err) + } + specs[0].AuthorUserID = 9 + specs[0].AuthorName = "系统管理员" + specs[0].Title = "不应覆盖正文标题" + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + got, err := engine.GetDocument(ctx, realID) + if err != nil { + t.Fatal(err) + } + if got.IsTemplateOnly || got.AuthorID != 398 || got.Title != "用户初始需求收集表" { + t.Fatalf("real document was overwritten: %+v", got) + } +} From f168e44ce7581e4cb1512f9773e77fc6663f4ddc Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:20:01 +0000 Subject: [PATCH 2/5] feat(projectinstance): allow live workflow config edits except switching Activated projects can update duties, approval policy, and document enablement. Changing the workflow definition stays rejected, and project 143 remains frozen. Co-authored-by: Cursor --- internal/app/app.go | 1 + internal/app/duty_reassign_adapters.go | 27 +++ internal/projectinstance/errors.go | 53 +++--- internal/projectinstance/live_plan.go | 132 +++++++++++++ internal/projectinstance/live_plan_test.go | 115 ++++++++++++ internal/projectinstance/materialize.go | 43 +++++ internal/projectinstance/service.go | 22 +++ internal/projectinstance/store.go | 184 +++++++++++++++++-- internal/projectinstance/types.go | 102 +++++----- internal/server/project_doc_handlers.go | 6 + internal/server/project_settings_handlers.go | 9 +- 11 files changed, 608 insertions(+), 86 deletions(-) create mode 100644 internal/projectinstance/live_plan.go create mode 100644 internal/projectinstance/live_plan_test.go create mode 100644 internal/projectinstance/materialize.go diff --git a/internal/app/app.go b/internal/app/app.go index e7a18a86..c414284e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -165,6 +165,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { } if parts.Gov != nil { instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov}) + instSvc.SetTemplateDocMaterializer(&templateDocMaterializerAdapter{gov: parts.Gov}) } } taskMgr := taskrun.NewManager(pwSvc, parts.Ctx) diff --git a/internal/app/duty_reassign_adapters.go b/internal/app/duty_reassign_adapters.go index 4783a8df..2d6ca872 100644 --- a/internal/app/duty_reassign_adapters.go +++ b/internal/app/duty_reassign_adapters.go @@ -32,6 +32,33 @@ func (a *documentDutyProjectorAdapter) ProjectDocTaskDuties( ) } +type templateDocMaterializerAdapter struct { + gov *docgov.GovernanceEngine +} + +func (a *templateDocMaterializerAdapter) MaterializeTemplateDocs(ctx context.Context, projectID int64, plan projectinstance.WorkflowPlan) error { + if a == nil || a.gov == nil || projectID <= 0 { + return nil + } + specs := make([]docgov.TemplateDocSpec, 0, len(plan.Stages)) + for _, stage := range plan.Stages { + for _, task := range stage.DocTasks { + _, effective, _ := projectinstance.ResolveDocTaskDuty(task) + specs = append(specs, docgov.TemplateDocSpec{ + TaskKey: task.TaskKey, + Title: task.TaskTitle, + StageKey: stage.StageKey, + DutyPolicy: task.DutyPolicy, + Enabled: stage.Enabled && task.Enabled, + AuthorUserID: effective.AuthorUserID, + ReviewerUserID: effective.ReviewerUserID, + ApproverUserID: effective.ApproverUserID, + }) + } + } + return a.gov.MaterializeTemplateDocs(ctx, projectID, specs) +} + type docTaskBusyCheckerAdapter struct { gov *docgov.GovernanceEngine taskRuns *taskrun.Manager diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index a81bea6a..0a1768df 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -7,33 +7,34 @@ import ( ) var ( - ErrNotAvailable = errors.New("project instance store not available") - ErrProjectNotFound = errors.New("project not found") - ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow") - ErrPlanFrozen = errors.New("plan frozen: workflow already active") - ErrWorkflowAlreadyActive = errors.New("workflow already active for project") - ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required") - ErrInvalidPlan = errors.New("invalid workflow plan") - ErrValidationFailed = errors.New("workflow plan validation failed") - ErrTokenInvalid = errors.New("activation token invalid or expired") - ErrConcurrentConflict = errors.New("conflict: project version mismatch") - ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch") - ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published") - ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation") - ErrInstanceNotFound = errors.New("workflow instance not found") - ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") - ErrStageNotActive = errors.New("stage is not the active stage") - ErrStageNotCompleted = errors.New("stage is not completed") - ErrRecheckAckRequired = errors.New("recheck acknowledgement required") - ErrInvalidStage = errors.New("invalid stage key for instance") - ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") - ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") - ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") - ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled") + ErrNotAvailable = errors.New("project instance store not available") + ErrProjectNotFound = errors.New("project not found") + ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow") + ErrPlanFrozen = errors.New("plan frozen: workflow already active") + ErrWorkflowSwitchFrozen = errors.New("workflow switch frozen: definition cannot change after activation") + ErrWorkflowAlreadyActive = errors.New("workflow already active for project") + ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required") + ErrInvalidPlan = errors.New("invalid workflow plan") + ErrValidationFailed = errors.New("workflow plan validation failed") + ErrTokenInvalid = errors.New("activation token invalid or expired") + ErrConcurrentConflict = errors.New("conflict: project version mismatch") + ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch") + ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published") + ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation") + ErrInstanceNotFound = errors.New("workflow instance not found") + ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") + ErrStageNotActive = errors.New("stage is not the active stage") + ErrStageNotCompleted = errors.New("stage is not completed") + ErrRecheckAckRequired = errors.New("recheck acknowledgement required") + ErrInvalidStage = errors.New("invalid stage key for instance") + ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") + ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") + ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") + ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled") ErrAuthorOnlyPolicyUnavailable = errors.New("author-only approval policy store unavailable") - ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview") - ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties") - ErrDocTaskNotFound = errors.New("document task not found or disabled") + ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview") + ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties") + ErrDocTaskNotFound = errors.New("document task not found or disabled") ) // DocTaskBusyError 文档小队任务运行中,禁止改派。 diff --git a/internal/projectinstance/live_plan.go b/internal/projectinstance/live_plan.go new file mode 100644 index 00000000..663a6b1c --- /dev/null +++ b/internal/projectinstance/live_plan.go @@ -0,0 +1,132 @@ +package projectinstance + +import ( + "context" + "strings" +) + +// UpdateLivePlan 在已激活实例上更新职责、审批策略和文档/阶段启用。 +// 工作流定义与版本不可变。项目 143 保持冻结。 +func (s *Service) UpdateLivePlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + if projectID == readonlyLivePlanProjectID { + return nil, ErrPlanFrozen + } + if !s.Available() { + return nil, ErrNotAvailable + } + + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil { + return nil, err + } + if live == nil { + return nil, ErrInstanceNotFound + } + if live.Status == "completed" || live.Status == "terminated" { + return nil, ErrPlanFrozen + } + if in.ExpectedPlanRevision != live.ConfigRevision { + return nil, ErrInstanceRevisionConflict + } + + plan := normalizeWorkflowPlanDuties(in.Plan) + if strings.TrimSpace(plan.WorkflowDefinitionKey) == "" { + plan.WorkflowDefinitionKey = live.ConfigSnapshot.WorkflowDefinitionKey + } + if plan.WorkflowVersion == 0 { + plan.WorkflowVersion = live.ConfigSnapshot.WorkflowVersion + } + if plan.WorkflowVersionID == 0 { + plan.WorkflowVersionID = live.WorkflowVersionID + } + if plan.WorkflowDefinitionKey != live.ConfigSnapshot.WorkflowDefinitionKey || + plan.WorkflowVersion != live.ConfigSnapshot.WorkflowVersion || + plan.WorkflowVersionID != live.WorkflowVersionID { + return nil, ErrWorkflowSwitchFrozen + } + plan.WorkflowVersionID = live.WorkflowVersionID + + version, err := s.resolvePublishedVersion(ctx, plan) + if err != nil { + return nil, err + } + validation := validatePlan(plan, version) + if !validation.Valid { + return nil, newValidationError(validation) + } + if err := s.checkActiveMembers(ctx, projectID, plan); err != nil { + return nil, err + } + if planUsesAuthorOnly(plan) { + if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil { + return nil, err + } + } + if err := s.rejectBusyDocTaskChanges(ctx, projectID, live.ConfigSnapshot, plan); err != nil { + return nil, err + } + + updated, err := s.store.UpdateLivePlan(ctx, projectID, plan, live.ConfigRevision, userID) + if err != nil { + return nil, err + } + s.projectLiveDuties(ctx, projectID, updated.ConfigSnapshot) + s.materializePlan(ctx, projectID, updated.ConfigSnapshot) + return s.GetSettings(ctx, projectID, userID, isCreator) +} + +func (s *Service) rejectBusyDocTaskChanges(ctx context.Context, projectID int64, prev, next WorkflowPlan) error { + if s == nil || s.busyChecker == nil { + return nil + } + for _, st := range prev.Stages { + for _, before := range st.DocTasks { + after := findDocTaskPlan(next, before.TaskKey) + if after == nil || !liveDocTaskChanged(before, *after) { + continue + } + busy, reason, err := s.busyChecker.DocTaskSquadBusy(ctx, projectID, before.TaskKey) + if err != nil { + return err + } + if busy { + if reason == "" { + reason = "该文档小队任务进行中,完成后可更换负责人" + } + return &DocTaskBusyError{TaskKey: before.TaskKey, Reason: reason} + } + } + } + return nil +} + +func liveDocTaskChanged(before, after DocTaskPlan) bool { + if before.Enabled != after.Enabled || before.DutyPolicy != after.DutyPolicy { + return true + } + return before.Duties != after.Duties +} + +func (s *Service) projectLiveDuties(ctx context.Context, projectID int64, plan WorkflowPlan) { + if s == nil || s.projector == nil { + return + } + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if !dt.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeNone { + continue + } + _ = s.projector.ProjectDocTaskDuties(ctx, projectID, dt.TaskKey, effective, dt.DutyPolicy, DutyDisplayNames{}) + } + } +} diff --git a/internal/projectinstance/live_plan_test.go b/internal/projectinstance/live_plan_test.go new file mode 100644 index 00000000..21b7bf68 --- /dev/null +++ b/internal/projectinstance/live_plan_test.go @@ -0,0 +1,115 @@ +package projectinstance + +import ( + "context" + "errors" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +type countingMaterializer struct { + calls int + projectID int64 +} + +func (c *countingMaterializer) MaterializeTemplateDocs(_ context.Context, projectID int64, _ WorkflowPlan) error { + c.calls++ + c.projectID = projectID + return nil +} + +func TestUpdateLivePlanKeepsWorkflowAndMaterializes(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + mat := &countingMaterializer{} + svc := NewService(store, testResolver(), nil, nil) + svc.SetTemplateDocMaterializer(mat) + + if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatal(err) + } + preview, err := svc.ActivationPreview(ctx, 42, 1, true) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatal(err) + } + if mat.calls != 1 || mat.projectID != 42 { + t.Fatalf("activate materialize calls=%d project=%d", mat.calls, mat.projectID) + } + + settings, err := svc.GetSettings(ctx, 42, 1, true) + if err != nil { + t.Fatal(err) + } + if settings.PlanEditMode != PlanEditLive { + t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode) + } + plan := *settings.Plan + plan.Stages[0].DocTasks[0].Duties.ApproverUserID = 4 + updated, err := svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: settings.WorkflowPlanRevision, LiveEdit: true, + }) + if err != nil { + t.Fatal(err) + } + if updated.Plan.Stages[0].DocTasks[0].Duties.ApproverUserID != 4 { + t.Fatalf("duty not updated: %+v", updated.Plan.Stages[0].DocTasks[0].Duties) + } + if updated.Plan.WorkflowDefinitionKey != plan.WorkflowDefinitionKey { + t.Fatal("workflow key changed") + } + if mat.calls != 2 { + t.Fatalf("live update materialize calls=%d", mat.calls) + } + + switched := plan + switched.WorkflowDefinitionKey = "other" + _, err = svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{ + Plan: switched, ExpectedPlanRevision: updated.WorkflowPlanRevision, + }) + if !errors.Is(err, ErrWorkflowSwitchFrozen) { + t.Fatalf("expected switch frozen, got %v", err) + } +} + +func TestUpdateLivePlanRejectsReadonlyProject(t *testing.T) { + svc := NewService(newMemStore(), testResolver(), nil, nil) + _, err := svc.UpdateLivePlan(context.Background(), readonlyLivePlanProjectID, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 1, LiveEdit: true, + }) + if !errors.Is(err, ErrPlanFrozen) { + t.Fatalf("expected frozen, got %v", err) + } +} + +func TestPlanEditModeFrozenForReadonlyLiveProject(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, readonlyLivePlanProjectID, LifecycleActive, workflowdef.OfficialKeyMain, 2, 1) + store.live[readonlyLivePlanProjectID] = &WorkflowInstance{ + ID: 89, + ProjectID: readonlyLivePlanProjectID, + WorkflowVersionID: 1, + Status: InstanceActive, + ConfigRevision: 3, + ConfigSnapshot: validPlan(), + } + svc := NewService(store, testResolver(), nil, nil) + settings, err := svc.GetSettings(ctx, readonlyLivePlanProjectID, 1, true) + if err != nil { + t.Fatal(err) + } + if settings.PlanEditMode != PlanEditFrozen { + t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode) + } +} diff --git a/internal/projectinstance/materialize.go b/internal/projectinstance/materialize.go new file mode 100644 index 00000000..a7b3f483 --- /dev/null +++ b/internal/projectinstance/materialize.go @@ -0,0 +1,43 @@ +package projectinstance + +import ( + "context" + "log/slog" +) + +// readonlyLivePlanProjectID 是历史验收项目。激活后保持只读,禁止改写规划与补文档行。 +const readonlyLivePlanProjectID int64 = 143 + +// TemplateDocMaterializer 把已启用文档任务物化到 document_records。 +// 实现放在 app 适配层,避免 projectinstance 依赖 docgov。 +type TemplateDocMaterializer interface { + MaterializeTemplateDocs(ctx context.Context, projectID int64, plan WorkflowPlan) error +} + +func (s *Service) SetTemplateDocMaterializer(m TemplateDocMaterializer) { + if s == nil { + return + } + s.templateDocs = m +} + +// MaterializeLiveDocs 按 live 快照补齐受控文档台账。列表接口可重复调用。 +func (s *Service) MaterializeLiveDocs(ctx context.Context, projectID int64) error { + if s == nil || s.templateDocs == nil || s.store == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID { + return nil + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil { + return err + } + return s.templateDocs.MaterializeTemplateDocs(ctx, projectID, live.ConfigSnapshot) +} + +func (s *Service) materializePlan(ctx context.Context, projectID int64, plan WorkflowPlan) { + if s == nil || s.templateDocs == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID { + return + } + if err := s.templateDocs.MaterializeTemplateDocs(ctx, projectID, plan); err != nil { + slog.Warn("materialize template docs", "project_id", projectID, "err", err) + } +} diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 7eeae54a..112c9582 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -28,6 +28,7 @@ type Service struct { authorOnlyPolicy AuthorOnlyPolicySource projector DocumentDutyProjector busyChecker DocTaskBusyChecker + templateDocs TemplateDocMaterializer recheck RecheckGate } @@ -94,6 +95,7 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 settings.LiveInstance = liveInst settings.CanEditPlan = false settings.CanActivate = false + settings.WorkflowPlanRevision = liveInst.ConfigRevision // 激活后权威规划在 live snapshot(planning 行已转为 active)。 snap := liveInst.ConfigSnapshot settings.Plan = &snap @@ -137,10 +139,27 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 } } settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx) + settings.PlanEditMode = planEditModeFor(projectID, lifecycle, liveInst) _ = userID return settings, nil } +func planEditModeFor(projectID int64, lifecycle string, live *WorkflowInstance) string { + if projectID == readonlyLivePlanProjectID && (live != nil || lifecycle != LifecyclePlanning) { + return PlanEditFrozen + } + if live != nil { + if live.Status == "completed" || live.Status == "terminated" { + return PlanEditFrozen + } + return PlanEditLive + } + if lifecycle == LifecyclePlanning { + return PlanEditPlanning + } + return PlanEditFrozen +} + func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) { if !isCreator { return nil, ErrForbiddenNotCreator @@ -318,6 +337,9 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i if err != nil { return nil, err } + if activated != nil { + s.materializePlan(ctx, projectID, activated.ConfigSnapshot) + } return &ActivateResult{ ProjectID: projectID, diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index 3918a361..46daa812 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -62,6 +62,8 @@ type Store interface { MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error // UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。 UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error) + // UpdateLivePlan 更新 live 快照(职责、审批策略、文档/阶段启用),并同步实例行与职责指派。 + UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) } // NewStore 构建 PG 或内存 Store。 @@ -771,6 +773,118 @@ func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, return inst, nil } +func (s *pgStore) UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var instanceID int64 + var revision int + err = tx.QueryRow(ctx, ` + SELECT id, config_revision + FROM project_workflow_instances + WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation') + FOR UPDATE + `, projectID).Scan(&instanceID, &revision) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceNotFound + } + if err != nil { + return nil, err + } + if revision != expectedConfigRevision { + return nil, ErrInstanceRevisionConflict + } + + planHash, err := computePlanHash(plan) + if err != nil { + return nil, err + } + snapRaw, err := json.Marshal(plan) + if err != nil { + return nil, err + } + now := time.Now() + row := tx.QueryRow(ctx, ` + UPDATE project_workflow_instances SET + config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4 + WHERE id = $1 AND config_revision = $5 + RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + `, instanceID, snapRaw, planHash, now, expectedConfigRevision) + inst, err := scanInstance(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceRevisionConflict + } + if err != nil { + return nil, err + } + + for _, st := range plan.Stages { + if _, err := tx.Exec(ctx, ` + UPDATE project_stage_instances + SET enabled = $3, updated_at = $4 + WHERE workflow_instance_id = $1 AND stage_key = $2 + `, inst.ID, st.StageKey, st.Enabled, now); err != nil { + return nil, err + } + for _, dt := range st.DocTasks { + var docTaskID int64 + err = tx.QueryRow(ctx, ` + UPDATE project_doc_task_instances AS dti SET + enabled = $3, + status = CASE + WHEN $3 = false AND dti.status = 'pending' THEN 'skipped_disabled' + WHEN $3 = true AND dti.status = 'skipped_disabled' THEN 'pending' + ELSE dti.status + END, + updated_at = $4 + FROM project_stage_instances si + WHERE dti.stage_instance_id = si.id + AND si.workflow_instance_id = $1 + AND dti.task_key = $2 + RETURNING dti.id + `, inst.ID, dt.TaskKey, dt.Enabled, now).Scan(&docTaskID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDocTaskNotFound + } + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil { + return nil, err + } + if !dt.Enabled || !st.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeNone { + continue + } + for role, uid := range map[string]int64{ + DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID, + } { + if uid <= 0 { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by) + VALUES ($1, $2, $3, $4) + `, docTaskID, role, uid, assignedBy); err != nil { + return nil, err + } + } + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return inst, nil +} + func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { tx, err := s.pool.Begin(ctx) if err != nil { @@ -834,21 +948,21 @@ type memDocTaskRecord struct { } type memStore struct { - mu sync.Mutex - meta map[int64]projectMeta - plan map[int64]*WorkflowInstance - live map[int64]*WorkflowInstance - tokens map[string]activationTokenRec - stages map[int64][]memStageRecord - nextStageID int64 - nextTaskID int64 + mu sync.Mutex + meta map[int64]projectMeta + plan map[int64]*WorkflowInstance + live map[int64]*WorkflowInstance + tokens map[string]activationTokenRec + stages map[int64][]memStageRecord + nextStageID int64 + nextTaskID int64 } type projectMeta struct { - lifecycle string - activeKey string - selectionVer int - planRevision int + lifecycle string + activeKey string + selectionVer int + planRevision int } type activationTokenRec struct { @@ -1276,6 +1390,52 @@ func (m *memStore) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, t return ©Inst, nil } +func (m *memStore) UpdateLivePlan(_ context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, _ int64) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + live := m.live[projectID] + if live == nil { + return nil, ErrInstanceNotFound + } + if live.ConfigRevision != expectedConfigRevision { + return nil, ErrInstanceRevisionConflict + } + planHash, err := computePlanHash(plan) + if err != nil { + return nil, err + } + copyInst := *live + copyInst.ConfigSnapshot = plan + copyInst.PlanHash = planHash + copyInst.ConfigRevision++ + copyInst.UpdatedAt = time.Now() + m.live[projectID] = ©Inst + + records := m.stages[copyInst.ID] + for i := range records { + for _, st := range plan.Stages { + if records[i].stageKey == st.StageKey { + records[i].enabled = st.Enabled + } + } + for j := range records[i].tasks { + dt := findDocTaskPlan(plan, records[i].tasks[j].taskKey) + if dt == nil { + continue + } + records[i].tasks[j].enabled = dt.Enabled + if !dt.Enabled && records[i].tasks[j].status == "pending" { + records[i].tasks[j].status = "skipped_disabled" + } + if dt.Enabled && records[i].tasks[j].status == "skipped_disabled" { + records[i].tasks[j].status = "pending" + } + } + } + m.stages[copyInst.ID] = records + return ©Inst, nil +} + func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index 128f9089..f91129a7 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -10,6 +10,10 @@ const ( LifecyclePlanning = "planning" LifecycleActive = "active" + PlanEditPlanning = "planning" + PlanEditLive = "live" + PlanEditFrozen = "frozen" + InstancePlanning = "planning" InstanceActive = "active" @@ -32,10 +36,10 @@ type DocDuty struct { // DocTaskPlan 文档任务裁剪与职责。 type DocTaskPlan struct { - TaskKey string `json:"task_key"` - TaskTitle string `json:"task_title,omitempty"` - Enabled bool `json:"enabled"` - ApprovalConfigurable bool `json:"approval_configurable,omitempty"` + TaskKey string `json:"task_key"` + TaskTitle string `json:"task_title,omitempty"` + Enabled bool `json:"enabled"` + ApprovalConfigurable bool `json:"approval_configurable,omitempty"` // DutyPolicy 逐文档职责模式;空或缺省按 separated 解读(omitempty 保证旧快照 hash 不变)。 DutyPolicy string `json:"duty_policy,omitempty"` Duties DocDuty `json:"duties"` @@ -83,20 +87,22 @@ type GovernancePolicyCapability struct { // ProjectSettings 项目设置聚合视图。 type ProjectSettings struct { - ProjectID int64 `json:"project_id"` - LifecycleStatus string `json:"lifecycle_status"` - ActiveWorkflowKey string `json:"active_workflow_key"` - WorkflowSelectionVersion int `json:"workflow_selection_version"` - WorkflowPlanRevision int `json:"workflow_plan_revision"` - Plan *WorkflowPlan `json:"plan,omitempty"` - PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"` - LiveInstance *WorkflowInstance `json:"live_instance,omitempty"` - CanEditPlan bool `json:"can_edit_plan"` - CanActivate bool `json:"can_activate"` - ActiveStageKey string `json:"active_stage_key,omitempty"` - StageAdvanceReady bool `json:"stage_advance_ready,omitempty"` - PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"` - AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"` + ProjectID int64 `json:"project_id"` + LifecycleStatus string `json:"lifecycle_status"` + ActiveWorkflowKey string `json:"active_workflow_key"` + WorkflowSelectionVersion int `json:"workflow_selection_version"` + WorkflowPlanRevision int `json:"workflow_plan_revision"` + Plan *WorkflowPlan `json:"plan,omitempty"` + PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"` + LiveInstance *WorkflowInstance `json:"live_instance,omitempty"` + CanEditPlan bool `json:"can_edit_plan"` + CanActivate bool `json:"can_activate"` + // PlanEditMode 是 planning | live | frozen。live 允许改职责/审批/启用,禁止切换工作流。 + PlanEditMode string `json:"plan_edit_mode,omitempty"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + StageAdvanceReady bool `json:"stage_advance_ready,omitempty"` + PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"` + AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"` } // PublishedOption 可选已发布工作流。 @@ -113,6 +119,8 @@ type SavePlanInput struct { Plan WorkflowPlan `json:"plan"` ExpectedPlanRevision int `json:"expected_plan_revision"` ExpectedSelectionVersion int `json:"expected_selection_version"` + // LiveEdit 为 true 时走激活后的配置更新;缺省仍走规划保存(激活后拒绝)。 + LiveEdit bool `json:"live_edit,omitempty"` } // ActivationPreview 激活预览与确认令牌。 @@ -135,18 +143,18 @@ type PreviewDocRef struct { // PreviewSummary 激活影响摘要。 type PreviewSummary struct { - WorkflowName string `json:"workflow_name"` - WorkflowVersion int `json:"workflow_version"` - EnabledStages int `json:"enabled_stages"` - EnabledDocTasks int `json:"enabled_doc_tasks"` - GovernedDocTasks int `json:"governed_doc_tasks"` - UnmanagedDocTasks int `json:"unmanaged_doc_tasks"` - AssignedDuties int `json:"assigned_duties"` - AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"` - UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"` - AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"` - FirstStageKey string `json:"first_stage_key,omitempty"` - FirstStageName string `json:"first_stage_name,omitempty"` + WorkflowName string `json:"workflow_name"` + WorkflowVersion int `json:"workflow_version"` + EnabledStages int `json:"enabled_stages"` + EnabledDocTasks int `json:"enabled_doc_tasks"` + GovernedDocTasks int `json:"governed_doc_tasks"` + UnmanagedDocTasks int `json:"unmanaged_doc_tasks"` + AssignedDuties int `json:"assigned_duties"` + AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"` + UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"` + AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"` + FirstStageKey string `json:"first_stage_key,omitempty"` + FirstStageName string `json:"first_stage_name,omitempty"` } // ActivateInput 二次确认激活。 @@ -170,8 +178,8 @@ const ( StageCompleted = "completed" StageSkipped = "skipped" - InstanceCompleted = "completed" - InstanceTerminated = "terminated" + InstanceCompleted = "completed" + InstanceTerminated = "terminated" InstanceAwaitingStageConfirmation = "awaiting_stage_confirmation" InstanceTerminationRequested = "termination_requested" ) @@ -227,25 +235,25 @@ type GateIssue struct { // DocTaskReadiness 文档任务就绪状态。 type DocTaskReadiness struct { - TaskKey string `json:"task_key"` - Required bool `json:"required"` - Enabled bool `json:"enabled"` - ApprovalManaged bool `json:"approval_managed"` - DocState string `json:"doc_state"` - Approved bool `json:"approved"` + TaskKey string `json:"task_key"` + Required bool `json:"required"` + Enabled bool `json:"enabled"` + ApprovalManaged bool `json:"approval_managed"` + DocState string `json:"doc_state"` + Approved bool `json:"approved"` } // StageReadiness 阶段推进就绪评估。 type StageReadiness struct { - InstanceID int64 `json:"instance_id"` - StageKey string `json:"stage_key"` - StageName string `json:"stage_name"` - Ready bool `json:"ready"` - CanAdvance bool `json:"can_advance"` - RequiresRecheckAck bool `json:"requires_recheck_ack"` - BlockingIssues []GateIssue `json:"blocking_issues"` - DocTasks []DocTaskReadiness `json:"doc_tasks"` - ConfigRevision int `json:"config_revision"` + InstanceID int64 `json:"instance_id"` + StageKey string `json:"stage_key"` + StageName string `json:"stage_name"` + Ready bool `json:"ready"` + CanAdvance bool `json:"can_advance"` + RequiresRecheckAck bool `json:"requires_recheck_ack"` + BlockingIssues []GateIssue `json:"blocking_issues"` + DocTasks []DocTaskReadiness `json:"doc_tasks"` + ConfigRevision int `json:"config_revision"` } // AdvanceStageInput 创建者确认推进。 diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index b0f10ea1..cd787383 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "log/slog" "net/http" "path/filepath" "strconv" @@ -83,6 +84,11 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht return } + if s.instSvc != nil { + if err := s.instSvc.MaterializeLiveDocs(r.Context(), projectID); err != nil { + slog.Warn("materialize live docs", "project_id", projectID, "err", err) + } + } docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error()) diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index cf7621d7..4628f7b1 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -98,7 +98,12 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) return } - settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) + var settings *projectinstance.ProjectSettings + if req.LiveEdit { + settings, err = s.instSvc.UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req) + } else { + settings, err = s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) + } if err != nil { writeProjectInstanceError(w, err) return @@ -163,6 +168,8 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`) case errors.Is(err, projectinstance.ErrPlanFrozen): writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`) + case errors.Is(err, projectinstance.ErrWorkflowSwitchFrozen): + writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_SWITCH_FROZEN","message":"运行中不能切换工作流,只能调整职责、审批和文档启用"}`) case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive): writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`) case errors.Is(err, projectinstance.ErrPlanChangedReconfirm): From 3a0f7a10335333c617e8501827a8a6c6c444303a Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:20:01 +0000 Subject: [PATCH 3/5] fix(web): route prefixed task keys and keep live settings editable Message routing follows the project's real task key, account switches clear local workbench state, and the duty matrix writes the reassignment API instead of a local drill. Co-authored-by: Cursor --- .../workflow-plan-409-frozen-duties.spec.ts | 8 +- web/src/App.tsx | 50 ++++++++-- web/src/api/documentRevision.ts | 8 +- web/src/api/projectSettings.ts | 4 +- web/src/api/projectSettingsError.ts | 2 +- web/src/components/layout/WorkbenchView.tsx | 2 +- .../project/ProjectSettingsModal.tsx | 40 ++++++-- .../user/UserProjectRightsModal.tsx | 98 +++++++++---------- web/src/data/storage.ts | 28 ++++++ web/src/data/storageUser.test.ts | 14 +++ web/src/engine/templateMatch.test.ts | 16 +++ web/src/engine/templateMatch.ts | 41 ++++++-- web/src/lib/projectSettingsMode.test.ts | 43 ++++++-- web/src/lib/projectSettingsMode.ts | 23 +++-- web/src/lib/taskRunErrorInfo.test.ts | 17 ++++ web/src/lib/taskRunErrorInfo.ts | 3 +- web/src/types/projectSettings.ts | 2 + 17 files changed, 292 insertions(+), 107 deletions(-) create mode 100644 web/src/data/storageUser.test.ts create mode 100644 web/src/lib/taskRunErrorInfo.test.ts diff --git a/web/e2e/workflow-plan-409-frozen-duties.spec.ts b/web/e2e/workflow-plan-409-frozen-duties.spec.ts index 86743826..87eeb1ab 100644 --- a/web/e2e/workflow-plan-409-frozen-duties.spec.ts +++ b/web/e2e/workflow-plan-409-frozen-duties.spec.ts @@ -169,9 +169,11 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => { await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 }); await shot(page, '03-project-a-activated.png'); await page.getByRole('button', { name: '查看工作流设置' }).click(); - await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText('纳入人工审批')).toHaveCount(0); + await expect(page.getByText(/运行中仍可调整/)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('纳入人工审批').first()).toBeVisible(); await expect(page.getByText('校验并预览')).toHaveCount(0); + await expect(page.getByRole('button', { name: '确认激活' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '保存配置' })).toBeVisible(); await shot(page, '03b-project-a-frozen.png'); await page.getByRole('button', { name: '关闭' }).click(); @@ -242,7 +244,7 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => { await page.getByRole('button', { name: /校验并预览/ }).click(); await expect(page.getByRole('alert')).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('当前工作流已激活')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(/规划(工作流选择与整表配置)已冻结/)).toBeVisible(); + await expect(page.getByText(/运行中仍可调整/)).toBeVisible(); await shot(page, '06-dual-tab-frozen.png'); await page2.close(); diff --git a/web/src/App.tsx b/web/src/App.tsx index abddb5ae..77ffbfe5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,7 +36,7 @@ import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; import { ProjectTracePanel } from './components/workflow/ProjectTracePanel'; import { OnDemand } from './components/lazy/OnDemand'; -import { fetchProjectSettings } from './api/projectSettings'; +import { fetchProjectSettings, reassignDocTaskDuties } from './api/projectSettings'; import { LoginView } from './components/auth/LoginView'; import { authClient } from './api/auth'; import { LoginResult, SystemAuthConfig } from './types/auth'; @@ -86,6 +86,7 @@ import { filterProjectSessionsForUser, getProjectMembership, resetWorkbenchStorage, + adoptWorkbenchUser, } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; @@ -1213,6 +1214,11 @@ export default function App() { setSessionValidated(false); setProjects([]); resetWorkbenchStorage(); + setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES)); + setSessions(loadSessions()); + setMessages([]); + setDocCache({}); + setActiveDocId(''); }); }; @@ -1431,15 +1437,29 @@ export default function App() { URL.revokeObjectURL(url); }; - // 测试演练:动态修改当前用户在某个文档的权限角色 - const handleRoleChangeForDoc = (docId: string, role: DocPermissionRole) => { - setCurrentUser((prev) => ({ - ...prev, - projectPermissions: { - ...(prev.projectPermissions || {}), - [docId]: role, - }, - })); + const handleRoleChangeForDoc = async (docId: string, role: DocPermissionRole) => { + if (role === 'viewer') return; + const item = deliverables.find((doc) => doc.docId === docId); + if (!item?.projectId || !item.taskKey) { + throw new Error('该文档没有服务端任务,无法改派职责'); + } + const uid = currentUser.id; + let author = role === 'author' ? uid : item.authorId; + let reviewer = role === 'reviewer' ? uid : item.reviewerId; + let approver = role === 'approver' ? uid : item.approverId; + if (item.dutyPolicy === 'author_only') { + const owner = author || uid; + author = owner; + reviewer = owner; + approver = owner; + } + await reassignDocTaskDuties(item.projectId, item.taskKey, { + author_user_id: author, + reviewer_user_id: reviewer, + approver_user_id: approver, + duty_policy: item.dutyPolicy, + }); + setWorkflowDocsEpoch((epoch) => epoch + 1); }; // 未登录拦截门禁:展示现代化医疗合规登录与注册页面 @@ -1479,6 +1499,16 @@ export default function App() { return ( { + const switched = adoptWorkbenchUser(newSession.user.id); + if (switched) { + setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES)); + setSessions(loadSessions()); + setMessages([]); + setDocCache({}); + setProjects([]); + setActiveDocId(''); + setActiveSessionId(loadActiveSessionId('')); + } setSession(newSession); setSessionValidated(true); setCapabilities(newSession.capabilities ?? []); diff --git a/web/src/api/documentRevision.ts b/web/src/api/documentRevision.ts index 53aca539..02528918 100644 --- a/web/src/api/documentRevision.ts +++ b/web/src/api/documentRevision.ts @@ -22,14 +22,18 @@ async function api(path: string, init?: RequestInit): Promise { }); const data = await res.json().catch(() => ({})); if (!res.ok) { - const err = new Error((data as { error?: string }).error || res.statusText) as Error & { + const err = new Error((data as { error?: string; message?: string }).message || (data as { error?: string }).error || res.statusText) as Error & { status: number; + httpStatus: number; code?: string; + errorCode?: string; conflict?: unknown; body?: unknown; }; err.status = res.status; - err.code = (data as { code?: string }).code; + err.httpStatus = res.status; + err.code = (data as { code?: string; error_code?: string }).code || (data as { error_code?: string }).error_code; + err.errorCode = err.code; err.conflict = (data as { conflict?: unknown }).conflict; err.body = data; throw err; diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index cbda60a2..d1de09d7 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -47,7 +47,8 @@ export async function saveWorkflowPlan( projectId: number, plan: WorkflowPlan, expectedPlanRevision: number, - expectedSelectionVersion: number + expectedSelectionVersion: number, + liveEdit = false ): Promise { const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, { method: 'PUT', @@ -56,6 +57,7 @@ export async function saveWorkflowPlan( plan, expected_plan_revision: expectedPlanRevision, expected_selection_version: expectedSelectionVersion, + live_edit: liveEdit || undefined, }), }); if (!res.ok) { diff --git a/web/src/api/projectSettingsError.ts b/web/src/api/projectSettingsError.ts index 030cfa36..6b73659b 100644 --- a/web/src/api/projectSettingsError.ts +++ b/web/src/api/projectSettingsError.ts @@ -126,7 +126,7 @@ export function recoverFromProjectSettingsError( err: Pick, ): ProjectSettingsErrorRecovery { if (err.code === 'PLAN_FROZEN') return 'refresh_and_freeze'; - if (err.code === 'PLAN_REVISION_CONFLICT') return 'reload_revision'; + if (err.code === 'PLAN_REVISION_CONFLICT' || err.code === 'INSTANCE_REVISION_CONFLICT') return 'reload_revision'; if (err.code === 'ERR_CONCURRENT_SWITCH') return 'retry_after_switch'; // Envelope-only 409 without nested business code: refresh then reconfirm. if (err.code === 'VERSION_CONFLICT' && err.status === 409) return 'retry_after_switch'; diff --git a/web/src/components/layout/WorkbenchView.tsx b/web/src/components/layout/WorkbenchView.tsx index b8d5c9b2..6b25c6bf 100644 --- a/web/src/components/layout/WorkbenchView.tsx +++ b/web/src/components/layout/WorkbenchView.tsx @@ -57,7 +57,7 @@ export interface WorkbenchViewProps { activeDocId: string; canAssignLead: boolean; handleSelectDoc: (id: string) => void; - handleRoleChangeForDoc: (docId: string, role: DocPermissionRole) => void; + handleRoleChangeForDoc: (docId: string, role: DocPermissionRole) => void | Promise; session: LoginResult | null; setSession: Dispatch>; handleLogout: () => void; diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 6cd29c77..d2786e92 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -213,7 +213,8 @@ export const ProjectSettingsModal: React.FC = ({ }; const handleSavePlan = async () => { - if (!plan || !settings || !settings.can_edit_plan) return; + const liveEdit = resolveProjectSettingsMode({ settings }) === 'live_edit'; + if (!plan || !settings || (!settings.can_edit_plan && !liveEdit)) return; const issues = clientDutyIssues(plan, members, requireApprovalKeys); if (issues.length > 0) { @@ -234,10 +235,12 @@ export const ProjectSettingsModal: React.FC = ({ projectId, plan, settings.workflow_plan_revision, - settings.workflow_selection_version + settings.workflow_selection_version, + liveEdit ); setSettings(updated); if (updated.plan) setPlan(updated.plan); + if (liveEdit) return; const pv = await fetchActivationPreview(projectId); setPreview(pv); setStep('confirm'); @@ -332,6 +335,7 @@ export const ProjectSettingsModal: React.FC = ({ settings, loadError: Boolean(error && !settings), }); + const liveEdit = mode === 'live_edit'; const showEditor = shouldShowPlanEditor(mode); const showSave = shouldShowSavePreview(mode); const chrome = projectSettingsChrome(mode); @@ -425,7 +429,7 @@ export const ProjectSettingsModal: React.FC = ({
版本:v{plan?.workflow_version ?? settings.workflow_selection_version}
当前阶段:{settings.active_stage_key || '—'}
- 规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。 + 当前工作流已冻结,只读查看。规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
@@ -571,10 +575,28 @@ export const ProjectSettingsModal: React.FC = ({
)} - {showEditor && step === 'select' && settings && ( + {liveEdit && settings && ( +
+
当前工作流已激活
+
+ 运行中仍可调整职责、审批与文档启用,但不能切换工作流。 +
+
+ )} + + {showEditor && chrome.showWorkflowSelector && step === 'select' && settings && (

- 选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。 + 选择要激活的已发布工作流版本。激活后不可切换工作流;职责、审批与文档启用在运行中仍可调整。

{(settings.published_workflows || []).map((wf) => (
+ {(currentUser.department || currentUser.position) && (
= ({ }} > - {currentUser.department || '机器人与微创内窥镜研发中心'} - · - - {currentUser.position || '资深医疗器械方案工程师'} - + {currentUser.department ? {currentUser.department} : null} + {currentUser.department && currentUser.position ? ( + · + ) : null} + {currentUser.position ? ( + {currentUser.position} + ) : null}
+ )} - - {/* Global Position Pill */} -
-
法定资质有效
-
ISO 13485 / GMP 认证
-
{/* Active Project Banner & Stats */} @@ -317,6 +307,11 @@ export const UserProjectRightsModal: FC = ({ 交付物总计: {deliverables.length} 份 + {dutyError ? ( +
+ {dutyError} +
+ ) : null} {/* Quick Stats Badges */}
@@ -473,15 +468,21 @@ export const UserProjectRightsModal: FC = ({
- {onRoleChangeForDoc ? ( + {onRoleChangeForDoc && canAssignLead && item.projectId && item.taskKey ? ( ) : ( 0 && nextId > 0 && prevId !== nextId; +} + +export function readWorkbenchUserId(): number | null { + if (typeof window === 'undefined' || !window.localStorage) return null; + const raw = window.localStorage.getItem(WORKBENCH_USER_KEY); + const id = Number(raw); + return Number.isFinite(id) && id > 0 ? id : null; +} + +export function rememberWorkbenchUserId(userId: number): void { + if (typeof window === 'undefined' || !window.localStorage || userId <= 0) return; + window.localStorage.setItem(WORKBENCH_USER_KEY, String(userId)); +} + +/** 账号切换时清空工作台缓存,并记下当前用户。返回是否发生了清空。 */ +export function adoptWorkbenchUser(nextUserId: number): boolean { + const prev = readWorkbenchUserId(); + const reset = shouldResetWorkbenchForUser(prev, nextUserId); + if (reset) resetWorkbenchStorage(); + rememberWorkbenchUserId(nextUserId); + return reset; +} + /** Default true = collapsed (user preference for right doc rail). */ export function loadRightRailCollapsed(fallback = true): boolean { return safeGetItem(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback); diff --git a/web/src/data/storageUser.test.ts b/web/src/data/storageUser.test.ts new file mode 100644 index 00000000..fad2239d --- /dev/null +++ b/web/src/data/storageUser.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldResetWorkbenchForUser } from './storage'; + +test('same account and first login do not reset workbench storage', () => { + assert.equal(shouldResetWorkbenchForUser(null, 9), false); + assert.equal(shouldResetWorkbenchForUser(0, 9), false); + assert.equal(shouldResetWorkbenchForUser(9, 9), false); +}); + +test('switching accounts resets workbench storage', () => { + assert.equal(shouldResetWorkbenchForUser(9, 398), true); + assert.equal(shouldResetWorkbenchForUser(398, 9), true); +}); diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 7c396860..024a9edf 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -9,6 +9,7 @@ import { resolveRequirementsTaskKey, resolveTaskKeyFromMessage, resolveTaskTemplateForMessage, + normalizeTaskKeyToProject, } from './templateMatch'; import { DeliverableItem } from '../types/workbench'; @@ -223,3 +224,18 @@ test('current-doc pin still wins when no other deliverable is named', () => { assert.equal(resolved?.taskKey, 'intake'); assert.equal(resolved?.pinnedToActiveDoc, true); }); + +test('v3 bd_ task key wins over unprefixed intake alias', () => { + const bdIntake: DeliverableItem = { + ...base, + docId: 'doc_proj6579_bd_intake', + projectId: 6579, + taskKey: 'bd_intake', + title: '用户初始需求收集表', + isTemplateOnly: true, + }; + assert.equal(normalizeTaskKeyToProject('intake', [bdIntake]), 'bd_intake'); + assert.equal(normalizeTaskKeyToProject('intake', [bdIntake, { ...bdIntake, taskKey: 'intake', docId: 'doc-p6579-intake' }]), 'intake'); + const resolved = resolveTaskTemplateForMessage('开始编写用户初始需求表', [bdIntake]); + assert.equal(resolved?.taskKey, 'bd_intake'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index c73b9d5c..0a2d0aba 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -122,6 +122,24 @@ function namedAliasTaskKey(text: string, excludeTaskKey?: string): string | unde return undefined; } +/** + * 把语义任务键对齐到当前项目的实际键。 + * 精确命中优先;否则仅当唯一后缀为 `_${semanticKey}` 时改写(intake → bd_intake)。 + */ +export function normalizeTaskKeyToProject(semanticKey: string, deliverables: DeliverableItem[]): string { + const keys = [ + ...new Set( + deliverables + .map((item) => item.taskKey) + .filter((key): key is string => Boolean(key)), + ), + ]; + if (keys.includes(semanticKey)) return semanticKey; + const suffix = `_${semanticKey}`; + const hits = keys.filter((key) => key.endsWith(suffix)); + return hits.length === 1 ? hits[0] : semanticKey; +} + /** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */ export function resolveTaskKeyFromMessage(text: string): string | undefined { const normalized = text.trim(); @@ -153,12 +171,14 @@ export function matchTemplateDeliverable( const pool = templatePool(deliverables); const taskKey = resolveTaskKeyFromMessage(text); if (!taskKey) return undefined; + const aligned = normalizeTaskKeyToProject(taskKey, deliverables); - const hit = findTemplateByTaskKey(pool, taskKey); - if (hit) return hit; + const hit = findTemplateByTaskKey(pool, aligned) || findTemplateByTaskKey(pool, taskKey); + if (hit) return { ...hit, taskKey: aligned }; if (taskKey === 'intake' || taskKey === 'user_requirements') { - return pickRequirementsTask(text, pool); + const picked = pickRequirementsTask(text, pool); + return picked ? { ...picked, taskKey: aligned } : undefined; } return undefined; } @@ -179,8 +199,9 @@ export function resolveTaskTemplateForMessage( const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!); const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey); if (named) { - const template = matchTemplateDeliverableByKey(named, deliverables, text); - return { taskKey: named, template }; + const taskKey = normalizeTaskKeyToProject(named, deliverables); + const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + return { taskKey, template }; } if (pinnedKey) { const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text); @@ -195,12 +216,14 @@ export function resolveTaskTemplateForMessage( const named = explicitNamedDeliverableTaskKey(text, deliverables); if (named) { - const template = matchTemplateDeliverableByKey(named, deliverables, text); - return { taskKey: named, template }; + const taskKey = normalizeTaskKeyToProject(named, deliverables); + const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + return { taskKey, template }; } - const taskKey = resolveTaskKeyFromMessage(text); - if (!taskKey) return undefined; + const rawKey = resolveTaskKeyFromMessage(text); + if (!rawKey) return undefined; + const taskKey = normalizeTaskKeyToProject(rawKey, deliverables); const template = matchTemplateDeliverable(text, deliverables); return { taskKey, template }; } diff --git a/web/src/lib/projectSettingsMode.test.ts b/web/src/lib/projectSettingsMode.test.ts index d88e27a5..b9db3ba3 100644 --- a/web/src/lib/projectSettingsMode.test.ts +++ b/web/src/lib/projectSettingsMode.test.ts @@ -24,16 +24,6 @@ describe('projectSettingsMode', () => { }), 'frozen', ); - assert.equal( - resolveProjectSettingsMode({ - settings: { - can_edit_plan: true, - lifecycle_status: 'planning', - live_instance: { id: 89, status: 'active', config_revision: 1 }, - }, - }), - 'frozen', - ); assert.equal(shouldShowPlanEditor('frozen'), false); assert.equal(shouldShowSavePreview('frozen'), false); const chrome = projectSettingsChrome('frozen'); @@ -44,6 +34,39 @@ describe('projectSettingsMode', () => { assert.equal(chrome.showDutyReassign, true); }); + it('keeps an active workflow editable except the definition switch', () => { + assert.equal( + resolveProjectSettingsMode({ + settings: { + can_edit_plan: false, + lifecycle_status: 'active', + plan_edit_mode: 'live', + live_instance: { id: 366, status: 'active', config_revision: 2 }, + }, + }), + 'live_edit', + ); + assert.equal( + resolveProjectSettingsMode({ + settings: { + can_edit_plan: false, + lifecycle_status: 'active', + plan_edit_mode: 'frozen', + live_instance: { id: 89, status: 'active', config_revision: 1 }, + }, + }), + 'frozen', + ); + assert.equal(shouldShowPlanEditor('live_edit'), true); + assert.equal(shouldShowSavePreview('live_edit'), true); + const chrome = projectSettingsChrome('live_edit'); + assert.equal(chrome.showWorkflowSelector, false); + assert.equal(chrome.showDutySelects, true); + assert.equal(chrome.showSavePreview, true); + assert.equal(chrome.showStepPills, false); + assert.equal(chrome.showFrozenSnapshot, false); + }); + it('stays planning only for an editable draft', () => { assert.equal( resolveProjectSettingsMode({ diff --git a/web/src/lib/projectSettingsMode.ts b/web/src/lib/projectSettingsMode.ts index 30f2d38c..82990bba 100644 --- a/web/src/lib/projectSettingsMode.ts +++ b/web/src/lib/projectSettingsMode.ts @@ -1,29 +1,34 @@ import type { ProjectSettings, WorkflowPlan } from '../types/projectSettings'; -export type ProjectSettingsMode = 'loading' | 'planning' | 'frozen' | 'error'; +export type ProjectSettingsMode = 'loading' | 'planning' | 'live_edit' | 'frozen' | 'error'; export function resolveProjectSettingsMode(input: { loading?: boolean; - settings?: Pick | null; + settings?: Pick | null; loadError?: boolean; }): ProjectSettingsMode { if (input.loadError && !input.settings) return 'error'; if (input.loading && !input.settings) return 'loading'; const settings = input.settings; if (!settings) return 'loading'; + if (settings.plan_edit_mode === 'frozen') return 'frozen'; + if (settings.plan_edit_mode === 'live') return 'live_edit'; + if (settings.plan_edit_mode === 'planning') return 'planning'; + const live = settings.live_instance; + const closed = Boolean(live && (live.status === 'completed' || live.status === 'terminated')); + if (closed) return 'frozen'; + if (live) return 'live_edit'; if (!settings.can_edit_plan) return 'frozen'; if (settings.lifecycle_status === 'active') return 'frozen'; - const live = settings.live_instance; - if (live && live.status !== 'completed' && live.status !== 'terminated') return 'frozen'; return 'planning'; } export function shouldShowPlanEditor(mode: ProjectSettingsMode): boolean { - return mode === 'planning'; + return mode === 'planning' || mode === 'live_edit'; } export function shouldShowSavePreview(mode: ProjectSettingsMode): boolean { - return mode === 'planning'; + return mode === 'planning' || mode === 'live_edit'; } export function projectSettingsChrome(mode: ProjectSettingsMode): { @@ -35,14 +40,14 @@ export function projectSettingsChrome(mode: ProjectSettingsMode): { showDutyReassign: boolean; } { const planning = mode === 'planning'; + const live = mode === 'live_edit'; const frozen = mode === 'frozen'; return { showWorkflowSelector: planning, - showDutySelects: planning, - showSavePreview: planning, + showDutySelects: planning || live, + showSavePreview: planning || live, showStepPills: planning, showFrozenSnapshot: frozen, - // 激活后可受控改派(需前端再与 canAssignLead 求交) showDutyReassign: frozen, }; } diff --git a/web/src/lib/taskRunErrorInfo.test.ts b/web/src/lib/taskRunErrorInfo.test.ts new file mode 100644 index 00000000..87a2840b --- /dev/null +++ b/web/src/lib/taskRunErrorInfo.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildNetworkErrorInfo } from './taskRunErrorInfo'; + +test('403 revision denial is not reported as a network error', () => { + const info = buildNetworkErrorInfo(403, { errorCode: 'ERR_FORBIDDEN', detail: '无权访问该文档' }); + assert.equal(info.code, 'ERR_FORBIDDEN'); + assert.notEqual(info.code, 'NETWORK_ERROR'); + assert.match(info.userMessage, /无权访问该文档/); +}); + +test('TASK_SCOPE_UNRESOLVED names the missing workflow stage', () => { + const info = buildNetworkErrorInfo(409, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); + assert.equal(info.code, 'TASK_SCOPE_UNRESOLVED'); + assert.match(info.userMessage, /无法定位该文档任务的工作流阶段/); + assert.notEqual(info.code, 'NETWORK_ERROR'); +}); diff --git a/web/src/lib/taskRunErrorInfo.ts b/web/src/lib/taskRunErrorInfo.ts index a517cfd9..98793bff 100644 --- a/web/src/lib/taskRunErrorInfo.ts +++ b/web/src/lib/taskRunErrorInfo.ts @@ -32,6 +32,7 @@ const fatalDenialCopy: Record = { WORKFLOW_INSTANCE_CLOSED: '工作流实例已关闭,无法执行该文档任务', DOC_DUTY_NOT_MANAGED: '该文档未配置职责,无法执行', SESSION_FORBIDDEN: '无权访问该会话', + TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员', }; export function buildNetworkErrorInfo( @@ -65,7 +66,7 @@ export function buildNetworkErrorInfo( }; } if (fatalDenialCopy[code]) { - const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED'; + const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED' || code === 'TASK_SCOPE_UNRESOLVED'; return { userMessage: detail || fatalDenialCopy[code], code, diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index c31e1908..85fbf77f 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -67,6 +67,8 @@ export interface ProjectSettings { plan?: WorkflowPlan; can_edit_plan: boolean; can_activate: boolean; + /** planning=激活前;live=激活后可改配置但不可切换;frozen=关闭或只读项目。 */ + plan_edit_mode?: 'planning' | 'live' | 'frozen'; active_stage_key?: string; stage_advance_ready?: boolean; live_instance?: { id: number; status: string; config_revision: number }; From 87c6068da722865cf7007701d250450c1fca2a84 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:22:50 +0000 Subject: [PATCH 4/5] test(web): expect a specific message when task scope cannot be resolved The scope error now has its own copy, so the 4xx case should assert that text instead of the generic request-failed fallback. Co-authored-by: Cursor --- web/src/hooks/useTaskRunAttempts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 41fd3709..7775f763 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -58,7 +58,7 @@ test('unknown 4xx business code is not reported as a network error', () => { const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED'); - assert.match(bare.userMessage, /请求失败/); + assert.match(bare.userMessage, /无法定位该文档任务的工作流阶段/); assert.equal(bare.userMessage.includes('请检查网络'), false); }); From b9eb9460c07a76617aa73dfe73cc677eaa27246f Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:31:02 +0000 Subject: [PATCH 5/5] docs(test): record the multi-user workflow fix and dev smoke The report keeps the 6579 document backfill, the live-edit switch rejection, and the rebuilt dev health check. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 3 +- .../2026-09-21-dev-multiuser-four-issues.md | 50 ++++++++++++++++ ...1-dev-multiuser-four-issues-test-report.md | 60 +++++++++++++++++++ .../6579-backfill.txt | 17 ++++++ .../api-6579.txt | 6 ++ 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .doc/ops/short-term/2026-09-21-dev-multiuser-four-issues.md create mode 100644 .doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/6579-backfill.txt create mode 100644 .doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/api-6579.txt diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 93a3f7f2..60c6f22c 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-dev-multiuser-four-issues | fix/dev-multiuser-four-issues-2026-09-21 | .worktrees/fix-dev-multiuser-four-issues | projectinstance+docgov | done | | Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done | | Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done | | Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done | @@ -172,7 +173,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | 任务错误透传已合入 dev 并 rebuild;`GET /api/v1/tasks/runs/{id}` 为 snake_case;`/health` schema_version=59 | - | +| Cursor | idle | 2026-09-21 | dev 已按 fix/dev-multiuser-four-issues-2026-09-21 重建;`/health` schema_version=59 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-dev-multiuser-four-issues.md b/.doc/ops/short-term/2026-09-21-dev-multiuser-four-issues.md new file mode 100644 index 00000000..7fd0c1f3 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-dev-multiuser-four-issues.md @@ -0,0 +1,50 @@ +--- +title: "dev 多用户四问题修复" +status: done +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/dev-multiuser-four-issues-2026-09-21 +worktree: .worktrees/fix-dev-multiuser-four-issues +mutex: projectinstance+docgov +--- + +# dev 多用户四问题修复 + +## 目标 + +项目 6579 上,成员能看到激活后的受控文档,消息能路由到 `bd_*` 任务键,职责矩阵与工作流配置一致,激活后仍可改职责/审批/启用但不能切换工作流。 + +## 范围 + +- 激活与列表时把已启用 doc_task 幂等写入 `document_records`(不覆盖已有正文) +- 语义任务键按项目实际键做后缀归一;同浏览器切换账号时清空工作台本地状态 +- 职责矩阵改走权威职责与 `PATCH .../duties`,去掉演示工号与演练文案 +- `UpdateLivePlan`:激活后可改配置;切换工作流仍拒绝;项目 143 保持冻结 + +## 非目标 + +- 不改写、不终止项目 143 +- 不把 `my_private` 跨用户 403 改成放行 +- 不晋升 test/main + +## 步骤 + +1. [x] 物化模板文档与 6579 回填 +2. [x] 任务键归一、账号隔离、403/409 文案 +3. [x] 职责矩阵接权威数据 +4. [x] 激活后 live 配置编辑 +5. [x] 占槽合入 dev、冒烟与实测报告 + +## 验收标准 + +- [ ] 陈威浏览器复测仍待做;管理员 `GET /projects/6579/documents` 已看到 7 个 `bd_*` 键 +- [x] 「开始编写用户初始需求表」在单测中解析为 `bd_intake` +- [x] 403 文案单测不再落到 NETWORK_ERROR;同浏览器切换清空有单测,未做双账号点击 +- [x] 激活后切换工作流被 409 拒绝,项目 143 为 frozen;职责改写有单测 + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: `.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md` diff --git a/.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md b/.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md new file mode 100644 index 00000000..bb369d41 --- /dev/null +++ b/.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md @@ -0,0 +1,60 @@ +# dev 多用户四问题修复 测试报告 + +## 一、 测试背景 (Background) +dev 上项目「新的测试项目」(id 6579)出现四类问题:职责矩阵与工作流配置不一致、激活后整表配置被冻结、成员看不到文档、发送「开始编写用户初始需求表」出现 409 并被界面说成网络错误。项目 143 保持只读。 + +## 二、 测试目标 (Test Objectives) +- 激活后的启用文档在服务端 `document_records` 中可见。 +- 语义任务键能对齐到项目里的 `bd_*` 键。 +- 激活后可以改职责、审批和启用,但不能切换工作流;项目 143 仍是冻结。 +- 403 不再被当成 `NETWORK_ERROR`。 + +## 三、 测试方案与环境 (Test Plan & Environment) +- OS:linux;分支 `fix/dev-multiuser-four-issues-2026-09-21`。 +- dev 容器于 2026-09-21 用该分支工作树重建。`/health`:`schema_version=59`,`agent_backend=pi-agent`,`kb_ready=true`。 +- 单元测试覆盖物化、live 规划、任务键后缀和设置模式。 +- 用管理员账号调用 dev `:8080` 核对项目 6579 与 143。未代登陈威(没有使用成员口令)。 + +## 四、 预期结果 (Expected Results) +- 6579 有 7 条 `bd_*` 模板文档;`bd_intake` 编制人为 398、策略 `author_only`。 +- 设置接口 `plan_edit_mode=live`;把工作流键改成别的值返回 409 `WORKFLOW_SWITCH_FROZEN`,版本号不变。 +- 项目 143 的 `plan_edit_mode=frozen`。 +- 「开始编写用户初始需求表」在只有 `bd_intake` 的交付物池里解析为 `bd_intake`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:6579 文档回填 +- 测试输入:向 dev 库为项目 6579 幂等插入 7 条 `doc_proj6579_bd_*`。 +- 实际输出:`INSERT 0 7`。随后 `GET /api/v1/projects/6579/documents` 返回 200,任务键包含 `bd_cdo_quote`、`bd_feasibility_dev`、`bd_feasibility_reg`、`bd_intake`、`bd_prelim_design`、`bd_research`、`bd_user_requirements`。另有一条更早的本地键 `intake`(`doc_proj6579_intake`),不是本次插入,也没有改项目 143(该项目文档数仍为 1)。 +- 评价判定:PASS。成员列表是否隐藏这条旧 `intake` 行,取决于阶段过滤;7 份 `bd_*` 已在服务端。 +- 性能与耗时:插入语句一次返回。 + +### 用例 2:激活后禁止切换工作流 +- 测试输入:`GET /api/v1/projects/6579/settings`,再 `PUT /workflow-plan` 且 `live_edit=true`、`workflow_definition_key=not-the-live-workflow`。 +- 实际输出:设置 `plan_edit_mode=live`,`can_edit_plan=false`,`workflow_plan_revision=1`。PUT 返回 409,错误码 `WORKFLOW_SWITCH_FROZEN`,消息「运行中不能切换工作流,只能调整职责、审批和文档启用」。再次读取修订号仍为 1,定义键仍为 `main`。 +- 评价判定:PASS。 +- 性能与耗时:两次读取与一次拒绝写,均在 1 秒内。 + +### 用例 3:项目 143 保持冻结 +- 测试输入:`GET /api/v1/projects/143/settings`。 +- 实际输出:`plan_edit_mode=frozen`。 +- 评价判定:PASS。 + +### 用例 4:任务键与错误文案 +- 测试输入:前端单测「开始编写用户初始需求表」对只有 `bd_intake` 的池;`buildNetworkErrorInfo(409, TASK_SCOPE_UNRESOLVED)` 与 403 `ERR_FORBIDDEN`。 +- 实际输出:任务键为 `bd_intake`。409 文案为「无法定位该文档任务的工作流阶段,请检查任务名或联系管理员」,不是网络错误。403 文案保留「无权访问」。 +- 评价判定:PASS(单测)。浏览器里用陈威再发一句消息未在本轮复测。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) +- `go test -short`:`internal/projectinstance`、`internal/app`、`internal/server` 通过。`internal/docgov` 新增物化测试通过;既有 `TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK` 在连到 dev 库时失败,原因是它用随机 `project_id` 插入文档,而建项目语句的错误被忽略,外键失败。该失败不在本次改动路径上。 +- `web`:`npm test` 与 `npm run build` 通过。 +- 重建后健康检查: + +```json +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":59,"schema_ready":true,"task_store_ready":true} +``` + +证据:`.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/`。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) +服务端文档、live 配置和切换拒绝已在 dev 容器上核对。可以合入 `dev`。尚未用陈威浏览器再走一遍发消息;旧的 `doc_proj6579_intake` 行仍在,不影响 `bd_*` 列表,但可以另开任务决定是否清掉。项目 143 未改写。 diff --git a/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/6579-backfill.txt b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/6579-backfill.txt new file mode 100644 index 00000000..611805f5 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/6579-backfill.txt @@ -0,0 +1,17 @@ + doc_id | task_key | is_template_only | author_id | duty_policy +-----------------------------------+----------------------+------------------+-----------+------------- + doc_proj6579_bd_cdo_quote | bd_cdo_quote | t | 0 | + doc_proj6579_bd_feasibility_dev | bd_feasibility_dev | t | 0 | + doc_proj6579_bd_feasibility_reg | bd_feasibility_reg | t | 0 | + doc_proj6579_bd_intake | bd_intake | t | 398 | author_only + doc_proj6579_bd_prelim_design | bd_prelim_design | t | 0 | + doc_proj6579_bd_research | bd_research | t | 0 | + doc_proj6579_bd_user_requirements | bd_user_requirements | t | 0 | + doc_proj6579_intake | intake | t | 398 | author_only +(8 rows) + + project_143_docs +------------------ + 1 +(1 row) + diff --git a/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/api-6579.txt b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/api-6579.txt new file mode 100644 index 00000000..b1935c0e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/api-6579.txt @@ -0,0 +1,6 @@ +login 200 user admin 9 +documents 200 count 8 task_keys ['bd_cdo_quote', 'bd_feasibility_dev', 'bd_feasibility_reg', 'bd_intake', 'bd_prelim_design', 'bd_research', 'bd_user_requirements', 'intake'] +settings 200 plan_edit_mode live revision 1 can_edit_plan False definition main +switch_put 409 error {'error': 'WORKFLOW_SWITCH_FROZEN', 'message': '运行中不能切换工作流,只能调整职责、审批和文档启用'} +revision_after_switch_attempt 1 definition main +project_143_mode 200 frozen