merge: feature/w5a-software-bootstrap-ui-pi

This commit is contained in:
weichen
2026-09-17 04:12:38 +00:00
20 changed files with 642 additions and 121 deletions
+3 -3
View File
@@ -5,7 +5,7 @@
## 焦点
1. **partial/deferred 全量收口**:W1–W4 **done**;续 W5a–W7
1. **partial/deferred 全量收口**:W1–W4 **done**;W5a **done**(矩阵自举行仍 partial);续 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 | done |
| 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 |
| - | idle | - | - | W6–W7 |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -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 = 受控执行器(独立安全评审)。
@@ -0,0 +1,21 @@
---
title: "W5a 软件自举 UI + docs_only PI"
status: done
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 纯软件自举行
+1 -1
View File
@@ -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` |
@@ -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 须独立安全评审后的受控执行器。
@@ -0,0 +1,3 @@
=== RUN TestPiSoftwareBootstrapAdvisor_LiveMiniMax
elapsed=14.928s narrative=# 自举配置 ... software_delivery ... 自动激活: false
--- PASS: TestPiSoftwareBootstrapAdvisor_LiveMiniMax (14.93s)
+92
View File
@@ -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
}
+95
View File
@@ -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)
}
}
+13
View File
@@ -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()
+15 -8
View File
@@ -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)
+35 -1
View File
@@ -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)
}
}
+20 -20
View File
@@ -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)
+54 -38
View File
@@ -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:
+15 -2
View File
@@ -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")
}
}
+30 -26
View File
@@ -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);
+13
View File
@@ -22,6 +22,19 @@ export async function fetchProjects(): Promise<Project[]> {
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<Project> {
const res = await fetch(`${API_BASE}/api/v1/projects`, {
method: 'POST',
+146 -20
View File
@@ -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<void>;
onSubmit: (data: CreateProjectConfirmData) => void | Promise<void>;
submitError?: string | null;
/** 立项/同步成员进行中:禁用确认、阻止关闭 */
submitting?: boolean;
@@ -46,6 +52,18 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
}) => {
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<CreateProjectModalProps> = ({
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<CreateProjectModalProps> = ({
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<CreateProjectModalProps> = ({
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<CreateProjectModalProps> = ({
</div>
<div>
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: 'var(--text-strong, #0f172a)' }}>
发起医疗器械新项目立项
{productDomain === 'non_medical_software' ? '发起软件项目' : '发起医疗器械新项目立项'}
</h2>
<p style={{ margin: '2px 0 0', fontSize: '12px', color: 'var(--text-muted, #64748b)' }}>
创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间
{productDomain === 'non_medical_software'
? '类型与工作流由服务端 catalog 驱动;不克隆医疗 7 份模板'
: '创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间'}
</p>
</div>
</div>
@@ -252,6 +296,88 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
</div>
)}
<div style={{ marginBottom: '16px', display: 'flex', gap: 8 }}>
<button
type="button"
disabled={submitting}
onClick={() => setProductDomain('medical_device')}
data-testid="create-domain-medical"
>
医疗器械
</button>
<button
type="button"
disabled={submitting}
onClick={() => setProductDomain('non_medical_software')}
data-testid="create-domain-software"
>
纯软件
</button>
</div>
{productDomain === 'non_medical_software' && teamMode === 'solo' ? (
<p data-testid="solo-unreviewed-banner" style={{ color: '#b45309', marginBottom: 12 }}>
未独立评审:solo 使用 creator_acknowledgement,审计将持续标注。
</p>
) : null}
{productDomain === 'non_medical_software' ? (
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: 6 }}>项目类型(服务端 catalog)</label>
<select
data-testid="software-project-type"
value={projectTypeKey}
disabled={submitting}
onChange={(e) => setProjectTypeKey(e.target.value)}
style={{ width: '100%', height: 40 }}
>
{(catalogTypes.length
? catalogTypes
: [{ key: 'greenfield', name: '绿地立项', description: '' }]
).map((it) => (
<option key={it.key} value={it.key}>
{it.name}
</option>
))}
</select>
<label style={{ display: 'block', fontWeight: 600, margin: '10px 0 6px' }}>执行模式</label>
<select
value={executionMode}
disabled={submitting}
onChange={(e) => setExecutionMode(e.target.value)}
>
<option value="docs_only">docs_only</option>
<option value="read_only_repo">read_only_repo</option>
</select>
<label style={{ display: 'block', fontWeight: 600, margin: '10px 0 6px' }}>团队模式</label>
<select value={teamMode} disabled={submitting} onChange={(e) => setTeamMode(e.target.value)}>
<option value="collaborative">collaborative</option>
<option value="solo">solo</option>
</select>
<input
placeholder="问题陈述"
value={problemStatement}
disabled={submitting}
onChange={(e) => setProblemStatement(e.target.value)}
style={{ width: '100%', marginTop: 8, height: 36 }}
/>
<input
placeholder="目标用户"
value={targetUsers}
disabled={submitting}
onChange={(e) => setTargetUsers(e.target.value)}
style={{ width: '100%', marginTop: 8, height: 36 }}
/>
<input
placeholder="目标结果"
value={targetOutcome}
disabled={submitting}
onChange={(e) => setTargetOutcome(e.target.value)}
style={{ width: '100%', marginTop: 8, height: 36 }}
/>
</div>
) : null}
{/* 1. 项目名称 */}
<div style={{ marginBottom: '18px' }}>
<label
@@ -6,6 +6,7 @@ import {
endCreateProjectSubmit,
resolveCreateProjectSubmitPhase,
shouldBlockCreateProjectClose,
shouldCloneMedicalTemplates,
} from './createProjectSubmitGuard';
test('beginCreateProjectSubmit rejects concurrent second entry until end', async () => {
@@ -48,3 +49,9 @@ test('createProjectSubmitButtonLabel and close block while submitting', () => {
assert.equal(shouldBlockCreateProjectClose(true), true);
assert.equal(shouldBlockCreateProjectClose(false), false);
});
test('shouldCloneMedicalTemplates skips the 7-pack for software domain', () => {
assert.equal(shouldCloneMedicalTemplates('non_medical_software'), false);
assert.equal(shouldCloneMedicalTemplates('medical_device'), true);
assert.equal(shouldCloneMedicalTemplates(undefined), true);
});
+5
View File
@@ -37,3 +37,8 @@ export function createProjectSubmitButtonLabel(
export function shouldBlockCreateProjectClose(submitting: boolean): boolean {
return submitting;
}
/** Software projects must not clone the medical 7-template pack. */
export function shouldCloneMedicalTemplates(productDomain: string | undefined): boolean {
return productDomain !== 'non_medical_software';
}
+9
View File
@@ -63,6 +63,15 @@ export interface CreateProjectInput {
device_type?: string;
intended_use?: string;
target_market?: string;
product_domain?: string;
project_type_key?: string;
compliance_profile_key?: string;
governance_profile?: string;
team_mode?: string;
execution_mode?: string;
problem_statement?: string;
target_users?: string;
target_outcome?: string;
/** @deprecated Accepted by API for catalog checks only; create dialog must not send this. */
initial_workflow_key?: string;
}