Compare commits

...
Author SHA1 Message Date
weichen 807c656886 Merge pull request 'Update Gitea endpoints after server migration' (#126) from fix/gitea-endpoint-migration into main
governance / governance (push) Successful in 22s
ci / test-and-build (push) Successful in 1m58s
2026-10-22 15:22:40 +00:00
weichen a601bf9239 Fix the UI checkout SSH port and known_hosts entry
governance / governance (pull_request) Successful in 17s
ci / test-and-build (pull_request) Successful in 1m48s
Why: the URL was migrated but the separate -p port and the
known_hosts entry still referenced the old endpoint shape.

Evidence: host-verified; see Dev PR #706

Impact: CI configuration only.

Refs: Agent-System-Dev PR #706
2026-10-22 15:14:25 +00:00
weichen d4a1d1577f Bump migrated dependency pins for the endpoint migration
governance / governance (pull_request) Successful in 38s
ci / test-and-build (pull_request) Failing after 2m15s
Why: recursive submodule checkout reads pinned dep commits; their
.gitmodules now carries the new Gitea endpoint base only in the
migrated mains.

Evidence: host-verified; see Dev PR #706

Impact: pin updates only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:36:16 +00:00
weichen bda546472d Bump the development-manager pin for the endpoint migration
governance / governance (pull_request) Successful in 45s
ci / test-and-build (pull_request) Failing after 2m17s
Why: governance checks validate candidate .gitmodules URLs against the
pinned development-manager scaffold, which now carries the new Gitea
endpoint base after its own migration.

Evidence: host-verified; see Dev PR #706

Impact: pin update only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:25:44 +00:00
weichen 8fcbda4f6d Update Gitea endpoints after server migration
governance / governance (pull_request) Failing after 34s
ci / test-and-build (pull_request) Failing after 2m22s
Why: the Gitea server moved to https://gitea.naitangbaobao.cn with SSH
on port 52222; endpoint references must follow or superproject CI
checkout refuses the old URLs.

Evidence: host-verified API reach and SSH auth (Dev task card
fix/gitea-endpoint-migration)

Impact: configuration only; no runtime behavior change.

Refs: Dev PR #706
2026-10-22 14:16:56 +00:00
weichen ac5cf6b322 Merge pull request 'Deliver increment C Frontend work: formal RDM audit workspace' (#125) from feature/rdm-two-trees-followup-w10 into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 1m19s
2026-10-22 06:25:22 +00:00
weichen a82a1e18de Merge remote-tracking branch 'origin/main' into feature/rdm-two-trees-followup-w10
governance / governance (pull_request) Successful in 18s
ci / test-and-build (pull_request) Successful in 52s
2026-10-22 06:23:25 +00:00
weichen 279b2d02a2 Merge pull request 'Keep the scratch flag out of the Core-generated contracts' (#124) from fix/scratch-conversation-view-type into main
governance / governance (push) Successful in 27s
ci / test-and-build (push) Successful in 1m8s
2026-10-22 06:07:28 +00:00
weichenandCursor 6e611c5de0 Keep the scratch flag out of the Core-generated contracts
governance / governance (pull_request) Successful in 19s
ci / test-and-build (pull_request) Successful in 49s
Why: src/contracts.ts is regenerated from Agent-System-Core by the Dev
composite; the scratch field added there is dropped on regeneration and
tsc then fails in make build.

Evidence: contracts.ts equals the Core-generated output; npx tsc -b
--noEmit, npx vitest run (685 tests) and check-knip pass locally (SELF).

Impact: ConversationView (Conversation & { scratch?: boolean }) in
api.ts carries the server-local flag, mirroring the Backend
conversationView; no behavior change.

Refs: Agent-System-Frontend#123, Agent-System-Dev#691;
agent-exec receipt u3f-run (cursor, success)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 06:06:01 +00:00
agent-exec-transport 4882e0ae25 agent-exec transport a9ccb6178c1cd1ffb0e66f9ad77b6b65/c2d99ab92f51 2026-10-22 05:57:33 +00:00
agent-exec-transport a61eaac9ae agent-exec transport 793bf9727d04cae50d0b270d9064534b/21372630236f 2026-10-22 05:50:45 +00:00
agent-exec-transport fa08be4145 agent-exec transport 3e6b9205399daae9ea6ea6069d96fecd/ab1e6aa64e0f 2026-10-22 05:29:31 +00:00
weichen ec1ced3050 Merge pull request 'Add the no-project conversation view backed by scratch sessions' (#123) from feature/scratch-session-general-agent into main
governance / governance (push) Successful in 22s
ci / test-and-build (push) Successful in 42s
2026-10-22 04:04:52 +00:00
22 changed files with 1294 additions and 50 deletions
+3 -3
View File
@@ -37,11 +37,11 @@ jobs:
umask 077 umask 077
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts" printf '%s\n' '[gitea.naitangbaobao.cn]:52222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411" export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 52222"
cd "${RUNNER_TEMP}/iam-frontend-ci/ui" cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
git init -q git init -q
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git" git remote add origin "ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-UI.git"
git fetch --depth=1 origin "${UI_SHA}" git fetch --depth=1 origin "${UI_SHA}"
git checkout -q --detach FETCH_HEAD git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${UI_SHA}" test "$(git rev-parse HEAD)" = "${UI_SHA}"
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
parsed_server = urlsplit(SERVER) parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc: if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL") sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/" SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。 # 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = { REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines", "deps/development-guidelines": "Development-Guidelines",
+2 -2
View File
@@ -1,6 +1,6 @@
[submodule "deps/development-guidelines"] [submodule "deps/development-guidelines"]
path = deps/development-guidelines path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
[submodule "deps/development-manager"] [submodule "deps/development-manager"]
path = deps/development-manager path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
+3 -3
View File
@@ -24,9 +24,9 @@
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败), 历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道: 见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3, - **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。 API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git` - **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。 (各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与 - **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`, `ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
@@ -34,7 +34,7 @@
(baseline 硬边界 2:只记录来源、字段与可用状态)。 (baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹 - **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec); `SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。 `~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制) ## Agent 执行与新项目准入(强制)
@@ -0,0 +1,90 @@
# 增量 C 前端独立评审(W10 审计工作台 + 依据/候选确认路径)
- 评审对象:`feature/rdm-two-trees-followup-w10` @ `fa08be4`(基线 `ec1ced3`,dev 无本地改动)
- 范围:`src/components/RdmAuditWorkspace.tsx(+test)`、`Conversation.tsx`、`ConversationWork.tsx`、`Workbench.tsx`、`src/api.ts`、`src/styles.css`、`src/companion-conversation.css`、`e2e/rdm-audit.playwright.ts`
- 增量:`+1048 / -7`,9 文件(`git diff --stat ec1ced3 fa08be4`)
- 评审性质:只读评审。代码零改动;本文件是唯一写入。契约核对基线:RDM 侧 `Agent-System-RDM-wt-feature-rdm-two-trees-followup-c-w8-w10` @ `c4c6df7`(本地检出的 W8b/W5 命令面);计划基线:`modules/documents` `rdm-two-trees-stage5-implementation-test-plan-r1.md` §W10
- 环境限制:本仓为独立检出,组合仓 `../ui`(`@ui` 源码别名)缺失、子模块未初始化 ⇒ 依赖 `@ui` 的 22 个测试文件与 `tsc -b` 无法在本工作区运行(见 §验证)。`npm ci --offline` 可用,故守护测试与 knip 实跑。
## 1. 逐项结论
| # | 检查项 | 结论 | 依据 |
|---|---|---|---|
| 1 | 设计门禁(令牌/动效/折叠) | **FAIL(部分)** | 裸色值、私有 keyframes、行内字号、`transition`、`--fs-ui-*` 全部通过守护测试(7/7 PASS);但两处折叠未用 `@ui Collapsible`(MAJOR-3) |
| 2 | DTO 边界(只消费服务端已裁剪 DTO、无新增全量拉取) | **PASS** | 页面只渲染 `display/recorded/disclosure`;服务端 `redactOperationBodies`(`inspection_visibility.go:70-88`)确实把无权正文的 `summary.display` 换成「受限」并清空 `object.current`,前端未越权补正文;列表单页 `limit:30`,证据/影响分析均为按需单条读取,无全量拉取 |
| 3 | 异步纪律(单一 owner、seq 丢迟到响应) | **PASS(有 1 处冗余镜像,见 MINOR-4)** | `createOperationsFeed` 每请求取 `++seq`,非最新 seq 的成功与失败一并丢弃,`dispose()` 亦作废在途;续页回传首页 `cutoffSeq`;其余面板沿用既有 `startLatestFetch` 单 owner 模式 |
| 4 | 四态可读 + 游标失效显式 | **PASS** | 加载/空/错误(denied·cursor·query·capability·auth 分流)/旧记录「当时未记录」/正文受限/部分覆盖/影响截断均有独立文案;错误分类正则与服务端真实串一致(`权限拒绝:…` 出自 `perms_api.go:234`;`操作记录游标已失效…` 出自 `operation_projection.go:42`),非臆测匹配 |
| 5 | 既有行为兼容(树/卡片/对话/导出预览;右栏收起的确认路径) | **FAIL** | 树/卡片/对话/导出预览无回归(页签按名定位,`审计记录` 与既有 `操作记录` 不冲突,`.rdm-audit-card` 计数断言互不干扰);但右栏收起后的「对话确认路径」在最需要它的工作卡场景下是空操作(MAJOR-1) |
| 6 | e2e 扩展质量(skip 如实) | **PASS(附 MINOR-6)** | `test.skip(!SVC_REAL_RDM_BIN, …)` 与注释如实说明「缺真实 RDM 二进制则整组跳过、不伪造」;但当前 CI 与 Dev `scripts/e2e.sh` 均不注入该变量 ⇒ 4 条新用例在任何现有执行入口下都永不运行(零证据) |
| 7 | 知识多挂载为 TODO 未实现、未虚报 | **PASS(附 MINOR-7)** | `RdmAuditWorkspace.tsx:591` 仅留 TODO,未见任何实现或「已完成」表述;TODO 给出的理由经核实为真:W8b 分支 RDM 侧 `grep '"mounts"'` 零命中,`rdm.inspect.tree/object` 确实无挂载字段(域层已有 `MountsOf`,仅未出 DTO) |
## 2. BLOCKING
无。
## 3. MAJOR
### MAJOR-1 右栏收起后的「对话确认路径」在目标场景下是空操作(承诺与实现不符)
- 位置:`src/components/Conversation.tsx:571-572, 734-740`;关联 `src/components/ConversationWork.tsx:65, 91`;承诺文案 `src/components/Workbench.tsx:82`
- 理由:新提示条仅做 DOM 定位滚动:
`body.querySelector('[data-card-id="…"]') ?? body.querySelector('[data-conversation-work]')`。
但显示条件恰是 `!workPaneOwnsCards`(右栏收起或非工作页签),此时:
1. `ConversationWork` 命中 `if (!detailHere) return null`(`ConversationWork.tsx:91`)——`[data-conversation-work]` 整个子树不存在;
2. 工作卡在对话正文里只经 `CardAnchor` 出现,而 `Conversation.tsx:56` 对带 `card.work` 的卡显式传 `card={undefined}`,UI 侧 `data-card-id={card?.id}`(`modules/ui/src/CardAnchor.tsx:25`)因此不落属性。
结果:W10 重点场景(候选工作卡待确认)点「N 项待你确认 · 在对话中处理 ↓」→ `target` 为 `null` → `target?.scrollIntoView` 静默无动作,而收起按钮的 title 却承诺「N 项待确认会在对话中继续处理」。
附带:`pendingHere`(含 `work.decision.state === 'open'`)与 `pendingCardId`(仅 `status === 'action_required'`,`Conversation.tsx:572`)口径不同,decision-open 卡即使能定位也算不出 `id`。
- 修复建议:改走仓内既有的唯一定位路径 `store.openInbox(pendingCardId)`(`src/store.ts:1625-1633`:展开右栏、切工作页签、`flashLocate('.right-panel','data-card-id',…)`),或在 `!workPaneOwnsCards` 时把候选/决议控件渲染进对话正文;同时让 `pendingCardId` 与 `pendingConfirmations` 用同一谓词。
### MAJOR-2 新增「审计记录」页签无能力门禁,缺命令面的构建上恒为错误态
- 位置:`src/components/RdmAuditWorkspace.tsx:52, 125-137`
- 理由:页签无条件挂载,而仓内既有约定是「能力缺失即不暴露」(`src/store.ts:1462-1475` 依 `api.listCommands()` 刷新能力位;`src/workspaces.ts:52` `rdmAuditReady`)。当前就绪门 `RDM_AUDIT_COMMANDS` 只含 7 个 `rdm.inspect.*`(`workspaces.ts:23-31`),不含 W8b/W5 的 `rdm.audit.operations`、`rdm.trace.coverage`、`rdm.trace.pending-recheck`、`rdm.compliance.impact.*`。因此在已合入的 RDM 上审计工作区入口照常可见,而新页签必然落到 `classifyOperationsError → capability`(「当前服务未提供审计能力」),与 `e2e/plain-base.playwright.ts:38-51` 记录的「零暴露、不进入即 400」原则相悖。
- 修复建议:在 `workspaces.ts` 增加 `rdmAuditOperationsReady(cmds)`(上述 5 条命令)并在页签列表按能力过滤;覆盖面板与影响分析入口同样受此门控;若坚持常显,则须在 PR 描述中登记为已知降级并由 RDM 发布节奏兜底。
### MAJOR-3 两处折叠未使用 `@ui Collapsible`(违反 DESIGN.md §9.2)
- 位置:`src/components/RdmAuditWorkspace.tsx:1200`(`EvidenceLink`)、`:1266`(`ImpactAnalysisBlock`)
- 理由:两者用 `<button aria-expanded>` + 条件挂载自建展开,未消费全项目唯一折叠原语;DESIGN.md §9.2「一切折叠区…用它:外壳高度动画 + 内容 fade 两层分离、300ms 延迟卸载、展开态记忆」。守护测试只拦 `<details>`,故测试全绿但违宪——本次评审门禁「折叠只用 @ui Collapsible」据此判 FAIL。
- 修复建议:改为 `<Collapsible id={…} trigger={…}>`(这两处都有稳定的 id 语义:证据 `${pid}/${kind}/${id}`、影响分析 `${pid}/${analysisId}`),可顺带获得展开态记忆与卸载动画。若以「同文件 `:408`、`RegCitation.tsx:157` 已有先例」为由豁免,请在 PR 中显式登记豁免理由,而非默认放行。
## 4. MINOR
| # | 位置 | 问题 | 建议 |
|---|---|---|---|
| MINOR-1 | `src/components/ConversationWork.tsx:22-24` | `candidateActions` 未排除已有决议表单的卡:@ui `WorkCardBody` 对 `work.decision` 开放态渲染 `DecisionForm`,若该卡 `actions` 同时含 confirm/reject,则同一决议出现两个入口,且新按钮只提交 `{resultVersion}`(不含 `selectedIds`/`freeText`),存在「未选选项即提交」风险 | 首行加 `if (card.work?.decision?.state === 'open') return []`,保持一个动作一个入口 |
| MINOR-2 | `src/components/Workbench.tsx:38-41` | `pendingConfirmations` 与 @ui `pendingCount`(`modules/ui/src/InboxList.tsx:103-106`)口径不一致:新函数未排除 `status === 'resolved'`,「resolved + decision open」的卡会被计入输入区提示与收起提示,却不计入收件箱/顶栏计数 ⇒ 同屏两个数字打架 | 直接复用 `pendingCount` 语义,或在注释中说明差异并统一文案 |
| MINOR-3 | `src/components/RdmAuditWorkspace.tsx:1303` | 覆盖面板用 `Promise.all([rdmTraceCoverage, rdmTracePendingRecheck])`:任一命令缺失(较旧构建只有 coverage)即整面板降级,正式/候选覆盖数字一并消失 | 改 `Promise.allSettled`,coverage 可用即渲染,pending 不可用时只在待复核一栏给说明 |
| MINOR-4 | `src/components/RdmAuditWorkspace.tsx:1051, 1060` | `dict` 是 `state.dictionary` 的第二份异步状态镜像;切项目时旧项目词典保留到新页落地,可能把「新项目不存在的筛选值」静默带上,且 `<select>` 无匹配 option 时显示为空白 | 随 `pid` 变化清空 `dict`,或词典未就绪前禁用两个 select |
| MINOR-5 | `src/components/RdmAuditWorkspace.tsx:1090` | `.rdm-audit-ops` 在任何 CSS 中都无规则(本次只补了 `.rdm-audit-wrap`、`.rdm-audit-op .rdm-audit-opgroup-head/.rdm-audit-op-pending`、`.rdm-audit-op-object`),列表容器无布局约束 | 补一条容器规则或复用既有类名,避免长对象名/窄屏下出现不可预期的块级堆叠 |
| MINOR-6 | `e2e/rdm-audit.playwright.ts:150` | 整组 skip 条件 `SVC_REAL_RDM_BIN` 在 Dev `scripts/e2e.sh` 与本仓 CI 中均无注入(`grep SVC_REAL_RDM_BIN scripts/e2e.sh` 零命中;CI 只跑 `npm test` + `npm run build`)⇒ 4 条新用例(含游标翻页、游标失效重查、无授权三态)当前零执行证据 | 组合验证时由 e2e.sh 导出该变量(它已在 `$OUT/agent-system-rdm` 构建出二进制,`e2e.sh:88`),或在 PR 中把「e2e 未运行」显式登记为 NOT_RUN 缺口 |
| MINOR-7 | `src/components/RdmAuditWorkspace.tsx:591` | 知识多挂载降级只以代码 TODO 记录;计划 §W10 把「知识多挂载」列入实施范围(`rdm-two-trees-stage5-implementation-test-plan-r1.md:154`),缺登记的降级记录,容易在跨仓验收时被当作已交付 | 在 Dev 任务卡/证据条目登记该验收项为「待 RDM DTO 增补」,并回指此 TODO;代码内无需再改 |
## 5. 已核实的正面结论(附证据)
1. **DTO 边界真实成立**:`internal/rdmapp/inspection_visibility.go:70-88` 在无权时把 `change.summary.display` 置为「受限」、`disclosure='restricted'`、`object.current=nil`;前端 `OperationRow`(`:1125-1170`)只渲染服务端 `display/recorded`,`refsText` 只拼 `kind.label+id`,未渲染任何前端臆造的正文或补全的「当前」名称。
2. **契约字段与 RDM 实际 DTO 对齐**:`OperationRecordDTO`/`OperationPage`/`ImpactAnalysis`/`TraceCoverageResult`/`TracePendingEdge` 的 JSON tag 与 `src/api.ts:73-175` 的类型逐字段核对一致(含 `cutoffSeq/nextCursor/total`、`ObjectRef.revision` 为字符串、`CGap{rule,node,reason}`)。
3. **错误分类与服务端真实串匹配**:`权限拒绝:`(`perms_api.go:234`)→ `denied`;`操作记录游标已失效`(`operation_projection.go:42`)/`操作记录游标不合法`(`:454`)→ `cursor` 且隐藏翻页按钮并给「从第一页重新查询」。
4. **异步纪律的测试覆盖到位**:`createOperationsFeed` 的 seq、dispose、续页 `cutoffSeq`、迟到失败响应均有断言(`RdmAuditWorkspace.test.tsx:145-215`),且实现与断言一致(`request()` 成功/失败双路都比对 `mine === seq`)。
5. **未引入 knip 基线压力**:`node scripts/check-knip.mjs` → `knip unused exports 58 <= 58`;逐项核对 knip JSON,本次改动的 5 个文件未新增 unused exports/types。
6. **既有回归面检查通过**:既有 `操作记录` 页签(TimelineTab)与新 `审计记录` 页签按 aria 名定位互不冲突;既有 e2e 的 `.rdm-audit-card` 计数断言作用于概览页签,与新覆盖卡片不同时渲染;导出预览入口(`exportNeedsDraft`/`DocumentRenderWorkspace`)未改动。
## 6. 验证命令与结果(本工作区实跑)
| 命令 | 结果 |
|---|---|
| `git diff --stat ec1ced3 fa08be4` | 9 files, +1048/-7(评审对象确认) |
| `npm ci --offline --no-audit --no-fund` | 176 packages(仅本地缓存,未联网) |
| `npx vitest run test/design-guard.test.ts` | **PASS** — Test Files 1 passed, Tests 7 passed(① 门禁实测) |
| `npx vitest run` | 43 files passed / 22 files failed;398 tests passed, 1 failed。22 个失败文件与该 1 个失败全部为 `Error: Cannot find package '@ui'`(组合仓 `../ui` 未检出),无其他错误类别 |
| `npx vitest run src/components/RdmAuditWorkspace.test.tsx` | **NOT_RUN** — 同一 `@ui` 解析失败(新增单测在本工作区无法执行) |
| `npx tsc -b` | **NOT_RUN** — 全部为 `TS2307 Cannot find module '@ui'` 及其引发的隐式 any,无新增类型错误信号 |
| `node scripts/check-knip.mjs` | **PASS** — `knip unused exports 58 <= 58` |
| `npm run e2e` / `npx playwright test` | **NOT_RUN** — 无 `PLAYWRIGHT_BASE_URL` 且无浏览器/后端组合环境;`SVC_REAL_RDM_BIN` 未注入时新组按声明跳过 |
| 浅/深主题、窄屏、长对象名视觉检查 | **NOT_RUN** — 需真实组合浏览器;静态侧只能确认新增样式全部消费令牌 |
## 7. 合入建议
1. **合入门禁**:MAJOR-1、MAJOR-2 建议在合入前修复(前者是承诺失效,后者是能力面 fail-closed 破口);MAJOR-3 可修或登记豁免。
2. **证据缺口**:本单元的 vitest/tsc/e2e 在组合检出(`../ui` 就绪 + auth-on 真实 RDM)下必须补跑;在补跑前,本评审对新增单测与 e2e 的结论仅为静态核对,不得记为「测试通过」。
3. **范围声明**:知识多挂载(MINOR-7)按计划 §W10 属未完成项,验收矩阵中应保持「待 RDM DTO 增补」状态,不得因代码内 TODO 而计为已交付。
+78
View File
@@ -144,3 +144,81 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
expect(file.suggestedFilename()).toMatch(/\.docx$/) expect(file.suggestedFilename()).toMatch(/\.docx$/)
}) })
}) })
// W10 审计工作台(rdm.audit.operations + rdm.trace.coverage/pending-recheck)。
// 需真实组合:RDM 二进制含 W8b/W5 命令面(SVC_REAL_RDM_BIN 指向该构建)且 serve 以 auth on 运行;
// e2e.sh 默认从 RDM origin/main 构建,未必含这些命令——缺 SVC_REAL_RDM_BIN 时整组跳过,不伪造结果。
const OPS_ROUTE = '**/api/commands/rdm.audit.operations'
async function openOperations(page: Page) {
await login(page)
await openAudit(page)
await page.getByRole('tab', { name: '审计记录' }).click()
await expect(page.getByText('正在读取审计记录…')).toHaveCount(0, { timeout: 15_000 })
}
/** 改写请求体的 params(命令总线请求体为 { params, ... })。 */
const withParams = (raw: string | null, patch: Record<string, unknown>) => {
const body = JSON.parse(raw || '{}') as { params?: Record<string, unknown> }
return JSON.stringify({ ...body, params: { ...(body.params || {}), ...patch } })
}
test.describe.serial('W10 审计工作台:审计记录', () => {
test.skip(!process.env.SVC_REAL_RDM_BIN, '需真实 RDM 二进制(SVC_REAL_RDM_BIN,含 rdm.audit.operations / rdm.trace.*)与 auth-on 组合环境')
test('列表呈现业务摘要与受控覆盖,行展开见字段差异与来源链', async ({ page }) => {
await openOperations(page)
await expect(page.getByText('受控追溯覆盖').or(page.getByText(/受控覆盖统计暂不可用/))).toBeVisible()
const rows = page.locator('.rdm-audit-op')
await expect(rows.first()).toBeVisible()
await expect(page.getByText(/已显示 \d+ \/ 共 \d+ 条 · 截至水位 \d+/)).toBeVisible()
await rows.first().locator('.mk-col-trigger').click()
await expect(rows.first().getByText('字段差异')).toBeVisible()
await expect(rows.first().getByText('来源与确认链')).toBeVisible()
await expect(rows.first().getByText('影响(当时与当前)')).toBeVisible()
})
test('游标翻页:每页一条时加载更多追加下一条', async ({ page }) => {
await page.route(OPS_ROUTE, route => route.continue({ postData: withParams(route.request().postData(), { limit: 1 }) }))
await openOperations(page)
const rows = page.locator('.rdm-audit-op')
await expect(rows).toHaveCount(1)
const meta = await page.getByText(/已显示 \d+ \/ 共 \d+ 条/).textContent()
const total = Number(/共 (\d+) 条/.exec(meta || '')?.[1] || 0)
test.skip(total < 2, `种子项目仅 ${total} 条审计记录,无法验证翻页`)
await page.getByRole('button', { name: '加载更多记录' }).click()
await expect(rows).toHaveCount(2)
})
test('游标失效:续页报明确错误,可从第一页重新查询', async ({ page }) => {
await page.route(OPS_ROUTE, async route => {
const body = JSON.parse(route.request().postData() || '{}') as { params?: { cursor?: string } }
if (body.params?.cursor) {
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ receipt: { state: 'rejected', error: '操作记录游标已失效:游标绑定主体不符——请从首页重新查询' } }) })
return
}
const res = await route.fetch({ postData: withParams(route.request().postData(), { limit: 1 }) })
const json = await res.json() as { payload?: Record<string, unknown> } & Record<string, unknown>
const pageBody = (json.payload ?? json) as Record<string, unknown>
pageBody.hasMore = true
pageBody.nextCursor = 'stale-cursor'
await route.fulfill({ response: res, json })
})
await openOperations(page)
await page.getByRole('button', { name: '加载更多记录' }).click()
await expect(page.getByText(/翻页位置已失效/)).toBeVisible()
await expect(page.getByRole('button', { name: '加载更多记录' })).toHaveCount(0)
await page.getByRole('button', { name: '从第一页重新查询' }).click()
await expect(page.getByText(/翻页位置已失效/)).toHaveCount(0)
await expect(page.locator('.rdm-audit-op')).toHaveCount(1)
})
test('无审计授权:首页错误态可读', async ({ page }) => {
await page.route(OPS_ROUTE, route => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ receipt: { state: 'rejected', error: '权限拒绝:无项目审计授权' } }) }))
await openOperations(page)
await expect(page.getByText(/你没有该项目的审计授权/)).toBeVisible()
await expect(page.getByText('原始回执')).toBeVisible()
})
})
+122 -4
View File
@@ -70,6 +70,109 @@ export interface ExecEnvView {
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[] bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
} }
// ---- W8b 操作记录投影(rdm.audit.operations):服务端已裁剪的业务 DTO ----
// 缺项 recorded=false 且 display 为「当时未记录」;current 只是当前状态补出的名称(marker「当前」),
// 不冒充历史。change.disclosure=restricted 时摘要已被服务端替换为「受限」,页面不得假设有正文。
export interface RdmOperationLabel { key: string; label: string }
export interface RdmOperationText { value?: string; recorded: boolean; display: string }
export interface RdmOperationCurrent { value: string; marker: string }
interface RdmOperationRevision { value?: number; recorded: boolean; display: string }
export interface RdmOperationRef { kind: RdmOperationLabel; id: string }
export interface RdmOperationRefs { items: RdmOperationRef[]; recorded: boolean; display: string }
export interface RdmOperationRecord {
id: string
operation: RdmOperationLabel
actor: { name: RdmOperationText; duty: RdmOperationText; current?: RdmOperationCurrent }
initiatedAt: string
effectiveAt: { value?: string; recorded: boolean; display: string }
object: { recorded: boolean; kind: RdmOperationLabel; id?: string; display: string; current?: RdmOperationCurrent }
change: { before: RdmOperationRevision; after: RdmOperationRevision; summary: RdmOperationText; disclosure: string }
basis: { reason: RdmOperationText; evidence: RdmOperationRefs }
outcome: RdmOperationLabel
impact: { direct: RdmOperationRefs; indirect: RdmOperationRefs; analyses: RdmOperationRefs }
run: { recorded: boolean; runId?: string; stepId?: string; attempt?: number; display: string }
}
export interface RdmOperationDictionary { types: RdmOperationLabel[]; outcomes: RdmOperationLabel[]; kinds: RdmOperationLabel[] }
export interface RdmOperationPage {
items: RdmOperationRecord[]
nextCursor?: string
hasMore: boolean
cutoffSeq: number
total: number
dictionary: RdmOperationDictionary
}
/** 游标绑定项目、主体、授权 epoch、筛选与截止水位;续页须带回首页的 cutoffSeq。 */
export interface RdmOperationQuery {
targetKind?: string; targetId?: string
types?: string[]; outcomes?: string[]
from?: string; to?: string
cutoffSeq?: number; cursor?: string; limit?: number
}
// ---- W5 受控影响分析 / 覆盖 / 待复核(rdm.compliance.impact.* / rdm.trace.*) ----
export interface RdmObjectRef { projectId?: string; kind: string; id: string; revision?: string; anchor?: string }
/** 不可变影响快照:后续边变化不改写历史结果;重算生成新 ID。 */
export interface RdmImpactAnalysis {
id: string
start: RdmObjectRef
direct: RdmObjectRef[]
layers: RdmObjectRef[][]
involved: RdmObjectRef[]
truncated: boolean
cursor?: { frontier: RdmObjectRef[]; depth: number }
maxNodes: number
maxDepth: number
at: string
actor?: string
}
interface RdmImpactStart { startKind: string; startId: string; startRevision: number; maxNodes?: number; maxDepth?: number }
export interface RdmTraceGap { rule: number; node: string; reason: string }
/** confirmed 为正式覆盖;candidate 分列且不计正式覆盖。 */
export interface RdmTraceCoverage { confirmed: number; candidate: number; gaps: RdmTraceGap[] }
export interface RdmPendingRecheckEdge {
kind: string
from: string
to: string
fromOldRevision: string
fromNewRevision: string
toOldRevision: string
toNewRevision: string
state: string
source?: string
edgeId?: string
}
const emptyRefs = (refs?: RdmOperationRefs | null): RdmOperationRefs =>
refs ? { ...refs, items: refs.items ?? [] } : { items: [], recorded: false, display: '当时未记录' }
function normalizeOperationPage(page: RdmOperationPage): RdmOperationPage {
const dictionary = page.dictionary ?? { types: [], outcomes: [], kinds: [] }
return {
...page,
items: (page.items ?? []).map(r => ({
...r,
basis: { ...r.basis, evidence: emptyRefs(r.basis?.evidence) },
impact: { direct: emptyRefs(r.impact?.direct), indirect: emptyRefs(r.impact?.indirect), analyses: emptyRefs(r.impact?.analyses) },
})),
hasMore: Boolean(page.hasMore),
dictionary: { types: dictionary.types ?? [], outcomes: dictionary.outcomes ?? [], kinds: dictionary.kinds ?? [] },
}
}
function normalizeImpact(a: RdmImpactAnalysis): RdmImpactAnalysis {
return { ...a, direct: a.direct ?? [], layers: (a.layers ?? []).map(l => l ?? []), involved: a.involved ?? [] }
}
function operationParams(projectId: string, q: RdmOperationQuery): Record<string, unknown> {
const params: Record<string, unknown> = { projectId }
for (const [k, v] of Object.entries(q)) {
if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue
if (k === 'cutoffSeq' && !v) continue
params[k] = v
}
return params
}
async function checked(res: Response): Promise<Response> { async function checked(res: Response): Promise<Response> {
if (!res.ok) { if (!res.ok) {
const text = await res.text() const text = await res.text()
@@ -83,6 +186,8 @@ async function json<T>(res: Response): Promise<T> {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
export type ConversationView = Conversation & { /** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */ scratch?: boolean }
export const api = { export const api = {
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []), permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => { listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
@@ -117,13 +222,13 @@ export const api = {
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)), explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)), listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: Conversation }>(r)), createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: ConversationView }>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked), deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)), listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<ConversationView[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)), listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<ConversationView[]>(r)),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)), checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
@@ -189,7 +294,7 @@ export const api = {
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r => conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)), json<{ conversation: ConversationView; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) => runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal }) auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
@@ -297,6 +402,19 @@ export const api = {
if (envelope && envelope.payload !== undefined) return envelope.payload if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T return out as T
}), }),
rdmAuditOperations: (projectId: string, query: RdmOperationQuery = {}) =>
api.rdmCommand<RdmOperationPage>('rdm.audit.operations', operationParams(projectId, query)).then(normalizeOperationPage),
rdmImpactAnalyze: (projectId: string, start: RdmImpactStart) =>
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.analyze', { projectId, ...start }).then(normalizeImpact),
rdmImpactGet: (projectId: string, analysisId: string) =>
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.get', { projectId, analysisId }).then(normalizeImpact),
rdmImpactRecompute: (projectId: string, start: RdmImpactStart) =>
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.recompute', { projectId, ...start }).then(normalizeImpact),
rdmTracePendingRecheck: (projectId: string) =>
api.rdmCommand<{ edges?: RdmPendingRecheckEdge[] | null }>('rdm.trace.pending-recheck', { projectId }).then(r => r?.edges ?? []),
rdmTraceCoverage: (projectId: string) =>
api.rdmCommand<RdmTraceCoverage>('rdm.trace.coverage', { projectId })
.then(r => ({ confirmed: r?.confirmed ?? 0, candidate: r?.candidate ?? 0, gaps: r?.gaps ?? [] })),
} }
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ---- // ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
+12
View File
@@ -137,6 +137,18 @@
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; } .conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
.conversation-action-anchor > span:last-child { color: var(--accent-text); } .conversation-action-anchor > span:last-child { color: var(--accent-text); }
.conversation-action-anchor:hover { background: var(--bg-hover); } .conversation-action-anchor:hover { background: var(--bg-hover); }
.conversation-work-required { display: grid; gap: 8px; min-width: 0; }
.conversation-candidate { display: grid; gap: 8px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); font-size: var(--fs-ui-sm); min-width: 0; }
.conversation-candidate-head { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
.conversation-candidate-change { display: grid; gap: 6px; margin: 0; }
.conversation-candidate-change > div { display: grid; grid-template-columns: 4.5em minmax(0, 1fr); gap: 8px; }
.conversation-candidate-change dt { color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-candidate-change dd { margin: 0; color: var(--text); overflow-wrap: anywhere; }
.conversation-candidate .acard-actions { margin-top: 0; }
.conversation-basis { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 14px; border-bottom: 1px solid var(--border-soft); color: var(--text-dim); font-size: var(--fs-ui-caption); min-width: 0; }
.conversation-basis-label { color: var(--text); font-weight: 600; }
.conversation-basis-chip { padding: 2px 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--bg-panel); overflow-wrap: anywhere; }
.conversation-basis-warn { color: var(--warn); }
.work-companion-chat .live-bar { flex-wrap: wrap; } .work-companion-chat .live-bar { flex-wrap: wrap; }
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; } .work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
@media (max-width: 540px) { @media (max-width: 540px) {
+24 -2
View File
@@ -9,7 +9,7 @@ import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline }
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate' import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts' import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui' import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench' import { docBridge, openPendingConfirmation, pendingConfirmations } from './Workbench'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts' import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline' import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork' import { ConversationWork } from './ConversationWork'
@@ -335,15 +335,32 @@ function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
) )
} }
/** 本次实际依据:只列来源名称与 token 估算,不展开正文(正文不随观测下发)。 */
export function BasisStrip({ obs }: { obs: ContextObs }) {
const sources = obs.sections && obs.sections.length > 0
? obs.sections.map(s => ({ key: s.name, label: `${s.name} · 约 ${s.estTokens} token` }))
: obs.items.map(it => ({ key: it, label: it }))
if (sources.length === 0) return null
return (
<div className="conversation-basis" data-conversation-basis aria-label="本次实际依据">
<span className="conversation-basis-label">本次依据</span>
{sources.map(s => <span key={s.key} className="conversation-basis-chip">{s.label}</span>)}
<span>共约 {obs.estChars} token</span>
{obs.truncations && obs.truncations.length > 0 && <span className="conversation-basis-warn">有 {obs.truncations.length} 处内容被裁剪</span>}
</div>
)
}
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。 // 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) { function ContextObsView({ obs }: { obs: ContextObs }) {
return ( return (
<div className="process-collapse"> <div className="process-collapse">
<BasisStrip obs={obs} />
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300} <Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<> trigger={<>
<span className="process-collapse-chev">▸</span> <span className="process-collapse-chev">▸</span>
<span>🧩 上下文构成</span> <span>🧩 上下文构成</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span> <span className="process-collapse-meta">查看构成明细</span>
</>}> </>}>
<div className="body"> <div className="body">
<ul className="ui-list"> <ul className="ui-list">
@@ -551,6 +568,7 @@ function ConversationSession() {
// Background work stays in its own card and does not insert another chat turn. // Background work stays in its own card and does not insert another chat turn.
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion) const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
const pendingHere = pendingConfirmations(cards, currentConvId, currentProjectId)
useEffect(() => { useEffect(() => {
if (!stickRef.current) return if (!stickRef.current) return
@@ -712,6 +730,10 @@ function ConversationSession() {
<div className="input-bar"> <div className="input-bar">
<OutboxPanel /> <OutboxPanel />
{!workPaneOwnsCards && pendingHere > 0 && <button type="button" className="work-result-notice" data-pending-confirm
onClick={() => openPendingConfirmation(cards, currentConvId, currentProjectId)}>
<Icon name="message" size={14} /><span>{pendingHere} 项待你确认</span><span>在工作栏处理 ↗</span>
</button>}
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}> {unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span> <Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
</button>} </button>}
+50 -1
View File
@@ -5,6 +5,52 @@ import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts' import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord' import { WorkExecutionRecord } from './WorkExecutionRecord'
interface CandidateChange { key: string; before?: string; after: string; impact?: string }
/** 候选差异与影响:只取服务端已给出的发现字段(corrects 为更正前,text 为候选,impact 为影响),不读取正文。 */
export function candidateChanges(card: ActivityCard): CandidateChange[] {
if (card.status !== 'action_required') return []
return (card.task?.findings ?? [])
.filter(f => f.status === 'active' && (f.corrects || f.impact))
.map(f => ({ key: `${f.id}:${f.revision}`, before: f.corrects, after: f.text, impact: f.impact }))
}
const CONFIRM_IDS = new Set(['confirm', 'accept', 'approve'])
const REJECT_IDS = new Set(['reject', 'decline', 'deny'])
/** 确认/拒绝动作:沿用卡片自带 actions;非工作卡的动作行已由 ActivityCardView 渲染,这里只补工作卡。 */
export function candidateActions(card: ActivityCard) {
if (!card.work || card.status !== 'action_required') return []
return (card.actions ?? []).filter(a => CONFIRM_IDS.has(a.id) || REJECT_IDS.has(a.id))
.sort((a, b) => Number(REJECT_IDS.has(a.id)) - Number(REJECT_IDS.has(b.id)))
}
function CandidateReview({ card, locked, onAction }: { card: ActivityCard; locked: boolean; onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => void }) {
const changes = candidateChanges(card)
const actions = candidateActions(card)
if (changes.length === 0 && actions.length === 0) return null
return (
<div className="conversation-candidate" data-candidate-card={card.id}>
<div className="conversation-candidate-head">候选变更 · {card.title}</div>
{changes.map(c => (
<dl key={c.key} className="conversation-candidate-change">
{c.before && <div><dt>原内容</dt><dd>{c.before}</dd></div>}
<div><dt>{c.before ? '候选' : '候选内容'}</dt><dd>{c.after}</dd></div>
{c.impact && <div><dt>影响</dt><dd>{c.impact}</dd></div>}
</dl>
))}
{actions.length > 0 && (
<div className="acard-actions">
{actions.map(a => (
<button key={a.id} type="button" className={`btn${REJECT_IDS.has(a.id) ? ' ghost' : ''}`} disabled={locked}
onClick={() => { if (!locked) onAction(card, a.id, { resultVersion: card.work!.resultVersion }) }}>{a.label}</button>
))}
</div>
)}
</div>
)
}
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */ /** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() { export function ConversationWork() {
const cards = useStore(s => s.cards) const cards = useStore(s => s.cards)
@@ -65,7 +111,10 @@ export function ConversationWork() {
<section className="conversation-work" data-conversation-work aria-label="工作进展"> <section className="conversation-work" data-conversation-work aria-label="工作进展">
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div> <div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
<div className="conversation-work-current"> <div className="conversation-work-current">
{required.map(view)} {required.map(card => <div key={card.id} className="conversation-work-required">
<CandidateReview card={card} locked={actionStates?.[card.id] !== undefined} onAction={callbacks.onAction} />
{view(card)}
</div>)}
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen} {focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}> triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
{view(focused)} {view(focused)}
+391 -1
View File
@@ -1,6 +1,18 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace' import { setPersistedOpen } from '@ui'
import type { RdmImpactAnalysis, RdmOperationPage, RdmOperationQuery, RdmOperationRecord } from '../api'
import {
classifyOperationsError, createOperationsFeed, describeOperation, diffImpact, isLegacyOperation,
OperationRow, OperationsListView, startLatestFetch, TraceCoverageView, TreeNodeRow, visibleAuditTabs,
type OperationsFeedState,
} from './RdmAuditWorkspace'
import type { ActivityCard } from '../contracts'
import { store } from '../store'
import { RDM_AUDIT_COMMANDS, RDM_AUDIT_OPERATIONS_COMMANDS, rdmAuditOperationsReady } from '../workspaces'
import { BasisStrip } from './Conversation'
import { candidateActions, candidateChanges } from './ConversationWork'
import { openPendingConfirmation, pendingConfirmations } from './Workbench'
function deferred<T>() { function deferred<T>() {
let resolve!: (v: T) => void let resolve!: (v: T) => void
@@ -82,3 +94,381 @@ describe('TreeNodeRow 无障碍属性', () => {
expect(html).not.toContain('rdm-audit-tree-caret') expect(html).not.toContain('rdm-audit-tree-caret')
}) })
}) })
// ---- W10 审计记录 ----
const NOT_RECORDED = { recorded: false, display: '当时未记录' }
const refs = (items: { key: string; label: string; id: string }[]) => ({
items: items.map(i => ({ kind: { key: i.key, label: i.label }, id: i.id })),
recorded: items.length > 0,
display: items.length > 0 ? `共 ${items.length} 项` : '当时未记录',
})
function record(id: string, patch: Partial<RdmOperationRecord> = {}): RdmOperationRecord {
return {
id,
operation: { key: 'domain.mutate', label: '领域变更' },
actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: { value: '设计负责人', recorded: true, display: '设计负责人' } },
initiatedAt: '2026-10-20T08:00:00Z',
effectiveAt: { value: '2026-10-20T08:00:01Z', recorded: true, display: '2026-10-20T08:00:01Z' },
object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'S-1', display: '章节 S-1' },
change: {
before: { value: 1, recorded: true, display: 'r1' },
after: { value: 2, recorded: true, display: 'r2' },
summary: { value: '补充风险控制措施', recorded: true, display: '补充风险控制措施' },
disclosure: 'ok',
},
basis: { reason: { value: '评审意见', recorded: true, display: '评审意见' }, evidence: refs([{ key: 'slice', label: '笔记切片', id: 'SL-9' }]) },
outcome: { key: 'succeeded', label: '成功' },
impact: { direct: refs([{ key: 'doc', label: '文档', id: 'D-1' }]), indirect: refs([]), analyses: refs([{ key: 'impact-analysis', label: '影响分析', id: 'IA-1' }]) },
run: { recorded: true, runId: 'run-7', stepId: 'step-2', attempt: 1, display: '宿主可信回执' },
...patch,
}
}
function page(ids: string[], extra: Partial<RdmOperationPage> = {}): RdmOperationPage {
return {
items: ids.map(id => record(id)), hasMore: false, cutoffSeq: 42, total: ids.length,
dictionary: { types: [], outcomes: [], kinds: [] }, ...extra,
}
}
const READY: OperationsFeedState = {
phase: 'ready', items: [], nextCursor: '', hasMore: false, cutoffSeq: 42, total: 0,
dictionary: null, loadingMore: false, error: null,
}
const listHtml = (state: OperationsFeedState) =>
renderToStaticMarkup(<OperationsListView pid="p1" state={state} onMore={() => {}} onRestart={() => {}} />)
describe('classifyOperationsError', () => {
it('游标失效给出明确的从首页重查文案', () => {
const e = classifyOperationsError(new Error('操作记录游标已失效:游标绑定主体不符——请从首页重新查询'))
expect(e.kind).toBe('cursor')
expect(e.message).toContain('翻页位置已失效')
})
it('无审计授权与能力缺失分开呈现', () => {
expect(classifyOperationsError(new Error('权限拒绝:无项目审计授权')).kind).toBe('denied')
expect(classifyOperationsError(new Error('400: {"error":"unknown or disabled command"}')).kind).toBe('capability')
expect(classifyOperationsError(new Error('boom')).kind).toBe('query')
})
})
describe('createOperationsFeed(审计列表单一异步所有者)', () => {
it('首页加载后续页带回游标与截止水位并追加', async () => {
const calls: RdmOperationQuery[] = []
const feed = createOperationsFeed(async q => {
calls.push(q)
return q.cursor ? page(['b']) : page(['a'], { hasMore: true, nextCursor: 'c1', total: 2 })
})
feed.first()
await flush()
expect(feed.getState().items.map(r => r.id)).toEqual(['a'])
feed.more()
expect(feed.getState().loadingMore).toBe(true)
await flush()
expect(calls[1]).toEqual({ cursor: 'c1', cutoffSeq: 42 })
expect(feed.getState().items.map(r => r.id)).toEqual(['a', 'b'])
expect(feed.getState().hasMore).toBe(false)
})
it('按 seq 丢弃迟到响应:重查后旧请求的结果不写回', async () => {
const older = deferred<RdmOperationPage>()
const newer = deferred<RdmOperationPage>()
const queue = [older, newer]
const feed = createOperationsFeed(() => queue.shift()!.promise)
const seen = vi.fn()
feed.subscribe(seen)
feed.first()
feed.first()
newer.resolve(page(['new']))
await flush()
older.resolve(page(['old']))
await flush()
expect(feed.getState().items.map(r => r.id)).toEqual(['new'])
older.reject(new Error('late'))
await flush()
expect(feed.getState().error).toBeNull()
})
it('翻页途中重查:迟到的续页不混入新列表', async () => {
const more = deferred<RdmOperationPage>()
let n = 0
const feed = createOperationsFeed(q => {
n++
if (q.cursor) return more.promise
return Promise.resolve(n === 1 ? page(['a'], { hasMore: true, nextCursor: 'c1' }) : page(['fresh']))
})
feed.first()
await flush()
feed.more()
feed.first()
await flush()
more.resolve(page(['stale-b']))
await flush()
expect(feed.getState().items.map(r => r.id)).toEqual(['fresh'])
})
it('dispose 后在途响应被丢弃', async () => {
const pending = deferred<RdmOperationPage>()
const feed = createOperationsFeed(() => pending.promise)
feed.first()
feed.dispose()
pending.resolve(page(['late']))
await flush()
expect(feed.getState().phase).toBe('loading')
})
it('续页游标失效:保留已读记录并给出游标错误', async () => {
const feed = createOperationsFeed(async q => {
if (q.cursor) throw new Error('操作记录游标已失效:游标绑定项目不符')
return page(['a'], { hasMore: true, nextCursor: 'c1' })
})
feed.first()
await flush()
feed.more()
await flush()
const s = feed.getState()
expect(s.items.map(r => r.id)).toEqual(['a'])
expect(s.error?.kind).toBe('cursor')
expect(s.loadingMore).toBe(false)
})
})
describe('OperationsListView 状态文案', () => {
it('加载态', () => {
expect(listHtml({ ...READY, phase: 'loading' })).toContain('正在读取审计记录')
})
it('首页错误:无权限态可读且附原始回执', () => {
const html = listHtml({ ...READY, phase: 'error', error: classifyOperationsError(new Error('权限拒绝:x')) })
expect(html).toContain('你没有该项目的审计授权')
expect(html).toContain('原始回执')
expect(html).not.toContain('<details')
})
it('空列表', () => {
expect(listHtml(READY)).toContain('当前筛选下没有审计记录')
})
it('列表含业务摘要、计数与翻页按钮', () => {
const html = listHtml({ ...READY, items: [record('a')], total: 3, hasMore: true, nextCursor: 'c1' })
expect(html).toContain('张三')
expect(html).toContain('领域变更')
expect(html).toContain('章节 S-1')
expect(html).toContain('已显示 1 / 共 3 条 · 截至水位 42')
expect(html).toContain('加载更多记录')
})
it('续页游标失效:显示错误与从第一页重新查询,不再提供续页按钮', () => {
const html = listHtml({ ...READY, items: [record('a')], hasMore: true, nextCursor: 'c1', error: classifyOperationsError(new Error('操作记录游标已失效')) })
expect(html).toContain('翻页位置已失效')
expect(html).toContain('从第一页重新查询')
expect(html).not.toContain('加载更多记录')
})
})
describe('OperationRow 展开内容', () => {
const row = (r: RdmOperationRecord) => renderToStaticMarkup(<OperationRow pid="p1" record={r} defaultOpen />)
it('字段差异、来源/确认链、当时影响与证据跳转入口', () => {
const html = row(record('a'))
expect(html).toContain('字段差异')
expect(html).toContain('r1')
expect(html).toContain('r2')
expect(html).toContain('补充风险控制措施')
expect(html).toContain('来源与确认链')
expect(html).toContain('评审意见')
expect(html).toContain('运行 run-7')
expect(html).toContain('查看证据:笔记切片 SL-9')
expect(html).toContain('文档 D-1')
expect(html).toContain('查看影响分析 IA-1')
})
it('人物以当时名称为主,当前名称只带「当前」标记', () => {
const r = record('a', { actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: NOT_RECORDED, current: { value: '张三丰', marker: '当前' } } })
const v = describeOperation(r)
expect(v.who).toBe('张三')
expect(v.whoNow).toBe('当前:张三丰')
expect(row(r)).toContain('当前:张三丰')
})
it('旧数据:缺项显示「当时未记录」并标旧记录', () => {
const r = record('a', { basis: { reason: NOT_RECORDED, evidence: refs([]) }, change: { ...record('a').change, before: NOT_RECORDED } })
expect(isLegacyOperation(r)).toBe(true)
const html = row(r)
expect(html).toContain('旧记录')
expect(html).toContain('当时未记录')
})
it('无正文权:只显示服务端给出的「受限」元数据', () => {
const r = record('a', { change: { ...record('a').change, summary: { recorded: true, display: '受限' }, disclosure: 'restricted' } })
const html = row(r)
expect(html).toContain('正文受限')
expect(html).toContain('受限')
expect(html).not.toContain('补充风险控制措施')
})
it('长对象名可换行(不截断)', () => {
const long = '章节 ' + 'X'.repeat(200)
const html = row(record('a', { object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'X', display: long } }))
expect(html).toContain(long)
expect(html).toContain('class="rdm-audit-op-object"')
expect(html).toContain('rdm-audit-event-summary rdm-audit-wrap')
})
})
describe('证据与影响分析折叠(@ui Collapsible,稳定 id)', () => {
const row = () => renderToStaticMarkup(<OperationRow pid="p1" record={record('a')} defaultOpen />)
const trigger = (expanded: boolean, text: string) =>
new RegExp(`<button type="button" class="mk-col-trigger\\s*" aria-expanded="${expanded}"><span class="rdm-audit-err-toggle rdm-audit-wrap">${text}</span></button>`)
it('默认收起:触发钮由 Collapsible 渲染,详情未挂载(不发起读取)', () => {
const html = row()
expect(html).toMatch(trigger(false, '查看证据:笔记切片 SL-9'))
expect(html).toMatch(trigger(false, '查看影响分析 IA-1'))
expect(html).not.toContain('btn v-ghost rdm-audit-wrap')
expect(html).not.toContain('加载中…')
})
it('展开态按稳定 id 记忆:重挂载后恢复展开并挂载详情', () => {
setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', true)
setPersistedOpen('rdm-audit-impact:p1/IA-1', true)
try {
const html = row()
expect(html).toMatch(trigger(true, '查看证据:笔记切片 SL-9'))
expect(html).toMatch(trigger(true, '查看影响分析 IA-1'))
expect(html.match(/加载中…/g)).toHaveLength(2)
} finally {
setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', false)
setPersistedOpen('rdm-audit-impact:p1/IA-1', false)
}
})
})
describe('审计记录页签能力门禁', () => {
const enabled = (ids: readonly string[]) => ids.map(id => ({ id, enabled: true }))
it('五条 W8b/W5 命令齐备且启用才就绪;缺一、停用或清单不可用均不就绪', () => {
expect(RDM_AUDIT_OPERATIONS_COMMANDS).toHaveLength(5)
const all = enabled(RDM_AUDIT_OPERATIONS_COMMANDS)
expect(rdmAuditOperationsReady(all)).toBe(true)
for (const missing of RDM_AUDIT_OPERATIONS_COMMANDS) {
expect(rdmAuditOperationsReady(all.filter(c => c.id !== missing))).toBe(false)
expect(rdmAuditOperationsReady(all.map(c => (c.id === missing ? { ...c, enabled: false } : c)))).toBe(false)
}
expect(rdmAuditOperationsReady(null)).toBe(false)
})
it('只有 rdm.inspect.* 七命令的构建上不就绪', () => {
expect(rdmAuditOperationsReady(enabled(RDM_AUDIT_COMMANDS))).toBe(false)
})
it('能力缺失时不暴露「审计记录」页签,其余页签不受影响', () => {
expect(visibleAuditTabs(false).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录'])
expect(visibleAuditTabs(true).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录', '审计记录'])
})
})
describe('TraceCoverageView 部分覆盖', () => {
it('候选不计正式覆盖;缺项与待复核提示部分覆盖', () => {
const html = renderToStaticMarkup(<TraceCoverageView pid="p1" data={{
coverage: { confirmed: 3, candidate: 2, gaps: [{ rule: 4, node: 'UN-1@1', reason: '缺执行结果' }] },
pending: [{ kind: 'verifies', from: 'SR:1', to: 'VR:2', fromOldRevision: '1', fromNewRevision: '2', toOldRevision: '1', toNewRevision: '', state: 'pending-recheck', source: 'tracenet', edgeId: 'e1' }],
}} />)
expect(html).toContain('正式覆盖')
expect(html).toContain('候选(不计正式覆盖)')
expect(html).toContain('部分覆盖')
expect(html).toContain('缺项链(1)')
expect(html).toContain('待复核受控边(1)')
})
it('全部确认时不提示部分覆盖', () => {
const html = renderToStaticMarkup(<TraceCoverageView pid="p1" data={{ coverage: { confirmed: 5, candidate: 0, gaps: [] }, pending: [] }} />)
expect(html).not.toContain('部分覆盖')
})
})
describe('diffImpact(当时与当前影响)', () => {
const analysis = (involved: string[]): RdmImpactAnalysis => ({
id: 'x', start: { kind: 'UR', id: '1', revision: '1' }, direct: [], layers: [],
involved: involved.map(id => ({ kind: 'SR', id })), truncated: false, maxNodes: 100, maxDepth: 5, at: '2026-10-20T00:00:00Z',
})
it('按 kind:id 比较新增与不再涉及', () => {
const d = diffImpact(analysis(['a', 'b']), analysis(['b', 'c']))
expect(d.added.map(r => r.id)).toEqual(['c'])
expect(d.removed.map(r => r.id)).toEqual(['a'])
})
})
describe('对话侧:候选差异、确认路径与依据条', () => {
const card = (over: Partial<ActivityCard>): ActivityCard => ({
id: 'c1', projectId: 'p1', conversationId: 'v1', kind: 'progress', title: '追溯候选', status: 'action_required',
createdAt: '2026-10-20T00:00:00Z', updatedAt: '2026-10-20T00:00:00Z', ...over,
})
const finding = (over: Record<string, unknown>) => ({ id: 'f1', text: '需求 UR-1 由 SR-2 覆盖', kind: 'analysis', status: 'active', revision: 1, sources: [], ...over })
const task = (findings: unknown[]) => ({ findings } as unknown as ActivityCard['task'])
it('候选差异只取服务端发现字段:更正前、候选与影响', () => {
const changes = candidateChanges(card({ task: task([
finding({ corrects: '需求 UR-1 无覆盖', impact: '验证 VR-3 需复核' }),
finding({ id: 'f2', status: 'superseded', corrects: '旧' }),
finding({ id: 'f3' }),
]) }))
expect(changes).toEqual([{ key: 'f1:1', before: '需求 UR-1 无覆盖', after: '需求 UR-1 由 SR-2 覆盖', impact: '验证 VR-3 需复核' }])
})
it('非待处理卡不呈现候选差异', () => {
expect(candidateChanges(card({ status: 'done', task: task([finding({ impact: 'x' })]) }))).toEqual([])
})
it('确认/拒绝只为工作卡补齐(非工作卡动作行由卡片自身渲染),确认在前', () => {
const actions = [{ id: 'reject', label: '拒绝' }, { id: 'confirm', label: '确认' }, { id: 'recheck', label: '重新检查' }]
expect(candidateActions(card({ actions }))).toEqual([])
const work = { resultVersion: 2, readVersion: 2 }
expect(candidateActions(card({ actions, work })).map(a => a.id)).toEqual(['confirm', 'reject'])
expect(candidateActions(card({ actions, work, status: 'resolved' }))).toEqual([])
})
it('待确认计数只算本会话待处理卡,供右栏收起后的对话确认路径', () => {
const cards = [
card({ id: 'a' }),
card({ id: 'b', status: 'active', work: { resultVersion: 1, readVersion: 1, decision: { mode: 'single', state: 'open' } } }),
card({ id: 'c', conversationId: 'other' }),
card({ id: 'd', status: 'done' }),
]
expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2)
expect(pendingConfirmations(cards, 'v1', 'p2')).toBe(0)
})
it('右栏收起后的确认路径经 openInbox 定位首张待处理卡,与计数同一谓词(含决议开放的工作卡)', () => {
const openInbox = vi.spyOn(store, 'openInbox').mockImplementation(() => {})
try {
const decisionOpen = { resultVersion: 1, readVersion: 1, decision: { mode: 'single' as const, state: 'open' as const } }
const cards = [
card({ id: 'dismissed', status: 'dismissed' }),
card({ id: 'elsewhere', conversationId: 'other' }),
card({ id: 'decision', status: 'active', work: decisionOpen }),
card({ id: 'confirm' }),
]
expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2)
openPendingConfirmation(cards, 'v1', 'p1')
expect(openInbox).toHaveBeenLastCalledWith('decision')
openPendingConfirmation(cards.filter(c => c.id !== 'decision'), 'v1', 'p1')
expect(openInbox).toHaveBeenLastCalledWith('confirm')
} finally {
openInbox.mockRestore()
}
})
it('依据条列出来源与 token 摘要,不含正文', () => {
const html = renderToStaticMarkup(<BasisStrip obs={{
items: ['项目知识', '对话历史'], estChars: 1800, truncations: ['历史已裁剪'],
sections: [{ name: '项目知识', estTokens: 1200 }, { name: '对话历史', estTokens: 600 }],
}} />)
expect(html).toContain('本次依据')
expect(html).toContain('项目知识 · 约 1200 token')
expect(html).toContain('共约 1800 token')
expect(html).toContain('有 1 处内容被裁剪')
expect(html).not.toContain('历史已裁剪')
})
it('无分节时退回条目名;无来源时不渲染', () => {
expect(renderToStaticMarkup(<BasisStrip obs={{ items: ['系统提示'], estChars: 10 }} />)).toContain('系统提示')
expect(renderToStaticMarkup(<BasisStrip obs={{ items: [], estChars: 0 }} />)).toBe('')
})
})
+453 -9
View File
@@ -1,8 +1,12 @@
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。 // U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。 // 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。 // 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { api } from '../api' import {
api,
type RdmImpactAnalysis, type RdmObjectRef, type RdmOperationDictionary, type RdmOperationPage, type RdmOperationQuery,
type RdmOperationRecord, type RdmOperationRef, type RdmOperationRefs, type RdmPendingRecheckEdge, type RdmTraceCoverage,
} from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors' import { classifyRdmError } from '../rdmErrors'
import { import {
@@ -45,9 +49,15 @@ const TABS = [
{ id: 'materials', label: '资料与文档' }, { id: 'materials', label: '资料与文档' },
{ id: 'trace', label: '关联追溯' }, { id: 'trace', label: '关联追溯' },
{ id: 'timeline', label: '操作记录' }, { id: 'timeline', label: '操作记录' },
{ id: 'operations', label: '审计记录' },
] as const ] as const
type TabId = (typeof TABS)[number]['id'] type TabId = (typeof TABS)[number]['id']
/** 「审计记录」页签及其覆盖面板、影响分析入口只在 W8b/W5 命令面就绪时暴露(store.rdmAuditOperationsAvailable)。 */
export function visibleAuditTabs(operationsAvailable: boolean) {
return TABS.filter(t => t.id !== 'operations' || operationsAvailable)
}
const OPERATION_FILTERS = [ const OPERATION_FILTERS = [
['', '全部操作'], ['', '全部操作'],
['doc.instantiate', '创建文档实例'], ['doc.instantiate', '创建文档实例'],
@@ -102,6 +112,8 @@ export function RdmAuditWorkspace() {
const project = caps?.projects.find(p => p.projectId === pid) const project = caps?.projects.find(p => p.projectId === pid)
const exportOn = useStore(s => s.rdmExportAvailable) const exportOn = useStore(s => s.rdmExportAvailable)
const tabs = visibleAuditTabs(useStore(s => s.rdmAuditOperationsAvailable))
const activeTab: TabId = tabs.some(t => t.id === tab) ? tab : 'summary'
if (error) return <RdmErrorState raw={error} /> if (error) return <RdmErrorState raw={error} />
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div> if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState> if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
@@ -118,17 +130,18 @@ export function RdmAuditWorkspace() {
{caps.crossProject && <Chip tone="info">跨项目</Chip>} {caps.crossProject && <Chip tone="info">跨项目</Chip>}
</div> </div>
<div className="tabs rdm-audit-tabs" role="tablist"> <div className="tabs rdm-audit-tabs" role="tablist">
{TABS.map(t => ( {tabs.map(t => (
<button key={t.id} role="tab" aria-selected={tab === t.id} <button key={t.id} role="tab" aria-selected={activeTab === t.id}
className={`tab${tab === t.id ? ' active' : ''}`} className={`tab${activeTab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}>{t.label}</button> onClick={() => setTab(t.id)}>{t.label}</button>
))} ))}
</div> </div>
<div className="rdm-audit-body"> <div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />} {activeTab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />} {activeTab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />} {activeTab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />} {activeTab === 'timeline' && <TimelineTab pid={pid} />}
{activeTab === 'operations' && <OperationsTab key={pid} pid={pid} />}
</div> </div>
</div> </div>
) )
@@ -582,6 +595,7 @@ function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs
) )
} }
// TODO(W10):知识多挂载位置暂不显示——rdm.inspect.tree/object DTO 尚无挂载位置字段,待 RDM 增补后在本行与对象详情跟进。
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: { export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
item: TreeItem item: TreeItem
selectedId?: string selectedId?: string
@@ -918,3 +932,433 @@ function CoverageList({ cov }: { cov: CoverageItem[] }) {
</div> </div>
) )
} }
// ---- 审计记录(W10:rdm.audit.operations 正式审计列表 + W5 影响/覆盖/待复核) ----
// 只渲染服务端已裁剪的 DTO:「当时未记录」「当前」「受限」等文案由服务端给出,页面不补正文。
const fmtTime = (s?: string) => {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN')
}
export interface OperationsFeedError { kind: 'cursor' | 'denied' | 'capability' | 'auth' | 'query'; message: string; raw: string }
export function classifyOperationsError(err: unknown): OperationsFeedError {
const raw = err instanceof Error ? err.message : String(err)
if (/游标已失效|游标不合法/.test(raw)) {
return { kind: 'cursor', message: '翻页位置已失效:项目、登录身份或授权、筛选条件或截止水位已变化,不能接着上一页继续读。请从第一页重新查询。', raw }
}
if (/权限拒绝/.test(raw)) {
return { kind: 'denied', message: '你没有该项目的审计授权,审计记录不可查看。如需查看,请联系项目创建者或域管理员授权。', raw }
}
const c = classifyRdmError(raw)
return { kind: c.kind, message: c.message, raw }
}
export interface OperationsFeedState {
phase: 'loading' | 'ready' | 'error'
items: RdmOperationRecord[]
nextCursor: string
hasMore: boolean
cutoffSeq: number
total: number
dictionary: RdmOperationDictionary | null
loadingMore: boolean
error: OperationsFeedError | null
}
const FEED_LOADING: OperationsFeedState = {
phase: 'loading', items: [], nextCursor: '', hasMore: false, cutoffSeq: 0, total: 0,
dictionary: null, loadingMore: false, error: null,
}
function appendUnique(prev: RdmOperationRecord[], next: RdmOperationRecord[]) {
const seen = new Set(prev.map(r => r.id))
return [...prev, ...next.filter(r => !seen.has(r.id))]
}
/** 审计列表的唯一异步状态所有者:每次请求取新 seq,非最新 seq 的响应(含失败)一律丢弃。
* 续页带回首页的 cutoffSeq,保证翻页期间新写入不让已翻过的页面漂移。 */
export function createOperationsFeed(fetchPage: (q: RdmOperationQuery) => Promise<RdmOperationPage>) {
let seq = 0
let state = FEED_LOADING
const listeners = new Set<() => void>()
const set = (next: OperationsFeedState) => {
state = next
for (const l of listeners) l()
}
const request = (
q: RdmOperationQuery,
onPage: (p: RdmOperationPage) => OperationsFeedState,
onError: (e: OperationsFeedError) => OperationsFeedState,
) => {
const mine = ++seq
fetchPage(q).then(
p => { if (mine === seq) set(onPage(p)) },
e => { if (mine === seq) set(onError(classifyOperationsError(e))) },
)
}
return {
getState: () => state,
subscribe: (l: () => void) => {
listeners.add(l)
return () => { listeners.delete(l) }
},
first: () => {
set(FEED_LOADING)
request({},
p => ({ ...FEED_LOADING, phase: 'ready', items: p.items, nextCursor: p.nextCursor || '', hasMore: p.hasMore, cutoffSeq: p.cutoffSeq, total: p.total, dictionary: p.dictionary }),
error => ({ ...FEED_LOADING, phase: 'error', error }))
},
more: () => {
if (state.phase !== 'ready' || state.loadingMore || !state.hasMore || !state.nextCursor) return
set({ ...state, loadingMore: true, error: null })
request({ cursor: state.nextCursor, cutoffSeq: state.cutoffSeq },
p => ({ ...state, loadingMore: false, items: appendUnique(state.items, p.items), nextCursor: p.nextCursor || '', hasMore: p.hasMore, total: p.total }),
error => ({ ...state, loadingMore: false, error }))
},
dispose: () => { seq++ },
}
}
const OUTCOME_TONE: Record<string, 'ok' | 'warn' | 'danger' | 'plain'> = {
succeeded: 'ok', rejected: 'danger', failed: 'danger', no_effect: 'plain', unknown_delay: 'warn',
}
/** 旧记录:服务端以 recorded=false 标出的缺项(显示「当时未记录」,不代表为空)。 */
export function isLegacyOperation(r: RdmOperationRecord): boolean {
return !r.actor.name.recorded || !r.object.recorded || !r.change.before.recorded || !r.change.after.recorded
|| !r.change.summary.recorded || !r.basis.reason.recorded
}
/** 业务中文摘要:当时名称为主;「当前」名称只作附注,不冒充历史。 */
export function describeOperation(r: RdmOperationRecord) {
const who = r.actor.name.display
const whoNow = r.actor.current && r.actor.current.value !== r.actor.name.value
? `${r.actor.current.marker}:${r.actor.current.value}` : ''
const object = r.object.display
const objectNow = r.object.current ? `${r.object.current.marker}:${r.object.current.value}` : ''
const when = fmtTime(r.initiatedAt)
return {
who, whoNow, object, objectNow, when,
action: r.operation.label,
outcome: r.outcome.label,
sentence: `${who} 于 ${when} 对「${object}」执行「${r.operation.label}」,结果:${r.outcome.label}`,
}
}
const refsText = (refs: RdmOperationRefs) =>
refs.recorded && refs.items.length > 0 ? refs.items.map(x => `${x.kind.label} ${x.id}`).join('、') : refs.display
function OperationsTab({ pid }: { pid: string }) {
const identity = useStore(() => store.getIdentityEpoch())
const [outcome, setOutcome] = useState('')
const [type, setType] = useState('')
const [dict, setDict] = useState<RdmOperationDictionary | null>(null)
const feed = useMemo(() => createOperationsFeed(q => api.rdmAuditOperations(pid, {
...q, limit: 30, outcomes: outcome ? [outcome] : undefined, types: type ? [type] : undefined,
})), [pid, outcome, type, identity])
useEffect(() => {
feed.first()
return feed.dispose
}, [feed])
const state = useSyncExternalStore(feed.subscribe, feed.getState, feed.getState)
useEffect(() => { if (state.dictionary) setDict(state.dictionary) }, [state.dictionary])
return (
<div className="rdm-audit-timeline">
<TraceCoveragePanel pid={pid} />
<SectionLabel>审计记录</SectionLabel>
<div className="rdm-audit-timeline-filters">
<select className="ui-input" aria-label="按结果筛选" value={outcome} onChange={e => setOutcome(e.target.value)}>
<option value="">全部结果</option>
{dict?.outcomes.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
</select>
<select className="ui-input" aria-label="按操作类型筛选" value={type} onChange={e => setType(e.target.value)}>
<option value="">全部类型</option>
{dict?.types.map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</div>
<OperationsListView pid={pid} state={state} onMore={feed.more} onRestart={feed.first} />
</div>
)
}
export function OperationsListView({ pid, state, onMore, onRestart }: {
pid: string
state: OperationsFeedState
onMore: () => void
onRestart: () => void
}) {
if (state.phase === 'loading') return <div className="empty-note">正在读取审计记录…</div>
if (state.phase === 'error' && state.error) return <OperationsErrorView error={state.error} onRestart={onRestart} />
return (
<div className="rdm-audit-ops">
{state.items.length === 0 && <EmptyState icon="clock">当前筛选下没有审计记录</EmptyState>}
{state.items.map(r => <OperationRow key={r.id} pid={pid} record={r} />)}
{state.items.length > 0 && (
<div className="rdm-audit-meta">已显示 {state.items.length} / 共 {state.total} 条 · 截至水位 {state.cutoffSeq}</div>
)}
{state.error && <OperationsErrorView error={state.error} onRestart={onRestart} />}
{state.hasMore && !state.error && (
<button type="button" className="btn v-ghost rdm-audit-more" disabled={state.loadingMore} onClick={onMore}>
{state.loadingMore ? '正在加载…' : '加载更多记录'}
</button>
)}
</div>
)
}
function OperationsErrorView({ error, onRestart }: { error: OperationsFeedError; onRestart: () => void }) {
return (
<div className="rdm-audit-err" role="alert">
<EmptyState icon={error.kind === 'denied' ? 'shield' : 'alert'}>{error.message}</EmptyState>
{(error.kind === 'cursor' || error.kind === 'query') && (
<button type="button" className="btn v-ghost" onClick={onRestart}>{error.kind === 'cursor' ? '从第一页重新查询' : '重试'}</button>
)}
<Collapsible id="rdm-audit-ops-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
<code className="rdm-audit-err-raw">{error.raw}</code>
</Collapsible>
</div>
)
}
export function OperationRow({ pid, record, defaultOpen }: { pid: string; record: RdmOperationRecord; defaultOpen?: boolean }) {
const view = describeOperation(record)
const legacy = isLegacyOperation(record)
const restricted = record.change.disclosure === 'restricted'
const { actor, change, basis, impact, run, effectiveAt } = record
return (
<div className="rdm-audit-opgroup rdm-audit-op" data-operation-id={record.id}>
<Collapsible id={`rdm-audit-op-${pid}-${record.id}`} defaultOpen={defaultOpen} trigger={(
<span className="rdm-audit-opgroup-head">
<span className="rdm-audit-opgroup-actor">{view.who}</span>
{view.whoNow && <Chip tone="plain">{view.whoNow}</Chip>}
<span className="rdm-audit-opgroup-action">{view.action}</span>
<span className="rdm-audit-op-object">{view.object}</span>
{view.objectNow && <Chip tone="plain">{view.objectNow}</Chip>}
<span className="rdm-audit-opgroup-time">{view.when}</span>
<Chip tone={OUTCOME_TONE[record.outcome.key] ?? 'plain'}>{view.outcome}</Chip>
{legacy && <Chip tone="warn">旧记录</Chip>}
{restricted && <Chip tone="plain">正文受限</Chip>}
</span>
)}>
<div className="rdm-audit-event">
<div className="rdm-audit-event-summary rdm-audit-wrap">{view.sentence}</div>
{legacy && <div className="rdm-audit-truncated">这是旧记录:标为「当时未记录」的字段在操作发生时没有记录,不代表为空或为零。</div>}
{restricted && <div className="rdm-audit-truncated">正文受限:你当前没有该对象的正文权(跨项目、无权或已撤权),这里只显示元数据。</div>}
<SectionLabel>字段差异</SectionLabel>
<dl className="rdm-audit-event-fields">
<div><dt>修改前</dt><dd>{change.before.display}</dd></div>
<div><dt>修改后</dt><dd>{change.after.display}</dd></div>
<div><dt>差异摘要</dt><dd>{change.summary.display}</dd></div>
</dl>
<SectionLabel>来源与确认链</SectionLabel>
<dl className="rdm-audit-event-fields">
<div><dt>操作人(当时)</dt><dd>{actor.name.display}{actor.duty.recorded ? ` · ${actor.duty.display}` : ''}{view.whoNow ? `(${view.whoNow})` : ''}</dd></div>
<div><dt>依据原因</dt><dd>{basis.reason.display}</dd></div>
<div><dt>执行回执</dt><dd>{run.display}{run.runId ? ` · 运行 ${run.runId}` : ''}{run.stepId ? ` · 步骤 ${run.stepId}` : ''}{run.attempt ? ` · 第 ${run.attempt} 次尝试` : ''}</dd></div>
<div><dt>发起时间</dt><dd>{view.when}</dd></div>
<div><dt>生效时间</dt><dd>{effectiveAt.recorded && effectiveAt.value ? fmtTime(effectiveAt.value) : effectiveAt.display}</dd></div>
<div><dt>对象路径</dt><dd>{view.object}{view.objectNow ? `(${view.objectNow})` : ''}</dd></div>
</dl>
<EvidenceList pid={pid} refs={basis.evidence} />
<SectionLabel>影响(当时与当前)</SectionLabel>
<dl className="rdm-audit-event-fields">
<div><dt>直接影响(当时)</dt><dd>{refsText(impact.direct)}</dd></div>
<div><dt>间接影响(当时)</dt><dd>{refsText(impact.indirect)}</dd></div>
<div><dt>受控影响分析</dt><dd>{impact.analyses.display}</dd></div>
</dl>
{impact.analyses.items.map(a => <ImpactAnalysisBlock key={a.id} pid={pid} analysisId={a.id} />)}
</div>
</Collapsible>
</div>
)
}
function EvidenceList({ pid, refs }: { pid: string; refs: RdmOperationRefs }) {
return (
<div className="rdm-audit-detail-body">
<SectionLabel>依据证据</SectionLabel>
{refs.items.length === 0
? <div className="empty-note">{refs.display}</div>
: refs.items.map(r => <EvidenceLink key={`${r.kind.key}:${r.id}`} pid={pid} evidence={r} />)}
</div>
)
}
/** 证据跳转:展开时才读取对象详情;服务端按当前正文权判定,无权时只给出可读拒绝,不假设有正文。 */
function EvidenceLink({ pid, evidence }: { pid: string; evidence: RdmOperationRef }) {
const label = `${evidence.kind.label} ${evidence.id}`
return (
<Collapsible id={`rdm-audit-evidence:${pid}/${evidence.kind.key}/${evidence.id}`}
trigger={<span className="rdm-audit-err-toggle rdm-audit-wrap">{`查看证据:${label}`}</span>}>
<EvidenceDetail pid={pid} evidence={evidence} label={label} />
</Collapsible>
)
}
function EvidenceDetail({ pid, evidence, label }: { pid: string; evidence: RdmOperationRef; label: string }) {
const [detail, setDetail] = useState<ObjectDetail | null>(null)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
setDetail(null); setErr('')
return startLatestFetch(genRef,
() => api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: evidence.kind.key, id: evidence.id }),
setDetail, setErr)
}, [pid, evidence.kind.key, evidence.id])
if (err) {
return /权限拒绝|无权|受限/.test(err)
? <div className="rdm-audit-truncated">无权查看该证据(跨项目、无权或已撤权),仅保留元数据:{label}</div>
: <RdmErrorState raw={err} />
}
return !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />
}
const refKey = (r: RdmObjectRef) => `${r.kind}:${r.id}`
const refLabel = (r: RdmObjectRef) => `${r.kind} ${r.id}${r.revision ? ` r${r.revision}` : ''}`
/** 当时快照与按当前受控图重算结果的差异(按对象 kind:id 比较)。 */
export function diffImpact(then: RdmImpactAnalysis, now: RdmImpactAnalysis) {
const thenKeys = new Set(then.involved.map(refKey))
const nowKeys = new Set(now.involved.map(refKey))
return {
added: now.involved.filter(r => !thenKeys.has(refKey(r))),
removed: then.involved.filter(r => !nowKeys.has(refKey(r))),
}
}
function ImpactSnapshotView({ title, a }: { title: string; a: RdmImpactAnalysis }) {
return (
<div className="rdm-audit-detail-body">
<SectionLabel>{title}</SectionLabel>
<div className="rdm-audit-wrap">起点 {refLabel(a.start)} · 分析 {a.id} · {fmtTime(a.at)}{a.actor ? ` · ${a.actor}` : ''}</div>
<div>直接影响 {a.direct.length} 项 · 共涉及 {a.involved.length} 项 · {a.layers.length} 层</div>
{a.direct.length > 0 && <div className="rdm-audit-wrap">直接:{a.direct.map(refLabel).join('、')}</div>}
{a.truncated && <div className="rdm-audit-truncated">部分覆盖:已达上限({a.maxNodes} 个对象 / {a.maxDepth} 层)截断,结果不完整</div>}
</div>
)
}
/** 当时与当前影响:展开时才读取不可变快照;重算须显式触发(生成新分析快照,不覆盖历史)。 */
function ImpactAnalysisBlock({ pid, analysisId }: { pid: string; analysisId: string }) {
return (
<div className="rdm-audit-detail-body">
<Collapsible id={`rdm-audit-impact:${pid}/${analysisId}`}
trigger={<span className="rdm-audit-err-toggle rdm-audit-wrap">{`查看影响分析 ${analysisId}`}</span>}>
<ImpactAnalysisDetail pid={pid} analysisId={analysisId} />
</Collapsible>
</div>
)
}
function ImpactAnalysisDetail({ pid, analysisId }: { pid: string; analysisId: string }) {
const [snapshot, setSnapshot] = useState<RdmImpactAnalysis | null>(null)
const [current, setCurrent] = useState<RdmImpactAnalysis | null>(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const snapRef = useRef(0)
const currentRef = useRef(0)
useEffect(() => {
setSnapshot(null); setErr('')
return startLatestFetch(snapRef, () => api.rdmImpactGet(pid, analysisId), setSnapshot, setErr)
}, [pid, analysisId])
useEffect(() => () => { currentRef.current++ }, [pid, analysisId])
const revision = Number(snapshot?.start.revision)
const recompute = () => {
if (!snapshot || !(revision > 0)) return
setBusy(true); setErr('')
startLatestFetch(currentRef,
() => api.rdmImpactRecompute(pid, { startKind: snapshot.start.kind, startId: snapshot.start.id, startRevision: revision, maxNodes: snapshot.maxNodes, maxDepth: snapshot.maxDepth }),
a => { setCurrent(a); setBusy(false) },
e => { setErr(e); setBusy(false) })
}
const diff = snapshot && current ? diffImpact(snapshot, current) : null
return (
<>
{err && <RdmErrorState raw={err} />}
{!snapshot && !err && <div className="empty-note">加载中…</div>}
{snapshot && <ImpactSnapshotView title="当时影响(不可变快照)" a={snapshot} />}
{snapshot && revision > 0 && (
<button type="button" className="btn v-ghost" disabled={busy} onClick={recompute}>
{busy ? '正在重算…' : '按当前受控图重算(生成新快照,不覆盖历史)'}
</button>
)}
{current && <ImpactSnapshotView title="当前影响" a={current} />}
{diff && (
<div className="rdm-audit-detail-body rdm-audit-wrap">
<div>当前新增:{diff.added.length ? diff.added.map(refLabel).join('、') : '无'}</div>
<div>当前不再涉及:{diff.removed.length ? diff.removed.map(refLabel).join('、') : '无'}</div>
</div>
)}
</>
)
}
interface TraceCoverageData { coverage: RdmTraceCoverage; pending: RdmPendingRecheckEdge[] }
const PENDING_STATE_LABELS: Record<string, string> = { 'pending-recheck': '待复核', stale: '已过期待复核' }
function TraceCoveragePanel({ pid }: { pid: string }) {
const [data, setData] = useState<TraceCoverageData | null>(null)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
setData(null); setErr('')
return startLatestFetch(genRef,
() => Promise.all([api.rdmTraceCoverage(pid), api.rdmTracePendingRecheck(pid)]).then(([coverage, pending]) => ({ coverage, pending })),
setData, setErr)
}, [pid])
if (err) return <div className="rdm-audit-truncated">受控覆盖统计暂不可用:{classifyOperationsError(err).message}</div>
if (!data) return <div className="empty-note">正在读取受控覆盖…</div>
return <TraceCoverageView pid={pid} data={data} />
}
export function TraceCoverageView({ pid, data }: { pid: string; data: TraceCoverageData }) {
const { coverage, pending } = data
const partial = coverage.gaps.length > 0 || coverage.candidate > 0 || pending.length > 0
const cards: [string, number][] = [
['正式覆盖', coverage.confirmed],
['候选(不计正式覆盖)', coverage.candidate],
['待复核', pending.length],
['缺项链', coverage.gaps.length],
]
return (
<div className="rdm-audit-coverage">
<SectionLabel>受控追溯覆盖</SectionLabel>
<div className="rdm-audit-cards">
{cards.map(([label, n]) => (
<div key={label} className="rdm-audit-card">
<div className="rdm-audit-card-num">{n}</div>
<div className="rdm-audit-card-label">{label}</div>
</div>
))}
</div>
{partial && <div className="rdm-audit-truncated">部分覆盖:存在候选、待复核或缺项链,正式覆盖只计已确认的受控边。</div>}
{coverage.gaps.length > 0 && (
<Collapsible id={`rdm-audit-gaps-${pid}`} trigger={<span className="rdm-audit-err-toggle">缺项链({coverage.gaps.length})</span>}>
{coverage.gaps.map((g, i) => (
<div key={i} className="rdm-audit-coverage-item rdm-audit-wrap">
<Chip tone="warn">规则 {g.rule}</Chip><span className="rdm-audit-wrap">{g.node} · {g.reason}</span>
</div>
))}
</Collapsible>
)}
{pending.length > 0 && (
<Collapsible id={`rdm-audit-pending-${pid}`} trigger={<span className="rdm-audit-err-toggle">待复核受控边({pending.length})</span>}>
{pending.map((e, i) => (
<div key={e.edgeId || i} className="rdm-audit-rel rdm-audit-op-pending">
<Chip tone="warn">{PENDING_STATE_LABELS[e.state] ?? humanizeState(e.state)}</Chip>
<span className="rdm-audit-rel-type">{e.kind}</span>
<span className="rdm-audit-wrap">{e.from}(r{e.fromOldRevision} → r{e.fromNewRevision || '?'})⇢ {e.to}(r{e.toOldRevision} → r{e.toNewRevision || '?'})</span>
</div>
))}
</Collapsible>
)}
</div>
)
}
+3 -4
View File
@@ -3,9 +3,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store' import { store, useStore } from '../store'
import { auth } from '../auth' import { auth } from '../auth'
import { api } from '../api' import { api, type ConversationView } from '../api'
import { Icon } from '@ui' import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist' import { relativeTime, filterTasks } from '../tasklist'
import { import {
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError, canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
@@ -96,7 +95,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试' const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null : syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
const rowActions = (t: Conversation) => ( const rowActions = (t: ConversationView) => (
<span className="tsb-row-acts"> <span className="tsb-row-acts">
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button> <button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}> <button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
@@ -118,7 +117,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
onConversationOpen?.() onConversationOpen?.()
} }
const taskRow = (t: Conversation) => ( const taskRow = (t: ConversationView) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`} <div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}> onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}> <button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
+20 -1
View File
@@ -34,6 +34,23 @@ const scratchInboxCallbacks = {
} }
const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) } const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) }
type Scope = string | null | undefined
/** 本会话仍待用户处理的卡片:计数与定位共用这一谓词。 */
const isPendingConfirmation = (card: ActivityCard, conversationId: Scope, projectId: Scope) =>
card.status !== 'dismissed' && card.conversationId === conversationId && card.projectId === projectId
&& (card.status === 'action_required' || card.work?.decision?.state === 'open')
/** 本会话仍待用户处理的卡片数:右栏收起后由对话输入区提示承接确认路径。 */
export function pendingConfirmations(cards: ActivityCard[], conversationId: Scope, projectId: Scope): number {
return cards.filter(card => isPendingConfirmation(card, conversationId, projectId)).length
}
/** 右栏收起或不在「工作」页签时的确认路径:经 openInbox 展开右栏、切到工作页签并定位首张待处理卡。 */
export function openPendingConfirmation(cards: ActivityCard[], conversationId: Scope, projectId: Scope) {
store.openInbox(cards.find(card => isPendingConfirmation(card, conversationId, projectId))?.id)
}
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */ /** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) { function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
const own = cards.filter(c => !c.conversationId) const own = cards.filter(c => !c.conversationId)
@@ -72,6 +89,8 @@ export function Workbench() {
const visible = inboxScope === 'project' ? projectCards : cards const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible) const pending = pendingCount(visible)
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
const pendingHere = useStore(s => pendingConfirmations(s.cards, s.currentConvId, s.currentProjectId))
const collapseHint = pendingHere > 0 ? `收起右栏(${pendingHere} 项待确认可从对话输入区的提示回到工作栏处理)` : '收起右栏'
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。 // 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') => const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false }) store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
@@ -86,7 +105,7 @@ export function Workbench() {
{rightTab === 'work' && ( {rightTab === 'work' && (
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button> <button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
)} )}
<button className="tab" title="收起右栏" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button> <button className="tab" title={collapseHint} aria-label={collapseHint} onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div> </div>
{rightTab === 'files' {rightTab === 'files'
? <FilesTab /> ? <FilesTab />
-2
View File
@@ -18,8 +18,6 @@ export interface Conversation {
allowCrossRead?: boolean; crossPrivate?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean;
permissionMode?: 'default' | 'plan' | 'auto_edit' | ''; permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
sessionNote?: string; sessionNote?: string;
/** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */
scratch?: boolean;
createdAt: string; updatedAt: string; createdAt: string; updatedAt: string;
} }
+4 -3
View File
@@ -4,10 +4,11 @@ import {
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS, PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus, createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
} from './store' } from './store'
import type { Conversation, Project } from './contracts' import type { ConversationView } from './api'
import type { Project } from './contracts'
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' }) const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
const task = (id: string, projectId: string): Conversation => ({ id, projectId, title: id, createdAt: '', updatedAt: '' }) const task = (id: string, projectId: string): ConversationView => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
function deferred<T>() { function deferred<T>() {
let resolve!: (value: T) => void let resolve!: (value: T) => void
@@ -39,7 +40,7 @@ function fakeEnv() {
} }
let projects: Project[] let projects: Project[]
let tasks: Conversation[] let tasks: ConversationView[]
let requests: { path: string; init?: RequestInit }[] let requests: { path: string; init?: RequestInit }[]
let route: (path: string, init?: RequestInit) => Promise<Response> let route: (path: string, init?: RequestInit) => Promise<Response>
+13 -8
View File
@@ -1,7 +1,7 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。 // 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api' import { type AdminZoneSection, type ConversationView, type LinkedCapabilities, api, connectEvents } from './api'
import { auth } from './auth' import { auth } from './auth'
import { noProjectError, projectActionError, projectWorkError } from './projectmeta' import { noProjectError, projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live' import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
@@ -11,7 +11,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync' import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces' import { cockpitReady, rdmAuditOperationsReady, rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement' import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation' import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
@@ -26,7 +26,6 @@ import type {
TaskSourceRef, TaskSourceRef,
AgentProfile, AgentProfile,
Artifact, Artifact,
Conversation,
HITLCard, HITLCard,
Message, Message,
PlanSnapshot, PlanSnapshot,
@@ -116,7 +115,7 @@ interface AppState {
currentProjectId: string | null currentProjectId: string | null
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */ /** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
scratchMode: boolean scratchMode: boolean
tasks: Conversation[] tasks: ConversationView[]
me: Me | null me: Me | null
currentConvId: string | null currentConvId: string | null
messages: Message[] messages: Message[]
@@ -148,6 +147,8 @@ interface AppState {
rdmCockpitAvailable: boolean rdmCockpitAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */ /** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean rdmExportAvailable: boolean
/** 「审计记录」页签(W10):五条 W8b/W5 命令都启用才为 true。不参与七命令就绪门。 */
rdmAuditOperationsAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */ /** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
rdmRolesAvailable: boolean rdmRolesAvailable: boolean
rdmMembersAvailable: boolean rdmMembersAvailable: boolean
@@ -227,6 +228,7 @@ let state: AppState = {
rdmAuditAvailable: false, rdmAuditAvailable: false,
rdmCockpitAvailable: false, rdmCockpitAvailable: false,
rdmExportAvailable: false, rdmExportAvailable: false,
rdmAuditOperationsAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES, rdmRender: NO_RENDER_CAPABILITIES,
adminSections: [], adminSections: [],
@@ -376,9 +378,9 @@ async function bindProject(projectId: string, remember: boolean) {
await store.refreshArtifacts(projectId) await store.refreshArtifacts(projectId)
} }
function latestScratchTask(): Conversation | undefined { function latestScratchTask(): ConversationView | undefined {
return state.tasks.filter(t => t.scratch && !t.archived) return state.tasks.filter(t => t.scratch && !t.archived)
.reduce<Conversation | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined) .reduce<ConversationView | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
} }
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) { function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
@@ -659,7 +661,8 @@ export const store = {
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [], runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmAuditOperationsAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
@@ -1466,6 +1469,7 @@ export const store = {
rdmAuditAvailable: rdmAuditReady(cmds), rdmAuditAvailable: rdmAuditReady(cmds),
rdmCockpitAvailable: cockpitReady(cmds), rdmCockpitAvailable: cockpitReady(cmds),
rdmExportAvailable: rdmExportReady(cmds), rdmExportAvailable: rdmExportReady(cmds),
rdmAuditOperationsAvailable: rdmAuditOperationsReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds), rdmDocReviewAvailable: rdmDutyReady(cmds),
@@ -1474,7 +1478,8 @@ export const store = {
}) })
} catch { } catch {
if (epoch !== identityEpoch) return if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmAuditOperationsAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES }) rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
} }
try { try {
+3
View File
@@ -669,6 +669,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); } .rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); } .rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); } .rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-wrap { overflow-wrap: anywhere; min-width: 0; }
.rdm-audit-op .rdm-audit-opgroup-head, .rdm-audit-op-pending { flex-wrap: wrap; }
.rdm-audit-op-object { color: var(--text); overflow-wrap: anywhere; min-width: 0; }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; } .rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); } .rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; } .rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
+15
View File
@@ -54,6 +54,21 @@ export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true)) return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
} }
/** 「审计记录」页签(W10)消费的 W8b/W5 命令:操作记录列表、受控覆盖面板、影响分析快照与重算。不并入七命令就绪门。 */
export const RDM_AUDIT_OPERATIONS_COMMANDS = [
'rdm.audit.operations',
'rdm.trace.coverage',
'rdm.trace.pending-recheck',
'rdm.compliance.impact.get',
'rdm.compliance.impact.recompute',
] as const
/** 五命令齐备且 enabled 才展示「审计记录」页签(能力缺失即不暴露,避免进入即 400)。 */
export function rdmAuditOperationsReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_AUDIT_OPERATIONS_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */ /** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */
export const COCKPIT_COMMANDS = [ export const COCKPIT_COMMANDS = [
'rdm.cockpit.overview', 'rdm.cockpit.overview',
+5 -4
View File
@@ -1,16 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { auth } from '../src/auth' import { auth } from '../src/auth'
import { canCreateProject, store } from '../src/store' import { canCreateProject, store } from '../src/store'
import type { Conversation, Project } from '../src/contracts' import type { ConversationView } from '../src/api'
import type { Project } from '../src/contracts'
const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' }) const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' })
const task = (id: string, projectId: string, scratch = false, updatedAt = ''): Conversation => const task = (id: string, projectId: string, scratch = false, updatedAt = ''): ConversationView =>
({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) }) ({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) })
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
const detail = (conversation: Conversation) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }) const detail = (conversation: ConversationView) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 })
let projects: Project[] let projects: Project[]
let tasks: Conversation[] let tasks: ConversationView[]
let me: Record<string, unknown> let me: Record<string, unknown>
let requests: { path: string; init?: RequestInit }[] let requests: { path: string; init?: RequestInit }[]
let scratchCount: number let scratchCount: number