merge: feature/stage-backtrack-impl

This commit is contained in:
weichen
2026-09-20 15:35:32 +00:00
57 changed files with 3189 additions and 83 deletions
+3 -2
View File
@@ -5,7 +5,7 @@
## 焦点
1. **2026-09-20 阶段回溯与影响闭环设计 done(仅文档)**:五表追踪索引上增加 L1 修订 / L2 再打开、待复核软标记和对话提案桥。不改业务代码,不改 `production_ready`。见 [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md) · [short-term](./short-term/2026-09-20-stage-backtrack-design.md)。实施切片保持 `planned`。
1. **阶段回溯与影响闭环实施中**:T1–T3 与 B1–B3 代码在 `feature/stage-backtrack-impl`。见各 short-term。迁移文件 `00059` 已入库,运行环境仍是 schema 58,缺表时追踪账本留在进程内存。不改 `production_ready`,不签 D7 对话 ADR。
2. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。
## 并行认领
@@ -13,6 +13,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done |
| Cursor-Auto | 2026-09-20-impact-loop-t1 至 T3/B1–B3 | feature/stage-backtrack-impl | - | trace-index | in_progress |
| Cursor | 2026-09-20-rdev-seed-hotfix | fix/workflow-seed-bootstrap-2026-09-20 | - | workflow-def | done |
| Cursor-Auto | 2026-09-20-module-decoupling-spec-audit | docs/module-decoupling-spec-2026-09-20 | - | module-decoupling-spec | done |
| Cursor-Auto | 2026-09-20-p0-closeout | docs/p0-closeout-2026-09-20 | - | docs | done |
@@ -167,7 +168,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | 2026-09-20 | 阶段回溯设计文档冒烟 `go vet ./...` 通过;docs-only,未 rebuild。此前 rdev 种子已在 test `rc-v1.0.0-beta.7` | - |
| Cursor-Auto | merging | 2026-09-20 | feature/stage-backtrack-impl 合入 dev;go vet 已过,随后短测 | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -1,10 +1,10 @@
---
title: "B3 对话修订提案桥"
status: planned
status: done
created: 2026-09-20
updated: 2026-09-20
owner: 待认领
branch: feature/chat-revise-bridge
owner: Cursor-Auto
branch: feature/stage-backtrack-impl
worktree: "-"
mutex: intent
---
@@ -36,9 +36,9 @@ mutex: intent
## 验收标准
- [ ] 只读分析零业务写。
- [ ] 修订提案停在编制人待决,文档状态不变。
- [ ] 撤权后旧卡片不能继续确认。
- [x] 只读分析零业务写。
- [x] 修订提案停在编制人待决,文档状态不变。
- [x] 确认时重新检查项目访问权。撤权后旧确认不会写入提案。单测 `TestRevisePropose_RevokedAccessDoesNotWrite`。
## 相关链接
@@ -1,10 +1,10 @@
---
title: "T1 影响报告触发补齐"
status: planned
status: done
created: 2026-09-20
updated: 2026-09-20
owner: 待认领
branch: feature/impact-loop-t1
owner: Cursor-Auto
branch: feature/stage-backtrack-impl
worktree: "-"
mutex: docgov
---
@@ -29,16 +29,16 @@ mutex: docgov
## 步骤
1. [ ] 从最新 `dev` 开 `feature/impact-loop-t1`,登记 mutex `docgov`。
2. [ ] 列出当前不出报告的提交路径并补事件。
3. [ ] 报告状态区分 `index_pending` / `partial` / `failed` / 无已知依赖。
4. [ ] 单测覆盖人改、导入、恢复;合 dev 前按仓库门禁验证。
1. [x] 从最新 `dev` 开实现分支 `feature/stage-backtrack-impl`(切片共用该分支,避免六个 mutex 同时 in_progress)。
2. [x] 人工修订正式提交写出 change report。
3. [x] 报告状态区分 `index_pending` / `partial` / `failed` / 无已知依赖。
4. [x] 单测:`TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex`、`TestHumanRevision_IndexedNoKnown`。
## 验收标准
- [ ] 三条提交来源都能留下 report,且拒绝后的旧报告不能再出建议。
- [ ] 索引未完成时界面与 API 不返回「无影响」。
- [ ] 不新增第二套影响表。
- [x] 人工修订、文档命令和自由文档正式提交都走 `CommitHumanRevision`,成功时带 authoritative report。拒绝后的建议门禁沿用既有 `AllowsSuggestedActions`(`report_disposition_test`)。
- [x] 索引未完成时报告摘要是「分析尚未完成,不能判断为无影响」,单测 `TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex`。
- [x] 不新增第二套影响表。覆盖状态写在既有 change report 的 `deterministic_facts.coverage_status`。
## 相关链接
@@ -47,4 +47,4 @@ mutex: docgov
## 证据 / 备注
依赖设计文档,不依赖 T2。`docgov` mutex 认领前须确认没有其他 `in_progress`。
`go test ./internal/docrevision/ -run TestHumanRevision` 通过。导入与聊天侧正式提交若进入 `docflow` 命令或 `freedoc` 的 `CommitHumanRevision`,会得到同一份报告。
@@ -1,10 +1,10 @@
---
title: "T2 项目追踪索引"
status: planned
status: done
created: 2026-09-20
updated: 2026-09-20
owner: 待认领
branch: feature/project-trace-index-t2
owner: Cursor-Auto
branch: feature/stage-backtrack-impl
worktree: "-"
mutex: trace-index
---
@@ -31,17 +31,17 @@ mutex: trace-index
## 步骤
1. [ ] 开 `feature/project-trace-index-t2`,mutex `trace-index`。
2. [ ] 写迁移与只追加的修订模型。
3. [ ] 投影显式键,记录索引水位。
4. [ ] 项目页只读列表;跨项目 doc 不可读。
1. [x] 实现落在 `feature/stage-backtrack-impl`。
2. [x] 迁移 `00059` 与只追加修订。
3. [x] 显式键投影与索引水位。
4. [x] 项目页只读列表;不可见文档只计数量。
## 验收标准
- [ ] 同一业务键跨文档不复制多套孤立编号。
- [ ] 重复事件不产生第二份相同修订。
- [ ] 不可见文档不泄漏标题;管理员不自动获得确认权。
- [ ] 下行迁移不得删掉已有决议账本。
- [x] 同一业务键在同一项目内 upsert,不复制多套身份。跨项目键互不影响。单测 `TestIndex_SameSnapshotDoesNotDuplicateRevision`。
- [x] 同一 snapshot、同一 hash、同一抽取器版本不写第二份修订。
- [x] `RedactItems` 不返回不可见文档陈述。裁决不因管理员身份放行,仍走 `CanDecide`。
- [x] `00059` Down 不含 DROP/DELETE/TRUNCATE。单测 `TestMigrationDownKeepsDecisionLedger`。
## 相关链接
@@ -1,10 +1,10 @@
---
title: "B2 待复核软标记"
status: planned
status: done
created: 2026-09-20
updated: 2026-09-20
owner: 待认领
branch: feature/recheck-soft-hold
owner: Cursor-Auto
branch: feature/stage-backtrack-impl
worktree: "-"
mutex: workflow
---
@@ -38,9 +38,9 @@ mutex: workflow
## 验收标准
- [ ] 未知悉时推进被拒绝;知悉后推进成功且标记仍为打开。
- [ ] 不存在「新版本批准后标记自己消失」的路径。
- [ ] `main` 或 `software_delivery` 上能走完:产生标记 → 知悉推进 → 人手关闭。
- [x] 未知悉时 `AdvanceStage` 返回 `ErrRecheckAckRequired`;知悉后推进成功,标记仍由人工关闭。单测 `TestAdvance_RecheckAckDoesNotClearByItself`。
- [x] 清除只接受 `no_change` 或 `revision_started`。批准或新 snapshot 不会调用清除。
- [x] 推进条在 `requires_recheck_ack` 时展示不可折叠的知悉勾选;项目页可对标记点「无需变更」。
## 相关链接
@@ -1,10 +1,10 @@
---
title: "B1 阶段再打开"
status: planned
status: done
created: 2026-09-20
updated: 2026-09-20
owner: 待认领
branch: feature/stage-reopen-l2
owner: Cursor-Auto
branch: feature/stage-backtrack-impl
worktree: "-"
mutex: workflow
---
@@ -40,10 +40,10 @@ mutex: workflow
## 验收标准
- [ ] 非创建者不能批准;无关成员不能申请。
- [ ] 未勾选的已批准文档在再打开后仍锁定。
- [ ] 下游 `active` 阶段不会被改成 `pending`。
- [ ] 再打开阶段第二次推进成功后回到 `completed`。
- [x] 非创建者批准接口返回 403。无关成员由 `MayRequestReopen` 拒绝,单测 `TestMayRequestReopen_DutyHolderOnly`。
- [x] 再打开只把勾选任务打回 `in_progress`,文档动作 `reopen_for_revision` 只作用于勾选且已批准的文档。
- [x] 下游 `active` 阶段不会被改成 `pending`。单测 `TestReopen_KeepsLaterStageActive`。
- [x] 再打开阶段第二次推进回到 `completed`,且不会另开一个 pending 阶段。
## 相关链接
@@ -1,10 +1,10 @@
---
title: "T3 多跳影响与候选边治理"
status: planned
status: done
created: 2026-09-20
updated: 2026-09-20
owner: 待认领
branch: feature/trace-multihop-t3
owner: Cursor-Auto
branch: feature/stage-backtrack-impl
worktree: "-"
mutex: trace-index
---
@@ -38,9 +38,9 @@ mutex: trace-index
## 验收标准
- [ ] 超过深度的边不会被说成已确认影响。
- [ ] 非下游编制人且非创建者不能裁决。
- [ ] 候选积压时报告仍是 `possible_impact`,不是「无影响」。
- [x] 反向遍历深度 3,更深的边不会进入已确认影响。单测 `TestWalkReverse_DepthAndCycle`。
- [x] 非下游编制人且非创建者不能裁决。`CanDecide` 不识别管理员。
- [x] 候选边不参与遍历;未决时覆盖状态可以是 `possible_impact`,分析在索引未完成时不会说无影响。
## 相关链接
+1 -1
View File
@@ -62,7 +62,7 @@
| 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);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告,见 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md)。项目追踪索引见下一行 | `docgov` |
| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | planned | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md) | 设计已写,代码未开始。切片 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md) [T2](../ops/short-term/2026-09-20-project-trace-index-t2.md) [B1](../ops/short-term/2026-09-20-stage-reopen-l2.md) [B2](../ops/short-term/2026-09-20-recheck-soft-hold.md) [B3](../ops/short-term/2026-09-20-chat-revise-bridge.md) [T3](../ops/short-term/2026-09-20-trace-multihop-t3.md) 均为 planned。未复核不拦截推进。追踪留存类别 `record`,不签 D7 对话 ADR | `trace-impact-design` |
| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | in_progress | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md) | 实现在 `feature/stage-backtrack-impl`。迁移 `00059` 未应用到运行中的环境(仍为 schema 58)前,进程在缺表时使用内存账本。未复核不拦截推进。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` |
| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` |
| 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` |
+19
View File
@@ -88,6 +88,7 @@ imports:
- projectworkflow
- storage
- taskrun
- traceimpact
- workflowdef
- workspace
- sysadmin
@@ -200,6 +201,7 @@ imports:
- stream/transport
- swboot
- taskrun
- traceimpact
- upload
- workflowdef
- workspace
@@ -251,10 +253,27 @@ imports:
- app/uow
sysadmin:
- config
traceimpact: []
table_owners:
document_records:
- docgov
project_trace_items:
- traceimpact
project_trace_item_revisions:
- traceimpact
project_trace_edges:
- traceimpact
project_trace_edge_decisions:
- traceimpact
project_trace_index_state:
- traceimpact
stage_reopen_requests:
- traceimpact
stage_recheck_marks:
- traceimpact
trace_revision_proposals:
- traceimpact
chat_messages:
- ctxmgr
chat_turns:
@@ -0,0 +1,108 @@
-- +goose Up
-- 项目追踪与阶段回溯账本。留存类别为 record:随项目保留。
-- 条目修订与边决议只追加。源文档依法清除时把条目标成 tombstone,不级联删边和决议。
CREATE TABLE IF NOT EXISTS project_trace_items (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
business_key TEXT NOT NULL,
kind TEXT NOT NULL,
lifecycle_state TEXT NOT NULL DEFAULT 'active',
review_state TEXT NOT NULL DEFAULT 'confirmed',
tombstone BOOLEAN NOT NULL DEFAULT false,
statement TEXT NOT NULL DEFAULT '',
source_doc_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, kind, business_key)
);
CREATE TABLE IF NOT EXISTS project_trace_item_revisions (
id BIGSERIAL PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES project_trace_items (id),
revision_no INT NOT NULL,
source_doc_id TEXT NOT NULL DEFAULT '',
source_snapshot_id BIGINT NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL DEFAULT '',
statement TEXT NOT NULL DEFAULT '',
extractor_version TEXT NOT NULL DEFAULT '',
tombstone BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (item_id, revision_no),
UNIQUE (item_id, source_snapshot_id, content_hash, extractor_version)
);
CREATE TABLE IF NOT EXISTS project_trace_edges (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
from_item_id BIGINT NOT NULL,
to_item_id BIGINT NOT NULL,
relation_type TEXT NOT NULL,
provenance TEXT NOT NULL,
review_state TEXT NOT NULL,
evidence_doc_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, from_item_id, to_item_id, relation_type, provenance)
);
CREATE TABLE IF NOT EXISTS project_trace_edge_decisions (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
edge_id BIGINT NOT NULL,
decision TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
decided_by BIGINT NOT NULL,
decided_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS project_trace_index_state (
project_id BIGINT NOT NULL,
doc_id TEXT NOT NULL,
source_snapshot_id BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL,
extractor_version TEXT NOT NULL,
item_count INT NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (project_id, doc_id, extractor_version)
);
CREATE TABLE IF NOT EXISTS stage_reopen_requests (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
instance_id BIGINT NOT NULL,
stage_key TEXT NOT NULL,
doc_task_keys TEXT[] NOT NULL,
reason TEXT NOT NULL,
requested_by BIGINT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS stage_recheck_marks (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
stage_key TEXT NOT NULL,
source_doc_id TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
acked_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS stage_recheck_marks_one_open
ON stage_recheck_marks (project_id, stage_key, source_doc_id)
WHERE status = 'open';
CREATE TABLE IF NOT EXISTS trace_revision_proposals (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_author',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
-- 这些表是合规业务记录,不是可丢弃缓存。
-- Down 不删除 project_trace_edge_decisions,也不删除条目、修订、边、索引水位、再打开申请和待复核标记。
SELECT 1;
+25
View File
@@ -36,6 +36,7 @@ import (
"chat-agent-system/internal/storage"
"chat-agent-system/internal/sysadmin"
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/traceimpact"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workspace"
@@ -71,6 +72,7 @@ type Runtime struct {
Pool *pgxpool.Pool
Gov *docgov.GovernanceEngine
Rev *docrevision.Service
Trace *traceimpact.Service
Ctx *ctxmgr.ContextManager
CAS *storage.CAS
FreeDoc *freedoc.Service
@@ -191,6 +193,28 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
if instSvc != nil && instSvc.Available() {
coordEngine.SetInstanceService(instSvc)
}
traceSvc := traceimpact.Open(pool)
traceSvc.SetStageReopener(traceStage{inst: instSvc})
traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov})
if instSvc != nil {
instSvc.SetRecheckGate(traceRecheck{svc: traceSvc})
}
coordEngine.SetImpactActions(traceChat{svc: traceSvc})
if pool != nil {
access := authz.NewAuthorizer(pool)
coordEngine.SetProjectAccess(func(ctx context.Context, projectID, userID int64) error {
return access.RequireProjectAccess(ctx, projectID, userID)
})
}
if parts.Rev != nil {
parts.Rev.CoverageResolver = func(ctx context.Context, docID string) string {
projectID := projectIDFromControlledDoc(docID)
if projectID == 0 {
return traceimpact.ImpactIndexPending
}
return traceSvc.CoverageForDoc(ctx, projectID, docID)
}
}
rt := &Runtime{
Cfg: cfg,
@@ -212,6 +236,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
Pool: pool,
Gov: parts.Gov,
Rev: parts.Rev,
Trace: traceSvc,
Ctx: parts.Ctx,
CAS: parts.CAS,
FreeDoc: parts.FreeDoc,
+98
View File
@@ -0,0 +1,98 @@
package app
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/traceimpact"
)
type traceRecheck struct{ svc *traceimpact.Service }
func (t traceRecheck) StageNeedsAck(ctx context.Context, projectID int64, stageKey string) (bool, error) {
if t.svc == nil {
return false, nil
}
return t.svc.NeedsRecheckAck(ctx, projectID, stageKey)
}
func (t traceRecheck) Acknowledge(ctx context.Context, projectID int64, stageKey string, userID int64) error {
if t.svc == nil {
return nil
}
return t.svc.AcknowledgeRecheck(ctx, projectID, stageKey, userID)
}
type traceStage struct{ inst *projectinstance.Service }
func (t traceStage) ReopenCompletedStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error {
if t.inst == nil {
return fmt.Errorf("trace: instance service unavailable")
}
return t.inst.ReopenStage(ctx, instanceID, stageKey, docTaskKeys, operatorID)
}
type traceUnlock struct{ gov *docgov.GovernanceEngine }
func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, docTaskKeys []string, actorID int64, reason string) error {
if t.gov == nil {
return nil
}
for _, key := range docTaskKeys {
docID := fmt.Sprintf("doc_proj%d_%s", projectID, key)
_, err := t.gov.ReopenForRevision(ctx, docID, actorID, "creator", reason)
if err == nil || errors.Is(err, docgov.ErrDocumentNotFound) {
continue
}
if errors.Is(err, docgov.ErrInvalidStateTransition) {
continue
}
return err
}
return nil
}
type traceChat struct{ svc *traceimpact.Service }
func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, _ int64, text string) (string, error) {
if t.svc == nil {
return "", fmt.Errorf("trace: unavailable")
}
rep, err := t.svc.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "chat", Markdown: text, Visible: true}, nil)
if err != nil {
return "", err
}
return rep.Summary, nil
}
func projectIDFromControlledDoc(docID string) int64 {
rest := strings.TrimPrefix(docID, "doc_proj")
if rest == docID {
return 0
}
i := strings.Index(rest, "_")
if i <= 0 {
return 0
}
n, err := strconv.ParseInt(rest[:i], 10, 64)
if err != nil {
return 0
}
return n
}
func (t traceChat) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) {
if t.svc == nil {
return "", fmt.Errorf("trace: unavailable")
}
id, err := t.svc.ProposeRevision(ctx, projectID, userID, text, confirmed)
if errors.Is(err, traceimpact.ErrNeedsConfirm) {
return "", coordinator.ErrImpactNeedsConfirm
}
return id, err
}
+15
View File
@@ -80,6 +80,8 @@ type CoordinatorEngine struct {
dataDir string
ctxMgr *ctxmgr.ContextManager
kbClient *kb.Client
impact ImpactActions
projectAccess ProjectAccess
}
// StageTaskRunner is the application owner for ExecuteTask / ExecuteStage.
@@ -115,6 +117,13 @@ func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) {
}
}
// SetImpactActions wires read-only impact analysis and confirmed revision proposals.
func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) {
if e != nil {
e.impact = a
}
}
func NewCoordinatorEngine(runner StageTaskRunner) *CoordinatorEngine {
return &CoordinatorEngine{
stageTasks: runner,
@@ -308,6 +317,12 @@ func (e *CoordinatorEngine) ProcessIntent(
case intent.IntentRunRestart:
coordRes, coordErr = e.handleRunRestart(ctx, top, userCtx)
case intent.IntentImpactAnalyze:
coordRes, coordErr = e.handleImpactAnalyze(ctx, top, userCtx, recRes.RawInput)
case intent.IntentRevisePropose:
coordRes, coordErr = e.handleRevisePropose(ctx, top, userCtx, recRes.RawInput)
default:
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
coordRes = &CoordinationResult{
+83
View File
@@ -0,0 +1,83 @@
package coordinator
import (
"context"
"errors"
"strings"
"chat-agent-system/internal/intent"
)
// ErrImpactNeedsConfirm means a revision proposal was requested but not confirmed.
var ErrImpactNeedsConfirm = errors.New("revision proposal needs confirmation")
// ImpactActions is the chat bridge into the trace index.
// Analyze is read-only. Propose must not write controlled content.
type ImpactActions interface {
AnalyzeImpact(ctx context.Context, projectID, userID int64, text string) (string, error)
ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error)
}
// ProjectAccess reports whether the user may still act on the project.
// A revoked member must fail this check even if an older card is confirmed.
type ProjectAccess func(ctx context.Context, projectID, userID int64) error
func (e *CoordinatorEngine) SetProjectAccess(fn ProjectAccess) {
if e != nil {
e.projectAccess = fn
}
}
func (e *CoordinatorEngine) handleImpactAnalyze(ctx context.Context, top *intent.CandidateIntent, userCtx *UserProjectContext, raw string) (*CoordinationResult, error) {
if e == nil || e.impact == nil || top == nil || userCtx == nil {
return &CoordinationResult{
Status: StatusAccepted, Intent: intent.IntentImpactAnalyze,
NaturalReply: "影响分析还没有接到这个环境。", UpdatedContext: userCtx,
}, nil
}
summary, err := e.impact.AnalyzeImpact(ctx, parseDecimalID(userCtx.Project.ID), parseDecimalID(userCtx.User.ID), raw)
if err != nil {
return nil, err
}
return &CoordinationResult{
Status: StatusAccepted, Intent: top.Intent,
NaturalReply: summary, UpdatedContext: userCtx,
}, nil
}
func (e *CoordinatorEngine) handleRevisePropose(ctx context.Context, top *intent.CandidateIntent, userCtx *UserProjectContext, raw string) (*CoordinationResult, error) {
if e == nil || e.impact == nil || top == nil || userCtx == nil {
return &CoordinationResult{
Status: StatusAccepted, Intent: intent.IntentRevisePropose,
NaturalReply: "修订提案还没有接到这个环境。", UpdatedContext: userCtx,
}, nil
}
confirmed := strings.Contains(raw, "确认")
projectID := parseDecimalID(userCtx.Project.ID)
userID := parseDecimalID(userCtx.User.ID)
if confirmed && e.projectAccess != nil {
if err := e.projectAccess(ctx, projectID, userID); err != nil {
return &CoordinationResult{
Status: StatusRejected, Intent: top.Intent,
NaturalReply: "你已不能访问该项目,旧的确认不能再生成修订提案。",
UpdatedContext: userCtx,
}, err
}
}
id, err := e.impact.ProposeRevision(ctx, projectID, userID, raw, confirmed)
if errors.Is(err, ErrImpactNeedsConfirm) {
return &CoordinationResult{
Status: StatusAccepted, Intent: top.Intent,
NaturalReply: "这会生成受控文档的修订提案,不会直接改正文。请回复「确认修订提案」后再执行。",
UpdatedContext: userCtx,
}, nil
}
if err != nil {
return nil, err
}
return &CoordinationResult{
Status: StatusAccepted, Intent: top.Intent,
NaturalReply: "已生成修订提案 " + id + ",等编制人决定后才会进入正文。",
UpdatedContext: userCtx,
}, nil
}
@@ -0,0 +1,37 @@
package coordinator
import (
"context"
"errors"
"testing"
"chat-agent-system/internal/intent"
)
type fakeImpact struct {
writes int
}
func (f *fakeImpact) AnalyzeImpact(context.Context, int64, int64, string) (string, error) {
return "只读", nil
}
func (f *fakeImpact) ProposeRevision(context.Context, int64, int64, string, bool) (string, error) {
f.writes++
return "proposal-1-by-4", nil
}
func TestRevisePropose_RevokedAccessDoesNotWrite(t *testing.T) {
impact := &fakeImpact{}
engine := &CoordinatorEngine{impact: impact}
engine.SetProjectAccess(func(context.Context, int64, int64) error {
return errors.New("revoked")
})
res, err := engine.handleRevisePropose(t.Context(), &intent.CandidateIntent{Intent: intent.IntentRevisePropose}, &UserProjectContext{
User: UserInfo{ID: "4"},
Project: ProjectInfo{ID: "9"},
}, "确认修订提案")
if err == nil || res == nil || res.Status != StatusRejected || impact.writes != 0 {
t.Fatalf("res %+v err %v writes %d", res, err, impact.writes)
}
}
+23
View File
@@ -991,6 +991,29 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act
return doc, nil
}
// ReopenForRevision moves an approved document back to drafting without recording a rejection.
func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string, actorID int64, actorName, reason string) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
var err error
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
if err != nil || doc == nil {
return nil, ErrDocumentNotFound
}
}
audit, err := e.sm.TransitionReopenForRevision(doc, actorID, actorName, reason)
if err != nil {
return nil, err
}
e.memAudits[docID] = append(e.memAudits[docID], audit)
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err
}
return doc, nil
}
// GetDocument 获取文档实体 (优先查内存,未命中查 DB)
func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*DocumentRecord, error) {
e.mu.Lock()
+24
View File
@@ -0,0 +1,24 @@
package docgov
import (
"errors"
"testing"
)
func TestTransitionReopenForRevision_IsNotARejection(t *testing.T) {
sm := NewStateMachine()
doc := &DocumentRecord{DocID: "doc_reopen", CurrentState: StateApproved, IsLocked: true, Config: DefaultDocumentConfig()}
audit, err := sm.TransitionReopenForRevision(doc, 4, "creator", "新材料")
if err != nil {
t.Fatal(err)
}
if doc.CurrentState != StateDrafting || doc.IsLocked {
t.Fatalf("state %s locked %v", doc.CurrentState, doc.IsLocked)
}
if audit.Action != "reopen_for_revision" || audit.ActorRole != RoleSystem {
t.Fatalf("audit %+v", audit)
}
if _, err := sm.TransitionReopenForRevision(doc, 4, "creator", "again"); !errors.Is(err, ErrInvalidStateTransition) {
t.Fatalf("drafting reopen err %v", err)
}
}
+24
View File
@@ -283,3 +283,27 @@ func (sm *StateMachine) TransitionApproverReject(doc *DocumentRecord, actorID in
CreatedAt: now,
}, nil
}
// TransitionReopenForRevision unlocks an approved document for another draft.
// It is not a rejection: the previous approval stays in the audit as a reopen.
func (sm *StateMachine) TransitionReopenForRevision(doc *DocumentRecord, actorID int64, actorName, reason string) (*AuditLog, error) {
if doc == nil {
return nil, ErrDocumentNotFound
}
if doc.CurrentState != StateApproved {
return nil, fmt.Errorf("%w: 只有已批准文档可以再打开编制,当前为【%s】", ErrInvalidStateTransition, doc.CurrentState)
}
if strings.TrimSpace(reason) == "" {
return nil, fmt.Errorf("%w: 再打开必须填写原因", ErrInvalidStateTransition)
}
from := doc.CurrentState
doc.CurrentState = StateDrafting
doc.IsLocked = false
now := time.Now()
doc.UpdatedAt = now
return &AuditLog{
DocID: doc.DocID, Action: "reopen_for_revision", ActorID: actorID, ActorName: actorName,
ActorRole: RoleSystem, FromState: from, ToState: StateDrafting,
Details: map[string]any{"reason": reason, "timestamp": now}, CreatedAt: now,
}, nil
}
+50
View File
@@ -0,0 +1,50 @@
package docrevision
import "context"
// CoverageResolver reports whether the trace index can support a no-impact claim.
// nil or an empty result means the index has not run; that must not be shown as no impact.
func (s *Service) formalCommitReportLocked(ctx context.Context, docID string, fromID, toID int64, hunks []DiffHunk) *ChangeReport {
impacts := s.impactLocked(ctx, docID)
coverage := ImpactIndexPending
if s.CoverageResolver != nil {
if got := s.CoverageResolver(ctx, docID); got != "" {
coverage = got
}
}
rep := fallbackReport("", "", RuleRiskFloor(hunks, impacts), impacts, hunks, "formal commit")
rep.DocID = docID
rep.FromSnapshotID = fromID
rep.ToSnapshotID = toID
rep.AnalysisStatus = AnalysisFallback
rep.Authority = ReportAuthoritative
rep.Lifecycle = ReportCommitted
if rep.DeterministicFacts == nil {
rep.DeterministicFacts = map[string]any{}
}
rep.DeterministicFacts["coverage_status"] = coverage
rep.DeterministicFacts["suggested_actions"] = formalSuggestions(coverage, len(impacts) > 0)
switch coverage {
case ImpactNoKnown:
rep.Summary = append(rep.Summary, "索引已覆盖,未发现已知关联")
case ImpactConfirmed, ImpactPossible:
rep.Summary = append(rep.Summary, "这次修改可能影响其他文档,见 document_impacts")
default:
rep.Summary = append(rep.Summary, "分析尚未完成或失败,不能判断为无影响")
rep.OpenQuestions = append(rep.OpenQuestions, "不能判断为无影响")
}
stampReportDisposition(rep)
return rep
}
func formalSuggestions(coverage string, hasImpact bool) []string {
switch coverage {
case ImpactIndexPending, ImpactPartial, ImpactFailed:
return []string{"wait_index"}
}
out := []string{"no_change"}
if hasImpact || coverage == ImpactPossible || coverage == ImpactConfirmed {
out = append(out, "l1_revise", "l2_reopen")
}
return out
}
@@ -0,0 +1,45 @@
package docrevision
import (
"context"
"strings"
"testing"
)
func TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex(t *testing.T) {
svc := NewService(nil)
res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{
DocID: "doc-1", Markdown: "UR-001 需要记录", IdempotencyKey: "k1",
ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3,
})
if err != nil {
t.Fatal(err)
}
if res.Report == nil || res.Report.Authority != ReportAuthoritative {
t.Fatalf("report %#v", res.Report)
}
if res.Report.DeterministicFacts["coverage_status"] != ImpactIndexPending {
t.Fatalf("coverage %#v", res.Report.DeterministicFacts["coverage_status"])
}
for _, line := range res.Report.Summary {
if strings.Contains(line, "未发现已知关联") {
t.Fatal(line)
}
}
}
func TestHumanRevision_IndexedNoKnown(t *testing.T) {
svc := NewService(nil)
svc.CoverageResolver = func(ctx context.Context, docID string) string { return ImpactNoKnown }
res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{
DocID: "doc-2", Markdown: "没有条目", IdempotencyKey: "k2",
ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3,
})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(res.Report.Summary, " ")
if !strings.Contains(joined, "未发现已知关联") {
t.Fatalf("summary %s", joined)
}
}
+14 -1
View File
@@ -66,7 +66,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st
return s.mustStateLocked(docID), nil
}
func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool) error {
func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool, rep *ChangeReport) error {
if !s.usingPG() {
if root != nil && snap != nil {
key := ""
@@ -75,6 +75,10 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
}
s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key)
}
if rep != nil {
rep.ToSnapshotID = snap.ID
s.assignReportIDLocked(rep)
}
return nil
}
blob := s.blobs[snap.ContentSHA256]
@@ -127,6 +131,15 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
if err := s.insertContentProjectionTX(ctx, tx, root.DocID, snap.ID, root.HeadRevisionNo, key); err != nil {
return err
}
if rep != nil {
rep.ToSnapshotID = snap.ID
rid, err := s.insertReportTX(ctx, tx, rep)
if err != nil {
return err
}
s.adoptReportID(rep, rid)
s.reports[rep.ID] = rep
}
s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key)
return s.projectRootPointersTX(ctx, tx, root)
})
+1 -1
View File
@@ -174,7 +174,7 @@ func (s *Service) CommitMigrationRepair(ctx context.Context, req MigrationRepair
root.RowRevision++
eventNo := s.nextEventNoLocked(req.DocID)
ev := s.putEventLocked(req.DocID, ver.ID, eventNo, parentPtr, snap.ID, ChangeMigrationRepair, ActorSystem, nil, "migration_repair", actor, nil, "", "", idemp)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil {
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, nil); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
+10 -4
View File
@@ -41,6 +41,10 @@ type Service struct {
// ImpactResolver returns deterministic downstream impacts (lineage); optional.
ImpactResolver func(ctx context.Context, docID string) []DocImpact
// CoverageResolver classifies trace-index coverage for a formal commit.
// When unset, reports stay index_pending and must not claim no impact.
CoverageResolver func(ctx context.Context, docID string) string
// NarrativeFetcher optionally loads typed PI narrative JSON when
// PI_CHANGE_REPORT_NARRATIVE is on and ReportJSON is empty.
NarrativeFetcher func(ctx context.Context, spec NarrativeFetchSpec) (string, error)
@@ -249,12 +253,13 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
root.ContentSHA256 = sha
root.RowRevision++
ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true); err != nil {
rep := s.formalCommitReportLocked(ctx, req.DocID, 0, snap.ID, nil)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true, rep); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
st := s.mustStateLocked(req.DocID)
return &HumanRevisionResult{State: *st, Event: *ev}, nil
return &HumanRevisionResult{State: *st, Event: *ev, Report: rep}, nil
}
ver := s.versions[*root.LedgerVersionID]
@@ -300,7 +305,8 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
root.RowRevision++
eventNo := s.nextEventNoLocked(req.DocID)
ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil {
rep := s.formalCommitReportLocked(ctx, req.DocID, parentID, snap.ID, diff.Hunks)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, rep); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
@@ -309,7 +315,7 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
overlay := ComputeDiff(baseMD, canon, s.snapshots[ver.BaselineSnapshotID].BlockMap, snap.BlockMap)
overlay.FromSnapshotID = ver.BaselineSnapshotID
overlay.ToSnapshotID = snap.ID
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay, Report: rep}, nil
}
func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) {
+9
View File
@@ -56,6 +56,14 @@ const (
AnalysisUnassessed = "unassessed"
AnalysisCorrected = "corrected"
// Trace coverage carried on a formal commit report. These are not PI analysis statuses.
ImpactConfirmed = "confirmed_impact"
ImpactPossible = "possible_impact"
ImpactNoKnown = "no_known_dependencies"
ImpactIndexPending = "index_pending"
ImpactPartial = "partial"
ImpactFailed = "failed"
// Report authority separates a candidate estimate from the report written at formal commit.
ReportProposed = "proposed"
ReportAuthoritative = "authoritative"
@@ -318,6 +326,7 @@ type HumanRevisionResult struct {
Event ChangeEvent `json:"event"`
Overlay DiffResult `json:"manual_overlay"`
Conflict *ConflictInfo `json:"conflict,omitempty"`
Report *ChangeReport `json:"report,omitempty"`
}
type ConflictInfo struct {
+3 -2
View File
@@ -86,7 +86,8 @@ func isWriteIntent(t IntentType) bool {
IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide,
IntentConfirmAction, IntentExecuteStageAdvance,
IntentSwitchWorkflow, IntentTriggerWorkflow,
IntentRunResume, IntentRunRestart:
IntentRunResume, IntentRunRestart,
IntentRevisePropose:
return true
default:
return false
@@ -104,7 +105,7 @@ func isQueryIntent(t IntentType) bool {
case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases,
IntentQueryUserRights, IntentQueryRejectReason,
IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities,
IntentRunStatusQuery:
IntentRunStatusQuery, IntentImpactAnalyze:
return true
default:
return false
+21
View File
@@ -185,6 +185,10 @@ func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, u
cand.Branch = BranchNewIntentPreempt
candidates = append(candidates, *cand)
}
if cand := matchImpactIntent(trimmed); cand != nil {
cand.Branch = BranchNewIntentPreempt
candidates = append(candidates, *cand)
}
// 3.2 补充信息/参数事实 (SUPPLEMENT_INFO)
if cand := matchSupplementInfo(trimmed); cand != nil {
@@ -1234,6 +1238,23 @@ func isGreetingOrHelp(input string) bool {
return false
}
func matchImpactIntent(input string) *CandidateIntent {
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些")
if !propose && !analyze {
return nil
}
intent := IntentImpactAnalyze
if propose {
intent = IntentRevisePropose
}
return &CandidateIntent{
Intent: intent,
Confidence: 0.9,
FilledSlots: map[string]string{},
}
}
func sortCandidates(c []CandidateIntent) {
for i := 0; i < len(c)-1; i++ {
for j := i + 1; j < len(c); j++ {
+2
View File
@@ -178,6 +178,8 @@ func registerBuiltinActions(r *ActionRegistry) {
{Key: "run.status.query", Domain: "project", Object: "workflow_run", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentRunStatusQuery, DialogueActs: []DialogueAct{DialogueActAsk}},
{Key: "run.resume", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskMedium, LegacyIntent: IntentRunResume, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}},
{Key: "run.restart", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentRunRestart, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}},
{Key: "project.impact.analyze", Domain: "project", Object: "controlled_document", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentImpactAnalyze, CapabilityKey: "IMPACT_ANALYZE", DialogueActs: []DialogueAct{DialogueActAsk}},
{Key: "project.doc.revise_propose", Domain: "project", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentRevisePropose, CapabilityKey: "REVISE_PROPOSE", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}},
}
for _, s := range specs {
_ = r.Register(s)
+1
View File
@@ -15,6 +15,7 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) {
IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus,
IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction,
IntentRunStatusQuery, IntentRunResume, IntentRunRestart,
IntentImpactAnalyze, IntentRevisePropose,
}
for _, lt := range legacyIntents {
spec, ok := r.GetByLegacyIntent(lt)
+4
View File
@@ -54,6 +54,10 @@ const (
IntentRunStatusQuery IntentType = "RUN_STATUS_QUERY"
IntentRunResume IntentType = "RUN_RESUME"
IntentRunRestart IntentType = "RUN_RESTART"
// 影响分析与修订提案。分析只读;提案确认后才写。
IntentImpactAnalyze IntentType = "IMPACT_ANALYZE"
IntentRevisePropose IntentType = "REVISE_PROPOSE"
)
// ResolutionBranch 三分支意图消解类别
+2
View File
@@ -23,6 +23,8 @@ var (
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")
+13
View File
@@ -28,6 +28,19 @@ type Service struct {
authorOnlyPolicy AuthorOnlyPolicySource
projector DocumentDutyProjector
busyChecker DocTaskBusyChecker
recheck RecheckGate
}
// RecheckGate is the soft hold on a completed stage. Acknowledgement is not clearance.
type RecheckGate interface {
StageNeedsAck(ctx context.Context, projectID int64, stageKey string) (bool, error)
Acknowledge(ctx context.Context, projectID int64, stageKey string, userID int64) error
}
func (s *Service) SetRecheckGate(g RecheckGate) {
if s != nil {
s.recheck = g
}
}
func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service {
@@ -0,0 +1,139 @@
package projectinstance
import (
"context"
"errors"
"testing"
)
type ackGate struct {
need bool
acked int
}
func (g *ackGate) StageNeedsAck(context.Context, int64, string) (bool, error) {
return g.need, nil
}
func (g *ackGate) Acknowledge(context.Context, int64, string, int64) error {
g.acked++
return nil
}
func TestReopen_KeepsLaterStageActive(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolverMultiStage(), nil, &stubDocChecker{states: map[string]string{
"intake": "approved", "feasibility_reg": "approved",
}})
plan := validPlan()
plan.Stages = append(plan.Stages, StagePlan{
StageKey: "V0.5_feasibility", Enabled: true,
DocTasks: []DocTaskPlan{{
TaskKey: "feasibility_reg", Enabled: true,
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
}},
})
if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}); err != nil {
t.Fatalf("save: %v", err)
}
preview, err := svc.ActivationPreview(ctx, 42, 1, true)
if err != nil {
t.Fatalf("preview: %v", err)
}
activated, err := svc.Activate(ctx, 42, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if err != nil {
t.Fatalf("activate: %v", err)
}
advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, 1, true, AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("advance: %v", err)
}
if err := svc.ReopenStage(ctx, activated.Instance.ID, "V0.1_triage", []string{"intake"}, 1); err != nil {
t.Fatalf("reopen: %v", err)
}
reopened, err := svc.GetInstanceDetail(ctx, activated.Instance.ID)
if err != nil {
t.Fatal(err)
}
var triage, feasibility string
for _, st := range reopened.Stages {
switch st.StageKey {
case "V0.1_triage":
triage = st.Status
case "V0.5_feasibility":
feasibility = st.Status
}
}
if triage != StageActive || feasibility != StageActive {
t.Fatalf("triage %s feasibility %s", triage, feasibility)
}
again, err := svc.AdvanceStage(ctx, activated.Instance.ID, 1, true, AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: reopened.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("second advance: %v", err)
}
if again.NextStageKey != "" && again.NextStageKey != "V0.5_feasibility" {
t.Fatalf("unexpected next %s", again.NextStageKey)
}
detail, err := svc.GetInstanceDetail(ctx, activated.Instance.ID)
if err != nil {
t.Fatal(err)
}
for _, st := range detail.Stages {
if st.StageKey == "V0.5_feasibility" && st.Status != StageActive {
t.Fatalf("downstream became %s", st.Status)
}
if st.StageKey == "V0.1_triage" && st.Status != StageCompleted {
t.Fatalf("reopened stage became %s", st.Status)
}
}
_ = advanced
}
func TestAdvance_RecheckAckDoesNotClearByItself(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 9, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
gate := &ackGate{need: true}
svc.SetRecheckGate(gate)
if _, err := svc.SaveWorkflowPlan(ctx, 9, 1, true, SavePlanInput{Plan: validPlan(), ExpectedPlanRevision: 0}); err != nil {
t.Fatal(err)
}
preview, err := svc.ActivationPreview(ctx, 9, 1, true)
if err != nil {
t.Fatal(err)
}
result, err := svc.Activate(ctx, 9, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if err != nil {
t.Fatal(err)
}
_, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
})
if !errors.Is(err, ErrRecheckAckRequired) {
t.Fatalf("expected ack required, got %v", err)
}
if gate.acked != 0 {
t.Fatal("ack recorded before confirm")
}
if _, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, AcknowledgeRecheck: true,
}); err != nil {
t.Fatal(err)
}
if gate.acked != 1 {
t.Fatalf("acked %d", gate.acked)
}
}
+43 -9
View File
@@ -50,16 +50,31 @@ func (s *Service) GetStageReadiness(ctx context.Context, instanceID int64, stage
if stageDef != nil && stageDef.Name != "" {
stageName = stageDef.Name
}
stageIsActive := false
for _, st := range detail.Stages {
if st.StageKey == stageKey && st.Status == StageActive {
stageIsActive = true
break
}
}
needsAck := false
if s.recheck != nil {
needsAck, err = s.recheck.StageNeedsAck(ctx, detail.Instance.ProjectID, stageKey)
if err != nil {
return nil, err
}
}
return &StageReadiness{
InstanceID: instanceID,
StageKey: stageKey,
StageName: stageName,
Ready: ready,
CanAdvance: isCreator && ready && detail.ActiveStageKey == stageKey,
BlockingIssues: issues,
DocTasks: tasks,
ConfigRevision: detail.Instance.ConfigRevision,
InstanceID: instanceID,
StageKey: stageKey,
StageName: stageName,
Ready: ready,
CanAdvance: isCreator && ready && stageIsActive,
RequiresRecheckAck: needsAck,
BlockingIssues: issues,
DocTasks: tasks,
ConfigRevision: detail.Instance.ConfigRevision,
}, nil
}
@@ -80,11 +95,18 @@ func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int
if in.ExpectedConfigRevision != readiness.ConfigRevision {
return nil, ErrInstanceRevisionConflict
}
before, err := s.store.GetInstanceByID(ctx, instanceID)
if err != nil {
return nil, err
}
if readiness.RequiresRecheckAck && !in.AcknowledgeRecheck {
return nil, ErrRecheckAckRequired
}
if readiness.RequiresRecheckAck && s.recheck != nil {
if err := s.recheck.Acknowledge(ctx, before.Instance.ProjectID, in.StageKey, userID); err != nil {
return nil, err
}
}
completedStage := before.ActiveStageKey
var completedStageInstanceID int64
for _, st := range before.Stages {
@@ -108,3 +130,15 @@ func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int
InstanceDone: after.Instance.Status == InstanceCompleted,
}, nil
}
// ReopenStage opens a completed stage again. Other stages stay as they are.
func (s *Service) ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error {
if !s.Available() {
return ErrNotAvailable
}
if stageKey == "" || len(docTaskKeys) == 0 {
return ErrInvalidStage
}
_, err := s.store.ReopenStage(ctx, instanceID, stageKey, docTaskKeys, operatorID)
return err
}
+98 -6
View File
@@ -118,17 +118,35 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s
if err != nil {
return nil, err
}
var otherActive int
if err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM project_stage_instances
WHERE workflow_instance_id = $1 AND enabled = true AND status = 'active'
`, instanceID).Scan(&otherActive); err != nil {
return nil, err
}
var nextStageID int64
var nextStageKey string
var projectID int64
_ = tx.QueryRow(ctx, `SELECT project_id FROM project_workflow_instances WHERE id = $1`, instanceID).Scan(&projectID)
err = tx.QueryRow(ctx, `
SELECT id, stage_key FROM project_stage_instances
WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending'
ORDER BY position ASC, id ASC LIMIT 1
`, instanceID).Scan(&nextStageID, &nextStageKey)
if errors.Is(err, pgx.ErrNoRows) {
err = pgx.ErrNoRows
if otherActive == 0 {
err = tx.QueryRow(ctx, `
SELECT id, stage_key FROM project_stage_instances
WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending'
ORDER BY position ASC, id ASC LIMIT 1
`, instanceID).Scan(&nextStageID, &nextStageKey)
}
if otherActive > 0 {
_, err = tx.Exec(ctx, `
UPDATE project_workflow_instances SET status = 'active', config_revision = config_revision + 1, updated_at = $2
WHERE id = $1
`, instanceID, now)
if err != nil {
return nil, err
}
} else if errors.Is(err, pgx.ErrNoRows) {
_, err = tx.Exec(ctx, `
UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1,
completed_at = $2, completed_by = $3, updated_at = $2
@@ -201,6 +219,80 @@ func (s *pgStore) MarkDocTaskApproved(ctx context.Context, projectID int64, task
return err
}
func (s *pgStore) ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) (*InstanceDetail, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var instStatus string
err = tx.QueryRow(ctx, `
SELECT status FROM project_workflow_instances WHERE id = $1 FOR UPDATE
`, instanceID).Scan(&instStatus)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInstanceNotFound
}
if err != nil {
return nil, err
}
if instStatus != InstanceActive && instStatus != InstanceCompleted {
return nil, fmt.Errorf("%w: instance status %s", ErrInvalidStage, instStatus)
}
var stageID int64
var stageStatus string
err = tx.QueryRow(ctx, `
SELECT id, status FROM project_stage_instances
WHERE workflow_instance_id = $1 AND stage_key = $2 AND enabled = true
`, instanceID, stageKey).Scan(&stageID, &stageStatus)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInvalidStage
}
if err != nil {
return nil, err
}
if stageStatus != StageCompleted {
return nil, ErrStageNotCompleted
}
now := time.Now()
if _, err = tx.Exec(ctx, `
UPDATE project_stage_instances SET status = 'active', updated_at = $2 WHERE id = $1
`, stageID, now); err != nil {
return nil, err
}
if len(docTaskKeys) > 0 {
if _, err = tx.Exec(ctx, `
UPDATE project_doc_task_instances
SET status = 'in_progress', updated_at = $3
WHERE stage_instance_id = $1 AND task_key = ANY($2) AND status = 'approved'
`, stageID, docTaskKeys, now); err != nil {
return nil, err
}
}
if _, err = tx.Exec(ctx, `
UPDATE project_workflow_instances
SET status = 'active', config_revision = config_revision + 1, updated_at = $2,
completed_at = NULL, completed_by = NULL
WHERE id = $1
`, instanceID, now); err != nil {
return nil, err
}
_ = operatorID
if err := tx.Commit(ctx); err != nil {
return nil, err
}
row := s.pool.QueryRow(ctx, `
SELECT 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
FROM project_workflow_instances WHERE id = $1
`, instanceID)
inst, err := scanInstance(row)
if err != nil {
return nil, err
}
return s.loadInstanceDetail(ctx, inst)
}
type dbExec interface {
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
}
+65 -4
View File
@@ -57,6 +57,8 @@ type Store interface {
GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error)
AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int, operatorID int64) (*InstanceDetail, error)
// ReopenStage sets a completed stage back to active without changing other stages.
ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) (*InstanceDetail, error)
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)
@@ -1148,16 +1150,27 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st
return nil, ErrInvalidStage
}
records[activeIdx].status = StageCompleted
nextIdx := -1
anotherActive := false
for i, rec := range records {
if rec.enabled && rec.status == StagePending {
nextIdx = i
if i != activeIdx && rec.enabled && rec.status == StageActive {
anotherActive = true
break
}
}
nextIdx := -1
if !anotherActive {
for i, rec := range records {
if rec.enabled && rec.status == StagePending {
nextIdx = i
break
}
}
}
inst.ConfigRevision++
inst.UpdatedAt = time.Now()
if nextIdx < 0 {
if anotherActive {
inst.Status = InstanceActive
} else if nextIdx < 0 {
inst.Status = InstanceCompleted
if pm := m.meta[inst.ProjectID]; pm.lifecycle != "" {
pm.lifecycle = LifecyclePlanning
@@ -1173,6 +1186,54 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st
return m.instanceDetailLocked(&copyInst), nil
}
func (m *memStore) ReopenStage(_ context.Context, instanceID int64, stageKey string, docTaskKeys []string, _ int64) (*InstanceDetail, error) {
m.mu.Lock()
defer m.mu.Unlock()
var inst *WorkflowInstance
for _, live := range m.live {
if live.ID == instanceID {
inst = live
break
}
}
if inst == nil {
return nil, ErrInstanceNotFound
}
records := m.stages[instanceID]
idx := -1
for i, rec := range records {
if rec.stageKey == stageKey {
idx = i
break
}
}
if idx < 0 {
return nil, ErrInvalidStage
}
if records[idx].status != StageCompleted {
return nil, ErrStageNotCompleted
}
records[idx].status = StageActive
wanted := map[string]bool{}
for _, key := range docTaskKeys {
wanted[key] = true
}
for j := range records[idx].tasks {
if wanted[records[idx].tasks[j].taskKey] {
records[idx].tasks[j].status = "in_progress"
}
}
if inst.Status == InstanceCompleted {
inst.Status = InstanceActive
}
inst.ConfigRevision++
inst.UpdatedAt = time.Now()
m.stages[instanceID] = records
copyInst := *inst
m.live[inst.ProjectID] = &copyInst
return m.instanceDetailLocked(&copyInst), nil
}
func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskKey string) error {
m.mu.Lock()
defer m.mu.Unlock()
+6 -2
View File
@@ -240,8 +240,9 @@ 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"`
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"`
@@ -251,6 +252,9 @@ type StageReadiness struct {
type AdvanceStageInput struct {
StageKey string `json:"stage_key"`
ExpectedConfigRevision int `json:"expected_config_revision"`
// AcknowledgeRecheck records that the creator saw open recheck marks.
// It does not clear those marks.
AcknowledgeRecheck bool `json:"acknowledge_recheck"`
}
// AdvanceStageResult 阶段推进结果。
@@ -194,6 +194,10 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_GATE_NOT_READY","message":"阶段门禁未满足,必需文档尚未全部批准"}`)
case errors.Is(err, projectinstance.ErrStageNotActive):
writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_NOT_ACTIVE","message":"目标阶段不是当前活动阶段"}`)
case errors.Is(err, projectinstance.ErrStageNotCompleted):
writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_NOT_COMPLETED","message":"只能再打开已完成的阶段"}`)
case errors.Is(err, projectinstance.ErrRecheckAckRequired):
writeSafeError(w, nil, http.StatusConflict, `{"error":"RECHECK_ACK_REQUIRED","message":"该阶段有待复核标记,推进前须确认已知悉"}`)
case errors.Is(err, projectinstance.ErrInvalidStage):
writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_STAGE","message":"无效的阶段"}`)
case errors.Is(err, projectinstance.ErrInstanceRevisionConflict):
+7
View File
@@ -211,6 +211,13 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow)
mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns)
mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage)
mux.HandleFunc("GET /api/v1/projects/{id}/trace", s.handleProjectTrace)
mux.HandleFunc("POST /api/v1/projects/{id}/trace/index", s.handleProjectTraceIndex)
mux.HandleFunc("POST /api/v1/projects/{id}/trace/analyze", s.handleProjectTraceAnalyze)
mux.HandleFunc("POST /api/v1/projects/{id}/trace/edges/{edgeID}/decision", s.handleProjectTraceDecision)
mux.HandleFunc("POST /api/v1/projects/{id}/trace/reopen", s.handleProjectTraceReopen)
mux.HandleFunc("POST /api/v1/projects/{id}/trace/reopen/{requestID}/approve", s.handleProjectTraceReopenApprove)
mux.HandleFunc("POST /api/v1/projects/{id}/trace/recheck/{markID}/clear", s.handleProjectTraceRecheckClear)
mux.HandleFunc("GET /api/v1/projects/{id}/documents", s.handleListProjectWorkflowDocuments)
mux.HandleFunc("GET /api/v1/projects/{id}/notifications", s.handleListProjectNotifications)
+263
View File
@@ -0,0 +1,263 @@
package server
import (
"encoding/json"
"io"
"net/http"
"strconv"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/traceimpact"
)
func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
items, err := s.rt.Trace.ListItems(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
visible, hidden := traceimpact.RedactItems(items, func(docID string) bool {
return s.traceDocVisible(r, projectID, user.ID, docID)
})
index, err := s.rt.Trace.ListIndex(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "")
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"items": visible,
"hidden_count": hidden,
"index": index,
"recheck_marks": marks,
})
}
func (s *Server) handleProjectTraceIndex(w http.ResponseWriter, r *http.Request) {
projectID, _, ok := s.traceProject(w, r)
if !ok {
return
}
var body traceimpact.DocRef
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
st, err := s.rt.Trace.IndexDocument(r.Context(), projectID, body)
if err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "TRACE_INDEX_FAILED", "message": err.Error(), "index": st})
return
}
writeJSONResponse(w, http.StatusOK, st)
}
func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Request) {
projectID, _, ok := s.traceProject(w, r)
if !ok {
return
}
var body struct {
traceimpact.DocRef
Others []traceimpact.DocRef `json:"others"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
rep, err := s.rt.Trace.Analyze(r.Context(), projectID, body.DocRef, body.Others)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
writeJSONResponse(w, http.StatusOK, rep)
}
func (s *Server) handleProjectTraceDecision(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
edgeID, err := strconv.ParseInt(r.PathValue("edgeID"), 10, 64)
if err != nil || edgeID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid edge id"}`)
return
}
var body struct {
DownstreamDoc string `json:"downstream_doc"`
DownstreamAuthor int64 `json:"downstream_author"`
Decision string `json:"decision"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
if !s.traceDocVisible(r, projectID, user.ID, body.DownstreamDoc) {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"看不见下游文档,不能裁决"}`)
return
}
creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
if err := s.rt.Trace.DecideCandidate(r.Context(), edgeID, user.ID, creator, body.DownstreamDoc, body.DownstreamAuthor, body.Decision, body.Reason); err != nil {
writeJSONResponse(w, http.StatusForbidden, map[string]string{"error": "TRACE_DECISION_DENIED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "recorded"})
}
func (s *Server) handleProjectTraceReopen(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
var req traceimpact.ReopenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
req.ProjectID = projectID
req.RequestedBy = user.ID
creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
if !s.callerMayRequestReopen(r, projectID, user.ID, creator, req) {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"只有该阶段文档职责人或创建人可以申请再打开"}`)
return
}
stored, err := s.rt.Trace.RequestReopen(r.Context(), req, creator)
if err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, stored)
}
func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
if !creator {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可批准再打开"}`)
return
}
requestID, err := strconv.ParseInt(r.PathValue("requestID"), 10, 64)
if err != nil || requestID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid request id"}`)
return
}
existing, err := s.rt.Trace.GetReopen(r.Context(), requestID)
if err != nil || existing.ProjectID != projectID {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"申请不属于该项目"}`)
return
}
stored, err := s.rt.Trace.ApproveReopen(r.Context(), requestID, user.ID)
if err != nil {
writeJSONResponse(w, http.StatusConflict, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, stored)
}
func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.Request) {
projectID, _, ok := s.traceProject(w, r)
if !ok {
return
}
markID, err := strconv.ParseInt(r.PathValue("markID"), 10, 64)
if err != nil || markID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid mark id"}`)
return
}
var body struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "")
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
found := false
for _, m := range marks {
if m.ID == markID {
found = true
break
}
}
if !found {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"未找到该项目的打开标记"}`)
return
}
if err := s.rt.Trace.ClearRecheck(r.Context(), markID, body.Reason); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
}
func (s *Server) traceProject(w http.ResponseWriter, r *http.Request) (int64, *auth.UserAccount, bool) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return 0, nil, false
}
if s.rt == nil || s.rt.Trace == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"追踪索引不可用"}`)
return 0, nil, false
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return 0, nil, false
}
return projectID, user, true
}
func (s *Server) callerMayRequestReopen(r *http.Request, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool {
if creator {
return true
}
if s.instSvc == nil {
return false
}
settings, err := s.instSvc.GetSettings(r.Context(), projectID, userID, false)
if err != nil || settings == nil || settings.Plan == nil {
return false
}
var seats []traceimpact.DutySeat
for _, stage := range settings.Plan.Stages {
if stage.StageKey != req.StageKey {
continue
}
for _, task := range stage.DocTasks {
seats = append(seats, traceimpact.DutySeat{
TaskKey: task.TaskKey, AuthorID: task.Duties.AuthorUserID,
ReviewerID: task.Duties.ReviewerUserID, ApproverID: task.Duties.ApproverUserID,
})
}
}
return traceimpact.MayRequestReopen(userID, false, req.DocTasks, seats)
}
func (s *Server) traceDocVisible(r *http.Request, projectID, userID int64, docID string) bool {
if docID == "" || docID == "chat" {
return true
}
parsedProject, taskKey, ok := parseCanonicalWorkflowDocID(docID)
if !ok {
return false
}
if parsedProject != projectID {
return false
}
return s.allowWorkflowDocTaskAccess(r.Context(), projectID, userID, taskKey)
}
+74
View File
@@ -0,0 +1,74 @@
package traceimpact
import (
"regexp"
"strings"
)
var (
explicitKeyRe = regexp.MustCompile(`\b((?:UR|RS|REQ|TC|RF|RP|DI)-\d+)\b`)
untypedKeyRe = regexp.MustCompile(`\b([A-Z]{2,10}-\d+)\b`)
)
// ExtractedKey is a business key found in markdown.
type ExtractedKey struct {
Key string
Kind string
Explicit bool
Line string
}
// ExtractKeys returns explicit UR/RS/REQ/TC-style keys and untyped ID mentions.
// Untyped mentions stay candidates and never become confirmed edges by themselves.
func ExtractKeys(markdown string) []ExtractedKey {
lines := strings.Split(markdown, "\n")
var out []ExtractedKey
seen := map[string]bool{}
for _, line := range lines {
for _, m := range explicitKeyRe.FindAllStringSubmatch(line, -1) {
key := m[1]
if seen["e:"+key] {
continue
}
seen["e:"+key] = true
kind := KindRequirement
if strings.HasPrefix(key, "TC-") {
kind = KindTest
}
out = append(out, ExtractedKey{Key: key, Kind: kind, Explicit: true, Line: line})
}
for _, m := range untypedKeyRe.FindAllStringSubmatch(line, -1) {
key := m[1]
if explicitKeyRe.MatchString(key) || seen["u:"+key] || seen["e:"+key] {
continue
}
seen["u:"+key] = true
out = append(out, ExtractedKey{Key: key, Kind: KindRequirement, Explicit: false, Line: line})
}
}
return out
}
// ExplicitPairsOnLine returns ordered pairs of explicit keys that share a line.
func ExplicitPairsOnLine(markdown string) [][2]string {
var pairs [][2]string
seen := map[string]bool{}
for _, line := range strings.Split(markdown, "\n") {
found := explicitKeyRe.FindAllStringSubmatch(line, -1)
if len(found) < 2 {
continue
}
for i := 0; i < len(found); i++ {
for j := i + 1; j < len(found); j++ {
a, b := found[i][1], found[j][1]
k := a + ">" + b
if seen[k] {
continue
}
seen[k] = true
pairs = append(pairs, [2]string{a, b})
}
}
}
return pairs
}
+27
View File
@@ -0,0 +1,27 @@
package traceimpact
import (
"os"
"strings"
"testing"
)
func TestMigrationDownKeepsDecisionLedger(t *testing.T) {
b, err := os.ReadFile("../../deploy/postgres/goose/00059_2026-09-20-project-trace.sql")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(string(b), "-- +goose Down")
if len(parts) != 2 {
t.Fatal("missing goose down")
}
down := strings.ToLower(parts[1])
for _, forbidden := range []string{"drop table", "delete from", "truncate"} {
if strings.Contains(down, forbidden) {
t.Fatalf("down migration must not %s the trace ledger", forbidden)
}
}
if !strings.Contains(down, "project_trace_edge_decisions") {
t.Fatal("down comment should name the decision ledger it refuses to drop")
}
}
+28
View File
@@ -0,0 +1,28 @@
package traceimpact
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Open uses the record tables when migration 00059 is present.
// Before that migration, it keeps an in-memory ledger so the process can still boot.
func Open(pool *pgxpool.Pool) *Service {
if pool == nil {
return NewMemoryService()
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var exists bool
err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'project_trace_items'
)`).Scan(&exists)
if err != nil || !exists {
return NewMemoryService()
}
return NewService(newPGStore(pool))
}
+307
View File
@@ -0,0 +1,307 @@
package traceimpact
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type pgStore struct {
pool *pgxpool.Pool
}
func newPGStore(pool *pgxpool.Pool) *pgStore {
return &pgStore{pool: pool}
}
func (p *pgStore) UpsertItem(ctx context.Context, item Item) (Item, error) {
if item.Lifecycle == "" {
item.Lifecycle = "active"
}
row := p.pool.QueryRow(ctx, `
INSERT INTO project_trace_items
(project_id, business_key, kind, lifecycle_state, review_state, tombstone, statement, source_doc_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (project_id, kind, business_key) DO UPDATE SET
review_state = EXCLUDED.review_state,
tombstone = EXCLUDED.tombstone,
statement = CASE WHEN EXCLUDED.statement <> '' THEN EXCLUDED.statement ELSE project_trace_items.statement END,
source_doc_id = CASE WHEN EXCLUDED.source_doc_id <> '' THEN EXCLUDED.source_doc_id ELSE project_trace_items.source_doc_id END
RETURNING id, project_id, business_key, kind, lifecycle_state, review_state, tombstone, statement, source_doc_id
`, item.ProjectID, item.BusinessKey, item.Kind, item.Lifecycle, item.ReviewState, item.Tombstone, item.Statement, item.SourceDocID)
return scanItem(row)
}
func scanItem(row pgx.Row) (Item, error) {
var item Item
err := row.Scan(&item.ID, &item.ProjectID, &item.BusinessKey, &item.Kind, &item.Lifecycle, &item.ReviewState, &item.Tombstone, &item.Statement, &item.SourceDocID)
return item, err
}
func (p *pgStore) ListItems(ctx context.Context, projectID int64) ([]Item, error) {
rows, err := p.pool.Query(ctx, `
SELECT id, project_id, business_key, kind, lifecycle_state, review_state, tombstone, statement, source_doc_id
FROM project_trace_items WHERE project_id = $1 ORDER BY id
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Item
for rows.Next() {
var item Item
if err := rows.Scan(&item.ID, &item.ProjectID, &item.BusinessKey, &item.Kind, &item.Lifecycle, &item.ReviewState, &item.Tombstone, &item.Statement, &item.SourceDocID); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (p *pgStore) UpsertEdge(ctx context.Context, edge Edge) (Edge, error) {
row := p.pool.QueryRow(ctx, `
INSERT INTO project_trace_edges
(project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (project_id, from_item_id, to_item_id, relation_type, provenance) DO UPDATE SET
review_state = CASE
WHEN project_trace_edges.review_state = 'rejected' AND EXCLUDED.review_state = 'candidate'
THEN project_trace_edges.review_state
WHEN project_trace_edges.review_state = 'confirmed'
THEN project_trace_edges.review_state
ELSE EXCLUDED.review_state
END
RETURNING id, project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id
`, edge.ProjectID, edge.FromItemID, edge.ToItemID, edge.RelationType, edge.Provenance, edge.ReviewState, edge.EvidenceDoc)
var out Edge
err := row.Scan(&out.ID, &out.ProjectID, &out.FromItemID, &out.ToItemID, &out.RelationType, &out.Provenance, &out.ReviewState, &out.EvidenceDoc)
return out, err
}
func (p *pgStore) ListEdges(ctx context.Context, projectID int64) ([]Edge, error) {
rows, err := p.pool.Query(ctx, `
SELECT id, project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id
FROM project_trace_edges WHERE project_id = $1 ORDER BY id
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Edge
for rows.Next() {
var e Edge
if err := rows.Scan(&e.ID, &e.ProjectID, &e.FromItemID, &e.ToItemID, &e.RelationType, &e.Provenance, &e.ReviewState, &e.EvidenceDoc); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (p *pgStore) PutIndex(ctx context.Context, st IndexState) error {
if st.Extractor == "" {
st.Extractor = ExtractorVersion
}
_, err := p.pool.Exec(ctx, `
INSERT INTO project_trace_index_state
(project_id, doc_id, source_snapshot_id, status, extractor_version, item_count, error)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (project_id, doc_id, extractor_version) DO UPDATE SET
source_snapshot_id = EXCLUDED.source_snapshot_id,
status = EXCLUDED.status,
item_count = EXCLUDED.item_count,
error = EXCLUDED.error,
indexed_at = now()
`, st.ProjectID, st.DocID, st.SnapshotID, st.Status, st.Extractor, st.ItemCount, st.Error)
return err
}
func (p *pgStore) IndexStatus(ctx context.Context, projectID int64, docID string) (IndexState, bool, error) {
row := p.pool.QueryRow(ctx, `
SELECT project_id, doc_id, source_snapshot_id, status, extractor_version, item_count, error
FROM project_trace_index_state
WHERE project_id = $1 AND doc_id = $2
ORDER BY indexed_at DESC LIMIT 1
`, projectID, docID)
var st IndexState
err := row.Scan(&st.ProjectID, &st.DocID, &st.SnapshotID, &st.Status, &st.Extractor, &st.ItemCount, &st.Error)
if err == pgx.ErrNoRows {
return IndexState{}, false, nil
}
if err != nil {
return IndexState{}, false, err
}
return st, true, nil
}
func (p *pgStore) ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) {
rows, err := p.pool.Query(ctx, `
SELECT project_id, doc_id, source_snapshot_id, status, extractor_version, item_count, error
FROM project_trace_index_state WHERE project_id = $1 ORDER BY doc_id
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []IndexState
for rows.Next() {
var st IndexState
if err := rows.Scan(&st.ProjectID, &st.DocID, &st.SnapshotID, &st.Status, &st.Extractor, &st.ItemCount, &st.Error); err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
func (p *pgStore) SetEdgeReview(ctx context.Context, edgeID int64, review string) error {
tag, err := p.pool.Exec(ctx, `UPDATE project_trace_edges SET review_state = $2 WHERE id = $1`, edgeID, review)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("traceimpact: edge not found")
}
return nil
}
func (p *pgStore) AddDecision(ctx context.Context, d Decision) (Decision, error) {
if d.ProjectID == 0 {
_ = p.pool.QueryRow(ctx, `SELECT project_id FROM project_trace_edges WHERE id = $1`, d.EdgeID).Scan(&d.ProjectID)
}
err := p.pool.QueryRow(ctx, `
INSERT INTO project_trace_edge_decisions (project_id, edge_id, decision, reason, decided_by)
VALUES ($1,$2,$3,$4,$5) RETURNING id
`, d.ProjectID, d.EdgeID, d.Decision, d.Reason, d.DecidedBy).Scan(&d.ID)
return d, err
}
func (p *pgStore) PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) {
if req.ID == 0 {
err := p.pool.QueryRow(ctx, `
INSERT INTO stage_reopen_requests (project_id, instance_id, stage_key, doc_task_keys, reason, requested_by, status)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id
`, req.ProjectID, req.InstanceID, req.StageKey, req.DocTasks, req.Reason, req.RequestedBy, req.Status).Scan(&req.ID)
return req, err
}
_, err := p.pool.Exec(ctx, `UPDATE stage_reopen_requests SET status = $2 WHERE id = $1`, req.ID, req.Status)
return req, err
}
func (p *pgStore) GetReopen(ctx context.Context, id int64) (ReopenRequest, error) {
var req ReopenRequest
err := p.pool.QueryRow(ctx, `
SELECT id, project_id, instance_id, stage_key, doc_task_keys, reason, requested_by, status
FROM stage_reopen_requests WHERE id = $1
`, id).Scan(&req.ID, &req.ProjectID, &req.InstanceID, &req.StageKey, &req.DocTasks, &req.Reason, &req.RequestedBy, &req.Status)
if err == pgx.ErrNoRows {
return ReopenRequest{}, fmt.Errorf("traceimpact: reopen request not found")
}
return req, err
}
func (p *pgStore) PutMark(ctx context.Context, mark RecheckMark) (RecheckMark, error) {
if mark.Status == "" {
mark.Status = MarkOpen
}
err := p.pool.QueryRow(ctx, `
INSERT INTO stage_recheck_marks (project_id, stage_key, source_doc_id, status, reason)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (project_id, stage_key, source_doc_id) WHERE status = 'open'
DO UPDATE SET reason = stage_recheck_marks.reason
RETURNING id, project_id, stage_key, source_doc_id, status, reason, COALESCE(acked_by, 0)
`, mark.ProjectID, mark.StageKey, mark.SourceDoc, mark.Status, mark.Reason).Scan(
&mark.ID, &mark.ProjectID, &mark.StageKey, &mark.SourceDoc, &mark.Status, &mark.Reason, &mark.AckedBy,
)
return mark, err
}
func (p *pgStore) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) {
rows, err := p.pool.Query(ctx, `
SELECT id, project_id, stage_key, source_doc_id, status, reason, COALESCE(acked_by, 0)
FROM stage_recheck_marks
WHERE project_id = $1 AND status = 'open' AND ($2 = '' OR stage_key = $2)
ORDER BY id
`, projectID, stageKey)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RecheckMark
for rows.Next() {
var m RecheckMark
if err := rows.Scan(&m.ID, &m.ProjectID, &m.StageKey, &m.SourceDoc, &m.Status, &m.Reason, &m.AckedBy); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (p *pgStore) AckMark(ctx context.Context, id, userID int64) error {
tag, err := p.pool.Exec(ctx, `UPDATE stage_recheck_marks SET acked_by = $2 WHERE id = $1 AND status = 'open'`, id, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("traceimpact: mark not found")
}
return nil
}
func (p *pgStore) SetMarkStatus(ctx context.Context, id int64, status, reason string) error {
tag, err := p.pool.Exec(ctx, `UPDATE stage_recheck_marks SET status = $2, reason = CASE WHEN $3 <> '' THEN $3 ELSE reason END WHERE id = $1`, id, status, reason)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("traceimpact: mark not found")
}
return nil
}
func (p *pgStore) GetEdge(ctx context.Context, edgeID int64) (Edge, error) {
var e Edge
err := p.pool.QueryRow(ctx, `
SELECT id, project_id, from_item_id, to_item_id, relation_type, provenance, review_state, evidence_doc_id
FROM project_trace_edges WHERE id = $1
`, edgeID).Scan(&e.ID, &e.ProjectID, &e.FromItemID, &e.ToItemID, &e.RelationType, &e.Provenance, &e.ReviewState, &e.EvidenceDoc)
if err == pgx.ErrNoRows {
return Edge{}, fmt.Errorf("traceimpact: edge not found")
}
return e, err
}
func (p *pgStore) AppendRevision(ctx context.Context, rev ItemRevision) error {
if rev.Extractor == "" {
rev.Extractor = ExtractorVersion
}
_, err := p.pool.Exec(ctx, `
INSERT INTO project_trace_item_revisions
(item_id, revision_no, source_doc_id, source_snapshot_id, content_hash, statement, extractor_version, tombstone)
SELECT $1,
COALESCE((SELECT MAX(revision_no) + 1 FROM project_trace_item_revisions WHERE item_id = $1), 1),
$2, $3, $4, $5, $6, $7
WHERE NOT EXISTS (
SELECT 1 FROM project_trace_item_revisions
WHERE item_id = $1 AND source_snapshot_id = $3 AND content_hash = $4 AND extractor_version = $6
)
`, rev.ItemID, rev.SourceDocID, rev.SnapshotID, rev.ContentHash, rev.Statement, rev.Extractor, rev.Tombstone)
return err
}
func (p *pgStore) RevisionCount(ctx context.Context, itemID int64) (int, error) {
var n int
err := p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM project_trace_item_revisions WHERE item_id = $1`, itemID).Scan(&n)
return n, err
}
func (p *pgStore) AddProposal(ctx context.Context, projectID, userID int64, text string) (int64, error) {
var id int64
err := p.pool.QueryRow(ctx, `
INSERT INTO trace_revision_proposals (project_id, author_id, body) VALUES ($1,$2,$3) RETURNING id
`, projectID, userID, text).Scan(&id)
return id, err
}
+114
View File
@@ -0,0 +1,114 @@
package traceimpact
import "fmt"
// Actor is the person trying to confirm or reject a candidate edge.
type Actor struct {
UserID int64
Creator bool
AuthorOf map[string]bool
}
// CanDecide reports whether actor may confirm or reject a model candidate
// on the downstream document. There is no separate trace_reviewer role.
func CanDecide(actor Actor, downstreamDocID string, downstreamAuthorID int64) error {
if downstreamDocID == "" {
return fmt.Errorf("traceimpact: missing downstream document")
}
if downstreamAuthorID > 0 {
if actor.UserID == downstreamAuthorID {
return nil
}
return fmt.Errorf("traceimpact: only the downstream author may decide this candidate")
}
if actor.Creator {
return nil
}
return fmt.Errorf("traceimpact: no downstream author; only the project creator may decide")
}
// DutySeat is one document's author, reviewer, and approver.
type DutySeat struct {
TaskKey string
AuthorID int64
ReviewerID int64
ApproverID int64
}
// MayRequestReopen allows the project creator or a duty holder on a selected document.
func MayRequestReopen(userID int64, creator bool, selected []string, seats []DutySeat) bool {
if creator {
return true
}
wanted := map[string]bool{}
for _, key := range selected {
wanted[key] = true
}
for _, seat := range seats {
if !wanted[seat.TaskKey] {
continue
}
if userID > 0 && (userID == seat.AuthorID || userID == seat.ReviewerID || userID == seat.ApproverID) {
return true
}
}
return false
}
// Suggest returns the three allowed next steps. They do not change state by themselves.
func Suggest(coverage string, hasImpact bool) []Suggestion {
switch coverage {
case ImpactFailed, ImpactIndexPending, ImpactPartial:
return []Suggestion{{
Action: "wait_index",
Reason: "索引未完成或分析失败,不能判断为无影响",
}}
}
out := []Suggestion{{
Action: ClearNoChange,
Reason: "确认这次修改不需要改其他文档",
}}
if hasImpact || coverage == ImpactPossible {
out = append(out,
Suggestion{Action: "l1_revise", Reason: "只修订受影响文档,阶段保持完成"},
Suggestion{Action: "l2_reopen", Reason: "需要重跑编制或补充文档时再打开阶段"},
)
}
return out
}
// SummaryForCoverage is the sentence shown when there is no confirmed hit.
func SummaryForCoverage(coverage string) string {
switch coverage {
case ImpactNoKnown:
return "索引已覆盖,未发现已知关联"
case ImpactIndexPending:
return "分析尚未完成,不能判断为无影响"
case ImpactPartial:
return "结果不完整,不能判断为无影响"
case ImpactFailed:
return "分析失败,不能判断为无影响"
case ImpactPossible:
return "存在未确认的可能影响"
case ImpactDependencyUnchanged:
return "已有关联,相关片段未变化"
default:
return "存在已确认影响"
}
}
// RedactItems drops statements from documents the viewer cannot see.
// Hidden items are counted and not returned, so titles cannot leak.
func RedactItems(items []Item, visible func(docID string) bool) (out []Item, hidden int) {
for _, item := range items {
if item.SourceDocID != "" && (visible == nil || !visible(item.SourceDocID)) {
hidden++
continue
}
out = append(out, item)
}
if out == nil {
out = []Item{}
}
return out, hidden
}
+380
View File
@@ -0,0 +1,380 @@
package traceimpact
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// StageReopener applies an approved reopen to the workflow instance.
// It must not pause the instance or rewrite downstream stages.
type StageReopener interface {
ReopenCompletedStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error
}
// DocUnlocker moves selected approved documents back to drafting.
type DocUnlocker interface {
ReopenApprovedDocs(ctx context.Context, projectID int64, docTaskKeys []string, actorID int64, reason string) error
}
// Service is the project trace index and the stage-backtrack ledger.
type Service struct {
store Store
reopen StageReopener
unlock DocUnlocker
}
func NewService(store Store) *Service {
if store == nil {
store = newMemStore()
}
return &Service{store: store}
}
func NewMemoryService() *Service {
return NewService(newMemStore())
}
func (s *Service) SetStageReopener(r StageReopener) { s.reopen = r }
func (s *Service) SetDocUnlocker(u DocUnlocker) { s.unlock = u }
// IndexDocument projects explicit keys into items and same-line pairs into confirmed edges.
// Untyped IDs become candidate items only.
func (s *Service) IndexDocument(ctx context.Context, projectID int64, doc DocRef) (IndexState, error) {
if projectID <= 0 || doc.DocID == "" {
return IndexState{}, fmt.Errorf("traceimpact: project and doc are required")
}
keys := ExtractKeys(doc.Markdown)
itemByKey := map[string]Item{}
for _, k := range keys {
review := ReviewConfirmed
if !k.Explicit {
review = ReviewCandidate
}
item, err := s.store.UpsertItem(ctx, Item{
ProjectID: projectID, BusinessKey: k.Key, Kind: k.Kind,
ReviewState: review, Statement: strings.TrimSpace(k.Line), SourceDocID: doc.DocID,
})
if err != nil {
st := IndexState{ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID, Status: ImpactFailed, Error: err.Error()}
_ = s.store.PutIndex(ctx, st)
return st, err
}
sum := sha256.Sum256([]byte(k.Key + "\n" + strings.TrimSpace(k.Line)))
if err := s.store.AppendRevision(ctx, ItemRevision{
ItemID: item.ID, SourceDocID: doc.DocID, SnapshotID: doc.SnapshotID,
ContentHash: hex.EncodeToString(sum[:]), Statement: strings.TrimSpace(k.Line),
Extractor: ExtractorVersion,
}); err != nil {
return IndexState{}, err
}
itemByKey[k.Key] = item
}
for _, pair := range ExplicitPairsOnLine(doc.Markdown) {
from, okA := itemByKey[pair[0]]
to, okB := itemByKey[pair[1]]
if !okA || !okB {
continue
}
if _, err := s.store.UpsertEdge(ctx, Edge{
ProjectID: projectID, FromItemID: from.ID, ToItemID: to.ID,
RelationType: RelationRefines, Provenance: ProvenanceExplicit,
ReviewState: ReviewConfirmed, EvidenceDoc: doc.DocID,
FromKey: from.BusinessKey, ToKey: to.BusinessKey,
}); err != nil {
return IndexState{}, err
}
}
st := IndexState{
ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID,
Status: "complete", Extractor: ExtractorVersion, ItemCount: len(keys),
}
if err := s.store.PutIndex(ctx, st); err != nil {
return IndexState{}, err
}
return st, nil
}
// CoverageForDoc is what a change report should show before claiming no impact.
func (s *Service) CoverageForDoc(ctx context.Context, projectID int64, docID string) string {
st, ok, err := s.store.IndexStatus(ctx, projectID, docID)
if err != nil || !ok || st.Status == "" {
return ImpactIndexPending
}
switch st.Status {
case "complete":
return ImpactNoKnown
case "failed":
return ImpactFailed
case "partial":
return ImpactPartial
default:
return ImpactIndexPending
}
}
// Analyze walks confirmed edges from keys mentioned in the changed markdown.
// Missing index coverage never becomes "no impact".
func (s *Service) Analyze(ctx context.Context, projectID int64, source DocRef, others []DocRef) (*Report, error) {
rep := &Report{ProjectID: projectID, SourceDocID: source.DocID, Impacts: []Impact{}}
if projectID <= 0 || source.DocID == "" {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
srcIndex, ok, err := s.store.IndexStatus(ctx, projectID, source.DocID)
if err != nil {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
if !ok || srcIndex.Status != "complete" {
rep.CoverageStatus = ImpactIndexPending
if ok && srcIndex.Status == "failed" {
rep.CoverageStatus = ImpactFailed
}
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
items, err := s.store.ListItems(ctx, projectID)
if err != nil {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
byID := map[int64]Item{}
byKey := map[string]Item{}
for _, it := range items {
byID[it.ID] = it
byKey[it.BusinessKey] = it
}
var start []int64
for _, k := range ExtractKeys(source.Markdown) {
if it, ok := byKey[k.Key]; ok && it.ReviewState == ReviewConfirmed {
start = append(start, it.ID)
}
}
edges, err := s.store.ListEdges(ctx, projectID)
if err != nil {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
hits, truncated := WalkReverse(edges, start, MaxWalkDepth, MaxWalkNodes)
docByKey := map[string]DocRef{source.DocID: source}
for _, d := range others {
docByKey[d.DocID] = d
}
for _, hit := range hits {
it := byID[hit.ItemID]
if !it.Tombstone && it.SourceDocID != "" && it.SourceDocID != source.DocID {
target := docByKey[it.SourceDocID]
if target.DocID != "" && !target.Visible {
continue
}
rep.Impacts = append(rep.Impacts, Impact{
DocID: it.SourceDocID, ItemKey: it.BusinessKey, StageKey: target.StageKey,
ImpactType: ImpactConfirmed, Depth: hit.Depth,
Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey),
})
if target.StageStatus == "completed" && target.StageKey != "" {
_, _ = s.store.PutMark(ctx, RecheckMark{
ProjectID: projectID, StageKey: target.StageKey, SourceDoc: source.DocID,
Status: MarkOpen, Reason: "上游或同项目条目已变化",
})
}
}
}
for _, e := range edges {
if e.ReviewState != ReviewCandidate {
continue
}
for _, id := range start {
if e.ToItemID == id || e.FromItemID == id {
rep.Impacts = append(rep.Impacts, Impact{
ItemKey: byID[e.FromItemID].BusinessKey, ImpactType: ImpactPossible, Depth: 1,
Reason: "候选关系尚未确认",
})
break
}
}
}
switch {
case truncated:
rep.CoverageStatus = ImpactPartial
case len(rep.Impacts) == 0:
rep.CoverageStatus = ImpactNoKnown
default:
rep.CoverageStatus = ImpactConfirmed
for _, imp := range rep.Impacts {
if imp.ImpactType == ImpactPossible && rep.CoverageStatus != ImpactConfirmed {
rep.CoverageStatus = ImpactPossible
}
if imp.ImpactType == ImpactConfirmed {
rep.CoverageStatus = ImpactConfirmed
break
}
}
onlyPossible := true
for _, imp := range rep.Impacts {
if imp.ImpactType == ImpactConfirmed {
onlyPossible = false
}
}
if onlyPossible {
rep.CoverageStatus = ImpactPossible
}
}
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
if rep.CoverageStatus == ImpactConfirmed {
rep.Summary = fmt.Sprintf("发现 %d 条影响", len(rep.Impacts))
}
rep.Suggestions = Suggest(rep.CoverageStatus, len(rep.Impacts) > 0)
return rep, nil
}
// DecideCandidate confirms or rejects a model/candidate edge.
func (s *Service) DecideCandidate(ctx context.Context, edgeID, actorID int64, creator bool, downstreamDoc string, downstreamAuthor int64, decision, reason string) error {
if err := CanDecide(Actor{UserID: actorID, Creator: creator}, downstreamDoc, downstreamAuthor); err != nil {
return err
}
if decision != DecisionConfirm && decision != DecisionReject {
return fmt.Errorf("traceimpact: decision must be confirm or reject")
}
edge, err := s.store.GetEdge(ctx, edgeID)
if err != nil {
return err
}
review := ReviewConfirmed
if decision == DecisionReject {
review = ReviewRejected
}
if err := s.store.SetEdgeReview(ctx, edgeID, review); err != nil {
return err
}
_, err = s.store.AddDecision(ctx, Decision{
ProjectID: edge.ProjectID, EdgeID: edgeID, Decision: decision, Reason: reason, DecidedBy: actorID,
})
return err
}
// NeedsRecheckAck is true when the stage still has an open mark.
// Acknowledgement does not clear the mark.
func (s *Service) NeedsRecheckAck(ctx context.Context, projectID int64, stageKey string) (bool, error) {
marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey)
if err != nil {
return false, err
}
return len(marks) > 0, nil
}
// AcknowledgeRecheck records that the creator saw the marks. Marks stay open.
func (s *Service) AcknowledgeRecheck(ctx context.Context, projectID int64, stageKey string, userID int64) error {
marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey)
if err != nil {
return err
}
for _, m := range marks {
if err := s.store.AckMark(ctx, m.ID, userID); err != nil {
return err
}
}
return nil
}
// ClearRecheck closes a mark only for an explicit human reason.
func (s *Service) ClearRecheck(ctx context.Context, markID int64, reason string) error {
switch reason {
case ClearNoChange, ClearRevision:
default:
return fmt.Errorf("traceimpact: recheck marks close only by no_change or revision_started")
}
return s.store.SetMarkStatus(ctx, markID, MarkCleared, reason)
}
// ReopenMark opens a previously cleared mark again, for example after a rejected proposal.
func (s *Service) ReopenMark(ctx context.Context, markID int64, reason string) error {
return s.store.SetMarkStatus(ctx, markID, MarkOpen, reason)
}
// RequestReopen stores a request. Creators are approved immediately and applied.
func (s *Service) RequestReopen(ctx context.Context, req ReopenRequest, creator bool) (ReopenRequest, error) {
if req.StageKey == "" || len(req.DocTasks) == 0 || strings.TrimSpace(req.Reason) == "" {
return ReopenRequest{}, fmt.Errorf("traceimpact: stage, documents, and reason are required")
}
req.Status = ReopenPending
stored, err := s.store.PutReopen(ctx, req)
if err != nil {
return ReopenRequest{}, err
}
if creator {
return s.ApproveReopen(ctx, stored.ID, req.RequestedBy)
}
return stored, nil
}
// ApproveReopen lets the creator apply a pending request.
func (s *Service) ApproveReopen(ctx context.Context, requestID, creatorID int64) (ReopenRequest, error) {
req, err := s.store.GetReopen(ctx, requestID)
if err != nil {
return ReopenRequest{}, err
}
if req.Status == ReopenApproved {
return req, nil
}
if s.reopen == nil {
return ReopenRequest{}, fmt.Errorf("traceimpact: stage reopen is not configured")
}
if err := s.reopen.ReopenCompletedStage(ctx, req.InstanceID, req.StageKey, req.DocTasks, creatorID); err != nil {
return ReopenRequest{}, err
}
if s.unlock != nil {
if err := s.unlock.ReopenApprovedDocs(ctx, req.ProjectID, req.DocTasks, creatorID, req.Reason); err != nil {
return ReopenRequest{}, err
}
}
req.Status = ReopenApproved
return s.store.PutReopen(ctx, req)
}
func (s *Service) ListItems(ctx context.Context, projectID int64) ([]Item, error) {
return s.store.ListItems(ctx, projectID)
}
func (s *Service) ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) {
return s.store.ListIndex(ctx, projectID)
}
func (s *Service) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) {
return s.store.ListOpenMarks(ctx, projectID, stageKey)
}
func (s *Service) GetReopen(ctx context.Context, id int64) (ReopenRequest, error) {
return s.store.GetReopen(ctx, id)
}
// ErrNeedsConfirm is returned when a revision proposal is requested before confirmation.
var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required")
// ProposeRevision records a proposal for a human author. It never edits controlled content.
func (s *Service) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) {
if !confirmed {
return "", ErrNeedsConfirm
}
if projectID <= 0 || strings.TrimSpace(text) == "" {
return "", fmt.Errorf("traceimpact: project and proposal text are required")
}
id, err := s.store.AddProposal(ctx, projectID, userID, strings.TrimSpace(text))
if err != nil {
return "", err
}
return fmt.Sprintf("proposal-%d-by-%d", id, userID), nil
}
+230
View File
@@ -0,0 +1,230 @@
package traceimpact
import (
"context"
"strings"
"testing"
)
func TestExtract_UntypedIsCandidateOnly(t *testing.T) {
keys := ExtractKeys("UR-001 对应 RS-002\n另见 AB-9")
explicit, untyped := 0, 0
for _, k := range keys {
if k.Explicit {
explicit++
} else {
untyped++
}
}
if explicit != 2 || untyped != 1 {
t.Fatalf("explicit=%d untyped=%d keys=%v", explicit, untyped, keys)
}
}
func TestWalkReverse_DepthAndCycle(t *testing.T) {
edges := []Edge{
{ID: 1, FromItemID: 2, ToItemID: 1, RelationType: RelationRefines, ReviewState: ReviewConfirmed},
{ID: 2, FromItemID: 3, ToItemID: 2, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed},
{ID: 3, FromItemID: 4, ToItemID: 3, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed},
{ID: 4, FromItemID: 1, ToItemID: 4, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed},
{ID: 5, FromItemID: 9, ToItemID: 1, RelationType: RelationDependsOn, ReviewState: ReviewCandidate},
}
hits, truncated := WalkReverse(edges, []int64{1}, 3, 50)
if truncated {
t.Fatal("did not expect truncation")
}
got := map[int64]int{}
for _, h := range hits {
got[h.ItemID] = h.Depth
}
if got[2] != 1 || got[3] != 2 || got[4] != 3 {
t.Fatalf("depths = %#v", got)
}
if _, ok := got[9]; ok {
t.Fatal("candidate edge must not be followed")
}
if _, ok := got[1]; ok {
t.Fatal("cycle must not revisit the start as a hit")
}
}
func TestAnalyze_PendingIsNotNoImpact(t *testing.T) {
svc := NewMemoryService()
rep, err := svc.Analyze(t.Context(), 7, DocRef{DocID: "doc-a", Markdown: "UR-001"}, nil)
if err != nil {
t.Fatal(err)
}
if rep.CoverageStatus != ImpactIndexPending {
t.Fatalf("status %s", rep.CoverageStatus)
}
if strings.Contains(rep.Summary, "未发现") {
t.Fatalf("pending must not look like no impact: %s", rep.Summary)
}
}
func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "down", Markdown: "RS-010 细化 UR-001"}); err != nil {
t.Fatal(err)
}
rep, err := svc.Analyze(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 改为必须记录"}, []DocRef{{
DocID: "down", StageKey: "V0.5", StageStatus: "completed", Visible: true,
}})
if err != nil {
t.Fatal(err)
}
if rep.CoverageStatus != ImpactConfirmed {
t.Fatalf("status %s summary %s impacts %#v", rep.CoverageStatus, rep.Summary, rep.Impacts)
}
need, err := svc.NeedsRecheckAck(ctx, 7, "V0.5")
if err != nil || !need {
t.Fatalf("need ack %v %v", need, err)
}
if err := svc.AcknowledgeRecheck(ctx, 7, "V0.5", 3); err != nil {
t.Fatal(err)
}
need, err = svc.NeedsRecheckAck(ctx, 7, "V0.5")
if err != nil || !need {
t.Fatal("ack must not clear the mark")
}
marks, err := svc.store.ListOpenMarks(ctx, 7, "V0.5")
if err != nil || len(marks) != 1 {
t.Fatal(err)
}
if err := svc.ClearRecheck(ctx, marks[0].ID, "auto"); err == nil {
t.Fatal("auto clear must fail")
}
if err := svc.ClearRecheck(ctx, marks[0].ID, ClearNoChange); err != nil {
t.Fatal(err)
}
need, _ = svc.NeedsRecheckAck(ctx, 7, "V0.5")
if need {
t.Fatal("explicit no_change should clear")
}
}
func TestDecide_DownstreamAuthorOnly(t *testing.T) {
if err := CanDecide(Actor{UserID: 2, Creator: true}, "down", 9); err == nil {
t.Fatal("creator must not override an existing author")
}
if err := CanDecide(Actor{UserID: 9}, "down", 9); err != nil {
t.Fatal(err)
}
if err := CanDecide(Actor{UserID: 1, Creator: true}, "down", 0); err != nil {
t.Fatal(err)
}
}
type fakeReopen struct{ called bool }
func (f *fakeReopen) ReopenCompletedStage(_ context.Context, _ int64, stageKey string, docTaskKeys []string, _ int64) error {
f.called = true
if stageKey != "V0.1" || len(docTaskKeys) != 1 {
return fmtError("bad reopen")
}
return nil
}
type fmtError string
func (e fmtError) Error() string { return string(e) }
func TestReopen_CreatorAppliesWithoutPausingHook(t *testing.T) {
svc := NewMemoryService()
hook := &fakeReopen{}
svc.SetStageReopener(hook)
req, err := svc.RequestReopen(t.Context(), ReopenRequest{
ProjectID: 1, InstanceID: 2, StageKey: "V0.1", DocTasks: []string{"bd_intake"},
Reason: "新材料推翻了摸底", RequestedBy: 4,
}, true)
if err != nil {
t.Fatal(err)
}
if req.Status != ReopenApproved || !hook.called {
t.Fatalf("status %s called %v", req.Status, hook.called)
}
}
func TestReopen_MemberStaysPending(t *testing.T) {
svc := NewMemoryService()
req, err := svc.RequestReopen(t.Context(), ReopenRequest{
ProjectID: 1, InstanceID: 2, StageKey: "V0.1", DocTasks: []string{"bd_intake"},
Reason: "需要重编", RequestedBy: 8,
}, false)
if err != nil {
t.Fatal(err)
}
if err != nil || req.Status != ReopenPending {
t.Fatalf("status %s err %v", req.Status, err)
}
}
func TestIndex_SameSnapshotDoesNotDuplicateRevision(t *testing.T) {
svc := NewMemoryService()
doc := DocRef{DocID: "doc_proj1_intake", Markdown: "UR-001 用户需求", SnapshotID: 7, Visible: true}
if _, err := svc.IndexDocument(t.Context(), 1, doc); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(t.Context(), 1, doc); err != nil {
t.Fatal(err)
}
items, err := svc.ListItems(t.Context(), 1)
if err != nil || len(items) != 1 {
t.Fatalf("items %+v err %v", items, err)
}
n, err := svc.store.RevisionCount(t.Context(), items[0].ID)
if err != nil || n != 1 {
t.Fatalf("revisions %d err %v", n, err)
}
again, err := svc.IndexDocument(t.Context(), 2, DocRef{DocID: "doc_proj2_intake", Markdown: "UR-001 另一项目", Visible: true})
if err != nil {
t.Fatal(err)
}
_ = again
other, err := svc.ListItems(t.Context(), 1)
if err != nil || len(other) != 1 || other[0].ProjectID != 1 {
t.Fatalf("cross project leaked %+v", other)
}
}
func TestRedactItems_HidesInvisibleStatements(t *testing.T) {
items := []Item{
{BusinessKey: "UR-001", Statement: "可见标题", SourceDocID: "doc_proj1_intake"},
{BusinessKey: "REQ-9", Statement: "秘密标题", SourceDocID: "doc_proj1_secret"},
}
out, hidden := RedactItems(items, func(docID string) bool { return docID == "doc_proj1_intake" })
if hidden != 1 || len(out) != 1 || out[0].Statement != "可见标题" {
t.Fatalf("out %+v hidden %d", out, hidden)
}
blob := strings.Join([]string{out[0].BusinessKey, out[0].Statement}, " ")
if strings.Contains(blob, "秘密") {
t.Fatal(blob)
}
}
func TestMayRequestReopen_DutyHolderOnly(t *testing.T) {
seats := []DutySeat{{TaskKey: "intake", AuthorID: 8, ReviewerID: 2, ApproverID: 3}}
if !MayRequestReopen(8, false, []string{"intake"}, seats) {
t.Fatal("author")
}
if !MayRequestReopen(1, true, []string{"intake"}, nil) {
t.Fatal("creator")
}
if MayRequestReopen(99, false, []string{"intake"}, seats) {
t.Fatal("stranger")
}
}
func TestPropose_UnconfirmedDoesNotStore(t *testing.T) {
svc := NewMemoryService()
if _, err := svc.ProposeRevision(t.Context(), 1, 4, "改写 UR-001", false); err != ErrNeedsConfirm {
t.Fatalf("err %v", err)
}
if _, err := svc.ProposeRevision(t.Context(), 1, 4, "改写 UR-001", true); err != nil {
t.Fatal(err)
}
}
+317
View File
@@ -0,0 +1,317 @@
package traceimpact
import (
"context"
"fmt"
"sync"
)
// Store persists trace rows. Implementations must not delete decision history.
type Store interface {
UpsertItem(ctx context.Context, item Item) (Item, error)
ListItems(ctx context.Context, projectID int64) ([]Item, error)
UpsertEdge(ctx context.Context, edge Edge) (Edge, error)
ListEdges(ctx context.Context, projectID int64) ([]Edge, error)
PutIndex(ctx context.Context, st IndexState) error
IndexStatus(ctx context.Context, projectID int64, docID string) (IndexState, bool, error)
ListIndex(ctx context.Context, projectID int64) ([]IndexState, error)
SetEdgeReview(ctx context.Context, edgeID int64, review string) error
AddDecision(ctx context.Context, d Decision) (Decision, error)
PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error)
GetReopen(ctx context.Context, id int64) (ReopenRequest, error)
PutMark(ctx context.Context, m RecheckMark) (RecheckMark, error)
ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error)
AckMark(ctx context.Context, id, userID int64) error
SetMarkStatus(ctx context.Context, id int64, status, reason string) error
GetEdge(ctx context.Context, edgeID int64) (Edge, error)
AppendRevision(ctx context.Context, rev ItemRevision) error
RevisionCount(ctx context.Context, itemID int64) (int, error)
AddProposal(ctx context.Context, projectID, userID int64, text string) (int64, error)
}
type memStore struct {
mu sync.Mutex
items map[int64]Item
byKey map[string]int64
edges map[int64]Edge
byEdge map[string]int64
index map[string]IndexState
decisions []Decision
reopens map[int64]ReopenRequest
marks map[int64]RecheckMark
revs map[int64][]ItemRevision
props []proposalRow
nextItem int64
nextEdge int64
nextDec int64
nextReq int64
nextMark int64
nextProp int64
}
func newMemStore() *memStore {
return &memStore{
items: map[int64]Item{},
byKey: map[string]int64{},
edges: map[int64]Edge{},
byEdge: map[string]int64{},
index: map[string]IndexState{},
reopens: map[int64]ReopenRequest{},
marks: map[int64]RecheckMark{},
}
}
func itemKey(projectID int64, kind, business string) string {
return fmt.Sprintf("%d|%s|%s", projectID, kind, business)
}
type proposalRow struct {
id int64
projectID int64
userID int64
text string
}
func (m *memStore) UpsertItem(_ context.Context, item Item) (Item, error) {
m.mu.Lock()
defer m.mu.Unlock()
k := itemKey(item.ProjectID, item.Kind, item.BusinessKey)
if id, ok := m.byKey[k]; ok {
cur := m.items[id]
if item.Statement != "" {
cur.Statement = item.Statement
}
if item.SourceDocID != "" {
cur.SourceDocID = item.SourceDocID
}
cur.ReviewState = item.ReviewState
cur.Tombstone = item.Tombstone
m.items[id] = cur
return cur, nil
}
m.nextItem++
item.ID = m.nextItem
if item.Lifecycle == "" {
item.Lifecycle = "active"
}
m.items[item.ID] = item
m.byKey[k] = item.ID
return item, nil
}
func (m *memStore) ListItems(_ context.Context, projectID int64) ([]Item, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Item
for _, it := range m.items {
if it.ProjectID == projectID {
out = append(out, it)
}
}
return out, nil
}
func (m *memStore) UpsertEdge(_ context.Context, edge Edge) (Edge, error) {
m.mu.Lock()
defer m.mu.Unlock()
k := fmt.Sprintf("%d|%d|%d|%s|%s", edge.ProjectID, edge.FromItemID, edge.ToItemID, edge.RelationType, edge.Provenance)
if id, ok := m.byEdge[k]; ok {
cur := m.edges[id]
if cur.ReviewState == ReviewRejected && edge.ReviewState == ReviewCandidate {
return cur, nil
}
if edge.ReviewState != "" && cur.ReviewState != ReviewConfirmed {
cur.ReviewState = edge.ReviewState
}
m.edges[id] = cur
return cur, nil
}
m.nextEdge++
edge.ID = m.nextEdge
m.edges[edge.ID] = edge
m.byEdge[k] = edge.ID
return edge, nil
}
func (m *memStore) ListEdges(_ context.Context, projectID int64) ([]Edge, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Edge
for _, e := range m.edges {
if e.ProjectID == projectID {
out = append(out, e)
}
}
return out, nil
}
func (m *memStore) PutIndex(_ context.Context, st IndexState) error {
m.mu.Lock()
defer m.mu.Unlock()
if st.Extractor == "" {
st.Extractor = ExtractorVersion
}
m.index[fmt.Sprintf("%d|%s", st.ProjectID, st.DocID)] = st
return nil
}
func (m *memStore) IndexStatus(_ context.Context, projectID int64, docID string) (IndexState, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
st, ok := m.index[fmt.Sprintf("%d|%s", projectID, docID)]
return st, ok, nil
}
func (m *memStore) ListIndex(_ context.Context, projectID int64) ([]IndexState, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []IndexState
for _, st := range m.index {
if st.ProjectID == projectID {
out = append(out, st)
}
}
return out, nil
}
func (m *memStore) SetEdgeReview(_ context.Context, edgeID int64, review string) error {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.edges[edgeID]
if !ok {
return fmt.Errorf("traceimpact: edge not found")
}
e.ReviewState = review
m.edges[edgeID] = e
return nil
}
func (m *memStore) AddDecision(_ context.Context, d Decision) (Decision, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.nextDec++
d.ID = m.nextDec
m.decisions = append(m.decisions, d)
return d, nil
}
func (m *memStore) PutReopen(_ context.Context, req ReopenRequest) (ReopenRequest, error) {
m.mu.Lock()
defer m.mu.Unlock()
if req.ID == 0 {
m.nextReq++
req.ID = m.nextReq
}
m.reopens[req.ID] = req
return req, nil
}
func (m *memStore) GetReopen(_ context.Context, id int64) (ReopenRequest, error) {
m.mu.Lock()
defer m.mu.Unlock()
req, ok := m.reopens[id]
if !ok {
return ReopenRequest{}, fmt.Errorf("traceimpact: reopen request not found")
}
return req, nil
}
func (m *memStore) PutMark(_ context.Context, mark RecheckMark) (RecheckMark, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, existing := range m.marks {
if existing.ProjectID == mark.ProjectID && existing.StageKey == mark.StageKey && existing.SourceDoc == mark.SourceDoc && existing.Status == MarkOpen {
return existing, nil
}
}
m.nextMark++
mark.ID = m.nextMark
if mark.Status == "" {
mark.Status = MarkOpen
}
m.marks[mark.ID] = mark
return mark, nil
}
func (m *memStore) ListOpenMarks(_ context.Context, projectID int64, stageKey string) ([]RecheckMark, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []RecheckMark
for _, mark := range m.marks {
if mark.ProjectID != projectID || mark.Status != MarkOpen {
continue
}
if stageKey != "" && mark.StageKey != stageKey {
continue
}
out = append(out, mark)
}
return out, nil
}
func (m *memStore) AckMark(_ context.Context, id, userID int64) error {
m.mu.Lock()
defer m.mu.Unlock()
mark, ok := m.marks[id]
if !ok {
return fmt.Errorf("traceimpact: mark not found")
}
mark.AckedBy = userID
m.marks[id] = mark
return nil
}
func (m *memStore) SetMarkStatus(_ context.Context, id int64, status, reason string) error {
m.mu.Lock()
defer m.mu.Unlock()
mark, ok := m.marks[id]
if !ok {
return fmt.Errorf("traceimpact: mark not found")
}
mark.Status = status
if reason != "" {
mark.Reason = reason
}
m.marks[id] = mark
return nil
}
func (m *memStore) GetEdge(_ context.Context, edgeID int64) (Edge, error) {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.edges[edgeID]
if !ok {
return Edge{}, fmt.Errorf("traceimpact: edge not found")
}
return e, nil
}
func (m *memStore) AppendRevision(_ context.Context, rev ItemRevision) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.revs == nil {
m.revs = map[int64][]ItemRevision{}
}
for _, existing := range m.revs[rev.ItemID] {
if existing.SnapshotID == rev.SnapshotID && existing.ContentHash == rev.ContentHash && existing.Extractor == rev.Extractor {
return nil
}
}
rev.RevisionNo = len(m.revs[rev.ItemID]) + 1
m.revs[rev.ItemID] = append(m.revs[rev.ItemID], rev)
return nil
}
func (m *memStore) RevisionCount(_ context.Context, itemID int64) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.revs[itemID]), nil
}
func (m *memStore) AddProposal(_ context.Context, projectID, userID int64, text string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.nextProp++
m.props = append(m.props, proposalRow{id: m.nextProp, projectID: projectID, userID: userID, text: text})
return m.nextProp, nil
}
+164
View File
@@ -0,0 +1,164 @@
package traceimpact
// Coverage and impact statuses. Unknown, failed, and empty are different.
const (
ImpactConfirmed = "confirmed_impact"
ImpactPossible = "possible_impact"
ImpactDependencyUnchanged = "dependency_unchanged"
ImpactNoKnown = "no_known_dependencies"
ImpactIndexPending = "index_pending"
ImpactPartial = "partial"
ImpactFailed = "failed"
KindRequirement = "requirement"
KindTest = "test"
ReviewConfirmed = "confirmed"
ReviewCandidate = "candidate"
ReviewRejected = "rejected"
ProvenanceExplicit = "explicit_reference"
ProvenanceRule = "rule"
ProvenanceModel = "model_candidate"
RelationRefines = "refines"
RelationDependsOn = "depends_on"
DecisionConfirm = "confirm"
DecisionReject = "reject"
ReopenPending = "pending"
ReopenApproved = "approved"
ReopenRejected = "rejected"
MarkOpen = "open"
MarkCleared = "cleared"
ClearNoChange = "no_change"
ClearRevision = "revision_started"
ExtractorVersion = "trace-keys-v1"
MaxWalkDepth = 3
MaxWalkNodes = 50
)
// ItemRevision is an immutable projection of one item at one snapshot.
type ItemRevision struct {
ID int64
ItemID int64
RevisionNo int
SourceDocID string
SnapshotID int64
ContentHash string
Statement string
Extractor string
Tombstone bool
}
// Item is a stable project entry.
type Item struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
BusinessKey string `json:"business_key"`
Kind string `json:"kind"`
Lifecycle string `json:"lifecycle_state"`
ReviewState string `json:"review_state"`
Tombstone bool `json:"tombstone"`
Statement string `json:"statement,omitempty"`
SourceDocID string `json:"source_doc_id,omitempty"`
}
// Edge is a directed relation. Reverse walk follows dependents of a changed item.
type Edge struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FromItemID int64 `json:"from_item_id"`
ToItemID int64 `json:"to_item_id"`
RelationType string `json:"relation_type"`
Provenance string `json:"provenance"`
ReviewState string `json:"review_state"`
EvidenceDoc string `json:"evidence_doc_id,omitempty"`
FromKey string `json:"from_key,omitempty"`
ToKey string `json:"to_key,omitempty"`
}
// Decision is the authority record for a candidate edge.
type Decision struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
EdgeID int64 `json:"edge_id"`
Decision string `json:"decision"`
Reason string `json:"reason"`
DecidedBy int64 `json:"decided_by"`
}
// IndexState is the coverage watermark for one document snapshot.
type IndexState struct {
ProjectID int64 `json:"project_id"`
DocID string `json:"doc_id"`
SnapshotID int64 `json:"source_snapshot_id"`
Status string `json:"status"`
Extractor string `json:"extractor_version"`
ItemCount int `json:"item_count"`
Error string `json:"error,omitempty"`
}
// DocRef binds a document to the stage the caller is asking about.
type DocRef struct {
DocID string `json:"doc_id"`
StageKey string `json:"stage_key,omitempty"`
StageStatus string `json:"stage_status,omitempty"`
AuthorID int64 `json:"author_id,omitempty"`
Visible bool `json:"visible"`
Markdown string `json:"markdown,omitempty"`
SnapshotID int64 `json:"snapshot_id,omitempty"`
}
// Suggestion is a clickable next step, not a free-text instruction.
type Suggestion struct {
Action string `json:"action"`
Reason string `json:"reason"`
}
// Impact is one affected document or item.
type Impact struct {
DocID string `json:"doc_id,omitempty"`
ItemKey string `json:"item_key,omitempty"`
StageKey string `json:"stage_key,omitempty"`
ImpactType string `json:"impact_type"`
Depth int `json:"depth"`
Reason string `json:"reason"`
}
// Report is the deterministic impact result.
type Report struct {
ProjectID int64 `json:"project_id"`
SourceDocID string `json:"source_doc_id"`
CoverageStatus string `json:"coverage_status"`
Impacts []Impact `json:"impacts"`
Suggestions []Suggestion `json:"suggestions"`
Summary string `json:"summary"`
}
// ReopenRequest asks to unlock selected documents on a completed stage.
type ReopenRequest struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
InstanceID int64 `json:"instance_id"`
StageKey string `json:"stage_key"`
DocTasks []string `json:"doc_task_keys"`
Reason string `json:"reason"`
RequestedBy int64 `json:"requested_by"`
Status string `json:"status"`
}
// RecheckMark is a soft signal on a completed stage.
type RecheckMark struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
StageKey string `json:"stage_key"`
SourceDoc string `json:"source_doc_id"`
Status string `json:"status"`
Reason string `json:"reason"`
AckedBy int64 `json:"acked_by,omitempty"`
}
+59
View File
@@ -0,0 +1,59 @@
package traceimpact
// WalkHit is one node reached by a reverse walk.
type WalkHit struct {
ItemID int64
EdgeID int64
Depth int
Via string
}
// WalkReverse follows confirmed edges from changed items to their dependents.
// An edge From --depends_on/refines--> To means From depends on To.
// Changing To therefore impacts From. Candidate edges are not followed.
func WalkReverse(edges []Edge, start []int64, maxDepth, maxNodes int) (hits []WalkHit, truncated bool) {
if maxDepth <= 0 {
maxDepth = MaxWalkDepth
}
if maxNodes <= 0 {
maxNodes = MaxWalkNodes
}
startSet := map[int64]bool{}
for _, id := range start {
startSet[id] = true
}
type node struct {
id int64
depth int
}
queue := make([]node, 0, len(start))
for _, id := range start {
queue = append(queue, node{id: id, depth: 0})
}
seen := map[int64]bool{}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if seen[cur.id] {
continue
}
seen[cur.id] = true
if len(seen) > maxNodes {
return hits, true
}
if cur.depth >= maxDepth {
continue
}
for _, e := range edges {
if e.ReviewState != ReviewConfirmed || e.ToItemID != cur.id {
continue
}
if startSet[e.FromItemID] || seen[e.FromItemID] {
continue
}
hits = append(hits, WalkHit{ItemID: e.FromItemID, EdgeID: e.ID, Depth: cur.depth + 1, Via: e.RelationType})
queue = append(queue, node{id: e.FromItemID, depth: cur.depth + 1})
}
}
return hits, false
}
+2
View File
@@ -34,6 +34,7 @@ import {
import { DocumentLineage } from './types/projectWorkflow';
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 { LoginView } from './components/auth/LoginView';
@@ -434,6 +435,7 @@ export default function App() {
isCreator={currentWfState.creatorId === currentUser.id}
/>
)}
{currentProjectId > 0 && <ProjectTracePanel projectId={currentProjectId} />}
<StaleDocumentNotice
lineages={projectLineages}
onTriggerRecheck={() => {
+3 -1
View File
@@ -206,7 +206,8 @@ export async function terminateWorkflowInstance(
export async function advanceStage(
instanceId: number,
stageKey: string,
expectedConfigRevision: number
expectedConfigRevision: number,
acknowledgeRecheck = false
): Promise<AdvanceStageResult> {
const res = await fetch(
`${API_BASE}/api/v1/workflow-instances/${instanceId}/stages/${encodeURIComponent(stageKey)}/advance`,
@@ -216,6 +217,7 @@ export async function advanceStage(
body: JSON.stringify({
stage_key: stageKey,
expected_config_revision: expectedConfigRevision,
acknowledge_recheck: acknowledgeRecheck,
}),
}
);
+55
View File
@@ -0,0 +1,55 @@
import { getBearerAuthHeaders } from './authHeaders';
const API_BASE = '';
export interface TraceItem {
id: number;
business_key: string;
kind: string;
statement?: string;
source_doc_id?: string;
review_state?: string;
}
export interface TraceIndex {
doc_id: string;
status: string;
item_count: number;
error?: string;
}
export interface RecheckMark {
id: number;
stage_key: string;
source_doc_id: string;
status: string;
reason: string;
}
export interface ProjectTraceView {
items: TraceItem[];
hidden_count: number;
index: TraceIndex[];
recheck_marks: RecheckMark[];
}
export async function fetchProjectTrace(projectId: number): Promise<ProjectTraceView> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`需求与影响加载失败: ${res.status}`);
}
return res.json();
}
export async function clearRecheckMark(projectId: number, markId: number, reason: 'no_change' | 'revision_started'): Promise<void> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, {
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({ reason }),
});
if (!res.ok) {
throw new Error(`关闭待复核失败: ${res.status}`);
}
}
@@ -0,0 +1,78 @@
import React, { useCallback, useEffect, useState } from 'react';
import { clearRecheckMark, fetchProjectTrace, ProjectTraceView } from '../../api/projectTrace';
interface ProjectTracePanelProps {
projectId: number;
}
export const ProjectTracePanel: React.FC<ProjectTracePanelProps> = ({ projectId }) => {
const [view, setView] = useState<ProjectTraceView | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setView(await fetchProjectTrace(projectId));
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载失败');
}
}, [projectId]);
useEffect(() => {
load();
}, [load]);
if (error) {
return <div style={boxStyle}>需求与影响暂时不可用:{error}</div>;
}
if (!view) return null;
return (
<div style={boxStyle}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>需求与影响</div>
<div style={{ color: 'var(--text-muted)' }}>
已索引 {view.index.length} 份文档 · 条目 {view.items.length}
{view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''}
</div>
{view.recheck_marks.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{view.recheck_marks.map((mark) => (
<li key={mark.id}>
阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id})
<button
type="button"
style={{ marginLeft: 8 }}
onClick={() => {
clearRecheckMark(projectId, mark.id, 'no_change').then(load).catch((err: unknown) => {
setError(err instanceof Error ? err.message : '关闭失败');
});
}}
>
无需变更
</button>
</li>
))}
</ul>
)}
{view.items.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{view.items.slice(0, 8).map((item) => (
<li key={item.id}>
{item.business_key}
{item.statement ? ` · ${item.statement}` : ''}
</li>
))}
</ul>
)}
</div>
);
};
const boxStyle: React.CSSProperties = {
margin: '8px 12px 0',
padding: '10px 12px',
borderRadius: 8,
border: '1px solid var(--border, #e2e8f0)',
background: 'var(--bg-subtle, #f8fafc)',
fontSize: 12,
};
@@ -7,6 +7,7 @@ import {
advanceStage,
} from '../../api/projectSettings';
import { StageReadiness } from '../../types/projectSettings';
import { canSubmitAdvance, recheckAckRequired } from '../../lib/recheckAck';
interface StageAdvanceBannerProps {
projectId: number;
@@ -25,6 +26,7 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
const [instanceId, setInstanceId] = useState<number | null>(null);
const [readiness, setReadiness] = useState<StageReadiness | null>(null);
const [lastNotificationId, setLastNotificationId] = useState(0);
const [acked, setAcked] = useState(false);
const load = useCallback(async () => {
setLoading(true);
@@ -42,6 +44,7 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
if (!activeStage) return;
const rd = await fetchStageReadiness(live.id, activeStage);
setReadiness(rd);
setAcked(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载阶段状态失败');
} finally {
@@ -80,11 +83,18 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
}, [projectId, lastNotificationId, load]);
const handleAdvance = async () => {
if (!readiness || !instanceId || !readiness.can_advance) return;
if (!readiness || !instanceId) return;
const ackRequired = recheckAckRequired(readiness);
if (!canSubmitAdvance(readiness.can_advance, ackRequired, acked)) return;
setAdvancing(true);
setError(null);
try {
const result = await advanceStage(instanceId, readiness.stage_key, readiness.config_revision);
const result = await advanceStage(
instanceId,
readiness.stage_key,
readiness.config_revision,
ackRequired
);
onStageAdvanced?.(result.next_stage_key);
await load();
} catch (err: unknown) {
@@ -100,6 +110,8 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
if (loading && !readiness) return null;
if (!readiness || !instanceId) return null;
const ackRequired = recheckAckRequired(readiness);
const submit = canSubmitAdvance(Boolean(readiness.can_advance), ackRequired, acked);
return (
<div
@@ -129,7 +141,7 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
{isCreator && readiness.can_advance && (
<button
onClick={handleAdvance}
disabled={advancing}
disabled={advancing || !submit}
style={{
display: 'flex',
alignItems: 'center',
@@ -148,6 +160,12 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
</button>
)}
</div>
{ackRequired && (
<label style={{ display: 'flex', gap: 8, marginTop: 8, color: '#9a3412', fontWeight: 600 }}>
<input type="checkbox" checked={acked} onChange={(e) => setAcked(e.target.checked)} />
此阶段有待复核影响。确认已知悉后才能推进。知悉不会关闭标记。
</label>
)}
{!readiness.ready && readiness.blocking_issues.length > 0 && (
<ul style={{ margin: '6px 0 0', paddingLeft: '18px', color: 'var(--text-muted)' }}>
{readiness.blocking_issues.slice(0, 3).map((issue) => (
+13
View File
@@ -0,0 +1,13 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { canSubmitAdvance, recheckAckRequired } from './recheckAck';
describe('recheckAck', () => {
it('requires an explicit acknowledgement before advance', () => {
assert.equal(recheckAckRequired({ requires_recheck_ack: true }), true);
assert.equal(recheckAckRequired({ requires_recheck_ack: false }), false);
assert.equal(canSubmitAdvance(true, true, false), false);
assert.equal(canSubmitAdvance(true, true, true), true);
assert.equal(canSubmitAdvance(true, false, false), true);
});
});
+9
View File
@@ -0,0 +1,9 @@
export function recheckAckRequired(readiness: { requires_recheck_ack?: boolean } | null): boolean {
return readiness?.requires_recheck_ack === true;
}
export function canSubmitAdvance(canAdvance: boolean, ackRequired: boolean, acknowledged: boolean): boolean {
if (!canAdvance) return false;
if (ackRequired && !acknowledged) return false;
return true;
}
+1
View File
@@ -143,6 +143,7 @@ export interface StageReadiness {
can_advance: boolean;
config_revision: number;
blocking_issues: Array<{ code: string; message: string; task_key?: string }>;
requires_recheck_ack?: boolean;
doc_tasks: Array<{
task_key: string;
required: boolean;