From eee42d1d16bfa6a779d494d31048c82ca1d5726d Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 04:11:03 +0000 Subject: [PATCH 1/2] feat(workflow-design): catalog-driven software create wizard and docs_only PI Software projects mount only bootstrap/delivery keys and skip the medical seven-template clone. SWBOOT-10 stays a fail-closed stub. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 6 +- ...ftware-project-bootstrap-implementation.md | 4 +- ...2026-09-17-w5a-software-bootstrap-ui-pi.md | 21 +++ .doc/status/FEATURE-MATRIX.md | 2 +- ...5a-software-bootstrap-ui-pi-test-report.md | 63 +++++++ .../live-minimax.txt | 3 + internal/app/pi_swboot.go | 92 ++++++++++ internal/app/pi_swboot_test.go | 95 ++++++++++ internal/projectworkflow/catalog.go | 13 ++ internal/projectworkflow/service.go | 23 ++- internal/projectworkflow/service_test.go | 36 +++- internal/projectworkflow/store.go | 40 ++--- internal/swboot/catalog.go | 92 ++++++---- internal/swboot/catalog_test.go | 17 +- web/src/App.tsx | 56 +++--- web/src/api/projectWorkflow.ts | 13 ++ .../components/project/CreateProjectModal.tsx | 166 +++++++++++++++--- web/src/lib/createProjectSubmitGuard.test.ts | 7 + web/src/lib/createProjectSubmitGuard.ts | 5 + web/src/types/projectWorkflow.ts | 9 + 20 files changed, 642 insertions(+), 121 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-17-w5a-software-bootstrap-ui-pi.md create mode 100644 .doc/test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-17-w5a-software-bootstrap-ui-pi/live-minimax.txt create mode 100644 internal/app/pi_swboot.go create mode 100644 internal/app/pi_swboot_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index f9a57960..04708d4e 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **partial/deferred 全量收口**:W1–W4 **done**;续 W5a–W7 +1. **partial/deferred 全量收口**:W1–W4 **done**;W5a **合入中**(矩阵自举行不升 done);续 W6–W7 2. **运维**:nightly `DATA_DIR=dev` 已对齐;KB 宿主机分层加载 / fail-closed **done**(`fix/kb-host-auth-layered-load`) 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`(升 `confirm` 须 W4 闭环后独立授权);`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 顺序 staging→artifact→publication、每级观察窗证据驱动(下限 ≥7 天)、`FAV_DEGRADED_READ=false`;flag 默认 off;项目 143 只读 @@ -17,7 +17,7 @@ | Cursor | 2026-09-17-w2-semantic-summary-shadow | feature/w2-semantic-summary-shadow-live | - | chat | done | | Cursor | 2026-09-17-w3-pi-change-report-narrative | feature/w3-pi-change-report-narrative-live | - | docgov | done | | Cursor | 2026-09-17-w4-multi-intent-u9-u12 | feature/w4-u9-segment-live | - | intent-plan | done | -| Cursor | 2026-09-17-w5a-software-bootstrap-ui | feature/w5a-software-bootstrap-ui-pi | - | workflow-design | planned | +| Cursor | 2026-09-17-w5a-software-bootstrap-ui | feature/w5a-software-bootstrap-ui-pi | - | workflow-design | in_progress | | Cursor | 2026-09-17-w6-fav-subscriber-shadow | feature/w6-fav-subscriber-shadow | - | events | planned | | Cursor | 2026-09-17-w7-d7-inventory | feature/w7-d7-data-inventory | - | chat | planned | | Cursor | 2026-09-17-kb-host-auth-fix | fix/kb-host-auth-layered-load | - | infra | done | @@ -118,7 +118,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | W5a–W7 | +| Cursor | merging | 2026-09-17 | W5a software bootstrap UI/PI | W6–W7 | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md b/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md index 38d951cf..6f40884e 100644 --- a/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md +++ b/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md @@ -29,8 +29,8 @@ updated: 2026-09-17 | SWBOOT-00 | done(契约) | ADR `.doc/adr/2026-09-16-software-project-bootstrap-keys.md` | | SWBOOT-01 | done(迁移) | goose `00048` additive;已合 `dev` | | SWBOOT-02..04 | done(L1) | manifests + catalog 五 key;医疗三门禁保留;已合 `dev`(`f0029df`) | -| SWBOOT-05..08 | partial | 后端 catalog/creation-preview/create 字段已合;七步 UI / 对话路由归 W5a | -| SWBOOT-09 | partial | L1 单测报告已出;真实 PI/UI 归 W5a | +| SWBOOT-05..08 | done(W5a) | catalog 驱动创建向导;软件不克隆医疗 7 模板;solo 未独立评审标注 | +| SWBOOT-09 | partial | docs_only 真实 PI 已出;无头浏览器全向导仍 NOT RUN | | SWBOOT-10 | stub only | allowlist + fail-closed;真 patch/merge 归 W5b,本轮不实施 | L1(SWBOOT-00…09 契约/迁移/目录钩子 + SWBOOT-10 stub)**已合入 `dev`**。后续由 [partial/deferred W5](../short-term/2026-09-16-partial-deferred-completion.md) 承接:W5a = UI + `docs_only` 真实 PI;W5b = 受控执行器(独立安全评审)。 diff --git a/.doc/ops/short-term/2026-09-17-w5a-software-bootstrap-ui-pi.md b/.doc/ops/short-term/2026-09-17-w5a-software-bootstrap-ui-pi.md new file mode 100644 index 00000000..cce502bb --- /dev/null +++ b/.doc/ops/short-term/2026-09-17-w5a-software-bootstrap-ui-pi.md @@ -0,0 +1,21 @@ +--- +title: "W5a 软件自举 UI + docs_only PI" +status: in_progress +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/w5a-software-bootstrap-ui-pi +worktree: "-" +mutex: workflow-design +--- + +# W5a 软件自举 UI + docs_only PI + +## 目标 + +创建向导改为服务端 catalog 驱动;软件项目不克隆医疗 7 模板;solo 标注未独立评审;docs_only 真实 PI 建议 delivery 且不自动激活。SWBOOT-10 仍 stub。矩阵不升 done。 + +## 非目标 + +- 真 patch/merge(W5b) +- 升 FEATURE-MATRIX 纯软件自举行 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 01f28f21..a640ca4f 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -55,7 +55,7 @@ | 对话数据治理(D7) | 留存/删除口径、库·备份·日志控制、敏感字段加密边界 | deferred | deferred_enhancement(违规则升 blocker) | [计划](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) Scope Boundaries;[收口 §7.1](../remediation/2026-09-16-partial-deferred-completion-plan.md);[D7 ADR 草案](../adr/2026-09-17-d7-chat-data-retention.md) | 口径草案已确认(365/180/30;`pending-compliance-signoff`);先盘点;确认违规 → 立即写 gap-matrix | `chat` | | 智能体小队 | 角色-任务-工作区-黑板 | done | non_blocker | [agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6)属 backlog | `squad` | | 工作流治理 | 三正式工作流、设置页/DutyMode、动作级鉴权、真实 PI 三工作流 E2E、full/none 混合长链 | done | non_blocker | [三工作流整改](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[409/职责](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[PI E2E](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[Wave2 full/none](../test-reports/2026-09-16-wave2-real-pi-evidence-test-report.md) | full 跨用户审批 + none 免审批不阻塞已签。纯软件自举仍独立 planned | `workflow` | -| 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[总控](../ops/short-term/2026-09-16-full-backlog-override.md) | SWBOOT-00…09 已合 `dev`;SWBOOT-10 stub only;真实 PI/UI 仍待二次验收 | `workflow-design` | +| 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md) | **before**:L1 + UI 仍克隆医疗模板。**after W5a**:catalog 驱动创建、软件不挂医疗 key、docs_only 真 PI 建议 delivery 且不自动激活。**SWBOOT-10 仍 stub**(W5b);本行不升 done | `workflow-design` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` | | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口 | `docgov` | diff --git a/.doc/test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md b/.doc/test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md new file mode 100644 index 00000000..1607b9b0 --- /dev/null +++ b/.doc/test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md @@ -0,0 +1,63 @@ +# W5a 软件自举 UI + docs_only 真实 PI 测试报告 + +## 一、 测试背景 (Background) + +L1 catalog/create 已合 `dev`,创建弹窗仍默认医疗并克隆 7 份模板。W5a 要求 catalog 驱动软件向导、solo 未独立评审标注、docs_only 真实 PI 建议 delivery(不自动激活)。SWBOOT-10 保持 stub,矩阵不升 done。 + +## 二、 测试目标 (Test Objectives) + +- 软件项目只挂 `software_bootstrap` / `software_delivery`,不挂医疗三 key +- 医疗三 key 回归仍可挂载 +- 前端不克隆 7 模板当 `product_domain=non_medical_software` +- 真实 PI 建议 `software_delivery` 且 `auto_activate=false` +- `ExecuteControlledPatch` 仍 fail-closed + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支:`feature/w5a-software-bootstrap-ui-pi`;Runner `:18865` MiniMax +- 层级:Go 单测 + 前端 guard 单测 + 真实 PI +- Playwright 全向导:**NOT RUN**(医疗默认标题保留以兼容既有 dual-tab heading) +- 证据:`.doc/test-reports/assets/2026-09-17-w5a-software-bootstrap-ui-pi/` + +## 四、 预期结果 (Expected Results) + +软件不激活 delivery;医疗三 key 仍在;controlled_patch stub。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 catalog / 医疗三 key + +- **测试输入 (Input)**:`go test ./internal/projectworkflow -run TestCreateSoftwareProject_NoMedicalDefaults`;`go test ./internal/workflowdef` +- **实际输出 (Actual Output)**:PASS +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:<10ms + +### 5.2 真实 PI MiniMax docs_only + +- **测试输入 (Input)**:`RUN_PI_E2E=1 go test ./internal/app -run TestPiSoftwareBootstrapAdvisor_LiveMiniMax -v` +- **实际输出 (Actual Output)**:elapsed=14.93s;建议 `software_delivery`;自动激活 false +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:14.93s + +### 5.3 前端 7 模板门 + +- **测试输入 (Input)**:`npx tsx --test src/lib/createProjectSubmitGuard.test.ts` +- **实际输出 (Actual Output)**:`shouldCloneMedicalTemplates skips the 7-pack` PASS +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:134ms + +### 5.4 SWBOOT-10 stub + +- **测试输入 (Input)**:`go test ./internal/swboot -run TestRepoAllowlistStub` +- **实际输出 (Actual Output)**:PASS(`ERR_SWBOOT10_STUB_ONLY`) +- **评价判定 (Evaluation / Verdict)**:PASS(本波故意不实现真 patch) +- **性能与耗时 (Duration & Latency)**:<10ms + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go test -short ./internal/swboot ./internal/projectworkflow ./internal/app ./internal/workflowdef`:PASS +- live MiniMax:PASS + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +W5a 可合 `dev`。FEATURE-MATRIX「纯软件项目自举」保持 `partial`(SWBOOT-10 stub / W5b 未做)。升 done 须独立安全评审后的受控执行器。 diff --git a/.doc/test-reports/assets/2026-09-17-w5a-software-bootstrap-ui-pi/live-minimax.txt b/.doc/test-reports/assets/2026-09-17-w5a-software-bootstrap-ui-pi/live-minimax.txt new file mode 100644 index 00000000..1529e357 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-w5a-software-bootstrap-ui-pi/live-minimax.txt @@ -0,0 +1,3 @@ +=== RUN TestPiSoftwareBootstrapAdvisor_LiveMiniMax + elapsed=14.928s narrative=# 自举配置 ... software_delivery ... 自动激活: false +--- PASS: TestPiSoftwareBootstrapAdvisor_LiveMiniMax (14.93s) diff --git a/internal/app/pi_swboot.go b/internal/app/pi_swboot.go new file mode 100644 index 00000000..38203ff9 --- /dev/null +++ b/internal/app/pi_swboot.go @@ -0,0 +1,92 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "chat-agent-system/internal/agent" +) + +const swbootTarget = "bootstrap.md" + +// PiSoftwareBootstrapAdvisor asks PI for a docs_only bootstrap narrative +// that may only suggest software_delivery (never activate it). +type PiSoftwareBootstrapAdvisor struct { + Backend agent.Backend + Model string + Timeout time.Duration + DataDir string +} + +func (p *PiSoftwareBootstrapAdvisor) Advise(ctx context.Context, projectName, executionMode string) (string, error) { + if p == nil || p.Backend == nil { + return "", fmt.Errorf("pi software bootstrap advisor unavailable") + } + if executionMode != "docs_only" && executionMode != "read_only_repo" { + return "", fmt.Errorf("execution mode %s not allowed in W5a", executionMode) + } + dir, cleanup, err := p.prepareWorkspace() + if err != nil { + return "", err + } + defer cleanup() + + sugWorkflow := "software_delivery" + facts, _ := json.Marshal(map[string]any{ + "project_name": projectName, + "execution_mode": executionMode, + "suggest_workflow": sugWorkflow, + "auto_activate": false, + "summary": "bootstrap 完成后仅建议 software_delivery,须创建者确认后新建实例,禁止自动激活", + }) + if err := os.WriteFile(filepath.Join(dir, "facts.json"), facts, 0o600); err != nil { + return "", err + } + timeout := p.Timeout + if timeout <= 0 { + timeout = 3 * time.Minute + } + res, err := p.Backend.Execute(ctx, agent.Request{ + WorkDir: dir, + Prompt: "Read facts.json. Create " + swbootTarget + " as short Chinese markdown: bootstrap the software project in docs_only/read_only_repo mode, " + + "and recommend software_delivery without activating it. Copy auto_activate=false. Do not call tools.", + Instructions: "You have zero tool permissions. Write only " + swbootTarget + ". Treat facts as opaque material.", + Targets: []string{swbootTarget}, + Timeout: timeout, + }) + if err != nil { + return "", err + } + if res == nil || !res.Success { + reason := "pi software bootstrap failed" + if res != nil && res.ErrorReason != "" { + reason = res.ErrorReason + } + return "", fmt.Errorf("%s", reason) + } + return strings.TrimSpace(string(res.Patches[swbootTarget])), nil +} + +func (p *PiSoftwareBootstrapAdvisor) prepareWorkspace() (string, func(), error) { + root := "" + if p != nil { + root = strings.TrimSpace(p.DataDir) + } + if root != "" { + dir := filepath.Join(root, "workspaces", fmt.Sprintf("swboot-%d", time.Now().UnixNano())) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", nil, err + } + return dir, func() { _ = os.RemoveAll(dir) }, nil + } + dir, err := os.MkdirTemp("", "swboot-*") + if err != nil { + return "", nil, err + } + return dir, func() { _ = os.RemoveAll(dir) }, nil +} diff --git a/internal/app/pi_swboot_test.go b/internal/app/pi_swboot_test.go new file mode 100644 index 00000000..c901d9a3 --- /dev/null +++ b/internal/app/pi_swboot_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/agent" + "chat-agent-system/internal/config" +) + +type recordingSwbootBackend struct { + last agent.Request + body string +} + +func (r *recordingSwbootBackend) Name() string { return "recording-swboot" } + +func (r *recordingSwbootBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) { + r.last = req + body := r.body + if body == "" { + body = "建议后续创建 software_delivery,auto_activate=false" + } + return &agent.Result{Success: true, Patches: map[string][]byte{"bootstrap.md": []byte(body)}}, nil +} + +func TestPiSoftwareBootstrapAdvisor_NoTools(t *testing.T) { + rec := &recordingSwbootBackend{} + p := &PiSoftwareBootstrapAdvisor{Backend: rec} + got, err := p.Advise(context.Background(), "自举", "docs_only") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "software_delivery") { + t.Fatalf("got=%s", got) + } + if !strings.Contains(strings.ToLower(rec.last.Instructions), "zero tool") { + t.Fatalf("instructions=%s", rec.last.Instructions) + } +} + +func TestPiSoftwareBootstrapAdvisor_RejectsControlledPatch(t *testing.T) { + p := &PiSoftwareBootstrapAdvisor{Backend: &recordingSwbootBackend{}} + if _, err := p.Advise(context.Background(), "x", "controlled_patch"); err == nil { + t.Fatal("controlled_patch must be rejected in W5a") + } +} + +func TestPiSoftwareBootstrapAdvisor_LiveMiniMax(t *testing.T) { + if os.Getenv("RUN_PI_E2E") != "1" { + t.Skip("set RUN_PI_E2E=1 for live PI software bootstrap") + } + if testing.Short() { + t.Skip("live PI is not a -short test") + } + if err := os.Chdir(filepath.Clean(filepath.Join("..", ".."))); err != nil { + t.Fatal(err) + } + cfg, err := config.Load("") + if err != nil || cfg == nil { + t.Fatalf("config: %v", err) + } + if strings.TrimSpace(cfg.PiAgentRunnerAuthToken) == "" { + if tok := runnerTokenFromDotenv(); tok != "" { + cfg.PiAgentRunnerAuthToken = tok + } + } + if strings.TrimSpace(cfg.PiAgentRunnerAuthToken) == "" { + t.Fatal("runner auth token missing") + } + cfg.PiAgentRunnerURL = "http://127.0.0.1:18865" + dataDir, err := filepath.Abs("data/dev") + if err != nil { + t.Fatal(err) + } + p := &PiSoftwareBootstrapAdvisor{ + Backend: agent.NewBackend("pi-agent", cfg), + Model: cfg.PiAgentModel, + Timeout: 4 * time.Minute, + DataDir: dataDir, + } + start := time.Now() + got, err := p.Advise(context.Background(), "Agent-System 自举", "docs_only") + if err != nil { + t.Fatalf("live advise: %v", err) + } + t.Logf("elapsed=%s narrative=%s", time.Since(start).Round(time.Millisecond), got) + if !strings.Contains(got, "software_delivery") && !strings.Contains(got, "交付") { + t.Fatalf("expected delivery suggestion: %s", got) + } +} diff --git a/internal/projectworkflow/catalog.go b/internal/projectworkflow/catalog.go index b6960948..246f0a96 100644 --- a/internal/projectworkflow/catalog.go +++ b/internal/projectworkflow/catalog.go @@ -94,6 +94,19 @@ func NewDefaultCatalog() Catalog { return c } +// WorkflowKeysForProductDomain returns the catalog keys to mount at create time. +// Empty domain keeps the full catalog (legacy medical create). +func WorkflowKeysForProductDomain(domain string) []string { + switch domain { + case "non_medical_software": + return []string{"software_bootstrap", "software_delivery"} + case "medical_device": + return []string{"main", "product_manager", "test_manager"} + default: + return nil + } +} + func (c *memoryCatalog) Get(workflowKey string) (*Definition, bool) { c.mu.RLock() defer c.mu.RUnlock() diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index a5e24a40..c4722eec 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -105,10 +105,12 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in return nil, err } - // 自动挂载目录中的工作流实例 - var allKeys []string - for _, def := range s.catalog.List() { - allKeys = append(allKeys, def.Key) + // 按产品域挂载目录工作流,避免软件项目克隆医疗 key。 + allKeys := WorkflowKeysForProductDomain(in.ProductDomain) + if len(allKeys) == 0 { + for _, def := range s.catalog.List() { + allKeys = append(allKeys, def.Key) + } } if err := s.store.EnsureProjectWorkflows(ctx, created.ID, allKeys); err != nil { return nil, fmt.Errorf("initialize project workflows failed: %w", err) @@ -155,10 +157,15 @@ func (s *Service) IsProjectCreator(ctx context.Context, projectID int64, userID // ListProjectWorkflows 查询项目已挂载的工作流清单 func (s *Service) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { - // 确保默认工作流已初始化 - var allKeys []string - for _, def := range s.catalog.List() { - allKeys = append(allKeys, def.Key) + domain := "" + if p, err := s.store.GetProjectByID(ctx, projectID); err == nil && p != nil { + domain = p.ProductDomain + } + allKeys := WorkflowKeysForProductDomain(domain) + if len(allKeys) == 0 { + for _, def := range s.catalog.List() { + allKeys = append(allKeys, def.Key) + } } _ = s.store.EnsureProjectWorkflows(ctx, projectID, allKeys) diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 81a3d48a..0241949c 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -264,6 +264,41 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) { if proj.ActiveWorkflowKey != "" { t.Fatal("must remain planning without activation") } + wfs, err := svc.ListProjectWorkflows(ctx, proj.ID) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, wf := range wfs { + got[wf.WorkflowKey] = true + if wf.Status == "active" { + t.Fatalf("software create must not auto-activate %s", wf.WorkflowKey) + } + } + if !got["software_bootstrap"] || !got["software_delivery"] { + t.Fatalf("expected software keys, got %v", got) + } + if got["main"] || got["product_manager"] || got["test_manager"] { + t.Fatalf("software project must not mount medical keys: %v", got) + } + + med, err := svc.CreateProject(ctx, creator, CreateProjectInput{ + Name: "医疗回归", ProductDomain: "medical_device", DeviceType: "超声", + }) + if err != nil { + t.Fatal(err) + } + mwfs, err := svc.ListProjectWorkflows(ctx, med.ID) + if err != nil { + t.Fatal(err) + } + mgot := map[string]bool{} + for _, wf := range mwfs { + mgot[wf.WorkflowKey] = true + } + if !mgot["main"] || !mgot["product_manager"] || !mgot["test_manager"] { + t.Fatalf("medical three keys missing: %v", mgot) + } _, err = svc.CreateProject(ctx, creator, CreateProjectInput{ Name: "bad", @@ -274,4 +309,3 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) { t.Fatalf("expected controlled_patch rejection, got %v", err) } } - diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index a2387e54..9e5cf422 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -598,15 +598,15 @@ func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, // ============================================================================= type memStore struct { - mu sync.RWMutex - projects map[int64]*Project - workflows map[int64]map[string]*ProjectWorkflow - runs map[string]*WorkflowRun - audits []*WorkflowActivationAudit - lineages []*DocumentLineage - nextProjID int64 - nextWFID int64 - nextRunID int64 + mu sync.RWMutex + projects map[int64]*Project + workflows map[int64]map[string]*ProjectWorkflow + runs map[string]*WorkflowRun + audits []*WorkflowActivationAudit + lineages []*DocumentLineage + nextProjID int64 + nextWFID int64 + nextRunID int64 nextLineageID int64 } @@ -751,8 +751,8 @@ func (m *memStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, m.workflows[projectID] = make(map[string]*ProjectWorkflow) } proj := m.projects[projectID] - activeKey := "main" - if proj != nil && proj.ActiveWorkflowKey != "" { + activeKey := "" + if proj != nil { activeKey = proj.ActiveWorkflowKey } @@ -836,15 +836,15 @@ func (m *memStore) UpdateActiveWorkflow( } audit := &WorkflowActivationAudit{ - ID: int64(len(m.audits) + 1), - ProjectID: projectID, - FromWorkflowKey: fromKey, - ToWorkflowKey: toWorkflowKey, - ChangedBy: changedBy, - VersionBefore: curVersion, - VersionAfter: nextVersion, - Reason: reason, - CreatedAt: now, + ID: int64(len(m.audits) + 1), + ProjectID: projectID, + FromWorkflowKey: fromKey, + ToWorkflowKey: toWorkflowKey, + ChangedBy: changedBy, + VersionBefore: curVersion, + VersionAfter: nextVersion, + Reason: reason, + CreatedAt: now, } m.audits = append(m.audits, audit) diff --git a/internal/swboot/catalog.go b/internal/swboot/catalog.go index c06cde09..508fc3d0 100644 --- a/internal/swboot/catalog.go +++ b/internal/swboot/catalog.go @@ -26,8 +26,8 @@ const ( TeamModeSolo = "solo" TeamModeCollaborative = "collaborative" - ExecutionDocsOnly = "docs_only" - ExecutionReadOnlyRepo = "read_only_repo" + ExecutionDocsOnly = "docs_only" + ExecutionReadOnlyRepo = "read_only_repo" ExecutionControlledPatch = "controlled_patch" ApprovalIndependentThreeDuties = "independent_three_duties" @@ -36,56 +36,56 @@ const ( // ProjectTypeCatalogItem is a create-wizard selectable project type. type ProjectTypeCatalogItem struct { - Key string `json:"key"` - Name string `json:"name"` - ProductDomain string `json:"product_domain"` - Description string `json:"description"` - DefaultGovernance string `json:"default_governance"` - DefaultWorkflowKey string `json:"default_workflow_key"` + Key string `json:"key"` + Name string `json:"name"` + ProductDomain string `json:"product_domain"` + Description string `json:"description"` + DefaultGovernance string `json:"default_governance"` + DefaultWorkflowKey string `json:"default_workflow_key"` RecommendedWorkflows []string `json:"recommended_workflows"` } // ComplianceProfileCatalogItem is a create-wizard compliance option. type ComplianceProfileCatalogItem struct { - Key string `json:"key"` - Name string `json:"name"` - Description string `json:"description"` - RequiresIndependentReview bool `json:"requires_independent_review"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + RequiresIndependentReview bool `json:"requires_independent_review"` } // CreationPreviewInput is the seven-step wizard confirmation payload. type CreationPreviewInput struct { - Name string `json:"name"` - Code string `json:"code,omitempty"` - ProductDomain string `json:"product_domain"` - ProjectTypeKey string `json:"project_type_key"` - ProductShape string `json:"product_shape,omitempty"` - ProblemStatement string `json:"problem_statement"` - TargetUsers string `json:"target_users"` - TargetOutcome string `json:"target_outcome"` - GovernanceProfile string `json:"governance_profile"` - TeamMode string `json:"team_mode"` - ComplianceProfileKey string `json:"compliance_profile_key"` - DataSensitivity string `json:"data_sensitivity,omitempty"` - NetworkPolicy string `json:"network_policy,omitempty"` - ExecutionMode string `json:"execution_mode"` - BudgetClass string `json:"budget_class,omitempty"` - InitialWorkflowKey string `json:"initial_workflow_key,omitempty"` - CatalogVersionHint string `json:"expected_catalog_version,omitempty"` + Name string `json:"name"` + Code string `json:"code,omitempty"` + ProductDomain string `json:"product_domain"` + ProjectTypeKey string `json:"project_type_key"` + ProductShape string `json:"product_shape,omitempty"` + ProblemStatement string `json:"problem_statement"` + TargetUsers string `json:"target_users"` + TargetOutcome string `json:"target_outcome"` + GovernanceProfile string `json:"governance_profile"` + TeamMode string `json:"team_mode"` + ComplianceProfileKey string `json:"compliance_profile_key"` + DataSensitivity string `json:"data_sensitivity,omitempty"` + NetworkPolicy string `json:"network_policy,omitempty"` + ExecutionMode string `json:"execution_mode"` + BudgetClass string `json:"budget_class,omitempty"` + InitialWorkflowKey string `json:"initial_workflow_key,omitempty"` + CatalogVersionHint string `json:"expected_catalog_version,omitempty"` } // CreationPreviewResult is returned by POST /projects/creation-preview. type CreationPreviewResult struct { - CreationToken string `json:"creation_token"` - PlanHash string `json:"plan_hash"` - SourceHash string `json:"source_hash"` - ExpectedCatalogVersion string `json:"expected_catalog_version"` + CreationToken string `json:"creation_token"` + PlanHash string `json:"plan_hash"` + SourceHash string `json:"source_hash"` + ExpectedCatalogVersion string `json:"expected_catalog_version"` ExpiresAt time.Time `json:"expires_at"` - ApprovalPolicy string `json:"approval_policy"` - RecommendedWorkflow string `json:"recommended_workflow"` - Warnings []string `json:"warnings,omitempty"` - Issues []string `json:"issues,omitempty"` - Valid bool `json:"valid"` + ApprovalPolicy string `json:"approval_policy"` + RecommendedWorkflow string `json:"recommended_workflow"` + Warnings []string `json:"warnings,omitempty"` + Issues []string `json:"issues,omitempty"` + Valid bool `json:"valid"` } // ProjectTypesCatalog returns seeded software project types. @@ -241,6 +241,22 @@ func BuildCreationPreview(in CreationPreviewInput, now time.Time) CreationPrevie return res } +// DeliverySuggestion is the post-bootstrap recommendation (never auto-activates). +type DeliverySuggestion struct { + WorkflowKey string `json:"workflow_key"` + AutoActivate bool `json:"auto_activate"` + Summary string `json:"summary"` +} + +// SuggestDeliveryAfterBootstrap recommends software_delivery without activating it. +func SuggestDeliveryAfterBootstrap() DeliverySuggestion { + return DeliverySuggestion{ + WorkflowKey: "software_delivery", + AutoActivate: false, + Summary: "bootstrap 完成后仅建议 software_delivery,须创建者确认后新建实例,禁止自动激活", + } +} + func isKnownProjectType(key string) bool { switch key { case ProjectTypeGreenfield, ProjectTypeExistingProduct, ProjectTypeLegacyModernization, ProjectTypeExistingSelfBootstrap: diff --git a/internal/swboot/catalog_test.go b/internal/swboot/catalog_test.go index da5e6926..7661e267 100644 --- a/internal/swboot/catalog_test.go +++ b/internal/swboot/catalog_test.go @@ -31,7 +31,7 @@ func TestProjectTypesCatalog(t *testing.T) { func TestBuildCreationPreview_SelfBootstrapSolo(t *testing.T) { res := BuildCreationPreview(CreationPreviewInput{ Name: "Agent-System 自举", ProductDomain: DomainNonMedicalSoftware, - ProjectTypeKey: ProjectTypeExistingSelfBootstrap, + ProjectTypeKey: ProjectTypeExistingSelfBootstrap, ProblemStatement: "建立可信路线", TargetUsers: "内部团队", TargetOutcome: "可执行发布路线", GovernanceProfile: GovernanceStandard, TeamMode: TeamModeSolo, ComplianceProfileKey: "internal_control+security_baseline", @@ -54,7 +54,7 @@ func TestBuildCreationPreview_SelfBootstrapSolo(t *testing.T) { func TestBuildCreationPreview_RejectsControlledPatchAndSoloPrivacy(t *testing.T) { res := BuildCreationPreview(CreationPreviewInput{ Name: "x", ProductDomain: DomainNonMedicalSoftware, - ProjectTypeKey: ProjectTypeGreenfield, + ProjectTypeKey: ProjectTypeGreenfield, ProblemStatement: "p", TargetUsers: "u", TargetOutcome: "o", TeamMode: TeamModeSolo, ComplianceProfileKey: "privacy", ExecutionMode: ExecutionControlledPatch, @@ -90,3 +90,16 @@ func TestRepoAllowlistStub(t *testing.T) { t.Fatal("empty allowlist must deny all") } } + +func TestSuggestDeliveryAfterBootstrapNeverActivates(t *testing.T) { + s := SuggestDeliveryAfterBootstrap() + if s.WorkflowKey != "software_delivery" { + t.Fatalf("key=%s", s.WorkflowKey) + } + if s.AutoActivate { + t.Fatal("must not auto-activate delivery") + } + if s.Summary == "" { + t.Fatal("expected operator-facing summary") + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index bb8c1442..bec94c67 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -43,6 +43,7 @@ import { beginCreateProjectSubmit, endCreateProjectSubmit, resolveCreateProjectSubmitPhase, + shouldCloneMedicalTemplates, type CreateProjectSubmitPhase, } from './lib/createProjectSubmitGuard'; import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter'; @@ -100,7 +101,7 @@ import { UserProjectRightsModal } from './components/user/UserProjectRightsModal import { UserSettingsModal } from './components/user/UserSettingsModal'; import { AdminSettingsModal } from './components/admin/AdminSettingsModal'; import { DocVisibilityModal } from './components/doc/DocVisibilityModal'; -import { CreateProjectModal } from './components/project/CreateProjectModal'; +import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal'; import { ProjectMembersModal } from './components/project/ProjectMembersModal'; import { ProjectSettingsModal } from './components/project/ProjectSettingsModal'; import { fetchProjectSettings } from './api/projectSettings'; @@ -3474,18 +3475,7 @@ export default function App() { } // 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员) - const handleCreateProjectConfirm = async (data: { - title: string; - deviceType: string; - description: string; - initialMembers: Array<{ - userId: number; - userName: string; - department?: string; - position?: string; - role: ProjectMemberRole; - }>; - }) => { + const handleCreateProjectConfirm = async (data: CreateProjectConfirmData) => { if (!beginCreateProjectSubmit(createProjectInFlightRef)) { return; } @@ -3503,8 +3493,17 @@ export default function App() { createProject: async () => { const created = await createProject({ name: data.title, - device_type: data.deviceType, - intended_use: data.description, + device_type: data.deviceType || undefined, + intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description, + product_domain: data.productDomain, + project_type_key: data.projectTypeKey, + team_mode: data.teamMode, + execution_mode: data.executionMode, + problem_statement: data.problemStatement, + target_users: data.targetUsers, + target_outcome: data.targetOutcome, + initial_workflow_key: + data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined, }); // create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create createProjectBootstrapIdRef.current = created.id; @@ -3596,14 +3595,17 @@ export default function App() { }); }); - // 3. 克隆全生命周期 7 份受控模板副本 - const { deliverables: clonedDocs, docASTMap: clonedAsts } = createProjectTemplateDeliverables({ - projectId: newProjId, - projectName: data.title, - deviceType: data.deviceType, - creatorId: currentUser.id, - creatorName: currentUser.name, - }); + // 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only + const clonedDocs = shouldCloneMedicalTemplates(data.productDomain) + ? createProjectTemplateDeliverables({ + projectId: newProjId, + projectName: data.title, + deviceType: data.deviceType, + creatorId: currentUser.id, + creatorName: currentUser.name, + }) + : { deliverables: [], docASTMap: {} }; + const clonedAsts = clonedDocs.docASTMap; setProjects((prev) => [newProject, ...prev]); setSessions((prev) => [...newSessionsToAppend, ...prev]); @@ -3621,14 +3623,16 @@ export default function App() { ).catch((err) => { console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err); }); - setDeliverables((prev) => [...prev, ...clonedDocs]); + setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]); setDocCache((prev) => ({ ...prev, ...clonedAsts })); if (creatorSessionId) { setActiveSessionId(creatorSessionId); } - setActiveDocId(clonedDocs[0].docId); - setActiveCategory('workflow'); + if (clonedDocs.deliverables[0]?.docId) { + setActiveDocId(clonedDocs.deliverables[0].docId); + setActiveCategory('workflow'); + } createProjectBootstrapIdRef.current = null; setCreateProjectModalOpen(false); setCreateProjectError(null); diff --git a/web/src/api/projectWorkflow.ts b/web/src/api/projectWorkflow.ts index b091d865..433ca7ae 100644 --- a/web/src/api/projectWorkflow.ts +++ b/web/src/api/projectWorkflow.ts @@ -22,6 +22,19 @@ export async function fetchProjects(): Promise { return Array.isArray(data) ? data : []; } +export async function fetchProjectTypesCatalog(): Promise< + Array<{ key: string; name: string; description: string; default_workflow_key: string }> +> { + const res = await fetch(`${API_BASE}/api/v1/project-types/catalog`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`获取项目类型目录失败: ${res.status}`); + } + const data = await res.json(); + return Array.isArray(data?.project_types) ? data.project_types : []; +} + export async function createProject(data: CreateProjectInput): Promise { const res = await fetch(`${API_BASE}/api/v1/projects`, { method: 'POST', diff --git a/web/src/components/project/CreateProjectModal.tsx b/web/src/components/project/CreateProjectModal.tsx index 927f6d91..955163f0 100644 --- a/web/src/components/project/CreateProjectModal.tsx +++ b/web/src/components/project/CreateProjectModal.tsx @@ -2,28 +2,34 @@ import React, { useEffect, useState } from 'react'; import { X, Sparkles, FolderPlus, Users, Check } from 'lucide-react'; import { UserProfile } from '../../types/docgov'; import { ProjectMemberRole } from '../../types/workbench'; -import { - createProjectSubmitButtonLabel, - shouldBlockCreateProjectClose, - type CreateProjectSubmitPhase, -} from '../../lib/createProjectSubmitGuard'; +import { createProjectSubmitButtonLabel, shouldBlockCreateProjectClose, type CreateProjectSubmitPhase } from '../../lib/createProjectSubmitGuard'; import { exclusiveMemberRoleChange } from '../../lib/projectDocumentDuty'; +import { fetchProjectTypesCatalog } from '../../api/projectWorkflow'; + +export type CreateProjectConfirmData = { + title: string; + deviceType: string; + description: string; + productDomain: 'medical_device' | 'non_medical_software'; + projectTypeKey?: string; + teamMode?: string; + executionMode?: string; + problemStatement?: string; + targetUsers?: string; + targetOutcome?: string; + initialMembers: Array<{ + userId: number; + userName: string; + department?: string; + position?: string; + role: ProjectMemberRole; + }>; +}; interface CreateProjectModalProps { isOpen: boolean; onClose: () => void; - onSubmit: (data: { - title: string; - deviceType: string; - description: string; - initialMembers: Array<{ - userId: number; - userName: string; - department?: string; - position?: string; - role: ProjectMemberRole; - }>; - }) => void | Promise; + onSubmit: (data: CreateProjectConfirmData) => void | Promise; submitError?: string | null; /** 立项/同步成员进行中:禁用确认、阻止关闭 */ submitting?: boolean; @@ -46,6 +52,18 @@ export const CreateProjectModal: React.FC = ({ }) => { const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); + const [productDomain, setProductDomain] = useState<'medical_device' | 'non_medical_software'>( + 'medical_device', + ); + const [projectTypeKey, setProjectTypeKey] = useState('greenfield'); + const [teamMode, setTeamMode] = useState('collaborative'); + const [executionMode, setExecutionMode] = useState('docs_only'); + const [problemStatement, setProblemStatement] = useState(''); + const [targetUsers, setTargetUsers] = useState(''); + const [targetOutcome, setTargetOutcome] = useState(''); + const [catalogTypes, setCatalogTypes] = useState< + Array<{ key: string; name: string; description: string }> + >([]); // 初始成员勾选列表(默认包含当前创建者) const [selectedMembers, setSelectedMembers] = useState< @@ -69,7 +87,18 @@ export const CreateProjectModal: React.FC = ({ setTitle(''); setDescription(''); setErrorMsg(''); + setProductDomain('medical_device'); + setProjectTypeKey('greenfield'); + setTeamMode('collaborative'); + setExecutionMode('docs_only'); + setProblemStatement(''); + setTargetUsers(''); + setTargetOutcome(''); + return; } + void fetchProjectTypesCatalog() + .then((items) => setCatalogTypes(items)) + .catch(() => setCatalogTypes([])); }, [isOpen]); if (!isOpen) return null; @@ -107,8 +136,14 @@ export const CreateProjectModal: React.FC = ({ setErrorMsg('请输入项目名称'); return; } + if (productDomain === 'non_medical_software') { + if (!problemStatement.trim() || !targetUsers.trim() || !targetOutcome.trim()) { + setErrorMsg('软件项目需填写问题、用户与目标结果'); + return; + } + } - const finalDeviceType = '通用医疗器械'; + const finalDeviceType = productDomain === 'non_medical_software' ? '' : '通用医疗器械'; const initialMembers: Array<{ userId: number; @@ -144,6 +179,13 @@ export const CreateProjectModal: React.FC = ({ title: cleanTitle, deviceType: finalDeviceType, description: description.trim(), + productDomain, + projectTypeKey: productDomain === 'non_medical_software' ? projectTypeKey : undefined, + teamMode: productDomain === 'non_medical_software' ? teamMode : undefined, + executionMode: productDomain === 'non_medical_software' ? executionMode : undefined, + problemStatement: productDomain === 'non_medical_software' ? problemStatement.trim() : undefined, + targetUsers: productDomain === 'non_medical_software' ? targetUsers.trim() : undefined, + targetOutcome: productDomain === 'non_medical_software' ? targetOutcome.trim() : undefined, initialMembers, }); }; @@ -205,10 +247,12 @@ export const CreateProjectModal: React.FC = ({

- 发起医疗器械新项目立项 + {productDomain === 'non_medical_software' ? '发起软件项目' : '发起医疗器械新项目立项'}

- 创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间 + {productDomain === 'non_medical_software' + ? '类型与工作流由服务端 catalog 驱动;不克隆医疗 7 份模板' + : '创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间'}

@@ -252,6 +296,88 @@ export const CreateProjectModal: React.FC = ({ )} +
+ + +
+ + {productDomain === 'non_medical_software' && teamMode === 'solo' ? ( +

+ 未独立评审:solo 使用 creator_acknowledgement,审计将持续标注。 +

+ ) : null} + + {productDomain === 'non_medical_software' ? ( +
+ + + + + + + setProblemStatement(e.target.value)} + style={{ width: '100%', marginTop: 8, height: 36 }} + /> + setTargetUsers(e.target.value)} + style={{ width: '100%', marginTop: 8, height: 36 }} + /> + setTargetOutcome(e.target.value)} + style={{ width: '100%', marginTop: 8, height: 36 }} + /> +
+ ) : null} + {/* 1. 项目名称 */}