merge: feature/stage-backtrack-impl
This commit is contained in:
+3
-2
@@ -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`,分析在索引未完成时不会说无影响。
|
||||
|
||||
## 相关链接
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 三分支意图消解类别
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(©Inst), 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] = ©Inst
|
||||
return m.instanceDetailLocked(©Inst), nil
|
||||
}
|
||||
|
||||
func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskKey string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user