diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c29856599..89b17ab27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: run: pnpm install - name: Build - run: pnpm build + run: pnpm build && pnpm build:npm - name: Test run: pnpm test diff --git a/.gitignore b/.gitignore index 58db45f69..cb2ffd598 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output dist/ build/ +dist-npm/ # API 前端构建产物落盘(apps/api/frontend/dist 由 prod/deploy.sh 同步, # 历史手工拷贝的 stale 副本以 dist.stale-* 隔离;整个目录不入库) @@ -62,6 +63,12 @@ docs/* !docs/adr/ # docs/visual-reports/ 豁免——#390 决议:截图 diff 报告入 git 作改版验收凭据(#391) !docs/visual-reports/ +# docs/specs/ui/style-guide.md 豁免——样式规范是唯一权威来源,须入 git 评审/回溯(批次 I-1 治理人闸当场过,2026-09-16);其余 spec 源文件维持忽略 +!docs/specs/ +docs/specs/* +!docs/specs/ui/ +docs/specs/ui/* +!docs/specs/ui/style-guide.md # Scratch workspace (ignored) .scratch/ @@ -88,9 +95,9 @@ e2e/.auth.json CLAUDE.md .claude/ -# Runner worktree artifacts (regenerated per worktree at runtime; -# committing them breaks per-worktree regeneration — runner-execution only -# writes CACHE_PREFIX.md when absent, and worktrees inherit tracked files) +# Runner worktree artifacts (regenerated per worktree at runtime; committing them +# breaks per-worktree regeneration, and worktrees inherit tracked files). +# 两条写方随 #562 删除,保留规则只为兜住历史 worktree 里的残留产物。 CACHE_PREFIX.md .prompt.md diff --git a/.harness/config.yml b/.harness/config.yml index bbc111ebe..6d1011510 100644 --- a/.harness/config.yml +++ b/.harness/config.yml @@ -1,3 +1,3 @@ preset: standard harness: - version: 1.7.0 + version: 1.8.0 diff --git a/AGENTS.md b/AGENTS.md index 883d1f432..49862c5fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ | `.harness/` | harness 配置与运行时状态 | | `apps/` | monorepo 应用:api、web | | `bin/` | 可执行入口/脚本 | +| `dist-npm/` | — | | `docs/` | 项目文档 | | `node-compile-cache/` | — | | `packages/` | monorepo 共享包:studio-agent、studio-audit、studio-capability、studio-notification、studio-shared、studio-skill、studio-spec | @@ -46,9 +47,11 @@ pnpm start # 启动生产服务 > 本段手写,增删改走段末「治理变更流程」。依据:docs/adr/2026-08-21-agent-docs-three-kinds.md(内容三分)+ 2026-08-21-agent-docs-placement-model.md(落点模型),2026-08-21 自 CLAUDE.md 迁入(#300/#303)。 +- 数据目录契约:`docs/architecture/data-directory-contract.md` 是 `~/.studio` 数据区布局/可改纪律/变更纪律/版本迁移的唯一正本;布局变更须先修订契约(出处:#570 / docs/plans/2026-09-npm-local-form.md §7.4,治理人闸 2026-09-16 当场通过)。 + ## Governance Rules - + ### Iron Laws (违反将阻断) - **no_completion_without_verification**: 在声明任务完成前,必须重新运行新鲜的验证命令——受改动影响的测试(vitest run --changed origin/master)+ type check,使用新鲜的输出作为完成证据,不得复用旧结果。全量测试由 CI / 发布流程兜底。 - **incremental_progress**: 一次只处理一个任务。改动涉及多个模块、超过 100 行、或影响多个文件时,必须拆分为小步骤分步执行,每步有独立 checkpoint 可回滚。不要试图一次性完成所有改动。 @@ -168,6 +171,7 @@ pnpm start # 启动生产服务 | `apps/api/src/modules/mcp` | MCP(Model Context Protocol)模块 — 将 Studio 系统能力暴露为 MCP tools,供 Agent 和 UI 共享调用。 | | `apps/api/src/modules/monitoring` | 负责聚合 Agent Network 的监控指标,包括 Agent 摘要、统计信息、飞轮指标(M1)和封装开销(M2),通过 HTTP 路由对外暴露。 | | `apps/api/src/modules/notifications` | 提供通知相关的 API 路由,包括获取通知列表、查询未读数量、标记单条已读和标记全部已读,作为后台消息通知模块的 HTTP 接口层。 | +| `apps/api/src/modules/notify-channels` | 通知渠道配置(#525 P2-6,决策 #516 项⑦⑧):服务端配置存储 + 读写 API + ClawBot(iLink 协议)内嵌轻量直连。/settings「通知渠道」配置区的服务端支撑——#434 死配置的「复活成真功能」版... | | `apps/api/src/modules/outbound-notify` | 本模块提供基于 Discord 的通知发送服务,支持多种任务与会议相关通知类型。内部封装了对 discordNotifier 的调用,并通过 eventBus 将通知事件发布到消息总线。还暴露 HTTP 路由供内部模块通过 POST... | | `apps/api/src/modules/pmo` | 项目管理办公室(PMO):OKR 管理 + 项目 CRUD + 交付守卫。PMO 是链条脊椎:id = 分支名、需求文档挂载点、状态 = WU 汇总 + 证据台账、交付策略挂在项目上。统一编号 PMO-。 | | `apps/api/src/modules/projects` | Project Discovery(AC-D1 + AC-D3):发现已注册的工程(repo)信息并对外提供查询 API,供频道默认工程、WorkUnit 工程绑定等流程使用。 | @@ -176,12 +180,12 @@ pnpm start # 启动生产服务 | `apps/api/src/modules/role-memory` | 角色记忆存储服务:per-role 目录落数据区(经 studioPath()),三件套--MEMORY.md 索引 + topics/*.md topic 正文 + draft.jsonl append-only 草稿区。role-... | | `apps/api/src/modules/skills` | skills 模块负责技能(Skill)的完整生命周期管理,包括基于文件的技能元数据存储(SkillStore)、技能目录扫描与加载(manifest-loader)、基于描述的技能匹配(skill-selector)、从 WorkU... | | `apps/api/src/modules/specs` | 提供 Specs 模块的 HTTP API 路由,包括变更分析、变更历史查询和门禁验证(待实现)。遵循 SP-002 变更分级流程,通过调用外部 SDK 中的服务处理 Spec 变更相关的业务逻辑。 | -| `apps/api/src/modules/transcripts` | transcript 归档器(#97,#88 子票):把会话原文落盘到数据区(经 studioDir()/studioPath()),供三个消费方共用——#99 WU 收尾批量提取(要全文)、handoff 摘要(要对话)、#85 执... | +| `apps/api/src/modules/transcripts` | transcript 归档器(#97,#88 子票):把会话原文落盘到数据区(经 studioDir()/studioPath()),供四个消费方共用——#99 WU 收尾批量提取(要全文)、handoff 摘要(要对话)、#85 执... | | `apps/api/src/modules/triage` | 实现错误的分类(triage)与严重度评估,提供策略路由(auto_retry / manual_fix / escalate / ignore),支持开发者错误和系统级事件的分类。 | | `apps/api/src/modules/triggers` | Trigger 子系统(AS-026,3.28c-4):SCHEDULE(cron)+ EVENT(EventBus)两类条件的触发器调度与持久化,动作包括 CREATE WorkUnit / UPDATE / EXECUTE。系统默... | | `apps/api/src/modules/workspaces` | 本机 Workspace 记录的自动注册与查询(只读 list/get + 删除)、CLI 运行时清单扫描。Token 管理已随 #481 退役删除。 | | `apps/api/src/modules/workunit` | WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 NEED_INPUT 挂起/恢复与超时提醒。 | -| `packages/studio-agent` | Sub-agent 的完整生命周期管理:创建隔离 worktree → spawn Claude Code → session loop 监控 → 完成判定。 | +| `packages/studio-agent` | Sub-agent 的完整生命周期管理:创建隔离 worktree → 传播 harness 配置 → spawn provider CLI(单 session)→ stream-json 解析与事件发射。完成判定/状态机不在本包,由... | | `packages/studio-audit` | 提供审计日志的记录、查询、统计与导出功能。通过 AuditService 进行持久化日志操作(JSONL 存储)。 | | `packages/studio-capability` | 本目录负责能力管理(CapabilityService)。CapabilityService 提供能力的 CRUD、同步、统计,并基于 FileStore JSON 文件存储实现(替代 Prisma)。 | | `packages/studio-notification` | 本目录提供 studio-notification 包的核心代码,包含通知的创建、查询、标记,服务层基于 FileStore 实现持久化通知管理。 | diff --git a/CAPABILITIES.md b/CAPABILITIES.md index b4aa814cb..0d23e57bd 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -1,6 +1,6 @@ # CAPABILITIES.md -> 最后更新: 2026-09-15 +> 最后更新: 2026-09-17 --- @@ -16,7 +16,7 @@ | src | packages/studio-shared/src/ | Studio Event Bus — 替代 Redis pub/sub(2026-05-08) | | auditor-types | packages/studio-shared/src/harness/auditor/auditor-types.ts | Auditor ↔ 其他角色协议定义(BP-013 + BP-014) | -| hooks | packages/studio-shared/src/harness/hooks/ | Agent Execution Phase Hooks | + | harness | packages/studio-shared/src/harness/ | Constraint Prompt Injection — 将 harness 约束的前置声明注入 Agent prompt | | runtime | packages/studio-shared/src/harness/runtime/ | Harness Runtime Bootstrap — Phase 2 迁移 | @@ -275,4 +275,7 @@ | NotifyChannelsSection | apps/web/src/components/settings/NotifyChannelsSection.tsx | 通知渠道 section(#525 P2-6):企业微信群机器人 webhook + ClawBot(个人微信)扫码绑定 + 浏览器通知总开关。 | | wecom-client | apps/api/src/modules/notify-channels/wecom-client.ts | 企业微信群机器人 markdown 发送唯一出口(#525 review 收口): | | ChannelStreamBody | apps/web/src/components/channel/ChannelStreamBody.tsx | 频道消息流结构组件(#531,架构评审 2026-09-14 候选 3):消费 useChannelStream 产物, | +| ChannelMessageEnv | apps/web/src/components/channel/ChannelMessageEnv.tsx | 频道消息环境(#547,架构评审 2026-09-15 候选 B3,grilling 已决): | +| ApproveDialogShell | apps/web/src/components/ui/ApproveDialogShell.tsx | ApproveDialogShell — 审批弹窗公共壳(docs/plans/2026-09-web-ux-optional-fixes.md Step 3, | +| PlanDirectionCard | apps/web/src/components/channel/PlanDirectionCard.tsx | PlanDirectionCard — #567:方向锁定接力卡(plan 一脉会话内方向人闸,裁决轮前置环节) | | mockMatchMedia | apps/web/src/test/mockMatchMedia.ts | #395:jsdom 无 window.matchMedia 实现——按给定视口宽度求值 (min|max)-width 媒体查询, | \ No newline at end of file diff --git a/apps/api/bench/claim-latency.ts b/apps/api/bench/claim-latency.ts index b487cbec1..b037941da 100644 --- a/apps/api/bench/claim-latency.ts +++ b/apps/api/bench/claim-latency.ts @@ -12,8 +12,9 @@ * 特征词触发 B2 守卫——认领后立即关闭,不起 LLM 会话、不烧 token): * B1 拾取延迟:建 unassigned WU(指名本 role)→ 轮询 index 拿 claimedAt,N 轮随机相位; * 同时记录 getIndex 调用时刻,验证 workunit.created EVENT 触发的 observe 是否带来认领。 - * B2 事件唤醒延迟:blocked WU(assignee=实例)进入 lastActiveWuIds 后发人类消息, + * B2 事件唤醒延迟:blocked WU(assignee=实例)就绪且 loop 进入 idleSleep 后发人类消息, * 测量 message_sent publish → 下一次 observe(getIndex) 的时延。 + * (2026-09-16 唤醒放宽:不再等 lastActiveWuIds,该派生缓存机制已删——唤醒只放行不裁决) */ import fs from 'node:fs'; import path from 'node:path'; @@ -187,7 +188,8 @@ async function main(): Promise { // B2:channel.message_sent 唤醒延迟 console.log('\n── B2: 人类消息 → 唤醒 idle sleep → 下一次 observe ──'); - // 建 blocked WU 挂本实例,等它进入 lastActiveWuIds(observe 每 ≤15s 刷一次)。 + // 建 blocked WU 挂本实例。2026-09-16 唤醒放宽后不再等 lastActiveWuIds(机制已删, + // 唤醒只放行不裁决)——只需等 loop 进入 idleSleep。 // scope 必须带 test 特征词:唤醒后 loop 会真的拾取该 WU 起会话——B2 守卫拦在 // prompt 组装前直接关闭,防误烧 token(2026-09-12 首跑教训:无 test 词的 scope // 被唤醒的 loop 当真任务 spawn 了真实 CLI 会话)。 @@ -197,17 +199,14 @@ async function main(): Promise { }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const seam = loop as any; - const regDeadline = Date.now() + 20_000; - while (!seam.lastActiveWuIds?.has(blockedWu.id) && Date.now() < regDeadline) { - await new Promise(res => setTimeout(res, 100)); - } - if (!seam.lastActiveWuIds?.has(blockedWu.id)) { - console.log('B2 SKIP: blocked WU 20s 未进入 lastActiveWuIds'); - } else { - console.log('[bench] blocked WU 已进入 lastActiveWuIds,loop 处于 idleSleep'); + { // 等 loop 确实进入 idleSleep(wakeIdle 挂起) const idleDeadline = Date.now() + 20_000; while (!seam.wakeIdle && Date.now() < idleDeadline) await new Promise(res => setTimeout(res, 50)); + if (!seam.wakeIdle) { + console.log('B2 SKIP: loop 20s 未进入 idleSleep'); + } else { + console.log('[bench] loop 处于 idleSleep'); const wakeLat: number[] = []; for (let i = 0; i < 3; i++) { // 等回 idleSleep @@ -233,6 +232,7 @@ async function main(): Promise { await new Promise(res => setTimeout(res, 3_000)); } if (wakeLat.length > 0) console.log(`B2 唤醒延迟: ${summary(wakeLat)}`); + } } loop.stop(); diff --git a/apps/api/package.json b/apps/api/package.json index af3177d0a..0456c1270 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,9 +2,6 @@ "name": "@dommaker/studio-api", "version": "0.1.0", "private": true, - "bin": { - "studio": "src/cli/studio-cli.ts" - }, "scripts": { "dev": "STUDIO_HOME=${STUDIO_HOME:-$HOME/.studio-dev} tsx watch src/index.ts", "build": "tsc", @@ -13,7 +10,7 @@ "test:watch": "vitest" }, "dependencies": { - "@dommaker/harness": "^1.7.0", + "@dommaker/harness": "^1.8.1", "@dommaker/studio-agent": "workspace:*", "@dommaker/studio-audit": "workspace:*", "@dommaker/studio-capability": "workspace:*", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4fd5d95af..4f550b13c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -106,6 +106,8 @@ export async function registerRoutes(): Promise { '/mcp/health', // MCP health check '/mcp/sse', // MCP SSE transport endpoint '/mcp/messages', // MCP SSE message endpoint + '/mcp/external/sse', // #566: 外部只读 SSE 入口(钉死 external 角色) + '/mcp/external/messages', // #566: 外部只读 SSE 消息端点 ]); const optAuth = optionalAuth(); app.use('/api/v1', async (req: any, res: any, next: any) => { diff --git a/apps/api/src/cli/CONTEXT.md b/apps/api/src/cli/CONTEXT.md new file mode 100644 index 000000000..1de441e9e --- /dev/null +++ b/apps/api/src/cli/CONTEXT.md @@ -0,0 +1,28 @@ +# apps/api/src/cli — Studio CLI 入口与命令域 + +## 职责 + +`studio` 命令的入口/分发(studio-cli.ts)与各命令域实现。两类服务启动路径: + +- `studio run web`(run-web.ts,#571):npm 本地形态一体起服务总入口——API + 托管已构建 web dist + 首启检测块 + 端口动态顺延,前台进程 Ctrl-C 即停。 +- `studio up`(server.ts):服务器形态启动(systemd 托管场景),原样保留。 + +## 核心导出 + +- `studio-cli.ts`:入口/分发/帮助文本;`run web [--port ]` 动词。 +- `run-web.ts`:`studioRunWeb()`——启动链:ensureDataDirs/ensureDaemonSecrets → 端口解析 → 首启面板 → preflight(存储探针 + frontend dist)→ ensureDefaults → `await import('../index.js')`。数据区迁移钩子接入点(#572)在其 preflight 之前。 +- `bootstrap.ts`:ensureDataDirs / ensureDaemonSecrets(env > .daemon/ 文件 > 生成落盘)/ probeStorageWritable;run web 与 up 共用。 +- `port-probe.ts`:probePortFree(net 模块探测,跨平台不走 lsof)/ resolvePort(默认 3001 起顺延上限 +100;显式 --port/PORT 占用即拒启)/ parsePortFlag / explicitPortFromEnv。 +- `first-run-panel.ts`:buildFirstRunPanel()——Node 版本对 engines>=20、cli-scanner 全 provider 探测结果、数据根、实际监听地址;零 provider 只提示不阻断。 +- `mcp-install.ts`(#566 P3):`studio mcp install [--url] [--print] [--uninstall]`——把对外只读 MCP 入口(/api/v1/mcp/external/sse)幂等合入 agent 用户级配置(claude `~/.claude.json` / kimi `~/.kimi-code/mcp.json` / opencode `~/.config/opencode/opencode.json`,均只动 `studio` key、写前 .bak 备份、JSON 损坏拒写);URL 缺省探测 3001 /mcp/health,不通报错提示 --url;codex 不支持 SSE 报错(Q2 不架 mcp-remote 桥)。 +- `skill.ts` + `skill-validate.ts`(#568):`studio skill validate/export/install` 流动三件套——纯本地文件操作(daemon 离线可用,与走 HTTP 的 `studio skill list` 分流于 studio-cli.ts `case 'skill'`);validate 规则 = 布局 + frontmatter(name≠目录名为 error,description/triggers 缺为 warning);export = 目录拷贝 + `PROVENANCE.json` 边车(contentHash 复用 `hashSkillDir`,脱敏不写绝对路径);install 与内置正本同名硬拒绝(保护 seed hash 升级通道),冲突/no-op/--force 三态,不碰 `.builtin-hashes.json` 与 `skills-index.json`。 + +## 注意事项 + +- **npm 单包形态 = esbuild bundle**(`scripts/build-npm.mjs` → `dist-npm/`,根 package.json files 白名单 `bin/studio` + `dist-npm`):workspace 包代码全部内嵌,npm 依赖保持 external(= 根 dependencies,metafile 校验内嵌零 node_modules)。`bin/studio` 薄入口:bundle 存在则 import,否则 monorepo fallback 到 tsx 跑源。 +- **两个 bundle 相对路径约定**:frontend dist = `/../frontend/dist`(与 app.ts:133 同表达式);内置 skill 正本 = `/../skills`(seed.ts defaultSourceDir 上溯一级约定)。改 dist-npm 布局三处要一起动。 +- **坑(esbuild 提升)**:index.ts 顶部的 `import 'dotenv/config'` 被 esbuild 提升到 bundle 顶层 → 任何 CLI 动词都会加载 **cwd 的 .env**;其中 PORT 对 `run web` 是显式语义(占用即拒启)。在含 PORT 的仓库目录下跑 `studio run web` 不带 --port 会被 .env 的 PORT 卡住。 +- **apps/api 的 tsc dist 不能被 node 直跑**(tsconfig module ESNext 而包无 `type: module`,产物含 ESM import + 裸 require/__dirname)——npm 形态不走 tsc dist,`pnpm start`/`dev` 走 tsx;不要再给 tsc dist 加 bin 指向(apps/api bin 已于 #571 移除)。 +- CJS 遗迹:部分模块仍有 `require(...)` 惰性调用——bundle 靠 banner 的 createRequire 兜底,dev 靠 tsx;改 ESM 化时逐文件处理,别指望 tsc 产物直接跑。 +- **checkPrerequisites 测试口径(#577)**:scanAllProviders 真机全量扫描成本随 provider 探测增长(#573 注册表扫描、#574 模型探测后复审环境实测 43-55s),`__tests__/server.test.ts` 已用 importOriginal 部分 mock 隔离——该文件任何用例不得再打真扫描/留集成级超时窗口;要真扫描覆盖请放 e2e。 +- `studio up` 语义不动,但端口口径已随 #573 收口:启动前经 port-probe 解析(PORT 显式占用即拒启,缺省 3001 动态顺延上限 +100),ops preflight 的 lsof abort 与 index.ts 的 EADDRINUSE 3s 无限重试均已删除(后者归 utils/listen-error.ts,listen 竞态撞占用 = 拒启)。checkPrerequisites 的 agent CLI 检查同票对齐 provider 注册表扫描(不再硬编码 claude),缺失只警告不阻断。 diff --git a/apps/api/src/cli/__tests__/bootstrap.test.ts b/apps/api/src/cli/__tests__/bootstrap.test.ts new file mode 100644 index 000000000..333daf7b5 --- /dev/null +++ b/apps/api/src/cli/__tests__/bootstrap.test.ts @@ -0,0 +1,88 @@ +/** + * bootstrap.ts 单元测试(#571:studio run web 与 studio up 共用的数据目录/密钥自举)。 + * + * 行为契约(自 server.ts studioUp 内联逻辑提取,语义不变): + * - ensureDataDirs:创建 data/ .analyst/ .daemon/ knowledge/ worktrees/,幂等; + * - ensureDaemonSecrets:env 已有值不动;.daemon 下有文件则读入 env;都没有则生成并落盘; + * 重复调用幂等(第二次从文件 loaded,不重新生成)。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { ensureDataDirs, ensureDaemonSecrets, probeStorageWritable } from '../bootstrap.js'; + +let tmp: string; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-bootstrap-')); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('ensureDataDirs', () => { + it('创建全部数据子目录', () => { + ensureDataDirs(tmp); + for (const d of ['data', '.analyst', '.daemon', 'knowledge', 'worktrees']) { + expect(fs.statSync(path.join(tmp, d)).isDirectory()).toBe(true); + } + }); + + it('幂等:重复调用不报错', () => { + ensureDataDirs(tmp); + expect(() => ensureDataDirs(tmp)).not.toThrow(); + }); +}); + +describe('probeStorageWritable', () => { + it('可写目录 → 不抛错,且不留探针文件', () => { + expect(() => probeStorageWritable(tmp)).not.toThrow(); + expect(fs.readdirSync(tmp)).toEqual([]); + }); + + it('不可写/不存在路径 → 抛错', () => { + expect(() => probeStorageWritable(path.join(tmp, 'no/such/dir'))).toThrow(); + }); +}); + +describe('ensureDaemonSecrets', () => { + it('全新数据根 → 生成 JWT_SECRET 与 ENCRYPTION_KEY 并落盘 .daemon/', () => { + const env: NodeJS.ProcessEnv = {}; + const r = ensureDaemonSecrets(path.join(tmp, '.daemon'), env); + expect(r).toEqual({ jwt: 'generated', encryption: 'generated' }); + expect(env.JWT_SECRET).toMatch(/^[0-9a-f]{64}$/); + expect(env.ENCRYPTION_KEY).toMatch(/^[0-9a-f]{64}$/); + expect(fs.readFileSync(path.join(tmp, '.daemon', 'jwt-secret'), 'utf-8')).toBe(env.JWT_SECRET); + expect(fs.readFileSync(path.join(tmp, '.daemon', 'encryption-key'), 'utf-8')).toBe(env.ENCRYPTION_KEY); + }); + + it('二次调用从文件 loaded,不重新生成(值不变)', () => { + const env1: NodeJS.ProcessEnv = {}; + ensureDaemonSecrets(path.join(tmp, '.daemon'), env1); + const env2: NodeJS.ProcessEnv = {}; + const r = ensureDaemonSecrets(path.join(tmp, '.daemon'), env2); + expect(r).toEqual({ jwt: 'loaded', encryption: 'loaded' }); + expect(env2.JWT_SECRET).toBe(env1.JWT_SECRET); + expect(env2.ENCRYPTION_KEY).toBe(env1.ENCRYPTION_KEY); + }); + + it('env 已显式配置 → 不动(env 优先于文件与生成)', () => { + const env: NodeJS.ProcessEnv = { JWT_SECRET: 'preset-jwt', ENCRYPTION_KEY: 'preset-enc' }; + const r = ensureDaemonSecrets(path.join(tmp, '.daemon'), env); + expect(r).toEqual({ jwt: 'env', encryption: 'env' }); + expect(env.JWT_SECRET).toBe('preset-jwt'); + expect(fs.existsSync(path.join(tmp, '.daemon', 'jwt-secret'))).toBe(false); + }); + + it('混合:env 有 JWT_SECRET、文件有 encryption-key → 各走各径', () => { + const daemonDir = path.join(tmp, '.daemon'); + fs.mkdirSync(daemonDir, { recursive: true }); + fs.writeFileSync(path.join(daemonDir, 'encryption-key'), 'file-enc\n'); + const env: NodeJS.ProcessEnv = { JWT_SECRET: 'preset-jwt' }; + const r = ensureDaemonSecrets(daemonDir, env); + expect(r).toEqual({ jwt: 'env', encryption: 'loaded' }); + expect(env.ENCRYPTION_KEY).toBe('file-enc'); + }); +}); diff --git a/apps/api/src/cli/__tests__/data.test.ts b/apps/api/src/cli/__tests__/data.test.ts index d50df43ec..c1f459afc 100644 --- a/apps/api/src/cli/__tests__/data.test.ts +++ b/apps/api/src/cli/__tests__/data.test.ts @@ -2,7 +2,7 @@ * data.ts 单元测试(T3 拆分新增,pre-commit TDD 门禁)。 * * 覆盖数据域 apiCommand / studioKnowledge / studioEnv / studioMcp 的离线路径: - * - 本地分支(run / 缺 id 的 show / 未知子命令 / upsert 缺参)不发请求直接输出; + * - 本地分支(缺 id 的 show / 未知子命令 / upsert 缺参)不发请求直接输出; * - 需要请求的分支在 API 不可达(ECONNREFUSED)时输出 "API server not running"。 * 导入前将 PORT 指到端口 19141(API 常量在模块加载时计算), * HOME 指向临时目录避免读取真实 ~/.studio session-token。 @@ -50,11 +50,6 @@ afterEach(() => { }); describe('apiCommand 本地分支(不发请求)', () => { - it('run 子命令提示使用 studio run', async () => { - await data.apiCommand('tasks', ['run']); - expect(logs.join('\n')).toContain('Use: studio run '); - }); - it('show 缺 id → usage 错误', async () => { await data.apiCommand('knowledge', ['show']); expect(errs.join('\n')).toContain('Usage: studio knowledge show '); @@ -110,7 +105,7 @@ describe('studioEnv / studioMcp', () => { it('studioMcp 未知子命令 → 用法行', async () => { await data.studioMcp(['bogus']); - expect(logs.join('\n')).toContain('studio mcp '); + expect(logs.join('\n')).toContain('studio mcp '); }); it('studioMcp 默认 tools 在 API 不可达时 → ECONNREFUSED 提示', async () => { diff --git a/apps/api/src/cli/__tests__/first-run-panel.test.ts b/apps/api/src/cli/__tests__/first-run-panel.test.ts new file mode 100644 index 000000000..d5379965e --- /dev/null +++ b/apps/api/src/cli/__tests__/first-run-panel.test.ts @@ -0,0 +1,61 @@ +/** + * first-run-panel.ts 单元测试(#571:studio run web 首启检测块)。 + * + * 冻结内容(data-directory-contract.md §7):Node 版本(对 engines >=20)、 + * agent CLI 探测结果(provider/path/version)、数据根位置、实际监听地址; + * 缺失 CLI 不阻断,但标注「至少需要一个 agent CLI 才能跑执行」。 + */ +import { describe, it, expect } from 'vitest'; +import { buildFirstRunPanel } from '../first-run-panel.js'; + +const base = { + nodeVersion: 'v22.22.0', + minNodeMajor: 20, + providers: [ + { provider: 'claude' as const, path: '/usr/local/bin/claude', version: '1.0.0' }, + { provider: 'kimi' as const, path: '/usr/local/bin/kimi', version: '2.0.0' }, + ], + dataRoot: '/home/u/.studio', + listenHost: '127.0.0.1', + listenPort: 3001, + shiftedFrom: null as number | null, +}; + +describe('buildFirstRunPanel', () => { + it('包含 Node 版本、provider 清单、数据根与监听地址', () => { + const panel = buildFirstRunPanel(base); + expect(panel).toContain('v22.22.0'); + expect(panel).toContain('claude'); + expect(panel).toContain('/usr/local/bin/claude'); + expect(panel).toContain('kimi'); + expect(panel).toContain('/home/u/.studio'); + expect(panel).toContain('127.0.0.1:3001'); + }); + + it('Node 版本低于下限 → 面板标注不满足 engines', () => { + const panel = buildFirstRunPanel({ ...base, nodeVersion: 'v18.19.0' }); + expect(panel).toMatch(/v18\.19\.0.*(below|require|>= ?20)/i); + }); + + it('Node 版本达标 → 不告警', () => { + const panel = buildFirstRunPanel(base); + expect(panel).not.toMatch(/v22\.22\.0.*below/i); + }); + + it('零 provider → 标注至少需要一个 agent CLI(不阻断语义,仅文案)', () => { + const panel = buildFirstRunPanel({ ...base, providers: [] }); + expect(panel).toMatch(/至少需要一个 agent CLI|at least one agent CLI/i); + }); + + it('有 provider → 不出现缺 CLI 提示', () => { + const panel = buildFirstRunPanel(base); + expect(panel).not.toMatch(/at least one agent CLI/i); + }); + + it('端口顺延 → 标明实际端口与原首选', () => { + const panel = buildFirstRunPanel({ ...base, listenPort: 3002, shiftedFrom: 3001 }); + expect(panel).toContain('127.0.0.1:3002'); + expect(panel).toContain('3001'); + expect(panel).toMatch(/shift|顺延|in use/i); + }); +}); diff --git a/apps/api/src/cli/__tests__/mcp-install.test.ts b/apps/api/src/cli/__tests__/mcp-install.test.ts new file mode 100644 index 000000000..f41cf9600 --- /dev/null +++ b/apps/api/src/cli/__tests__/mcp-install.test.ts @@ -0,0 +1,201 @@ +/** + * mcp-install 单元测试(#566 P3,pre-commit TDD 门禁)。 + * + * 覆盖 studio mcp install 的三态幂等: + * - install 写用户级配置(claude/kimi/opencode 各自路径与格式),只动 studio key + * - --print 干跑不落盘;--uninstall 只删 studio key,既有字段无损 + * - Q3:默认探测 localhost:3001 /mcp/health,不通报错提示 --url;--url 跳过探测 + * - 安全:JSON 损坏拒写;写前备份 .bak;codex 不支持 SSE 报错(Q2 不架桥) + * + * 隔离:HOME 指向临时目录;fetch 全局 stub。 + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +let tmpHome: string; +let prevHome: string | undefined; +let logs: string[]; +let errs: string[]; + +const { mcpInstall } = await import('../mcp-install.js'); + +function stubFetchOk() { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); +} +function stubFetchFail() { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); +} + +function readJson(file: string): any { + return JSON.parse(fs.readFileSync(file, 'utf-8')); +} + +beforeAll(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-mcp-install-')); + prevHome = process.env.HOME; +}); + +afterAll(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +beforeEach(() => { + process.env.HOME = tmpHome; + logs = []; + errs = []; + vi.spyOn(console, 'log').mockImplementation((...a: any[]) => { logs.push(a.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation((...a: any[]) => { errs.push(a.map(String).join(' ')); }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + // 清理临时 HOME 内的产物 + for (const entry of fs.readdirSync(tmpHome)) { + fs.rmSync(path.join(tmpHome, entry), { recursive: true, force: true }); + } +}); + +describe('参数与 agent 支持面', () => { + it('无 agent → 用法行', async () => { + await mcpInstall([]); + expect(logs.join('\n')).toContain('studio mcp install '); + }); + + it('未知 agent → 报错 + 用法行', async () => { + await mcpInstall(['cursor']); + expect(errs.join('\n')).toContain('未知 agent: cursor'); + }); + + it('codex 不支持 SSE → 报错提示,不写任何文件(Q2 不架桥)', async () => { + stubFetchOk(); + await mcpInstall(['codex']); + expect(errs.join('\n')).toContain('codex 不支持 SSE transport'); + expect(fs.existsSync(path.join(tmpHome, '.codex'))).toBe(false); + }); +}); + +describe('install 写配置', () => { + it('claude:探测 3001 通过 → 写 ~/.claude.json 的 mcpServers.studio(type:sse)', async () => { + stubFetchOk(); + await mcpInstall(['claude']); + const file = path.join(tmpHome, '.claude.json'); + const config = readJson(file); + expect(config.mcpServers.studio).toEqual({ + type: 'sse', + url: 'http://localhost:3001/api/v1/mcp/external/sse', + }); + expect(fs.existsSync(`${file}.bak`)).toBe(false); // 首次写入无既有文件,无备份 + }); + + it('kimi/opencode 各自的容器 key 与 entry 格式', async () => { + stubFetchOk(); + await mcpInstall(['kimi']); + expect(readJson(path.join(tmpHome, '.kimi-code', 'mcp.json')).mcpServers.studio) + .toEqual({ transport: 'sse', url: 'http://localhost:3001/api/v1/mcp/external/sse' }); + + await mcpInstall(['opencode']); + expect(readJson(path.join(tmpHome, '.config', 'opencode', 'opencode.json')).mcp.studio) + .toEqual({ type: 'remote', url: 'http://localhost:3001/api/v1/mcp/external/sse' }); + }); + + it('幂等合并:既有字段与其他 mcpServers 无损,重复 install 不改动', async () => { + stubFetchOk(); + const file = path.join(tmpHome, '.claude.json'); + fs.writeFileSync(file, JSON.stringify({ + theme: 'dark', + mcpServers: { other: { type: 'sse', url: 'http://localhost:9999/sse' } }, + })); + + await mcpInstall(['claude']); + const config = readJson(file); + expect(config.theme).toBe('dark'); + expect(config.mcpServers.other).toEqual({ type: 'sse', url: 'http://localhost:9999/sse' }); + expect(config.mcpServers.studio).toBeDefined(); + expect(readJson(`${file}.bak`).mcpServers.studio).toBeUndefined(); // 备份是写入前快照 + + const afterFirst = fs.readFileSync(file, 'utf-8'); + await mcpInstall(['claude']); + expect(logs[logs.length - 1]).toContain('已是目标配置,无需改动'); + expect(fs.readFileSync(file, 'utf-8')).toBe(afterFirst); + }); + + it('URL 变化时重复 install 更新条目(可重入)', async () => { + stubFetchOk(); + await mcpInstall(['claude', '--url', 'http://localhost:13101/api/v1/mcp/external/sse']); + await mcpInstall(['claude', '--url', 'http://localhost:3001/api/v1/mcp/external/sse']); + expect(readJson(path.join(tmpHome, '.claude.json')).mcpServers.studio.url) + .toBe('http://localhost:3001/api/v1/mcp/external/sse'); + }); +}); + +describe('URL 来源(Q3)', () => { + it('探测失败且无 --url → 报错提示 --url,不落盘', async () => { + stubFetchFail(); + await mcpInstall(['claude']); + expect(errs.join('\n')).toContain('--url'); + expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false); + }); + + it('--url 跳过探测直接用', async () => { + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + await mcpInstall(['claude', '--url', 'http://localhost:7777/api/v1/mcp/external/sse']); + expect(mockFetch).not.toHaveBeenCalled(); + expect(readJson(path.join(tmpHome, '.claude.json')).mcpServers.studio.url) + .toBe('http://localhost:7777/api/v1/mcp/external/sse'); + }); +}); + +describe('--print / --uninstall', () => { + it('--print 输出片段不落盘', async () => { + stubFetchOk(); + await mcpInstall(['claude', '--print']); + const out = logs.join('\n'); + expect(out).toContain('.claude.json'); + expect(out).toContain('"type": "sse"'); + expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false); + }); + + it('--uninstall 只删 studio key,既有字段无损;重复卸载幂等', async () => { + stubFetchOk(); + await mcpInstall(['claude']); + const file = path.join(tmpHome, '.claude.json'); + // 模拟用户后续加了别的 key + const config = readJson(file); + config.mcpServers.other = { command: 'x' }; + fs.writeFileSync(file, JSON.stringify(config)); + + await mcpInstall(['claude', '--uninstall']); + const after = readJson(file); + expect(after.mcpServers.studio).toBeUndefined(); + expect(after.mcpServers.other).toEqual({ command: 'x' }); + + await mcpInstall(['claude', '--uninstall']); + expect(logs[logs.length - 1]).toContain('未安装'); + }); + + it('--uninstall 无配置文件 → 未安装提示', async () => { + await mcpInstall(['kimi', '--uninstall']); + expect(logs.join('\n')).toContain('未安装'); + }); +}); + +describe('安全边界', () => { + it('配置文件 JSON 损坏 → 拒写(install 与 uninstall 同)', async () => { + stubFetchOk(); + const file = path.join(tmpHome, '.claude.json'); + fs.writeFileSync(file, '{broken json'); + await mcpInstall(['claude']); + expect(errs.join('\n')).toContain('解析失败'); + expect(fs.readFileSync(file, 'utf-8')).toBe('{broken json'); + + await mcpInstall(['claude', '--uninstall']); + expect(errs.join('\n')).toContain('拒绝修改'); + expect(fs.readFileSync(file, 'utf-8')).toBe('{broken json'); + }); +}); diff --git a/apps/api/src/cli/__tests__/port-probe.test.ts b/apps/api/src/cli/__tests__/port-probe.test.ts new file mode 100644 index 000000000..b18fb84e4 --- /dev/null +++ b/apps/api/src/cli/__tests__/port-probe.test.ts @@ -0,0 +1,135 @@ +/** + * port-probe.ts 单元测试(#571 npm 本地形态:端口动态顺延)。 + * + * 冻结语义(docs/architecture/data-directory-contract.md §7): + * - 默认 3001,占用顺次探测 +1 …(上限 maxOffset); + * - 显式指定(--port / PORT)占用即拒启,显式意图不被顺延覆盖; + * - 顺延耗尽报错拒启。 + * 探测用 node net 模块(跨平台),不走 lsof(Linux-only)。 + */ +import { describe, it, expect, afterEach } from 'vitest'; +import net from 'node:net'; +import { probePortFree, resolvePort, parsePortFlag, explicitPortFromEnv } from '../port-probe.js'; + +const HOST = '127.0.0.1'; + +let servers: net.Server[] = []; + +async function occupy(port: number): Promise { + const srv = net.createServer(); + await new Promise((resolve, reject) => { + srv.once('error', reject); + srv.listen(port, HOST, () => resolve()); + }); + servers.push(srv); + return srv; +} + +/** 从 OS 取一个空闲端口(listen 0 后立即关闭,供后续用例占用/探测) */ +async function grabFreePort(): Promise { + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, HOST, () => resolve())); + const port = (srv.address() as net.AddressInfo).port; + await new Promise((resolve) => srv.close(() => resolve())); + return port; +} + +afterEach(async () => { + for (const srv of servers) { + await new Promise((resolve) => srv.close(() => resolve())); + } + servers = []; +}); + +describe('probePortFree', () => { + it('空闲端口返回 true', async () => { + const port = await grabFreePort(); + expect(await probePortFree(port, HOST)).toBe(true); + }); + + it('被占用端口返回 false', async () => { + const port = await grabFreePort(); + await occupy(port); + expect(await probePortFree(port, HOST)).toBe(false); + }); +}); + +describe('resolvePort', () => { + it('首选端口空闲 → 直接命中,无顺延', async () => { + const port = await grabFreePort(); + const r = await resolvePort({ host: HOST, preferred: port, maxOffset: 5 }); + expect(r.port).toBe(port); + expect(r.shiftedFrom).toBeNull(); + }); + + it('首选被占用 → 顺延到下一个空闲端口并记录 shiftedFrom', async () => { + const base = await grabFreePort(); + await occupy(base); + await occupy(base + 1); + const r = await resolvePort({ host: HOST, preferred: base, maxOffset: 5 }); + expect(r.port).toBe(base + 2); + expect(r.shiftedFrom).toBe(base); + }); + + it('顺延耗尽 → 抛错拒启', async () => { + const base = await grabFreePort(); + await occupy(base); + await occupy(base + 1); + await expect(resolvePort({ host: HOST, preferred: base, maxOffset: 1 })) + .rejects.toThrow(/[Nn]o free port/); + }); + + it('显式端口空闲 → 命中', async () => { + const port = await grabFreePort(); + const r = await resolvePort({ host: HOST, explicitPort: port }); + expect(r.port).toBe(port); + expect(r.shiftedFrom).toBeNull(); + }); + + it('显式端口被占用 → 抛错拒启(不顺延)', async () => { + const port = await grabFreePort(); + await occupy(port); + await expect(resolvePort({ host: HOST, explicitPort: port })) + .rejects.toThrow(new RegExp(`${port}`)); + // 显式语义报错文案须体现「显式指定不顺延」 + await expect(resolvePort({ host: HOST, explicitPort: port })) + .rejects.toThrow(/explicit/i); + }); +}); + +describe('explicitPortFromEnv', () => { + it('--port flag 优先于 PORT env', () => { + expect(explicitPortFromEnv(3005, { PORT: '4001' })).toBe(3005); + }); + + it('仅 PORT env → 用之(显式语义)', () => { + expect(explicitPortFromEnv(undefined, { PORT: '4001' })).toBe(4001); + }); + + it('皆无 → undefined(走动态顺延)', () => { + expect(explicitPortFromEnv(undefined, {})).toBeUndefined(); + }); + + it('PORT 非数字/越界 → 抛错', () => { + expect(() => explicitPortFromEnv(undefined, { PORT: 'abc' })).toThrow(/PORT/); + expect(() => explicitPortFromEnv(undefined, { PORT: '70000' })).toThrow(/PORT/); + }); +}); + +describe('parsePortFlag', () => { + it('无 --port → port undefined,args 原样', () => { + expect(parsePortFlag(['web'])).toEqual({ port: undefined, rest: ['web'] }); + }); + + it('--port 3005 → port 3005,flag 从参数中摘除', () => { + expect(parsePortFlag(['web', '--port', '3005'])).toEqual({ port: 3005, rest: ['web'] }); + }); + + it('--port 非数字 → 抛错', () => { + expect(() => parsePortFlag(['--port', 'abc'])).toThrow(/--port/); + }); + + it('--port 缺值 → 抛错', () => { + expect(() => parsePortFlag(['--port'])).toThrow(/--port/); + }); +}); diff --git a/apps/api/src/cli/__tests__/run-web.e2e.test.ts b/apps/api/src/cli/__tests__/run-web.e2e.test.ts new file mode 100644 index 000000000..533d63083 --- /dev/null +++ b/apps/api/src/cli/__tests__/run-web.e2e.test.ts @@ -0,0 +1,108 @@ +/** + * studio run web 活端口 e2e(#571)— 默认 skip,STUDIO_E2E_LIVE=1 才跑(#219 门禁同构)。 + * + * 全链路验证:tsx 起 CLI → 首启检测块输出 → 显式 --port 命中 → health 200 → + * web 静态产物可访问 → SIGINT 优雅退出(前台进程 Ctrl-C 即停)。 + * + * 注:tsx 形态下 app.ts/本模块的 frontend dist 解析到 apps/api/frontend/dist + * (bundle/tsc 形态为 /apps/api/frontend/dist,路径约定同构),测试在该位置 + * 放最小 index.html 避开 auto-build,teardown 清除。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; + +const LIVE = process.env.STUDIO_E2E_LIVE === '1'; +const API_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +// app.ts 的 frontend dist 落点(__dirname/../frontend/dist,全形态同构) +const STUB_FRONTEND_DIST = path.join(API_DIR, 'frontend', 'dist'); + +async function grabFreePort(): Promise { + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve())); + const port = (srv.address() as net.AddressInfo).port; + await new Promise((resolve) => srv.close(() => resolve())); + return port; +} + +describe.skipIf(!LIVE)('studio run web e2e', () => { + let tmpHome: string; + let port: number; + let child: ChildProcess; + let stdoutBuf = ''; + let stubbedFrontend = false; + + beforeAll(async () => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-run-web-e2e-')); + port = await grabFreePort(); + if (!fs.existsSync(path.join(STUB_FRONTEND_DIST, 'index.html'))) { + fs.mkdirSync(STUB_FRONTEND_DIST, { recursive: true }); + fs.writeFileSync(path.join(STUB_FRONTEND_DIST, 'index.html'), 'studio-e2e'); + stubbedFrontend = true; + } + + // node --import tsx 直起(不经 npx 包装),SIGINT 才能直达服务器进程;入口用绝对路径 + child = spawn(process.execPath, ['--import', 'tsx', path.join(API_DIR, 'src', 'cli', 'studio-cli.ts'), 'run', 'web', '--port', String(port)], { + cwd: API_DIR, + env: { + ...process.env, + STUDIO_HOME: tmpHome, + STUDIO_AUTH: 'none', + NODE_ENV: 'test', + CLOUDFLARED_ENABLED: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout?.on('data', (d) => { stdoutBuf += d.toString(); }); + child.stderr?.on('data', (d) => { stdoutBuf += d.toString(); }); + + // 等 health 就绪(首启含 secrets/defaults/harness 自举,放宽到 90s) + const deadline = Date.now() + 90_000; + for (;;) { + try { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(2000) }); + if (res.ok) return; + } catch { /* not ready */ } + if (Date.now() > deadline) throw new Error(`server did not become ready.\n--- output ---\n${stdoutBuf}`); + if (child.exitCode !== null) throw new Error(`server exited early (${child.exitCode}).\n--- output ---\n${stdoutBuf}`); + await new Promise(r => setTimeout(r, 1000)); + } + }, 120_000); + + afterAll(async () => { + if (child && child.exitCode === null) child.kill('SIGKILL'); + if (stubbedFrontend) fs.rmSync(path.join(API_DIR, 'frontend'), { recursive: true, force: true }); + if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('首启检测块打印(数据根 / 监听地址 / Node 版本)', () => { + expect(stdoutBuf).toContain('environment check'); + expect(stdoutBuf).toContain(tmpHome); + expect(stdoutBuf).toContain(`127.0.0.1:${port}`); + expect(stdoutBuf).toContain(process.version); + }); + + it('health endpoint 200', async () => { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(5000) }); + expect(res.status).toBe(200); + }); + + it('web 静态产物可访问(SPA index)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(5000) }); + expect(res.status).toBe(200); + expect(await res.text()).toContain('studio-e2e'); + }); + + it('SIGINT 优雅退出(Ctrl-C 即停)', async () => { + child.kill('SIGINT'); + const code = await new Promise((resolve) => { + const t = setTimeout(() => resolve(null), 15_000); + child.once('exit', (c) => { clearTimeout(t); resolve(c); }); + }); + expect(code).not.toBeNull(); + }, 20_000); +}); diff --git a/apps/api/src/cli/__tests__/run-web.test.ts b/apps/api/src/cli/__tests__/run-web.test.ts new file mode 100644 index 000000000..ffac75976 --- /dev/null +++ b/apps/api/src/cli/__tests__/run-web.test.ts @@ -0,0 +1,50 @@ +/** + * run-web.ts 单元测试(#571):frontend dist 路径解析与 monorepo 根探测。 + * 全链路(起服务/首启面板/SIGINT)由同目录 run-web.e2e.test.ts(STUDIO_E2E_LIVE=1)覆盖。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { resolveFrontendDistFrom, findMonorepoRoot } from '../run-web.js'; + +describe('resolveFrontendDistFrom', () => { + it('src/tsc 形态(.../cli/ 下)→ 上溯两级', () => { + expect(resolveFrontendDistFrom('/pkg/apps/api/src/cli')) + .toBe('/pkg/apps/api/frontend/dist'); + expect(resolveFrontendDistFrom('/pkg/apps/api/dist/cli')) + .toBe('/pkg/apps/api/frontend/dist'); + }); + + it('bundle 拍平形态(.../dist/ 直接落 bundle)→ 上溯一级', () => { + expect(resolveFrontendDistFrom('/pkg/dist-npm/apps/api/dist')) + .toBe('/pkg/dist-npm/apps/api/frontend/dist'); + }); +}); + +describe('findMonorepoRoot', () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-run-web-')); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('向上找到含 apps/web/package.json 的根', () => { + const root = path.join(tmp, 'repo'); + fs.mkdirSync(path.join(root, 'apps/web'), { recursive: true }); + fs.writeFileSync(path.join(root, 'apps/web', 'package.json'), '{}'); + const deep = path.join(root, 'apps/api/dist/cli'); + fs.mkdirSync(deep, { recursive: true }); + expect(findMonorepoRoot(deep)).toBe(root); + }); + + it('无 apps/web(npm 形态)→ null(触发包损坏分支而非 auto-build)', () => { + const isolated = path.join(tmp, 'prefix/lib/node_modules/@dommaker/studio/dist-npm/apps/api/dist'); + fs.mkdirSync(isolated, { recursive: true }); + expect(findMonorepoRoot(isolated)).toBeNull(); + }); +}); diff --git a/apps/api/src/cli/__tests__/server.test.ts b/apps/api/src/cli/__tests__/server.test.ts index 59dfa8eab..4494b69f7 100644 --- a/apps/api/src/cli/__tests__/server.test.ts +++ b/apps/api/src/cli/__tests__/server.test.ts @@ -6,11 +6,24 @@ * - studioStop:端口无监听时输出 "No server found"(PORT/VITE_PORT 指向 * 未占用端口,确保不会误杀本机真实进程); * - studioStatus:API 不可达时输出 not reachable 并提前返回; - * - checkPrerequisites:冒烟(不抛错,输出与否则取决于本机是否装 claude)。 + * - checkPrerequisites:冒烟(不抛错;scanAllProviders 已 mock 隔离真机扫描,见 #577)。 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { checkPrerequisites, studioDb, studioStatus, studioStop } from '../server.js'; +// #577:mock scanAllProviders 隔离真机全量扫描——#573 挂注册表扫描、#574 挂模型探测 +// (kimi ~4s / opencode ~7s)后真扫描在复审环境实测 43-55s,两次上调超时(#565 → 30s) +// 仍被拖爆;扫描成本只随 provider 探测增长,单测不应为机器计时买单。importOriginal +// 部分 mock(wholesale mock import 期崩坑先例,见 agents/CONTEXT.md #575 条), +// KNOWN_PROVIDERS 等其余导出保持真实;git --version 检查仍走真 execSync。 +vi.mock('../../daemon/cli-scanner.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanAllProviders: () => [{ provider: 'kimi', path: '/usr/local/bin/kimi', version: '1.0.0' }], + }; +}); + let logs: string[]; let errs: string[]; let prevPort: string | undefined; @@ -70,5 +83,22 @@ describe('checkPrerequisites', () => { expect(errs[0]).toMatch(/^Missing prerequisites: /); expect(errs[1]).toBe('Install them before running studio up.'); } + // 扫描已由顶部 vi.mock 隔离(#577),本用例只验证默认参数路径不抛错 + + // 缺 git 时的输出形态;无需集成级超时窗口 + }); + + // #573 漂移修正(摸底 Q5):agent CLI 检查对齐 provider 注册表扫描,不再硬编码 claude + it('注入空探测结果 → 报缺 agent CLI(注册表口径,不点名单一 CLI)', () => { + checkPrerequisites([]); + const out = errs.join('\n'); + expect(out).toMatch(/^Missing prerequisites: /); + expect(out).toContain('agent CLI'); + expect(out).toContain('至少需要一个 agent CLI 才能跑执行'); + expect(out).not.toContain('claude CLI'); + }); + + it('注入任一已探测 provider → 不报缺 agent CLI', () => { + checkPrerequisites([{ provider: 'kimi', path: '/usr/local/bin/kimi', version: '1.0.0' }]); + expect(errs.join('\n')).not.toContain('agent CLI'); }); }); diff --git a/apps/api/src/cli/__tests__/skill-validate.test.ts b/apps/api/src/cli/__tests__/skill-validate.test.ts new file mode 100644 index 000000000..be8c42fb9 --- /dev/null +++ b/apps/api/src/cli/__tests__/skill-validate.test.ts @@ -0,0 +1,119 @@ +/** + * skill-validate 规则矩阵测试(#568 P1) + * + * 每个 error/warning 规则一例 + 合法用例(含真实内置正本,对应 AC1)。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { validateSkillDir } from '../skill-validate.js'; + +let root: string; + +function writeSkill(name: string, content: string, dirName = name): string { + const dir = path.join(root, dirName); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), content, 'utf-8'); + return dir; +} + +const VALID = [ + '---', + 'name: my-skill', + 'description: "测试 skill"', + 'triggers: [测试]', + 'status: published', + '---', + '', + '# body', +].join('\n'); + +beforeEach(() => { + root = fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'skill-validate-test-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('validateSkillDir — error 规则', () => { + it('目录不存在 → error', () => { + const r = validateSkillDir(path.join(root, 'nope')); + expect(r.errors.some(e => e.includes('不存在'))).toBe(true); + }); + + it('缺 SKILL.md → error', () => { + const dir = path.join(root, 'empty-skill'); + fs.mkdirSync(dir, { recursive: true }); + const r = validateSkillDir(dir); + expect(r.errors.some(e => e.includes('SKILL.md'))).toBe(true); + }); + + it('frontmatter 不可解析 → error', () => { + const dir = writeSkill('bad-fm', '# 没有 frontmatter 的 markdown\n'); + const r = validateSkillDir(dir); + expect(r.errors.some(e => e.includes('frontmatter'))).toBe(true); + }); + + it('name 缺失 → error', () => { + const dir = writeSkill('no-name', '---\ndescription: "x"\n---\nbody\n'); + const r = validateSkillDir(dir); + expect(r.errors.some(e => e.includes('name'))).toBe(true); + }); + + it('name 为空 → error', () => { + const dir = writeSkill('empty-name', '---\nname: ""\n---\nbody\n'); + const r = validateSkillDir(dir); + expect(r.errors.some(e => e.includes('name'))).toBe(true); + }); + + it('name 与目录名不一致 → error', () => { + const dir = writeSkill('inner-name', VALID.replace('name: my-skill', 'name: inner-name'), 'outer-dir'); + const r = validateSkillDir(dir); + expect(r.errors.some(e => e.includes('inner-name') && e.includes('outer-dir'))).toBe(true); + }); + + it('status 越界(不在词表) → error', () => { + const dir = writeSkill('bad-status', VALID.replace('status: published', 'status: archived')); + const r = validateSkillDir(dir); + expect(r.errors.some(e => e.includes('status') && e.includes('archived'))).toBe(true); + }); +}); + +describe('validateSkillDir — warning 规则', () => { + it('description 缺失 → warning 而非 error', () => { + const dir = writeSkill('no-desc', VALID.replace('name: my-skill', 'name: no-desc').replace('description: "测试 skill"\n', '')); + const r = validateSkillDir(dir); + expect(r.errors).toEqual([]); + expect(r.warnings.some(w => w.includes('description'))).toBe(true); + }); + + it('status 非 published → warning 而非 error', () => { + const dir = writeSkill('draft-skill', VALID.replace('name: my-skill', 'name: draft-skill').replace('status: published', 'status: draft')); + const r = validateSkillDir(dir); + expect(r.errors).toEqual([]); + expect(r.warnings.some(w => w.includes('draft'))).toBe(true); + }); + + it('无 triggers → warning 而非 error', () => { + const dir = writeSkill('no-triggers', VALID.replace('name: my-skill', 'name: no-triggers').replace('triggers: [测试]\n', '')); + const r = validateSkillDir(dir); + expect(r.errors).toEqual([]); + expect(r.warnings.some(w => w.includes('triggers'))).toBe(true); + }); +}); + +describe('validateSkillDir — 合法用例(AC1 正面)', () => { + it('完整 frontmatter 的 skill → 零 error 零 warning', () => { + const dir = writeSkill('my-skill', VALID); + const r = validateSkillDir(dir); + expect(r.errors).toEqual([]); + expect(r.warnings).toEqual([]); + }); + + it('真实内置正本 research → 零 error', () => { + const builtin = path.resolve(__dirname, '../../../../../packages/studio-skill/skills/research'); + const r = validateSkillDir(builtin); + expect(r.errors).toEqual([]); + }); +}); diff --git a/apps/api/src/cli/__tests__/skill.test.ts b/apps/api/src/cli/__tests__/skill.test.ts new file mode 100644 index 000000000..d94670725 --- /dev/null +++ b/apps/api/src/cli/__tests__/skill.test.ts @@ -0,0 +1,192 @@ +/** + * studio skill validate/export/install 命令测试(#568) + * + * P1:validate CLI 退出码;P2:export/install 语义(tmp 目录 + SKILLS_DIR/STUDIO_HOME 隔离)。 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { studioSkill } from '../skill.js'; +import { hashSkillDir, skillLoader } from '@dommaker/studio-skill'; + +let root: string; +let savedEnv: Record; + +function writeSkill(base: string, name: string, files: Record): string { + const dir = path.join(base, name); + for (const [rel, content] of Object.entries(files)) { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content, 'utf-8'); + } + return dir; +} + +function validSkillMd(name: string, extra = ''): string { + return `---\nname: ${name}\ndescription: "测试"\ntriggers: [测试]\nstatus: published\n${extra}---\n\n# body\n`; +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'skill-cmd-test-')); + savedEnv = { SKILLS_DIR: process.env.SKILLS_DIR, STUDIO_HOME: process.env.STUDIO_HOME }; + process.env.STUDIO_HOME = path.join(root, 'home'); + process.env.SKILLS_DIR = path.join(root, 'home', 'skills'); + fs.mkdirSync(process.env.SKILLS_DIR, { recursive: true }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('studio skill validate(CLI 退出码)', () => { + it('合法 skill → 0', async () => { + const dir = writeSkill(root, 'ok-skill', { 'SKILL.md': validSkillMd('ok-skill') }); + expect(await studioSkill(['validate', dir])).toBe(0); + }); + + it('缺 SKILL.md → 1', async () => { + const dir = path.join(root, 'broken'); + fs.mkdirSync(dir, { recursive: true }); + expect(await studioSkill(['validate', dir])).toBe(1); + }); + + it('name≠目录名 → 1', async () => { + const dir = writeSkill(root, 'outer', { 'SKILL.md': validSkillMd('inner') }); + expect(await studioSkill(['validate', dir])).toBe(1); + }); + + it('缺参数 → 1', async () => { + expect(await studioSkill(['validate'])).toBe(1); + }); + + it('未知子命令 → 1', async () => { + expect(await studioSkill(['frobnicate'])).toBe(1); + }); +}); + +describe('studio skill export', () => { + it('产出目录树与数据区逐字节一致,PROVENANCE.json 字段正确(AC2)', async () => { + const src = writeSkill(process.env.SKILLS_DIR!, 'exp-skill', { + 'SKILL.md': validSkillMd('exp-skill', 'version: 3\n'), + 'refs/x.md': 'ref-content\n', + 'refs/deep/y.md': 'deep\n', + }); + const out = path.join(root, 'out'); + fs.mkdirSync(out, { recursive: true }); + expect(await studioSkill(['export', 'exp-skill', out])).toBe(0); + + const dest = path.join(out, 'exp-skill'); + for (const rel of ['SKILL.md', 'refs/x.md', 'refs/deep/y.md']) { + expect(fs.readFileSync(path.join(dest, rel), 'utf-8')) + .toBe(fs.readFileSync(path.join(src, rel), 'utf-8')); + } + const prov = JSON.parse(fs.readFileSync(path.join(dest, 'PROVENANCE.json'), 'utf-8')); + expect(prov.name).toBe('exp-skill'); + expect(prov.version).toBe(3); + expect(prov.exportedAt).toBeTruthy(); + expect(prov.source).toBe('unknown'); // 无 skills-index 记录 + expect(prov.contentHash).toBe(hashSkillDir(src)); + }); + + it('提取来源 skill 的 source/sourceWorkUnits 来自 skills-index.json(AC2)', async () => { + writeSkill(process.env.SKILLS_DIR!, 'ext-skill', { 'SKILL.md': validSkillMd('ext-skill') }); + fs.writeFileSync( + path.join(process.env.STUDIO_HOME!, 'skills-index.json'), + JSON.stringify([{ name: 'ext-skill', source: 'auto_extracted', metadata: JSON.stringify({ sourceGoalIds: ['WU-1', 'WU-2'] }) }]), + 'utf-8', + ); + const out = path.join(root, 'out'); + fs.mkdirSync(out, { recursive: true }); + expect(await studioSkill(['export', 'ext-skill', out])).toBe(0); + const prov = JSON.parse(fs.readFileSync(path.join(out, 'ext-skill', 'PROVENANCE.json'), 'utf-8')); + expect(prov.source).toBe('auto_extracted'); + expect(prov.sourceWorkUnits).toEqual(['WU-1', 'WU-2']); + }); + + it('frontmatter 无 version → PROVENANCE version 缺省 1', async () => { + writeSkill(process.env.SKILLS_DIR!, 'noversion', { 'SKILL.md': validSkillMd('noversion') }); + const out = path.join(root, 'out'); + fs.mkdirSync(out, { recursive: true }); + expect(await studioSkill(['export', 'noversion', out])).toBe(0); + const prov = JSON.parse(fs.readFileSync(path.join(out, 'noversion', 'PROVENANCE.json'), 'utf-8')); + expect(prov.version).toBe(1); + }); + + it('数据区不存在该 skill → 1', async () => { + expect(await studioSkill(['export', 'ghost', root])).toBe(1); + }); + + it('数据区 skill 校验有 error → 1 且不产出', async () => { + writeSkill(process.env.SKILLS_DIR!, 'bad-skill', { 'SKILL.md': '---\nname: other\n---\n' }); + const out = path.join(root, 'out'); + fs.mkdirSync(out, { recursive: true }); + expect(await studioSkill(['export', 'bad-skill', out])).toBe(1); + expect(fs.existsSync(path.join(out, 'bad-skill'))).toBe(false); + }); + + it('outDir 已存在同名 → 拒绝;--force 覆盖', async () => { + writeSkill(process.env.SKILLS_DIR!, 'dup-skill', { 'SKILL.md': validSkillMd('dup-skill') }); + const out = path.join(root, 'out'); + writeSkill(out, 'dup-skill', { 'SKILL.md': 'stale\n' }); + expect(await studioSkill(['export', 'dup-skill', out])).toBe(1); + expect(fs.readFileSync(path.join(out, 'dup-skill', 'SKILL.md'), 'utf-8')).toBe('stale\n'); + expect(await studioSkill(['export', 'dup-skill', out, '--force'])).toBe(0); + expect(fs.readFileSync(path.join(out, 'dup-skill', 'SKILL.md'), 'utf-8')).toBe(validSkillMd('dup-skill')); + }); +}); + +describe('studio skill install', () => { + it('装进数据区后可被 skillLoader.loadSingle 加载(AC3)', async () => { + const src = writeSkill(root, 'new-skill', { 'SKILL.md': validSkillMd('new-skill'), 'refs/a.md': 'a\n' }); + expect(await studioSkill(['install', src])).toBe(0); + const def = skillLoader.loadSingle('new-skill'); + expect(def).not.toBeNull(); + expect(def!.name).toBe('new-skill'); + expect(fs.existsSync(path.join(process.env.SKILLS_DIR!, 'new-skill', 'refs', 'a.md'))).toBe(true); + }); + + it('与内置正本同名 → 硬拒绝且不写任何文件(AC4/Q3)', async () => { + const src = writeSkill(root, 'research', { 'SKILL.md': validSkillMd('research') }); + expect(await studioSkill(['install', src])).toBe(1); + expect(fs.existsSync(path.join(process.env.SKILLS_DIR!, 'research'))).toBe(false); + }); + + it('数据区同名同 hash → no-op(0)', async () => { + const files = { 'SKILL.md': validSkillMd('same-skill'), 'refs/a.md': 'a\n' }; + writeSkill(process.env.SKILLS_DIR!, 'same-skill', files); + const src = writeSkill(root, 'same-skill', files); + expect(await studioSkill(['install', src])).toBe(0); + }); + + it('数据区同名不同 hash → 无 --force 拒绝,有 --force 覆盖(AC4)', async () => { + writeSkill(process.env.SKILLS_DIR!, 'conflict-skill', { 'SKILL.md': validSkillMd('conflict-skill') + 'old\n' }); + const src = writeSkill(root, 'conflict-skill', { 'SKILL.md': validSkillMd('conflict-skill') + 'new\n' }); + expect(await studioSkill(['install', src])).toBe(1); + expect(fs.readFileSync(path.join(process.env.SKILLS_DIR!, 'conflict-skill', 'SKILL.md'), 'utf-8')).toContain('old'); + expect(await studioSkill(['install', src, '--force'])).toBe(0); + expect(fs.readFileSync(path.join(process.env.SKILLS_DIR!, 'conflict-skill', 'SKILL.md'), 'utf-8')).toContain('new'); + }); + + it('install 后 .builtin-hashes.json 与 skills-index.json 内容不变(AC5)', async () => { + const hashesFile = path.join(process.env.SKILLS_DIR!, '.builtin-hashes.json'); + const indexFile = path.join(process.env.STUDIO_HOME!, 'skills-index.json'); + fs.writeFileSync(hashesFile, '{"a":"b"}\n', 'utf-8'); + fs.writeFileSync(indexFile, '[]', 'utf-8'); + const src = writeSkill(root, 'clean-skill', { 'SKILL.md': validSkillMd('clean-skill') }); + expect(await studioSkill(['install', src])).toBe(0); + expect(fs.readFileSync(hashesFile, 'utf-8')).toBe('{"a":"b"}\n'); + expect(fs.readFileSync(indexFile, 'utf-8')).toBe('[]'); + }); + + it('来源目录校验有 error → 1', async () => { + const src = writeSkill(root, 'bad-install', { 'SKILL.md': '# no frontmatter\n' }); + expect(await studioSkill(['install', src])).toBe(1); + expect(fs.existsSync(path.join(process.env.SKILLS_DIR!, 'bad-install'))).toBe(false); + }); +}); diff --git a/apps/api/src/cli/__tests__/workflow.test.ts b/apps/api/src/cli/__tests__/workflow.test.ts index 791a5e9bd..237c1f629 100644 --- a/apps/api/src/cli/__tests__/workflow.test.ts +++ b/apps/api/src/cli/__tests__/workflow.test.ts @@ -1,15 +1,14 @@ /** * workflow.ts 单元测试(T3 拆分新增,pre-commit TDD 门禁)。 * - * 覆盖执行/审批域的离线路径(PORT 指向未占用端口): - * - studioRun:空需求 → usage + exit(1);API 不可达 → 连接失败 + exit(1); + * 覆盖审批域的离线路径(PORT 指向未占用端口): * - studioApprove:无参数 → usage 块(不发请求、不 exit); * - studioReject:无参数 → usage;未知类型 → Unknown reject type + exit(1)(不发请求); * 合法类型但 API 不可达 → Failed + exit(1)。 * process.exit mock 为抛错以断言退出码;process.argv 按用例替换并恢复。 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { studioApprove, studioReject, studioRun } from '../workflow.js'; +import { studioApprove, studioReject } from '../workflow.js'; let logs: string[]; let errs: string[]; @@ -36,23 +35,6 @@ afterEach(() => { else process.env.PORT = prevPort; }); -describe('studioRun', () => { - it('空需求 → usage + exit(1)', async () => { - process.argv = ['node', 'studio', 'run', ' ']; - await expect(studioRun()).rejects.toThrow('exit:1'); - expect(errs.join('\n')).toContain('Usage: studio run "requirement description"'); - }); - - it('API 不可达 → 连接失败提示 + exit(1)', async () => { - process.env.PORT = '19131'; - process.argv = ['node', 'studio', 'run', 'add', 'feature']; - await expect(studioRun()).rejects.toThrow('exit:1'); - const out = errs.join('\n'); - expect(out).toContain('Failed to connect to studio server:'); - expect(out).toContain('Make sure studio is running: studio up'); - }); -}); - describe('studioApprove', () => { it('无参数 → 打印 usage 块,不发请求不 exit', async () => { process.argv = ['node', 'studio', 'approve']; diff --git a/apps/api/src/cli/bootstrap.ts b/apps/api/src/cli/bootstrap.ts new file mode 100644 index 000000000..95874ca90 --- /dev/null +++ b/apps/api/src/cli/bootstrap.ts @@ -0,0 +1,58 @@ +/** + * bootstrap — studio run web / studio up 共用的数据目录与密钥自举(#571) + * + * 自 server.ts studioUp 内联逻辑提取,语义不变: + * - 密钥自举必须先于 .env 加载(.env 中的占位值不得覆盖已生成密钥); + * - env 已显式配置 > .daemon/ 文件 > 生成并落盘。 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { randomBytes } from 'node:crypto'; + +/** run web / up 启动所需的数据根子目录(布局契约:docs/architecture/data-directory-contract.md §2) */ +const DATA_SUBDIRS = ['data', '.analyst', '.daemon', 'knowledge', 'worktrees'] as const; + +/** 创建数据根及子目录,幂等 */ +export function ensureDataDirs(studioDirPath: string): void { + for (const d of DATA_SUBDIRS) { + const dir = path.join(studioDirPath, d); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } +} + +export interface DaemonSecretsResult { + /** env = 环境变量已配置;loaded = 自 .daemon/ 文件读入;generated = 新生成并落盘 */ + jwt: 'env' | 'loaded' | 'generated'; + encryption: 'env' | 'loaded' | 'generated'; +} + +function ensureSecret(daemonDir: string, fileName: string, envKey: 'JWT_SECRET' | 'ENCRYPTION_KEY', env: NodeJS.ProcessEnv): 'env' | 'loaded' | 'generated' { + if (env[envKey]) return 'env'; + const file = path.join(daemonDir, fileName); + if (fs.existsSync(file)) { + env[envKey] = fs.readFileSync(file, 'utf-8').trim(); + return 'loaded'; + } + fs.mkdirSync(daemonDir, { recursive: true }); + const secret = randomBytes(32).toString('hex'); + fs.writeFileSync(file, secret, 'utf-8'); + env[envKey] = secret; + console.log(`Generated ${envKey} (stored in ${file})`); + return 'generated'; +} + +/** 数据目录可写探针:写后即删,失败抛错(preflight storage 检查,契约 §7 首启链路) */ +export function probeStorageWritable(dataDir: string): void { + const probe = path.join(dataDir, '_health_probe'); + fs.writeFileSync(probe, Date.now().toString()); + fs.unlinkSync(probe); +} + +/** 自举 JWT_SECRET / ENCRYPTION_KEY(程序独占区 .daemon/,契约 §3) */ +export function ensureDaemonSecrets(daemonDir: string, env: NodeJS.ProcessEnv = process.env): DaemonSecretsResult { + return { + jwt: ensureSecret(daemonDir, 'jwt-secret', 'JWT_SECRET', env), + encryption: ensureSecret(daemonDir, 'encryption-key', 'ENCRYPTION_KEY', env), + }; +} diff --git a/apps/api/src/cli/data.ts b/apps/api/src/cli/data.ts index c6eb72d8a..5998595db 100644 --- a/apps/api/src/cli/data.ts +++ b/apps/api/src/cli/data.ts @@ -45,9 +45,6 @@ export async function apiCommand(resource: string, args: string[]) { case 'queue': console.log(JSON.stringify(await apiGet(`/${resource}?companyId=${cid}&status=pending`), null, 2)); break; - case 'run': - console.log('Use: studio run '); - break; default: console.log(`studio ${resource} `); } @@ -150,6 +147,11 @@ export async function studioEnv() { export async function studioMcp(args: string[]) { const sub = args[0] || 'tools'; + // #566 P3:install 是纯本地文件操作 + health 探测,不需要登录 token + if (sub === 'install') { + const { mcpInstall } = await import('./mcp-install.js'); + return mcpInstall(args.slice(1)); + } try { await getToken(); switch (sub) { @@ -160,7 +162,7 @@ export async function studioMcp(args: string[]) { console.log(JSON.stringify(await apiGet('/mcp/health'), null, 2)); break; default: - console.log('studio mcp '); + console.log('studio mcp '); } } catch (e: any) { if (e?.cause?.code === 'ECONNREFUSED') console.error('API server not running. Run: studio up'); diff --git a/apps/api/src/cli/first-run-panel.ts b/apps/api/src/cli/first-run-panel.ts new file mode 100644 index 000000000..ddb5882e6 --- /dev/null +++ b/apps/api/src/cli/first-run-panel.ts @@ -0,0 +1,52 @@ +/** + * first-run-panel — studio run web 首启检测块(#571) + * + * 冻结内容(docs/architecture/data-directory-contract.md §7): + * Node 版本(对 engines >=20)、agent CLI 探测结果(复用 cli-scanner 输出: + * provider/path/version)、数据根位置、实际监听地址。缺失 CLI 不阻断, + * 但标注「至少需要一个 agent CLI 才能跑执行」。 + */ + +import type { DetectedRuntime } from '../daemon/cli-scanner.js'; + +export interface FirstRunPanelInfo { + /** process.version(vX.Y.Z) */ + nodeVersion: string; + /** engines 下限主版本(root package.json engines.node) */ + minNodeMajor: number; + /** cli-scanner.scanAllProviders() 输出 */ + providers: DetectedRuntime[]; + /** 数据根(studioDir()) */ + dataRoot: string; + listenHost: string; + listenPort: number; + /** 端口顺延前的首选端口;未顺延时为 null */ + shiftedFrom: number | null; +} + +export function buildFirstRunPanel(info: FirstRunPanelInfo): string { + const lines: string[] = []; + lines.push('Studio — environment check'); + lines.push('──────────────────────────'); + + const major = Number(info.nodeVersion.replace(/^v/, '').split('.')[0]); + const nodeNote = Number.isInteger(major) && major < info.minNodeMajor + ? ` ⚠️ below engines requirement (>= ${info.minNodeMajor})` + : ''; + lines.push(` Node: ${info.nodeVersion}${nodeNote}`); + + if (info.providers.length > 0) { + lines.push(' Agent CLIs:'); + for (const p of info.providers) { + lines.push(` - ${p.provider} ${p.path} ${p.version}`); + } + } else { + lines.push(' Agent CLIs: none detected'); + lines.push(' 至少需要一个 agent CLI 才能跑执行(at least one agent CLI is required to run executions)'); + } + + lines.push(` Data dir: ${info.dataRoot}`); + const shiftNote = info.shiftedFrom !== null ? ` (port ${info.shiftedFrom} in use, shifted / 顺延)` : ''; + lines.push(` Listen: http://${info.listenHost}:${info.listenPort}${shiftNote}`); + return lines.join('\n'); +} diff --git a/apps/api/src/cli/mcp-install.ts b/apps/api/src/cli/mcp-install.ts new file mode 100644 index 000000000..734a93dc0 --- /dev/null +++ b/apps/api/src/cli/mcp-install.ts @@ -0,0 +1,166 @@ +// ── studio mcp install(#566 P3)── +// 把 studio 对外只读 MCP 入口(/api/v1/mcp/external/sse)一键写进本机 agent 的用户级配置。 +// 幂等合并且只动 `studio` 一个 key;写前备份 .bak;JSON 解析失败拒写。 +// +// agent 支持面(2026-09-16 核查,方案「实施时核查项」结论): +// - claude ~/.claude.json mcpServers.studio = { type: 'sse', url }(Q4 决策:写用户级) +// - kimi ~/.kimi-code/mcp.json mcpServers.studio = { transport: 'sse', url }(官方文档 SSE 支持) +// - opencode ~/.config/opencode/opencode.json mcp.studio = { type: 'remote', url }(remote 支持 SSE 端点) +// - codex 不支持 SSE transport(仅 streamable HTTP),按 Q2 决策不引入 mcp-remote 桥 → 报错提示 + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const SERVER_KEY = 'studio'; +const HEALTH_URL = 'http://localhost:3001/api/v1/mcp/health'; +const DEFAULT_SSE_URL = 'http://localhost:3001/api/v1/mcp/external/sse'; +const PROBE_TIMEOUT_MS = 2000; + +interface McpAgentAdapter { + /** 用户级配置文件绝对路径 */ + configPath: () => string; + /** MCP 段的容器 key(claude/kimi = mcpServers,opencode = mcp) */ + containerKey: string; + /** studio server 条目(值内 URL 只写 localhost,脱敏纪律) */ + entry: (url: string) => Record; +} + +const ADAPTERS: Record = { + claude: { + configPath: () => path.join(os.homedir(), '.claude.json'), + containerKey: 'mcpServers', + entry: (url) => ({ type: 'sse', url }), + }, + kimi: { + configPath: () => path.join(os.homedir(), '.kimi-code', 'mcp.json'), + containerKey: 'mcpServers', + entry: (url) => ({ transport: 'sse', url }), + }, + opencode: { + configPath: () => path.join(os.homedir(), '.config', 'opencode', 'opencode.json'), + containerKey: 'mcp', + entry: (url) => ({ type: 'remote', url }), + }, +}; + +/** SSE 不支持、按 Q2 不架桥的 agent:install 时报错提示而非静默写错配置 */ +const UNSUPPORTED: Record = { + codex: 'codex 不支持 SSE transport(仅 streamable HTTP),本版不引入 mcp-remote 桥(#566 Q2 决策),跳过。', +}; + +function usage(): void { + console.log('studio mcp install [--url ] [--print] [--uninstall]'); + console.log(` 支持: ${Object.keys(ADAPTERS).join(' | ')}(不支持: ${Object.keys(UNSUPPORTED).join(' | ')})`); +} + +/** Q3 决策:探测 3001 health,通了用默认 URL;不通报错提示 --url,不顺延猜端口 */ +async function resolveUrl(flags: Record): Promise { + if (flags.url) return flags.url; + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS); + const res = await fetch(HEALTH_URL, { signal: ctrl.signal }); + clearTimeout(timer); + if (res.ok) return DEFAULT_SSE_URL; + } catch { /* fall through to error */ } + return null; +} + +function readConfig(file: string): Record | null { + if (!fs.existsSync(file)) return {}; + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch { /* fall through */ } + return null; // 解析失败或非对象 → 拒写 +} + +function writeConfig(file: string, config: Record): void { + if (fs.existsSync(file)) { + fs.copyFileSync(file, `${file}.bak`); + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n'); +} + +export async function mcpInstall(args: string[]): Promise { + const agent = args.find(a => !a.startsWith('--')); + const flags: Record = {}; + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--') && i + 1 < args.length && !args[i + 1].startsWith('--')) { + flags[args[i].slice(2)] = args[++i]; + } else if (args[i].startsWith('--')) { + flags[args[i].slice(2)] = 'true'; + } + } + + if (!agent) { + usage(); + return; + } + if (UNSUPPORTED[agent]) { + console.error(UNSUPPORTED[agent]); + return; + } + const adapter = ADAPTERS[agent]; + if (!adapter) { + console.error(`未知 agent: ${agent}`); + usage(); + return; + } + + const file = adapter.configPath(); + + // --uninstall:只删 studio key,既有字段无损 + if (flags.uninstall === 'true') { + const config = readConfig(file); + if (config === null) { + console.error(`配置文件损坏,拒绝修改: ${file}`); + return; + } + const container = config[adapter.containerKey]; + if (!container || container[SERVER_KEY] === undefined) { + console.log(`未安装(${file} 无 ${adapter.containerKey}.${SERVER_KEY}),无需改动`); + return; + } + delete container[SERVER_KEY]; + writeConfig(file, config); + console.log(`已卸载 ${SERVER_KEY} MCP 配置: ${file}(备份 ${file}.bak)`); + return; + } + + const url = await resolveUrl(flags); + if (!url) { + console.error(`Studio API 未运行(探测 ${HEALTH_URL} 失败)。请先 studio up,或用 --url 显式指定 SSE 地址。`); + return; + } + + const entry = adapter.entry(url); + + // --print:干跑输出片段不落盘 + if (flags.print === 'true') { + console.log(`目标文件: ${file}`); + console.log(JSON.stringify({ [adapter.containerKey]: { [SERVER_KEY]: entry } }, null, 2)); + return; + } + + const config = readConfig(file); + if (config === null) { + console.error(`配置文件 JSON 解析失败,拒绝覆写: ${file}(请手工修复后重试)`); + return; + } + if (!config[adapter.containerKey] || typeof config[adapter.containerKey] !== 'object') { + config[adapter.containerKey] = {}; + } + + const existing = config[adapter.containerKey][SERVER_KEY]; + if (existing !== undefined && JSON.stringify(existing) === JSON.stringify(entry)) { + console.log(`已是目标配置,无需改动: ${file}`); + return; + } + + config[adapter.containerKey][SERVER_KEY] = entry; + writeConfig(file, config); + console.log(`已写入 ${SERVER_KEY} MCP 配置: ${file}(备份 ${file}.bak,重开 ${agent} 会话生效)`); +} diff --git a/apps/api/src/cli/port-probe.ts b/apps/api/src/cli/port-probe.ts new file mode 100644 index 000000000..002f04a79 --- /dev/null +++ b/apps/api/src/cli/port-probe.ts @@ -0,0 +1,92 @@ +/** + * port-probe — studio run web 端口探测与动态顺延(#571) + * + * 冻结语义(docs/architecture/data-directory-contract.md §7): + * - 默认 3001,占用则顺次探测 +1…(上限 +100);顺延结果由调用方标明; + * - 显式指定(--port / PORT)占用即拒启——显式意图不被顺延覆盖; + * - 顺延耗尽报错拒启。 + * + * 探测用 node net 模块(跨平台),不走 lsof(Linux-only,契约 §9 冲突 7)。 + */ + +import net from 'node:net'; + +export const DEFAULT_PORT = 3001; +export const MAX_PORT_OFFSET = 100; + +export interface ResolvePortOptions { + host: string; + /** 显式指定端口(--port / PORT):占用即拒启 */ + explicitPort?: number; + /** 缺省首选端口(默认 3001) */ + preferred?: number; + /** 顺延上限(默认 +100) */ + maxOffset?: number; +} + +export interface ResolvedPort { + port: number; + /** 发生顺延时为原首选端口,未顺延为 null */ + shiftedFrom: number | null; +} + +/** 尝试在 host:port 监听以探测可用性(跨平台,占用/权限错误一律视为不可用) */ +export function probePortFree(port: number, host: string): Promise { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.once('error', () => resolve(false)); + srv.once('listening', () => srv.close(() => resolve(true))); + srv.listen(port, host); + }); +} + +export async function resolvePort(opts: ResolvePortOptions): Promise { + const host = opts.host; + + if (opts.explicitPort !== undefined) { + const port = opts.explicitPort; + if (await probePortFree(port, host)) return { port, shiftedFrom: null }; + throw new Error( + `Port ${port} was explicitly requested (--port / PORT) but is already in use. ` + + 'Explicit intent is never overridden by fallback — free the port or pick another.', + ); + } + + const preferred = opts.preferred ?? DEFAULT_PORT; + const maxOffset = opts.maxOffset ?? MAX_PORT_OFFSET; + for (let offset = 0; offset <= maxOffset; offset++) { + const port = preferred + offset; + if (await probePortFree(port, host)) { + return { port, shiftedFrom: offset === 0 ? null : preferred }; + } + } + throw new Error( + `No free port in ${preferred}..${preferred + maxOffset} (fallback exhausted). ` + + 'Free a port or pass --port to choose one explicitly.', + ); +} + +/** 显式端口来源合一:--port flag 优先,其次 PORT env;皆无 → undefined(动态顺延) */ +export function explicitPortFromEnv(flagPort: number | undefined, env: NodeJS.ProcessEnv = process.env): number | undefined { + if (flagPort !== undefined) return flagPort; + const raw = env.PORT; + if (raw === undefined || raw === '') return undefined; + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`PORT must be an integer between 1 and 65535, got: ${raw}`); + } + return port; +} + +/** 解析 --port flag:返回端口与摘除 flag 后的参数;缺值/非数字即抛错 */ +export function parsePortFlag(args: string[]): { port?: number; rest: string[] } { + const idx = args.indexOf('--port'); + if (idx === -1) return { port: undefined, rest: args }; + const raw = args[idx + 1]; + if (raw === undefined) throw new Error('--port requires a value (e.g. --port 3001)'); + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`--port must be an integer between 1 and 65535, got: ${raw}`); + } + return { port, rest: args.filter((_, i) => i !== idx && i !== idx + 1) }; +} diff --git a/apps/api/src/cli/run-web.ts b/apps/api/src/cli/run-web.ts new file mode 100644 index 000000000..312dace0c --- /dev/null +++ b/apps/api/src/cli/run-web.ts @@ -0,0 +1,100 @@ +// ── studio run web:npm 本地形态一体起服务总入口(#571)── +// API + 托管已构建 web dist + 首启检测块 + 端口动态顺延;前台进程,Ctrl-C 即停 +//(SIGINT → index.ts shutdown 处理器)。studio up 原样保留(服务器形态)。 +// +// 数据区迁移钩子(#572 迁移框架落地后挂入):接入点在本函数 PREFLIGHT 之前、 +// 首启检测块之后——迁移失败 = 拒绝启动(契约 §5)。 +// +// frontend dist 路径约定与 app.ts:133 一致(<模块目录上溯>/frontend/dist): +// src/tsc 形态本文件在 .../cli/ 下 → 上溯两级 = apps/api/frontend/dist; +// esbuild bundle 形态 cli/ 被拍平,bundle 直接在 .../dist/ → 上溯一级 = dist-npm/apps/api/frontend/dist。 +// tsx 直跑 src 形态下 web 静态托管本就不工作(dev 走独立 vite),此处不另开分支。 + +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { STUDIO_DIR } from './shared.js'; +import { ensureDataDirs, ensureDaemonSecrets, probeStorageWritable } from './bootstrap.js'; +import { scanAllProviders } from '../daemon/cli-scanner.js'; +import { buildFirstRunPanel } from './first-run-panel.js'; +import { parsePortFlag, resolvePort, explicitPortFromEnv } from './port-probe.js'; +import { ensureFrontendDist } from '../utils/frontend-dist.js'; +import { resolveListenHost } from '../utils/listen-host.js'; + +const CLI_DIR = path.dirname(fileURLToPath(import.meta.url)); + +/** frontend dist 目录:cli/ 形态上溯两级,bundle 拍平形态上溯一级(见文件头注释) */ +export function resolveFrontendDistFrom(cliDir: string): string { + const up = path.basename(cliDir) === 'cli' ? 2 : 1; + return path.resolve(cliDir, ...Array(up).fill('..'), 'frontend', 'dist'); +} + +/** 从 CLI 所在位置向上找含 apps/web 源码的 monorepo 根(dev 形态 auto-build 分支用) */ +export function findMonorepoRoot(from: string): string | null { + let dir = from; + for (let i = 0; i < 8 && dir !== path.dirname(dir); i++) { + if (fs.existsSync(path.join(dir, 'apps/web', 'package.json'))) return dir; + dir = path.dirname(dir); + } + return null; +} + +export async function studioRunWeb(rawArgs: string[]): Promise { + const { port: flagPort } = parsePortFlag(rawArgs); + + // 1. 数据目录 + 密钥自举(必须先于任何 .env/配置加载,占位值不得覆盖已生成密钥) + ensureDataDirs(STUDIO_DIR); + const daemonDir = path.join(STUDIO_DIR, '.daemon'); + ensureDaemonSecrets(daemonDir); + + // 2. 默认环境变量(不覆盖显式配置) + if (!process.env.ANALYST_DIR) process.env.ANALYST_DIR = path.join(STUDIO_DIR, '.analyst'); + if (!process.env.DAEMON_DIR) process.env.DAEMON_DIR = daemonDir; + if (!process.env.WORKTREES_DIR) process.env.WORKTREES_DIR = path.join(STUDIO_DIR, 'worktrees'); + // KNOWLEDGE_DIR 缺省由 index.ts 经 utils/runtime-paths 归数据根(harness-knowledge) + + // 3. 端口:显式(--port / PORT)占用即拒启;缺省 3001 起动态顺延(上限 +100) + const host = resolveListenHost(process.env); + const explicitPort = explicitPortFromEnv(flagPort); + const resolved = await resolvePort({ host, explicitPort }); + + // 4. 首启检测块(Node 版本 / agent CLI 探测 / 数据根 / 实际监听地址;缺 CLI 不阻断) + const providers = scanAllProviders(); + console.log(buildFirstRunPanel({ + nodeVersion: process.version, + minNodeMajor: 20, + providers, + dataRoot: STUDIO_DIR, + listenHost: host, + listenPort: resolved.port, + shiftedFrom: resolved.shiftedFrom, + })); + + // 5. Preflight:数据根可写 + 前端产物(npm 形态缺 dist = 包损坏报错,不现场构建) + try { + probeStorageWritable(path.join(STUDIO_DIR, 'data')); + } catch (e: any) { + console.error(`❌ Data dir not writable: ${path.join(STUDIO_DIR, 'data')} (${e.message})`); + process.exit(1); + } + const frontendDist = resolveFrontendDistFrom(CLI_DIR); + const frontend = ensureFrontendDist(frontendDist, findMonorepoRoot(CLI_DIR) ?? CLI_DIR); + if (!frontend.ok) { + console.error(frontend.message); + if (frontend.corrupt) process.exit(1); + } + + // 6. 首启默认数据(默认频道 + admin,幂等) + try { + const { createOpsService } = await import('../modules/agents/ops/ops.service.js'); + const defaults = await createOpsService(resolved.port).ensureDefaults(); + console.log(`Defaults: ${defaults.channels} channels, admin ${defaults.admin ? 'exists' : 'created'}`); + } catch (e: any) { + console.error('Defaults init failed (non-blocking):', String(e?.message ?? e).slice(0, 200)); + } + + // 7. 起服务(index.ts 于 import 时自启;SIGINT/SIGTERM 走其 shutdown 处理器) + process.env.PORT = String(resolved.port); + console.log('Starting server...'); + await import('../index.js'); +} diff --git a/apps/api/src/cli/server.ts b/apps/api/src/cli/server.ts index 50cf6b3d8..1fdc04d88 100644 --- a/apps/api/src/cli/server.ts +++ b/apps/api/src/cli/server.ts @@ -4,15 +4,26 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { STUDIO_DIR, DATA_DIR, ensureDir } from './shared.js'; +import { fileURLToPath } from 'url'; +import { STUDIO_DIR } from './shared.js'; +import { ensureDataDirs, ensureDaemonSecrets } from './bootstrap.js'; +import { defaultKnowledgeDir } from '../utils/runtime-paths.js'; +import { scanAllProviders, KNOWN_PROVIDERS, type DetectedRuntime } from '../daemon/cli-scanner.js'; +import { resolvePort, explicitPortFromEnv } from './port-probe.js'; +import { resolveListenHost } from '../utils/listen-host.js'; -export function checkPrerequisites() { +// ESM 形态(esbuild bundle / tsc ESNext)无 CJS __dirname,与 app.ts 同法自取 +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// #573 漂移修正(摸底 Q5):agent CLI 检查对齐 provider 注册表扫描(覆盖全部 +// 内置 provider),不再硬编码 claude;缺失不阻断,仅警告(契约 §7 语义)。 +// providers 可注入以便测试。 +export function checkPrerequisites(providers: DetectedRuntime[] = scanAllProviders()) { const missing: string[] = []; try { execSync('git --version', { stdio: 'pipe' }); } catch { missing.push('git'); } - try { - const out = execSync('claude --version 2>&1 || echo "NOT_FOUND"', { encoding: 'utf-8', stdio: 'pipe' }); - if (out.includes('NOT_FOUND')) missing.push('claude CLI'); - } catch { missing.push('claude CLI'); } + if (providers.length === 0) { + missing.push(`agent CLI(注册表扫描 ${KNOWN_PROVIDERS.join('/')} 均无命中;至少需要一个 agent CLI 才能跑执行)`); + } if (missing.length > 0) { console.error(`Missing prerequisites: ${missing.join(', ')}`); console.error('Install them before running studio up.'); @@ -22,43 +33,14 @@ export function checkPrerequisites() { export async function studioUp(configPath?: string) { console.log('Studio starting...'); - // 1. 确保数据目录 - ensureDir(STUDIO_DIR); - ensureDir(DATA_DIR); + // 1. 确保数据目录(#571:共用 bootstrap;events/ ensureDir 已随契约 §8 收编移除) + ensureDataDirs(STUDIO_DIR); const ANALYST_DIR = path.join(STUDIO_DIR, '.analyst'); const DAEMON_DIR = path.join(STUDIO_DIR, '.daemon'); - const KNOWLEDGE_DIR = path.join(STUDIO_DIR, 'knowledge'); - const EVENTS_DIR = path.join(STUDIO_DIR, 'events'); const WORKTREES_DIR = path.join(STUDIO_DIR, 'worktrees'); - ensureDir(ANALYST_DIR); - ensureDir(DAEMON_DIR); - ensureDir(KNOWLEDGE_DIR); - ensureDir(EVENTS_DIR); - ensureDir(WORKTREES_DIR); // 2. 自动生成密钥(必须在加载 .env 之前,避免 .env 中的占位值覆盖生成的密钥) - if (!process.env.JWT_SECRET) { - const jwtFile = path.join(DAEMON_DIR, 'jwt-secret'); - if (fs.existsSync(jwtFile)) { - process.env.JWT_SECRET = fs.readFileSync(jwtFile, 'utf-8').trim(); - } else { - const secret = require('crypto').randomBytes(32).toString('hex'); - fs.writeFileSync(jwtFile, secret, 'utf-8'); - process.env.JWT_SECRET = secret; - console.log(`Generated JWT_SECRET (stored in ${jwtFile})`); - } - } - if (!process.env.ENCRYPTION_KEY) { - const encFile = path.join(DAEMON_DIR, 'encryption-key'); - if (fs.existsSync(encFile)) { - process.env.ENCRYPTION_KEY = fs.readFileSync(encFile, 'utf-8').trim(); - } else { - const encKey = require('crypto').randomBytes(32).toString('hex'); - fs.writeFileSync(encFile, encKey, 'utf-8'); - process.env.ENCRYPTION_KEY = encKey; - console.log(`Generated ENCRYPTION_KEY (stored in ${encFile})`); - } - } + ensureDaemonSecrets(DAEMON_DIR); // 3. 加载配置(--config 参数 或 STUDIO_CONFIG_DIR 环境变量 或 默认路径) // 注:密钥先生成再加载 .env — .env 中的 JWT_SECRET 占位值不会覆盖已生成的密钥 @@ -88,8 +70,9 @@ export async function studioUp(configPath?: string) { // DATABASE_URL removed (Spec 4 Phase 4) — FileStore only if (!process.env.ANALYST_DIR) process.env.ANALYST_DIR = ANALYST_DIR; if (!process.env.DAEMON_DIR) process.env.DAEMON_DIR = DAEMON_DIR; - if (!process.env.KNOWLEDGE_DIR) process.env.KNOWLEDGE_DIR = KNOWLEDGE_DIR; - if (!process.env.EVENTS_DIR) process.env.EVENTS_DIR = EVENTS_DIR; + // KNOWLEDGE_DIR = harness hook 知识目录(非 FileKnowledgeStore 的 data 根 knowledge/), + // 缺省归数据根 harness-knowledge(#571 / 契约 §8);events/ 双口径已收编,不再注入 EVENTS_DIR + if (!process.env.KNOWLEDGE_DIR) process.env.KNOWLEDGE_DIR = defaultKnowledgeDir(); if (!process.env.WORKTREES_DIR) process.env.WORKTREES_DIR = WORKTREES_DIR; console.log(`Data dir: ${STUDIO_DIR}`); @@ -116,10 +99,25 @@ export async function studioUp(configPath?: string) { } console.log(`Project root: ${process.env.REPO_DIR}`); - const port = parseInt(process.env.PORT || '3001'); + // 端口(#573 契约 §7 单口径):PORT 显式指定占用即拒启;缺省 3001 起动态 + // 顺延(上限 +100),顺延结果在日志标明实际端口。原 ops preflight lsof + // abort 语义收编为此处分支。 + const host = resolveListenHost(process.env); + let port: number; + try { + const resolved = await resolvePort({ host, explicitPort: explicitPortFromEnv(undefined) }); + port = resolved.port; + if (resolved.shiftedFrom !== null) { + console.log(`Port ${resolved.shiftedFrom} in use — shifted to ${port}(动态顺延)`); + } + } catch (e: any) { + console.error(`❌ ${e.message}`); + process.exit(1); + } + process.env.PORT = String(port); // ── Ops Pre-flight Guard ── - // Replace old --accept-data-loss db push + port check with full pre-flight + // 存储/前端产物/进程/磁盘等启动检查;端口检查已收编 cli/port-probe(#573) try { const { createOpsService } = await import('../modules/agents/ops/ops.service.js'); const ops = createOpsService(port); diff --git a/apps/api/src/cli/skill-validate.ts b/apps/api/src/cli/skill-validate.ts new file mode 100644 index 000000000..420a3fbe4 --- /dev/null +++ b/apps/api/src/cli/skill-validate.ts @@ -0,0 +1,72 @@ +/** + * skill-validate — skill 目录布局 + frontmatter 离线校验(#568) + * + * 纯函数,零 I/O 副作用(只读)。规则出处 docs/plans/2026-09-skill-export-import.md §3.2: + * - error:目录不存在或无 SKILL.md;frontmatter 不可解析;name 缺失/为空; + * name ≠ 目录名(loader 按目录名寻址、id 取 meta.name,不一致会索引错乱); + * status 显式存在但不在词表。 + * - warning:description 缺失(SkillStore 生成的 draft 本就无 description,不能是 error); + * status 非 published(不会被注入,仅提示);无 triggers(selector 匹配弱)。 + * + * 解析口径复用 studio-shared parseFrontmatter(与 loader 同源); + * triggers/consumers 仅 manifest-loader 多认,此处对 triggers 做宽松存在性检查。 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseFrontmatter } from '@dommaker/studio-shared'; + +export interface SkillValidation { + errors: string[]; + warnings: string[]; +} + +const STATUS_VOCAB = ['published', 'draft', 'deprecated']; + +export function validateSkillDir(dir: string): SkillValidation { + const errors: string[] = []; + const warnings: string[] = []; + + const skillFile = path.join(dir, 'SKILL.md'); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory() || !fs.existsSync(skillFile)) { + errors.push(`skill 目录不存在或缺少 SKILL.md:${dir}`); + return { errors, warnings }; + } + + const parsed = parseFrontmatter(fs.readFileSync(skillFile, 'utf-8')); + if (!parsed) { + errors.push('SKILL.md frontmatter 不可解析(缺 --- 围栏)'); + return { errors, warnings }; + } + + const meta = parsed.meta; + const name = typeof meta.name === 'string' ? meta.name.trim() : ''; + if (!name) { + errors.push('frontmatter name 缺失或为空'); + } else { + const dirName = path.basename(path.resolve(dir)); + if (name !== dirName) { + errors.push(`frontmatter name (${name}) 与目录名 (${dirName}) 不一致`); + } + } + + if (meta.status !== undefined) { + const status = String(meta.status); + if (!STATUS_VOCAB.includes(status)) { + errors.push(`status 越界:${status}(词表:${STATUS_VOCAB.join('/')})`); + } else if (status !== 'published') { + warnings.push(`status=${status}:不会被 loader 注入,仅提示`); + } + } + + if (!meta.description) { + warnings.push('description 缺失(索引进 prompt 的主信号)'); + } + + const triggers = meta.triggers; + if (!triggers || (Array.isArray(triggers) && triggers.length === 0)) { + warnings.push('无 triggers(selector 匹配弱)'); + } + + return { errors, warnings }; +} diff --git a/apps/api/src/cli/skill.ts b/apps/api/src/cli/skill.ts new file mode 100644 index 000000000..4e976b1d5 --- /dev/null +++ b/apps/api/src/cli/skill.ts @@ -0,0 +1,187 @@ +/** + * studio skill validate/export/install — skill 流动三件套(#568) + * + * 纯本地文件操作,不依赖 daemon 在线(与走 HTTP API 的 `studio skill list` 本质区别)。 + * 方案正本:docs/plans/2026-09-skill-export-import.md。 + * + * 与 seed 机制的隔离规则(§3.4): + * - install 与内置正本同名 → 硬拒绝(占名会堵死 seed hash 升级通道),提示改名; + * - install/export/validate 对 /.builtin-hashes.json 只字不碰 + * (台账在 skill 目录的父级,按目录树拷贝天然不含); + * - install 不写 skills-index.json(与 seed 对齐:目录即注册)。 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseFrontmatter } from '@dommaker/studio-shared'; +import { studioDir, studioPath } from '@dommaker/studio-shared/studio-dir'; +import { hashSkillDir, builtinSkillsDir } from '@dommaker/studio-skill'; +import { validateSkillDir } from './skill-validate.js'; + +export async function studioSkill(args: string[]): Promise { + const sub = args[0]; + switch (sub) { + case 'validate': + return skillValidateCmd(args.slice(1)); + case 'export': + return skillExport(args.slice(1)); + case 'install': + return skillInstall(args.slice(1)); + default: + console.error('Usage: studio skill | export [outDir] [--force] | install [--force]>'); + return 1; + } +} + +/** 数据区 skill 目录,运行期读 SKILLS_DIR 支持测试隔离(与 loader/seed 同口径) */ +function skillsDir(): string { + return process.env.SKILLS_DIR || studioPath('skills'); +} + +/** 递归整目录拷贝(与 seed copyTree 同语义:先清目标再逐文件复制) */ +function copyTree(src: string, dest: string): void { + fs.rmSync(dest, { recursive: true, force: true }); + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyTree(s, d); + else if (entry.isFile()) fs.copyFileSync(s, d); + } +} + +function splitFlags(args: string[]): { positional: string[]; force: boolean } { + return { positional: args.filter(a => a !== '--force'), force: args.includes('--force') }; +} + +/** 从 skills-index.json 取 SkillRecord 的来源信息(取不到 → source: 'unknown') */ +function readProvenance(name: string): { source: string; sourceWorkUnits?: string[] } { + try { + const indexFile = path.join(studioDir(), 'skills-index.json'); + const records = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as Array<{ name?: string; source?: string; metadata?: string | null }>; + const record = records.find(r => r.name === name); + if (!record) return { source: 'unknown' }; + let sourceWorkUnits: string[] | undefined; + if (record.metadata) { + try { + const meta = JSON.parse(record.metadata) as { sourceGoalIds?: string[] }; + if (Array.isArray(meta.sourceGoalIds) && meta.sourceGoalIds.length > 0) { + sourceWorkUnits = meta.sourceGoalIds; + } + } catch { /* metadata 非法 JSON 时来源信息从缺 */ } + } + return { source: record.source || 'unknown', ...(sourceWorkUnits ? { sourceWorkUnits } : {}) }; + } catch { + return { source: 'unknown' }; + } +} + +function readVersion(skillDir: string): number { + const parsed = parseFrontmatter(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8')); + const v = parsed?.meta.version; + return typeof v === 'number' ? v : 1; +} + +function skillValidateCmd(args: string[]): number { + const dir = args[0]; + if (!dir) { + console.error('Usage: studio skill validate '); + return 1; + } + const abs = path.resolve(dir); + const { errors, warnings } = validateSkillDir(abs); + for (const e of errors) console.error(`error: ${e}`); + for (const w of warnings) console.log(`warning: ${w}`); + if (errors.length > 0) { + console.error(`validate ${abs}: ${errors.length} error(s), ${warnings.length} warning(s)`); + return 1; + } + console.log(`validate ${abs}: ok (${warnings.length} warning(s))`); + return 0; +} + +function skillExport(args: string[]): number { + const { positional, force } = splitFlags(args); + const name = positional[0]; + if (!name) { + console.error('Usage: studio skill export [outDir] [--force]'); + return 1; + } + const src = path.join(skillsDir(), name); + const { errors } = validateSkillDir(src); + if (errors.length > 0) { + for (const e of errors) console.error(`error: ${e}`); + console.error(`export 拒绝:数据区 skill '${name}' 校验未过`); + return 1; + } + + const outDir = path.resolve(positional[1] || process.cwd()); + const dest = path.join(outDir, name); + if (fs.existsSync(dest) && !force) { + console.error(`export 拒绝:${dest} 已存在,使用 --force 覆盖`); + return 1; + } + copyTree(src, dest); + + // PROVENANCE.json 边车(不进 frontmatter,loader/manifest 天然忽略未知文件)。 + // 脱敏:只含名字/版本/hash/WU id,不写任何绝对路径或机器信息。 + const provenance = { + name, + version: readVersion(src), + exportedAt: new Date().toISOString(), + ...readProvenance(name), + contentHash: hashSkillDir(src), + }; + fs.writeFileSync(path.join(dest, 'PROVENANCE.json'), JSON.stringify(provenance, null, 2) + '\n', 'utf-8'); + console.log(`exported: ${dest}(含 PROVENANCE.json)`); + return 0; +} + +function skillInstall(args: string[]): number { + const { positional, force } = splitFlags(args); + const dir = positional[0]; + if (!dir) { + console.error('Usage: studio skill install [--force]'); + return 1; + } + const src = path.resolve(dir); + const { errors } = validateSkillDir(src); + if (errors.length > 0) { + for (const e of errors) console.error(`error: ${e}`); + console.error('install 拒绝:来源目录校验未过'); + return 1; + } + const name = path.basename(src); + + // 隔离规则 1:与内置正本同名 → 硬拒绝(占名会让 seed 判 skippedLegacy/adopted, + // 内置同名 skill 从此收不到升级,或用户内容被静默覆盖) + let builtins: string[] = []; + try { + builtins = fs.readdirSync(builtinSkillsDir(), { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { /* 内置正本不可读时名单为空,不阻断 install */ } + if (builtins.includes(name)) { + const existing = path.join(skillsDir(), name); + console.error( + `install 拒绝:'${name}' 与内置 skill 正本同名(会堵死 seed hash 升级通道),请改名后重试` + + (fs.existsSync(existing) ? `;数据区已存在同名 legacy 目录 ${existing},请人工决定改名或删除` : ''), + ); + return 1; + } + + const dest = path.join(skillsDir(), name); + if (fs.existsSync(dest)) { + if (hashSkillDir(src) === hashSkillDir(dest)) { + console.log(`install:'${name}' 内容相同,no-op`); + return 0; + } + if (!force) { + console.error(`install 拒绝:数据区已存在 '${name}' 且内容不同,使用 --force 覆盖`); + return 1; + } + } + copyTree(src, dest); + console.log(`installed: ${dest}`); + return 0; +} diff --git a/apps/api/src/cli/studio-cli.ts b/apps/api/src/cli/studio-cli.ts index af3d91aae..b534065e4 100755 --- a/apps/api/src/cli/studio-cli.ts +++ b/apps/api/src/cli/studio-cli.ts @@ -6,16 +6,26 @@ import { extractConfigFlag } from './shared.js'; import { studioUp, studioStatus, studioStop, studioRestart, studioLogs, studioDb } from './server.js'; import { studioTest, studioBuild } from './dev.js'; -import { studioRun, studioApprove, studioReject } from './workflow.js'; +import { studioApprove, studioReject } from './workflow.js'; import { apiCommand, studioKnowledge, studioEnv, studioMcp, studioHarnessCli } from './data.js'; import { studioConfig } from './config.js'; import { studioProject, studioWorkon } from './admin.js'; +import { studioRunWeb } from './run-web.js'; async function main() { const { configPath, args } = extractConfigFlag(process.argv.slice(2)); const cmd = args[0]; switch (cmd) { + case 'run': + // #571:studio run web = npm 本地形态一体起服务总入口(旧 studio run 提交需求语义已随 #569 移除) + if (args[1] === 'web') { + await studioRunWeb(args.slice(1)); + } else { + console.error('Usage: studio run web [--port ]'); + process.exit(1); + } + break; case 'up': await studioUp(configPath); break; @@ -49,9 +59,6 @@ async function main() { case 'build': studioBuild(); break; - case 'run': - await studioRun(); - break; case 'status': await studioStatus(); break; @@ -85,9 +92,17 @@ async function main() { case 'harness': await studioHarnessCli(args.slice(1)); break; - case 'skill': - await apiCommand('skills', args.slice(1)); - break; + case 'skill': { + // #568:export/validate/install 为纯本地文件操作(daemon 离线可用),其余维持 HTTP API + const sub = args[1]; + if (sub === 'export' || sub === 'validate' || sub === 'install') { + const { studioSkill } = await import('./skill.js'); + process.exitCode = await studioSkill(args.slice(1)); + } else { + await apiCommand('skills', args.slice(1)); + } + break; + } case 'config': await studioConfig(args.slice(1)); break; @@ -95,6 +110,7 @@ async function main() { console.log('Studio CLI'); console.log(''); console.log(' 服务管理:'); + console.log(' studio run web Start Studio (API + web) in foreground — npm 本地形态总入口'); console.log(' studio up Start Studio server'); console.log(' studio stop Stop Studio server'); console.log(' studio restart Restart Studio server'); @@ -105,17 +121,17 @@ async function main() { console.log(' studio build Build all packages (pnpm build)'); console.log(' studio test Quick API E2E test (8 checks)'); console.log(''); - console.log(' 执行:'); - console.log(' studio run Submit to #研发 (@Analyst)'); - console.log(''); console.log(' 数据:'); console.log(' studio knowledge Knowledge base'); console.log(' studio channel Channel list'); console.log(' studio task Task management'); console.log(' studio agent Agent status'); console.log(' studio env Environment snapshot'); - console.log(' studio mcp MCP Server management'); + console.log(' studio mcp MCP Server management / install read-only MCP into agent'); console.log(' studio skill Skills list'); + console.log(' studio skill validate Validate skill directory (local, offline)'); + console.log(' studio skill export [dir] Export skill from data dir (local, offline)'); + console.log(' studio skill install Install skill into data dir (local, offline)'); console.log(' studio harness Harness constraint check'); console.log(''); console.log(' 审批:'); diff --git a/apps/api/src/cli/workflow.ts b/apps/api/src/cli/workflow.ts index c23a43fc8..2b7e52ed2 100644 --- a/apps/api/src/cli/workflow.ts +++ b/apps/api/src/cli/workflow.ts @@ -1,53 +1,5 @@ -// ── 执行/审批域(2026-07-20 自 studio-cli.ts 按命令域拆分)── -// studio run / approve / reject - -export async function studioRun() { - const args = process.argv.slice(3); - const requirement = args.join(' ').trim(); - - if (!requirement) { - console.error('Usage: studio run "requirement description"'); - process.exit(1); - } - - const port = process.env.PORT || '3001'; - const baseUrl = `http://localhost:${port}/api/v1`; - - try { - // Get #研发 channel - const chResp = await fetch(`${baseUrl}/channels`); - const { data: channels } = await chResp.json() as { data: Array<{ id: string; type: string; name: string }> }; - // Dev mode → prefer #研发-dev, prod → prefer #研发 (skip -dev suffixed) - const isDev = process.env.NODE_ENV === 'development'; - const rndChannel = isDev - ? channels.find((c: any) => c.type === 'rnd' && c.name?.endsWith('-dev')) - : channels.find((c: any) => c.type === 'rnd' && !c.name?.endsWith('-dev')); - if (!rndChannel) { - console.error(`No ${isDev ? '#研发-dev' : '#研发'} channel found. Start studio with: studio up`); - process.exit(1); - } - - // Send message (@mention → WorkUnit creation in route handler) - const content = /@analyst/i.test(requirement) ? requirement : `${requirement} @Analyst`; - const msgResp = await fetch(`${baseUrl}/channels/${rndChannel.id}/messages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content }), - }); - const msgResult = await msgResp.json() as { success: boolean; data?: { id: string }; error?: string }; - - if (!msgResult.success) { - console.error('Failed to submit:', msgResult.error); - process.exit(1); - } - - console.log(`✅ Submitted to ${rndChannel.name}. Analyst is analyzing...`); - } catch (err: any) { - console.error('Failed to connect to studio server:', err.message); - console.error('Make sure studio is running: studio up'); - process.exit(1); - } -} +// ── 审批域(2026-07-20 自 studio-cli.ts 按命令域拆分)── +// studio approve / reject(2026-09-16 #569:studio run 已随 @Analyst 角色退役删除) // ─── studio approve/reject ─── diff --git a/apps/api/src/daemon/__tests__/cli-scanner.test.ts b/apps/api/src/daemon/__tests__/cli-scanner.test.ts index 2245231c0..708f4bc49 100644 --- a/apps/api/src/daemon/__tests__/cli-scanner.test.ts +++ b/apps/api/src/daemon/__tests__/cli-scanner.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { resetCapabilityProbeCache, resetModelProbeCache } from '@dommaker/studio-shared/node'; import { detectProvider, scanAllProviders, hasDocker, KNOWN_PROVIDERS } from '../cli-scanner'; // Mock child_process(detectProvider 走 execFileSync,hasDocker 走 execSync) @@ -14,6 +15,10 @@ const mockExecFileSync = vi.mocked(execFileSync); describe('cli-scanner', () => { beforeEach(() => { vi.clearAllMocks(); + // #565/#574: studio-shared 探测模块是进程内缓存(key=binary@version), + // 用例间必须复位,避免跨用例污染 + resetCapabilityProbeCache(); + resetModelProbeCache(); }); afterEach(() => { @@ -24,13 +29,19 @@ describe('cli-scanner', () => { test('returns runtime info when CLI is found', () => { mockExecFileSync .mockReturnValueOnce('/usr/local/bin/claude\n') // which - .mockReturnValueOnce('claude 1.2.3\n'); // --version + .mockReturnValueOnce('claude 1.2.3\n') // --version + .mockReturnValueOnce('{\n "loggedIn": true\n}\n'); // auth status const result = detectProvider('claude'); expect(result).toEqual({ provider: 'claude', path: '/usr/local/bin/claude', version: 'claude 1.2.3', + auth: 'ok', + authCheckedAt: expect.any(String), + // #574: claude 未声明 listModels(实测无模型列表命令)→ 静态兜底 + models: ['opus', 'sonnet'], + modelsSource: 'fallback', }); }); @@ -50,7 +61,7 @@ describe('cli-scanner', () => { .mockImplementationOnce(() => { throw new Error('no -v'); }); // -v const result = detectProvider('codex'); - expect(result).toEqual({ + expect(result).toMatchObject({ provider: 'codex', path: '/usr/local/bin/codex', version: 'unknown', @@ -68,6 +79,11 @@ describe('cli-scanner', () => { provider: 'opencode', path: '/usr/local/bin/opencode', version: 'opencode v0.5.0', + auth: 'unknown', // opencode 不声明 authProbe(#565:无可靠探测手段) + authCheckedAt: expect.any(String), + // #574: opencode 声明了 listModels,但本用例 mock 无第四次返回(探测失败)→ 静态兜底 + models: ['opencode/big-pickle'], + modelsSource: 'fallback', }); }); @@ -81,6 +97,16 @@ describe('cli-scanner', () => { provider: 'kimi', path: '/root/.kimi-code/bin/kimi', version: '0.27.0', + auth: 'unknown', // kimi 不声明 authProbe(#565:login 无 status 子命令) + authCheckedAt: expect.any(String), + // #574: kimi 声明了 listModels,但本用例 mock 无第三次返回(探测失败)→ 静态兜底 + models: [ + 'kimi-code/kimi-for-coding', + 'kimi-code/kimi-for-coding-highspeed', + 'kimi-code/k3', + 'kimi-code/k3-256k', + ], + modelsSource: 'fallback', }); }); @@ -149,6 +175,137 @@ describe('cli-scanner', () => { }); }); + describe('auth probe(#565 AC3:三态,未声明恒 unknown,不猜配置目录)', () => { + test('claude:auth status 输出 loggedIn=true → ok', () => { + mockExecFileSync + .mockReturnValueOnce('/usr/local/bin/claude\n') + .mockReturnValueOnce('2.1.273\n') + .mockReturnValueOnce('{\n "loggedIn": true,\n "authMethod": "oauth_token"\n}\n'); + + const result = detectProvider('claude'); + expect(result?.auth).toBe('ok'); + expect(result?.authHint).toBeUndefined(); + expect(result?.authCheckedAt).toEqual(expect.any(String)); + }); + + test('claude:输出命中 loggedIn=false 模式 → failed + 修复 hint', () => { + mockExecFileSync + .mockReturnValueOnce('/usr/local/bin/claude\n') + .mockReturnValueOnce('2.1.273\n') + .mockReturnValueOnce('{\n "loggedIn": false\n}\n'); + + const result = detectProvider('claude'); + expect(result?.auth).toBe('failed'); + expect(result?.authHint).toContain('claude login'); + }); + + test('codex:login status 非零退出(进程跑起来了)→ failed + 修复 hint', () => { + mockExecFileSync.mockImplementation(((cmd: string, args?: readonly string[]) => { + if (cmd === 'which') return '/usr/local/bin/codex\n'; + if (args?.includes('--version')) return 'codex-cli 0.147.0\n'; + if (args?.join(' ') === 'exec --help') return 'Usage: codex exec --json\n'; // capability warm + if (args?.join(' ') === 'login status') { + // 0.147.0 实测:未登录 exit 1 + "Not logged in" + const err = new Error('Command failed') as Error & { status: number; stderr: string }; + err.status = 1; + err.stderr = 'Not logged in'; + throw err; + } + throw new Error(`unexpected: ${cmd} ${args?.join(' ')}`); + }) as typeof execFileSync); + + const result = detectProvider('codex'); + expect(result?.auth).toBe('failed'); + expect(result?.authHint).toContain('codex login'); + }); + + test('探测自身出错(spawn 失败,无 exit status)→ unknown,不误报 failed', () => { + mockExecFileSync.mockImplementation(((cmd: string, args?: readonly string[]) => { + if (cmd === 'which') return '/usr/local/bin/claude\n'; + if (args?.includes('--version')) return '2.1.273\n'; + // auth status spawn 失败(如 ENOENT/超时):没有 status 字段 + throw new Error('spawn claude ENOENT'); + }) as typeof execFileSync); + + const result = detectProvider('claude'); + expect(result?.auth).toBe('unknown'); + expect(result?.authHint).toBeUndefined(); + }); + + test('未声明 authProbe 的 provider(kimi)恒 unknown,且不发起 auth 探测进程', () => { + mockExecFileSync + .mockReturnValueOnce('/root/.kimi-code/bin/kimi\n') + .mockReturnValueOnce('0.38.0\n'); + + const result = detectProvider('kimi'); + expect(result?.auth).toBe('unknown'); + // which + --version + #574 模型列表探测(provider list --json,mock 无返回 → 兜底) + // 共三次进程调用,无第四次 auth 探测 + expect(mockExecFileSync).toHaveBeenCalledTimes(3); + const authCalls = mockExecFileSync.mock.calls.filter( + (call) => Array.isArray(call[1]) && (call[1] as string[]).includes('login'), + ); + expect(authCalls.length).toBe(0); + }); + }); + + describe('models probe(#574:listModels 活模型 + fallback 兜底)', () => { + const CODEX_DEBUG_MODELS = JSON.stringify({ + models: [ + { slug: 'gpt-5.6-sol', visibility: 'list' }, + { slug: 'gpt-5.5', visibility: 'list' }, + { slug: 'gpt-5.4', visibility: 'hide' }, + ], + }); + + function mockCodexCli(debugModelsOutput: string) { + mockExecFileSync.mockImplementation(((cmd: string, args?: readonly string[]) => { + if (cmd === 'which') return '/usr/local/bin/codex\n'; + if (args?.includes('--version')) return 'codex-cli 0.147.0\n'; + if (args?.join(' ') === 'exec --help') return 'Usage: codex exec --json\n'; // capability warm + if (args?.join(' ') === 'login status') return 'Logged in\n'; + if (args?.join(' ') === 'debug models') return debugModelsOutput; + throw new Error(`unexpected: ${cmd} ${args?.join(' ')}`); + }) as typeof execFileSync); + } + + test('codex:`debug models` 探测成功 → models 为实测可见 slug,source=live', () => { + mockCodexCli(CODEX_DEBUG_MODELS); + const result = detectProvider('codex'); + expect(result?.models).toEqual(['gpt-5.6-sol', 'gpt-5.5']); + expect(result?.modelsSource).toBe('live'); + }); + + test('codex:探测失败 → 退回注册表静态 fallbackModels,source=fallback', () => { + mockExecFileSync.mockImplementation(((cmd: string, args?: readonly string[]) => { + if (cmd === 'which') return '/usr/local/bin/codex\n'; + if (args?.includes('--version')) return 'codex-cli 0.147.0\n'; + if (args?.join(' ') === 'exec --help') return 'Usage: codex exec --json\n'; + if (args?.join(' ') === 'login status') return 'Logged in\n'; + throw new Error('Command failed'); // debug models 非零退出 + }) as typeof execFileSync); + + const result = detectProvider('codex'); + expect(result?.modelsSource).toBe('fallback'); + expect(result?.models).toContain('gpt-5.6-sol'); + }); + + test('同 binary@version 重扫命中进程内缓存,不重复跑 debug models', () => { + mockCodexCli(CODEX_DEBUG_MODELS); + detectProvider('codex'); + const callsAfterFirst = mockExecFileSync.mock.calls.length; + + const second = detectProvider('codex'); + expect(second?.models).toEqual(['gpt-5.6-sol', 'gpt-5.5']); + const probeCalls = mockExecFileSync.mock.calls.filter( + (call) => Array.isArray(call[1]) && (call[1] as string[]).join(' ') === 'debug models', + ); + expect(probeCalls.length).toBe(1); + // 第二次扫描仍有 which/version/auth 开销,仅探测进程被缓存省掉 + expect(mockExecFileSync.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + }); + describe('KNOWN_PROVIDERS', () => { test('contains expected providers (F4: kimi in, openclaw out of defaults)', () => { expect(KNOWN_PROVIDERS).toContain('claude'); diff --git a/apps/api/src/daemon/cli-scanner.ts b/apps/api/src/daemon/cli-scanner.ts index 467320c89..75864a1aa 100644 --- a/apps/api/src/daemon/cli-scanner.ts +++ b/apps/api/src/daemon/cli-scanner.ts @@ -7,7 +7,7 @@ */ import { execFileSync, execSync } from 'child_process'; -import { listScanProviders, resolveProviderDefinition, type ProviderId } from '@dommaker/studio-shared/node'; +import { listScanProviders, resolveProviderDefinition, warmCapabilityProbe, getProviderModels, type ProviderId } from '@dommaker/studio-shared/node'; /** Known agent CLI providers (from the provider registry, built-ins + user config) */ export const KNOWN_PROVIDERS: readonly string[] = listScanProviders(); @@ -17,6 +17,61 @@ export interface DetectedRuntime { provider: ProviderName; path: string; version: string; + /** + * #565: 登录态三态。未声明 authProbe / 探测自身出错(spawn 失败、超时)→ unknown, + * 不根据配置目录/凭证文件存在性猜测。探测节奏 = 启动扫描 + 手动 rescan, + * 结果随 runtimes 记录持久(见 authCheckedAt),不随每次 GET 重跑。 + */ + auth: 'ok' | 'failed' | 'unknown'; + /** auth=failed 时的修复提示(产品语言,来自注册表 authProbe.authHint) */ + authHint?: string; + /** 本次 auth 探测时间(ISO) */ + authCheckedAt: string; + /** + * #574: 模型清单。live = listModels 探测实测;fallback = 注册表静态兜底 + * (探测失败/超时/未声明 listModels 时)。探测节奏同 auth:启动扫描 + 手动 + * rescan,随 runtimes 记录持久;进程内缓存 key=binary@version,重扫不重跑。 + */ + models?: string[]; + /** models 的来源标注(消费面区分实测/静态兜底) */ + modelsSource?: 'live' | 'fallback'; +} + +/** + * #565: 登录态探测(注册表 authProbe 声明驱动)。 + * exit 0 且输出不命中已知未登录模式 → ok;命中模式或非零 exit → failed; + * 未声明 / spawn 失败(进程根本没跑起来)→ unknown。 + */ +function probeAuth( + def: ReturnType, + binaryPath: string, +): Pick { + const authCheckedAt = new Date().toISOString(); + const probe = def.authProbe; + if (!probe) return { auth: 'unknown', authCheckedAt }; + + const notLoggedIn = (output: string): boolean => + (probe.notLoggedInPatterns ?? []).some(p => new RegExp(p).test(output)); + + try { + // execFileSync 数组参数不经 shell(2026-08-25 安全收口) + const output = execFileSync(binaryPath, probe.args, { + encoding: 'utf-8', + stdio: 'pipe', + timeout: probe.timeoutMs ?? 10_000, + }); + if (notLoggedIn(String(output))) { + return { auth: 'failed', authHint: probe.authHint, authCheckedAt }; + } + return { auth: 'ok', authCheckedAt }; + } catch (err) { + // 进程跑了但非零退出(err.status 是数字)→ failed;spawn 失败/超时 → unknown + const e = err as { status?: number }; + if (typeof e.status === 'number') { + return { auth: 'failed', authHint: probe.authHint, authCheckedAt }; + } + return { auth: 'unknown', authCheckedAt }; + } } /** @@ -51,7 +106,30 @@ export function detectProvider(name: ProviderName): DetectedRuntime | null { } } - return { provider: name, path: cliPath, version }; + // #565: 声明了能力探测的 provider 随扫描 warm flag 缓存(best-effort; + // 探测失败 fail-open,spawn 时行为与现状一致) + if (def.capabilityProbe) { + try { + warmCapabilityProbe(name, cliPath, version); + } catch { /* best-effort:不阻断扫描 */ } + } + + const authResult = probeAuth(def, cliPath); + + // #574: 模型清单探测(进程内缓存 key=binary@version,重扫命中缓存不重跑; + // 失败/未声明回退静态 fallbackModels,best-effort 不阻断扫描) + let modelsResult: { models: string[]; source: 'live' | 'fallback' } | undefined; + try { + modelsResult = getProviderModels(name, cliPath, version); + } catch { /* best-effort:不阻断扫描 */ } + + return { + provider: name, + path: cliPath, + version, + ...authResult, + ...(modelsResult ? { models: modelsResult.models, modelsSource: modelsResult.source } : {}), + }; } catch { // binary not found — try the next candidate } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8370fe643..8588392f2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,7 +2,9 @@ import 'dotenv/config'; // 固定 KnowledgeStore 路径 — CWD 无关, 与 memory-knowledge-sync hook 共用 -process.env.KNOWLEDGE_DIR = process.env.KNOWLEDGE_DIR || require('path').resolve(__dirname, '..', '.harness', 'knowledge'); +// #571:缺省自 monorepo 相对路径迁至数据根 harness-knowledge(契约 §8 待归位),env 可覆盖 +import { defaultKnowledgeDir, tunnelUrlFile } from './utils/runtime-paths.js'; +process.env.KNOWLEDGE_DIR = defaultKnowledgeDir(); import { createServer } from 'http'; import { app, registerRoutes } from './app.js'; @@ -19,6 +21,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { studioDir as resolveStudioDir, warnIfNonProdUsesProdRoot } from '@dommaker/studio-shared/studio-dir'; import { resolveListenHost } from './utils/listen-host.js'; +import { handleServerListenError } from './utils/listen-error.js'; +import { isCloudflaredEnabled } from './utils/cloudflared.js'; const PORT = process.env.PORT || 3001; // 2026-08-25 安全收口:默认只绑回环(服务器模式经 nginx 同机反代,npm 自托管 @@ -35,7 +39,7 @@ function loadConfig(): void { // Fallback: STUDIO_HOME(缺省 ~/.studio)defaults const studioDir = resolveStudioDir(); if (!process.env.WORKTREES_DIR) process.env.WORKTREES_DIR = path.join(studioDir, 'worktrees'); - if (!process.env.EVENTS_DIR) process.env.EVENTS_DIR = path.join(studioDir, 'events'); + // events/ 目录双口径已收编(#571 / 契约 §8):统一事件流正本在 logs/,不再注入 EVENTS_DIR return; } @@ -65,6 +69,18 @@ async function start() { // FileStore 自动建目录,无需 DB 连接 logger.info('Storage initialized (FileStore)'); + // #572 / 契约 §5(data-directory-contract.md):数据区 schema 版本迁移,必须在 reconcileIndex + // 之前——迁移后的布局才是对账/读写的正本。失败抛 MigrationError(含备份路径+回滚指引), + // 由外层 catch 兜底 process.exit(1) = 失败拒启,不留半迁移态带病运行。 + const { runMigrations } = await import('@dommaker/studio-shared/migrations'); + const migration = await runMigrations(resolveStudioDir()); + if (migration.applied.length > 0 || migration.skipped.length > 0) { + logger.info('[Migration] 数据区 schema 迁移完成', { + fromVersion: migration.fromVersion, toVersion: migration.toVersion, + applied: migration.applied, skipped: migration.skipped, backupPaths: migration.backupPaths, + }); + } + // #170(决策 #65-3):启动对账 WorkUnit events vs index —— 不一致即按事件流重建索引 // 并走告警频道(#62 决议出口:dispatchMonitorAlerts 既有管线,warning 级)。 // 历史数据可能已分叉,不对账永远不可知;失败不阻断启动。 @@ -328,6 +344,13 @@ async function start() { logger.info('[RoleMemory] Completion extraction subscribed (workunit.status_changed → done)'); } catch (e) { logger.warn('[RoleMemory] Completion extraction init failed', { error: String(e) }); } + // skill 度量地基票 B:WU done → transcript 后验扫描 skill 使用痕迹 → skill_used 事件(纯确定性零 LLM) + try { + const { initSkillUsageScan } = await import('./modules/skills/skill-usage-scan.js'); + initSkillUsageScan(); + logger.info('[SkillUsageScan] Subscribed to workunit.status_changed (done → transcript scan)'); + } catch (e) { logger.warn('[SkillUsageScan] Failed to subscribe', { error: String(e) }); } + // #143 蒸馏主链路:WU done → 门槛检测(纯计数零 LLM)→ distill_proposal 人审卡 try { const { initDistillLoop } = await import('./modules/distill/distill-runtime.js'); @@ -461,13 +484,10 @@ async function start() { }); // 启动服务器 + // #573 端口双口径收口(契约 §7):端口由 port-probe 启动前单口径解析, + // listen 时再撞 EADDRINUSE = 竞态 → 拒启,删除原 3s 无限重试 server.on('error', (err: any) => { - if (err?.code === 'EADDRINUSE') { - logger.warn(`Port ${PORT} in use, retrying in 3s...`); - setTimeout(() => { server.close(); server.listen(Number(PORT), HOST); }, 3000); - } else { - logger.error('Server listen error', { code: err?.code, message: err?.message, port: PORT }); - } + handleServerListenError(err, { port: PORT, host: HOST, logger }); }); logger.info('Attempting server.listen...'); server.listen(Number(PORT), HOST, () => { @@ -478,11 +498,11 @@ async function start() { // Cloudflared Tunnel — 自动重启守护 + URL 变化通知 let cloudflaredProc: ChildProcess | null = null; let lastTunnelUrl = ''; - const TUNNEL_URL_FILE = require('path').join(require('os').homedir(), '.claude', 'tunnel-url'); + const TUNNEL_URL_FILE = tunnelUrlFile(); const notifyTunnelUrl = async (url: string) => { // 写文件,方便随时查看 - try { require('fs').writeFileSync(TUNNEL_URL_FILE, url, 'utf-8'); } catch {} + try { fs.writeFileSync(TUNNEL_URL_FILE, url, 'utf-8'); } catch {} // 显著日志 logger.info('='.repeat(70)); logger.info(`🔗 DISCORD INTERACTIONS ENDPOINT URL: ${url}/api/v1/discord/interactions`); @@ -536,10 +556,11 @@ async function start() { logger.warn('[Cloudflared] Not available, Discord tunnel disabled'); } }; - if (process.env.CLOUDFLARED_ENABLED !== 'false') { + // #571 冲突 5 冻结:外联隧道默认关,仅显式 CLOUDFLARED_ENABLED=true 拉起 + if (isCloudflaredEnabled()) { startCloudflared(); } else { - logger.info('[Cloudflared] Disabled via CLOUDFLARED_ENABLED=false'); + logger.info('[Cloudflared] Disabled (default off; set CLOUDFLARED_ENABLED=true to enable)'); } // 优雅关闭 diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index ff38a1d63..9d33f2db4 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -17,11 +17,11 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) ### 核心导出 - `monitor/` - monitor.service(门面,5min 轮询健康监控+渐进告警)、monitor-probes(WU 级探测:失败趋势/停滞/超时/池停滞/评审停滞/僵尸认领守卫——#464 起 stale_claim_guard 命中除告警外同步向 WU 所在频道发「已沉睡」milestone 提醒,频率同 staleGuardBlockedAt 标记生命周期)、monitor-system-probes(系统/知识级探测与自修复;#409 起 worktree GC 唯一入口 gcStaleWorktrees——7d 阈值、目录按调用时解析 WORKTREES_DIR>~/worktrees、prune 走 execAsync、不调 git worktree remove)、monitor-alerts(告警分发/Triage 升级,指纹冷却去重 w4h/c1h)、monitor-reports(轨迹评估/每日洞察/交互观察)、monitor-lifecycle(知识沉淀闸门+每日 TTL 清理) -- `ops/` - ops.service(preflight 启动检查/health 轮询自愈/代理守护/默认数据 ensure;#409 起 worktree GC 已归一到 monitor-system-probes.gcStaleWorktrees 单实现,Ops 版 cleanupWorktrees 及其 hourly 挂载删除)、createHealthRoutes(/healthz 健康端点)、proc-probes(#344:/proc 系统探测单出口——statfs 磁盘/meminfo 内存//proc/*/stat 僵尸//proc/loadavg,零子进程;#374 起 ops.getStatus、monitor systemHealthCheck、ops preflight 磁盘检查三处同源委托;#418 起 +readProcessCmdline/countProcessesByCmdline/listPidsByCmdline/listZombieProcesses 四导出,triage.service 与 knowledge/env-snapper 探测同源) +- `ops/` - ops.service(preflight 启动检查/health 轮询自愈/代理守护/默认数据 ensure;#409 起 worktree GC 已归一到 monitor-system-probes.gcStaleWorktrees 单实现,Ops 版 cleanupWorktrees 及其 hourly 挂载删除;#571 起 frontend dist 检查委托 utils/frontend-dist.ts——npm 形态缺 dist = 包损坏 critical abort,auto-build 仅 monorepo dev 形态保留)、createHealthRoutes(/healthz 健康端点)、proc-probes(#344:/proc 系统探测单出口——statfs 磁盘/meminfo 内存//proc/*/stat 僵尸//proc/loadavg,零子进程;#374 起 ops.getStatus、monitor systemHealthCheck、ops preflight 磁盘检查三处同源委托;#418 起 +readProcessCmdline/countProcessesByCmdline/listPidsByCmdline/listZombieProcesses 四导出,triage.service 与 knowledge/env-snapper 探测同源) - `auditor/` - auditor.service(门面,24h 日审跨任务审计)、auditor-rules(错误归类/技能建议/知识健康;#523 起 OKR 低达成建 okr_proposal 单收口 WorkUnitService.create——原 commitSnapshot 直写不发 eventBus 事件,对唤醒体系隐形)、auditor-execution(低风险自动应用/确认卡片经 review-proposal 正本发卡/Resolution/Triage 升级;#439 起铃铛通知 link 带消息粒度——发卡后按 proposalId 反查卡消息拼 `?highlight=`,link 构造唯一出口 `buildAuditorNotificationLink`,反查不到降级频道粒度不阻断)、auditor-reports(行为趋势/七日趋势/tier 反馈)、review-adapter(#356:auditor_suggestion 卡接线 review-proposal 正本,kind=auditor,onApprove 建未指派 task 工单——自旧 channels/card-decision.service 搬入;审批走通用端点 /review-proposals/auditor/:id/*;`findAuditorCardMessageId` = 按提案 id 反查卡消息唯一出口,onApprove 原卡链接与 #439 通知 link 共用) - `knowledge/` - knowledge-curator.service(门面,冷启动+每日维护)、knowledge-extraction(提取 prompt 单一来源)、knowledge-cold-start(四源导入 docs/code/git/manual)、knowledge-maintenance(语义去重/质量评估/过期验证/矛盾审查) -- `loop/` - agent-loop(循环编排,拆分后仅保留类逻辑+re-export;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info) -- 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-loop.ts(门面,决策循环编排+re-export)、agent-output-parser(ACTION 协议解析/审查结论/任务拆分/动态间隔)、prompt-composer(prompt 组装,分段软定额截断)、agent-loop-workspace(worktree 解析/归属链/PMO 分支)、session-resume(续用判定+上限 MAX_SESSIONS_PER_WU=5)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、workunit-token-events(tokens/tool:call 事件写入)、agent-loop-utils(进程存活/git 根/worktrees 工具)、wu-test-guards(测试特征 WU 判定)、agent-loop-instance-state(实例状态:启动失败/idle 心跳/忙闲 SSE)、agent-loop-record-result(recordResult:提交/子任务/验证+状态迁移+里程碑;步骤安全阀 stepSafetyLimit——review=30、其余=15、#471 起 plan=null 不走强制收口)、agent-loop-step-guards(前置守卫:测试 WU 关闭/token 预算熔断;#471 起 plan 步数额度守卫在 agentStep 内 PLAN_STEP_LIMIT=60,到线 need_input 挂 blocked 转人,waiting-input 回复续期) +- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、step-guards(#541 入口守卫链:B2 测试 WU / C3 日预算 / #162 WU 预算 / #471 plan 额度,Ctx/Deps/Outcome 对称 completion-gates,agentStep 注入副作用 deps)、step-retry-policy(#543 步执行重试策略:#94 续用丢失降级 + #96 上下文溢出两段孪生骨架收敛为 runStepRetry 一份,Ctx/Deps/Outcome 同 step-guards,MAX_SESSIONS_PER_WU 与 resetUnestablishedSessionBookkeeping 正本在此,fake executor 直测占额/簿记)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、completion-harvest(#542 per-WU-type COMPLETE 收割注册表:type→收割器组,review/analysis|plan/decision/spec 五键,新 type=注册一行,单收割器抛错仅跳过)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) +- 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、wu-test-guards(测试特征 WU 判定) ### 依赖关系 @@ -30,7 +30,10 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) ### 注意事项 +- **wholesale mock 桶包的 import 期崩坑(#575/#578)**:测试对 `@dommaker/studio-shared` / `@dommaker/harness` 做 wholesale `vi.mock` 时,被 mock 包内任何**模块级**导出调用(如 workunit.service 的 `createSettledTracker()`、goal.hooks 的 `new CheckCache()`)都会要求 mock 补齐该导出,否则套件 0 测试即崩("No X export is defined on the mock")。上游包新增模块级导出调用 = 全部 wholesale-mock 套件的潜在随动点;修法二选一:mock 补桩,或 `importOriginal` 部分 mock(monitor-probes.test.ts 先例) +- **monitor-reports 知识质量审计构造(harness 1.8.0 / #134①)**:`new KnowledgeAudit(store, 阈值?)` —— baseDir/autoFix 已退出构造参数(autoFix 留在 `run({autoFix})`),日兜底走 `sharedStore` 单例(与运行时知识库同路径 UNIFIED_KNOWLEDGE_DIR),不再自拼 `{baseDir}` 构造;该调用点经 `await import(...) as any` 动态导入,TS 编译期拦不住签名漂移,改签名时须连 `__tests__/monitor-reports.test.ts` 的 KnowledgeAudit mock 一起核 - **周期循环 scan-sharing(候选 3,2026-09-08)**:monitor `check()` 一轮开头一次 `getIndex()`,快照作 caller-private 传给 6 个 WU 探针(收 `snapshots` 参数、内存 filter,不再各自 getIndex;动作前新鲜度复核 `getIndex({id})` 点读不受影响)——**新 WU 探针一律收快照不自己读**;auditor generateSuggestions 的 4 周事件窗口同样每轮一次读、多消费方共享(原 skill 循环内 N+1 全窗口扫描)。跨 job 不共享(各自的轮各自读,非快照层) +- **decision/spec 豁免超时巡检(#553 裁决,2026-09-16)**:checkTotalExecutionTime 跳过 DECISION_SPEC_TYPES——#108 裁剪状态机无 closed、决策/成文单可等关键人多天,语义选「跳过巡检」(非降级告警/仍关闭),与 autoAbandonStaleBlocked 的 #176 豁免同源。此前 2.5h 强杀被状态机拒绝、catch 吞掉只记日志,导致每轮巡检重复 critical 告警但永不关闭 - **路由分叉坑(#391 实测)**:`/api/v1/agents` 的 GET / 由 legacy `routes.ts`(agents-registry,响应条目**无 id 字段**)处理;前端 profile 列表走 `/api/v1/agent-profiles`(agent-profile.routes.ts,有 id)。发现 profileId 勿用 `/agents` - **AgentProfile 持久化**:`~/.studio/data/agents/{id}/profile.json` + `state.json`;原子写+mkdir 锁,仅可显式 DELETE;保留名 `studio`。删除时反向收敛引用(agent-profile.service.delete):清各频道 members + #497 起同步把 channel.routing 中指名该 profile 的档归一化为 null(其他档不动;无引用的频道零写入,幂等) - **studio 系统角色语义(2026-09-10 修正)**:AC-1.3「studio 不挂 loop」已废除——trigger `assigneeRole` 指名 = 独占认领(仅该角色 loop 可见),studio 无 loop 期间指名 WU 全部结构性死单(doc-semantic-review 滞留 143h 事故)。现 studio = 系统维护任务执行角色(正常挂 loop,`channels=[]` 只看得见指名给它的单)+ systemExecutor 直调身份(读其 provider)双重用途;保留名保护扩展到**禁止停用**(update status 非 active 拒绝),删除仍拒。存量旧 description 文案由 `ensureStudioProfile` 按 `STUDIO_ROLE_LEGACY_DESCRIPTION` 匹配迁移,用户自定义不覆盖 @@ -38,12 +41,13 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - **三层超时**:步墙钟 1800s 兜底+静默看门狗(300s warn/600s 杀进程组)+maxTurns=50 - **Idle 心跳 45s**,超时扫描 5min;**isOnline** = loop 存活+心跳新鲜(≤5min);#345 起 isOnline/每角色最新 error 聚合单源 `summarizeRoleStates`(agent-instance.service),agent-profile list 与 instance-timeout-scan 的 5min 窗口同源(INSTANCE_ALIVE_TIMEOUT_MS) - **系统探测 /proc 单出口(#344)**:新探测先落 `ops/proc-probes.ts`(零子进程)。磁盘 = statfs (blocks−bavail)/blocks(含 root 保留块,比 df Use% 偏高 1–5pp);内存 = MemTotal−MemAvailable(比 free -m used 列偏小,现代可用口径);僵尸 = /proc//stat state=Z。monitor systemHealthCheck 阈值不变(disk>90 warning / mem>95 critical / zombie>0 warning),details 的 dfOutput/freeOutput 已随 shell 解析删除,保留 usagePercent/zombieCount;ops 残留 ss/systemctl 走 async exec。#418 起命令行类探测同出口:readProcessCmdline 按 NUL→空格还原 ps aux 渲染口径(僵尸/内核线程空 cmdline → null 不计),countProcessesByCmdline 子串计数、listPidsByCmdline 按 JS RegExp 列 pid(ops-rules processes_to_clean 契约)、listZombieProcesses 列 pid+comm;ops.service isCloudflaredRunning/cleanStaleProcesses(kill 改 process.kill SIGKILL,失败不计口径不变)/countProcesses、triage 僵尸列举+claude 计数、env-snapper cloudflared 探测均委托此单出口 -- **monitor-round 零同步子进程(#374)**:全部同步 exec 收口(review 补收票面漏列的第四处)——gcStaleWorktrees `git worktree prune`(cwd/5s timeout 语义不变)与 checkKnowledgeHealth 用户模型更新(30s timeout、`|| echo "{}"` shell 兜底不变)改 `monitor/exec-async.ts` 异步 exec;该用户模型命令已于 studio#501(2026-09-14)随 user-model 链路整体退役删除;dailyReflection git log/diff 改 execFileAsync(数组参数不经 shell 语义不变);ops preflight 磁盘 df 解析第三副本删除,委托 proc-probes.readDiskUsage(message 保真 Use% + available,statfs 不可用 → skip 不 fail)。preflight 其余同步 exec(vite build/lsof/cloudflared nohup spawn)为启动一次性路径,#374 方案未列,保留;进程清理与 cloudflared 探测的 ps aux 探测部分 #418 已收口;#454 补收 triage.service 这条遗漏同胞——diagnose 的 curl /health 与 tmux ls、act() 的修复命令与 service_down 验证 curl 全部改 exec-async 异步出口(timeout/shell 兜底语义不变),df/free 探测删除改 readDiskUsage/readMemoryUsage 同源(findings 前缀 `Disk:`/`Memory:`/`Tmux:` 不变,文案钉在 triage-agent.test.ts),ops/system-health.ts collectDisk 死代码里的同步 df 同清(statfs 口径,仅测试引用的模块) +- **monitor-round 零同步子进程(#374)**:全部同步 exec 收口(review 补收票面漏列的第四处)——gcStaleWorktrees `git worktree prune`(cwd/5s timeout 语义不变)与 checkKnowledgeHealth 用户模型更新(30s timeout、`|| echo "{}"` shell 兜底不变)改 `monitor/exec-async.ts` 异步 exec;该用户模型命令已于 studio#501(2026-09-14)随 user-model 链路整体退役删除;dailyReflection git log/diff 改 execFileAsync(数组参数不经 shell 语义不变);ops preflight 磁盘 df 解析第三副本删除,委托 proc-probes.readDiskUsage(message 保真 Use% + available,statfs 不可用 → skip 不 fail)。preflight 其余同步 exec(vite build/cloudflared nohup spawn)为启动一次性路径,#374 方案未列,保留(lsof 端口检查已随 #573 收编 cli/port-probe 删除);进程清理与 cloudflared 探测的 ps aux 探测部分 #418 已收口;#454 补收 triage.service 这条遗漏同胞——diagnose 的 curl /health 与 tmux ls、act() 的修复命令与 service_down 验证 curl 全部改 exec-async 异步出口(timeout/shell 兜底语义不变),df/free 探测删除改 readDiskUsage/readMemoryUsage 同源(findings 前缀 `Disk:`/`Memory:`/`Tmux:` 不变,文案钉在 triage-agent.test.ts),ops/system-health.ts collectDisk 死代码里的同步 df 同清(statfs 口径,仅测试引用的模块) +- **WU 删除走 service.delete 单口(#538,ADR 2026-09-15 决策 3)**:monitor-lifecycle 每日 TTL 与 ops/system-health runGC(done/closed 且 completedAt >30 天,无生产接线仅测试可达)的筛选逻辑留调用方,删除循环改调 `service.delete(id, { reason })`——墓碑事件行不再两处手抄(service 单点构造),删除经 workunit:removed 事件出声(SSE 转发前端删行,不进频道);monitor TTL 筛选 #540 起加终态守卫——仅删 done/closed(原「90 天无状态过滤」系 G31 GoalExecution TTL 机械沿用,非有意设计;缺 status 按非终态保留);runGC 接线与否仍为独立 needs-triage 票 - **多实例单活**:`STUDIO_AGENT_LOOP_ENABLED=false` 实例 standby;`AgentLoop.start()` 内置同角色单活守卫 - **SSE 负载含 channelId(2026-08-24 SSE 负载加深,批 1)**:`workunit.execution.step` / `workunit.execution.stream`(含 step-start)负载与 `workunit.tokens` SSE 信封 data 均携带 `channelId`(wu.channelId 透传,无频道 WU 缺省该键)——前端按频道过滤 step/token 事件的数据源;`workunit:tokens` 落盘后顺带经 eventBus.publish 发 SSE(best-effort,不落盘二次) - **派单链**:WorkUnitService.create -> workunit.created -> TriggerScheduler -> AgentLoop 唤醒 -> runLoop observe(15s 轮询纯兜底,#523 起非主通道)-> 过滤 -> claim -> agentStep -> LocalExecutor -> spawn CLI -> recordResult -> 回帖(EventBus/SSE)。#445 起「认领即发声」(#175 决策 1)不再是 loop 私有实现:claimAndAnnounce 薄封装委托 workunit/claim-announce.ts 的 claimWorkUnitAndAnnounce 原语,REST claim 端点同路径(契约测试双入口间谍锁定,见 workunit/__tests__/claim-announce.test.ts) -- **observe 读路径优化(#330)**:observe 调 `queryAllMessages` 传 myActive WU 的 channelId 集合做频道预过滤(任一活跃 WU 无 channelId 退全扫;已接受盲区 = WU 换频道后旧频道新回复不扫);loop start 订阅 `channel.message_sent`(eventBus 同进程,human 且 workUnitId ∈ myActive 命中即打断空闲 sleep 立即 observe,stop 退订)——空闲兜底轮询维持 15s 不变(事件 fire-and-forget 无持久,防跨进程写者/重启间隙)。**#493 唤醒加固**:①唤醒闩锁 `pendingWake`——事件到达时 loop 不在 idleSleep(执行中/步骤间,wakeIdle 为 null)则置闩,下一次 idleSleep 入口消费并立即放行重跑 observe,唤醒不再丢(无事件时 15s 调度不变);②新回复检测 `msg.createdAt > wu.updatedAt` 改 `>=`——同毫秒落盘(含重启间隙首扫捞回)不再漏检,消费后 recordResult 簿记推进 updatedAt 不会误循环。**#523 认领真唤醒(#515 决议)**:workunit.created EVENT handler 不再白跑一次 observe 丢弃,改复用同款 pendingWake 闩锁+wakeIdle 叫醒 runLoop 自己走 observe→认领,过滤口径 = 负载现成的 `claimable === true`(pending 人闸单/有依赖单不空唤醒);同条件增订 workunit.status_changed(人闸确认/unclaim/reopen 全覆盖,blocked→active 等非认领路径被条件天然滤掉,零新事件类型,stop 退订);步间 sleep(dynamicInterval 3/10/30s)复用同一可中断原语(idleSleep),15s idle 地板原样保留为纯兜底 -- **F4 review 派发**:父 in_review -> 建未指派 review 子 WU 走 claim 涌现;excludeAssignee 禁自领;同父唯一性 flock 锁。#466 起 review-dispatcher 先查频道路由表 review 档(channels/routing.ts):命中则指名评审角色(assigneeId 硬约束取代涌现+排除);路由=实现者本人/角色 inactive/移出频道 → 回池涌现(现状语义不变)+ 频道出声提醒。#523(#516 决议①)起建单成功即在父 WU 线程出声「已派评审」(非里程碑不带 atHuman,认领播报照旧不合并);对账重跑 redispatchReview 传 silent 压掉本条,由 dispatch-reconciliation 在重派成功后统一发「断链已自愈」(#516 决议②,#159 决议 5 作废),两条 happy path 各恰好一条不双发 +- **observe 读路径优化(#330)**:observe 调 `queryAllMessages` 传 myActive WU 的 channelId 集合做频道预过滤(任一活跃 WU 无 channelId 退全扫;已接受盲区 = WU 换频道后旧频道新回复不扫);loop start 订阅 `channel.message_sent`(eventBus 同进程,stop 退订)——空闲兜底轮询维持 15s 不变(事件 fire-and-forget 无持久,防跨进程写者/重启间隙)。**#493 唤醒加固**:①唤醒闩锁 `pendingWake`——事件到达时 loop 不在 idleSleep(执行中/步骤间,wakeIdle 为 null)则置闩,下一次 idleSleep 入口消费并立即放行重跑 observe,唤醒不再丢(无事件时 15s 调度不变);②新回复检测 `msg.createdAt > wu.updatedAt` 改 `>=`——同毫秒落盘(含重启间隙首扫捞回)不再漏检,消费后 recordResult 簿记推进 updatedAt 不会误循环。**#523 认领真唤醒(#515 决议)**:workunit.created EVENT handler 不再白跑一次 observe 丢弃,改复用同款 pendingWake 闩锁+wakeIdle 叫醒 runLoop 自己走 observe→认领;同口径增订 workunit.status_changed(人闸确认/unclaim/reopen 覆盖,零新事件类型,stop 退订);步间 sleep(dynamicInterval 3/10/30s)复用同一可中断原语(idleSleep),15s idle 地板原样保留为纯兜底。**2026-09-16 唤醒放宽(只放行不裁决)**:唤醒过滤器不再复制 observe 的裁决逻辑——`channel.message_sent` 口径从「workUnitId ∈ lastActiveWuIds 派生缓存」放宽为「人类消息且带 workUnitId」(旧缓存在认领后首个 sleep 窗口必 stale,回复唤醒 100% 被滤掉,perf 实测白等满 30s dynamicInterval,outputs/studio-perf-20260916);workunit.created/status_changed 口径从「仅 claimable===true」放宽为「claimable===true 或 assigneeId===本实例」(NEED_INPUT 复活 blocked→active 的 claimable 恒 false,旧口径把它排除)。lastActiveWuIds 机制删除;归属/可见性最终裁决归 observe(幂等零 token),唤醒宁可误醒(空跑 <1ms)不可漏醒。真接线(registerTrigger→eventBus 订阅→EXECUTE handler→唤醒)由 agents/__tests__/agent-loop-wiring.test.ts 锁定——wakeup 测试直驱 seam 不覆盖接线层 +- **F4 review 派发**:父 in_review -> 建未指派 review 子 WU 走 claim 涌现;excludeAssignee 禁自领;同父唯一性 flock 锁。#466 起 review-dispatcher 先查频道路由表 review 档(channels/routing.ts):命中则指名评审角色(assigneeId 硬约束取代涌现+排除);路由=实现者本人/角色 inactive/移出频道 → 回池涌现(现状语义不变)+ 频道出声提醒。#523(#516 决议①)起建单成功即在父 WU 线程出声「已派评审」(非里程碑不带 atHuman,认领播报照旧不合并);对账重跑 redispatchReview 传 silent 压掉本条,由 dispatch-reconciliation 在重派成功后统一发「断链已自愈」(#516 决议②,#159 决议 5 作废),两条 happy path 各恰好一条不双发。**P7(2026-09-16)自动评审总开关 `STUDIO_AUTO_REVIEW=false`**(默认开):闸两条自动路径(in_review 事件链 + 对账 redispatchReview),人工补派 dispatchReviewNow 不闸——无凭证/fake-provider 环境豁免评审子单被真实 CLI 角色认领空转 - **R3 评审契约**:评审子 WU scope = diff-only+`+code-review`;needs-info -> 转人工 - **不派评审类型**:decision/spec/analysis 走人工 in_review - **F6 台账**:COMPLETE 前验证守卫写 l1;`POST /workunits/:id/verify` 人工重跑;`POST /workunits/:id/dispatch-review` 人工补派 @@ -56,9 +60,10 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - **鉴权**:POST/PUT = requireAuth()+requireNotGuest();terminate = requireAuth()+requireAdmin() - **SystemExecutor 输出形态(#364)**:claude 模板固定带 `--verbose`,`--output-format json --verbose` stdout 是单行 stream-json 事件数组(产出与 usage 在末位 `type=result` 事件),非单 envelope;`extractResultEnvelope` 统一归一两种形态,mock CLI 输出时必须按真实形态(数组)写,否则测试绿生产哑 - **SystemExecutor 按源超时(#369)**:`run()` 超时解析 = 显式 `timeoutMs` > `DEFAULT_TIMEOUT_BY_EVENT_SOURCE[eventSource]` > 30s 全局默认;注册表收编重 prompt 源 knowledge-distill / constraint-audit / knowledge-maintenance =120s(依据 #365 实测蒸馏 21-27s 撞 30s)。新调用点必须带 eventSource(同时是 system:tokens 成本聚合键);轻源不进表走裸 30s,durationMs 遥测贴上限再评估 +- **SystemExecutor 恒开 killProcessGroup(#581,2026-09-17 修复)**:`run()` 的 `execSh` 调用恒传 `killProcessGroup: true`,超时杀整进程组(#171 机制,同 runner-lightweight 先例),CLI spawn 的孙进程不再孤儿化写临时目录;兼容性结论 = execSh posix-only(bash -c),无 Windows 负担 - **system:tokens 写通路可观测(#370)**:写入成功打 info 日志(eventSource/provider/durationMs),失败 warn 带 eventSource——"零产出"与"静默失败"可区分;#370 实证写链路本身无 bug(冒烟脚本 `scripts/system-tokens-smoke.ts` 直接 `getSystemExecutor().run()` 验证落盘),零产出根因是全部 13 个调用方事件驱动、环境未触发 - **频道发声**:里程碑+异常+每步简报+认领消息+步失败消息;认领/失败消息不过新鲜度检查 -- **认领门槛**:纯显式,三门槛:assigneeId 排他+excludeAssignee+blockedBy 依赖门禁 +- **认领门槛**:纯显式,三门槛:assigneeId 排他+excludeAssignee+blockedBy 依赖门禁;决策 14 起加认领前适任判断(loop/claim-fitness.ts)——resolveTarget 选中 unassigned 候选后、claim 前一次性轻量 LLM(复用 system-executor)问「能否胜任」,判 no 落档 WU `metadata.unfitRoles`(约束挂 WU 不挂角色身份,合规 ADR D2/决策 10),observe 第 7 道过滤(含本 role.id 即不可见);判断失败/解析不出从宽视为适任;unfitRoles 覆盖频道全部 active 成员 → `WorkUnitService.blockForAllUnfit` 置 blocked 转人工(语义方法直写,不动状态机表)。**#579(2026-09-17)适任判断总开关 `STUDIO_CLAIM_FITNESS=false`**(默认开):无凭证/fake-provider 环境豁免判断直接放行(不起真实 CLI),P7 `STUDIO_AUTO_REVIEW` / P8 `STUDIO_KNOWLEDGE_EXTRACTION` 同款 `!== 'false'` 风格;mvp-loop e2e 已置 false - **失败步埋点**:recordOutcomeEvent 落 knowledge:outcome:failure/success - **Auditor 零执行早退**:24h 零执行不 push 不记录不升级 - **WU 收尾提取**:订阅 done -> 读 transcript -> LLM -> 角色记忆草稿区;与 R3 并行独立 @@ -67,4 +72,5 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - instance 忙闲 SSE:agent.instance.status_changed,内存去重。#312(2026-08-24 SSE 负载契约体检):负载 additive 带 `currentWorkUnit` 快照(逐字段对齐 getAgentSummary:title = metadata.title ?? scope;悬空 WU → null 裸 id 保留)+ `channelId`(当前 WU 所在频道)+ `lastError/lastErrorAt`;发布面 active/idle 扩到 error(recordStartupFailure 路径,agent.health.failed 保留),terminated 不发。#318:负载再添 additive `pmo` 快照 + `startedAt`;WU 聚合上下文走共享出口 monitoring/current-wu-context.ts(与 getAgentSummary 同源,claimedAt 快照原样透传);负载构造唯一出口 buildInstanceStatusPayload(publishInstanceStatus / recordStartupFailure 共用) - A2A:ACTION: DELEGATE:@: 建子单+发 delegate 卡片 - **#467 裁决轮协议(RULING 行)**:plan 契约(prompt-composer CONTRACT_TEMPLATES.plan)约定 fog 调研齐后出 `ACTION: NEED_INPUT:裁决轮…` + 紧随逐行 `RULING: {"question","suggestion","default"?}`;parseAgentOutput(agent-loop-parsers)收集为 StepResult.rulings(封顶 MAP_OPENING_FOG_MAX=12、字段截 300、坏行跳过、与 OPTIONS 行可共存)→ recordResult 落 metadata.planRulings + waitingReason='plan-ruling',NEED_INPUT 里程碑 meta.cardType='plan_ruling'(前端裁决接力卡)。提交落地在 pmo/plan-ruling.ts。 +- **#567 方向锁定协议(DIRECTION 行)**:plan 契约方向锁定段(prompt-composer PLAN_DIRECTION_SECTION,插在裁决轮段之前;metadata.directionPick===false 时 buildContractSection 替换为「本单已关闭方向锁定」提示)约定互斥大方向时出 `ACTION: NEED_INPUT:方向锁定…` + 紧随一行 `DIRECTION: {"question","options":[{name,summary,tradeoffs,impact,recommended}]}`;parseDirectionLine(agent-loop-parsers)解析为 StepResult.directions(options 须 2..4 条否则整条拒收、字段截 500、recommended 非恰好一个归一为首条、坏行跳过、与 OPTIONS/RULING 行共存)→ recordResult 落 metadata.planDirections + waitingReason='plan-direction'(与 rulings 并存时 direction 优先),里程碑 meta.cardType='plan_direction'。提交落地在 pmo/plan-direction.ts(POST /:id/direction)。 - **user-model 链路退役(studio#501,2026-09-14)**:`update-user-model`/`analyze-sessions` CLI + session-mining 解析器 + monitor 24h 调用块 + auditor `analyzeUserModel`/`model_weight_tune` 建议链整体删除。理由:字面 n-gram 挖掘达不到「懂用户」目的,且该目的已由更好机制覆盖——交互会话的 auto memory + studio 的 role-memory 飞轮(WU 收尾 LLM 提取 → 人审 → spawn 注入,provider 无关)。原「user-model 状态文件路径唯一出口」条目随之作废。 diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts index 9f9e463a5..d6fc96f9a 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts @@ -35,7 +35,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, parseAgentOutput } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { parseAgentOutput } from '../loop/agent-loop-parsers'; function makeProfile(id: string, name: string): AgentProfileData { return { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts index c953f2577..ca00747cd 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts @@ -28,7 +28,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, resolveTarget } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { resolveTarget } from '../loop/agent-loop-parsers'; const SELF_ROLE_ID = 'role-self'; const OTHER_ROLE_ID = 'role-other'; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts index c68cc172c..71690b013 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts @@ -1,6 +1,9 @@ // #96: CLI 上下文溢出纯反应式策略 —— 溢出错误 → 会话滚动摘要落盘 → 新会话带摘要 // 注入重试一次 → 再败 NEED_INPUT;并收口 #95 降级路径超限(续用降级也遵守 MAX)。 // 真实 FileStore(tmpdir)+ 真实 WorkUnitService;CLI 执行与 knowledge-service mock。 +// #543:溢出/降级重试序收编 loop/step-retry-policy 后,重试细节断言(占额/簿记/摘要注入) +// 移 loop/__tests__/step-retry-policy.test.ts 直测(fake executor),本文件只留整类构造的 +// 触发接线、配额分叉与失败 outcome 落盘接线。 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -102,39 +105,27 @@ describe('#96: CLI 上下文溢出纯反应式策略', () => { return mockExecuteLightweight.mock.calls.at(index)![0] as AgentTask; } - it('续用会话溢出 → 新会话带摘要重试成功:sessionCount+1、sessionSummary 落盘、重试 prompt 注入摘要', async () => { + it('续用会话溢出 → 新会话带摘要重试成功(接线冒烟;占额/簿记/摘要注入细节见 loop/__tests__/step-retry-policy 直测)', async () => { const wu = await setupWorkUnit({ sessionId: 'sess-overflow', sessionCount: 1, stepCount: 2, progressLog: [{ step: 1, action: 'progress', summary: '完成数据层', at: '2026-08-12T10:00:00Z' }], }); // 首次(续用形态)报溢出;重试走默认成功 - let firstParams: AgentTask['parameters'] | undefined; - mockExecuteLightweight.mockImplementationOnce(async (task: AgentTask) => { - firstParams = { ...task.parameters }; - return { ...OVERFLOW_FAILURE }; - }); + mockExecuteLightweight.mockImplementationOnce(async () => ({ ...OVERFLOW_FAILURE })); const step = await (agentLoop as unknown as AgentStepCapable).agentStep({ workUnit: wu }); + // 接线:首败触发策略重试一次(新建形态);重试 prompt 带真实滚动摘要段;成功走正常路径 expect(mockExecuteLightweight).toHaveBeenCalledTimes(2); - // 首次:续用形态;重试:新建形态(claude 传新 UUID、不带 sessionResume) - expect(firstParams?.sessionId).toBe('sess-overflow'); - expect(firstParams?.sessionResume).toBe(true); const retryParams = taskAt(1).parameters!; expect(retryParams.sessionId).toMatch(UUID_RE); expect(retryParams.sessionId).not.toBe('sess-overflow'); expect(retryParams.sessionResume).toBeUndefined(); - // 摘要落盘 + 会话簿记:新号、sessionCount+1、lastSessionResumed=false + expect(taskAt(1).prompt).toContain('## 会话摘要(上下文溢出)'); + expect(taskAt(1).prompt).toContain('完成数据层'); expect(step.action).toBe('progress'); - expect(step.metadataUpdates?.sessionId).toBe(retryParams.sessionId); expect(step.metadataUpdates?.sessionCount).toBe(2); - expect(step.metadataUpdates?.lastSessionResumed).toBe(false); - expect(typeof step.metadataUpdates?.sessionSummary).toBe('string'); expect(step.metadataUpdates?.sessionSummary).toContain('实现登录功能'); - expect(step.metadataUpdates?.sessionSummary).toContain('完成数据层'); - // 重试 prompt 注入摘要段 - expect(taskAt(1).prompt).toContain('## 会话摘要(上下文溢出)'); - expect(taskAt(1).prompt).toContain('完成数据层'); }); it('新建会话(首步后)溢出 → 同样落盘摘要并重试一次', async () => { @@ -153,7 +144,7 @@ describe('#96: CLI 上下文溢出纯反应式策略', () => { expect(taskAt(1).prompt).toContain('## 会话摘要(上下文溢出)'); }); - it('溢出重试仍失败 → NEED_INPUT(合流既有路径):sessionSummary 保留落盘、sessionId 重置、sessionCount 计入', async () => { + it('溢出重试仍失败 → NEED_INPUT(接线冒烟;簿记重置/摘要保留/记账细节见 loop/__tests__/step-retry-policy 直测)', async () => { const wu = await setupWorkUnit({ sessionId: 'sess-overflow', sessionCount: 1 }); mockExecuteLightweight.mockResolvedValue({ ...OVERFLOW_FAILURE }); @@ -163,15 +154,13 @@ describe('#96: CLI 上下文溢出纯反应式策略', () => { expect(step.action).toBe('need_input'); expect(step.summary).toContain('上下文溢出'); // #90: 溢出重试再败转 need_input 也落 failure outcome + // (策略 recordFailureOutcome → 本类 recordOutcomeEvent → knowledgeService 的接线断言) expect(knowledgeService.recordOutcome).toHaveBeenCalledWith(expect.objectContaining({ success: false, errorType: 'execution_failed', })); - // 摘要保留(落盘供人工参考),会话簿记:sessionId 重置、sessionCount 计入 - expect(typeof step.metadataUpdates?.sessionSummary).toBe('string'); expect(step.metadataUpdates).not.toHaveProperty('sessionId'); expect(step.metadataUpdates!.sessionCount).toBe(2); - expect(step.metadataUpdates).not.toHaveProperty('lastSessionResumed'); }); it('溢出且会话配额已满(sessionCount>=MAX)→ 摘要落盘 + 直接 NEED_INPUT,不再起新会话', async () => { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts new file mode 100644 index 000000000..8ab397215 --- /dev/null +++ b/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts @@ -0,0 +1,32 @@ +/** + * parseDecisionConclusion 单测(#463:decision WU 输出的 `## 结论摘要` 段解析 → + * metadata.decisionSuggestion,确认弹窗预填 + 收口闸 2 契约产物锚点的数据源)。 + */ +import { describe, it, expect } from 'vitest'; +import { parseDecisionConclusion } from '../loop/agent-loop-parsers.js'; + +describe('parseDecisionConclusion', () => { + it('提取 `## 结论摘要` 段正文(到下一个 ## 段为止)', () => { + const text = [ + '前序分析过程……', + '## 结论摘要', + '选方案 A,理由是 X。', + '第二行补充。', + '## 其他段', + '不应进入结论', + 'ACTION: COMPLETE: done', + ].join('\n'); + expect(parseDecisionConclusion(text)).toBe('选方案 A,理由是 X。\n第二行补充。'); + }); + + it('段末遇 ACTION: 行截断', () => { + const text = '## 结论摘要\n结论 B。\nACTION: COMPLETE: done'; + expect(parseDecisionConclusion(text)).toBe('结论 B。'); + }); + + it('无摘要段 / 段体为空 / 空输入 → null', () => { + expect(parseDecisionConclusion('只是普通输出\nACTION: COMPLETE: done')).toBeNull(); + expect(parseDecisionConclusion('## 结论摘要\n\n## 下一节\nx')).toBeNull(); + expect(parseDecisionConclusion('')).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts index a1d8e04c3..e325ef269 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts @@ -26,7 +26,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, resolveTarget } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { resolveTarget } from '../loop/agent-loop-parsers'; import { resumeWaitingWorkUnit } from '../../workunit/waiting-input.js'; const mockRole = { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-plan-direction.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-plan-direction.test.ts new file mode 100644 index 000000000..878c1e554 --- /dev/null +++ b/apps/api/src/modules/agents/__tests__/agent-loop-plan-direction.test.ts @@ -0,0 +1,249 @@ +// #567:方向锁定——plan 一脉会话裁决轮的前置可选环节(互斥大方向先出一次方向卡) +// parser:NEED_INPUT 紧随的 DIRECTION: JSON 行 → StepResult.directions +// (方向抉择点 + 候选方向清单;防御口径同 RULING,options 2..4 条、recommended 恰好一个) +// loop:need_input + directions → metadata.planDirections 落档 + waitingReason='plan-direction' +// + 里程碑消息 meta.cardType='plan_direction'(前端方向接力卡数据源) +// 真实 FileStore(tmpdir)+ 真实 WorkUnitService;CLI 执行与 knowledge-service mock(同 agent-loop-plan-ruling) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { FileStore } from '@dommaker/studio-shared'; +import { WorkUnitService, type WorkUnitMetadata } from '../../workunit/workunit.service.js'; + +vi.mock('@dommaker/studio-agent', () => ({ + agentRunner: { executeLightweight: vi.fn() }, +})); + +vi.mock('../../knowledge/knowledge-service', () => ({ + knowledgeService: { + injectContext: vi.fn().mockResolvedValue({ prompt: '', injectedIds: [] }), + recordOutcome: vi.fn().mockResolvedValue(undefined), + extractFromExecution: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { AgentLoop } from '../loop/agent-loop'; +import { parseAgentOutput } from '../loop/agent-loop-parsers'; + +const mockRole = { + id: 'role-direction', + name: 'direction-agent', + description: 'direction test agent', + channels: '[]', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), +}; + +interface RecordResultCapable { + recordResult(target: unknown, result: unknown): Promise; +} + +const DIRECTION_PAYLOAD = { + question: '自研还是引入依赖?', + options: [ + { name: '自研', summary: '自研调度内核', tradeoffs: '可控但慢', impact: '触及 scheduler 模块', recommended: true }, + { name: '引入依赖', summary: '引入 bullmq', tradeoffs: '快但多一个依赖', impact: '触及 api 与 daemon', recommended: false }, + ], +}; + +describe('#567 parser:NEED_INPUT 携 DIRECTION 行', () => { + it('DIRECTION 行解析(question + options 五字段)', () => { + const result = parseAgentOutput([ + '调研发现两个互斥大方向,请人先定方向。', + 'ACTION: NEED_INPUT:方向锁定——请选定本票方向', + `DIRECTION: ${JSON.stringify(DIRECTION_PAYLOAD)}`, + ].join('\n')); + expect(result.action).toBe('need_input'); + expect(result.directions).toEqual(DIRECTION_PAYLOAD); + }); + + it('DIRECTION 与 OPTIONS/RULING 行共存(同区解析互不影响)', () => { + const result = parseAgentOutput([ + 'ACTION: NEED_INPUT:方向锁定', + 'OPTIONS: [{"label":"全对","value":"ok"}]', + `DIRECTION: ${JSON.stringify(DIRECTION_PAYLOAD)}`, + 'RULING: {"question":"q1","suggestion":"s1"}', + ].join('\n')); + expect(result.options).toEqual([{ label: '全对', value: 'ok' }]); + expect(result.rulings).toEqual([{ question: 'q1', suggestion: 's1' }]); + expect(result.directions).toEqual(DIRECTION_PAYLOAD); + }); + + it('坏行防御:JSON 损坏跳过、question 缺失丢弃、首个其他行出区', () => { + const result = parseAgentOutput([ + 'ACTION: NEED_INPUT:方向锁定', + 'DIRECTION: {bad json', + 'DIRECTION: {"options":[{"name":"a","summary":"s","tradeoffs":"t","impact":"i","recommended":true},{"name":"b","summary":"s","tradeoffs":"t","impact":"i","recommended":false}]}', + `DIRECTION: ${JSON.stringify(DIRECTION_PAYLOAD)}`, + '普通文本行', + `DIRECTION: ${JSON.stringify({ question: '出区后不计', options: DIRECTION_PAYLOAD.options })}`, + ].join('\n')); + expect(result.directions).toEqual(DIRECTION_PAYLOAD); + }); + + it('options 越界拒收:<2 条与 >4 条整条丢弃', () => { + const one = parseAgentOutput([ + 'ACTION: NEED_INPUT:方向锁定', + `DIRECTION: ${JSON.stringify({ question: 'q', options: [DIRECTION_PAYLOAD.options[0]] })}`, + ].join('\n')); + expect(one.directions).toBeUndefined(); + + const five = parseAgentOutput([ + 'ACTION: NEED_INPUT:方向锁定', + `DIRECTION: ${JSON.stringify({ + question: 'q', + options: Array.from({ length: 5 }, (_, i) => ({ + name: `d${i}`, summary: 's', tradeoffs: 't', impact: 'i', recommended: i === 0, + })), + })}`, + ].join('\n')); + expect(five.directions).toBeUndefined(); + }); + + it('recommended 非恰好一个 → 归一(全部置 false 后首条置 true)', () => { + const none = parseAgentOutput([ + 'ACTION: NEED_INPUT:方向锁定', + `DIRECTION: ${JSON.stringify({ + question: 'q', + options: [ + { name: 'a', summary: 's', tradeoffs: 't', impact: 'i', recommended: false }, + { name: 'b', summary: 's', tradeoffs: 't', impact: 'i', recommended: false }, + ], + })}`, + ].join('\n')); + expect(none.directions!.options.map(o => o.recommended)).toEqual([true, false]); + + const two = parseAgentOutput([ + 'ACTION: NEED_INPUT:方向锁定', + `DIRECTION: ${JSON.stringify({ + question: 'q', + options: [ + { name: 'a', summary: 's', tradeoffs: 't', impact: 'i', recommended: true }, + { name: 'b', summary: 's', tradeoffs: 't', impact: 'i', recommended: true }, + ], + })}`, + ].join('\n')); + expect(two.directions!.options.map(o => o.recommended)).toEqual([true, false]); + }); + + it('字段截 500 字符(question/name/summary/tradeoffs/impact)', () => { + const long = 'x'.repeat(600); + const result = parseAgentOutput([ + 'ACTION: NEED_INPUT:方向锁定', + `DIRECTION: ${JSON.stringify({ + question: long, + options: [ + { name: long, summary: long, tradeoffs: long, impact: long, recommended: true }, + { name: 'b', summary: 's', tradeoffs: 't', impact: 'i', recommended: false }, + ], + })}`, + ].join('\n')); + expect(result.directions!.question).toHaveLength(500); + expect(result.directions!.options[0].name).toHaveLength(500); + expect(result.directions!.options[0].summary).toHaveLength(500); + expect(result.directions!.options[0].tradeoffs).toHaveLength(500); + expect(result.directions!.options[0].impact).toHaveLength(500); + }); + + it('回归:无 DIRECTION 行的 NEED_INPUT directions 为 undefined', () => { + const result = parseAgentOutput('ACTION: NEED_INPUT:用 OAuth 还是账号密码?'); + expect(result.action).toBe('need_input'); + expect(result.directions).toBeUndefined(); + }); +}); + +describe('#567 loop:方向锁定挂起与接力卡', () => { + let testDir: string; + let fileStore: FileStore; + let wuService: WorkUnitService; + let channelId: string; + let agentLoop: AgentLoop; + + beforeEach(async () => { + vi.clearAllMocks(); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-direction-')); + fileStore = new FileStore(testDir); + wuService = new WorkUnitService(fileStore); + channelId = `ch-direction-${Date.now()}`; + await fileStore.createChannel({ + id: channelId, name: '#direction-test', type: 'rnd', + defaultWorkspaceId: null, defaultPath: null, + discordChannelId: null, discordWebhookUrl: null, members: '[]', + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + agentLoop = new AgentLoop(mockRole, fileStore); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + async function setupPlanWu() { + return wuService.create({ + scope: '规划需求 PMO-1: 测试', channelId, type: 'plan', + status: 'active', assigneeId: 'instance-1', + metadata: { pmoId: 'proj-1' }, + }); + } + + it('need_input + directions → planDirections/waitingReason 落档 + 里程碑带 plan_direction 卡', async () => { + const wu = await setupPlanWu(); + + await (agentLoop as unknown as RecordResultCapable).recordResult( + { workUnit: wu }, + { action: 'need_input', summary: '方向锁定——请选定本票方向', directions: DIRECTION_PAYLOAD }, + ); + + const parked = (await wuService.getById(wu.id))!; + expect(parked.status).toBe('blocked'); + const meta: WorkUnitMetadata = JSON.parse(parked.metadata!); + expect(meta.waitingForInput).toBe(true); + expect(meta.waitingReason).toBe('plan-direction'); + expect(meta.planDirections).toEqual(DIRECTION_PAYLOAD); + + const messages = await fileStore.queryMessages(channelId, { workUnitId: wu.id }); + const question = messages.find(m => m.authorType === 'agent' && m.content.includes('需要输入')); + expect(question).toBeDefined(); + const msgMeta = JSON.parse(question!.meta as string); + expect(msgMeta.cardType).toBe('plan_direction'); + }); + + it('directions 与 rulings 并存:direction 优先定 waitingReason/cardType,rulings 落档不动', async () => { + const wu = await setupPlanWu(); + const rulings = [{ question: '存储选型?', suggestion: 'SQLite' }]; + + await (agentLoop as unknown as RecordResultCapable).recordResult( + { workUnit: wu }, + { action: 'need_input', summary: '方向锁定', directions: DIRECTION_PAYLOAD, rulings }, + ); + + const meta: WorkUnitMetadata = JSON.parse((await wuService.getById(wu.id))!.metadata!); + expect(meta.waitingReason).toBe('plan-direction'); + expect(meta.planDirections).toEqual(DIRECTION_PAYLOAD); + expect(meta.planRulings).toEqual(rulings); + + const messages = await fileStore.queryMessages(channelId, { workUnitId: wu.id }); + const question = messages.find(m => m.authorType === 'agent' && m.content.includes('需要输入')); + expect(JSON.parse(question!.meta as string).cardType).toBe('plan_direction'); + }); + + it('回归:无 directions 的 need_input 不落 planDirections/waitingReason、不带卡', async () => { + const wu = await setupPlanWu(); + + await (agentLoop as unknown as RecordResultCapable).recordResult( + { workUnit: wu }, + { action: 'need_input', summary: '澄清:目标是内部工具还是对外产品?' }, + ); + + const meta: WorkUnitMetadata = JSON.parse((await wuService.getById(wu.id))!.metadata!); + expect(meta.waitingForInput).toBe(true); + expect(meta.planDirections).toBeUndefined(); + expect(meta.waitingReason).toBeUndefined(); + + const messages = await fileStore.queryMessages(channelId, { workUnitId: wu.id }); + const question = messages.find(m => m.authorType === 'agent' && m.content.includes('需要输入')); + expect(JSON.parse(question!.meta as string).cardType).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts index abf44719a..cf37a1f86 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts @@ -22,7 +22,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, parseAgentOutput } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { parseAgentOutput } from '../loop/agent-loop-parsers'; const mockRole = { id: 'role-ruling', diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts index b546cb359..2d7c8c323 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts @@ -112,7 +112,9 @@ describe('P1: review WU 守卫豁免', () => { } it('review WU + 父 worktree 有未提交改动 + COMPLETE → 不打回,in_review → done 自动收口,且全程不碰 git', async () => { - const { reviewWu } = await setupReviewWu(); + // reviewReport 模拟 agentStep 协议行解析落档(收口闸 2 要求 review 契约产物非空; + // 本用例走 recordResult 直调,绕过 agentStep,需预置) + const { reviewWu } = await setupReviewWu({ reviewReport: { approved: true } }); const after = await record(reviewWu.id, 'complete', 'LGTM,评审通过'); @@ -138,7 +140,8 @@ describe('P1: review WU 守卫豁免', () => { }); it('review WU stepCount 超 15 未超 30 时 COMPLETE → 不被强制拦截,仍走 complete → done 收口', async () => { - const { reviewWu } = await setupReviewWu({ stepCount: 15 }); + // reviewReport 预置理由同上(收口闸 2 契约产物锚点) + const { reviewWu } = await setupReviewWu({ stepCount: 15, reviewReport: { approved: true } }); const after = await record(reviewWu.id, 'complete', '评审通过'); diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts index 5c3b0d063..544c93a99 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts @@ -4,6 +4,9 @@ // 续用步报「会话不存在」→ 换发新 sessionId 降级重试一次。 // HOME 经 vi.stubEnv 指向 tmpdir 造会话文件;真实 FileStore(tmpdir)+ 真实 WorkUnitService; // CLI 执行 mock;#481:执行根经 metadata.workspaceRoot 注入(wu.workspaceId 不再参与 cwd 决策)。 +// #543:降级重试序收编 loop/step-retry-policy 后,重试细节断言(占额/簿记/prompt 重算) +// 移 loop/__tests__/step-retry-policy.test.ts 直测(fake executor),本文件只留整类构造的 +// 续用判定/形态接线与降级路径冒烟。 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -265,7 +268,7 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { expect(lastTask().parameters?.sessionResume).toBeUndefined(); }); - it('续用步报「会话不存在」→ 降级:换发新 UUID 重试一次(无 sessionResume),成功后落新号 + sessionCount+1 + lastSessionResumed=false', async () => { + it('续用步报「会话不存在」→ 触发降级重试(接线冒烟;占额/簿记/prompt 重算细节见 loop/__tests__/step-retry-policy 直测)', async () => { createClaudeSessionFile('sess-lost'); const wu = await setupWorkUnit({ sessionId: 'sess-lost', sessionCount: 1 }); // 降级重试复用同一 task 对象改 parameters(#94 设计)——首次调用形态需在调用时快照 @@ -280,20 +283,18 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { const step = await (agentLoop as unknown as AgentStepCapable).agentStep({ workUnit: wu }); + // 接线:首次续用形态失败 → 策略改参重试一次(新建形态)→ 成功走正常路径 expect(mockExecuteLightweight).toHaveBeenCalledTimes(2); - // 第一次:续用形态;第二次:新建形态(claude 传新 UUID、不带 sessionResume) expect(firstCallParams?.sessionId).toBe('sess-lost'); expect(firstCallParams?.sessionResume).toBe(true); const retryParams = taskAt(1).parameters!; expect(retryParams.sessionId).toMatch(UUID_RE); expect(retryParams.sessionId).not.toBe('sess-lost'); expect(retryParams.sessionResume).toBeUndefined(); - // 降级成功:metadata 落新号、sessionCount+1、lastSessionResumed=false expect(step.action).toBe('progress'); expect(step.metadataUpdates?.sessionId).toBe(retryParams.sessionId); expect(step.metadataUpdates?.sessionCount).toBe(2); - expect(step.metadataUpdates?.lastSessionResumed).toBe(false); - // 续用实际失败 → sessionResumes 不计(#94 起只计实际续用成功的步) + // 续用实际失败 → sessionResumes 不计(#94 起只计实际续用成功的步;agent-loop 据 retried outcome 翻 sessionResumed=false 的接线断言) expect(step.metadataUpdates).not.toHaveProperty('sessionResumes'); }); @@ -311,7 +312,7 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { expect(step.action).toBe('failed'); }); - it('降级重试仍失败 → action=failed,sessionId/lastSessionResumed 回滚、sessionCount 计入(#95)', async () => { + it('降级重试仍失败 → action=failed(接线冒烟;簿记回滚/记账/errorType 细节见 loop/__tests__/step-retry-policy 直测)', async () => { createClaudeSessionFile('sess-lost'); const wu = await setupWorkUnit({ sessionId: 'sess-lost', sessionCount: 1 }); mockExecuteLightweight.mockResolvedValue({ @@ -325,7 +326,6 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { expect(step.action).toBe('failed'); expect(step.metadataUpdates).not.toHaveProperty('sessionId'); expect(step.metadataUpdates!.sessionCount).toBe(2); - expect(step.metadataUpdates).not.toHaveProperty('lastSessionResumed'); }); it('#95: 续用降级(check 判命中、执行才发现会话丢失)→ 重试 prompt 注入前序进展段', async () => { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts index 8b4b6302f..245378933 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts @@ -28,7 +28,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, resolveTarget } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { resolveTarget } from '../loop/agent-loop-parsers'; const SELF_ROLE_ID = 'role-self'; const SELF_INSTANCE_ID = 'instance-self'; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts index e11b2cded..6712c88b9 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts @@ -2,7 +2,7 @@ * parseTaskBreakdown 单测(PMO 分析接力:analysis WU 输出的 TASK: 行解析) */ import { describe, it, expect } from 'vitest'; -import { parseTaskBreakdown } from '../loop/agent-loop.js'; +import { parseTaskBreakdown } from '../loop/agent-loop-parsers.js'; import { ANALYSIS_TASKS_MAX } from '../../workunit/workunit.service.js'; describe('parseTaskBreakdown', () => { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts index 753a15fd6..237cd1691 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts @@ -11,7 +11,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { TokenEstimator } from '@dommaker/harness'; -import { writeWorkunitTokenEvent, WORKUNIT_TOKENS_SSE_TYPE } from '../loop/agent-loop.js'; +import { writeWorkunitTokenEvent, WORKUNIT_TOKENS_SSE_TYPE } from '../loop/agent-loop-events.js'; import { eventBus } from '@dommaker/studio-shared'; import { resolveTokenLedgerFile } from '../../../utils/token-ledger.js'; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts index 457703c5b..e7357389a 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts @@ -31,7 +31,7 @@ let resolveTarget: (obs: { // Dynamic import to handle RED phase (module doesn't exist yet) beforeEach(async () => { try { - const mod = await import('../loop/agent-loop.js'); + const mod = await import('../loop/agent-loop-parsers.js'); parseAgentOutput = mod.parseAgentOutput; dynamicInterval = mod.dynamicInterval; resolveTarget = mod.resolveTarget; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts index c5efdb379..ff8751ca4 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts @@ -170,7 +170,7 @@ describe('#330: observe 扫描裁剪 + 事件驱动唤醒', () => { }, { timeout: 2000, interval: 20 }); }); - it('非 human 或 workUnitId 不在 myActive 的事件不唤醒', async () => { + it('非 human / 无 workUnitId 的事件不唤醒', async () => { const instanceId = await startAndWaitFirstObserve(); const wu = await createBlockedWu(instanceId, channelId); await refreshObserve(); @@ -181,9 +181,6 @@ describe('#330: observe 扫描裁剪 + 事件驱动唤醒', () => { eventBus.publish('channel.message_sent', { channelId, message: { authorType: 'agent', workUnitId: wu.id }, }); - eventBus.publish('channel.message_sent', { - channelId, message: { authorType: 'human', workUnitId: 'wu-stranger' }, - }); eventBus.publish('channel.message_sent', { channelId, message: { authorType: 'human', workUnitId: null }, }); @@ -192,6 +189,24 @@ describe('#330: observe 扫描裁剪 + 事件驱动唤醒', () => { expect(indexSpy.mock.calls.length).toBe(before); }); + // 2026-09-16 唤醒放宽(只放行不裁决):归属不再用 lastActiveWuIds 派生缓存否决—— + // 缓存在认领后首个 sleep 窗口必 stale,回复唤醒 100% 被滤掉(实测白等 30s)。 + // 人类消息带 workUnitId 即醒,是否归我由 observe 裁决(幂等廉价)。 + it('陌生 workUnitId 的人类消息同样唤醒(只放行不裁决)', async () => { + await startAndWaitFirstObserve(); // 不建任何 WU——myActive 为空 + + const indexSpy = vi.spyOn(fileStore, 'getIndex'); + const before = indexSpy.mock.calls.length; + + eventBus.publish('channel.message_sent', { + channelId, message: { authorType: 'human', workUnitId: 'wu-stranger' }, + }); + + await vi.waitFor(() => { + expect(indexSpy.mock.calls.length).toBeGreaterThan(before); + }, { timeout: 2000, interval: 20 }); + }); + it('stop() 退订 channel.message_sent', async () => { const subscribeSpy = vi.spyOn(eventBus, 'subscribe'); const unsubscribeSpy = vi.spyOn(eventBus, 'unsubscribe'); @@ -228,7 +243,6 @@ describe('#493: 新回复同毫秒边界 + 唤醒闩锁', () => { /** seam:私有成员访问(TS private 仅编译期) */ interface Seam493 { alive: boolean; - lastActiveWuIds: Set; pendingWake: boolean; onChannelMessageSent(payload: { message?: { authorType?: string; workUnitId?: string | null } }): void; idleSleep(ms: number): Promise; @@ -263,7 +277,6 @@ describe('#493: 新回复同毫秒边界 + 唤醒闩锁', () => { const loop = new AgentLoop(mockRole, fileStore); const seam = seamOf(loop); seam.alive = true; - seam.lastActiveWuIds = new Set(['wu-1']); seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: 'wu-1' } }); expect(seam.pendingWake).toBe(true); @@ -274,19 +287,27 @@ describe('#493: 新回复同毫秒边界 + 唤醒闩锁', () => { expect(seam.pendingWake).toBe(false); // 已消费,不残留 }); - it('闩锁不误置:非 human / 非 myActive / 无 workUnitId 的事件不置闩', () => { + it('闩锁不误置:非 human / 无 workUnitId / 空负载不置闩', () => { const loop = new AgentLoop(mockRole, fileStore); const seam = seamOf(loop); seam.alive = true; - seam.lastActiveWuIds = new Set(['wu-1']); seam.onChannelMessageSent({ message: { authorType: 'agent', workUnitId: 'wu-1' } }); - seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: 'wu-stranger' } }); seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: null } }); seam.onChannelMessageSent({}); expect(seam.pendingWake).toBe(false); }); + // 2026-09-16 唤醒放宽:陌生 workUnitId 也置闩(归属裁决归 observe,见 #330 段同名测试) + it('陌生 workUnitId 的人类消息置闩(只放行不裁决)', () => { + const loop = new AgentLoop(mockRole, fileStore); + const seam = seamOf(loop); + seam.alive = true; + + seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: 'wu-stranger' } }); + expect(seam.pendingWake).toBe(true); + }); + it('无闩时 idleSleep 正常睡足(闩锁不改变无事件时的空闲调度)', async () => { const loop = new AgentLoop(mockRole, fileStore); const seam = seamOf(loop); @@ -327,8 +348,8 @@ describe('#523: workunit 认领真唤醒(created + status_changed)', () => { fs.rmSync(testDir, { recursive: true, force: true }); }, 5000); - /** 启动 loop 并等首轮 observe 完成 */ - async function startAndWaitFirstObserve(): Promise { + /** 启动 loop 并等首轮 observe 完成;返回本实例 id(assigneeId 口径) */ + async function startAndWaitFirstObserve(): Promise { agentLoop = new AgentLoop(mockRole, fileStore); const indexSpy = vi.spyOn(fileStore, 'getIndex'); await agentLoop.start(); @@ -336,6 +357,9 @@ describe('#523: workunit 认领真唤醒(created + status_changed)', () => { expect(indexSpy.mock.calls.length).toBeGreaterThanOrEqual(1); }, { timeout: 3000, interval: 20 }); indexSpy.mockRestore(); + const seam = agentLoop as unknown as { instance: { id: string } | null }; + expect(seam.instance).toBeTruthy(); + return seam.instance!.id; } /** workunit.created 的 EVENT EXECUTE handler(trigger scheduler 会把事件 payload 传进来) */ @@ -389,6 +413,38 @@ describe('#523: workunit 认领真唤醒(created + status_changed)', () => { }, { timeout: 2000, interval: 20 }); }); + // 2026-09-16 唤醒放宽(只放行不裁决):复活路径 blocked→active 的 claimable 恒 false, + // 旧过滤把它排除 → 人类回复后 loop 白等满 30s dynamicInterval(perf 实测实锤)。 + // 新口径:claimable===true 或 assigneeId===本实例 即醒,归属裁决归 observe。 + it('status_changed:claimable=false 但 assigneeId=本实例(NEED_INPUT 复活)→ 唤醒', async () => { + const instanceId = await startAndWaitFirstObserve(); + + const indexSpy = vi.spyOn(fileStore, 'getIndex'); + const before = indexSpy.mock.calls.length; + + eventBus.publish('workunit.status_changed', { + workunit: { id: 'wu-mine-resumed', status: 'active', claimable: false, assigneeId: instanceId }, + }); + + await vi.waitFor(() => { + expect(indexSpy.mock.calls.length).toBeGreaterThan(before); + }, { timeout: 2000, interval: 20 }); + }); + + it('status_changed:assigneeId 是别人的 WU → 不唤醒', async () => { + await startAndWaitFirstObserve(); + + const indexSpy = vi.spyOn(fileStore, 'getIndex'); + const before = indexSpy.mock.calls.length; + + eventBus.publish('workunit.status_changed', { + workunit: { id: 'wu-other', status: 'active', claimable: false, assigneeId: 'inst-someone-else' }, + }); + + await new Promise(resolve => setTimeout(resolve, 400)); + expect(indexSpy.mock.calls.length).toBe(before); + }); + it('stop() 退订 workunit.status_changed', async () => { const unsubscribeSpy = vi.spyOn(eventBus, 'unsubscribe'); await startAndWaitFirstObserve(); diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-wiring.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-wiring.test.ts new file mode 100644 index 000000000..cfb4ad62e --- /dev/null +++ b/apps/api/src/modules/agents/__tests__/agent-loop-wiring.test.ts @@ -0,0 +1,88 @@ +// 2026-09-16 真接线集成测试:不 mock trigger-registry—— +// AgentLoop.start → TriggerScheduler.registerTrigger → eventBus 订阅 → EXECUTE handler → 唤醒 全链验证。 +// 背景:agent-loop-wakeup.test.ts 直驱 seam(手喂 payload),遮住了接线层; +// perf 实测(outputs/studio-perf-20260916)中 PMO publish 建单后 loop 睡满两个 15s idle 周期才认领, +// 疑似接线洞。本测试锁定真实接线,防回归。 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { FileStore, eventBus } from '@dommaker/studio-shared'; + +const { mockExecSync } = vi.hoisted(() => ({ + mockExecSync: vi.fn().mockReturnValue('Claude Code CLI version 1.0.0'), +})); + +vi.mock('child_process', () => ({ + execSync: mockExecSync, +})); + +vi.mock('@dommaker/studio-agent', () => ({ + agentRunner: { + executeLightweight: vi.fn(), + }, +})); + +vi.mock('../../knowledge/knowledge-service', () => ({ + knowledgeService: { + injectContext: vi.fn().mockResolvedValue({ prompt: '', injectedIds: [] }), + recordOutcome: vi.fn().mockResolvedValue(undefined), + extractFromExecution: vi.fn().mockResolvedValue(undefined), + }, +})); + +// 注意:不 mock ../../triggers/trigger-registry——本文件的全部意义就是用真身 + +import { AgentLoop } from '../loop/agent-loop'; + +const mockRole = { + id: 'role-wiring', + name: 'wiring-agent', + description: '真接线测试 agent', + channels: '[]', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('真接线:workunit.created 经 TriggerScheduler 唤醒 loop', () => { + let testDir: string; + let fileStore: FileStore; + let agentLoop: AgentLoop; + + beforeEach(() => { + vi.clearAllMocks(); + mockExecSync.mockReturnValue('Claude Code CLI version 1.0.0'); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-wiring-')); + fileStore = new FileStore(testDir); + }); + + afterEach(async () => { + if (agentLoop) { + agentLoop.stop(); // 内部 unregisterTrigger(退订 eventBus)+ 中断 sleep + await Promise.race([ + agentLoop.waitForStop(), + new Promise(resolve => setTimeout(resolve, 2000)), + ]); + } + fs.rmSync(testDir, { recursive: true, force: true }); + }, 5000); + + it('loop 进入 idle 后,eventBus 发 workunit.created(claimable=true)→ 2s 内跑新 observe(不等 15s 地板)', async () => { + agentLoop = new AgentLoop(mockRole, fileStore); + const indexSpy = vi.spyOn(fileStore, 'getIndex'); + await agentLoop.start(); + // 等首轮 observe 完成并进入 idleSleep + await vi.waitFor(() => { + expect(indexSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + }, { timeout: 3000, interval: 20 }); + await new Promise(resolve => setTimeout(resolve, 100)); // 让 loop 落定到 idleSleep + + const before = indexSpy.mock.calls.length; + eventBus.publish('workunit.created', { workunit: { id: 'wu-wiring', status: 'unassigned', claimable: true } }); + + await vi.waitFor(() => { + expect(indexSpy.mock.calls.length).toBeGreaterThan(before); + }, { timeout: 2000, interval: 20 }); + }); +}); diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts index a71f68499..1ecbd67c5 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts @@ -444,7 +444,11 @@ describe('B3b-i: 每 WU worktree 隔离 + 自动验证', () => { const parent = await createWu('task', { workspaceRoot: repoRoot, worktreePath: WT() }); const reviewWu = await createWu('review', { workspaceRoot: repoRoot, worktreePath: WT() }, { parentId: parent.id }); - await loop().recordResult({ workUnit: reviewWu }, { action: 'complete', summary: 'REVIEW_RESULT: {"verdict":"pass"}' }); + await loop().recordResult({ workUnit: reviewWu }, { + action: 'complete', summary: 'REVIEW_RESULT: {"verdict":"pass"}', + // reviewReport 模拟 agentStep 协议行解析落档(收口闸 2 要求 review 契约产物非空) + metadataUpdates: { reviewReport: { approved: true } }, + }); expect(mockExecSh).not.toHaveBeenCalled(); // review 子 WU complete 直接收口 done(P0 修复路径) diff --git a/apps/api/src/modules/agents/__tests__/agent-loop.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop.test.ts index e91afdd9a..894f7c9ac 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop.test.ts @@ -95,7 +95,9 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, isProcessAlive, writeToolCallEvents, resolveToolTraceFile } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { isProcessAlive } from '../loop/agent-loop-parsers'; +import { writeToolCallEvents, resolveToolTraceFile } from '../loop/agent-loop-events'; describe('AgentLoop', () => { let agentLoop: AgentLoop; diff --git a/apps/api/src/modules/agents/__tests__/completion-gates.test.ts b/apps/api/src/modules/agents/__tests__/completion-gates.test.ts index ac8491790..662b935bf 100644 --- a/apps/api/src/modules/agents/__tests__/completion-gates.test.ts +++ b/apps/api/src/modules/agents/__tests__/completion-gates.test.ts @@ -2,6 +2,7 @@ // 覆盖:三条守卫各自的触发/跳过/降级路径、守卫优先级(commit → child → verify)、 // hint 文案、l1 台账形状、verifyFailCount ≥3 → verifyBlocked、no-commit 计数/提醒。 import { describe, it, expect, vi } from 'vitest'; +import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -9,6 +10,8 @@ import { runCompletionGuards, hasUncommittedChanges, readHeadHash, + countBranchCommits, + hasAnalysisReport, parseWuGitLog, loadCompletionCheckersConfig, type CompletionCheckerFns, @@ -80,8 +83,10 @@ describe('completion-gates: §10.5 提交守卫', () => { // 注入 checkers=null 隔离提交守卫语义:harness ≥1.1.0 起软观测段激活, // contract-presence 对无 worktree 类型(含 review)会设计性回退解析 cwd 取 .harness 契约清单 // (见 completion-gates.ts runSoftObservation 注释),不在本测试的提交守卫豁免口径内。 + // reviewReport 备齐:收口闸 2(契约产物)对缺 reviewReport 的 review 会降级,不属于本测试口径。 const deps = makeDeps({ loadCompletionCheckers: async () => null }); - const out = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), {}), deps); + const out = await runCompletionGuards( + ctxOf(makeWu({ type: 'review' }), { reviewReport: { approved: true } }), deps); expect(deps.resolveExecutionCwd).not.toHaveBeenCalled(); expect(deps.hasUncommittedChanges).not.toHaveBeenCalled(); @@ -396,7 +401,10 @@ describe('completion-gates: T7-E2 软观测段', () => { expect(out.action).toBe('complete'); }); - it('圈定口径:review 型契约缺失(contracts 含 review + 无 reviewReport)→ violation 事件 + hint', async () => { + it('圈定口径:review 型契约(contracts 含 review)→ reviewReport 透传,violation 事件 + hint', async () => { + // reviewReport 备齐以过收口闸 2(缺报告已在闸 2 硬降级,走不到软观测段); + // 本用例只验软观测段把 reviewReport 透传给 harness contract-presence + const report = { approved: false, reason: '缺测试' }; const { deps, fns, events } = makeSoftDeps( { verifyContractPresence: vi.fn().mockReturnValue({ @@ -405,9 +413,9 @@ describe('completion-gates: T7-E2 软观测段', () => { }, { config: { contracts: ['review'] } }, ); - const out = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), {}), deps); + const out = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), { reviewReport: report }), deps); - expect(fns.verifyContractPresence).toHaveBeenCalledWith('review', { reviewReport: undefined }, { contracts: ['review'] }); + expect(fns.verifyContractPresence).toHaveBeenCalledWith('review', { reviewReport: report }, { contracts: ['review'] }); expect(events).toContainEqual(expect.objectContaining({ checker: 'contract-presence', verdict: 'violation' })); expect(out.guardUpdates.processCheckHint).toContain('[contract-presence]'); expect(out.action).toBe('complete'); @@ -423,6 +431,206 @@ describe('completion-gates: T7-E2 软观测段', () => { }); }); +describe('completion-gates: 收口闸 1(代码类 diff 非空)', () => { + const DIFF_META: WorkUnitMetadata = { worktreePath: '/repo/wt', worktreeBaseBranch: 'main' }; + + it('空 diff(rev-list=0)→ 降级 progress + diffEmptyHint + diffEmptyCount++,verify 不再触发', async () => { + const deps = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(0) }); + const out = await runCompletionGuards(ctxOf(makeWu(), { ...DIFF_META, diffEmptyCount: 1 }), deps); + + expect(out.action).toBe('progress'); + expect(out.guardUpdates.diffEmptyCount).toBe(2); + expect(out.guardUpdates.diffEmptyHint) + .toBe('你报告了完成,但相对 main 没有任何提交内容(第 2 次)。请实现并 commit,或说明为何无需改动'); + // 优先级:闸 1 降级后 verify 不再触发 + expect(deps.runVerification).not.toHaveBeenCalled(); + expect(out.notices.diffEmptyBlocked).toBe(false); + }); + + it('有提交内容(rev-list>0)→ 放行不写 hint;既有计数归零', async () => { + const deps = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(3) }); + const out = await runCompletionGuards(ctxOf(makeWu(), { ...DIFF_META, diffEmptyCount: 2 }), deps); + + expect(out.action).toBe('complete'); + expect(out.guardUpdates.diffEmptyHint).toBeUndefined(); + expect(out.guardUpdates.diffEmptyCount).toBe(0); + expect(out.notices.diffEmptyBlocked).toBe(false); + }); + + it('diffEmptyCount 到 3 → diffEmptyBlocked', async () => { + const deps = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(0) }); + const out = await runCompletionGuards(ctxOf(makeWu(), { ...DIFF_META, diffEmptyCount: 2 }), deps); + + expect(out.guardUpdates.diffEmptyCount).toBe(3); + expect(out.notices.diffEmptyBlocked).toBe(true); + }); + + it('跳过条件:缺 worktreeBaseBranch → 不跑 git 探针;git 失败(null)→ 静默放行', async () => { + const spy = vi.fn().mockReturnValue(0); + const noBase = makeDeps({ countBranchCommits: spy }); + const out1 = await runCompletionGuards(ctxOf(makeWu(), { worktreePath: '/repo/wt' }), noBase); + expect(spy).not.toHaveBeenCalled(); + expect(out1.action).toBe('complete'); + + const gitFail = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(null) }); + const out2 = await runCompletionGuards(ctxOf(makeWu(), DIFF_META), gitFail); + expect(out2.action).toBe('complete'); + expect(out2.guardUpdates.diffEmptyHint).toBeUndefined(); + }); + + it('跳过条件:非代码类(analysis)即使有 worktree/base 也不进闸 1', async () => { + const spy = vi.fn().mockReturnValue(0); + const deps = makeDeps({ countBranchCommits: spy }); + const out = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { ...DIFF_META, prototype: true }), deps); + + expect(spy).not.toHaveBeenCalled(); + expect(out.action).toBe('complete'); + }); +}); + +describe('completion-gates: 收口闸 2(非代码类契约产物)', () => { + it('review 缺 reviewReport → 降级 progress + contractArtifactHint;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactCount).toBe(1); + expect(missing.guardUpdates.contractArtifactHint) + .toBe('契约产物缺失:评审结论(REVIEW_RESULT 协议输出)(第 1 次)。请补齐产物后再报告完成'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'review' }), { reviewReport: { approved: true } }), makeDeps()); + expect(present.action).toBe('complete'); + expect(present.guardUpdates.contractArtifactHint).toBeUndefined(); + }); + + it('plan 缺 analysisTasks → 降级;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'plan' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactHint).toContain('任务拆分清单'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'plan' }), { analysisTasks: ['拆分子任务 A'] }), makeDeps()); + expect(present.action).toBe('complete'); + }); + + it('spec 缺 specTasks → 降级;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'spec' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactHint).toContain('物化任务清单'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'spec' }), { specTasks: [{ title: 't', ac: [], blockedBy: [] }] }), makeDeps()); + expect(present.action).toBe('complete'); + }); + + it('decision 缺 decisionSuggestion → 降级;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'decision' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactHint).toContain('结论摘要'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'decision' }), { decisionSuggestion: '选方案 A' }), makeDeps()); + expect(present.action).toBe('complete'); + }); + + it('analysis 报告缺失 → 降级;落盘 → 放行;巡检变体按 inspection 检查', async () => { + const reportMissing = makeDeps({ analysisReportExists: vi.fn().mockReturnValue(false) }); + const out1 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { workspaceRoot: '/repo' }), reportMissing); + expect(out1.action).toBe('progress'); + expect(out1.guardUpdates.contractArtifactHint).toContain('调研报告(.studio/research/)'); + expect(reportMissing.analysisReportExists).toHaveBeenCalledWith('/repo', false); + + const reportPresent = makeDeps({ analysisReportExists: vi.fn().mockReturnValue(true) }); + const out2 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { workspaceRoot: '/repo' }), reportPresent); + expect(out2.action).toBe('complete'); + + const inspection = makeDeps({ analysisReportExists: vi.fn().mockReturnValue(false) }); + const out3 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { workspaceRoot: '/repo', inspection: true }), inspection); + expect(out3.action).toBe('progress'); + expect(out3.guardUpdates.contractArtifactHint).toContain('inspection-<日期>'); + expect(inspection.analysisReportExists).toHaveBeenCalledWith('/repo', true); + }); + + it('analysis 跳过条件:prototype 原型单豁免;无 workspaceRoot 无法校验 → 跳过', async () => { + const spy = vi.fn().mockReturnValue(false); + const proto = makeDeps({ analysisReportExists: spy }); + const out1 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { prototype: true, workspaceRoot: '/repo' }), proto); + expect(spy).not.toHaveBeenCalled(); + expect(out1.action).toBe('complete'); + + const noRoot = makeDeps({ analysisReportExists: spy }); + const out2 = await runCompletionGuards(ctxOf(makeWu({ type: 'analysis' }), {}), noRoot); + expect(spy).not.toHaveBeenCalled(); + expect(out2.action).toBe('complete'); + }); + + it('contractArtifactCount 到 3 → contractArtifactBlocked', async () => { + const deps = makeDeps(); + const out = await runCompletionGuards( + ctxOf(makeWu({ type: 'decision' }), { contractArtifactCount: 2 }), deps); + + expect(out.guardUpdates.contractArtifactCount).toBe(3); + expect(out.notices.contractArtifactBlocked).toBe(true); + expect(out.action).toBe('progress'); + }); + + it('代码类(task)不进本闸:无锚点可判 → 不降级', async () => { + const out = await runCompletionGuards(ctxOf(makeWu(), {}), makeDeps()); + expect(out.action).toBe('complete'); + expect(out.guardUpdates.contractArtifactHint).toBeUndefined(); + }); +}); + +describe('completion-gates: 默认产物探针(真实实现)', () => { + it('countBranchCommits:真实 git 仓库按 base..HEAD 计数;非 git 目录 → null', () => { + const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-plain-')); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-repo-')); + try { + expect(countBranchCommits(plain, 'main')).toBeNull(); + + execSync('git init -q -b main && git config user.email t@t && git config user.name t', { cwd: repo }); + fs.writeFileSync(path.join(repo, 'a.txt'), 'a'); + execSync('git add . && git commit -qm init', { cwd: repo }); + expect(countBranchCommits(repo, 'main')).toBe(0); // HEAD 即 base,无领先提交 + + execSync('git checkout -qb task/wu-1', { cwd: repo }); + fs.writeFileSync(path.join(repo, 'b.txt'), 'b'); + execSync('git add . && git commit -qm work', { cwd: repo }); + expect(countBranchCommits(repo, 'main')).toBe(1); + } finally { + fs.rmSync(plain, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('hasAnalysisReport:目录缺失/空目录/空文件 → false;非空报告 → true;inspection 只认 inspection-*.md', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-report-')); + try { + expect(hasAnalysisReport(dir, false)).toBe(false); // 无 .studio/research 目录 + + fs.mkdirSync(path.join(dir, '.studio', 'research'), { recursive: true }); + expect(hasAnalysisReport(dir, false)).toBe(false); // 空目录 + + const report = path.join(dir, '.studio', 'research', 'report.md'); + fs.writeFileSync(report, ''); + expect(hasAnalysisReport(dir, false)).toBe(false); // 空文件 + + fs.writeFileSync(report, '调研结论'); + expect(hasAnalysisReport(dir, false)).toBe(true); + expect(hasAnalysisReport(dir, true)).toBe(false); // 巡检变体不认普通报告 + + fs.writeFileSync(path.join(dir, '.studio', 'research', 'inspection-2026-09-15.md'), '巡检结论'); + expect(hasAnalysisReport(dir, true)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('completion-gates: T7-E2 parseWuGitLog(git log %(trailers) 输出解析)', () => { it('sha/subject/parents/trailer/文件清单分段;merge commit 按 %P 父数标记;输出反转为升序', () => { const shaNew = 'b'.repeat(40); diff --git a/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts b/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts index 6d3c38ba8..b0a0eec54 100644 --- a/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts +++ b/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts @@ -44,7 +44,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, writeWorkunitTokenEvent, type StepResult } from '../loop/agent-loop'; +import { AgentLoop, type StepResult } from '../loop/agent-loop'; +import { writeWorkunitTokenEvent } from '../loop/agent-loop-events'; import { BUDGET_TRIPPED_EVENT, DEFAULT_DAILY_TOKEN_BUDGET, diff --git a/apps/api/src/modules/agents/__tests__/e2e-verify.ts b/apps/api/src/modules/agents/__tests__/e2e-verify.ts index 2ff444424..df4fc5f12 100644 --- a/apps/api/src/modules/agents/__tests__/e2e-verify.ts +++ b/apps/api/src/modules/agents/__tests__/e2e-verify.ts @@ -11,7 +11,8 @@ * Requires: running API server, valid ANTHROPIC_API_KEY, Claude CLI installed */ const prisma = undefined as never; // @dommaker/studio-prisma removed (Spec 4 Phase 4) -import { AgentLoop, parseAgentOutput, resolveTarget, dynamicInterval } from '../loop/agent-loop.js'; +import { AgentLoop } from '../loop/agent-loop.js'; +import { parseAgentOutput, resolveTarget, dynamicInterval } from '../loop/agent-loop-parsers.js'; import { agentRunner } from '@dommaker/studio-agent'; import type { AgentTask, ExecutionResult } from '@dommaker/studio-agent'; import type { WorkUnit, AgentProfile, ChannelMessage } from '@prisma/client'; @@ -76,10 +77,11 @@ async function verifyAssumption2() { const task1: AgentTask = { id: workUnitId, executionId: `${workUnitId}-step1`, - agentType: 'claude', + provider: 'claude', prompt: 'You are a test agent. Read the file /root/projects/studio/apps/api/src/modules/agents/loop/agent-loop.ts and tell me the first function name you see. Output ACTION: PROGRESS:', parameters: { - sessionFlags: `--session-id ${sessionId}`, + // 新建会话:cli-adapter 按 claude 模板生成 --session-id + sessionId, agentRole: 'executor', workUnitId, extraEnv: { @@ -110,7 +112,8 @@ async function verifyAssumption2() { prompt: 'Continuing from before. What was the second function you saw? Output ACTION: COMPLETE:', parameters: { ...task1.parameters, - sessionFlags: `--resume ${sessionId}`, + // 续用同一会话:cli-adapter 对 claude 换 --resume + sessionResume: true, }, }; diff --git a/apps/api/src/modules/agents/__tests__/monitor-agent-resilience.test.ts b/apps/api/src/modules/agents/__tests__/monitor-agent-resilience.test.ts index ce9680408..17c00f389 100644 --- a/apps/api/src/modules/agents/__tests__/monitor-agent-resilience.test.ts +++ b/apps/api/src/modules/agents/__tests__/monitor-agent-resilience.test.ts @@ -19,6 +19,8 @@ const { vi.mock('@dommaker/studio-shared', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + // #575:workunit.service 模块级 createSettledTracker()(经 monitor-probes 导入链拉到),wholesale mock 需补齐 + createSettledTracker: () => ({ track: vi.fn(), waitForSettled: vi.fn(() => Promise.resolve()) }), FileStore: class { getIndex = mockGetIndex; upsertSnapshot = mockUpsertSnapshot; @@ -36,6 +38,12 @@ vi.mock('@dommaker/harness', () => ({ KnowledgeHealthScorer: class {}, ReferenceTracker: class {}, CheckpointValidator: { getInstance: () => ({ validate: () => [] }) }, + // #578:goal.hooks.ts 模块级 new CheckCache(),mock 需对齐 harness 1.8.0 导出形态 + CheckCache: class { + get(_ns: string, _key: string, fn: () => Promise) { return fn(); } + getSync(_ns: string, _key: string, fn: () => unknown) { return fn(); } + invalidate() {} + }, })); vi.mock('../../knowledge/knowledge-singletons.js', () => ({ diff --git a/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts b/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts index 16f6da3e4..52a3c3d1f 100644 --- a/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts +++ b/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts @@ -115,11 +115,12 @@ describe('dataLifecycle (每日 23:55 TTL)', () => { expect(fileStore.getIndex).not.toHaveBeenCalled(); }); - it('in window: runs precipitation gate + deletes WorkUnits older than 90 days, once per day', async () => { + it('in window: runs precipitation gate + deletes terminal WorkUnits older than 90 days, once per day', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 6, 19, 23, 55)); // 本地 23:55 - const oldWu = { id: 'wu-old', createdAt: new Date(Date.now() - 100 * 24 * 3600_000).toISOString() }; - const newWu = { id: 'wu-new', createdAt: new Date().toISOString() }; + // #540:仅终态(done/closed)才进 TTL 删除;进行中/阻塞单满 90 天保留 + const oldWu = { id: 'wu-old', status: 'done', createdAt: new Date(Date.now() - 100 * 24 * 3600_000).toISOString() }; + const newWu = { id: 'wu-new', status: 'active', createdAt: new Date().toISOString() }; const fileStore = makeFileStore({ getIndex: vi.fn(async () => [oldWu, newWu]) }); const state = { lastPrecipitateRun: '', lastDataLifecycleRun: '' }; @@ -127,10 +128,15 @@ describe('dataLifecycle (每日 23:55 TTL)', () => { expect(state.lastDataLifecycleRun).not.toBe(''); expect(state.lastPrecipitateRun).not.toBe(''); // 闸门先于清理执行 - // #170:删除走锁内墓碑(closed + deleted:true)+ 索引移除成对原语 + // #538(ADR 2026-09-15 决策 3):删除循环改调 service.delete(id, { reason })—— + // 墓碑事件行由 service 单点构造(closed + deleted:true + reason),调用方不自拼 expect(fileStore.commitRemoval).toHaveBeenCalledTimes(1); expect(fileStore.commitRemoval).toHaveBeenCalledWith( - expect.objectContaining({ type: 'closed', wuId: 'wu-old', data: { deleted: true } }), + expect.objectContaining({ + type: 'closed', + wuId: 'wu-old', + data: expect.objectContaining({ deleted: true, reason: expect.stringContaining('90 days') }), + }), 'wu-old', ); @@ -139,6 +145,31 @@ describe('dataLifecycle (每日 23:55 TTL)', () => { expect(fileStore.commitRemoval).toHaveBeenCalledTimes(1); }); + it('满 90 天的非终态单(active/blocked/in_review/pending/unassigned/无 status)不删,仅终态(done/closed)删(#540)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 6, 19, 23, 55)); + const old = () => new Date(Date.now() - 100 * 24 * 3600_000).toISOString(); + const fileStore = makeFileStore({ + getIndex: vi.fn(async () => [ + { id: 'wu-done', status: 'done', createdAt: old() }, + { id: 'wu-closed', status: 'closed', createdAt: old() }, + { id: 'wu-active', status: 'active', createdAt: old() }, + { id: 'wu-blocked', status: 'blocked', createdAt: old() }, + { id: 'wu-review', status: 'in_review', createdAt: old() }, + { id: 'wu-pending', status: 'pending', createdAt: old() }, + { id: 'wu-unassigned', status: 'unassigned', createdAt: old() }, + { id: 'wu-nostatus', createdAt: old() }, // 缺 status 按非终态保留(不误删) + ]), + }); + const state = { lastPrecipitateRun: '', lastDataLifecycleRun: '' }; + + await dataLifecycle(fileStore, state); + + expect(fileStore.commitRemoval).toHaveBeenCalledTimes(2); + const deletedIds = fileStore.commitRemoval.mock.calls.map((c: [unknown, string]) => c[1]); + expect(deletedIds.sort()).toEqual(['wu-closed', 'wu-done']); + }); + it('truncates 统一事件文件 keeping last 7 days(createdAt 口径,坏行保留)', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 6, 19, 23, 55)); diff --git a/apps/api/src/modules/agents/__tests__/monitor-probes.test.ts b/apps/api/src/modules/agents/__tests__/monitor-probes.test.ts index c415ec0fb..01b6227a7 100644 --- a/apps/api/src/modules/agents/__tests__/monitor-probes.test.ts +++ b/apps/api/src/modules/agents/__tests__/monitor-probes.test.ts @@ -51,10 +51,9 @@ vi.mock('../../mcp/tool-registry.js', () => ({ toolRegistry: { getStats: mockGetStats }, })); -// #176:关闭双出声出口(wu-closure)打桩 —— 其自身行为由 wu-closure.test.ts 覆盖 -vi.mock('../../workunit/wu-closure.js', () => ({ - closeWorkUnitWithNotice: mockCloseWithNotice, - WORKUNIT_CLOSED_EVENT_TYPE: 'workunit:closed', +// #550:关闭状态机单口(WorkUnitService.close)打桩 —— 其自身行为由 workunit-close.test.ts 覆盖 +vi.mock('../../workunit/workunit.service.js', () => ({ + WorkUnitService: vi.fn(function () { return { close: mockCloseWithNotice }; }), })); // #181:统一事件流读取打桩(全量 mock——真模块顶层 new FileStore() 依赖 shared,不宜 importOriginal) @@ -420,15 +419,27 @@ describe('checkTotalExecutionTime', () => { expect.objectContaining({ source: 'total_time', level: 'critical', relatedTaskIds: ['exec-timeout'] }), ])); expect(mockAgentStop).toHaveBeenCalledWith('exec-timeout'); - // #176(决策 #62 §3):2.5h 强杀置 closed 补关闭原因事件 + 频道说明(经统一出口) + // #176(决策 #62 §3):2.5h 强杀置 closed 补关闭原因事件 + 频道说明(#550 起经 close 状态机单口) expect(mockCloseWithNotice).toHaveBeenCalledTimes(1); expect(mockCloseWithNotice).toHaveBeenCalledWith( - fileStore, - expect.objectContaining({ id: 'exec-timeout', status: 'active' }), + 'exec-timeout', expect.objectContaining({ closedBy: 'total-time-kill', reason: expect.stringContaining('2.5h') }), ); }); + // #553:decision/spec 豁免超时巡检——#108 裁剪状态机无 closed,不进超时关闭路径, + // 不告警、不杀进程、不尝试 close(否则每轮巡检重复 critical 但永不生效) + it.each(['decision', 'spec'])('skips %s workUnit exceeding 2.5h entirely (no alert/stop/close)', async (type) => { + const threeHoursAgo = new Date(Date.now() - 3 * 3600_000).toISOString(); + const exec = makeSnapshot({ id: `exec-${type}`, type, status: 'active', claimedAt: threeHoursAgo, createdAt: threeHoursAgo }); + const fileStore = makeFileStore({ getIndex: vi.fn(async () => [exec]) }); + + const alerts = await checkTotalExecutionTime(fileStore, await fileStore.getIndex()); + expect(alerts).toEqual([]); + expect(mockAgentStop).not.toHaveBeenCalled(); + expect(mockCloseWithNotice).not.toHaveBeenCalled(); + }); + it('emits warning at >2h and info at >1h without intervention', async () => { const mkActive = (hoursAgo: number) => makeSnapshot({ id: `exec-${hoursAgo}h`, status: 'active', @@ -458,8 +469,7 @@ describe('autoAbandon probes', () => { expect(mockCloseWithNotice).toHaveBeenCalledTimes(1); expect(mockCloseWithNotice).toHaveBeenCalledWith( - fileStore, - expect.objectContaining({ id: 'wu-stale', status: 'blocked' }), + 'wu-stale', expect.objectContaining({ closedBy: 'auto-abandon-stale-blocked' }), ); }); @@ -518,6 +528,21 @@ describe('autoAbandon probes', () => { expect(mockCloseWithNotice).not.toHaveBeenCalled(); }); + + // #567 AC7:方向锁定/裁决轮挂起的 plan 超 24h 不被自动关闭(#471 plan 类型豁免的 + // 显式回归网;30 分钟提醒 scanWaitingForInputReminders 覆盖全部 blocked,零改动) + it.each(['plan-direction', 'plan-ruling'])('#567: waitingReason=%s 的 blocked plan 超 24h 豁免死信', async (waitingReason) => { + const plan = makeSnapshot({ + id: `wu-plan-${waitingReason}`, type: 'plan', status: 'blocked', + createdAt: new Date(Date.now() - 96 * 3600_000).toISOString(), + metadata: JSON.stringify({ blockedAt: new Date(Date.now() - 96 * 3600_000).toISOString(), waitingReason }), + }); + const fileStore = makeFileStore({ getIndex: vi.fn(async () => [plan]) }); + + await autoAbandonStaleBlocked(fileStore, await fileStore.getIndex()); + + expect(mockCloseWithNotice).not.toHaveBeenCalled(); + }); }); describe('checkSessionFileHealth', () => { diff --git a/apps/api/src/modules/agents/__tests__/ops-agent-resilience.test.ts b/apps/api/src/modules/agents/__tests__/ops-agent-resilience.test.ts index 169532a49..0dff3efe5 100644 --- a/apps/api/src/modules/agents/__tests__/ops-agent-resilience.test.ts +++ b/apps/api/src/modules/agents/__tests__/ops-agent-resilience.test.ts @@ -229,6 +229,26 @@ describe('OpsService proxy health (AC-2)', () => { } }); + it('#573: 端口检查收编 port-probe——preflight 不再含 port-available,不再跑 lsof', async () => { + // 端口口径单源:契约 §7(摸底冲突 4)——lsof abort 语义收编为 port-probe + // 动态顺延分支(调用方 studioUp/run-web 启动前解析),preflight 不再查端口 + mockExecSync.mockClear(); + ensureStudioData(); + const dist = makeTmp('ops-pf-dist-'); + const repo = makeTmp('ops-pf-repo-'); + fs.writeFileSync(path.join(dist, 'index.html'), 'x'); + try { + const ops = createOpsService(19999); + pinThresholds(ops); + const result = await ops.preflight(repo, dist); + expect(result.checks.find(c => c.name === 'port-available')).toBeUndefined(); + expect(mockExecSync.mock.calls.flat().join(' ')).not.toContain('lsof -ti:'); + } finally { + fs.rmSync(dist, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + it('readDiskUsage 不可用(null)→ skip 而非失败', async () => { mockReadDiskUsage.mockReturnValue(null); ensureStudioData(); diff --git a/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts b/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts index b9807460d..d40c7868f 100644 --- a/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts +++ b/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts @@ -933,6 +933,7 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(prompt).toContain('## 产出契约'); expect(prompt).toContain('REVIEW_RESULT'); + expect(prompt).toContain('本环节标准打法:code-review'); expect(knowledgeContext).not.toContain('## 产出契约'); const baseIdx = prompt.indexOf('## 当前工作'); @@ -954,6 +955,7 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(prompt).toContain('## 产出契约'); expect(prompt).toContain('测试先行'); expect(prompt).toContain('Phase commit'); + expect(prompt).toContain('本环节标准打法:tdd-implement'); }); it('契约段 decision(决策单)→ 结论摘要格式', async () => { @@ -1002,6 +1004,7 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(prompt).toContain('## 产出契约'); expect(prompt).toContain('复现测试先行'); expect(prompt).toContain('防回归测试随修复同 commit'); + expect(prompt).toContain('本环节标准打法:diagnosing-bugs'); }); it('未知/无契约 type(task/feature)→ 空段不注入(spec 自 #463 起有物化清单契约)', async () => { @@ -1040,10 +1043,55 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(prompt).toContain('.studio/specs/'); }); + it('#567 契约段 plan 含方向锁定段(DIRECTION 协议行,在裁决轮段之前)', async () => { + const plan = CONTRACT_TEMPLATES.plan; + expect(plan).toContain('DIRECTION:'); + expect(plan).toContain('方向锁定'); + const dirIdx = plan.indexOf('方向锁定'); + const rulingIdx = plan.indexOf('裁决轮(fog 调研齐后出一次'); + expect(dirIdx).toBeGreaterThanOrEqual(0); + expect(rulingIdx).toBeGreaterThan(dirIdx); + + const { prompt } = await composeStepPrompt( + { wu: makeWu({ type: 'plan' }), metadata: {} as any }, + deps(makeRole()), + ); + expect(prompt).toContain('DIRECTION:'); + }); + + it('#567 directionPick:false → 契约段方向锁定段替换为「本单已关闭方向锁定」提示', async () => { + const { prompt } = await composeStepPrompt( + { wu: makeWu({ type: 'plan' }), metadata: { directionPick: false } as any }, + deps(makeRole()), + ); + + expect(prompt).toContain('本单已关闭方向锁定'); + expect(prompt).not.toContain('DIRECTION:'); + }); + + it('#567 AC6:非 plan 类型契约模板不含方向锁定段(零改动证明)', () => { + for (const [type, template] of Object.entries(CONTRACT_TEMPLATES)) { + if (type === 'plan') continue; + expect(template, `${type} 模板不应含 DIRECTION 段`).not.toContain('DIRECTION:'); + expect(template, `${type} 模板不应含方向锁定`).not.toContain('方向锁定'); + } + }); + it('契约段 200 软定额 + 模板表覆盖 review/implement/decision/analysis/bug/spec/plan(#121/#463/#471)', () => { expect(SECTION_QUOTAS.contract).toBe(200); expect(Object.keys(CONTRACT_TEMPLATES).sort()).toEqual(['analysis', 'bug', 'decision', 'implement', 'plan', 'review', 'spec']); }); + + it('契约段尾标准打法指引行(不受定额截断影响,直接断言模板表):implement/bug/plan/analysis/review → 对应 skill', () => { + expect(CONTRACT_TEMPLATES.implement).toContain('本环节标准打法:tdd-implement(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.bug).toContain('本环节标准打法:diagnosing-bugs(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.plan).toContain('本环节标准打法:requirement-clarify + to-tickets(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.analysis).toContain('本环节标准打法:research(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.review).toContain('本环节标准打法:code-review(索引见 skills 段,先 loadSkill 读全文再开工)。'); + // decision/spec/巡检变体(INSPECTION_CONTRACT)无对应打法 skill,不加指引行 + expect(CONTRACT_TEMPLATES.decision).not.toContain('本环节标准打法'); + expect(CONTRACT_TEMPLATES.spec).not.toContain('本环节标准打法'); + }); }); describe('#161 T7-E2: processCheckHint 注入→消费→清除回路', () => { diff --git a/apps/api/src/modules/agents/__tests__/review-dispatcher.test.ts b/apps/api/src/modules/agents/__tests__/review-dispatcher.test.ts index f8dfdd5d0..ccd90a554 100644 --- a/apps/api/src/modules/agents/__tests__/review-dispatcher.test.ts +++ b/apps/api/src/modules/agents/__tests__/review-dispatcher.test.ts @@ -704,3 +704,38 @@ describe('#466 routing.review(评审路由)', () => { expect(notice).toBeDefined(); }); }); + +describe('P7: STUDIO_AUTO_REVIEW 开关(fake/无凭证环境豁免自动评审)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('STUDIO_AUTO_REVIEW=false:in_review 事件链不自动建评审子 WU', async () => { + vi.stubEnv('STUDIO_AUTO_REVIEW', 'false'); + const { child } = await createParentAndReview('实现功能 P7', executorProfile.id); + expect(child).toBeUndefined(); + }); + + it('STUDIO_AUTO_REVIEW=false:对账 redispatchReview 同样不建单', async () => { + vi.stubEnv('STUDIO_AUTO_REVIEW', 'false'); + const parent = await wuService.create({ + scope: '实现功能 P7R', + type: 'feature', + channelId: 'ch-test', + assigneeId: executorProfile.id, + status: 'active', + }); + const inReview = await wuService.transitionStatus(parent.id, 'in_review'); + await dispatcher.waitForSettled(); + const out = await dispatcher.redispatchReview(inReview); + expect(out).toBeNull(); + const snapshots = await fileStore.getIndex(); + expect(snapshots.find(s => s.parentId === parent.id && s.type === 'review')).toBeUndefined(); + }); + + it('默认(未设置/其他值)照旧自动派评审', async () => { + vi.stubEnv('STUDIO_AUTO_REVIEW', 'true'); + const { child } = await createParentAndReview('实现功能 P7D', executorProfile.id); + expect(child).toBeDefined(); + }); +}); diff --git a/apps/api/src/modules/agents/__tests__/system-executor.test.ts b/apps/api/src/modules/agents/__tests__/system-executor.test.ts index 386b7a65d..121342a46 100644 --- a/apps/api/src/modules/agents/__tests__/system-executor.test.ts +++ b/apps/api/src/modules/agents/__tests__/system-executor.test.ts @@ -462,5 +462,22 @@ describe('SystemExecutor', () => { expect.objectContaining({ timeoutMs: 15_000 }), ); }); + + // #581:超时整树回收——恒开 killProcessGroup(#171 机制,runner-lightweight 先例) + it('execSh 恒开 killProcessGroup(超时杀整进程组,不留孙进程孤儿)', async () => { + await ensureStudioProfile(fileStore); + const profiles = await fileStore.listProfiles(); + const studio = profiles.find(p => p.name === 'studio')!; + await fileStore.updateProfile(studio.id, { provider: 'claude' }); + + mockExecSh.mockResolvedValue({ stdout: '{}', stderr: '' }); + + await executor.run('test'); + + expect(mockExecSh).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ killProcessGroup: true }), + ); + }); }); }); diff --git a/apps/api/src/modules/agents/__tests__/system-health.test.ts b/apps/api/src/modules/agents/__tests__/system-health.test.ts index 61e9b0c5c..c6e2bbf85 100644 --- a/apps/api/src/modules/agents/__tests__/system-health.test.ts +++ b/apps/api/src/modules/agents/__tests__/system-health.test.ts @@ -21,6 +21,15 @@ vi.mock('@dommaker/studio-shared', () => { return { FileStore, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }; }); +// #538:runGC 的 WU 删除改走 service.delete 单口——本套件只钉「委托语义 + 筛选口径」, +// 墓碑/reason/workunit:removed 不变式由 workunit-crud.test.ts 锁定(模块边界单测,不重复钉)。 +// 真 chain 在本 mock 下不可达:studio-shared 整体替换后 workunit.service 的传递 import +//(routing.parseChannels / withAttestation 等)缺导出会在 import 期炸。 +const mockWuDelete = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock('../../workunit/workunit.service.js', () => ({ + WorkUnitService: vi.fn(function () { return { delete: mockWuDelete }; }), +})); + // 数据根入口走真实现会撞上面的 os mock(无 homedir),这里钉一个 env 敏感的等价物: // 断言的边界是「runGC 经 studioPath/STUDIO_HOME 解析 sessions 目录」而非 studioDir 本身。 vi.mock('@dommaker/studio-shared/studio-dir', async () => { @@ -173,4 +182,31 @@ describe('runGC', () => { fs.rmSync(tmpRoot, { recursive: true, force: true }); } }); + + // #538(ADR 2026-09-15 决策 3):WU 删除分支断言——删除循环委托 service.delete(id, { reason }) + // 单口(不再直摸 commitRemoval),筛选逻辑(done/closed 且 completedAt >30 天)留调用方不变 + it('deletes completed WorkUnits older than 30 days via service.delete(委托单口 + 筛选口径不变)', async () => { + const { FileStore } = await import('@dommaker/studio-shared'); + const getIndex = (FileStore as unknown as { prototype: { getIndex: ReturnType } }).prototype.getIndex; + const old = new Date(Date.now() - 31 * 24 * 3600_000).toISOString(); + const fresh = new Date(Date.now() - 10 * 24 * 3600_000).toISOString(); + getIndex.mockResolvedValue([ + { id: 'wu-old', status: 'done', completedAt: old, channelId: 'ch-1' }, + { id: 'wu-fresh', status: 'done', completedAt: fresh, channelId: null }, + { id: 'wu-active', status: 'active', completedAt: null, channelId: null }, + ]); + mockWuDelete.mockClear(); + try { + const { runGC } = await import('../ops/system-health'); + const result = await runGC(); + + expect(result.details.some((d) => d.includes('wu-old'))).toBe(true); + expect(result.details.some((d) => d.includes('wu-fresh'))).toBe(false); + expect(result.details.some((d) => d.includes('wu-active'))).toBe(false); + expect(mockWuDelete).toHaveBeenCalledTimes(1); + expect(mockWuDelete).toHaveBeenCalledWith('wu-old', { reason: expect.stringContaining('30 days') }); + } finally { + getIndex.mockResolvedValue([]); // 不污染同文件其它用例(FileStore.prototype 共享) + } + }); }); diff --git a/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts b/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts index 64ee015fe..6c15f73d4 100644 --- a/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts +++ b/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts @@ -44,14 +44,9 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { - AgentLoop, - isTestLikeWorkUnit, - testWuGuardEnabled, - resolveRealUsage, - writeWorkunitTokenEvent, - type StepResult, -} from '../loop/agent-loop'; +import { AgentLoop, type StepResult } from '../loop/agent-loop'; +import { isTestLikeWorkUnit, testWuGuardEnabled } from '../loop/agent-loop-guards'; +import { resolveRealUsage, writeWorkunitTokenEvent } from '../loop/agent-loop-events'; import { WorkUnitService, type WorkUnitMetadata, type WorkUnitData } from '../../workunit/workunit.service'; import type { ExecutionResult } from '@dommaker/studio-agent'; diff --git a/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts b/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts new file mode 100644 index 000000000..bbd41dd6e --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts @@ -0,0 +1,440 @@ +/** + * 决策 14 认领前适任判断(agents/loop/claim-fitness.ts + agent-loop 集成)单测 + * + * 覆盖(对应设计要点): + * - parseFitnessVerdict:FIT 协议解析(yes/no/理由/信封拆封),解析不出 → 从宽适任 + * - parseUnfitRoles/isRoleUnfit:metadata 容错解析 + * - judgeClaimFitness:prompt 组装(type+scope 截断+persona/acceptedTypes)、调用失败 → 从宽 + * - observe 第 7 道过滤:unfitRoles 含本 role → 不可见;看下一候选;全不适任 → 不认领 + * - ensureClaimFit:适任放行 / 不适任落档 unfitRoles / 指名不判 / 落档失败从宽 + * - 全员不适任:unfitRoles 覆盖频道全部 active 成员 → blocked + blockReason + 频道消息 + * + * 真实 FileStore(tmpdir)+ 真实 WorkUnitService;LLM 调用 mock getSystemExecutor + * (同 completion-extraction.test.ts 先例);CLI 执行与 knowledge-service mock。 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { FileStore } from '@dommaker/studio-shared'; +import { WorkUnitService, type WorkUnitData } from '../../../workunit/workunit.service.js'; +import { parseWuMetadata } from '../../../workunit/wu-metadata.js'; + +const { mockRun } = vi.hoisted(() => ({ mockRun: vi.fn() })); + +vi.mock('../../system-executor.js', () => ({ + getSystemExecutor: () => ({ run: mockRun }), +})); + +vi.mock('@dommaker/studio-agent', () => ({ + agentRunner: { + executeLightweight: vi.fn(), + }, +})); + +vi.mock('../../../knowledge/knowledge-service', () => ({ + knowledgeService: { + injectContext: vi.fn().mockResolvedValue({ prompt: '', injectedIds: [] }), + recordOutcome: vi.fn().mockResolvedValue(undefined), + extractFromExecution: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { AgentLoop } from '../agent-loop'; +import { resolveTarget } from '../agent-loop-parsers'; +import { + buildClaimFitnessPrompt, parseFitnessVerdict, parseUnfitRoles, isRoleUnfit, + judgeClaimFitness, FITNESS_SCOPE_MAX_CHARS, +} from '../claim-fitness'; + +const SELF_ROLE_ID = 'role-self'; +const OTHER_ROLE_ID = 'role-other'; +const SELF_INSTANCE_ID = 'instance-self'; +const MY_CHANNEL = 'ch-mine'; + +const mockRole = { + id: SELF_ROLE_ID, + name: 'self-agent', + description: '后端开发角色', + channels: JSON.stringify([MY_CHANNEL]), + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + persona: '我负责后端 API 与数据层实现', + acceptedTypes: ['implement', 'bug'], +}; + +interface ObserveCapable { + observe(): Promise<{ + myActive: WorkUnitData[]; + unassigned: WorkUnitData[]; + newReplies: unknown[]; + }>; +} + +interface FitnessCapable { + ensureClaimFit(wu: WorkUnitData): Promise; +} + +let testDir: string; +let fileStore: FileStore; +let wuService: WorkUnitService; +let agentLoop: AgentLoop; + +beforeEach(() => { + vi.clearAllMocks(); + mockRun.mockResolvedValue({ output: 'FIT: yes: 属于本角色职能域' }); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-claim-fitness-')); + fileStore = new FileStore(testDir); + wuService = new WorkUnitService(fileStore); + agentLoop = new AgentLoop(mockRole, fileStore); + (agentLoop as unknown as { instance: unknown }).instance = { + id: SELF_INSTANCE_ID, + roleId: SELF_ROLE_ID, + sessionId: null, + status: 'idle', + currentWorkUnitId: null, + startedAt: new Date().toISOString(), + terminatedAt: null, + lastHeartbeat: null, + metadata: null, + }; +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +function observe() { + return (agentLoop as unknown as ObserveCapable).observe(); +} + +function ensureClaimFit(wu: WorkUnitData) { + return (agentLoop as unknown as FitnessCapable).ensureClaimFit(wu); +} + +async function createUnassignedWu(scope: string, metadata?: Record, assigneeId: string | null = null) { + return wuService.create({ + scope, channelId: MY_CHANNEL, type: 'implement', + status: 'unassigned', assigneeId, + ...(metadata ? { metadata } : {}), + }); +} + +/** 把 WU createdAt 回填到过去(控制 observe 的 createdAt asc 排序) */ +async function backdateCreatedAt(wuId: string, ms: number): Promise { + const snap = (await fileStore.getIndex()).find(s => s.id === wuId); + if (!snap) throw new Error(`wu ${wuId} not found`); + await fileStore.upsertSnapshot({ ...snap, createdAt: new Date(Date.now() - ms).toISOString() }); +} + +async function createChannelWithMembers(memberIds: string[]): Promise { + const now = new Date().toISOString(); + await fileStore.createChannel({ + id: MY_CHANNEL, + name: 'test-channel', + type: 'rnd', + defaultWorkspaceId: null, + defaultPath: null, + discordChannelId: null, + discordWebhookUrl: null, + members: JSON.stringify(memberIds), + createdAt: now, + updatedAt: now, + }); +} + +async function createProfile(id: string, name: string, status: string): Promise { + const now = new Date().toISOString(); + await fileStore.createProfile({ + id, name, description: null, channels: '[]', status, provider: 'claude', + createdAt: now, updatedAt: now, + }); +} + +describe('parseFitnessVerdict', () => { + it('FIT: yes → 适任,理由随行为空可缺省', () => { + expect(parseFitnessVerdict('FIT: yes: 属于后端范畴')).toEqual({ fit: true, reason: '属于后端范畴' }); + expect(parseFitnessVerdict('FIT: yes')).toEqual({ fit: true, reason: '' }); + }); + + it('FIT: no → 不适任,理由取同行余下部分', () => { + expect(parseFitnessVerdict('FIT: no: 这是前端样式任务')).toEqual({ fit: false, reason: '这是前端样式任务' }); + }); + + it('claude --output-format json 信封先拆封再解析', () => { + const envelope = JSON.stringify({ type: 'result', result: 'FIT: no: 与角色无关' }); + expect(parseFitnessVerdict(envelope)).toEqual({ fit: false, reason: '与角色无关' }); + }); + + it('模型输出夹带其他行 → 只认 FIT 行', () => { + expect(parseFitnessVerdict('让我想想……\nFIT: no: 做不了\n以上')).toEqual({ fit: false, reason: '做不了' }); + }); + + it('解析不出(无 FIT 行/空串/坏信封)→ 从宽适任', () => { + expect(parseFitnessVerdict('我觉得可以试试吧').fit).toBe(true); + expect(parseFitnessVerdict('').fit).toBe(true); + expect(parseFitnessVerdict('{"type":"other"}').fit).toBe(true); + }); + + it('理由超 120 字符截断', () => { + const long = parseFitnessVerdict(`FIT: no: ${'x'.repeat(200)}`); + expect(long.fit).toBe(false); + expect(long.reason.length).toBe(120); + }); +}); + +describe('parseUnfitRoles / isRoleUnfit', () => { + const entry = { roleId: SELF_ROLE_ID, reason: 'r', at: '2026-09-15T00:00:00.000Z' }; + + it('正常名单解析 + 命中判定', () => { + const meta = JSON.stringify({ unfitRoles: [entry] }); + expect(parseUnfitRoles(meta)).toEqual([entry]); + expect(isRoleUnfit(meta, SELF_ROLE_ID)).toBe(true); + expect(isRoleUnfit(meta, OTHER_ROLE_ID)).toBe(false); + }); + + it('缺失/坏 JSON/非数组/缺 roleId 条目 → 空名单(不排除)', () => { + expect(parseUnfitRoles(null)).toEqual([]); + expect(parseUnfitRoles(undefined)).toEqual([]); + expect(parseUnfitRoles('{bad json')).toEqual([]); + expect(parseUnfitRoles(JSON.stringify({ unfitRoles: 'nope' }))).toEqual([]); + expect(parseUnfitRoles(JSON.stringify({ unfitRoles: [{ reason: 'x' }, entry] }))).toEqual([entry]); + }); +}); + +describe('buildClaimFitnessPrompt / judgeClaimFitness', () => { + it('prompt 含 role 名/persona/acceptedTypes/wu type/scope', () => { + const prompt = buildClaimFitnessPrompt( + { type: 'implement', scope: '给 API 加分页' }, + mockRole as never, + ); + expect(prompt).toContain('self-agent'); + expect(prompt).toContain('我负责后端 API 与数据层实现'); + expect(prompt).toContain('implement、bug'); + expect(prompt).toContain('类型 implement'); + expect(prompt).toContain('给 API 加分页'); + }); + + it('scope 超长按 FITNESS_SCOPE_MAX_CHARS 截断', () => { + const prompt = buildClaimFitnessPrompt( + { type: 'task', scope: 's'.repeat(FITNESS_SCOPE_MAX_CHARS + 500) }, + mockRole as never, + ); + expect(prompt).toContain('[truncated]'); + expect(prompt.length).toBeLessThan(FITNESS_SCOPE_MAX_CHARS + 500); + }); + + it('判断调用(默认走 getSystemExecutor)返回 FIT: no → 不适任', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 前端任务' }); + const verdict = await judgeClaimFitness({ type: 'implement', scope: '改样式' }, mockRole as never); + expect(verdict.fit).toBe(false); + expect(mockRun).toHaveBeenCalledTimes(1); + const callArgs = mockRun.mock.calls[0]; + expect(callArgs[1].eventSource).toBe('claim-fitness'); + }); + + it('判断调用抛错(spawn 失败/超时/未配置)→ 从宽适任', async () => { + mockRun.mockRejectedValue(new Error('cli timeout')); + const verdict = await judgeClaimFitness({ type: 'implement', scope: '任何任务' }, mockRole as never); + expect(verdict.fit).toBe(true); + }); +}); + +describe('#579: STUDIO_CLAIM_FITNESS 开关(fake/无凭证环境豁免适任判断)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('STUDIO_CLAIM_FITNESS=false:judgeClaimFitness 直接放行,不起 LLM 调用', async () => { + vi.stubEnv('STUDIO_CLAIM_FITNESS', 'false'); + const verdict = await judgeClaimFitness({ type: 'implement', scope: '任何任务' }, mockRole as never); + expect(verdict).toEqual({ fit: true, reason: '' }); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('STUDIO_CLAIM_FITNESS=false:ensureClaimFit 涌现认领放行且不落档', async () => { + vi.stubEnv('STUDIO_CLAIM_FITNESS', 'false'); + mockRun.mockResolvedValue({ output: 'FIT: no: 不该被读到' }); + const wu = await createUnassignedWu('普通单'); + expect(await ensureClaimFit(wu)).toBe(true); + expect(mockRun).not.toHaveBeenCalled(); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toBeUndefined(); + }); + + it('默认(未设置/其他值)照旧做适任判断', async () => { + vi.stubEnv('STUDIO_CLAIM_FITNESS', 'true'); + const verdict = await judgeClaimFitness({ type: 'implement', scope: '任何任务' }, mockRole as never); + expect(verdict.fit).toBe(true); + expect(mockRun).toHaveBeenCalledTimes(1); + }); +}); + +describe('observe 第 7 道过滤(unfitRoles 不可见)', () => { + it('unfitRoles 含本 role → 不可见;含其他 role → 可见', async () => { + const unfitForSelf = await createUnassignedWu('别人做不了的单', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: '做不了', at: new Date().toISOString() }], + }); + const unfitForOther = await createUnassignedWu('别人已被排除的单', { + unfitRoles: [{ roleId: OTHER_ROLE_ID, reason: '不适合他', at: new Date().toISOString() }], + }); + + const obs = await observe(); + const ids = obs.unassigned.map(w => w.id); + expect(ids).not.toContain(unfitForSelf.id); + expect(ids).toContain(unfitForOther.id); + }); + + it('首候选不适任 → resolveTarget 取下一候选(继续评估下一单)', async () => { + const first = await createUnassignedWu('更早但不适合我的单', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: 'r', at: new Date().toISOString() }], + }); + await backdateCreatedAt(first.id, 60_000); + const second = await createUnassignedWu('稍晚的普通单'); + + const obs = await observe(); + const target = resolveTarget(obs); + expect(target?.workUnit.id).toBe(second.id); + }); + + it('全部候选不适任 → resolveTarget 为 null(本轮不认领静默等待)', async () => { + await createUnassignedWu('单甲', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: 'r', at: new Date().toISOString() }], + }); + await createUnassignedWu('单乙', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: 'r', at: new Date().toISOString() }], + }); + + const obs = await observe(); + expect(obs.unassigned).toHaveLength(0); + expect(resolveTarget(obs)).toBeNull(); + }); +}); + +describe('ensureClaimFit(认领前闸门)', () => { + it('判 yes → 放行,metadata 不写 unfitRoles', async () => { + const wu = await createUnassignedWu('普通后端单'); + expect(await ensureClaimFit(wu)).toBe(true); + expect(mockRun).toHaveBeenCalledTimes(1); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toBeUndefined(); + }); + + it('判 no → 不放行 + unfitRoles 落档 {roleId, reason, at}', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 纯前端样式任务' }); + const wu = await createUnassignedWu('调整按钮颜色'); + expect(await ensureClaimFit(wu)).toBe(false); + + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toHaveLength(1); + expect(meta.unfitRoles![0].roleId).toBe(SELF_ROLE_ID); + expect(meta.unfitRoles![0].reason).toBe('纯前端样式任务'); + expect(typeof meta.unfitRoles![0].at).toBe('string'); + }); + + it('已落档过本 role → 去重不重复追加', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('单', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: '旧理由', at: '2026-09-14T00:00:00.000Z' }], + }); + expect(await ensureClaimFit(wu)).toBe(false); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toHaveLength(1); + expect(meta.unfitRoles![0].reason).toBe('做不了'); + }); + + it('判断调用失败 → 从宽放行,不落档', async () => { + mockRun.mockRejectedValue(new Error('spawn failed')); + const wu = await createUnassignedWu('普通单'); + expect(await ensureClaimFit(wu)).toBe(true); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toBeUndefined(); + }); + + it('显式指名本 role 的 WU 不判(人已点名),直接放行且不调用 LLM', async () => { + const wu = await createUnassignedWu('指名单', undefined, SELF_ROLE_ID); + expect(await ensureClaimFit(wu)).toBe(true); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('落档失败(FileStore 故障)→ 从宽放行(宁可误抢,不每轮空烧判断)', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('普通单'); + const svc = (agentLoop as unknown as { workUnitService: WorkUnitService }).workUnitService; + const original = svc.update.bind(svc); + svc.update = vi.fn().mockRejectedValue(new Error('fs broken')) as never; + try { + expect(await ensureClaimFit(wu)).toBe(true); + } finally { + svc.update = original; + } + }); + + it('判 no 但 WU 已被他人抢先认领 → 不落档', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('普通单'); + await wuService.claim(wu.id, 'instance-other'); + expect(await ensureClaimFit(wu)).toBe(false); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toBeUndefined(); + }); +}); + +describe('全员不适任转人工', () => { + beforeEach(async () => { + await createChannelWithMembers([SELF_ROLE_ID, OTHER_ROLE_ID]); + await createProfile(SELF_ROLE_ID, 'self-agent', 'active'); + }); + + it('unfitRoles 覆盖频道全部 active 成员 → blocked + blockReason + 频道消息', async () => { + await createProfile(OTHER_ROLE_ID, 'other-agent', 'active'); + mockRun.mockResolvedValue({ output: 'FIT: no: 跨领域任务' }); + const wu = await createUnassignedWu('谁都不会做的单', { + unfitRoles: [{ roleId: OTHER_ROLE_ID, reason: '他也不行', at: new Date().toISOString() }], + }); + + expect(await ensureClaimFit(wu)).toBe(false); + + const after = (await wuService.getById(wu.id))!; + expect(after.status).toBe('blocked'); + const meta = parseWuMetadata(after.metadata); + expect(meta.blockReason).toContain('unfit-all'); + expect(meta.unfitRoles!.map(e => e.roleId).sort()).toEqual([OTHER_ROLE_ID, SELF_ROLE_ID].sort()); + // 仿 blocked 转人模式:频道消息「无人能接」 + const messages = await fileStore.queryAllMessages({ workUnitId: wu.id }); + expect(messages.some(m => m.content.includes('无人能接此任务'))).toBe(true); + }); + + it('还有其他 active 成员未判 → 保持 unassigned,不转人工', async () => { + await createProfile(OTHER_ROLE_ID, 'other-agent', 'active'); + mockRun.mockResolvedValue({ output: 'FIT: no: 我做不了' }); + const wu = await createUnassignedWu('普通单'); + + expect(await ensureClaimFit(wu)).toBe(false); + const after = (await wuService.getById(wu.id))!; + expect(after.status).toBe('unassigned'); + const messages = await fileStore.queryAllMessages({ workUnitId: wu.id }); + expect(messages.some(m => m.content.includes('无人能接此任务'))).toBe(false); + }); + + it('inactive 成员不计入「全员」——唯一 active 成员判 no 即全员不适任', async () => { + await createProfile(OTHER_ROLE_ID, 'other-agent', 'inactive'); + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('普通单'); + + expect(await ensureClaimFit(wu)).toBe(false); + expect((await wuService.getById(wu.id))!.status).toBe('blocked'); + }); + + it('无频道 WU 判 no → 落档但不判全员(保持 unassigned)', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await wuService.create({ + scope: '无频道单', channelId: null, type: 'implement', status: 'unassigned', assigneeId: null, + }); + expect(await ensureClaimFit(wu)).toBe(false); + const after = (await wuService.getById(wu.id))!; + expect(after.status).toBe('unassigned'); + expect(parseWuMetadata(after.metadata).unfitRoles).toHaveLength(1); + }); +}); diff --git a/apps/api/src/modules/agents/loop/__tests__/completion-harvest.test.ts b/apps/api/src/modules/agents/loop/__tests__/completion-harvest.test.ts new file mode 100644 index 000000000..a20d73626 --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/completion-harvest.test.ts @@ -0,0 +1,159 @@ +// per-WU-type COMPLETE 收割注册表单测(#542):表驱动直测 harvestCompletionMetadata, +// 不构造 AgentLoop、不 mock 执行器(对称 step-guards/completion-gates 的可测试性契约)。 +// 覆盖:六种收割分支逐 type 命中/落空、plan 复用 analysis 契约、inspection 变体标记、 +// 未注册 type 空获、单个收割器抛错不阻断其余收割器(失败语义决议)。 +import { describe, it, expect, afterEach } from 'vitest'; +import { + harvestCompletionMetadata, + COMPLETION_HARVEST_REGISTRY, + type CompletionHarvestContext, +} from '../completion-harvest'; +import type { WorkUnitMetadata } from '../../../workunit/workunit.service.js'; + +function ctx(overrides: Partial = {}): CompletionHarvestContext { + return { wuId: 'wu-1', wuType: 'task', metadata: {} as WorkUnitMetadata, ...overrides }; +} + +describe('harvestCompletionMetadata', () => { + it.each([ + ['task'], ['feature'], ['bug'], ['unknown-type'], + ])('未注册 type(%s)→ 空 updates', (wuType) => { + expect(harvestCompletionMetadata('REVIEW_RESULT: {"verdict":"pass"}', ctx({ wuType }))).toEqual({}); + }); + + describe('review', () => { + it.each([ + { + name: 'REVIEW_RESULT pass 行 → reviewReport.approved=true', + text: '评审完毕\nREVIEW_RESULT: {"verdict":"pass","summary":"LGTM"}', + expected: { reviewReport: { approved: true, reason: 'LGTM', issues: undefined } }, + }, + { + name: 'REVIEW_RESULT reject 行 → reviewReport.approved=false', + text: 'REVIEW_RESULT: {"verdict":"reject","summary":"缺测试"}', + expected: { reviewReport: { approved: false, reason: '缺测试', issues: undefined } }, + }, + { + name: '无 REVIEW_RESULT 且无 verdict 关键词 → 空(转人工,不误拒)', + text: '随便一段没有结论的输出', + expected: {}, + }, + ])('$name', ({ text, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType: 'review' }))).toEqual(expected); + }); + }); + + describe.each(['analysis', 'plan'])('%s(plan 复用 analysis 契约)', (wuType) => { + it.each([ + { + name: 'TASK: 拆分行 → analysisTasks', + text: '分析结论\nTASK: 拆出登录接口\nTASK: 补齐鉴权测试', + metadata: {}, + expected: { analysisTasks: ['拆出登录接口', '补齐鉴权测试'] }, + }, + { + name: 'FOG:+DESTINATION: → analysisFog + analysisDestination', + text: 'DESTINATION: 上线支付\nFOG: 用哪家通道?\nFOG: 额度怎么定?', + metadata: {}, + expected: { analysisFog: ['用哪家通道?', '额度怎么定?'], analysisDestination: '上线支付' }, + }, + { + name: '有 FOG 无 DESTINATION → 只落 analysisFog(destination 不单独落档)', + text: 'FOG: 用哪家通道?', + metadata: {}, + expected: { analysisFog: ['用哪家通道?'] }, + }, + { + name: 'inspection=true + OPPORTUNITY: 行 → opportunities(opp-N/pending)', + text: 'OPPORTUNITY: {"problem":"构建慢","suggestion":"开缓存","estimate":"1d"}', + metadata: { inspection: true }, + expected: { + opportunities: [{ id: 'opp-1', problem: '构建慢', suggestion: '开缓存', estimate: '1d', status: 'pending' }], + }, + }, + { + name: '无 inspection 标记 → OPPORTUNITY 行不收割', + text: 'OPPORTUNITY: {"problem":"构建慢","suggestion":"开缓存"}', + metadata: {}, + expected: {}, + }, + { + name: '无任何协议行 → 空(不阻断完成)', + text: '就是一段普通结论文本', + metadata: {}, + expected: {}, + }, + { + name: '三类协议行同现 → 全部落档', + text: 'TASK: 拆接口\nFOG: 通道选型?\nOPPORTUNITY: {"problem":"构建慢","suggestion":"开缓存"}', + metadata: { inspection: true }, + expected: { + analysisTasks: ['拆接口'], + analysisFog: ['通道选型?'], + opportunities: [{ id: 'opp-1', problem: '构建慢', suggestion: '开缓存', status: 'pending' }], + }, + }, + ])('$name', ({ text, metadata, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType, metadata: metadata as WorkUnitMetadata }))).toEqual(expected); + }); + }); + + describe('decision', () => { + it.each([ + { + name: '## 结论摘要 段 → decisionSuggestion', + text: '分析过程……\n## 结论摘要\n建议走方案 A。\n## 其他\n无关内容', + expected: { decisionSuggestion: '建议走方案 A。' }, + }, + { + name: '无结论摘要段 → 空(确认弹窗空手填)', + text: '没有任何标题的正文', + expected: {}, + }, + ])('$name', ({ text, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType: 'decision' }))).toEqual(expected); + }); + }); + + describe('spec', () => { + it.each([ + { + name: 'TASK: 物化行(含 AC/BLOCKEDBY/LEG 段)→ specTasks', + text: 'TASK: 登录接口 | AC: 返回 token | BLOCKEDBY: wu-0, wu-1 | LEG: api', + expected: { specTasks: [{ title: '登录接口', ac: ['返回 token'], blockedBy: ['wu-0', 'wu-1'], leg: 'api' }] }, + }, + { + name: '无 TASK 行 → 空(不阻断完成)', + text: '普通 spec 结论文本', + expected: {}, + }, + ])('$name', ({ text, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType: 'spec' }))).toEqual(expected); + }); + }); + + describe('失败语义:单个收割器抛错不阻断收割链', () => { + afterEach(() => { + delete COMPLETION_HARVEST_REGISTRY['throwing-type']; + }); + + it('抛错的收割器被跳过,同 type 其余收割器照常收割', () => { + COMPLETION_HARVEST_REGISTRY['throwing-type'] = [ + () => { throw new Error('boom'); }, + () => ({ analysisTasks: ['幸存'] }), + ]; + expect(harvestCompletionMetadata('whatever', ctx({ wuType: 'throwing-type' }))) + .toEqual({ analysisTasks: ['幸存'] }); + }); + }); + + it('新 type 收割 = 注册一行(注册表驱动,无 type 分支)', () => { + COMPLETION_HARVEST_REGISTRY['ad-hoc-type'] = [(text) => text.includes('X') ? { customField: 1 } : {}]; + try { + expect(harvestCompletionMetadata('has X', ctx({ wuType: 'ad-hoc-type' }))).toEqual({ customField: 1 }); + expect(harvestCompletionMetadata('no marker', ctx({ wuType: 'ad-hoc-type' }))).toEqual({}); + } finally { + delete COMPLETION_HARVEST_REGISTRY['ad-hoc-type']; + } + }); +}); diff --git a/apps/api/src/modules/agents/loop/__tests__/step-guards.test.ts b/apps/api/src/modules/agents/loop/__tests__/step-guards.test.ts new file mode 100644 index 000000000..a4e121780 --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/step-guards.test.ts @@ -0,0 +1,287 @@ +// 入口守卫链单测(step-guards,#541):纯 ctx 对象 + 注入伪依赖,无 vi.mock 模块工厂、 +// 不整类构造 AgentLoop(对称 completion-gates.test.ts 的可测试性契约)。 +// 覆盖:四段守卫各自命中/放行、短路顺序(B2 → C3 日预算 → #162 WU 预算 → #471 plan 额度)、 +// B2 副作用(留痕/关闭/频道通知)与 dep 失败容错、C3 当日只告警一次、 +// WU 预算/plan 额度的 need_input 形状(waitingReason)。 +import { describe, it, expect, vi } from 'vitest'; +import { + runStepGuards, + type StepGuardDeps, +} from '../step-guards'; +import { PLAN_STEP_LIMIT } from '../../../workunit/workunit.types'; +import type { WorkUnitData, WorkUnitMetadata } from '../../../workunit/workunit.service.js'; + +function makeWu(overrides: Partial = {}): WorkUnitData { + return { + id: 'wu-1', parentId: null, type: 'task', scope: '实现功能', assigneeId: 'instance-1', + status: 'active', failureType: null, retryCount: 0, timeoutAt: null, + channelId: null, projectPath: null, workspaceId: null, metadata: null, + createdAt: new Date(), updatedAt: new Date(), claimedAt: null, completedAt: null, + ...overrides, + }; +} + +/** 默认伪依赖:全部守卫放行(B2 关 / 日预算充足 / 无 WU 预算 / 非 plan) */ +function makeDeps(overrides: Partial = {}): StepGuardDeps & { + updateWuMetadata: ReturnType; + closeWu: ReturnType; + postNotice: ReturnType; + eventsFilePath: ReturnType; + testWuGuardEnabled: ReturnType; + isTestLikeWorkUnit: ReturnType; + tokenBudgetGuardEnabled: ReturnType; + resolveDailyTokenBudget: ReturnType; + getDailyTokenUsage: ReturnType; + notifyBudgetTripped: ReturnType; +} { + return { + updateWuMetadata: vi.fn().mockResolvedValue(undefined), + closeWu: vi.fn().mockResolvedValue(undefined), + postNotice: vi.fn().mockResolvedValue(undefined), + eventsFilePath: vi.fn().mockReturnValue('/tmp/studio-events.jsonl'), + testWuGuardEnabled: vi.fn().mockReturnValue(false), + isTestLikeWorkUnit: vi.fn().mockReturnValue(false), + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(false), + resolveDailyTokenBudget: vi.fn().mockReturnValue(2_000_000), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens: 0, notified: false }), + notifyBudgetTripped: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as ReturnType; +} + +describe('step-guards: B2 测试特征 WU 守卫', () => { + it('命中:skipped + 留痕(testWorkUnitGuard/blockReason)+ 关闭 + 频道通知', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + }); + const metadata: WorkUnitMetadata = { triggerId: 't-1' }; + const out = await runStepGuards({ wu: makeWu({ scope: 'tree-tokens test' }), metadata }, deps); + + expect(out.result).toEqual({ action: 'skipped', summary: '' }); + expect(deps.updateWuMetadata).toHaveBeenCalledTimes(1); + const [wuId, written] = deps.updateWuMetadata.mock.calls[0] as [string, WorkUnitMetadata]; + expect(wuId).toBe('wu-1'); + expect(written.triggerId).toBe('t-1'); // 原 metadata 字段保留 + expect(written.testWorkUnitGuard).toBe(true); + expect(written.blockReason).toContain('test-wu-guard'); + expect(deps.closeWu).toHaveBeenCalledWith('wu-1'); + expect(deps.postNotice).toHaveBeenCalledTimes(1); + expect((deps.postNotice.mock.calls[0] as [string, string])[1]).toContain('测试特征任务'); + }); + + it('守卫关闭或非测试特征:放行(result=null),零副作用', async () => { + for (const deps of [ + makeDeps({ testWuGuardEnabled: vi.fn().mockReturnValue(false), isTestLikeWorkUnit: vi.fn().mockReturnValue(true) }), + makeDeps({ testWuGuardEnabled: vi.fn().mockReturnValue(true), isTestLikeWorkUnit: vi.fn().mockReturnValue(false) }), + ]) { + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + expect(out.result).toBeNull(); + expect(deps.updateWuMetadata).not.toHaveBeenCalled(); + expect(deps.closeWu).not.toHaveBeenCalled(); + expect(deps.postNotice).not.toHaveBeenCalled(); + } + }); + + it('WU 已 closed:不再重复关闭,仍留痕 + 通知', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + }); + const out = await runStepGuards({ wu: makeWu({ status: 'closed' }), metadata: {} }, deps); + + expect(out.result?.action).toBe('skipped'); + expect(deps.closeWu).not.toHaveBeenCalled(); + expect(deps.updateWuMetadata).toHaveBeenCalledTimes(1); + expect(deps.postNotice).toHaveBeenCalledTimes(1); + }); + + it('副作用 dep 失败(写盘/关闭 reject):容错继续,仍返回 skipped', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + updateWuMetadata: vi.fn().mockRejectedValue(new Error('disk full')), + closeWu: vi.fn().mockRejectedValue(new Error('transition failed')), + }); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result?.action).toBe('skipped'); + expect(deps.closeWu).toHaveBeenCalled(); // 写盘失败不阻断关闭尝试 + expect(deps.postNotice).toHaveBeenCalledTimes(1); + }); +}); + +describe('step-guards: C3 日 token 预算熔断', () => { + const guardOn = (usedTokens: number, notified = false) => makeDeps({ + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + resolveDailyTokenBudget: vi.fn().mockReturnValue(2_000_000), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens, notified }), + }); + + it('当日已耗 ≥ 预算:need_input 挂起 + 首次告警(notifyBudgetTripped)', async () => { + const deps = guardOn(2_000_000); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.summary).toContain('预算已熔断'); + expect(deps.getDailyTokenUsage).toHaveBeenCalledWith({ eventsFile: '/tmp/studio-events.jsonl' }); + expect(deps.notifyBudgetTripped).toHaveBeenCalledTimes(1); + expect(deps.notifyBudgetTripped).toHaveBeenCalledWith({ + eventsFile: '/tmp/studio-events.jsonl', usedTokens: 2_000_000, budget: 2_000_000, + }); + }); + + it('当日已告警过(notified=true):仍挂起但不再告警', async () => { + const deps = guardOn(3_000_000, true); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(deps.notifyBudgetTripped).not.toHaveBeenCalled(); + }); + + it('当日已耗 < 预算:放行', async () => { + const deps = guardOn(1_999_999); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result).toBeNull(); + expect(deps.notifyBudgetTripped).not.toHaveBeenCalled(); + }); + + it('预算 <=0:关闭熔断,已耗再高也放行', async () => { + const deps = makeDeps({ + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + resolveDailyTokenBudget: vi.fn().mockReturnValue(0), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens: 999_999_999, notified: false }), + }); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result).toBeNull(); + expect(deps.getDailyTokenUsage).not.toHaveBeenCalled(); // 预算 <=0 不查用量 + }); + + it('守卫开关关闭:不查用量直接放行', async () => { + const deps = makeDeps({ tokenBudgetGuardEnabled: vi.fn().mockReturnValue(false) }); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result).toBeNull(); + expect(deps.getDailyTokenUsage).not.toHaveBeenCalled(); + }); +}); + +describe('step-guards: #162 WU 级 tokenBudget 熔断', () => { + it('_cumulativeTokens ≥ tokenBudget:need_input + waitingReason=wu-token-budget + 人话三选文案', async () => { + const deps = makeDeps(); + const out = await runStepGuards({ + wu: makeWu(), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500 }, + }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.metadataUpdates?.waitingReason).toBe('wu-token-budget'); + expect(out.result?.summary).toContain('追加预算'); + expect(out.result?.summary).toContain('收尾'); + expect(out.result?.summary).toContain('放弃'); + }); + + it('_cumulativeTokens < tokenBudget:放行', async () => { + const out = await runStepGuards({ + wu: makeWu(), + metadata: { tokenBudget: 1000, _cumulativeTokens: 999 }, + }, makeDeps()); + expect(out.result).toBeNull(); + }); + + it('无 tokenBudget / <=0 / 非法值:不受限放行', async () => { + for (const metadata of [ + { _cumulativeTokens: 999_999 }, + { tokenBudget: 0, _cumulativeTokens: 999_999 }, + { tokenBudget: Number.NaN, _cumulativeTokens: 999_999 }, + { tokenBudget: -5, _cumulativeTokens: 999_999 }, + ]) { + const out = await runStepGuards({ wu: makeWu(), metadata }, makeDeps()); + expect(out.result).toBeNull(); + } + }); +}); + +describe('step-guards: #471 plan 步数额度熔断', () => { + it('plan + stepCount ≥ 默认额度(PLAN_STEP_LIMIT):need_input + waitingReason=plan-step-limit', async () => { + const out = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { stepCount: PLAN_STEP_LIMIT }, + }, makeDeps()); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.metadataUpdates?.waitingReason).toBe('plan-step-limit'); + expect(out.result?.summary).toContain('步数额度'); + }); + + it('plan + 自定义 planStepAllowance:未达额度放行;续期后额度生效', async () => { + const below = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { stepCount: 61, planStepAllowance: 120 }, + }, makeDeps()); + expect(below.result).toBeNull(); + + const at = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { stepCount: 120, planStepAllowance: 120 }, + }, makeDeps()); + expect(at.result?.action).toBe('need_input'); + }); + + it('非 plan 类型:stepCount 再高也不进本守卫', async () => { + const out = await runStepGuards({ + wu: makeWu({ type: 'task' }), + metadata: { stepCount: PLAN_STEP_LIMIT + 100 }, + }, makeDeps()); + expect(out.result).toBeNull(); + }); +}); + +describe('step-guards: 守卫链顺序与短路', () => { + it('B2 命中时后续守卫不跑(日预算不查询、WU 预算不判)', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + }); + const out = await runStepGuards({ + wu: makeWu(), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500 }, + }, deps); + + expect(out.result?.action).toBe('skipped'); + expect(deps.getDailyTokenUsage).not.toHaveBeenCalled(); + }); + + it('日预算熔断时 WU 预算/plan 守卫不跑(日预算优先)', async () => { + const deps = makeDeps({ + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + resolveDailyTokenBudget: vi.fn().mockReturnValue(100), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens: 500, notified: true }), + }); + const out = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500, stepCount: PLAN_STEP_LIMIT }, + }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.summary).toContain('预算已熔断'); // 日预算文案,非 WU 预算三选 + expect(out.result?.metadataUpdates?.waitingReason).toBeUndefined(); + }); + + it('WU 预算熔断时 plan 守卫不跑(WU 预算优先)', async () => { + const out = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500, stepCount: PLAN_STEP_LIMIT }, + }, makeDeps()); + + expect(out.result?.metadataUpdates?.waitingReason).toBe('wu-token-budget'); + }); + + it('全部放行:result=null', async () => { + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, makeDeps()); + expect(out.result).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/agents/loop/__tests__/step-retry-policy.test.ts b/apps/api/src/modules/agents/loop/__tests__/step-retry-policy.test.ts new file mode 100644 index 000000000..96d192b99 --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/step-retry-policy.test.ts @@ -0,0 +1,353 @@ +// 步执行重试策略单测(step-retry-policy,#543):#94 续用丢失降级与 #96 上下文溢出 +// 两段孪生骨架收敛为一份后的显式断言点 —— 纯 ctx 对象 + 注入伪依赖(fake executor), +// 无 vi.mock 模块工厂、不整类构造 AgentLoop(对称 step-guards/completion-gates 的可测试性契约)。 +// 覆盖:触发判定(matchStepRetry)、会话配额占额(sessionCount 计入/不计入)、 +// 会话簿记回写(sessionId/lastSessionResumed/sessionSummary)、重试 task 改写形态 +// (claude 传新号 / kimi 不传、sessionResume 摘除)、prompt 重算参数、成败分叉的 +// terminal 形状(failed vs need_input)与副作用调用(recordTokenEvent/recordFailureOutcome/emitFailedStep)。 +import { describe, it, expect, vi } from 'vitest'; +import { logger } from '@dommaker/studio-shared'; +import type { AgentTask, ExecutionResult } from '@dommaker/studio-agent'; +import { + matchStepRetry, + runStepRetry, + resetUnestablishedSessionBookkeeping, + MAX_SESSIONS_PER_WU, + type StepRetryCtx, + type StepRetryDeps, +} from '../step-retry-policy'; +import type { WorkUnitData, WorkUnitMetadata } from '../../../workunit/workunit.service.js'; + +const RESUME_LOST_DETAIL = 'No conversation found with session ID sess-lost'; +const OVERFLOW_DETAIL = 'Prompt is too long'; +const NEW_SESSION_ID = '11111111-2222-3333-4444-555555555555'; + +function makeWu(overrides: Partial = {}): WorkUnitData { + return { + id: 'wu-1', parentId: null, type: 'task', scope: '实现登录功能', assigneeId: 'instance-1', + status: 'active', failureType: null, retryCount: 0, timeoutAt: null, + channelId: null, projectPath: null, workspaceId: null, metadata: null, + createdAt: new Date(), updatedAt: new Date(), claimedAt: null, completedAt: null, + ...overrides, + }; +} + +function makeTask(provider: 'claude' | 'kimi' = 'claude'): AgentTask { + return { + id: 'wu-1', + executionId: 'wu-1-1', + provider, + prompt: 'ORIGINAL-PROMPT', + parameters: { sessionId: 'sess-lost', sessionResume: true, maxTurns: 50 }, + timeoutMs: 1000, + }; +} + +function failureResult(error: string): ExecutionResult { + return { + success: false, error, worktree: '/tmp/wt', outputFiles: [], logFile: '/tmp/log', sessionCount: 1, + }; +} + +const SUCCESS_RESULT: ExecutionResult = { + success: true, outputText: 'ACTION: PROGRESS:继续', + worktree: '/tmp/wt', outputFiles: [], logFile: '/tmp/log', sessionCount: 1, +}; + +function makeCtx(overrides: Partial = {}): StepRetryCtx { + return { + wu: makeWu(), + metadata: {}, + provider: 'claude', + task: makeTask(), + firstResult: failureResult(RESUME_LOST_DETAIL), + detail: RESUME_LOST_DETAIL, + sessionsUsed: 1, + resumeSessionId: 'sess-lost', + metadataUpdates: {}, + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}) { + return { + execute: vi.fn().mockResolvedValue(SUCCESS_RESULT), + recomposePrompt: vi.fn().mockResolvedValue('RECOMPOSED-PROMPT'), + recordTokenEvent: vi.fn(), + recordFailureOutcome: vi.fn(), + emitFailedStep: vi.fn(), + newSessionId: vi.fn().mockReturnValue(NEW_SESSION_ID), + ...overrides, + }; +} + +describe('matchStepRetry: 重试触发判定(顺序即优先级)', () => { + it('续用步报「会话不存在」→ session-resume-lost', () => { + expect(matchStepRetry({ resumeSessionId: 'sess-lost', detail: RESUME_LOST_DETAIL })).toBe('session-resume-lost'); + }); + + it('非续用步报「会话不存在」→ null(不触发降级,也不误判溢出)', () => { + expect(matchStepRetry({ resumeSessionId: null, detail: RESUME_LOST_DETAIL })).toBeNull(); + }); + + it('新建步报溢出错误 → context-overflow', () => { + expect(matchStepRetry({ resumeSessionId: null, detail: OVERFLOW_DETAIL })).toBe('context-overflow'); + }); + + it('续用步报溢出错误(非会话丢失类)→ context-overflow', () => { + expect(matchStepRetry({ resumeSessionId: 'sess-1', detail: OVERFLOW_DETAIL })).toBe('context-overflow'); + }); + + it('普通执行错误 → null', () => { + expect(matchStepRetry({ resumeSessionId: 'sess-1', detail: 'CLI boom' })).toBeNull(); + expect(matchStepRetry({ resumeSessionId: null, detail: 'CLI boom' })).toBeNull(); + }); +}); + +describe('runStepRetry: session-resume-lost(#94 续用丢失降级)', () => { + it('配额未满 → 换新号重试成功:task 改写新建形态、sessionCount+1、簿记落新号、prompt 按新建重算', async () => { + const ctx = makeCtx(); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).toHaveBeenCalledTimes(1); + // task 原地改写:claude 传新 UUID、摘除 sessionResume + expect(ctx.task.parameters?.sessionId).toBe(NEW_SESSION_ID); + expect(ctx.task.parameters?.sessionResume).toBeUndefined(); + // prompt 重算:isNewSession=true(断链新会话,注入前序进展段由 composer 负责) + expect(deps.recomposePrompt).toHaveBeenCalledWith({ isNewSession: true }); + expect(ctx.task.prompt).toBe('RECOMPOSED-PROMPT'); + // 占额:降级 = 一次新建会话尝试,sessionCount = sessionsUsed + 1 + expect(ctx.metadataUpdates.sessionCount).toBe(2); + // 回写:换新号落盘 + 续用标记翻 false + expect(ctx.metadataUpdates.sessionId).toBe(NEW_SESSION_ID); + expect(ctx.metadataUpdates.lastSessionResumed).toBe(false); + // outcome:retried,交还重试结果与新号(agent-loop 走正常成功路径) + expect(out).toEqual({ kind: 'retried', result: SUCCESS_RESULT, sessionId: NEW_SESSION_ID }); + // 成功路径不重记账(重试结果由 agent-loop 成功路径统一 recordTokenEvent) + expect(deps.recordTokenEvent).not.toHaveBeenCalled(); + expect(deps.recordFailureOutcome).not.toHaveBeenCalled(); + }); + + it('配额已满 → 不起新会话:terminal need_input + failure outcome + failed 步事件,sessionCount 不增', async () => { + const ctx = makeCtx({ sessionsUsed: MAX_SESSIONS_PER_WU }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).not.toHaveBeenCalled(); + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('need_input'); + expect(out.result.summary).toContain('续用会话已丢失且会话重建已达上限'); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith(RESUME_LOST_DETAIL); + expect(deps.emitFailedStep).toHaveBeenCalledWith('need_input', RESUME_LOST_DETAIL, ctx.firstResult); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionCount'); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionId'); + }); + + it('重试仍失败 → terminal failed(errorType/errorDetail/errorAt):重试记账、簿记重置、sessionCount 计入', async () => { + const retryFailure = failureResult('CLI boom again'); + const ctx = makeCtx(); + const deps = makeDeps({ execute: vi.fn().mockResolvedValue(retryFailure) }); + + const out = await runStepRetry(ctx, deps); + + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('failed'); + expect(out.result.summary).toBe('CLI 执行失败: CLI boom again'); + expect(out.result.metadataUpdates?.errorType).toBe('execution_failed'); + expect(out.result.metadataUpdates?.errorDetail).toBe('CLI boom again'); + expect(typeof out.result.metadataUpdates?.errorAt).toBe('string'); + // 失败的重试照样烧 token → 记账;失败 outcome + failed 步事件 + expect(deps.recordTokenEvent).toHaveBeenCalledWith(retryFailure); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith('CLI boom again'); + expect(deps.emitFailedStep).toHaveBeenCalledWith('failed', 'CLI boom again', retryFailure); + // 新会话未建立:sessionId/lastSessionResumed 回滚,sessionCount 保留计入(#95) + expect(ctx.metadataUpdates).not.toHaveProperty('sessionId'); + expect(ctx.metadataUpdates).not.toHaveProperty('lastSessionResumed'); + expect(ctx.metadataUpdates.sessionCount).toBe(2); + }); + + it('非 claude provider(kimi cwd 维度续用)→ 重试不传 sessionId', async () => { + const ctx = makeCtx({ provider: 'kimi', task: makeTask('kimi') }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(ctx.task.parameters?.sessionId).toBeUndefined(); + expect(ctx.task.parameters?.sessionResume).toBeUndefined(); + expect(out.kind).toBe('retried'); + }); +}); + +describe('runStepRetry: context-overflow(#96 上下文溢出)', () => { + const overflowMetadata: WorkUnitMetadata = { + progressLog: [{ step: 1, action: 'progress', summary: '完成数据层', at: '2026-08-12T10:00:00Z' }], + }; + + it('重试成功:摘要落盘、prompt = 重算(isNewSession:false)+ 摘要段、sessionCount+1、簿记落新号', async () => { + const ctx = makeCtx({ + detail: OVERFLOW_DETAIL, + firstResult: failureResult(OVERFLOW_DETAIL), + resumeSessionId: null, + metadata: overflowMetadata, + }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).toHaveBeenCalledTimes(1); + // 摘要先于配额判定落盘(scope + progressLog) + expect(ctx.metadataUpdates.sessionSummary).toContain('实现登录功能'); + expect(ctx.metadataUpdates.sessionSummary).toContain('完成数据层'); + // prompt 重算:isNewSession=false(避免与 handoff 前序进展段重复),摘要段追加在后 + expect(deps.recomposePrompt).toHaveBeenCalledWith({ isNewSession: false }); + expect(ctx.task.prompt).toBe( + `RECOMPOSED-PROMPT\n\n## 会话摘要(上下文溢出)\n\n${ctx.metadataUpdates.sessionSummary}`, + ); + expect(ctx.metadataUpdates.sessionCount).toBe(2); + expect(ctx.metadataUpdates.sessionId).toBe(NEW_SESSION_ID); + expect(ctx.metadataUpdates.lastSessionResumed).toBe(false); + expect(out).toEqual({ kind: 'retried', result: SUCCESS_RESULT, sessionId: NEW_SESSION_ID }); + }); + + it('配额已满 → 摘要仍落盘 + terminal need_input,不再起新会话', async () => { + const ctx = makeCtx({ + detail: OVERFLOW_DETAIL, + firstResult: failureResult(OVERFLOW_DETAIL), + resumeSessionId: null, + metadata: overflowMetadata, + sessionsUsed: MAX_SESSIONS_PER_WU, + }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).not.toHaveBeenCalled(); + expect(typeof ctx.metadataUpdates.sessionSummary).toBe('string'); + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('need_input'); + expect(out.result.summary).toContain('上下文溢出且会话重建已达上限'); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith(OVERFLOW_DETAIL); + expect(deps.emitFailedStep).toHaveBeenCalledWith('need_input', OVERFLOW_DETAIL, ctx.firstResult); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionCount'); + }); + + it('重试仍失败 → terminal need_input(非 failed):摘要保留、簿记重置、sessionCount 计入', async () => { + const retryFailure = failureResult('Prompt is too long'); + const ctx = makeCtx({ + detail: OVERFLOW_DETAIL, + firstResult: failureResult(OVERFLOW_DETAIL), + resumeSessionId: null, + metadata: overflowMetadata, + }); + const deps = makeDeps({ execute: vi.fn().mockResolvedValue(retryFailure) }); + + const out = await runStepRetry(ctx, deps); + + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('need_input'); + expect(out.result.summary).toContain('上下文溢出'); + expect(out.result.summary).toContain('新会话带摘要重试一次仍失败'); + expect(deps.recordTokenEvent).toHaveBeenCalledWith(retryFailure); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith('Prompt is too long'); + expect(deps.emitFailedStep).toHaveBeenCalledWith('need_input', 'Prompt is too long', retryFailure); + expect(typeof ctx.metadataUpdates.sessionSummary).toBe('string'); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionId'); + expect(ctx.metadataUpdates).not.toHaveProperty('lastSessionResumed'); + expect(ctx.metadataUpdates.sessionCount).toBe(2); + }); +}); + +describe('runStepRetry: 不触发重试', () => { + it('普通执行错误 → no-retry:零副作用、零簿记改写', async () => { + const ctx = makeCtx({ detail: 'CLI boom', firstResult: failureResult('CLI boom') }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(out).toEqual({ kind: 'no-retry' }); + expect(deps.execute).not.toHaveBeenCalled(); + expect(deps.recomposePrompt).not.toHaveBeenCalled(); + expect(ctx.metadataUpdates).toEqual({}); + }); +}); + +describe('runStepRetry: traceId 透传(#552)', () => { + // #543 收编后三处日志丢 traceId:ctx 加 traceId?: string,三处恢复携带, + // 与首败日志(agent-loop agentStep execution failed)同一字段串联重试段日志。 + const TRACE_ID = 'trace-552'; + + it('配额已满 → warn 携带 traceId', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const ctx = makeCtx({ sessionsUsed: MAX_SESSIONS_PER_WU, traceId: TRACE_ID }); + const deps = makeDeps(); + + await runStepRetry(ctx, deps); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('session limit reached'), + expect.objectContaining({ traceId: TRACE_ID }), + ); + warn.mockRestore(); + }); + + it('fallback 签发 / 溢出落摘要 → warn 携带 traceId', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const ctx = makeCtx({ traceId: TRACE_ID }); + const deps = makeDeps(); + + await runStepRetry(ctx, deps); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('falling back to a new session'), + expect.objectContaining({ traceId: TRACE_ID }), + ); + warn.mockRestore(); + }); + + it('重试再败 → error 携带 traceId', async () => { + const error = vi.spyOn(logger, 'error').mockImplementation(() => {}); + const ctx = makeCtx({ traceId: TRACE_ID }); + const deps = makeDeps({ execute: vi.fn().mockResolvedValue(failureResult('CLI boom again')) }); + + await runStepRetry(ctx, deps); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('retry failed'), + expect.objectContaining({ traceId: TRACE_ID }), + ); + error.mockRestore(); + }); + + it('ctx 无 traceId → 日志字段为 undefined(可选透传,不影响无 trace 链路)', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const ctx = makeCtx(); + const deps = makeDeps(); + + await runStepRetry(ctx, deps); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('falling back to a new session'), + expect.objectContaining({ traceId: undefined }), + ); + warn.mockRestore(); + }); +}); + +describe('resetUnestablishedSessionBookkeeping', () => { + it('摘除 sessionId/lastSessionResumed,保留 sessionCount(失败尝试计入配额,#95)', () => { + const updates: Partial = { + sessionId: 'sess-x', lastSessionResumed: false, sessionCount: 3, + }; + resetUnestablishedSessionBookkeeping(updates); + expect(updates).not.toHaveProperty('sessionId'); + expect(updates).not.toHaveProperty('lastSessionResumed'); + expect(updates.sessionCount).toBe(3); + }); +}); diff --git a/apps/api/src/modules/agents/loop/agent-loop-parsers.ts b/apps/api/src/modules/agents/loop/agent-loop-parsers.ts index dbcdf8559..66ee727ca 100644 --- a/apps/api/src/modules/agents/loop/agent-loop-parsers.ts +++ b/apps/api/src/modules/agents/loop/agent-loop-parsers.ts @@ -86,6 +86,9 @@ export function parseAgentOutput(text: string): StepResult { // #467:裁决轮——NEED_INPUT 紧随的 RULING: 行(可与 OPTIONS 共存) const rulings = parseRulingLines(lines.slice(i + 1)); if (rulings) result.rulings = rulings; + // #567:方向锁定——NEED_INPUT 紧随的 DIRECTION: 行(可与 OPTIONS/RULING 共存) + const directions = parseDirectionLine(lines.slice(i + 1)); + if (directions) result.directions = directions; } return result; } @@ -126,6 +129,7 @@ function parseRulingLines(lines: string[]): StepResult['rulings'] { const rulings: NonNullable = []; for (const line of lines) { if (/^OPTIONS:/.test(line)) continue; // #279 选项行与裁决行可共存 + if (/^DIRECTION:/.test(line)) continue; // #567 方向锁定行与裁决行可共存 if (!/^RULING:/.test(line)) break; // 裁决行连续紧随 NEED_INPUT;首个其他行即出区 const match = line.match(/^RULING:\s*(\{.*\})\s*$/); if (!match) continue; // 形坏(非 JSON 对象形)跳过,不阻断后续裁决行 @@ -146,6 +150,69 @@ function parseRulingLines(lines: string[]): StepResult['rulings'] { return rulings.length > 0 ? rulings : undefined; } +/** #567:DIRECTION 载荷字段截断上限(与 pmo/plan-ruling.ts RULING_FIELD_MAX_CHARS 同口径) */ +const DIRECTION_FIELD_MAX_CHARS = 500; +/** #567:方向候选条数区间(契约写 2~3 个,解析容忍至多 4) */ +const DIRECTION_OPTIONS_MIN = 2; +const DIRECTION_OPTIONS_MAX = 4; + +/** + * #567:解析 NEED_INPUT 后续的 DIRECTION: JSON 行(plan 方向锁定契约,见 prompt-composer + * PLAN_DIRECTION_SECTION)。每行一条 `DIRECTION: {"question":"...","options":[{name,summary, + * tradeoffs,impact,recommended}]}`;方向行紧随 NEED_INPUT(OPTIONS/RULING 行可夹杂跳过, + * 首个其他行即出区),第一条合法 DIRECTION 行生效。防御口径同 RULING:JSON 损坏静默跳过、 + * question/options 各必填字段缺或非串整条拒收、options 必须 2..4 条否则整条拒收、 + * 字段截 DIRECTION_FIELD_MAX_CHARS 字符;recommended 非恰好一个 → 归一(全部置 false + * 后首条置 true)。无合法行返回 undefined(调用方据此不落 planDirections,NEED_INPUT + * 本体解析不受影响)。 + */ +function parseDirectionLine(lines: string[]): StepResult['directions'] { + for (const line of lines) { + if (/^OPTIONS:/.test(line) || /^RULING:/.test(line)) continue; // 选项行/裁决行与方向行可共存 + if (!/^DIRECTION:/.test(line)) break; // 方向行紧随 NEED_INPUT;首个其他行即出区 + const match = line.match(/^DIRECTION:\s*(\{.*\})\s*$/); + if (!match) continue; // 形坏(非 JSON 对象形)跳过,不阻断后续行 + try { + const parsed = JSON.parse(match[1]) as { + question?: unknown; + options?: unknown; + }; + if (typeof parsed.question !== 'string' || !parsed.question.trim()) continue; + if (!Array.isArray(parsed.options)) continue; + if (parsed.options.length < DIRECTION_OPTIONS_MIN || parsed.options.length > DIRECTION_OPTIONS_MAX) continue; + const options: NonNullable['options'] = []; + let valid = true; + for (const entry of parsed.options) { + if (entry === null || typeof entry !== 'object') { valid = false; break; } + const o = entry as Record; + const name = typeof o.name === 'string' ? o.name.trim() : ''; + const summary = typeof o.summary === 'string' ? o.summary.trim() : ''; + const tradeoffs = typeof o.tradeoffs === 'string' ? o.tradeoffs.trim() : ''; + const impact = typeof o.impact === 'string' ? o.impact.trim() : ''; + if (!name || !summary || !tradeoffs || !impact) { valid = false; break; } + options.push({ + name: name.slice(0, DIRECTION_FIELD_MAX_CHARS), + summary: summary.slice(0, DIRECTION_FIELD_MAX_CHARS), + tradeoffs: tradeoffs.slice(0, DIRECTION_FIELD_MAX_CHARS), + impact: impact.slice(0, DIRECTION_FIELD_MAX_CHARS), + recommended: o.recommended === true, + }); + } + if (!valid) continue; + // recommended 恰好一个(契约硬约束);非恰好一个 → 归一:全部置 false 后首条置 true + if (options.filter(o => o.recommended).length !== 1) { + for (const o of options) o.recommended = false; + options[0].recommended = true; + } + return { + question: parsed.question.trim().slice(0, DIRECTION_FIELD_MAX_CHARS), + options, + }; + } catch { /* JSON 损坏行跳过 */ } + } + return undefined; +} + /** Dynamic sleep interval based on result */ export function dynamicInterval(result: { action: string }): number { switch (result.action) { diff --git a/apps/api/src/modules/agents/loop/agent-loop.ts b/apps/api/src/modules/agents/loop/agent-loop.ts index 1f95e3fd6..754355ad0 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.ts @@ -5,7 +5,12 @@ // agent-loop-guards.js。知识搜索分析块(knowledge-search-analysis)零生产调用方,工单 43 已删。 // 工单 05(2026-08):prompt/上下文组装(含 buildSkill/Persona/RosterSection)→ prompt-composer.js; // DELEGATE 分支(建子单 + collab 元数据 + 降级文案)→ delegate-branch.js。 -// 本文件保留 AgentLoop 类编排逻辑 + re-export(对外导出语义不变)。 +// #541(2026-09):agentStep 入口四段前置守卫(B2 测试 WU / C3 日预算 / #162 WU 预算 / +// #471 plan 额度)→ step-guards.js(对称出口侧 completion-gates 的 Ctx/Deps/Outcome 模式)。 +// #543(2026-09):agentStep 中段两段孪生重试骨架(#94 续用丢失降级 / #96 上下文溢出) +// → step-retry-policy.js(同 Ctx/Deps/Outcome 模式,fake executor 直测占额/簿记回写)。 +// 本文件保留 AgentLoop 类编排逻辑;导出面 = AgentLoop + StepResult(#544 拆除 re-export 门面, +// 测试 import 已迁真属主 agent-loop-parsers / agent-loop-events / agent-loop-guards)。 import { execSync } from 'child_process'; import { eventBus, logger, FileStore, parseChannels, withAttestation, isStaleClaimSleep, parseStreamEvents, type RuntimeStateData } from '@dommaker/studio-shared'; import { TokenEstimator } from '@dommaker/harness'; @@ -23,58 +28,36 @@ import { postWuSystemMessage } from '../../workunit/wu-messenger.js'; import type { MessageMeta } from '../../channels/channel-message.service.js'; import { withBlockedCta } from '../../workunit/blocked-cta.js'; import { parseWuMetadata, mergedWuView } from '../../workunit/wu-metadata.js'; -import { PLAN_STEP_LIMIT } from '../../workunit/workunit.types.js'; import { hasUnfinishedDeps, buildStatusById } from '../../workunit/wu-dependencies.js'; import { resolvePmoBranchForWU } from '../../requirements/pmo-branch-resolver.js'; import { resolveStudioLogFile } from '../../../utils/studio-log-path.js'; import { writeStudioEvent } from '../../../utils/studio-events.js'; import { getErrorMessage } from '../../../utils/errors.js'; -import { - tokenBudgetGuardEnabled, resolveDailyTokenBudget, getDailyTokenUsage, - notifyBudgetTripped, -} from './daily-token-budget.js'; import { emitExecutionStepEvent, emitExecutionStreamLine, emitExecutionStreamStepStart, emitWorkUnitFailedEvent } from './execution-step-events.js'; import { loadCurrentWuContexts, type CurrentWuContext } from '../../monitoring/current-wu-context.js'; import { CODE_WORKTREE_TYPES, runWuVerification } from './wu-verification.js'; import { runCompletionGuards } from './completion-gates.js'; -import { parseMapOpening } from '../../pmo/map-opening.js'; -import { parseSpecTasks } from '../../pmo/spec-materialization.js'; +import { runStepGuards } from './step-guards.js'; +import { harvestCompletionMetadata } from './completion-harvest.js'; import type { StepResult, Observations, Target, RuntimeInstanceRow } from './agent-loop.types.js'; import { isProcessAlive, isGitRepoRoot, resolveWorktreesDir, - resolveTarget, parseAgentOutput, dynamicInterval, parseReviewReport, parseTaskBreakdown, - parseOpportunities, parseDecisionConclusion, + resolveTarget, parseAgentOutput, dynamicInterval, sleep, } from './agent-loop-parsers.js'; import { resolveRealUsage, writeWorkunitTokenEvent, resolveToolTraceFile, writeToolCallEvents, type RealUsage, } from './agent-loop-events.js'; -import { testWuGuardEnabled, isTestLikeWorkUnit, parseExcludeAssignee } from './agent-loop-guards.js'; +import { parseExcludeAssignee } from './agent-loop-guards.js'; +import { judgeClaimFitness, isRoleUnfit, parseUnfitRoles } from './claim-fitness.js'; import { composeStepPrompt } from './prompt-composer.js'; import { handleDelegateBranch } from './delegate-branch.js'; -import { shouldResumeSession, RESUME_FAILURE_RE } from './session-resume.js'; -import { isContextOverflowError, buildRollingSummary, OVERFLOW_SUMMARY_HEADER } from './context-overflow.js'; +import { shouldResumeSession } from './session-resume.js'; +import { runStepRetry, resetUnestablishedSessionBookkeeping, MAX_SESSIONS_PER_WU } from './step-retry-policy.js'; import { WuLeaseTracker } from './wu-lease.js'; import { appendTranscriptStep, transcriptPath } from '../../transcripts/transcript-archive.js'; -// 输出解析/prompt 构建纯函数已抽到 ./agent-loop-parsers.js(工单 28,行为不变); -// re-export 保持对外导出语义不变 -export { - isProcessAlive, isGitRepoRoot, resolveWorktreesDir, - resolveTarget, parseAgentOutput, dynamicInterval, parseReviewReport, parseTaskBreakdown, - parseDecisionConclusion, -} from './agent-loop-parsers.js'; - -// workunit:tokens / tool:call 事件落盘已抽到 ./agent-loop-events.js(工单 28,行为不变); -// re-export 保持对外导出语义不变 -export { resolveRealUsage, writeWorkunitTokenEvent, resolveToolTraceFile, writeToolCallEvents, WORKUNIT_TOKENS_SSE_TYPE } from './agent-loop-events.js'; -export type { WorkunitTokenEventArgs, RealUsage } from './agent-loop-events.js'; - -// B2 测试特征 WU 守卫 + F4 excludeAssignee 解析已抽到 ./agent-loop-guards.js(工单 28,行为不变); -// re-export 保持对外导出语义不变 -export { testWuGuardEnabled, isTestLikeWorkUnit } from './agent-loop-guards.js'; - /** M2: workunit:tokens 事件写入目标(与 knowledge consumption/outcome 事件同一事件流)。 * STUDIO_EVENTS_JSONL 环境变量可覆盖(测试隔离用);缺省走 resolveStudioLogFile —— * 测试环境(VITEST/NODE_ENV=test)自动改写 tmpdir,防止测试污染生产事件流 @@ -113,12 +96,8 @@ const STEP_WALL_CLOCK_MS = 1_800_000; const STEP_SILENCE_WARN_MS = 300_000; const STEP_SILENCE_KILL_MS = 600_000; -/** B5(2026-08-03 token-burn issue P1-1):每 WU 独立会话数上限(#95 由 2 放宽到 5)。 - * 会话反复重建(stuck 重开 / token 截断重开)意味着整段 transcript 全文重放重新烧一遍; - * 超限说明自动执行已失控,转 need_input 等人工评估(#94 起人工回复不再重置预算—— - * 复活后凭 metadata.sessionId 优先续用旧会话,见 waiting-input.ts)。 - * #95: 失败/超时的会话建立尝试计入预算(resetUnestablishedSession 不再清 sessionCount)。 */ -const MAX_SESSIONS_PER_WU = 5; +// B5 每 WU 会话数上限 MAX_SESSIONS_PER_WU:#543 迁到 ./step-retry-policy.js(重试配额判定 +// 真属主,语义注释随迁),本文件新建会话签发(下方 sessionsUsed 检查)经 import 复用同一常量。 /** F6-fix: 空闲分支心跳节流间隔 — agent-timeout-scan 阈值为 5min,45s 一次足够保活 */ const IDLE_HEARTBEAT_INTERVAL_MS = 45_000; @@ -130,7 +109,8 @@ const HEARTBEAT_FAIL_LIMIT = 3; // §10 P0 注入总预算(2K 红线)随 prompt 组装段一并迁到 ./prompt-composer.js(2026-08 工单 05) -// 类型契约已抽到 ./agent-loop.types.js(工单 28,行为不变);re-export 保持对外导出语义不变 +// 类型契约已抽到 ./agent-loop.types.js(工单 28,行为不变); +// StepResult re-export 保留——completion-gates 等生产消费方经本门面取类型(#544) export type { StepResult } from './agent-loop.types.js'; export class AgentLoop { @@ -146,8 +126,6 @@ export class AgentLoop { private lastIdleHeartbeatAt = 0; /** #330:事件驱动唤醒——空闲 sleep 的中断器(idleSleep 挂起期间非 null) */ private wakeIdle: (() => void) | null = null; - /** #330:最近一轮 observe 的 myActive WU id 集——channel.message_sent 唤醒过滤口径 */ - private lastActiveWuIds = new Set(); /** #330:channel.message_sent 订阅句柄(start 挂、stop 退) */ private messageSentHandler: ((payload: { message?: { authorType?: string; workUnitId?: string | null } }) => void) | null = null; /** #493:唤醒闩锁——事件到达时 loop 不在 idleSleep(执行中/步骤间)则置 true, @@ -285,17 +263,19 @@ export class AgentLoop { }); // #330: 事件驱动唤醒——订阅 channel.message_sent(同进程 eventBus,先例 channel-review)。 - // 人类消息且 workUnitId 命中当前 myActive 时打断空闲 sleep、立即跑一轮 observe; - // 窄过滤天然限流,不去抖。事件 fire-and-forget 无持久,15s 空闲兜底轮询保留 + // 2026-09-16 放宽为「只放行不裁决」:人类消息带 workUnitId 即打断空闲 sleep 跑一轮 observe, + // 不再用 myActive 派生缓存否决(缓存在认领后首个 sleep 窗口必 stale,回复唤醒会被滤掉, + // 实测白等满 30s dynamicInterval);归属裁决归 observe(幂等零 token),误醒廉价、漏醒人感。 + // 事件 fire-and-forget 无持久,15s 空闲兜底轮询保留 // (防未来跨进程写者与重启间隙——当前生产写消息路径全部经 channel-message.service 发事件; // 重启间隙的回复由启动首轮 observe 经 newReplies >= 口径捞回,#493)。 // #493:过滤+闩锁逻辑收进 onChannelMessageSent(seam 可测)。 this.messageSentHandler = (payload) => this.onChannelMessageSent(payload); eventBus.subscribe('channel.message_sent', this.messageSentHandler); - // #523(#515 决议 P0-2):派生可认领路径同口径补唤醒——增订 workunit.status_changed, - // 过滤条件不变(claimable === true):人闸确认(pending→unassigned)、unclaim 释放、 - // reopen 全覆盖;blocked→active 复活等非认领路径被条件天然滤掉。零新事件类型。 + // #523(#515 决议 P0-2)+ 2026-09-16 放宽:增订 workunit.status_changed—— + // claimable===true(人闸确认/unclaim/reopen)或 assigneeId===本实例 + // (含 NEED_INPUT 复活 blocked→active,旧口径把它滤掉导致回复后白等 30s)。零新事件类型。 this.workunitChangedHandler = (payload) => this.onWorkUnitClaimable(payload); eventBus.subscribe('workunit.status_changed', this.workunitChangedHandler); @@ -420,6 +400,12 @@ export class AgentLoop { // Claim if unassigned if (target.workUnit.status === 'unassigned') { + // 决策 14 认领前适任判断(「做不了的不抢」):一次性 LLM 判 no → 落档 + // metadata.unfitRoles 后本轮放弃;下一轮 observe 经第 7 道过滤自动看下一候选, + // 全部候选不适任则静默等待。成本与认领次数成正比,不进 observe 轮询 + if (!(await this.ensureClaimFit(target.workUnit))) { + continue; + } const claimed = await this.claimAndAnnounce(target.workUnit); if (!claimed) { await sleep(1_000); @@ -638,6 +624,83 @@ export class AgentLoop { } catch { /* best-effort:负载构建失败绝不阻断主循环 */ } } + /** + * 决策 14 认领前适任判断(agents/loop/claim-fitness.ts)。 + * 显式指名(assigneeId=profile id,人已点名)不判直接放行;涌现认领才判。 + * 判 no → recordClaimUnfit 落档 unfitRoles(含全员不适任转 blocked 检查)→ false。 + * 判断本身从宽(judgeClaimFitness 内部全 catch);落档失败同口径从宽放行—— + * 宁可误抢(NEED_INPUT 兜底)也不可因 FileStore 抖动每轮空烧判断调用。 + */ + private async ensureClaimFit(wu: WorkUnitData): Promise { + if (wu.assigneeId) return true; + const verdict = await judgeClaimFitness(wu, this.role); + if (verdict.fit) return true; + try { + await this.recordClaimUnfit(wu, verdict.reason); + } catch (err) { + logger.warn(`[AgentLoop] record unfitRoles failed — 从宽放行认领: ${getErrorMessage(err)}`, { wuId: wu.id }); + return true; + } + return false; + } + + /** + * 不适任落档:读-改-写合并最新 metadata 追加 unfitRoles 条目 + * (读-改-写互覆防护同 completion-extraction 哨兵先例)。WU 已非 unassigned + * (他角色抢先认领)→ 不落档。落档后检查全员不适任 → 转 blocked 转人工。 + */ + private async recordClaimUnfit(wu: WorkUnitData, reason: string): Promise { + const fresh = await this.workUnitService.getById(wu.id); + if (!fresh || fresh.status !== 'unassigned') return; + const unfitRoles = [ + ...parseUnfitRoles(fresh.metadata).filter(e => e.roleId !== this.role.id), + { roleId: this.role.id, reason, at: new Date().toISOString() }, + ]; + await this.workUnitService.update(fresh.id, { + metadata: { ...parseWuMetadata(fresh.metadata), unfitRoles }, + }); + logger.info(`[AgentLoop] ${this.role.name} 认领前适任判断 = 不适任,已落档 unfitRoles`, { + wuId: fresh.id, reason, + }); + await this.blockIfAllMembersUnfit(fresh.id, unfitRoles); + } + + /** + * 全员不适任转人工:unfitRoles 覆盖该频道全部 active 成员(口径同 + * review-dispatcher.getChannelActiveMembers:channel.members ∩ active profile,排除 studio) + * → WU 置 blocked + blockReason 落档 + 频道消息「无人能接」(仿现有 blocked 转人模式: + * withBlockedCta + 里程碑)。成员未知(历史频道未回填)/ 无频道 → 不判,保持涌现等待。 + * 各步失败只记日志,不抛(调用方本轮已放弃认领,WU 保持 unassigned 等下轮/人工)。 + */ + private async blockIfAllMembersUnfit(wuId: string, unfitRoles: { roleId: string }[]): Promise { + try { + const wu = await this.workUnitService.getById(wuId); + if (!wu?.channelId) return; + const channel = await this.fileStore.getChannel(wu.channelId); + const memberIds = parseChannels(channel?.members); + if (memberIds.length === 0) return; + const activeMembers = (await this.fileStore.listProfiles({ status: 'active' })) + .filter(p => memberIds.includes(p.id) && p.name !== 'studio'); + if (activeMembers.length === 0) return; + const unfitIds = new Set(unfitRoles.map(e => e.roleId)); + if (!activeMembers.every(p => unfitIds.has(p.id))) return; + + const blockReason = `unfit-all: 频道 ${activeMembers.length} 个 active 角色认领前适任判断均为不适任`; + // unassigned → blocked 不在 VALID_TRANSITIONS,走语义方法直写(blockForManualRelease 先例); + // 返回 null = 已非 unassigned(他角色抢先认领/状态迁移),不再转人工 + const blocked = await this.workUnitService.blockForAllUnfit(wuId, blockReason); + if (!blocked) return; + logger.warn(`[AgentLoop] 全员不适任,WU 转 blocked 待人工`, { wuId, blockReason }); + await this.postToDiscussionSpace( + wuId, + withBlockedCta('频道内无人能接此任务,已转 blocked 等待人工介入', blockReason), + blocked, + ); + } catch (err) { + logger.warn(`[AgentLoop] blockIfAllMembersUnfit failed (non-blocking): ${getErrorMessage(err)}`, { wuId }); + } + } + /** Stop the agent loop and clean up */ stop(): void { this.alive = false; @@ -671,30 +734,35 @@ export class AgentLoop { } } - /** #330/#493:channel.message_sent 唤醒过滤 + 闩锁(独立方法供测试 seam 直驱) */ + /** #330/#493 + 2026-09-16 放宽:channel.message_sent 唤醒过滤 + 闩锁(独立方法供测试 seam 直驱)。 + * 只放行不裁决:人类消息且带 workUnitId 即醒(负载原始字段);不再用 lastActiveWuIds + * 派生缓存否决(认领后首个 sleep 窗口缓存必 stale,回复唤醒 100% 被滤掉,实测白等 30s)。 */ private onChannelMessageSent(payload: { message?: { authorType?: string; workUnitId?: string | null } }): void { const msg = payload?.message; if (!msg || msg.authorType !== 'human' || !msg.workUnitId) return; - if (!this.lastActiveWuIds.has(msg.workUnitId)) return; // #493:闩锁先行——不在 idleSleep(执行中/步骤间 sleep)时 wakeIdle 为 null, // 唤醒原样会丢;置闩后下一次 idleSleep 入口消费并立即放行重跑 observe this.pendingWake = true; this.wakeIdle?.(); } - /** #523(#515 决议 P0-1/P0-2):workunit.created / workunit.status_changed 唤醒过滤 + 闩锁 - * (独立方法供测试 seam 直驱)。过滤口径 = 负载现成的 claimable === true—— - * pending 人闸单/有依赖单不空唤醒;status_changed 同条件覆盖人闸确认、unclaim 释放、reopen, - * blocked→active 复活等非认领路径被条件天然滤掉。 */ + /** #523(#515 决议 P0-1/P0-2)+ 2026-09-16 唤醒放宽(只放行不裁决): + * workunit.created / workunit.status_changed 唤醒过滤(独立方法供测试 seam 直驱)。 + * 放行口径只用负载原始字段:claimable === true(可认领:pending 人闸单/有依赖单不空唤醒) + * 或 assigneeId === 本实例(我的单状态变化——含 NEED_INPUT 复活 blocked→active; + * 旧口径只放 claimable,复活被排除,实测回复后白等满 30s dynamicInterval)。 + * 归属/可见性的最终裁决归 observe(幂等零 token),唤醒宁可误醒不可漏醒。 */ private onWorkUnitClaimable(payload: unknown): void { - const wu = (payload as { workunit?: { claimable?: boolean } } | null)?.workunit; - if (wu?.claimable !== true) return; + const wu = (payload as { workunit?: { claimable?: boolean; assigneeId?: string | null } } | null)?.workunit; + if (!wu) return; + const mine = wu.assigneeId != null && wu.assigneeId === this.instance?.id; + if (wu.claimable !== true && !mine) return; this.pendingWake = true; this.wakeIdle?.(); } - /** #330: 可中断的空闲/步间 sleep——channel.message_sent 命中 myActive、workunit 认领事件 - * (#523 claimable === true)或 stop 时提前返回 */ + /** #330: 可中断的空闲/步间 sleep——channel.message_sent(人类消息带 workUnitId)、 + * workunit 事件(claimable===true 或 assigneeId===本实例)或 stop 时提前返回 */ private idleSleep(ms: number): Promise { if (!this.alive) return Promise.resolve(); // stop 与进入 sleep 的竞态:已停则立即返回 // #493:消费唤醒闩锁——挂起期间有唤醒意图未送达,不睡直接重跑一轮 observe @@ -781,6 +849,9 @@ export class AgentLoop { if (parseExcludeAssignee(s.metadata) === this.role.id) return false; // #109(M4 接单过滤):metadata.blockedBy 中有未 done 的 WU → 对所有 loop 不可见 if (hasUnfinishedDeps(s.metadata, statusById)) return false; + // 决策 14 认领前适任判断:本角色已被判不适任(metadata.unfitRoles 含本 role.id)→ 不可见。 + // 约束挂 WU 不挂角色身份(合规 ADR D2/决策 10),同 excludeAssignee 口径 + if (isRoleUnfit(s.metadata, this.role.id)) return false; return true; }).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) .slice(0, 5) @@ -795,8 +866,6 @@ export class AgentLoop { })); const activeWuIds = myActive.map(wu => wu.id); - // #330: 缓存 myActive 口径供 channel.message_sent 事件唤醒过滤 - this.lastActiveWuIds = new Set(activeWuIds); // #330 扫描裁剪:只扫活跃 WU 所在频道(通常 1-2 个,原来是全频道无差别全扫)。 // 任一活跃 WU 无 channelId 时退全扫(该 WU 回复可能落在任意频道)。 // 已接受盲区:WU 换频道后旧频道里的新人类回复不再被扫到(换频道罕见, @@ -825,90 +894,17 @@ export class AgentLoop { const wu = target.workUnit; const metadata = parseWuMetadata(wu.metadata); - // B2 守卫(2026-08-03 token-burn issue P0-1c):测试特征 WU 不起会话、直接关闭。 - // 历史事故:路由测试经共享数据根把测试 WU 写进生产 FileStore,daemon 当真任务逐个 - // 起 Claude 会话执行(16 个会话 420 万 token)。关闭留痕 testWorkUnitGuard + blockReason。 - if (testWuGuardEnabled() && isTestLikeWorkUnit(wu, metadata)) { - logger.warn('[AgentLoop] Test-like WorkUnit guarded — closing without execution', { - workUnitId: wu.id, scope: wu.scope, - }); - await this.workUnitService.update(wu.id, { - metadata: { ...metadata, testWorkUnitGuard: true, blockReason: 'test-wu-guard: 测试特征任务,守卫关闭' }, - }).catch(err => logger.warn('[AgentLoop] test-wu guard metadata write failed', { workUnitId: wu.id, error: String(err) })); - if (wu.status !== 'closed') { - await this.workUnitService.transitionStatus(wu.id, 'closed') - .catch(err => logger.warn('[AgentLoop] test-wu guard close failed', { workUnitId: wu.id, error: String(err) })); - } - await this.postToDiscussionSpace(wu.id, '检测到测试特征任务,已跳过执行并关闭(防止测试数据空烧 token)') - .catch(() => {}); - return { action: 'skipped', summary: '' }; - } + // 入口守卫链(#541,对称出口侧 completion-gates):B2 测试特征 WU / C3 日 token 预算 / + // #162 WU 级 tokenBudget / #471 plan 步数额度四段「该不该跑这一步」前置判定抽到 + // ./step-guards.js(顺序即优先级,首个命中短路)。命中即返回(skipped/need_input),放行继续。 + const guardOutcome = await runStepGuards({ wu, metadata }, { + updateWuMetadata: (wuId, m) => this.workUnitService.update(wuId, { metadata: m }), + closeWu: wuId => this.workUnitService.transitionStatus(wuId, 'closed'), + postNotice: (wuId, text) => this.postToDiscussionSpace(wuId, text), + eventsFilePath: studioEventsJsonlPath, + }); + if (guardOutcome.result) return guardOutcome.result; - // C3 守卫(2026-08-03 token-burn issue P2-2,决策记录 #4):每日 token 预算熔断。 - // 当日 billed 口径消耗 ≥ 预算(默认 2M/日,STUDIO_DAILY_TOKEN_BUDGET 覆盖,<=0 关闭)→ - // 不起会话,WU 经 need_input 挂起(recordResult 落 waitingForInput + blockReason), - // 等次日本地零点预算复位或人工处置;全局当日只告警一次(studio:budget-tripped 事件留痕)。 - // 用量走进程内计数器(daily-token-budget),仅首次/跨天全量扫一次事件文件,不拖慢热路径。 - if (tokenBudgetGuardEnabled()) { - const dailyBudget = resolveDailyTokenBudget(); - if (dailyBudget > 0) { - const eventsFile = studioEventsJsonlPath(); - const daily = await getDailyTokenUsage({ eventsFile }); - if (daily.usedTokens >= dailyBudget) { - logger.warn('[AgentLoop] Daily token budget tripped — pausing automatic execution', { - workUnitId: wu.id, usedTokens: daily.usedTokens, budget: dailyBudget, - }); - if (!daily.notified) { - await notifyBudgetTripped({ eventsFile, usedTokens: daily.usedTokens, budget: dailyBudget }); - } - return { - action: 'need_input' as const, - summary: `每日 token 预算已熔断(当日已用 ${daily.usedTokens.toLocaleString()} / 上限 ${dailyBudget.toLocaleString()},billed 口径含 cache_read):已暂停自动执行、不再起会话。次日(本地零点)预算复位后回复任意内容继续,或直接关闭任务`, - }; - } - } - } - // #162(T8-E1,#130 决策 3):WU 级 token 预算熔断。metadata.tokenBudget 显式数值 - // (任何类型 WU 可带,与日预算无关、不吃 STUDIO_TOKEN_BUDGET_GUARD 开关——字段在场即生效), - // 对照 metadata._cumulativeTokens(billed 口径簿记,与日预算同口径)。超线复用日预算同款 - // need_input 挂起路径(recordResult 落 waitingForInput + blockReason),不新造状态; - // waitingReason='wu-token-budget' 供 waiting-input 人三选分流(追加预算/收尾/放弃)。 - // 人读面说人话:提示文案不出现 WU/metadata/闸/熔断等机制黑话。 - if (typeof metadata.tokenBudget === 'number' && Number.isFinite(metadata.tokenBudget) && metadata.tokenBudget > 0) { - const wuBudget = Math.floor(metadata.tokenBudget); - const wuUsed = metadata._cumulativeTokens ?? 0; - if (wuUsed >= wuBudget) { - logger.warn('[AgentLoop] WU token budget reached — suspending for human decision', { - workUnitId: wu.id, usedTokens: wuUsed, budget: wuBudget, - }); - return { - action: 'need_input' as const, - summary: `这项任务已消耗 ${wuUsed.toLocaleString()} token,达到为它设定的上限 ${wuBudget.toLocaleString()},已暂停等你决定。回复:「追加预算」在上限之上再加 ${wuBudget.toLocaleString()} 继续执行;「追加预算 <数值>」把上限改为指定数值;「收尾」用现有产出提交审查;「放弃」结束任务`, - metadataUpdates: { waitingReason: 'wu-token-budget' }, - }; - } - } - // #471(Triage 定稿 1):plan 步数额度熔断。一脉会话承载全规划链,额度高于 - // implement(PLAN_STEP_LIMIT=60,常量与语义见 workunit.types.ts)。到线不走 - // recordResult 的强制收口 in_review(plan 已从中豁免)——前置守卫在此转 - // need_input 挂 blocked 转人,不静默截断;人回复即续期(waiting-input 给 - // planStepAllowance 加一份 PLAN_STEP_LIMIT,复活回 active 续跑)。 - if (wu.type === 'plan') { - const allowance = typeof metadata.planStepAllowance === 'number' && Number.isFinite(metadata.planStepAllowance) && metadata.planStepAllowance > 0 - ? Math.floor(metadata.planStepAllowance) - : PLAN_STEP_LIMIT; - const planSteps = metadata.stepCount ?? 0; - if (planSteps >= allowance) { - logger.warn('[AgentLoop] Plan step limit reached — suspending for human decision', { - workUnitId: wu.id, stepCount: planSteps, allowance, - }); - return { - action: 'need_input' as const, - summary: `这次规划已推进 ${planSteps} 步,达到为它设定的步数额度 ${allowance},已暂停等你决定。回复任意内容(或「继续」)即续期 ${PLAN_STEP_LIMIT} 步接着跑;想收尾可在 Web 端点「通过」进入人工确认`, - metadataUpdates: { waitingReason: 'plan-step-limit' }, - }; - } - } // P0 修复 6: traceId 贯穿 — 频道消息 → WU metadata → 执行参数(extraEnv)与日志行 const traceId = typeof metadata.traceId === 'string' && metadata.traceId ? metadata.traceId : undefined; @@ -1202,112 +1198,36 @@ export class AgentLoop { errorAt: new Date().toISOString(), }, }); - let detail = (result.error ?? '未知错误').slice(0, 500); + const detail = (result.error ?? '未知错误').slice(0, 500); logger.error(`[AgentLoop] agentStep execution failed for ${wu.id}: ${detail}`, { traceId }); // B6: 失败执行同样记账(CLI 已跑的轮次照样烧了 token,runner error 路径透出 usage) recordTokenEvent(result); - // #94 续用降级:仅续用步 + 「会话不存在」错误(档案 sessionId 对应会话已被清理)→ - // 换发新 sessionId 重试一次(claude 传 --session-id、不带 sessionResume)。 + // #543: #94 续用丢失降级与 #96 上下文溢出两段孪生重试骨架(配额判定 → 改参 → + // 重算 prompt → 再执行 → 成败分叉 → 簿记回写)收编到 ./step-retry-policy.js —— + // 本处只保留编排:注入 executor/prompt 重算/记账/失败事件 deps,按 outcome 分流。 // 非续用类错误(超时/业务失败)与 catch 分支(spawn 异常)不触发;每步至多烧一次重试。 - if (resumeSessionId && RESUME_FAILURE_RE.test(detail)) { - // #96: 收口 #95 降级超限 —— 续用降级重试也遵守 MAX_SESSIONS_PER_WU(删除 #94「绕过 MAX 一次」先例)。 - if (sessionsUsed >= MAX_SESSIONS_PER_WU) { - logger.warn('[AgentLoop] Resume session lost and session limit reached — need human evaluation', { - workUnitId: wu.id, sessionsUsed, max: MAX_SESSIONS_PER_WU, - }); - this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('need_input', detail, result); - return { - action: 'need_input' as const, - summary: `续用会话已丢失且会话重建已达上限(${sessionsUsed}/${MAX_SESSIONS_PER_WU}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, - metadataUpdates, - }; - } - const fallbackSessionId = randomUUID(); - logger.warn(`[AgentLoop] Resume target session lost for ${wu.id} — falling back to a new session`, { traceId }); - task.parameters!.sessionId = taskProvider === 'claude' ? fallbackSessionId : undefined; - delete task.parameters!.sessionResume; - // #95: 降级重试 = 一次新建会话尝试,成败均计入会话预算(失败/超时尝试计入) - metadataUpdates.sessionCount = sessionsUsed + 1; - // #95: 降级换新号 = 执行期续用不命中(断链新会话)——check 时 shouldResumeSession 判命中 - // 未注入前序进展,执行才发现会话丢失。重算 prompt 以注入「前序进展」段 + 回放 - // waitingQuestion(复用同一 ctx/deps;knowledge/skill 等段重复组装一次,副作用均 fire-and-forget)。 - const recomposed = await composeStepPrompt( - { wu, metadata, newReplies: target.newReplies?.map(r => r.content), isNewSession: true }, - composeDeps, - ); - task.prompt = recomposed.prompt; - const retryResult: ExecutionResult = await this.executor.execute(task); - if (retryResult.success === false) { - // 降级重试仍失败 → 既有 failed 返回;新会话未建立,重置 sessionId 但保留 sessionCount(计入) - detail = (retryResult.error ?? '未知错误').slice(0, 500); - logger.error(`[AgentLoop] agentStep fallback retry failed for ${wu.id}: ${detail}`, { traceId }); - recordTokenEvent(retryResult); - this.resetUnestablishedSession(metadataUpdates); - this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('failed', detail, retryResult); - return failResult(detail); - } - // 降级成功:走正常成功路径;换新号落盘(sessionCount 已在查过 MAX 后计入) - result = retryResult; - effectiveSessionId = fallbackSessionId; - sessionResumed = false; - metadataUpdates.sessionId = fallbackSessionId; - metadataUpdates.lastSessionResumed = false; - } else if (isContextOverflowError(detail)) { - // #96: CLI 上下文溢出纯反应式策略 —— 溢出错误 → 会话滚动摘要落盘 → 新会话带摘要 - // 注入重试一次 → 再败 NEED_INPUT。溢出重试 = 一次新建会话尝试,占会话配额 - // (与 #95 失败/超时尝试计入语义一致,超限走 need_input,不静默绕过 MAX)。 - // 摘要来源 = wu.scope + progressLog(会话内逐步 summary),不递归摘要、不建语义搜索。 - const overflowSummary = buildRollingSummary(wu.scope, metadata); - metadataUpdates.sessionSummary = overflowSummary; - if (sessionsUsed >= MAX_SESSIONS_PER_WU) { - logger.warn('[AgentLoop] Context overflow and session limit reached — need human evaluation', { - workUnitId: wu.id, sessionsUsed, max: MAX_SESSIONS_PER_WU, - }); - this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('need_input', detail, result); - return { - action: 'need_input' as const, - summary: `CLI 上下文溢出且会话重建已达上限(${sessionsUsed}/${MAX_SESSIONS_PER_WU}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, - metadataUpdates, - }; - } - const overflowSessionId = randomUUID(); - logger.warn(`[AgentLoop] Context overflow for ${wu.id} — persisting rolling summary and retrying in a new session`, { traceId }); - task.parameters!.sessionId = taskProvider === 'claude' ? overflowSessionId : undefined; - delete task.parameters!.sessionResume; - metadataUpdates.sessionCount = sessionsUsed + 1; - metadataUpdates.sessionId = overflowSessionId; - metadataUpdates.lastSessionResumed = false; - // 重算 prompt 注入摘要(isNewSession:false 避免与 handoff 前序进展段重复;摘要单独注入) - const recomposed = await composeStepPrompt( - { wu, metadata, newReplies: target.newReplies?.map(r => r.content), isNewSession: false }, - composeDeps, - ); - task.prompt = `${recomposed.prompt}\n\n${OVERFLOW_SUMMARY_HEADER}\n\n${overflowSummary}`; - const retryResult: ExecutionResult = await this.executor.execute(task); - if (retryResult.success === false) { - // 再败 → NEED_INPUT(合流既有 need_input 路径);sessionSummary 保留落盘供人工参考, - // sessionId 重置、sessionCount 计入(不再三连败静默 blocked) - const retryDetail = (retryResult.error ?? '未知错误').slice(0, 500); - logger.error(`[AgentLoop] agentStep overflow retry failed for ${wu.id}: ${retryDetail}`, { traceId }); - recordTokenEvent(retryResult); - this.resetUnestablishedSession(metadataUpdates); - this.recordOutcomeEvent(wu, false, retryDetail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('need_input', retryDetail, retryResult); - return { - action: 'need_input' as const, - summary: `CLI 上下文溢出:新会话带摘要重试一次仍失败(${retryDetail.slice(0, 200)}),已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, - metadataUpdates, - }; - } - // 溢出重试成功:走正常成功路径;换新号落盘 - result = retryResult; - effectiveSessionId = overflowSessionId; + const retryOutcome = await runStepRetry( + { wu, metadata, provider: taskProvider, task, firstResult: result, detail, sessionsUsed, resumeSessionId, metadataUpdates, traceId }, + { + execute: (t) => this.executor.execute(t), + recomposePrompt: async ({ isNewSession }) => (await composeStepPrompt( + { wu, metadata, newReplies: target.newReplies?.map(r => r.content), isNewSession }, + composeDeps, + )).prompt, + recordTokenEvent, + recordFailureOutcome: (d) => this.recordOutcomeEvent(wu, false, d, 'execution_failed', injectedKnowledgeIds), + emitFailedStep, + }, + ); + if (retryOutcome.kind === 'terminal') return retryOutcome.result; + if (retryOutcome.kind === 'retried') { + // 重试成功:走正常成功路径;换新号(簿记已由策略写入 metadataUpdates) + result = retryOutcome.result; + effectiveSessionId = retryOutcome.sessionId; sessionResumed = false; } else { - // 首 step 失败:会话未必已建立,重置避免下步 --resume 一个从未建立的会话 + // 非重试类失败(no-retry):首 step 失败会话未必已建立,重置避免下步 --resume + // 一个从未建立的会话 if (newSessionId) this.resetUnestablishedSession(metadataUpdates); this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); emitFailedStep('failed', detail, result); @@ -1404,70 +1324,14 @@ export class AgentLoop { } catch { /* non-blocking */ } } - // P0 修复(reviewReport 回传断链):review 子 WU 报告 COMPLETE 时,把 reviewer - // 最终输出解析为结构化结论写入 metadata.reviewReport —— 这是 ReviewDispatcher - // 路径 B 判定父 WU 过/拒的唯一数据源。解析失败不写(dispatcher 转人工,不误拒)。 - if (wu.type === 'review' && stepResult.action === 'complete') { - const report = parseReviewReport(result.outputText ?? ''); - if (report) { - metadataUpdates.reviewReport = report; - } else { - logger.warn(`[AgentLoop] Review WU ${wu.id} completed without parseable REVIEW_RESULT — 由 ReviewDispatcher 转人工`); - } - } - - // PMO 规划接力(analysis-handoff):analysis/plan WU COMPLETE 时解析 TASK: 拆分行 - // 写入 metadata.analysisTasks —— 人工确认(reviewPassed → done)后由 - // analysis-handoff 据此建未指派 task 子 WU(频道成员涌现认领 = 派工)。 - // #471:plan(一脉会话)沿用 analysis 字段名——解析契约/确认弹窗预填/派工消费全不变。 - // 解析失败/无 TASK 行不写(确认后仅提示可手动拆,不阻断完成)。 - if ((wu.type === 'analysis' || wu.type === 'plan') && stepResult.action === 'complete') { - const tasks = parseTaskBreakdown(result.outputText ?? ''); - if (tasks.length > 0) { - metadataUpdates.analysisTasks = tasks; - } - // #106 M7 对齐:同份输出里的 FOG:/DESTINATION: 行(map-opening 同一解析器, - // 契约单一来源)落 metadata——人工确认弹窗据此预填待决问题清单(人审改后随 - // l3.summary 回传开图)。无 FOG 行 = 非探路型,两字段都不落(destination - // 单独落档会预填出一行无人消费的 DESTINATION,误导确认人)。 - const opening = parseMapOpening(result.outputText ?? ''); - if (opening.fog.length > 0) { - metadataUpdates.analysisFog = opening.fog; - if (opening.destination) { - metadataUpdates.analysisDestination = opening.destination; - } - } - // #163(T8-E2,#130 决策 2):巡检单 COMPLETE 时解析 OPPORTUNITY: 协议行 - // 落 metadata.opportunities(初始全 pending;id=opp-N 单内唯一)——冷却闸判定 - // 与 web 确认 UI(采纳/忽略)消费。无合法 OPPORTUNITY 行不写,不阻断完成。 - if (metadata.inspection === true) { - const opps = parseOpportunities(result.outputText ?? ''); - if (opps.length > 0) { - metadataUpdates.opportunities = opps.map((o, i) => ({ - id: `opp-${i + 1}`, - ...o, - status: 'pending' as const, - })); - } - } - } - - // #463:decision/spec 确认表单的结构化预填数据源(照 analysis TASK/FOG 落档先例, - // 结构化数据在确认之前落档,确认弹窗只做评审不做录入): - // decision COMPLETE → `## 结论摘要` 段(prompt 契约已有)落 decisionSuggestion; - // spec COMPLETE → TASK: 物化行(spec-materialization 同一解析器,契约单一来源) - // 落 specTasks。解析无获不写,不阻断完成。 - if (wu.type === 'decision' && stepResult.action === 'complete') { - const suggestion = parseDecisionConclusion(result.outputText ?? ''); - if (suggestion) { - metadataUpdates.decisionSuggestion = suggestion; - } - } - if (wu.type === 'spec' && stepResult.action === 'complete') { - const specTasks = parseSpecTasks(result.outputText ?? ''); - if (specTasks.length > 0) { - metadataUpdates.specTasks = specTasks; - } + // #542:per-WU-type COMPLETE 收割改注册表(completion-harvest.ts)—— + // review→reviewReport / analysis|plan→analysisTasks+analysisFog+opportunities / + // decision→decisionSuggestion / spec→specTasks。新 type 收割 = 注册表加一行; + // 解析无获不落档、单收割器抛错仅跳过,均不阻断完成(语义同原各分支)。 + if (stepResult.action === 'complete') { + Object.assign(metadataUpdates, harvestCompletionMetadata(result.outputText ?? '', { + wuId: wu.id, wuType: wu.type, metadata, + })); } return { ...stepResult, metadataUpdates, channelVersion }; @@ -1589,15 +1453,16 @@ export class AgentLoop { } /** - * 首 step(新建会话)执行失败 / 续用降级重试仍失败时重置会话簿记:CLI 会话未必已建立 + * 首 step(新建会话)执行失败 / spawn 异常时重置会话簿记:CLI 会话未必已建立 * (可能根本没 spawn 到),不重置则下一步按续用发 `--resume <从未建立的 id>` * (claude 必报 "No conversation found")。续用 step 失败不调用 —— 会话已存在, * 保留下一步继续 resume。(#94:实例槽位清除已随 per-WU 化一并移除) * #95: sessionCount 不再清除 —— 失败/超时的会话建立尝试计入预算(超限转 need_input)。 + * #543: 实现唯一正本在 ./step-retry-policy.js(resetUnestablishedSessionBookkeeping), + * 重试再败路径由策略模块直接调用,本方法委托保持编排侧单一入口。 */ private resetUnestablishedSession(metadataUpdates: Partial): void { - delete metadataUpdates.sessionId; - delete metadataUpdates.lastSessionResumed; + resetUnestablishedSessionBookkeeping(metadataUpdates); } /** Record result: monitoring checkpoints + state transitions (zero token) */ @@ -1623,7 +1488,7 @@ export class AgentLoop { // P0 修复 6: traceId(与 agentStep 同一来源,供日志行携带) const traceId = typeof metadata.traceId === 'string' && metadata.traceId ? metadata.traceId : undefined; - // 收口守卫链(§10.5 提交守卫 → §6-2 子任务守卫 → B3b-i 自动验证守卫)已抽到 + // 收口守卫链(§10.5 提交守卫 → §6-2 子任务守卫 → 产出实双闸(空 diff / 契约产物)→ B3b-i 自动验证守卫)已抽到 // ./completion-gates.js(行为一字不改,含守卫顺序/hint 写法/l1 台账/合并视图口径)—— // recordResult 只保留编排:构建合并视图(上方)→ 跑守卫 → delegate/新鲜度/强制收口 → // 单次原子写 → 状态迁移与频道通知。git/子任务查询经 deps 注入(loop 绑定的两个方法下传)。 @@ -1639,6 +1504,9 @@ export class AgentLoop { const noCommitNotice = guards.notices.noCommit; const verifyBlocked = guards.notices.verifyBlocked; const verifyPassNotice = guards.notices.verifyPassed; + // 收口闸(产出实):空 diff / 契约产物连续打回 ≥3 次 → blocked(模式同 verifyBlocked) + const diffEmptyBlocked = guards.notices.diffEmptyBlocked; + const contractArtifactBlocked = guards.notices.contractArtifactBlocked; // F6-c:本 step COMPLETE 守卫是否已跑过验证 —— 下方步骤超限强制收口路径据此避免重复跑 const verifyGuardRan = guards.notices.verifyGuardRan; @@ -1686,7 +1554,7 @@ export class AgentLoop { } const stepCount = (metadata.stepCount ?? 0) + 1; - let consecutiveStuck = action === 'progress' ? 0 : (metadata.consecutiveStuck ?? 0) + 1; + const consecutiveStuck = action === 'progress' ? 0 : (metadata.consecutiveStuck ?? 0) + 1; // #95: progressLog 环形簿记 —— 只记成功步(progress/complete;delegate 经 handleDelegateBranch // 已归化为 progress/need_input,failed/need_input 不进 log),summary 截 200 字符、保留最近 5 条。 @@ -1744,6 +1612,12 @@ export class AgentLoop { ...(result.rulings?.length ? { planRulings: result.rulings, waitingReason: 'plan-ruling' } : {}), + // #567:方向锁定——DIRECTION 行落档(方向接力卡预填数据源)+ 挂起原因标记; + // 与 rulings 并存时 direction 优先定 waitingReason(方向是裁决轮前置环节); + // 人提交选定(POST /:id/direction → pmo/plan-direction.ts)后清除 + ...(result.directions + ? { planDirections: result.directions, waitingReason: 'plan-direction' } + : {}), } : metadata.waitingForInput ? { waitingForInput: false, waitingReminded: false } @@ -1754,6 +1628,10 @@ export class AgentLoop { const blockReasonUpdates: Partial = {}; if (verifyBlocked) { blockReasonUpdates.blockReason = `verify-failed x${guardUpdates.verifyFailCount}: 自动验证连续失败`; + } else if (diffEmptyBlocked) { + blockReasonUpdates.blockReason = `diff-empty x${guardUpdates.diffEmptyCount}: 报告完成但无提交内容`; + } else if (contractArtifactBlocked) { + blockReasonUpdates.blockReason = `contract-artifact x${guardUpdates.contractArtifactCount}: 契约产物连续缺失`; } else if (consecutiveStuck >= 3) { blockReasonUpdates.blockReason = action === 'failed' && result.summary ? `stuck: 连续 3 步无进展(${result.summary.slice(0, 200)})` @@ -1847,6 +1725,40 @@ export class AgentLoop { return; } + // 收口闸(产出实):空 diff / 契约产物连续打回 ≥3 次 → blocked 并频道说明(模式同 verifyBlocked) + const outputGateBlocked = diffEmptyBlocked + ? { + failureType: 'diff_empty', + message: `连续 ${guardUpdates.diffEmptyCount} 次报告完成却没有任何提交内容,任务已转 blocked,等待人类介入`, + } + : contractArtifactBlocked + ? { + failureType: 'contract_artifact', + message: `契约产物连续缺失 ${guardUpdates.contractArtifactCount} 次,任务已转 blocked,等待人类介入。缺失产物与提示已记录到任务上下文`, + } + : null; + if (outputGateBlocked) { + // #178(#63 决议 2):状态迁移前 fencing,易主即静默退出 + if (wu.status !== 'blocked' && !(await this.transitionIfHeld(wuId, 'blocked'))) return; + // #172(#60 决策 Q1):WU 级终态失败事件落盘(level=warning,#62 失败趋势探测数据源) + void emitWorkUnitFailedEvent({ + workUnitId: wuId, + failureType: outputGateBlocked.failureType, + blockReason: String(blockReasonUpdates.blockReason ?? ''), + consecutiveStuck, + attempts: stepCount, + totalDurationMs: Math.max(0, Date.now() - new Date(wu.createdAt).getTime()), + traceId, + }).catch(() => {}); + // #176(决策 #57 D3-1):blocked 里程碑统一携带 CTA 行动召唤块 + await this.postToDiscussionSpace( + wuId, + withBlockedCta(outputGateBlocked.message, String(blockReasonUpdates.blockReason ?? '')), + wu, + ); + return; + } + // B3b-i: 验证全绿 → 频道简报(跑了哪几条;仅当 COMPLETE 未被其他守卫拦截) if (verifyPassNotice && action === 'complete') { await this.postToDiscussionSpace(wuId, verifyPassNotice); @@ -1916,10 +1828,13 @@ export class AgentLoop { // 2026-07 PMO-flow UX(§6-3):NEED_INPUT 里程碑 —— meta 带 pmoId(可解析时)+ atHuman // #279(决策 #250 D3):result.options 存在时随 meta 透传,供前端渲染选项卡 // #467:result.rulings 存在时 meta.cardType='plan_ruling'——前端渲染裁决轮接力卡 + // #567:result.directions 存在时 meta.cardType='plan_direction'——前端渲染方向接力卡 + // (方向是裁决轮前置环节,并存时 direction 优先) if (!skipResultPost) { const extraMeta: MessageMeta = {}; if (result.options?.length) extraMeta.options = result.options; if (result.rulings?.length) extraMeta.cardType = 'plan_ruling'; + if (result.directions) extraMeta.cardType = 'plan_direction'; await this.postToDiscussionSpace(wuId, `需要输入: ${result.summary}`, wu, Object.keys(extraMeta).length > 0 ? extraMeta : undefined); } diff --git a/apps/api/src/modules/agents/loop/agent-loop.types.ts b/apps/api/src/modules/agents/loop/agent-loop.types.ts index 758cb2fe1..08e39d2af 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.types.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.types.ts @@ -21,6 +21,13 @@ export interface StepResult { * prompt-composer CONTRACT_TEMPLATES.plan):问题清单 + 每题建议结论 + 默认值; * 落 metadata.planRulings 作裁决卡预填数据源;解析失败/缺失时为 undefined */ rulings?: { question: string; suggestion: string; default?: string }[]; + /** #567:方向锁定——NEED_INPUT 紧随的 DIRECTION: JSON 行解析结果(plan 契约, + * 裁决轮前置可选环节):方向抉择点 + 候选方向清单(2..4 条,recommended 恰好一个); + * 落 metadata.planDirections 作方向接力卡预填数据源;解析失败/缺失时为 undefined */ + directions?: { + question: string; + options: { name: string; summary: string; tradeoffs: string; impact: string; recommended: boolean }[]; + }; /** §4.2 发言层新鲜度检查:step 开始时捕获的频道版本(agentStep 写入,recordResult 比对)。 * #319:行号口径退役(压实会压缩行数),以最后一行消息 id 为锚 */ channelVersion?: { lastMessageId: string | null }; diff --git a/apps/api/src/modules/agents/loop/claim-fitness.ts b/apps/api/src/modules/agents/loop/claim-fitness.ts new file mode 100644 index 000000000..cbb0fbc7e --- /dev/null +++ b/apps/api/src/modules/agents/loop/claim-fitness.ts @@ -0,0 +1,136 @@ +/** + * 认领前适任判断(决策 14,docs/adr/2026-08-25-review-independence-trust-model.md 补充段)。 + * + * 问题:频道角色认领是「代码代抢」——observe(零 LLM)过滤 → resolveTarget(纯代码排序) + * → claim 抢占,agent 第一次看到 scope 全文是认领后的第一步 prompt,错误认领是结构性必然。 + * 本模块在 resolveTarget 选中 unassigned 候选之后、claimAndAnnounce 之前插一道一次性 LLM + * 判断:「能否胜任、是否属于你」。只在角色空闲且即将认领时触发(成本与认领次数成正比), + * 不在 observe 15s 轮询里加 LLM。 + * + * 治理约束(ADR D2/决策 10):约束挂 WU 不挂角色身份——不适任结果落档 WU 侧 + * metadata.unfitRoles 名单({roleId, reason, at}),observe 过滤链据此排除本角色; + * 不恢复 acceptedTypes 静态类型过滤(那是治理变更)。 + * + * 从宽哲学:判断调用失败/超时/解析不出 → 视为适任(宁可误抢不可漏抢,错抢还有 + * NEED_INPUT 甩人兜底)。落档失败同口径从宽放行。 + * + * LLM 调用复用 system-executor(轻量一次性 spawn,无 worktree,与 distill / + * wu-completion-extraction 同款机制),经 getSystemExecutor() 懒单例;测试注入 run 替代。 + */ +import type { AgentProfileData } from '@dommaker/studio-shared'; +import type { WorkUnitData, WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import type { UnfitRoleEntry } from '../../workunit/workunit.types.js'; +import { getSystemExecutor } from '../system-executor.js'; + +/** 适任判断输入的 scope 截断上限(字符)——轻量 prompt,控制单次调用规模 */ +export const FITNESS_SCOPE_MAX_CHARS = 2_000; +/** 不适任理由截断上限(字符)——落档 unfitRoles.reason 与频道消息共用 */ +export const FITNESS_REASON_MAX_CHARS = 120; + +export interface FitnessVerdict { + fit: boolean; + reason: string; +} + +/** 一次性 LLM 调用签名(SystemExecutor.run 的子集;测试注入替代真实 CLI spawn) */ +export type FitnessRunner = ( + prompt: string, + options?: { systemPrompt?: string; eventSource?: string }, +) => Promise<{ output: string }>; + +/** + * 适任判断 prompt(单一来源):候选 WU 的 type + scope 摘要 + role 的 + * persona/description/acceptedTypes,一次性问「能否胜任、是否属于你」。 + * acceptedTypes 在此仅作判断素材(推断),不做静态过滤(决策 10 红线不碰)。 + */ +export function buildClaimFitnessPrompt( + wu: Pick, + role: AgentProfileData, +): string { + const persona = (role.persona ?? role.description ?? '').trim(); + const accepted = (role.acceptedTypes ?? []).join('、') || '(未声明)'; + const scope = (wu.scope ?? '').length > FITNESS_SCOPE_MAX_CHARS + ? `${wu.scope.slice(0, FITNESS_SCOPE_MAX_CHARS)}…[truncated]` + : (wu.scope ?? ''); + return [ + `你是角色「${role.name}」。`, + persona ? `角色自述:${persona}` : null, + `角色声明的职能域:${accepted}`, + '', + `待认领工单(类型 ${wu.type}):`, + scope, + ].filter(line => line !== null).join('\n'); +} + +/** 判断系统提示词:输出协议唯一出处(parseFitnessVerdict 据此解析) */ +export const CLAIM_FITNESS_SYSTEM_PROMPT = `你是认领前适任判断器。根据角色自述与职能域,判断该角色能否胜任、该工单是否属于它。 +拿不准一律视为适任(误抢的代价远小于漏抢)。 +只输出一行,协议:FIT: yes|no: 一行理由(不超过 80 字)。不要输出任何其他内容。`; + +/** + * 解析判断输出。容错链:claude --output-format json 信封({type:'result',result:string}) + * 先拆封取模型文本 → 逐行找 `FIT: yes|no` → 理由取同行余下部分(截断)。 + * 解析不出 → 从宽 { fit: true }。 + */ +export function parseFitnessVerdict(output: string): FitnessVerdict { + let text = output; + try { + const parsed: unknown = JSON.parse(output); + const result = (parsed as { result?: unknown } | null)?.result; + if (typeof result === 'string') text = result; + } catch { /* 非 JSON 信封 → 按裸文本扫 */ } + for (const line of text.split('\n')) { + const m = line.match(/^\s*FIT:\s*(yes|no)\b\s*:?\s*(.*)$/i); + if (!m) continue; + const reason = (m[2] ?? '').trim().slice(0, FITNESS_REASON_MAX_CHARS); + return { fit: m[1].toLowerCase() === 'yes', reason }; + } + return { fit: true, reason: '' }; +} + +/** + * 一次性适任判断。任何失败(spawn 失败/超时/未配置 studio provider)→ 从宽适任。 + * eventSource='claim-fitness' 走 system:tokens 记账(默认 30s 超时,轻量 prompt 够用)。 + * + * #579(2026-09-17):总开关 `STUDIO_CLAIM_FITNESS=false`(默认开),无凭证/ + * fake-provider 环境豁免适任判断——否则每个涌现认领都经 system-executor 起真实 + * CLI 空转(P7/P8 同类治理,STUDIO_AUTO_REVIEW / STUDIO_KNOWLEDGE_EXTRACTION + * 同风格 `!== 'false'`)。关闭时从宽放行(等同判断失败语义)。 + */ +export async function judgeClaimFitness( + wu: Pick, + role: AgentProfileData, + run?: FitnessRunner, +): Promise { + if (process.env.STUDIO_CLAIM_FITNESS === 'false') { + return { fit: true, reason: '' }; // #579:fake/无凭证环境豁免,不起真实 CLI + } + const call: FitnessRunner = run ?? ((prompt, options) => getSystemExecutor().run(prompt, options)); + try { + const result = await call(buildClaimFitnessPrompt(wu, role), { + systemPrompt: CLAIM_FITNESS_SYSTEM_PROMPT, + eventSource: 'claim-fitness', + }); + return parseFitnessVerdict(result.output); + } catch { + return { fit: true, reason: '' }; // 从宽:判断调用失败视为适任(NEED_INPUT 兜底错抢) + } +} + +/** 安全解析 WU metadata.unfitRoles —— 缺失/损坏/非数组一律 [](不排除),仿 parseExcludeAssignee */ +export function parseUnfitRoles(metadata: WorkUnitMetadata | string | null | undefined): UnfitRoleEntry[] { + try { + const m = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + const v = (m as { unfitRoles?: unknown } | null)?.unfitRoles; + if (!Array.isArray(v)) return []; + return v.filter((e): e is UnfitRoleEntry => + !!e && typeof e === 'object' && typeof (e as UnfitRoleEntry).roleId === 'string'); + } catch { + return []; + } +} + +/** observe 第 7 道过滤判据:本角色是否已被判不适任 */ +export function isRoleUnfit(metadata: WorkUnitMetadata | string | null | undefined, roleId: string): boolean { + return parseUnfitRoles(metadata).some(e => e.roleId === roleId); +} diff --git a/apps/api/src/modules/agents/loop/completion-gates.ts b/apps/api/src/modules/agents/loop/completion-gates.ts index 8979afe2b..5ebf7ae14 100644 --- a/apps/api/src/modules/agents/loop/completion-gates.ts +++ b/apps/api/src/modules/agents/loop/completion-gates.ts @@ -1,6 +1,7 @@ /** * 收口守卫链(2026-08 从 agent-loop.recordResult 抽出,行为一字不改): - * recordResult 的 COMPLETE 收口判定 —— §10.5 提交守卫 → §6-2 子任务守卫 → B3b-i 自动验证守卫 + * recordResult 的 COMPLETE 收口判定 —— §10.5 提交守卫 → §6-2 子任务守卫 → 产出实双闸 + * (闸 1 代码类 diff 非空 / 闸 2 非代码类契约产物)→ B3b-i 自动验证守卫 * → T7-E2 软观测段(#161,只观测不拦截:checker:soft_check 台账 + processCheckHint)。 * 顺序即优先级:前面的守卫把 action 降级为 progress 后,后面的 COMPLETE 守卫自然不再触发。 * @@ -15,7 +16,7 @@ */ import { execFileSync, execSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import yaml from 'js-yaml'; import { logger, withAttestation } from '@dommaker/studio-shared'; @@ -54,6 +55,71 @@ export function readHeadHash(cwd: string): string | null { } } +/** 收口闸 1(产出实): git rev-list --count ..HEAD —— 提交守卫已保证无未提交改动, + * 这里判「有没有提交内容」。git 失败返回 null —— 静默跳过(基础设施故障不阻断完成)。 */ +export function countBranchCommits(cwd: string, baseBranch: string): number | null { + try { + const out = execFileSync('git', ['rev-list', '--count', `${baseBranch}..HEAD`], { + cwd, timeout: 5000, encoding: 'utf-8', + }).trim(); + const n = Number.parseInt(out, 10); + return Number.isFinite(n) ? n : null; + } catch { + return null; + } +} + +/** 收口闸 2(analysis):契约报告落盘检查 —— /.studio/research/ 下存在非空文件 + * (inspection 巡检变体只认 inspection-*.md)。目录不存在/读取失败 = 无产物(false)。 */ +export function hasAnalysisReport(workspaceRoot: string, inspection: boolean): boolean { + try { + const dir = join(workspaceRoot, '.studio', 'research'); + for (const name of readdirSync(dir)) { + if (inspection && !/^inspection-.+\.md$/.test(name)) continue; + const st = statSync(join(dir, name)); + if (st.isFile() && st.size > 0) return true; + } + return false; + } catch { + return false; + } +} + +/** 闸 2 锚点分派(按 wu.type):返回缺失产物的人话描述;null = 产物在或本类型不进闸。 + * decision 的 `## 结论摘要` 解析落档已由 #463 提供(agent-loop → metadata.decisionSuggestion), + * 本闸只验非空,不重复解析。 */ +function missingContractArtifact( + wu: WorkUnitData, + metadata: WorkUnitMetadata, + reportExists: (workspaceRoot: string, inspection: boolean) => boolean, +): string | null { + switch (wu.type) { + case 'review': + return metadata.reviewReport ? null : '评审结论(REVIEW_RESULT 协议输出)'; + case 'plan': + return Array.isArray(metadata.analysisTasks) && metadata.analysisTasks.length > 0 + ? null : '任务拆分清单(TASK: 协议行)'; + case 'spec': + return Array.isArray(metadata.specTasks) && metadata.specTasks.length > 0 + ? null : '物化任务清单(TASK: 协议行)'; + case 'decision': + return typeof metadata.decisionSuggestion === 'string' && metadata.decisionSuggestion.trim().length > 0 + ? null : '结论摘要(## 结论摘要 段)'; + case 'analysis': { + if (metadata.prototype === true) return null; // 原型单契约是 prototype/ 分支代码,豁免本闸 + const root = typeof metadata.workspaceRoot === 'string' && metadata.workspaceRoot.length > 0 + ? metadata.workspaceRoot : null; + if (!root) return null; // 无落盘根无法校验 → 静默跳过 + const inspection = metadata.inspection === true; + return reportExists(root, inspection) + ? null + : inspection ? '巡检报告(.studio/research/inspection-<日期>.md)' : '调研报告(.studio/research/)'; + } + default: + return null; // 代码类由闸 1 覆盖,其余类型无契约产物锚点 + } +} + /** 守卫链输入。metadata 必须是「持久化 + 本 step metadataUpdates」的合并视图(调用方构建): * 首个 step 的 worktreePath 等字段由 agentStep 经 result.metadataUpdates 传入、此刻尚未落库; * 只看持久化值会让首 step 的 COMPLETE 退到主仓库(干净)做检查而漏拦。 */ @@ -74,6 +140,10 @@ export interface CompletionGuardDeps { listUnfinishedChildren: (wuId: string) => Promise; hasUncommittedChanges?: (cwd: string) => boolean; readHeadHash?: (cwd: string) => string | null; + /** 收口闸 1: git rev-list --count ..HEAD(默认 execFileSync;失败返回 null = 静默跳过) */ + countBranchCommits?: (cwd: string, baseBranch: string) => number | null; + /** 收口闸 2: analysis 契约报告落盘检查(默认 fs 实现;inspection 变体只认 inspection-*.md) */ + analysisReportExists?: (workspaceRoot: string, inspection: boolean) => boolean; runVerification?: (wu: WorkUnitData, metadata: WorkUnitMetadata, worktreePath: string) => Promise; /** T7-E2(#161): harness 三纯函数(默认 = @dommaker/harness 静态导入,#425 去镜像; * 返回 null = 软观测段整体 fail-open 跳过) */ @@ -95,6 +165,10 @@ export interface CompletionGuardNotices { verifyPassed: string | null; /** B3b-i: verifyFailCount ≥3 → blocked 并频道说明 */ verifyBlocked: boolean; + /** 收口闸 1: diffEmptyCount ≥3 → blocked 并频道说明 */ + diffEmptyBlocked: boolean; + /** 收口闸 2: contractArtifactCount ≥3 → blocked 并频道说明 */ + contractArtifactBlocked: boolean; /** F6-c:本 step COMPLETE 守卫是否已跑过验证 —— 步骤超限强制收口路径据此避免重复跑 */ verifyGuardRan: boolean; } @@ -339,15 +413,19 @@ async function runSoftObservation( } /** - * 依次跑收口守卫(顺序即优先级,前三张任一降级后后续 COMPLETE 守卫不再触发): + * 依次跑收口守卫(顺序即优先级,前面的守卫任一降级后后续 COMPLETE 守卫不再触发): * 1. §10.5 提交守卫:COMPLETE + 未提交改动 → 降级 progress + commitGuardHint; * PROGRESS 无提交监视(lastCommitHash/noCommitSteps,≥3 → noCommit notice + 归零)。 * review WU 整体豁免(评审职责是读不是写);路径解析/git 失败一律静默跳过。 * 2. §6-2 子任务守卫:存在未完结子 WU → 降级 progress + childGuardHint。 - * 3. B3b-i 自动验证守卫:代码类 WU 有 worktreePath 才跑(runWuVerification); + * 3. 收口闸 1(产出实):代码类 COMPLETE 但 base..HEAD 无提交内容 → 降级 progress + diffEmptyHint, + * diffEmptyCount ≥3 → diffEmptyBlocked;有提交 → 计数归零。 + * 4. 收口闸 2(产出实):非代码类契约产物锚点缺失 → 降级 progress + contractArtifactHint, + * contractArtifactCount ≥3 → contractArtifactBlocked。 + * 5. B3b-i 自动验证守卫:代码类 WU 有 worktreePath 才跑(runWuVerification); * 失败 → verifyFailCount++/verifyFailHint/l1 rejected 台账/降级,≥3 → verifyBlocked; * 全绿 → verifyReport + l1 approved 台账 + verifyPassed 简报。 - * 4. T7-E2 软观测段:仅 action 仍为 complete 才跑;不降级不阻断,违规落台账 + + * 6. T7-E2 软观测段:仅 action 仍为 complete 才跑;不降级不阻断,违规落台账 + * processCheckHint(详见 runSoftObservation)。 */ export async function runCompletionGuards( @@ -365,6 +443,8 @@ export async function runCompletionGuards( noCommit: false, verifyPassed: null, verifyBlocked: false, + diffEmptyBlocked: false, + contractArtifactBlocked: false, verifyGuardRan: false, }; @@ -413,6 +493,56 @@ export async function runCompletionGuards( } } + // 收口闸 1(产出实):代码类 WU 声称完成但 base..HEAD 无任何提交内容 → 降级 progress + diffEmptyHint。 + // 提交守卫已保证工作区干净,这里判「有没有提交内容」——空 diff 不许进 in_review。 + // review 不在 CODE_WORKTREE_TYPES(diff-only 不改代码),天然不覆盖; + // 缺 worktreeBaseBranch / git 失败 → 静默跳过(基础设施故障不阻断完成)。 + // diffEmptyCount ≥3 → blocked(模式同 verifyFailCount)。 + if (action === 'complete' + && CODE_WORKTREE_TYPES.has(wu.type) + && typeof metadata.worktreePath === 'string' && metadata.worktreePath.length > 0 + && typeof metadata.worktreeBaseBranch === 'string' && metadata.worktreeBaseBranch.length > 0) { + const countCommits = deps.countBranchCommits ?? countBranchCommits; + let commitCount: number | null = null; + try { + commitCount = countCommits(metadata.worktreePath, metadata.worktreeBaseBranch); + } catch { + commitCount = null; + } + if (commitCount === 0) { + const failCount = (metadata.diffEmptyCount ?? 0) + 1; + guardUpdates.diffEmptyCount = failCount; + guardUpdates.diffEmptyHint = `你报告了完成,但相对 ${metadata.worktreeBaseBranch} 没有任何提交内容(第 ${failCount} 次)。请实现并 commit,或说明为何无需改动`; + action = 'progress'; + notices.diffEmptyBlocked = failCount >= 3; + logger.info(`[AgentLoop] Diff guard: COMPLETE downgraded for ${wuId} (no commits since ${metadata.worktreeBaseBranch}, count ${failCount})`); + } else if (commitCount !== null && (metadata.diffEmptyCount ?? 0) > 0) { + guardUpdates.diffEmptyCount = 0; // 有提交内容 → 计数归零(与 verifyFailCount 同模式) + } + } + + // 收口闸 2(产出实):非代码类 WU 契约产物锚点缺失 → 降级 progress + contractArtifactHint。 + // 锚点分派见 missingContractArtifact(review→reviewReport、plan→analysisTasks、spec→specTasks、 + // decision→decisionSuggestion、analysis→调研报告落盘);代码类不进本闸(由闸 1 覆盖)。 + // contractArtifactCount ≥3 → blocked(模式同 verifyFailCount)。 + if (action === 'complete') { + const reportExists = deps.analysisReportExists ?? hasAnalysisReport; + let missingAnchor: string | null = null; + try { + missingAnchor = missingContractArtifact(wu, metadata, reportExists); + } catch { + missingAnchor = null; // 检查故障静默跳过,绝不因基础设施故障阻断完成 + } + if (missingAnchor) { + const failCount = (metadata.contractArtifactCount ?? 0) + 1; + guardUpdates.contractArtifactCount = failCount; + guardUpdates.contractArtifactHint = `契约产物缺失:${missingAnchor}(第 ${failCount} 次)。请补齐产物后再报告完成`; + action = 'progress'; + notices.contractArtifactBlocked = failCount >= 3; + logger.info(`[AgentLoop] Contract-artifact guard: COMPLETE downgraded for ${wuId} (missing: ${missingAnchor}, count ${failCount})`); + } + } + // B3b-i(决策 D3 前半): COMPLETE 前自动验证 —— 仅代码类 WU(有专属 worktree)。 // 提交守卫/子任务守卫已通过(action 仍为 complete)才跑;命令解析:覆盖 > 约定(见 resolveVerifyCommands)。 // 全绿 → verifyReport 落档 + 频道简报;任一失败 → 降级 progress,失败命令+输出尾部注入下一轮 prompt, diff --git a/apps/api/src/modules/agents/loop/completion-harvest.ts b/apps/api/src/modules/agents/loop/completion-harvest.ts new file mode 100644 index 000000000..5de72327b --- /dev/null +++ b/apps/api/src/modules/agents/loop/completion-harvest.ts @@ -0,0 +1,122 @@ +/** + * per-WU-type COMPLETE 收割注册表(#542,2026-09-15 架构评审候选 A2) + * + * agentStep 成功路径的 WU 输出解析 → metadataUpdates 落档,从 agent-loop.ts 的 + * 长串 if 分支改为「type → 收割器组」注册表调度: + * - 新 WU type 收割 = 在 COMPLETION_HARVEST_REGISTRY 注册一行; + * - 注册表键 = wu.type 单键(开放问题 1 决议);变体走收割器内部读 ctx.metadata + * (inspection 标记),plan 与 analysis 共享同一收割器组(#471 一脉会话,契约不变); + * - 失败语义(开放问题 2 决议):单个收割器抛错仅 warn 跳过该收割器,其余收割器 + * 与整条收割链不受影响;解析器本身返回 null/[] 不抛(解析无获不落档、不阻断完成, + * 语义与各分支原实现逐一对应)。 + * + * 解析器全部复用现成品(agent-loop-parsers / pmo/map-opening / pmo/spec-materialization), + * 本模块只做调度与落档形状组装,不新增解析契约。 + */ + +import { logger } from '@dommaker/studio-shared'; +import type { WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import { + parseReviewReport, parseTaskBreakdown, parseOpportunities, parseDecisionConclusion, +} from './agent-loop-parsers.js'; +import { parseMapOpening } from '../../pmo/map-opening.js'; +import { parseSpecTasks } from '../../pmo/spec-materialization.js'; + +export interface CompletionHarvestContext { + wuId: string; + wuType: string; + /** 当前 metadata(变体判定用,如 inspection 标记) */ + metadata: WorkUnitMetadata; +} + +/** 收割器:从 WU 最终输出解析出 metadataUpdates 贡献(无获返回 {}) */ +export type CompletionHarvester = (outputText: string, ctx: CompletionHarvestContext) => Partial; + +/** review:REVIEW_RESULT → reviewReport(ReviewDispatcher 路径 B 判定的唯一数据源) */ +const reviewReportHarvester: CompletionHarvester = (outputText, ctx) => { + const report = parseReviewReport(outputText); + if (report) return { reviewReport: report }; + logger.warn(`[AgentLoop] Review WU ${ctx.wuId} completed without parseable REVIEW_RESULT — 由 ReviewDispatcher 转人工`); + return {}; +}; + +/** analysis/plan:TASK: 拆分行 → analysisTasks(analysis-handoff 派工数据源) */ +const analysisTasksHarvester: CompletionHarvester = (outputText) => { + const tasks = parseTaskBreakdown(outputText); + return tasks.length > 0 ? { analysisTasks: tasks } : {}; +}; + +/** analysis/plan:FOG:/DESTINATION: 行 → analysisFog/analysisDestination(确认弹窗预填) */ +const mapOpeningHarvester: CompletionHarvester = (outputText) => { + const opening = parseMapOpening(outputText); + if (opening.fog.length === 0) return {}; + return { + analysisFog: opening.fog, + ...(opening.destination ? { analysisDestination: opening.destination } : {}), + }; +}; + +/** analysis/plan 巡检变体(metadata.inspection===true):OPPORTUNITY: 行 → opportunities */ +const inspectionOpportunitiesHarvester: CompletionHarvester = (outputText, ctx) => { + if (ctx.metadata.inspection !== true) return {}; + const opps = parseOpportunities(outputText); + if (opps.length === 0) return {}; + return { + opportunities: opps.map((o, i) => ({ + id: `opp-${i + 1}`, + ...o, + status: 'pending' as const, + })), + }; +}; + +/** decision:`## 结论摘要` 段 → decisionSuggestion(确认弹窗预填 agent 建议结论) */ +const decisionSuggestionHarvester: CompletionHarvester = (outputText) => { + const suggestion = parseDecisionConclusion(outputText); + return suggestion ? { decisionSuggestion: suggestion } : {}; +}; + +/** spec:TASK: 物化行 → specTasks(确认弹窗卡片墙预填,spec-materialization 同一解析器) */ +const specTasksHarvester: CompletionHarvester = (outputText) => { + const specTasks = parseSpecTasks(outputText); + return specTasks.length > 0 ? { specTasks } : {}; +}; + +/** analysis 与 plan 共享的收割器组(#471:plan 沿用 analysis 字段名与解析契约) */ +const ANALYSIS_HARVESTERS: CompletionHarvester[] = [ + analysisTasksHarvester, + mapOpeningHarvester, + inspectionOpportunitiesHarvester, +]; + +/** + * 收割注册表:wu.type → 收割器组。新 WU type 收割 = 注册一行。 + * 同 type 多收割器按序执行,结果合并(后写覆盖先写——同 type 内各收割器落不同字段, + * 正常无覆盖)。 + */ +export const COMPLETION_HARVEST_REGISTRY: Record = { + review: [reviewReportHarvester], + analysis: ANALYSIS_HARVESTERS, + plan: ANALYSIS_HARVESTERS, + decision: [decisionSuggestionHarvester], + spec: [specTasksHarvester], +}; + +/** + * 调度入口:按 wu.type 查注册表,逐收割器执行并合并 metadataUpdates。 + * 单个收割器抛错仅 warn 跳过(收割是落档增强,绝不让解析异常打断 agentStep 成功路径); + * 未注册 type → {}。 + */ +export function harvestCompletionMetadata(outputText: string, ctx: CompletionHarvestContext): Partial { + const harvesters = COMPLETION_HARVEST_REGISTRY[ctx.wuType]; + if (!harvesters) return {}; + const updates: Partial = {}; + for (const harvester of harvesters) { + try { + Object.assign(updates, harvester(outputText, ctx)); + } catch (err) { + logger.warn(`[AgentLoop] completion harvest failed for WU ${ctx.wuId} (type=${ctx.wuType}): ${err instanceof Error ? err.message : String(err)}`); + } + } + return updates; +} diff --git a/apps/api/src/modules/agents/loop/prompt-composer.ts b/apps/api/src/modules/agents/loop/prompt-composer.ts index 5c81e6827..8c159962d 100644 --- a/apps/api/src/modules/agents/loop/prompt-composer.ts +++ b/apps/api/src/modules/agents/loop/prompt-composer.ts @@ -67,6 +67,22 @@ export const MAP_SUMMARY_MAX_CHARS = 160; /** #95:waitingQuestion 仅新会话回放的截断字符上限 */ export const WAITING_QUESTION_REPLAY_MAX_CHARS = 300; +/** + * #567:plan 方向锁定契约段(插在裁决轮段之前)——存在互斥大方向时出一次方向卡 + * (NEED_INPUT + 紧随一行 DIRECTION: JSON),人锁定方向后同会话继续,结论落探路台账; + * 解析见 agent-loop-parsers parseDirectionLine,提交链见 pmo/plan-direction.ts。 + */ +export const PLAN_DIRECTION_SECTION = [ + '方向锁定(存在互斥大方向时出一次,在裁决轮之前):输出', + ' ACTION: NEED_INPUT:方向锁定——请选定本票方向', + ' 紧随一行 DIRECTION: {"question":"<方向抉择点>","options":[{"name":"<方向名>","summary":"<一句话>","tradeoffs":"<取舍说明>","impact":"<影响面:触及哪些模块/票>","recommended":true|false}]}', + ' 候选 2~3 个(至多 4),recommended 恰好一个;人选定后同一会话继续,方向结论落探路台账。', + '无互斥方向(只有一条合理路径)或小需求快道:不出方向锁定,直接进裁决轮/成文拆单。', +].join('\n'); + +/** #567:metadata.directionPick === false 时方向锁定段的替换提示(buildContractSection 裁剪) */ +export const PLAN_DIRECTION_DISABLED_HINT = '本单已关闭方向锁定(派单时显式关闭):跳过方向锁定,直接进裁决轮/成文拆单。'; + /** * #119:契约段按 WU type 的产出格式 + 最小模板(内容定稿随 #118 续烤迭代,先落最简模板)。 * review → REVIEW_RESULT 协议行;implement → 测试先行 + Phase commit 格式; @@ -74,17 +90,22 @@ export const WAITING_QUESTION_REPLAY_MAX_CHARS = 300; * + bug 路由规则与升级触发器(#121);bug → 复现测试先行 + 防回归测试随修复同 commit(#121)。 * #163(T8-E2):analysis + metadata.inspection===true → 巡检契约(INSPECTION_CONTRACT, * 优先级高于 analysis 通用模板)。 - * 未列出的 type(task/feature/spec 等)→ 空段(不注入)。 + * 各类型契约段尾附「本环节标准打法」指引行(implement→tdd-implement、bug→diagnosing-bugs、 + * plan→requirement-clarify + to-tickets、analysis→research、review→code-review), + * 指向 skills 段索引按需 loadSkill 读全文;decision/spec/巡检变体无对应打法 skill,不加。 + * 未列出的 type(task/feature 等)→ 空段(不注入)。 */ export const CONTRACT_TEMPLATES: Record = { review: [ '完成审查后,除 ACTION 行外,还必须在输出的最后一行给出结构化结论:', 'REVIEW_RESULT: {"verdict":"pass"|"reject"|"needs-info","summary":"一句话结论","issues":[{"severity":"error"|"warn"|"info","message":"问题描述"}]}', '(verdict=pass 通过 / reject 打回 / needs-info 上下文不足转人工;缺少该行将转人工评审。)', + '本环节标准打法:code-review(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), implement: [ '测试先行:先写失败测试(RED)再实现到测试全绿(GREEN),测试全绿后才算完成。', 'Phase commit:按阶段分批提交,commit message 用 `phase(<阶段名>): <摘要>` 格式。', + '本环节标准打法:tdd-implement(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), decision: [ '结论摘要格式:输出末段 `## 结论摘要`,用一句话给出待决问题的结论与理由。', @@ -99,9 +120,12 @@ export const CONTRACT_TEMPLATES: Record = { // #471:plan(一脉会话规划单)契约——澄清→调研→裁决→成文→拆单同会话完成; // 输出协议行是 agent-loop COMPLETE 解析与 #463 确认弹窗预填的数据源(契约单一来源) // #467:裁决轮协议(RULING 行)——fog 调研齐后向人出一次裁决卡,不逐题问人 + // #567:方向锁定协议(DIRECTION 行)——裁决轮前置可选环节,directionPick:false 时 + // 由 buildContractSection 替换为关闭提示 plan: [ '一脉会话(详见 skills 段 requirement-clarify / to-tickets 全文):澄清 → fog 调研(仓外调研派 DELEGATE research 子单)→ 裁决轮 → spec 成文落业务仓 .studio/specs/ → 拆任务清单,全程不换会话。', '会话中断恢复:prompt「探路地图」段的台账(目的地 + 待决 + 已裁决结论)是唯一恢复事实源,以台账续跑,不整单重来。', + PLAN_DIRECTION_SECTION, '裁决轮(fog 调研齐后出一次,不逐题问人):输出', ' ACTION: NEED_INPUT:裁决轮——N 个待决问题请一次性裁决', ' 紧随逐行 RULING: {"question":"<待决问题>","suggestion":"<建议结论>","default":"<人不答时的默认值,可省>"}', @@ -111,6 +135,7 @@ export const CONTRACT_TEMPLATES: Record = { ' TASK: <任务描述>(3~8 条,可被独立认领完成;人工确认后按清单自动派工)', ' FOG: <待决问题>(探路型才输出,至多 12 条;确认后入探路台账,不再单独立决策单)', ' DESTINATION: <一句话目标>(可选,缺省用项目标题)', + '本环节标准打法:requirement-clarify + to-tickets(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), analysis: [ '方法论二选一(详见 skills 段 research / prototype 全文):', @@ -118,11 +143,13 @@ export const CONTRACT_TEMPLATES: Record = { 'prototype → 一次性代码落 prototype/ 分支(不合并、不进评审),结论(回答了什么问题)记录回工单。', 'bug 路由(#121):bug 默认快速路(不开图不写成文单),落任务单走「诊断→复现→修复→防回归」(diagnosing-bugs)。', '升级触发器:根因在需求/设计层 → 转决策单或开图;诊断事实(复现命令/已排除假设/定位结论)随票携带。', + '本环节标准打法:research(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), // #121:bug 类契约段模板——复现测试先行(FAIL 复现 bug → 修复 → GREEN),防回归测试随修复同 commit bug: [ '复现测试先行:先写 FAIL 的复现测试(断言报告的症状,确认变红),再修复到 GREEN。', '防回归测试随修复同 commit:复现测试最小化后即为防回归测试,与修复代码同一个 commit 提交。', + '本环节标准打法:diagnosing-bugs(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), }; @@ -202,7 +229,7 @@ export interface ComposedStepPrompt { /** * 组装本 step 的 prompt 与注入上下文: - * 1. hint 读取(pendingReplies / commitGuardHint / verifyFailHint / childGuardHint / processCheckHint,注入后即消费); + * 1. hint 读取(pendingReplies / commitGuardHint / verifyFailHint / childGuardHint / processCheckHint / diffEmptyHint / contractArtifactHint,注入后即消费); * 2. base prompt:pendingReplies > target.newReplies > continue; * 3. 三类 guard hint 段注入; * 4. 注入段分段软定额 + 池内余量共享(#91),稳定前缀序 persona > roster > skills > map > memory > knowledge > files(#285), @@ -241,6 +268,16 @@ export async function composeStepPrompt( ? metadata.processCheckHint : null; + // 收口闸 1(产出实):上一轮 COMPLETE 因空 diff(base..HEAD 无提交内容)被打回时的提示(注入后即消费) + const diffEmptyHint = typeof metadata.diffEmptyHint === 'string' && metadata.diffEmptyHint.length > 0 + ? metadata.diffEmptyHint + : null; + + // 收口闸 2(产出实):上一轮 COMPLETE 因契约产物缺失被打回时的提示(注入后即消费) + const contractArtifactHint = typeof metadata.contractArtifactHint === 'string' && metadata.contractArtifactHint.length > 0 + ? metadata.contractArtifactHint + : null; + // #95: 仅新会话回放 waitingQuestion(截 300 字符)并入人类回复段——断链后 agent 忘了自己提过什么问题 const replyTexts = pendingReplies.length > 0 && ctx.isNewSession === true @@ -259,6 +296,8 @@ export async function composeStepPrompt( verifyFailHint ? `## 验证失败\n\n${verifyFailHint}` : null, childGuardHint ? `## 子任务提醒\n\n${childGuardHint}` : null, processCheckHint ? `## 过程检查提醒\n\n${processCheckHint}` : null, + diffEmptyHint ? `## 提交内容提醒\n\n${diffEmptyHint}` : null, + contractArtifactHint ? `## 契约产物提醒\n\n${contractArtifactHint}` : null, ].filter((s): s is string => s !== null).join('\n\n'); // #91: 分段软定额 + 池内余量共享 —— 段有效预算 = 定额 + 池;用量差额回流池中。 @@ -408,6 +447,14 @@ export async function composeStepPrompt( // T7-E2: 提示已注入 prompt,清除避免后续步骤重复注入 consumedHintUpdates.processCheckHint = undefined; } + if (diffEmptyHint) { + // 收口闸 1: 提示已注入 prompt,清除避免后续步骤重复注入 + consumedHintUpdates.diffEmptyHint = undefined; + } + if (contractArtifactHint) { + // 收口闸 2: 提示已注入 prompt,清除避免后续步骤重复注入 + consumedHintUpdates.contractArtifactHint = undefined; + } return { prompt, pendingReplies, knowledgeContext, skillMatched, injectedKnowledgeIds, consumedHintUpdates }; } @@ -558,9 +605,13 @@ function buildFilesSection(metadata: WorkUnitMetadata, tokenBudget: number): Bui */ function buildContractSection(wu: WorkUnitData, metadata: WorkUnitMetadata, tokenBudget: number): BuiltSection { // #163(T8-E2):巡检单契约优先于 analysis 通用模板 - const template = (wu.type === 'analysis' && metadata.inspection === true) + let template = (wu.type === 'analysis' && metadata.inspection === true) ? INSPECTION_CONTRACT : CONTRACT_TEMPLATES[wu.type]; + // #567:plan + directionPick:false → 方向锁定段替换为关闭提示(INSPECTION_CONTRACT 变体先例的轻量版) + if (wu.type === 'plan' && metadata.directionPick === false && template) { + template = template.replace(PLAN_DIRECTION_SECTION, PLAN_DIRECTION_DISABLED_HINT); + } if (!template || tokenBudget <= 0) return { section: '', tokens: 0, originalTokens: 0 }; const full = `## 产出契约\n\n${template}`; diff --git a/apps/api/src/modules/agents/loop/review-dispatcher.ts b/apps/api/src/modules/agents/loop/review-dispatcher.ts index 4085e620d..131b645d0 100644 --- a/apps/api/src/modules/agents/loop/review-dispatcher.ts +++ b/apps/api/src/modules/agents/loop/review-dispatcher.ts @@ -29,6 +29,17 @@ import { parseWuMetadata, clearSessionBookkeeping } from '../../workunit/wu-meta import { resolveOrNotice } from '../../channels/routing.js'; import type { ParsedReviewReport } from './review-contract.js'; +/** + * P7(2026-09-16 perf 实测):自动评审总开关,默认开。无凭证/fake-provider 环境 + * 置 `STUDIO_AUTO_REVIEW=false` 停建评审子 WU——否则子单被真实 CLI 角色认领空转 + * (无凭证 exit 1 ×3 → blocked + 频道刷屏「执行失败」)。 + * 只闸两条自动路径(事件链 handleParentInReview + 对账 redispatchReview); + * 人工补派 dispatchReviewNow 不闸(人显式发起)。 + */ +function autoReviewEnabled(): boolean { + return process.env.STUDIO_AUTO_REVIEW !== 'false'; +} + export class ReviewDispatcher { private subscribed = false; /** #228 测试可观测性(纯增量):在途事件链登记,见 waitForSettled */ @@ -108,6 +119,7 @@ export class ReviewDispatcher { /** 父 WU 进入 in_review 时的处理 */ private async handleParentInReview(parent: WorkUnitData): Promise { if (!parent.channelId) return; + if (!autoReviewEnabled()) return; // P7:自动评审关停(fake/无凭证环境) // #170(决策 #65-2):同父唯一性检查收进建单的同一把 workunits flock // (锁内 check-then-create),并发/多实例事件链下不会重复建单; @@ -124,6 +136,7 @@ export class ReviewDispatcher { */ async redispatchReview(parent: WorkUnitData): Promise { if (!parent.channelId) return null; + if (!autoReviewEnabled()) return null; // P7:自动评审关停(对账重派同口径) return this.createReviewChildFor(parent, { silent: true }); } diff --git a/apps/api/src/modules/agents/loop/step-guards.ts b/apps/api/src/modules/agents/loop/step-guards.ts new file mode 100644 index 000000000..ea08b9aae --- /dev/null +++ b/apps/api/src/modules/agents/loop/step-guards.ts @@ -0,0 +1,177 @@ +/** + * 入口守卫链(#541,2026-09 从 agent-loop.agentStep 头部原样抽出,行为一字不改, + * 对称补齐出口侧 completion-gates):agentStep 的四段「该不该跑这一步」前置判定 —— + * B2 测试特征 WU 守卫 → C3 日 token 预算熔断 → #162 WU 级 token 预算熔断 → #471 plan 步数额度熔断。 + * 顺序即优先级:首个命中的守卫短路返回 StepResult,后续守卫不再执行(与原内联行为一致)。 + * + * 职责边界: + * - 本模块 = 守卫政策(guard policy):判定/短路结果/B2 留痕副作用的调用编排。 + * - agent-loop.agentStep = 编排:构建 ctx → 调 runStepGuards → 命中即返回, + * 放行则继续 traceId/频道版本/worktree/会话签发/prompt 组装。 + * + * 可测试性:B2 副作用(WU 写盘/关闭/频道通知)与事件流路径经 deps 注入(agent-loop 绑定 + * workUnitService/postToDiscussionSpace/studioEventsJsonlPath);守卫开关与外部数据源 + * (日预算用量/告警)有生产默认实现,单测整体注入伪实现,无需 vi.mock 模块工厂。 + */ + +import { logger } from '@dommaker/studio-shared'; +import { PLAN_STEP_LIMIT } from '../../workunit/workunit.types.js'; +import type { WorkUnitData, WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import type { StepResult } from './agent-loop.types.js'; +import { testWuGuardEnabled, isTestLikeWorkUnit } from './agent-loop-guards.js'; +import { + tokenBudgetGuardEnabled, resolveDailyTokenBudget, getDailyTokenUsage, + notifyBudgetTripped, type DailyTokenUsage, +} from './daily-token-budget.js'; + +/** 守卫链输入:wu + 已解析的 metadata(agentStep 入口 parseWuMetadata 的同一对象) */ +export interface StepGuardCtx { + wu: WorkUnitData; + metadata: WorkUnitMetadata; +} + +/** 守卫链外部依赖。前四项为编排层绑定(agent-loop 注入),无默认实现; + * 其余为开关/数据源,默认 = 生产实现,单测整体注入伪实现。 */ +export interface StepGuardDeps { + /** B2: WU metadata 写盘留痕(agent-loop 注入 workUnitService.update(id, { metadata })) */ + updateWuMetadata: (wuId: string, metadata: WorkUnitMetadata) => Promise; + /** B2: 关闭 WU(agent-loop 注入 workUnitService.transitionStatus(id, 'closed')) */ + closeWu: (wuId: string) => Promise; + /** B2: 频道留痕(agent-loop 注入 postToDiscussionSpace) */ + postNotice: (wuId: string, text: string) => Promise; + /** C3: 事件流文件路径(agent-loop 注入 studioEventsJsonlPath,惰性解析 env 覆盖) */ + eventsFilePath: () => string; + /** B2 守卫开关(默认读 process.env;测试环境默认关闭,STUDIO_TEST_WU_GUARD 覆盖) */ + testWuGuardEnabled?: () => boolean; + /** B2 测试特征判定(metadata 显式标记或 scope 命中测试名单模式) */ + isTestLikeWorkUnit?: (wu: { scope: string }, metadata: WorkUnitMetadata) => boolean; + /** C3 守卫开关(默认读 process.env;STUDIO_TOKEN_BUDGET_GUARD 覆盖) */ + tokenBudgetGuardEnabled?: () => boolean; + /** C3: 每日预算(STUDIO_DAILY_TOKEN_BUDGET 覆盖;<=0 = 不熔断) */ + resolveDailyTokenBudget?: () => number; + /** C3: 当日已耗查询(进程内计数器 + 首查/跨天全量扫) */ + getDailyTokenUsage?: (opts: { eventsFile: string }) => Promise; + /** C3: 当日首次熔断告警(budget-tripped 事件留痕 + 通知出口) */ + notifyBudgetTripped?: (opts: { eventsFile: string; usedTokens: number; budget: number }) => Promise; +} + +export interface StepGuardOutcome { + /** 非 null = 守卫短路:agentStep 直接返回该 StepResult;null = 放行继续执行 */ + result: StepResult | null; +} + +/** + * 依次跑入口守卫(顺序即优先级,首个命中即短路): + * 1. B2 测试特征 WU 守卫:不起会话、直接关闭(留痕 testWorkUnitGuard + blockReason + 频道通知)。 + * 2. C3 日 token 预算熔断:当日 billed 消耗 ≥ 预算 → need_input 挂起(次日零点复位或人工处置); + * 全局当日只告警一次(budget-tripped 事件留痕)。 + * 3. #162 WU 级 tokenBudget 熔断:metadata.tokenBudget 显式数值在场即生效(不吃 C3 开关), + * 超线 → need_input 挂起 + waitingReason='wu-token-budget'(人三选:追加预算/收尾/放弃)。 + * 4. #471 plan 步数额度熔断:stepCount ≥ planStepAllowance(默认 PLAN_STEP_LIMIT)→ + * need_input 挂 blocked 转人 + waitingReason='plan-step-limit'(人回复即续期)。 + */ +export async function runStepGuards( + ctx: StepGuardCtx, + deps: StepGuardDeps, +): Promise { + const { wu, metadata } = ctx; + const testGuardOn = deps.testWuGuardEnabled ?? (() => testWuGuardEnabled()); + const isTestLike = deps.isTestLikeWorkUnit ?? isTestLikeWorkUnit; + const budgetGuardOn = deps.tokenBudgetGuardEnabled ?? (() => tokenBudgetGuardEnabled()); + const dailyBudgetOf = deps.resolveDailyTokenBudget ?? (() => resolveDailyTokenBudget()); + const dailyUsage = deps.getDailyTokenUsage ?? getDailyTokenUsage; + const notifyTripped = deps.notifyBudgetTripped ?? notifyBudgetTripped; + + // B2 守卫(2026-08-03 token-burn issue P0-1c):测试特征 WU 不起会话、直接关闭。 + // 历史事故:路由测试经共享数据根把测试 WU 写进生产 FileStore,daemon 当真任务逐个 + // 起 Claude 会话执行(16 个会话 420 万 token)。关闭留痕 testWorkUnitGuard + blockReason。 + if (testGuardOn() && isTestLike(wu, metadata)) { + logger.warn('[AgentLoop] Test-like WorkUnit guarded — closing without execution', { + workUnitId: wu.id, scope: wu.scope, + }); + await deps.updateWuMetadata(wu.id, { + ...metadata, testWorkUnitGuard: true, blockReason: 'test-wu-guard: 测试特征任务,守卫关闭', + }).catch(err => logger.warn('[AgentLoop] test-wu guard metadata write failed', { workUnitId: wu.id, error: String(err) })); + if (wu.status !== 'closed') { + await deps.closeWu(wu.id) + .catch(err => logger.warn('[AgentLoop] test-wu guard close failed', { workUnitId: wu.id, error: String(err) })); + } + await deps.postNotice(wu.id, '检测到测试特征任务,已跳过执行并关闭(防止测试数据空烧 token)') + .catch(() => {}); + return { result: { action: 'skipped', summary: '' } }; + } + + // C3 守卫(2026-08-03 token-burn issue P2-2,决策记录 #4):每日 token 预算熔断。 + // 当日 billed 口径消耗 ≥ 预算(默认 2M/日,STUDIO_DAILY_TOKEN_BUDGET 覆盖,<=0 关闭)→ + // 不起会话,WU 经 need_input 挂起(recordResult 落 waitingForInput + blockReason), + // 等次日本地零点预算复位或人工处置;全局当日只告警一次(studio:budget-tripped 事件留痕)。 + // 用量走进程内计数器(daily-token-budget),仅首次/跨天全量扫一次事件文件,不拖慢热路径。 + if (budgetGuardOn()) { + const dailyBudget = dailyBudgetOf(); + if (dailyBudget > 0) { + const eventsFile = deps.eventsFilePath(); + const daily = await dailyUsage({ eventsFile }); + if (daily.usedTokens >= dailyBudget) { + logger.warn('[AgentLoop] Daily token budget tripped — pausing automatic execution', { + workUnitId: wu.id, usedTokens: daily.usedTokens, budget: dailyBudget, + }); + if (!daily.notified) { + await notifyTripped({ eventsFile, usedTokens: daily.usedTokens, budget: dailyBudget }); + } + return { + result: { + action: 'need_input' as const, + summary: `每日 token 预算已熔断(当日已用 ${daily.usedTokens.toLocaleString()} / 上限 ${dailyBudget.toLocaleString()},billed 口径含 cache_read):已暂停自动执行、不再起会话。次日(本地零点)预算复位后回复任意内容继续,或直接关闭任务`, + }, + }; + } + } + } + // #162(T8-E1,#130 决策 3):WU 级 token 预算熔断。metadata.tokenBudget 显式数值 + // (任何类型 WU 可带,与日预算无关、不吃 STUDIO_TOKEN_BUDGET_GUARD 开关——字段在场即生效), + // 对照 metadata._cumulativeTokens(billed 口径簿记,与日预算同口径)。超线复用日预算同款 + // need_input 挂起路径(recordResult 落 waitingForInput + blockReason),不新造状态; + // waitingReason='wu-token-budget' 供 waiting-input 人三选分流(追加预算/收尾/放弃)。 + // 人读面说人话:提示文案不出现 WU/metadata/闸/熔断等机制黑话。 + if (typeof metadata.tokenBudget === 'number' && Number.isFinite(metadata.tokenBudget) && metadata.tokenBudget > 0) { + const wuBudget = Math.floor(metadata.tokenBudget); + const wuUsed = metadata._cumulativeTokens ?? 0; + if (wuUsed >= wuBudget) { + logger.warn('[AgentLoop] WU token budget reached — suspending for human decision', { + workUnitId: wu.id, usedTokens: wuUsed, budget: wuBudget, + }); + return { + result: { + action: 'need_input' as const, + summary: `这项任务已消耗 ${wuUsed.toLocaleString()} token,达到为它设定的上限 ${wuBudget.toLocaleString()},已暂停等你决定。回复:「追加预算」在上限之上再加 ${wuBudget.toLocaleString()} 继续执行;「追加预算 <数值>」把上限改为指定数值;「收尾」用现有产出提交审查;「放弃」结束任务`, + metadataUpdates: { waitingReason: 'wu-token-budget' }, + }, + }; + } + } + // #471(Triage 定稿 1):plan 步数额度熔断。一脉会话承载全规划链,额度高于 + // implement(PLAN_STEP_LIMIT=60,常量与语义见 workunit.types.ts)。到线不走 + // recordResult 的强制收口 in_review(plan 已从中豁免)——前置守卫在此转 + // need_input 挂 blocked 转人,不静默截断;人回复即续期(waiting-input 给 + // planStepAllowance 加一份 PLAN_STEP_LIMIT,复活回 active 续跑)。 + if (wu.type === 'plan') { + const allowance = typeof metadata.planStepAllowance === 'number' && Number.isFinite(metadata.planStepAllowance) && metadata.planStepAllowance > 0 + ? Math.floor(metadata.planStepAllowance) + : PLAN_STEP_LIMIT; + const planSteps = metadata.stepCount ?? 0; + if (planSteps >= allowance) { + logger.warn('[AgentLoop] Plan step limit reached — suspending for human decision', { + workUnitId: wu.id, stepCount: planSteps, allowance, + }); + return { + result: { + action: 'need_input' as const, + summary: `这次规划已推进 ${planSteps} 步,达到为它设定的步数额度 ${allowance},已暂停等你决定。回复任意内容(或「继续」)即续期 ${PLAN_STEP_LIMIT} 步接着跑;想收尾可在 Web 端点「通过」进入人工确认`, + metadataUpdates: { waitingReason: 'plan-step-limit' }, + }, + }; + } + } + + return { result: null }; +} diff --git a/apps/api/src/modules/agents/loop/step-retry-policy.ts b/apps/api/src/modules/agents/loop/step-retry-policy.ts new file mode 100644 index 000000000..db3dcbc34 --- /dev/null +++ b/apps/api/src/modules/agents/loop/step-retry-policy.ts @@ -0,0 +1,215 @@ +/** + * 步执行重试策略(#543,2026-09 架构评审 A3):收编 agentStep 中段两段孪生重试骨架 —— + * #94「续用丢失降级」(续用步报「会话不存在」→ 换发新 sessionId 按新建重试一次)与 + * #96「上下文溢出重试」(溢出错误 → 滚动摘要落盘 → 新会话带摘要重试一次 → 再败 NEED_INPUT)。 + * 两段原各写一遍「配额判定 → 改参 → 重算 prompt → 再执行 → 成败分叉 → 簿记回写」, + * 本模块收敛为 runStepRetry 一份;差异(触发条件/prompt 重算参数/再败的 terminal 形状) + * 以 StepRetryKind 参数化。 + * + * 职责边界: + * - 本模块 = 重试政策(retry policy):触发判定(matchStepRetry)、会话配额占额、 + * 会话簿记回写、重试 task 改写与再执行的调用编排。 + * - agent-loop.agentStep = 编排:首次执行失败 → 调 runStepRetry → terminal 直接返回 / + * retried 改写 result 走正常成功路径 / no-retry 走既有 failed 路径。 + * + * 可测试性:executor/prompt 重算/记账/失败事件全部经 deps 注入(同 step-guards 的 + * Ctx/Deps/Outcome 模式),单测 fake executor 直测,无需整类构造 AgentLoop。 + * 重试复用 #94 设计:同一 task 对象原地改 parameters/prompt 再执行。 + */ + +import { randomUUID } from 'crypto'; +import { logger } from '@dommaker/studio-shared'; +import type { AgentTask, ExecutionResult } from '@dommaker/studio-agent'; +import type { WorkUnitData, WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import type { StepResult } from './agent-loop.types.js'; +import { RESUME_FAILURE_RE } from './session-resume.js'; +import { isContextOverflowError, buildRollingSummary, OVERFLOW_SUMMARY_HEADER } from './context-overflow.js'; + +/** B5(2026-08-03 token-burn issue P1-1):每 WU 独立会话数上限(#95 由 2 放宽到 5)。 + * 会话反复重建(stuck 重开 / token 截断重开)意味着整段 transcript 全文重放重新烧一遍; + * 超限说明自动执行已失控,转 need_input 等人工评估(#94 起人工回复不再重置预算—— + * 复活后凭 metadata.sessionId 优先续用旧会话,见 waiting-input.ts)。 + * #95: 失败/超时的会话建立尝试计入预算(resetUnestablishedSession 不再清 sessionCount)。 + * #543: 常量从 agent-loop 迁入本模块(重试配额判定的真属主),agent-loop 新建签发复用。 */ +export const MAX_SESSIONS_PER_WU = 5; + +/** 重试种类:session-resume-lost = #94 续用丢失降级;context-overflow = #96 溢出重试 */ +export type StepRetryKind = 'session-resume-lost' | 'context-overflow'; + +/** + * 重试触发判定(纯函数)。顺序即优先级: + * 1. 续用步(resumeSessionId 在场)+「会话不存在」类错误 → session-resume-lost。 + * 非续用步报同款错误不触发降级(会话本就不是续用目标,无链可断)。 + * 2. 溢出类错误(续用/新建步皆可)→ context-overflow。 + * 其余错误(超时/业务失败)→ null,走既有 failed 路径;catch 分支(spawn 异常)不进本函数。 + */ +export function matchStepRetry(input: { resumeSessionId: string | null; detail: string }): StepRetryKind | null { + if (input.resumeSessionId && RESUME_FAILURE_RE.test(input.detail)) return 'session-resume-lost'; + if (isContextOverflowError(input.detail)) return 'context-overflow'; + return null; +} + +/** 重试策略输入。task 与 metadataUpdates 为原地改写对象(#94 设计 + 簿记断言点)。 */ +export interface StepRetryCtx { + wu: WorkUnitData; + metadata: WorkUnitMetadata; + /** profile provider(registry id)——仅 claude 给 CLI 传新 sessionId(kimi/codex/opencode 续用语义不传) */ + provider: string; + /** 首次执行的 task(重试时原地改 parameters.sessionId/sessionResume 与 prompt 后再执行) */ + task: AgentTask; + /** 首次失败结果(budget-exceeded terminal 的 emitFailedStep 携带) */ + firstResult: ExecutionResult; + /** 首次失败 detail(已截 500 字符) */ + detail: string; + /** 本步前已用会话数(metadata.sessionCount ?? 有 sessionId 按 1 计,agent-loop 口径) */ + sessionsUsed: number; + /** 本步续用目标(null = 新建步) */ + resumeSessionId: string | null; + /** 本步簿记增量(原地改写:占额 sessionCount / 回写 sessionId/lastSessionResumed/sessionSummary) */ + metadataUpdates: Partial; + /** P0 修复 6 traceId 透传(#552):与首败日志同一字段,串联重试段日志;无 trace 链路可缺省 */ + traceId?: string; +} + +/** 重试策略外部依赖(agent-loop 绑定注入;单测整体注入伪实现)。 */ +export interface StepRetryDeps { + /** 再执行(agent-loop 注入 this.executor.execute;fake executor 注入形状 = 本签名) */ + execute: (task: AgentTask) => Promise; + /** prompt 重算(agent-loop 注入 composeStepPrompt 的同款 ctx/deps 调用,只回传 prompt 文本) */ + recomposePrompt: (opts: { isNewSession: boolean }) => Promise; + /** B6 token 记账(仅重试再败时调用;重试成功由 agent-loop 成功路径统一记账) */ + recordTokenEvent: (res: ExecutionResult) => unknown; + /** #90 failure outcome 落盘(agent-loop 绑死 success=false + errorType='execution_failed' + 注入知识 ids) */ + recordFailureOutcome: (detail: string) => void; + /** #172 失败步 execution_step 事件(agent-loop 闭包,读取 effectiveSessionId/sessionResumed 当前值) */ + emitFailedStep: (action: 'failed' | 'need_input', detail: string, res?: ExecutionResult) => void; + /** 新会话号签发(默认 randomUUID;单测注入定值) */ + newSessionId?: () => string; + /** 会话配额上限(默认 MAX_SESSIONS_PER_WU;单测可注入小值) */ + maxSessionsPerWu?: number; +} + +export type StepRetryOutcome = + /** 未触发重试:agent-loop 走既有 failed 路径 */ + | { kind: 'no-retry' } + /** 重试成功:agent-loop 以 result 替换首败结果、sessionId 改写有效会话号、sessionResumed=false,走正常成功路径 */ + | { kind: 'retried'; result: ExecutionResult; sessionId: string } + /** 重试序终结(配额满 / 再败):agentStep 直接返回该 StepResult */ + | { kind: 'terminal'; result: StepResult }; + +/** + * 首 step(新建会话)执行失败 / 降级重试仍失败时重置会话簿记:CLI 会话未必已建立 + * (可能根本没 spawn 到),不重置则下一步按续用发 `--resume <从未建立的 id>` + * (claude 必报 "No conversation found")。续用 step 失败不调用 —— 会话已存在, + * 保留下一步继续 resume。 + * #95: sessionCount 不清除 —— 失败/超时的会话建立尝试计入预算(超限转 need_input)。 + * #543: 从 agent-loop 私有方法提为模块级纯函数(重试再败路径的簿记断言点), + * agent-loop 的 resetUnestablishedSession 委托本函数(单一正本)。 + */ +export function resetUnestablishedSessionBookkeeping(metadataUpdates: Partial): void { + delete metadataUpdates.sessionId; + delete metadataUpdates.lastSessionResumed; +} + +/** + * 重试序统一骨架(两段孪生块的收敛体): + * 触发判定 → [溢出:摘要先落盘] → 配额判定(满 → terminal need_input,不起新会话)→ + * 签发新号 + task 改新建形态 + sessionCount 计入 → prompt 重算(降级 isNewSession:true; + * 溢出 isNewSession:false + 追加摘要段)→ 再执行 → + * 再败(重置簿记 + 记账 + failure outcome + failed 步事件 → terminal;降级=failed,溢出=need_input)/ + * 成功(簿记落新号 → retried)。 + */ +export async function runStepRetry(ctx: StepRetryCtx, deps: StepRetryDeps): Promise { + const kind = matchStepRetry({ resumeSessionId: ctx.resumeSessionId, detail: ctx.detail }); + if (!kind) return { kind: 'no-retry' }; + + const { wu, metadata, task, metadataUpdates } = ctx; + const maxSessions = deps.maxSessionsPerWu ?? MAX_SESSIONS_PER_WU; + + // #96: 溢出先落滚动摘要(配额满转人工时也保留供人工参考);来源 = wu.scope + progressLog, + // 不递归摘要、不建语义搜索。摘要字段与 buildRollingSummary 单一正本在 context-overflow.ts。 + let overflowSummary: string | null = null; + if (kind === 'context-overflow') { + overflowSummary = buildRollingSummary(wu.scope, metadata); + metadataUpdates.sessionSummary = overflowSummary; + } + + // 配额判定(#96 收口 #95 降级超限先例):两种重试都 = 一次新建会话尝试,遵守 + // MAX_SESSIONS_PER_WU,超限 terminal need_input 转人工,不静默绕过 MAX。 + if (ctx.sessionsUsed >= maxSessions) { + const summary = kind === 'session-resume-lost' + ? `续用会话已丢失且会话重建已达上限(${ctx.sessionsUsed}/${maxSessions}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务` + : `CLI 上下文溢出且会话重建已达上限(${ctx.sessionsUsed}/${maxSessions}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`; + logger.warn(`[AgentLoop] ${kind === 'session-resume-lost' ? 'Resume session lost' : 'Context overflow'} and session limit reached — need human evaluation`, { + workUnitId: wu.id, sessionsUsed: ctx.sessionsUsed, max: maxSessions, traceId: ctx.traceId, + }); + deps.recordFailureOutcome(ctx.detail); + deps.emitFailedStep('need_input', ctx.detail, ctx.firstResult); + return { kind: 'terminal', result: { action: 'need_input' as const, summary, metadataUpdates } }; + } + + const fallbackSessionId = (deps.newSessionId ?? randomUUID)(); + logger.warn( + kind === 'session-resume-lost' + ? `[AgentLoop] Resume target session lost for ${wu.id} — falling back to a new session` + : `[AgentLoop] Context overflow for ${wu.id} — persisting rolling summary and retrying in a new session`, + { traceId: ctx.traceId }, + ); + task.parameters!.sessionId = ctx.provider === 'claude' ? fallbackSessionId : undefined; + delete task.parameters!.sessionResume; + // 重试 = 一次新建会话尝试,成败均计入会话预算(#95 失败/超时尝试计入语义) + metadataUpdates.sessionCount = ctx.sessionsUsed + 1; + + // prompt 重算(复用 agent-loop 同一 ctx/deps;knowledge/skill 等段重复组装一次, + // 副作用均 fire-and-forget): + // - 降级:isNewSession=true —— 断链新会话(check 判命中、执行才发现丢失,未注入前序进展), + // 重算注入「前序进展」段 + 回放 waitingQuestion; + // - 溢出:isNewSession=false(避免与 handoff 前序进展段重复),摘要段单独追加。 + const recomposedPrompt = await deps.recomposePrompt({ isNewSession: kind === 'session-resume-lost' }); + task.prompt = kind === 'context-overflow' + ? `${recomposedPrompt}\n\n${OVERFLOW_SUMMARY_HEADER}\n\n${overflowSummary}` + : recomposedPrompt; + + const retryResult: ExecutionResult = await deps.execute(task); + if (retryResult.success === false) { + const retryDetail = (retryResult.error ?? '未知错误').slice(0, 500); + logger.error(`[AgentLoop] agentStep ${kind === 'session-resume-lost' ? 'fallback' : 'overflow'} retry failed for ${wu.id}: ${retryDetail}`, { traceId: ctx.traceId }); + deps.recordTokenEvent(retryResult); + // 新会话未建立:sessionId/lastSessionResumed 回滚;sessionCount 保留计入(#95); + // 溢出的 sessionSummary 保留落盘供人工参考 + resetUnestablishedSessionBookkeeping(metadataUpdates); + deps.recordFailureOutcome(retryDetail); + if (kind === 'session-resume-lost') { + // 降级再败 → 既有 failed 返回(记 consecutiveStuck,连续 3 次走 blocked 里程碑) + deps.emitFailedStep('failed', retryDetail, retryResult); + return { + kind: 'terminal', + result: { + action: 'failed' as const, + summary: `CLI 执行失败: ${retryDetail}`, + metadataUpdates: { + ...metadataUpdates, + errorType: 'execution_failed', + errorDetail: retryDetail, + errorAt: new Date().toISOString(), + }, + }, + }; + } + // 溢出再败 → NEED_INPUT(合流既有 need_input 路径,不再三连败静默 blocked) + deps.emitFailedStep('need_input', retryDetail, retryResult); + return { + kind: 'terminal', + result: { + action: 'need_input' as const, + summary: `CLI 上下文溢出:新会话带摘要重试一次仍失败(${retryDetail.slice(0, 200)}),已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, + metadataUpdates, + }, + }; + } + + // 重试成功:换新号落盘(sessionCount 已在查过 MAX 后计入),agent-loop 走正常成功路径 + metadataUpdates.sessionId = fallbackSessionId; + metadataUpdates.lastSessionResumed = false; + return { kind: 'retried', result: retryResult, sessionId: fallbackSessionId }; +} diff --git a/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts b/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts index 58f404712..20fcd848e 100644 --- a/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts +++ b/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts @@ -178,15 +178,20 @@ export async function dataLifecycle(fileStore: FileStore, state: LifecycleState) try { const execCutoffMs = Date.now() - 90 * 24 * 3600_000; const allWu = await fileStore.getIndex(); - const toDelete = allWu.filter(s => new Date(s.createdAt).getTime() < execCutoffMs); + // #540:加终态守卫——仅删 done/closed。原「无状态过滤」是 G31 GoalExecution TTL + // (执行记录,无生命周期状态)迁到 WorkUnit 时机械沿用的产物,非有意设计; + // 进行中/阻塞的长周期单不再被强删(僵尸单走探测告警/人审,不走静默删除)。 + // 缺 status 的历史行按非终态保留,不误删。 + const TERMINAL_STATUSES = new Set(['done', 'closed']); + const toDelete = allWu.filter(s => + TERMINAL_STATUSES.has(s.status) && new Date(s.createdAt).getTime() < execCutoffMs); + // #538(ADR 2026-09-15 决策 3):筛选逻辑留本调用方, + // 删除循环走 service.delete 单口——墓碑单点构造 + workunit:removed 出声。 + // 动态引入避环(agents → workunit 静态链会经 channels 绕回 agents) + const { WorkUnitService } = await import('../../workunit/workunit.service.js'); + const workUnitService = new WorkUnitService(fileStore); for (const wu of toDelete) { - // #170:墓碑事件 + 索引移除同锁成对(对账/重建不复活已删 WU) - await fileStore.commitRemoval({ - type: 'closed', - wuId: wu.id, - timestamp: new Date().toISOString(), - data: { deleted: true }, - }, wu.id); + await workUnitService.delete(wu.id, { reason: 'monitor TTL: terminal WorkUnit older than 90 days' }); } logger.info('[MonitorService] TTL: WorkUnit cleaned', { deleted: toDelete.length, cutoff: new Date(execCutoffMs).toISOString() }); } catch (e) { diff --git a/apps/api/src/modules/agents/monitor/monitor-probes.ts b/apps/api/src/modules/agents/monitor/monitor-probes.ts index d58520824..066267734 100644 --- a/apps/api/src/modules/agents/monitor/monitor-probes.ts +++ b/apps/api/src/modules/agents/monitor/monitor-probes.ts @@ -19,7 +19,7 @@ import { } from '@dommaker/studio-shared'; import { agentRunner } from '@dommaker/studio-agent'; import type { MonitorAlert } from '../types.js'; -import { closeWorkUnitWithNotice } from '../../workunit/wu-closure.js'; +import { WorkUnitService } from '../../workunit/workunit.service.js'; import { buildDeadLetterNotice } from '../../workunit/blocked-cta.js'; import { parseWuMetadata } from '../../workunit/wu-metadata.js'; import { postWuSystemMessage } from '../../workunit/wu-messenger.js'; @@ -277,7 +277,10 @@ export async function checkProgressStagnation(snapshots: WorkUnitSnapshot[]): Pr export async function checkTotalExecutionTime(fileStore: FileStore, snapshots: WorkUnitSnapshot[]): Promise { const alerts: MonitorAlert[] = []; - const running = snapshots.filter(s => s.status === 'active').slice(0, 10); + // #553:decision/spec 豁免超时巡检——#108 裁剪状态机无 closed,不进超时关闭路径 + // (与 autoAbandonStaleBlocked 的豁免同源);此前 2.5h 强杀被状态机拒绝、 + // catch 吞掉后每轮重复发 critical 但永不关闭 + const running = snapshots.filter(s => s.status === 'active' && !DECISION_SPEC_TYPES.has(s.type)).slice(0, 10); for (const exec of running) { const startTime = new Date(exec.claimedAt || exec.createdAt).getTime(); @@ -300,11 +303,11 @@ export async function checkTotalExecutionTime(fileStore: FileStore, snapshots: W logger.warn('[MonitorService] Failed to stop workUnit process', { workUnitId: exec.id.slice(0, 8), error: String(stopErr) }); } // 推向终态(closed)必须双出声(#176,决策 #62 §3):结构化事件 + 频道说明, - // 统一走 wu-closure 出口(#170 锁内成对写在其内部保持) + // #550 起统一走 WorkUnitService.close 状态机单口(幂等:已 closed 直返现状) try { const current = (await fileStore.getIndex({ id: exec.id }))[0]; if (current) { - await closeWorkUnitWithNotice(fileStore, current, { + await new WorkUnitService(fileStore).close(exec.id, { reason: `执行超过 2.5h(已 ${elapsedMin} 分钟),系统强制关闭`, closedBy: 'total-time-kill', }); @@ -340,7 +343,7 @@ export async function checkTotalExecutionTime(fileStore: FileStore, snapshots: W * (修掉「创建超 24h 的 WU 刚 blocked 就被秒关」bug;无 blockedAt 的存量档案回退 createdAt)。 * 全 blocked 类型统一适用;decision/spec 豁免(#108 裁剪状态机无 closed,可等关键人多天)。 * 关闭必须双出声(决策 #62 §3):workunit:closed 结构化事件 + 频道死信通知 - * (已关闭 + 后续出路),统一走 wu-closure 出口,不再静默改状态。 + * (已关闭 + 后续出路),#550 起统一走 WorkUnitService.close 状态机单口,不再静默改状态。 */ export async function autoAbandonStaleBlocked(fileStore: FileStore, snapshots: WorkUnitSnapshot[]): Promise { const cutoff = Date.now() - BLOCKED_AUTO_ABANDON_MS; @@ -362,7 +365,7 @@ export async function autoAbandonStaleBlocked(fileStore: FileStore, snapshots: W if (current && current.status === 'blocked') { const meta = parseWuMetadata(current.metadata); const title = (meta.title ?? current.scope ?? current.id).slice(0, 50); - await closeWorkUnitWithNotice(fileStore, current, { + await new WorkUnitService(fileStore).close(current.id, { reason: 'blocked 超 24h 无人工介入,自动关闭', closedBy: 'auto-abandon-stale-blocked', message: buildDeadLetterNotice(title, meta.blockReason), diff --git a/apps/api/src/modules/agents/monitor/monitor-reports.ts b/apps/api/src/modules/agents/monitor/monitor-reports.ts index 6186bd3ae..97f907128 100644 --- a/apps/api/src/modules/agents/monitor/monitor-reports.ts +++ b/apps/api/src/modules/agents/monitor/monitor-reports.ts @@ -307,10 +307,13 @@ export async function dailyReflection(fileStore: FileStore, state: ReportState): } catch { /* best-effort */ } // 5. Knowledge quality audit (daily, auto-fix) + // #134①(harness 1.8.0):KnowledgeAudit 构造签名改 (store, 阈值), + // baseDir 由 store 供给——走 sharedStore 单例(与运行时知识库同一路径 + // UNIFIED_KNOWLEDGE_DIR),不再自拼 {baseDir} 构造。 try { const { KnowledgeAudit } = await import('@dommaker/harness') as any; - const knowledgeDir = studioPath('knowledge'); - const audit = new KnowledgeAudit({ baseDir: knowledgeDir }); + const { sharedStore } = await import('../../knowledge/knowledge-singletons.js'); + const audit = new KnowledgeAudit(sharedStore); const report = audit.run({ autoFix: true }); if (report.totalEntries > 0) { lines.push('', '### 知识质量审计'); diff --git a/apps/api/src/modules/agents/ops/ops.service.ts b/apps/api/src/modules/agents/ops/ops.service.ts index 33d1cdae2..8f55be13c 100644 --- a/apps/api/src/modules/agents/ops/ops.service.ts +++ b/apps/api/src/modules/agents/ops/ops.service.ts @@ -15,6 +15,7 @@ import { loadRules, type OpsRules } from './ops-rules.js'; import { readDiskUsage, readMemoryUsage, readLoadAvgRaw, countProcessesByCmdline, listPidsByCmdline } from './proc-probes.js'; import { hashPassword } from '../../auth/service.js'; import { resolveStudioLogFile } from '../../../utils/studio-log-path.js'; +import { ensureFrontendDist } from '../../../utils/frontend-dist.js'; const execAsync = promisify(exec); @@ -79,52 +80,21 @@ export class OpsService { }); } - // 2. Check frontend dist - const indexHtml = path.join(frontendDistPath, 'index.html'); - if (fs.existsSync(indexHtml)) { - add({ name: 'frontend-dist', passed: true, message: 'Frontend dist exists', critical: false }); - } else { - // Try to auto-build - try { - const webDir = path.join(repoDir, 'apps/web'); - if (fs.existsSync(webDir)) { - logger.info('[Ops] Building frontend...'); - execSync('npx vite build', { cwd: webDir, stdio: 'pipe', timeout: 120_000 }); - // Copy to frontend dist - const srcDist = path.join(webDir, 'dist'); - if (fs.existsSync(srcDist)) { - fs.mkdirSync(path.dirname(frontendDistPath), { recursive: true }); - execSync(`cp -r "${srcDist}/"* "${frontendDistPath}/"`, { stdio: 'pipe' }); - add({ - name: 'frontend-dist', passed: true, critical: false, - message: 'Frontend built and deployed', autoFixed: true, - }); - } else { - add({ name: 'frontend-dist', passed: false, critical: false, message: '⚠️ Frontend build produced no dist' }); - } - } - } catch (e: any) { - add({ - name: 'frontend-dist', passed: false, critical: false, - message: `⚠️ Frontend dist missing and auto-build failed: ${e.message.slice(0, 100)}`, - }); - } - } - - // 3. Check port - try { - const output = execSync(`lsof -ti:${this.port} 2>/dev/null || true`, { encoding: 'utf-8', stdio: 'pipe' }).trim(); - if (output) { - add({ - name: 'port-available', passed: false, critical: true, - message: `❌ Port ${this.port} is already in use by PID(s): ${output.replace(/\n/g, ', ')}. Kill old processes first: studio stop`, - }); - } else { - add({ name: 'port-available', passed: true, message: `Port ${this.port} available`, critical: true }); - } - } catch (e: any) { - add({ name: 'port-available', passed: true, message: `Port ${this.port} available`, critical: true }); - } + // 2. Check frontend dist(#571 冲突 8 冻结:npm 形态缺 dist = 包损坏 critical abort, + // 不现场构建;monorepo dev 形态(apps/web 源码在)保留 auto-build 分支) + const frontend = ensureFrontendDist(frontendDistPath, repoDir); + if (frontend.autoBuilt) logger.info('[Ops] Building frontend...'); + add({ + name: 'frontend-dist', + passed: frontend.ok, + critical: frontend.corrupt === true, + message: frontend.message, + autoFixed: frontend.autoBuilt, + }); + + // 3. 端口检查已移出(#573 契约 §7 双口径收口):原 lsof 占用即 abort 语义 + // 收编为 cli/port-probe 的动态顺延分支——调用方(studioUp / studioRunWeb) + // 在 preflight 前完成端口解析(默认顺延上限 +100,显式指定占用即拒启)。 // 4. Clean stale processes try { diff --git a/apps/api/src/modules/agents/ops/system-health.ts b/apps/api/src/modules/agents/ops/system-health.ts index bbe34e2a4..eeb8d12c5 100644 --- a/apps/api/src/modules/agents/ops/system-health.ts +++ b/apps/api/src/modules/agents/ops/system-health.ts @@ -303,18 +303,16 @@ export async function runGC(): Promise { // Clean completed WorkUnits older than 30 days try { const { FileStore } = await import('@dommaker/studio-shared'); + // #538(ADR 2026-09-15 决策 3):筛选逻辑留本调用方(done/closed 且 completedAt >30 天照旧), + // 删除循环走 service.delete 单口——墓碑单点构造 + workunit:removed 出声 + const { WorkUnitService } = await import('../../workunit/workunit.service.js'); const fileStore = new FileStore(); + const workUnitService = new WorkUnitService(fileStore); const snapshots = await fileStore.getIndex(); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); for (const s of snapshots) { if ((s.status === 'done' || s.status === 'closed') && s.completedAt && s.completedAt < cutoff) { - // #170:墓碑事件 + 索引移除同锁成对(对账/重建不复活已删 WU) - await fileStore.commitRemoval({ - type: 'closed', - wuId: s.id, - timestamp: new Date().toISOString(), - data: { deleted: true }, - }, s.id); + await workUnitService.delete(s.id, { reason: 'ops GC: completed WorkUnit older than 30 days' }); cleaned++; details.push(`Removed completed WorkUnit: ${s.id}`); } diff --git a/apps/api/src/modules/agents/system-executor.ts b/apps/api/src/modules/agents/system-executor.ts index 65d2561a9..5ecf30eb2 100644 --- a/apps/api/src/modules/agents/system-executor.ts +++ b/apps/api/src/modules/agents/system-executor.ts @@ -47,6 +47,8 @@ export const DEFAULT_TIMEOUT_BY_EVENT_SOURCE: Readonly> = 'knowledge-distill': 120_000, 'constraint-audit': 120_000, 'knowledge-maintenance': 120_000, + // 合并冲突 LLM 解:会话内含解冲突 + typecheck/test 验证命令(对齐 VERIFY_COMMAND_TIMEOUT_MS 单条 10min 量级) + 'merge-conflict-resolution': 600_000, }; export interface SystemExecutorResult { @@ -133,6 +135,10 @@ export class SystemExecutor { }, timeoutMs: effectiveTimeoutMs, maxBuffer: opts.maxBuffer, + // #581:恒开 killProcessGroup(#171 机制,同 runner-lightweight 先例)—— + // 超时杀整进程组,CLI spawn 的孙进程不留孤儿继续写临时目录。 + // 兼容性:execSh 本身 posix-only(bash -c),kill(-pid) 为 posix 语义,无新增负担。 + killProcessGroup: true, }); // 5. 解析 JSON envelope.usage(claude --verbose 时为事件数组,归一到 result 事件,#364) diff --git a/apps/api/src/modules/audit/__tests__/audit-subscriber.test.ts b/apps/api/src/modules/audit/__tests__/audit-subscriber.test.ts index 1ab4b5623..71d8d21c8 100644 --- a/apps/api/src/modules/audit/__tests__/audit-subscriber.test.ts +++ b/apps/api/src/modules/audit/__tests__/audit-subscriber.test.ts @@ -1,5 +1,5 @@ // audit-subscriber 单测(#324):直订 eventBus 的 events:audit, -// handler 收对象 payload(发布方 harness/hooks/audit.ts 本就直发对象), +// handler 收对象 payload(发布方 harness/audit.ts 本就直发对象), // 以原形态持久化到 KnowledgeStore(sharedStore.save,content = 事件 JSON)。 import { describe, it, expect, afterAll, vi } from 'vitest'; import { eventBus } from '@dommaker/studio-shared'; diff --git a/apps/api/src/modules/audit/audit-subscriber.ts b/apps/api/src/modules/audit/audit-subscriber.ts index b1fa79e7c..53a874f67 100644 --- a/apps/api/src/modules/audit/audit-subscriber.ts +++ b/apps/api/src/modules/audit/audit-subscriber.ts @@ -1,5 +1,5 @@ // Audit Event Subscriber — EventBus 审计事件持久化到 KnowledgeStore (B0-002) -// #324:直订 eventBus(发布方 harness/hooks/audit.ts 本就直发对象 payload,无字符串壳) +// #324:直订 eventBus(发布方 harness/audit.ts 本就直发对象 payload,无字符串壳) import { eventBus, logger } from '@dommaker/studio-shared'; let started = false; diff --git a/apps/api/src/modules/channels/CONTEXT.md b/apps/api/src/modules/channels/CONTEXT.md index acc3b67a3..42fa5e4ac 100644 --- a/apps/api/src/modules/channels/CONTEXT.md +++ b/apps/api/src/modules/channels/CONTEXT.md @@ -19,7 +19,7 @@ Channel 域:频道 CRUD/成员/路由表、消息创建与路由(replyTo 线 | migrate-routing.ts | `migrateDefaultPipelineToRouting()` | #466 启动幂等迁移:存量 defaultPipeline 吞并入 routing.implement | | migrate-members.ts | `migrateProfileChannelsToMembers()` | 启动幂等迁移:AgentProfile.channels → channel.members 唯一事实源 | | current-pmo.ts | `deriveChannelCurrentPmo()` | 「当前 PMO」chip 派生(不落库现算) | -| file-ref-vocabulary.ts | — | #281 @文件引用候选词表(git ls-files + 60s 进程内存缓存) | +| file-ref-vocabulary.ts | — | #281 @文件引用候选词表(git ls-files + 60s 进程内存缓存;B7 起候选集本身带 10s 短 TTL 缓存、REQ 挂接 PMO 并行解析) | | convert-to-task.service.ts | `ConvertToTaskService` | 消息转任务(LLM 建议标题/描述/归属) | | attachments.ts | `saveChannelImage()` / `resolveChannelImage()` / `MAX_IMAGE_BYTES` / `ATTACHMENT_BODY_LIMIT` | 频道图片附件(2026-09 截图粘贴):base64 落数据区 + 取图解析,id 白名单校验防路径穿越 | | channel-init.ts | `ensureDefaultChannels()` | 启动时默认频道初始化(三默认频道,members 空) | @@ -41,17 +41,17 @@ Channel 域:频道 CRUD/成员/路由表、消息创建与路由(replyTo 线 - **DB 去重**:同 channel 24h 内有有效 RequirementsDoc → 直接复用(0 token) - **outputFile 唯一性**:Claude 通过 Write tool 写文件,stdout 只有 "DONE"。文件丢失 = 数据丢失 - **鉴权分层(2026-09-14 起读写对称,推翻 2026-07-24 姿态 A 的「围观匿名公开」)**:全部 GET 读端点(`/`、`/:id`、`/:id/messages`、`/:id/suggestions`、`/:id/current-pmo`、`/:id/file-vocabulary`)挂 `requireAuth()`(读侧同样返回频道全量消息/成员/词表,与写侧对称;回归测试 `__tests__/channel.routes.test.ts` 锁匿名 401);`/channels` 已移出 app.ts PUBLIC_API 白名单(前缀放行覆盖全部子路径)。9 条写端点(建频道/发消息/删频道/archive/restore/PATCH/members/convert-to-task×2)= `requireAuth()+requireNotGuest()`(card-decision 端点 #356 起随 auditor_suggestion 卡接线 review-proposal 正本而删)。注意 `POST /:id/messages` 经 @mention 派单/恢复挂起 WU 可直接触发 agent 执行与 LLM 消耗,是收紧前最危险的匿名入口。requirements-docs PUT 同为 requireNotGuest -- **apiCache 写后失效(#448,2026-09-02;#532 收口进 service)**:`GET /` 挂 30s apiCache,频道记录写端点(POST `/`、PATCH `/:id`、PATCH `/:id/members`、DELETE `/:id`、PUT archive/restore)的写后失效由 `channel.service` 写方法内部完成——`clearCache` 唯一调用点 = service 私有 `invalidateListCache()`,失效键 = 常量 `/api/v1/channels`(= route-registry 挂载点,与缓存 key 的 `req.baseUrl` 前缀口径一致),漏调在结构上不可能;`POST /:id/messages` 等不改频道记录的端点不清(高频消息保持缓存有效) +- **apiCache 写后失效(#448,2026-09-02;#532 收口进 service)**:`GET /` 挂 30s apiCache,频道记录写端点(POST `/`、PATCH `/:id`、PATCH `/:id/members`、DELETE `/:id`、PUT archive/restore)的写后失效由 `channel.service` 写方法内部完成——`clearCache` 唯一调用点 = service 私有 `invalidateListCache()`,失效键 = 常量 `/api/v1/channels`(= route-registry 挂载点,与缓存 key 的 `req.baseUrl` 前缀口径一致),漏调在结构上不可能;`POST /:id/messages` 等不改频道记录的端点不清(高频消息保持缓存有效)。**B7(2026-09-16 channel 性能审计)**:`GET /:id/current-pmo` 与 `GET /:id/file-vocabulary` 挂 5s short 档 apiCache(派生链 N+1 全量读取,无写后失效接线,容忍秒级滞后)。**B8(同批)**:`GET /:id` 不再附 `_count.ChannelMessage`(prisma 遗留形状,全仓无消费方)——每请求 O(热文件行数) 全量 `countMessages` 已随字段一并去除 - **消息路由优先级**(`message-routing.ts` routeMessage):`replyToId` 线程回复(继承父消息 workUnitId;**#327 起父消息不在热层——已归档/不存在不可区分——降级放行**:帖子成立、replyToId 保留、workUnitId 继承失效落 null、不整帖抛错)→ `@mention` 派单(建 WorkUnit,`metadata.creationMode='mention'`;§9.5 只匹配本频道 members,members 为空回退全量 active profile)→ 纯文本仅存储。mention = 纯文本 `@name`(无结构化 id),检测与 scope 剥离用 Unicode 正则 `[\p{L}\p{N}_-]+/u`;手打中文连写无空格(`@开发你好`)由 **#496 前缀兜底**兜住——token 精确匹配失败时对成员名做最长前缀匹配(互为前缀取最长,等长歧义/同名 profile 并列回退未匹配现状),命中后 scope 只剥 `@成员名` 保留连写剩余文本,`metadata.mentionName` 落解析后正名;studio 系统角色不参与前缀兜底。归属解析出 PMO 项目时创建期落 `metadata.pmoId`(2026-08 归因统一 canonical key;原 `ownershipProjectId` 废弃不再写入,读取侧同级兼容)。**#464 出声**:@mention 未匹配(角色不存在/非本频道成员;@studio 无默认入口)→ 线程回「未找到角色/未配置入口,已转自动认领」系统消息(此前 WU 照建但频道零提示);无归属挂起提问带 milestone(atHuman 响铃 + 行动中心通知)。**#494 派单原子性**:mention/决策12 两派单路径先落派发消息再建 WU,`metadata.anchorMessageId` 显式携带派发消息 id(认领播报优先锚它,消 created 事件先于派发消息落库的 findAnchorMessage 竞态),建单后 `linkWorkUnit` 回填消息 ↔ WU 关联(best-effort,同 createFromMessage 路径)。**#495 决策12 合并窗口(方案 a)**:defaultProfileId 派单前先查合并目标(**#524 P1-1 起查法 = `readMessagesTail` 尾部倒扫收集 20 条 human**,替代全热文件读,#514 D 方案)——同频道最近一条携带 workUnitId 的人类消息在窗口内(默认 5 分钟,`STUDIO_CHANNEL_MERGE_WINDOW_MINUTES` 覆盖)且 WU 在途(pending/unassigned/active/blocked)→ 消息并入该 WU 线程(replyToId=anchorMessageId,workUnitId=该 WU)不新建;blocked 经 resumeWaitingWorkUnit 复活注入,其余状态锁内追加 `metadata.pendingReplies`(loop 下一步 prompt-composer 注入)。窗口外/终态(done/closed/in_review)/@mention 照章新建。合并消息自身携带 workUnitId → 滑动窗口(连发 N 条 = 1 张单)。合并目标 metadata 解析走 `parseWuMetadata` 容错口径(畸形 JSON 落 {}、anchorMessageId 缺省走首根回退),不抛错拖垮整道路由。**#525 P2-2(2026-09-12,#517 项③)**:POST 路由层 404 判定已读的 channel 经 `ctx.channel` 传进 routeMessage(mention/决策12 两路径及 resolveWorkspaceForWU 不再重复 `getChannel`;未传入保持现状读——既有直调测试不走路由层) - **traceId 全链路关联键(#519,2026-09-12)**:发消息路由生成 traceId(复用 audit 中间件 requestId,同一次 HTTP 请求同值;无则 randomUUID),三条派单路径统一落 WU `metadata.traceId`——@mention / 决策12 新建 WU 创建时写入;线程回复与合并窗口关联到既有 WU 时经 `updateMetadata` 刷新为本次请求值(best-effort,失败仅 warn 不阻断)。口径:spec user story 5 二选一取「本次消息 traceId」,即 WU 的 traceId 始终指向最近一次触达它的请求,离线对齐工具按此一致处理 - **频道工单路由表(#466,2026-09-09)**:`routing.ts` 是唯一解析/校验事实源——`ChannelData.routing = { plan?, implement?, review? }`(profile id,某档空 = 回池涌现);`resolveStageRouting` 判定 = 配置了且角色 active 且频道成员(members 空回退 profile.channels,agent-loop 同口径)才指名,否则回池涌现 + 调用方用 `routingFallbackText` 发频道提醒;`validateRouting` 供 PATCH `/channels/:id`(值须 active profile id,'' /null = 清档,不强制成员——成员边界路由时判定,defaultProfileId 先例),PATCH 与存量合并(单档更新不清其他档)。三消费点:publish 建 plan 单(plan 档,显式 assigneeId 优先)、analysis-handoff TASK 拆派工 + workunit feature 展开(implement 档)、review-dispatcher 建评审单(review 档,路由=实现者本人时回退涌现——不许自评指名)。`migrate-routing.ts` 启动幂等吞并:存量 `defaultPipeline`(AC-6.1 name 数组,已退役)首跳 name→id 落 `routing.implement` 并删旧字段,无法解析按死配置移除。**#497 漂移收敛(2026-09-11)**:删 profile 同步清各频道 routing 悬空档(agent-profile.service.delete,与清 members 同一循环,幂等);PATCH `/:id/members` 移出被 routing 档/defaultProfileId 指名的角色 → 响应 `data.warning` 提示(不阻断);fallback 提醒过 `shouldEmitFallbackReminder` 冷却闸(进程内 Map,键 = 频道+档+原因,窗 1h `FALLBACK_REMINDER_COOLDOWN_MS`,重启重置),四个消费点(publish/analysis-handoff/workunit 展开/review-dispatcher)均已接线——冷却只压提醒,不影响路由回退本身。**#477 骨架收口(2026-09-11)**:四消费点共用 `resolveOrNotice`(解析 + fallback 判定 + 冷却闸 + 文案一次完成,返回 `{ resolution, notice }`);出声通道(WU 系统消息/频道 agent 消息)与时机(建单前即时/建单完成后)四点差异留调用侧,review-dispatcher「路由=实现者」专属文案为点级差异留在调用侧。**#525 P2-5(2026-09-12,#516 项④)**:workunit-crud #464 未配置提示(notConfiguredText 入参)同挂冷却闸——键 = 频道+档+`not-configured`(与配错同表不同键),首次照出、1h 内不重复 -- **成员绑定**:`channel.members`(config.json 内 JSON 字符串数组)是成员关系唯一事实源,`AgentProfile.channels` 已废弃(启动时幂等迁移);PATCH `/:id/members` 合并 add/remove 后整体回写;删除 profile 时由 AgentProfileService 反向清理各频道 members 悬空引用 +- **成员绑定**:`channel.members`(config.json 内 JSON 字符串数组)是成员关系唯一事实源,`AgentProfile.channels` 已废弃(启动时幂等迁移);PATCH `/:id/members` 合并 add/remove 后整体回写;**id 校验(2026-09-16)**:add/remove 中非字符串/空串 id → ChannelError(400),add 额外要求 profile 已存在(否则 400 不写盘,防 `{add:[undefined]}` 存成 `"[null]"`);remove 不存在 profile 的 id 保持幂等 no-op(允许清悬空成员);删除 profile 时由 AgentProfileService 反向清理各频道 members 悬空引用 - **系统消息唯一发布路径(2026-08-06)**:`channelMessageService.createAgentMessage` 是唯一会发 `channel.message_sent`(eventBus + SSE)的入口;WU 线程系统消息(anchor + pmoId + atHuman)一律经 `workunit/wu-messenger.postWuSystemMessage` 委托到此——禁止再裸 `fileStore.appendMessage` 发系统消息(不发事件 = 通知铃不响、频道页不实时)。`message-routing` 的 @studio 改派/归属提问两处已迁移;`MessageRecord` 类型自本模块导出供 messenger 返回类型使用 - **@文件引用前半链路(#281,决策 #249/#257)**:`file-ref-vocabulary.ts` —— 候选集 = 频道相关工程(频道默认工程 ∪ 本频道 REQ 挂接 PMO 的 gitRepo+deliveries 多腿 ∪ 杂务 PMO,尾斜杠归一去重,频道内最近 WU metadata.workspaceRoot 优先;UX 划界非安全边界,实时计算不动 project-discovery),词表 = 各仓 `git ls-files` + 进程内存缓存(60s TTL,单仓失败空词表不拖垮整体)。只读端点 `GET /:id/file-vocabulary`(GET 族,2026-09-14 起挂 requireAuth)。发送带 `files:[{repo,path}]`(repo 绝对路径同 PMO gitRepos 形,path 原样相对路径,无行范围)→ routeMessage 路由时存在性校验:有效引用写消息 `meta.files`(mention 纯文本不动),失效引用剔除 + Studio 系统播报 + `channel:file_refs_dropped` 事件(reason=not-found/not-in-candidate-set,dropped 封顶前 5 条 + droppedCount 全量,D9 尺寸纪律)。composer(web ChannelInput)@弹框统一分组(上 Agents 下 Files),文件候选 = 词表后缀精确匹配,选中插入纯路径文本(不带 @ 防 mention 正则误吃),发送仅上送正文仍含其路径的引用。后半链路 API 侧已落地(#285):kept refs 写 WU `metadata.fileRefs`(@mention 与决策 12 channel-default 两条建 WU 路径,仅在有有效引用时写字段;线程回复不建 WU 不动);归属链新 rung(ownership-resolver.ts)= REQ 继承 > 文件引用(全部引用尾斜杠归一后同仓 → `source=file-refs`,workspaceRoot=归一 repo;跨多仓不参与、落下一 rung;空数组等同无引用)> 频道默认工程 > none(#481:显式 workspaceId 与频道 defaultWorkspaceId 机器指针两级已退役);prompt 注入段「## 引用文件」(prompt-composer.ts files 段:稳定前缀最末、knowledge 段之后,定额 400 参与池内余量共享;内容 = 每引用一条块(绝对路径 repo/path + 本工程内相对路径/「位于本工程之外」标注)+ 段尾固定只读约束行;块级截断保注入序前缀 + 段尾「另有 N 条引用未注入」,病态超预算空段不注入;`prompt:section_trimmed` payload 增 keptCount/droppedPaths(≤5)/droppedCount;截断时首步(stepCount 缺失或 0)向 WU 频道播报「引用文件较多,已注入前 N 条」一次,best-effort);agent 侧 chip 见本票 web 侧 - **顶栏默认工程正名 + 当前 PMO chip(#272,决策 #251 Q2'/Q6)**:「默认工程」= 本地 repo(`channel.defaultPath`,顶栏下拉数据源 `/projects/discover`,非 Admin 可用)——**#481(2026-09-11)起它是频道级唯一工程配置点**:「默认执行机器」(`defaultWorkspaceId`)设置面已退役(web `DefaultExecutionMachineSection` 删除),归属链两级机器指针(显式指定 / 频道默认)同步拆除,WU 不再落 `workspaceId` 机器指针(字段仅历史记录展示用;`defaultWorkspaceId` 仅存于 channel PATCH 绑定校验与 file-ref-vocabulary legacy 候选集)。归属链接通:ownership-resolver rung `channel-default-path`(defaultPath 直接作 workspaceRoot,位于文件引用之后、none 之前);file-ref-vocabulary 候选集默认工程改读 defaultPath(legacy defaultWorkspaceId 根保留)。「当前 PMO」chip 派生不落库(`current-pmo.ts` `deriveChannelCurrentPmo`:本频道最近挂接 REQ(seq 大→小)所属 PMO → 杂务 PMO 反推 → null;端点 `GET /:id/current-pmo`(2026-09-14 起挂 requireAuth),返回 `{id, pmoNumber, title, gitRepos}`,多仓 tooltip 列 gitRepos)。创建频道表单合并为 web `CreateChannelForm` 单一实现(ChannelListPage/ChannelRail 共用),含可选默认工程(可留空),POST /channels 收 `defaultPath`(空串归一 null) - **文件 chip WU 文件集优先层(#285 AC4 补齐,2026-08-20)**:per-WU 产出/修改文件集查询面 = `workunit/wu-changed-files.ts listWorkUnitChangedFiles`(session:start 事件 payload.workUnitId(#174)→ sessionId 集合 → file:change 事件(studio-agent output-capture 对 Write/Edit 发射)payload.path 绝对路径,去重保序;读失败/无数据 → 空数组绝不抛出),端点 `GET /workunits/:id/changed-files` 匿名只读。注意路径通常是 per-execution worktree 内绝对路径(非候选仓路径),file:change 无 envelope level 按信号类保留(热 30 天→月度归档),过期自然降级。web 侧 `fileChipMatch.matchFileRefToken(token, vocabulary, wuFiles?)` 两层命中即停:WU 集唯一命中 → FileRef 按命中边界从绝对路径切出(不映射回候选仓);歧义 → 纯文本不回退;无命中 → 候选集词表。ChannelDetailPage 按 agent 消息 distinct workUnitId 逐个拉取缓存(wuChangedFiles state + fetched ref),经 ChannelMessageItem prop 透传。注入段「跨仓只读」固定行已对齐 #285 票体原文(去掉自加的「(workspaceRoot)」与 `**` 加粗) - **删除频道兜底(B2-012)不再直触 WU 存储(2026-08-06,Card 9;#532 起整体住 channel.service.deleteWithFallback)**:WU 重挂委托 `WorkUnitService.rebindSourceChannel`(解析 metadata 按 `context.sourceChannelId` 字段相等匹配 + appendEvent 留痕)——原为路由内 `metadata.includes(channelId)` 子串匹配(可被其它字段误伤)+ 裸 `upsertSnapshot`(无事件、手置 updatedAt)。service 内含:兜底 rnd 频道查找/创建、频道删除;路由只剩响应形状 `{ deleted, fallbackChannelId }` -- **channel.message_updated 负载带全量本体(#311,ADR 2026-08-24 D1/D2)**:`updateMessageMeta`/`updateMessage` 两发射点(eventBus + SSE 同负载)additive 增 `message` 字段 = 落库后完整 shaped message(与 `channel.message_sent` 同源 `shapeMessageData()`);顶层既有字段语义不动——注意 `updateMessage` 顶层 `content`/`meta` 仍是**增量**(缺省 undefined),合并后全量真值读 `message.meta`。消费端迁移到读 `message` 已落地(#315);`createdAt` = 不可变诞生时刻(#317,ADR 2026-08-24 message-createdat-immutable)——两更新方法不再 bump,SSE 原位替换与 REST 刷新归位恒一致;#332 起 `createFromMessage`/convert-to-task 两条关联 WU 的直写 append 路径同口径(不 bump,否则 agent-loop `msg.createdAt > wu.updatedAt` 新回复检测把已转换消息误判为新回复);#333 起这两条路径迁移到 `channelMessageService.linkWorkUnit`(append 保留 createdAt + eventBus/SSE 双发,负载顶层附 `workUnitId` 增量字段);**#524 P1-1 起 `linkWorkUnit(messageId, workUnitId, channelId)` channelId 必填**——按频道直查消灭全频道扫描反查(replyTo 父消息查找同迁 `getMessageById(replyToId, channelId)`;convert-to-task/suggest/createFromMessage 等调用点同步迁移,createFromMessage 经路由 body.channelId 透传;review 收口批再把 `deleteMessage(messageId, channelId?)` 与 skills retract decide 卡片回写(body.channelId → `updateMessageMeta` 第三参)补齐,缺省均保留扇出兼容)——消息更新(content/meta/workUnitId)统一收敛到本服务,构造器注入口径同 convert-to-task.service;**#529(2026-09-14)`listByWorkUnitId` 收口**——签名加 `channelId`(type 可选、语义必填):指定即按频道直查,唯一生产调用点 workunit.routes `GET /workunits/:id/messages` 从 WU 一等列 `channelId` 解析归属后传入(与写侧 POST /:id/messages 同字段,读写对称);wu.channelId==null 的 legacy/手工单或缺省调用保留扇出 fallback(写侧对无归属 WU 落 #研发,其讨论消息可能散在任一频道) +- **channel.message_updated 负载带全量本体(#311,ADR 2026-08-24 D1/D2)**:`updateMessageMeta`/`updateMessage` 两发射点(eventBus + SSE 同负载)additive 增 `message` 字段 = 落库后完整 shaped message(与 `channel.message_sent` 同源 `shapeMessageData()`);顶层既有字段语义不动——注意 `updateMessage` 顶层 `content`/`meta` 仍是**增量**(缺省 undefined),合并后全量真值读 `message.meta`。消费端迁移到读 `message` 已落地(#315);`createdAt` = 不可变诞生时刻(#317,ADR 2026-08-24 message-createdat-immutable)——两更新方法不再 bump,SSE 原位替换与 REST 刷新归位恒一致;#332 起 `createFromMessage`/convert-to-task 两条关联 WU 的直写 append 路径同口径(不 bump,否则 agent-loop `msg.createdAt > wu.updatedAt` 新回复检测把已转换消息误判为新回复);#333 起这两条路径迁移到 `channelMessageService.linkWorkUnit`(append 保留 createdAt + eventBus/SSE 双发,负载顶层附 `workUnitId` 增量字段);**#524 P1-1 起 `linkWorkUnit(messageId, workUnitId, channelId)` channelId 必填**——按频道直查消灭全频道扫描反查(replyTo 父消息查找同迁 `getMessageById(replyToId, channelId)`;convert-to-task/suggest/createFromMessage 等调用点同步迁移,createFromMessage 经路由 body.channelId 透传;review 收口批再把 `deleteMessage(messageId, channelId?)` 与 skills retract decide 卡片回写(body.channelId → `updateMessageMeta` 第三参)补齐,缺省均保留扇出兼容)——消息更新(content/meta/workUnitId)统一收敛到本服务,构造器注入口径同 convert-to-task.service;**#529(2026-09-14)`listByWorkUnitId` 收口**——签名加 `channelId`(type 可选、语义必填):指定即按频道直查,唯一生产调用点 workunit.routes `GET /workunits/:id/messages` 从 WU 一等列 `channelId` 解析归属后传入(与写侧 POST /:id/messages 同字段,读写对称);wu.channelId==null 的 legacy/手工单或缺省调用保留扇出 fallback(写侧对无归属 WU 落 #研发,其讨论消息可能散在任一频道);**#576(2026-09-17,channel 体检 B5 遗留收口)**——`listByWorkUnitId` 对齐「取最新 N 条」语义并下推 limit(实取 limit+1 判更旧余量)+ before(ISO,createdAt 严格 <),带过滤查询命中 file-store 谓词倒扫快径,扫描行数不随热文件全长增长;有意识的语义变更:原「最旧 N 条 + 精确 total」→ 现「最新 N 条 + total 下界口径」(匹配 ≤limit 时精确,超 limit 时 total=limit+1,路由 hasMore=data.length { it('PATCH members 后立读:缓存失效(MISS)', async () => { const id = await seedChannel('members'); + // updateMembers 校验 add 的 profile 存在性——先确保 agent-x 存在 + if (!(await fileStore.getProfile('agent-x'))) { + const now = new Date().toISOString(); + await fileStore.createProfile({ id: 'agent-x', name: 'agent-x', description: null, channels: '[]', status: 'active', createdAt: now, updatedAt: now }); + } await getList(); // 写入缓存 const cached = await getList(); expect(cached.res.headers.get('x-cache')).toBe('HIT'); diff --git a/apps/api/src/modules/channels/__tests__/channel-derived-routes.test.ts b/apps/api/src/modules/channels/__tests__/channel-derived-routes.test.ts new file mode 100644 index 000000000..b5f8d3c33 --- /dev/null +++ b/apps/api/src/modules/channels/__tests__/channel-derived-routes.test.ts @@ -0,0 +1,127 @@ +/** + * B7/B8(2026-09-16 channel 性能审计):频道派生读端点缓存 + 详情端点瘦身。 + * + * B7:`GET /:id/current-pmo` 与 `GET /:id/file-vocabulary` 挂短 TTL apiCache + * (CACHE_CONFIG.short=5s)——两端点派生链为 N+1 全量读取,TTL 内二次请求 + * 不得再触发派生(下游派生函数调用次数作探针,X-Cache 头佐证)。 + * B8:`GET /:id` 不再附 `_count.ChannelMessage`(prisma 时代遗留形状, + * 前端/全仓无消费方,grep `_count` 于 apps/web 零命中)——响应无该字段 + * 且不再调用 fileStore.countMessages(每请求 O(热文件行数) 全量计数)。 + * + * 接线同 channel.routes.test.ts:STUDIO_DATA_DIR 指临时目录后动态 import + * channel.routes(模块级 new FileStore() 在 import 时解析数据目录); + * auth 中间件 mock 直通;两个派生函数 mock 为计数 spy。 + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import express from 'express'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { FileStore } from '@dommaker/studio-shared'; + +const { mockDeriveCurrentPmo, mockGetVocabulary } = vi.hoisted(() => ({ + mockDeriveCurrentPmo: vi.fn(async () => null), + mockGetVocabulary: vi.fn(async () => ({ repos: [] })), +})); + +vi.mock('../../middleware/auth.js', () => ({ + requireAuth: () => (_q: unknown, _s: unknown, n: () => void) => n(), + requireNotGuest: () => (_q: unknown, _s: unknown, n: () => void) => n(), +})); + +vi.mock('../current-pmo.js', () => ({ + deriveChannelCurrentPmo: mockDeriveCurrentPmo, +})); + +vi.mock('../file-ref-vocabulary.js', async (importOriginal) => ({ + ...(await importOriginal()), + getChannelFileVocabulary: mockGetVocabulary, +})); + +import { clearCache } from '../../../middleware/api-cache.js'; + +let tmpDir: string; +let server: Server; +let baseUrl: string; +const CH = `ch-derived-${Date.now()}`; +let prevDataDir: string | undefined; + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ch-derived-routes-')); + prevDataDir = process.env.STUDIO_DATA_DIR; + process.env.STUDIO_DATA_DIR = tmpDir; + const fileStore = new FileStore(tmpDir); + const now = new Date().toISOString(); + await fileStore.createChannel({ + id: CH, name: '#derived-cache', type: 'rnd', + defaultWorkspaceId: null, defaultPath: null, + discordChannelId: null, discordWebhookUrl: null, + members: '[]', createdAt: now, updatedAt: now, + }); + + const { default: channelRoutes } = await import('../channel.routes.js'); + const app = express(); + app.use(express.json()); + app.use('/api/v1/channels', channelRoutes); + await new Promise(resolve => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/channels`; +}); + +afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())); + if (prevDataDir === undefined) delete process.env.STUDIO_DATA_DIR; + else process.env.STUDIO_DATA_DIR = prevDataDir; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +beforeEach(async () => { + vi.clearAllMocks(); + await clearCache(''); +}); + +describe('B7:派生读端点短 TTL apiCache', () => { + it('GET /:id/current-pmo —— TTL 内二次请求 HIT,派生仅一次', async () => { + const res1 = await fetch(`${baseUrl}/${CH}/current-pmo`); + expect(res1.status).toBe(200); + expect(res1.headers.get('x-cache')).toBe('MISS'); + expect(mockDeriveCurrentPmo).toHaveBeenCalledTimes(1); + + const res2 = await fetch(`${baseUrl}/${CH}/current-pmo`); + expect(res2.status).toBe(200); + expect(res2.headers.get('x-cache')).toBe('HIT'); + expect(mockDeriveCurrentPmo).toHaveBeenCalledTimes(1); + }); + + it('GET /:id/file-vocabulary —— TTL 内二次请求 HIT,词表派生仅一次', async () => { + const res1 = await fetch(`${baseUrl}/${CH}/file-vocabulary`); + expect(res1.status).toBe(200); + expect(res1.headers.get('x-cache')).toBe('MISS'); + expect(mockGetVocabulary).toHaveBeenCalledTimes(1); + + const res2 = await fetch(`${baseUrl}/${CH}/file-vocabulary`); + expect(res2.status).toBe(200); + expect(res2.headers.get('x-cache')).toBe('HIT'); + expect(mockGetVocabulary).toHaveBeenCalledTimes(1); + }); +}); + +describe('B8:GET /:id 不再全量 countMessages', () => { + it('响应无 prisma 遗留 _count 字段,且不调用 fileStore.countMessages', async () => { + const countSpy = vi.spyOn(FileStore.prototype, 'countMessages'); + try { + const res = await fetch(`${baseUrl}/${CH}`); + expect(res.status).toBe(200); + const body = await res.json() as { success: boolean; data: Record }; + expect(body.success).toBe(true); + expect(body.data.id).toBe(CH); + expect(body.data._count).toBeUndefined(); + expect(countSpy).not.toHaveBeenCalled(); + } finally { + countSpy.mockRestore(); + } + }); +}); diff --git a/apps/api/src/modules/channels/__tests__/channel-members.test.ts b/apps/api/src/modules/channels/__tests__/channel-members.test.ts index 3accfa689..5d0844302 100644 --- a/apps/api/src/modules/channels/__tests__/channel-members.test.ts +++ b/apps/api/src/modules/channels/__tests__/channel-members.test.ts @@ -116,6 +116,38 @@ describe('AC-B1+B2+B3: Channel Members', () => { expect(result).toEqual([]); }); + it('add non-existent profile id → ChannelError(400), members not written', async () => { + const ch = await createTestChannel('#test-b2-add-unknown'); + testChannelIds.push(ch.id); + + await expect(channelService.updateMembers(ch.id, { add: ['no-such-profile-id'] })) + .rejects.toMatchObject({ name: 'ChannelError', status: 400 }); + const stored = await fileStore.getChannel(ch.id); + expect(stored?.members).toBe('[]'); + }); + + it('add undefined entry → ChannelError(400), members not written ("[null]" 防回归)', async () => { + const ch = await createTestChannel('#test-b2-add-undefined'); + testChannelIds.push(ch.id); + + await expect(channelService.updateMembers(ch.id, { add: [undefined as unknown as string] })) + .rejects.toMatchObject({ name: 'ChannelError', status: 400 }); + const stored = await fileStore.getChannel(ch.id); + expect(stored?.members).toBe('[]'); + }); + + it('remove non-string entry → ChannelError(400), members unchanged', async () => { + const agent = await createTestAgent(); + testAgentIds.push(agent.id); + const ch = await createTestChannel('#test-b2-rm-undefined', { members: JSON.stringify([agent.id]) }); + testChannelIds.push(ch.id); + + await expect(channelService.updateMembers(ch.id, { remove: [null as unknown as string] })) + .rejects.toMatchObject({ name: 'ChannelError', status: 400 }); + const stored = await fileStore.getChannel(ch.id); + expect(JSON.parse(stored!.members)).toContain(agent.id); + }); + it('empty body → members unchanged', async () => { const agent = await createTestAgent(); testAgentIds.push(agent.id); diff --git a/apps/api/src/modules/channels/__tests__/channel-message-service.test.ts b/apps/api/src/modules/channels/__tests__/channel-message-service.test.ts index 1c326ca99..38dac6528 100644 --- a/apps/api/src/modules/channels/__tests__/channel-message-service.test.ts +++ b/apps/api/src/modules/channels/__tests__/channel-message-service.test.ts @@ -430,3 +430,156 @@ describe('#529 listByWorkUnitId channelId 直查', () => { expect([...result.data.map(m => m.id)].sort()).toEqual([mA.id, mB.id].sort()); }); }); + +// ── #576:listByWorkUnitId 查询形态改造——对齐「取最新 N 条」语义并下推 limit(B5 快径遗留收口) ── +describe('#576 listByWorkUnitId limit 下推(命中 B5 谓词倒扫快径)', () => { + let fs576: FileStore; + let svc576: ChannelMessageService; + let ch576: string; + let dir576: string; + + beforeAll(async () => { + dir576 = path.join(os.tmpdir(), `channel-msg-576-${Date.now()}`); + fs576 = new FileStore(dir576); + svc576 = new ChannelMessageService(fs576); + ch576 = `ch-576-${Date.now()}`; + await fs576.createChannel({ + id: ch576, + name: `#${ch576}`, + type: 'rnd', + defaultWorkspaceId: null, + defaultPath: null, + discordChannelId: null, + discordWebhookUrl: null, + members: '[]', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }); + + afterAll(() => { + fs.rmSync(dir576, { recursive: true, force: true }); + }); + + it('limit 下推为 limit+1(取最新 N 条 + 判 hasMore),before 透传为 ISO 字符串', async () => { + await svc576.createHumanMessage(ch576, 'shape', undefined, 'wu-576-shape'); + const spyQuery = vi.spyOn(fs576, 'queryMessages'); + try { + await svc576.listByWorkUnitId('wu-576-shape', { + channelId: ch576, + limit: 50, + before: new Date('2027-01-01T00:00:00Z'), + }); + expect(spyQuery).toHaveBeenCalledTimes(1); + expect(spyQuery).toHaveBeenCalledWith(ch576, { + workUnitId: 'wu-576-shape', + before: '2027-01-01T00:00:00.000Z', + limit: 51, + }); + } finally { + spyQuery.mockRestore(); + } + }); + + it('匹配数超过 limit:返回最新 N 条(升序),total = limit+1 下界口径(路由 hasMore 为真)', async () => { + const ids: string[] = []; + for (let i = 1; i <= 15; i++) { + const m = await svc576.createHumanMessage(ch576, `hot-${i}`, undefined, 'wu-576-hot'); + ids.push(m.id); + } + + const result = await svc576.listByWorkUnitId('wu-576-hot', { channelId: ch576, limit: 10 }); + + expect(result.data.map(m => m.id)).toEqual(ids.slice(5)); + expect(result.total).toBe(11); // 下界:至少 11 条;精确总数需全扫,与有界扫描不可兼得 + }); + + it('匹配数不超过 limit:total 精确(hasMore 为假)', async () => { + await svc576.createHumanMessage(ch576, 'a', undefined, 'wu-576-few'); + await svc576.createHumanMessage(ch576, 'b', undefined, 'wu-576-few'); + + const result = await svc576.listByWorkUnitId('wu-576-few', { channelId: ch576, limit: 10 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('before + limit:取 before 之前最新 N 条(倒序翻页语义),createdAt 严格 <', async () => { + vi.useFakeTimers(); + try { + const ids: string[] = []; + for (let i = 1; i <= 5; i++) { + vi.setSystemTime(new Date(Date.UTC(2026, 8, 1, 0, 0, i))); + const m = await svc576.createHumanMessage(ch576, `p-${i}`, undefined, 'wu-576-page'); + ids.push(m.id); + } + + // before = 第 4 条时刻 → 严格小于 → m1..m3 + const result = await svc576.listByWorkUnitId('wu-576-page', { + channelId: ch576, + limit: 10, + before: new Date(Date.UTC(2026, 8, 1, 0, 0, 4)), + }); + + expect(result.data.map(m => m.id)).toEqual(ids.slice(0, 3)); + expect(result.total).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('缺 channelId 扇出 fallback:每频道下推 limit+1,归并后仍取全局最新 N 条', async () => { + const ids: string[] = []; + for (let i = 1; i <= 15; i++) { + const m = await svc576.createHumanMessage(ch576, `fan-${i}`, undefined, 'wu-576-fanout'); + ids.push(m.id); + } + + const result = await svc576.listByWorkUnitId('wu-576-fanout', { limit: 10 }); + + expect(result.data.map(m => m.id)).toEqual(ids.slice(5)); + expect(result.total).toBe(11); + }); + + it('3000 行热文件:直查不走 readJsonl 全量读口(倒扫行数上界由 studio-shared 热扫测试断言)', async () => { + const big = `ch-576-big-${Date.now()}`; + await fs576.createChannel({ + id: big, + name: `#${big}`, + type: 'rnd', + defaultWorkspaceId: null, + defaultPath: null, + discordChannelId: null, + discordWebhookUrl: null, + members: '[]', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + // 直写热文件:b1..b3000(createdAt 严格递增),wu-576-big = 尾部 30 条 + const rows: string[] = []; + for (let i = 1; i <= 3000; i++) { + rows.push(JSON.stringify({ + id: `b${i}`, + channelId: big, + workUnitId: i > 2970 ? 'wu-576-big' : null, + authorType: 'human', + agentName: null, + content: `big ${i}`, + replyToId: null, + meta: '{}', + createdAt: new Date(Date.UTC(2026, 0, 1) + i * 1000).toISOString(), + })); + } + fs.writeFileSync(path.join(dir576, 'channels', big, 'messages.jsonl'), rows.join('\n') + '\n'); + + const spyRead = vi.spyOn(fs576, 'readJsonl'); + try { + const result = await svc576.listByWorkUnitId('wu-576-big', { channelId: big, limit: 10 }); + expect(result.data.map(m => m.id)).toEqual(Array.from({ length: 10 }, (_, k) => `b${2991 + k}`)); + expect(result.total).toBe(11); // 30 条匹配 → 下界 limit+1 + expect(spyRead).not.toHaveBeenCalled(); // 未走 resolveActiveMessages 全量读口 + } finally { + spyRead.mockRestore(); + } + }); +}); diff --git a/apps/api/src/modules/channels/__tests__/channel.service.test.ts b/apps/api/src/modules/channels/__tests__/channel.service.test.ts index fc6fc964e..dc3a3ff64 100644 --- a/apps/api/src/modules/channels/__tests__/channel.service.test.ts +++ b/apps/api/src/modules/channels/__tests__/channel.service.test.ts @@ -164,6 +164,11 @@ describe('写路径内部失效缓存', () => { it('updateMembers → 合并写回 + 失效', async () => { const id = await seedChannel('members'); + // updateMembers 校验 add 的 profile 存在性——先确保 agent-a 存在 + if (!(await fileStore.getProfile('agent-a'))) { + const now = new Date().toISOString(); + await fileStore.createProfile({ id: 'agent-a', name: 'agent-a', description: null, channels: '[]', status: 'active', createdAt: now, updatedAt: now }); + } const members = await service.updateMembers(id, { add: ['agent-a'] }); expect(members).toEqual(['agent-a']); expect(clearCacheMock).toHaveBeenCalledWith('/api/v1/channels'); diff --git a/apps/api/src/modules/channels/__tests__/file-ref-vocabulary.test.ts b/apps/api/src/modules/channels/__tests__/file-ref-vocabulary.test.ts index 000ae66b2..306c3e207 100644 --- a/apps/api/src/modules/channels/__tests__/file-ref-vocabulary.test.ts +++ b/apps/api/src/modules/channels/__tests__/file-ref-vocabulary.test.ts @@ -5,7 +5,7 @@ * ∪ 杂务 PMO 工程,去重,频道内最近 WU 涉及工程优先(UX 划界,非安全边界)。 * 词表 = 各仓 git ls-files + 内存缓存;校验 = repo 在候选集 + path 在词表。 */ -import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -145,6 +145,33 @@ describe('computeCandidateRepos(候选集计算)', () => { const repos = await computeCandidateRepos(channelId, deps); expect(repos).toEqual(['/repo/a']); }); + + it('B7:候选集结果短 TTL 缓存——TTL 内二次调用零下游重读,过期后重算', async () => { + await createChannel('ws-1'); + await createWuWithRoot('/repo/default'); + let nowMs = 1_000_000; + const deps: FileRefVocabularyDeps = { + fileStore, + now: () => nowMs, + resolveWorkspaceRoot: async () => '/repo/default', + getProject: async () => null, + findChoreProject: async () => null, + }; + // getIndex 全量快照 + 逐条 metadata JSON.parse 是每请求主税基,以其调用次数作缓存命中探针 + const indexSpy = vi.spyOn(fileStore, 'getIndex'); + + const first = await computeCandidateRepos(channelId, deps); + expect(first).toEqual(['/repo/default']); + expect(indexSpy).toHaveBeenCalledTimes(1); + + const second = await computeCandidateRepos(channelId, deps); + expect(second).toEqual(first); + expect(indexSpy).toHaveBeenCalledTimes(1); // TTL 内不重读 + + nowMs += 11_000; // 越过默认候选集 TTL(10s)→ 重算 + await computeCandidateRepos(channelId, deps); + expect(indexSpy).toHaveBeenCalledTimes(2); + }); }); describe('getChannelFileVocabulary(git ls-files 词表 + 内存缓存)', () => { @@ -229,6 +256,26 @@ describe('listChannelReqPmoProjects(频道 REQ 挂接 PMO 工程共用查询 await expect(listChannelReqPmoProjects(channelId, { fileStore: broken })) .rejects.toThrow('disk gone'); }); + + it('B7:多条挂接并行解析(Promise.all)——全部 getProject 启动后才逐个完成', async () => { + await createRequirement('REQ-0001', 'p1'); + await createRequirement('REQ-0002', 'p2'); + await createRequirement('REQ-0003', 'p3'); + const started: string[] = []; + const resolvers: Array<() => void> = []; + const pending = listChannelReqPmoProjects(channelId, { + fileStore, + getProject: (id: string) => new Promise<{ gitRepo: string } | null>(resolve => { + started.push(id); + resolvers.push(() => resolve({ gitRepo: `/repo/${id}` })); + }), + }); + // 串行 await 实现下此处只会启动第一条;并行实现三条全部已启动 + await vi.waitFor(() => expect(started).toHaveLength(3)); + for (const r of resolvers) r(); + const links = await pending; + expect(links.map(l => l.projectId)).toEqual(['p1', 'p2', 'p3']); // 保序 + }); }); describe('validateFileRefs(存在性校验)', () => { diff --git a/apps/api/src/modules/channels/channel-message.service.ts b/apps/api/src/modules/channels/channel-message.service.ts index 873d8aa7b..bf1ce4055 100644 --- a/apps/api/src/modules/channels/channel-message.service.ts +++ b/apps/api/src/modules/channels/channel-message.service.ts @@ -266,41 +266,54 @@ export class ChannelMessageService { * Returns messages ordered by createdAt ascending (chronological). * #524 P1-1 同构(#529 收口):channelId 指定时按频道直查,免全频道扇出 * (原实现耗时随频道数 × 各频道热层行数线性变差)。 + * #576(B5 遗留收口):对齐「取最新 N 条」语义并下推 limit(实取 limit+1 判是否还有 + * 更旧消息),带过滤查询命中 file-store 谓词倒扫快径,扫描行数不随热文件全长增长。 + * 有意识的语义变更(票内 AC 许可并在此记录):原「取最旧 N 条 + 精确 total」→ + * 现「取最新 N 条 + total 下界口径」——匹配数 ≤ limit 时 total 精确;超过 limit 时 + * total = limit+1 表「至少 limit+1 条」(精确总数需全扫,与有界扫描不可兼得; + * 路由 hasMore = data.length < total 两种情形下均正确)。before(createdAt 严格 <) + * 随 limit 一同下推,组合语义 = before 之前最新 N 条(倒序翻页)。 */ async listByWorkUnitId( workUnitId: string, options?: { channelId?: string; before?: Date; limit?: number }, ): Promise<{ data: MessageRecord[]; total: number }> { const limit = options?.limit ?? 50; - const since = options?.before ? options.before.toISOString() : undefined; + const before = options?.before ? options.before.toISOString() : undefined; + // limit+1:多取一条判「还有更旧消息」;limit<=0 = 全量(旧语义保留,不下推) + const pageLimit = limit > 0 ? limit + 1 : undefined; - let allMessages: ChannelMessageData[]; + let matched: ChannelMessageData[]; if (options?.channelId) { // #529:归属频道直查。channelId = WU 一等列 wu.channelId(写侧讨论消息 // 全部锚定它:POST /:id/messages、wu-messenger、派单 linkWorkUnit)。 - allMessages = await this.fileStore.queryMessages(options.channelId, { workUnitId }); + matched = await this.fileStore.queryMessages(options.channelId, { workUnitId, before, limit: pageLimit }); } else { // fallback(根因):无 channelId 的 WU(legacy/手工单)——写侧 POST /:id/messages // 对这类 WU 落第一个 #研发 频道,讨论消息可能散在任一频道,只能保留扇出。 - allMessages = []; + // 每频道各取最新 pageLimit 条再归并截断:全局最新 N ⊆ 各频道最新 N 之并,不丢。 + matched = []; const allChannels = await this.fileStore.listChannels(); for (const ch of allChannels) { - const msgs = await this.fileStore.queryMessages(ch.id, { workUnitId }); - allMessages = allMessages.concat(msgs); + const msgs = await this.fileStore.queryMessages(ch.id, { workUnitId, before, limit: pageLimit }); + matched = matched.concat(msgs); + } + matched.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + if (pageLimit !== undefined && matched.length > pageLimit) { + matched = matched.slice(-pageLimit); } } - if (since) { - allMessages = allMessages.filter(m => new Date(m.createdAt).getTime() < new Date(since).getTime()); + if (pageLimit !== undefined && matched.length > limit) { + // 还有更旧消息:data = 最新 limit 条;total = limit+1 下界(精确总数未计算) + return { + data: matched.slice(-limit).map(m => shapeMessageData(m)), + total: pageLimit, + }; } - - allMessages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - const total = allMessages.length; - const sliced = limit > 0 ? allMessages.slice(0, limit) : allMessages; - return { - data: sliced.map(m => shapeMessageData(m)), - total, + data: matched.map(m => shapeMessageData(m)), + total: matched.length, }; } diff --git a/apps/api/src/modules/channels/channel.routes.ts b/apps/api/src/modules/channels/channel.routes.ts index 7f35cd575..aa2f41db4 100644 --- a/apps/api/src/modules/channels/channel.routes.ts +++ b/apps/api/src/modules/channels/channel.routes.ts @@ -67,15 +67,17 @@ router.post('/', requireAuth(), requireNotGuest(), handle(async (req, res) => { })); // GET /api/v1/channels/:id — get channel detail +// B8(2026-09-16 channel 性能审计):去掉 prisma 时代遗留的 `_count.ChannelMessage` +// (全仓无消费方,每请求 O(热文件行数) 全量 countMessages 纯浪费) router.get('/:id', requireAuth(), handle(async (req, res) => { const channel = await channelService.getOrThrow(req.params.id); - const messageCount = await fileStore.countMessages(req.params.id); - res.json({ success: true, data: { ...channel, _count: { ChannelMessage: messageCount } } }); + res.json({ success: true, data: channel }); })); // GET /api/v1/channels/:id/current-pmo — #272(决策 #251 Q6):顶栏「当前 PMO」chip // 派生概念不落库:最近挂接 REQ 所属 PMO → 杂务 PMO 反推 → null(见 current-pmo.ts)。 -router.get('/:id/current-pmo', requireAuth(), handle(async (req, res) => { +// B7:派生链为 N+1 全量读取,挂短 TTL apiCache(5s 档,同 GET / 列表先例)。 +router.get('/:id/current-pmo', requireAuth(), apiCache(CACHE_CONFIG.short), handle(async (req, res) => { await channelService.getOrThrow(req.params.id); const pmo = await deriveChannelCurrentPmo(req.params.id); res.json({ success: true, data: pmo }); @@ -117,7 +119,8 @@ router.get('/:id/messages', requireAuth(), async (req, res) => { // GET /api/v1/channels/:id/file-vocabulary — #281:@文件引用只读词表 // 候选集 = 频道相关工程(默认工程 ∪ REQ 挂接 PMO ∪ 杂务 PMO,最近使用优先), // 各仓 git ls-files + 内存缓存(见 file-ref-vocabulary.ts)。 -router.get('/:id/file-vocabulary', requireAuth(), handle(async (req, res) => { +// B7:挂短 TTL apiCache(5s 档)——词表进程缓存之外再挡一层 HTTP 级重复派生。 +router.get('/:id/file-vocabulary', requireAuth(), apiCache(CACHE_CONFIG.short), handle(async (req, res) => { await channelService.getOrThrow(req.params.id); try { const vocabulary = await getChannelFileVocabulary(req.params.id); diff --git a/apps/api/src/modules/channels/channel.service.ts b/apps/api/src/modules/channels/channel.service.ts index 81cdc26a4..28462ac2a 100644 --- a/apps/api/src/modules/channels/channel.service.ts +++ b/apps/api/src/modules/channels/channel.service.ts @@ -191,6 +191,22 @@ export class ChannelService { const addIds: string[] = ops.add ?? []; const removeIds: string[] = ops.remove ?? []; + // 成员 id 校验:非字符串/空串一律 400(防止 {add:[undefined]} 被存成 "[null]"—— + // 频道看似有成员实际没有,涌现单无人认领);add 额外要求 profile 存在。 + // remove 保持幂等:移除不存在的 profile id 视为 no-op(允许清理悬空成员)。 + for (const id of [...addIds, ...removeIds]) { + if (typeof id !== 'string' || id.length === 0) { + throw new ChannelError('member id must be a non-empty string', 400); + } + } + if (addIds.length > 0) { + const known = new Set((await this.fileStore.listProfiles()).map(p => p.id)); + const unknown = addIds.filter(id => !known.has(id)); + if (unknown.length > 0) { + throw new ChannelError(`Profile not found: ${unknown.join(', ')}`, 400); + } + } + const updated = [...new Set([...current, ...addIds])].filter(id => !removeIds.includes(id)); await this.fileStore.updateChannel(channelId, { members: JSON.stringify(updated) }); diff --git a/apps/api/src/modules/channels/file-ref-vocabulary.ts b/apps/api/src/modules/channels/file-ref-vocabulary.ts index cf3c8ffb4..e33309946 100644 --- a/apps/api/src/modules/channels/file-ref-vocabulary.ts +++ b/apps/api/src/modules/channels/file-ref-vocabulary.ts @@ -12,6 +12,7 @@ * 词表 = 各仓 `git ls-files` 原样相对路径 + 进程内存缓存(TTL,默认 60s, * 同 project-discovery 先例)。单仓失败(非 git 仓/权限/超时)→ 该仓空词表, * 不拖垮其他仓;失败同样入缓存防热路径反复 spawn。 + * 候选集本身也带短 TTL 进程缓存(默认 10s,B7)——避免每请求全量 WU metadata 解析。 * * 校验(路由层存在性校验用):repo 不在候选集 → not-in-candidate-set; * path 不在该仓词表 → not-found。各数据源读取失败仅记日志并跳过该来源, @@ -53,6 +54,8 @@ export interface FileRefVocabularyDeps { listFiles?: (repo: string) => Promise; /** 词表缓存 TTL(默认 60_000ms) */ cacheTtlMs?: number; + /** 候选集缓存 TTL(默认 CANDIDATE_CACHE_TTL_MS=10_000ms) */ + candidateTtlMs?: number; now?: () => number; } @@ -84,20 +87,22 @@ export async function listChannelReqPmoProjects

( const getProject = deps.getProject ?? (async (id: string) => (await projectService.get(id)) as unknown as P | null); const requirements = await fileStore.listRequirements({ channelId }); - const links: ChannelReqPmoLink

[] = []; - for (const req of requirements) { + // B7(2026-09-16 channel 性能审计):多项目解析并行发出(Promise.all), + // 原为逐条串行 await(N+1 串行延迟叠加);Promise.all 保输入序,输出序不变。 + const results = await Promise.all(requirements.map(async (req): Promise | null> => { const projectId = (req as { projectId?: string | null }).projectId; - if (!projectId) continue; + if (!projectId) return null; try { const project = await getProject(projectId); - if (project) links.push({ reqId: req.id, seq: req.seq, projectId, project }); + return project ? { reqId: req.id, seq: req.seq, projectId, project } : null; } catch (err) { logger.warn('[ChannelReqPmo] REQ project resolution failed, skipped', { channelId, reqId: req.id, projectId, error: String(err), }); + return null; } - } - return links; + })); + return results.filter((l): l is ChannelReqPmoLink

=> l !== null); } /** 工程记录 → 仓路径清单(gitRepo + deliveries[].gitRepo,去重保序;#272 当前 PMO chip 复用) */ @@ -121,9 +126,20 @@ export function reposOfProject(project: ProjectLike | null): string[] { const vocabCache = new Map(); -/** 测试/运维用:清空词表缓存 */ +// ─── 候选集内存缓存(进程级;channelId 为键;B7,2026-09-16 channel 性能审计) ─── +// computeCandidateRepos 每请求要读频道记录 + REQ 列表 + getIndex 全量快照并逐条 +// JSON.parse WU metadata——发消息路径(validateFileRefs)与词表端点都会触发。 +// 候选集由频道配置/REQ 挂接/WU 归属派生,短 TTL(默认 10s)内容忍秒级滞后。 + +/** 候选集缓存默认 TTL(deps.candidateTtlMs 可覆盖) */ +export const CANDIDATE_CACHE_TTL_MS = 10_000; + +const candidateCache = new Map(); + +/** 测试/运维用:清空词表缓存与候选集缓存 */ export function invalidateFileRefVocabularyCache(): void { vocabCache.clear(); + candidateCache.clear(); } /** 默认词表来源:git ls-files(原样相对路径,一行一条) */ @@ -162,10 +178,25 @@ async function getRepoFiles(repo: string, deps: FileRefVocabularyDeps): Promise< /** * 候选集计算:默认工程 ∪ REQ 挂接 PMO ∪ 杂务 PMO,去重,最近 WU 涉及工程优先。 * 每个来源独立容错:读取失败记日志并跳过该来源。 + * B7:结果带短 TTL 进程缓存(CANDIDATE_CACHE_TTL_MS),TTL 内零下游重读。 */ export async function computeCandidateRepos( channelId: string, deps: FileRefVocabularyDeps = {}, +): Promise { + const nowMs = (deps.now ?? Date.now)(); + const ttl = deps.candidateTtlMs ?? CANDIDATE_CACHE_TTL_MS; + const hit = candidateCache.get(channelId); + if (hit && nowMs - hit.at < ttl) return hit.repos; + const repos = await computeCandidateReposFresh(channelId, deps); + candidateCache.set(channelId, { repos, at: nowMs }); + return repos; +} + +/** 候选集实时计算本体(无缓存;缓存壳见 computeCandidateRepos) */ +async function computeCandidateReposFresh( + channelId: string, + deps: FileRefVocabularyDeps = {}, ): Promise { const fileStore = deps.fileStore ?? new FileStore(); const getProject = deps.getProject ?? (async (id: string) => projectService.get(id)); diff --git a/apps/api/src/modules/discord/CONTEXT.md b/apps/api/src/modules/discord/CONTEXT.md index a2e5d8566..a43e99cef 100644 --- a/apps/api/src/modules/discord/CONTEXT.md +++ b/apps/api/src/modules/discord/CONTEXT.md @@ -14,9 +14,9 @@ ### 依赖关系 **上游(本目录依赖):** -- `@dommaker/studio-shared`:提供 `FileStore`、`eventBus`、`WorkUnitSnapshot`、`logger` +- `@dommaker/studio-shared`:提供 `FileStore`、`logger` - `../channels/channel-message.service.ts`:`channelMessageService` -- `../workunit/workunit.service.ts`:`WorkUnitService` +- `../workunit/workunit.service.ts`:`WorkUnitService`(#550 起关闭走 `close()` 状态机单口,wu-closure 已删) - `../../utils/logger.ts`:logger - `express`、`crypto` 等标准库 @@ -29,3 +29,4 @@ - 必须配置环境变量 `DISCORD_PUBLIC_KEY`,否则交互端点返回 500 - `triggerRequirement` 依赖 `#研发` 频道存在,否则抛出错误 - WorkUnit 创建时 `creationMode` 标记为 `'discord'`,用于区分来源 +- **WU 写路径走 service 单口(#538,ADR 2026-09-15 决策 2)**:按钮 retry/retry-new = `unclaim` 回池 + 重试标记(resumeAfterRetry/extraRounds/freshPrompt)经 WorkUnitService `updateMetadata` 语义口锁内合并(#554 起不再直摸 FileStore 原语);abandon 与 `/studio stop` = `WorkUnitService.close()`(#550 状态机单口,closedBy: human-command,补 closedAt + status_changed + workunit:closed 记录 + 频道出声;decision/spec 无 closed 边 → 状态机抛错拒绝;#555 起路由层区分「not found」与「状态机拒绝/落库失败」,后者回真实原因不再误报 not found)。旧 closeAndEmit/updateWorkUnitStatus 直写原语已删(metadata 整写覆盖、不写 closedAt、不发事件三宗罪),legacy `events:goal-execution` 事件随 Goal 体系退役停发。测试:`__tests__/routes.test.ts`(按钮两路径 + stop 收口)与 `__tests__/routes-stop.test.ts`(stop 委托 agentRunner) diff --git a/apps/api/src/modules/discord/__tests__/routes.test.ts b/apps/api/src/modules/discord/__tests__/routes.test.ts new file mode 100644 index 000000000..d02cf14d1 --- /dev/null +++ b/apps/api/src/modules/discord/__tests__/routes.test.ts @@ -0,0 +1,251 @@ +/** + * Discord interactions 路由测试(#538 从零补齐——现状零覆盖) + * + * 钉住 ADR 2026-09-15 决策 2 的收口语义: + * - retry / retry-new 按钮 → unclaim 回池 + 重试标记经 updateMetadata 语义口合并 + * (#554:patch 并入走 WorkUnitService,路由零直摸 FileStore 写原语; + * 旧实现整写 metadata 覆盖摧毁既有键、直摸 commitSnapshot 绕过 service 层) + * - abandon 按钮 / /studio stop → WorkUnitService.close 状态机单口(#550) + * (closedBy: human-command,补 closedAt + status_changed + workunit:closed 记录 + 频道出声) + * - 停发 legacy events:goal-execution 事件(Goal 体系已退役) + * + * 签名验证走真 Ed25519(测试内生成密钥对),路由层契约同 trigger-fire.routes.test.ts 模式: + * 真 express server + fetch;FileStore / WorkUnitService / agentRunner mock。 + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import express from 'express'; +import type { Server } from 'node:http'; +import { generateKeyPairSync, sign } from 'node:crypto'; + +const { mockFileStore, mockUnclaim, mockUpdateMetadata, mockClose, mockAgentStop, mockEventBusPublish } = vi.hoisted(() => ({ + mockFileStore: { + getIndex: vi.fn(), + updateMetadata: vi.fn(), + commitSnapshot: vi.fn(), + commitRemoval: vi.fn(), + }, + mockUnclaim: vi.fn(), + mockUpdateMetadata: vi.fn(), + mockClose: vi.fn(), + mockAgentStop: vi.fn(), + mockEventBusPublish: vi.fn(), +})); + +vi.mock('@dommaker/studio-shared', async (importOriginal) => { + const orig = await importOriginal() as Record; + return { + ...orig, + FileStore: vi.fn(function () { return mockFileStore; }), + eventBus: { publish: mockEventBusPublish, subscribe: vi.fn(), unsubscribe: vi.fn() }, + }; +}); + +vi.mock('../../workunit/workunit.service.js', () => ({ + WorkUnitService: vi.fn(function () { return { unclaim: mockUnclaim, close: mockClose, updateMetadata: mockUpdateMetadata }; }), +})); + +vi.mock('@dommaker/studio-agent', () => ({ + agentRunner: { stop: mockAgentStop }, +})); + +import router from '../routes.js'; + +// 真 Ed25519 密钥对:公钥导出 raw 32 字节(spki der 尾 32B),与路由的 verifyDiscordSignature 同口径 +const { publicKey, privateKey } = generateKeyPairSync('ed25519'); +const PUBLIC_KEY_HEX = Buffer.from(publicKey.export({ format: 'der', type: 'spki' })).subarray(-32).toString('hex'); + +function signBody(body: string, timestamp: string): string { + return sign(null, Buffer.from(timestamp + body, 'utf-8'), privateKey).toString('hex'); +} + +function makeSnapshot(id: string, overrides: Record = {}) { + return { + id, + parentId: null, + type: 'task', + scope: `scope-${id}`, + assigneeId: 'agent-1', + status: 'active', + failureType: null, + retryCount: 0, + timeoutAt: null, + channelId: 'ch-1', + projectPath: null, + metadata: '{"keep":"me"}', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + claimedAt: new Date().toISOString(), + completedAt: null, + ...overrides, + }; +} + +describe('discord interactions 路由(#538 写路径收口)', () => { + let server: Server; + let base: string; + + beforeAll(async () => { + process.env.DISCORD_PUBLIC_KEY = PUBLIC_KEY_HEX; + const app = express(); + app.use(express.raw({ type: () => true })); + app.use('/discord', router); + await new Promise(resolve => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + base = `http://127.0.0.1:${port}/discord`; + }); + + afterAll(async () => { + delete process.env.DISCORD_PUBLIC_KEY; + await new Promise(resolve => server.close(() => resolve())); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockClose.mockResolvedValue(true); + mockUnclaim.mockResolvedValue({}); + mockUpdateMetadata.mockResolvedValue({}); + }); + + async function postInteraction(payload: unknown, opts?: { badSignature?: boolean }) { + const body = JSON.stringify(payload); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = opts?.badSignature ? 'deadbeef' : signBody(body, timestamp); + return fetch(`${base}/interactions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-signature-ed25519': signature, + 'x-signature-timestamp': timestamp, + }, + body, + }); + } + + const button = (customId: string) => ({ type: 3, data: { component_type: 2, custom_id: customId } }); + + it('签名无效 → 401(签名验证优先于业务逻辑)', async () => { + const res = await postInteraction(button('abandon:wu-1'), { badSignature: true }); + expect(res.status).toBe(401); + expect(mockClose).not.toHaveBeenCalled(); + }); + + it('retry 按钮 → unclaim 回池 + updateMetadata 语义口合并(resumeAfterRetry + extraRounds),零直写', async () => { + const res = await postInteraction(button('retry:wu-1:3')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('🔁'); + expect(body.data.content).toContain('3'); + + expect(mockUnclaim).toHaveBeenCalledWith('wu-1'); + // #554:重试标记走 service 语义口(patch 并入,锁内合并语义由 service/FileStore 层测试钉住) + expect(mockUpdateMetadata).toHaveBeenCalledTimes(1); + expect(mockUpdateMetadata).toHaveBeenCalledWith('wu-1', { resumeAfterRetry: true, extraRounds: 3 }); + // 不直摸 FileStore 写原语(含 updateMetadata) + expect(mockFileStore.updateMetadata).not.toHaveBeenCalled(); + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + expect(mockFileStore.commitRemoval).not.toHaveBeenCalled(); + }); + + it('retry-new 按钮 → unclaim + updateMetadata 语义口合并(resumeAfterRetry + freshPrompt)', async () => { + const res = await postInteraction(button('retry-new:wu-1')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('🔄'); + + expect(mockUnclaim).toHaveBeenCalledWith('wu-1'); + expect(mockUpdateMetadata).toHaveBeenCalledWith('wu-1', { resumeAfterRetry: true, freshPrompt: true }); + expect(mockFileStore.updateMetadata).not.toHaveBeenCalled(); + }); + + it('abandon 按钮 → WorkUnitService.close 状态机单口(human-command),不直写不发 legacy 事件', async () => { + const snap = makeSnapshot('wu-1'); + mockFileStore.getIndex.mockResolvedValue([snap]); + + const res = await postInteraction(button('abandon:wu-1')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('已放弃'); + + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockClose).toHaveBeenCalledWith('wu-1', { reason: 'Abandoned by user via Discord', closedBy: 'human-command' }); + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + // legacy events:goal-execution 停发(Goal 体系已退役) + expect(mockEventBusPublish).not.toHaveBeenCalledWith('events:goal-execution', expect.anything()); + }); + + it('abandon 目标存在但状态机拒绝(decision/spec 无 closed 边)→ 回真实原因而非 not found(#555)', async () => { + const snap = makeSnapshot('wu-2', { type: 'decision', status: 'done' }); + mockFileStore.getIndex.mockResolvedValue([snap]); + mockClose.mockRejectedValue(new Error('Invalid status transition: done → closed')); + + const res = await postInteraction(button('abandon:wu-2')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).not.toMatch(/not found/i); + expect(body.data.content).toContain('Invalid status transition: done → closed'); + }); + + it('abandon 目标存在但落库失败 → 回真实原因而非 not found(#555)', async () => { + const snap = makeSnapshot('wu-3'); + mockFileStore.getIndex.mockResolvedValue([snap]); + mockClose.mockRejectedValue(new Error('commitSnapshot failed: disk full')); + + const res = await postInteraction(button('abandon:wu-3')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).not.toMatch(/not found/i); + expect(body.data.content).toContain('commitSnapshot failed: disk full'); + }); + + it('abandon 目标不存在 → 不关闭,回 not found', async () => { + mockFileStore.getIndex.mockResolvedValue([]); + + const res = await postInteraction(button('abandon:wu-404')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toMatch(/not found/i); + expect(mockClose).not.toHaveBeenCalled(); + }); + + it('/studio stop → WorkUnitService.close 状态机单口 + agentRunner.stop,停发 legacy 事件', async () => { + const exec = makeSnapshot('exec-abc123', { status: 'active' }); + mockFileStore.getIndex.mockResolvedValue([exec]); + + const res = await postInteraction({ + type: 2, + data: { + name: 'studio', + options: [{ name: 'stop', options: [{ name: 'execution_id', value: 'exec-abc123' }] }], + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('✅ Stopped'); + + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockClose).toHaveBeenCalledWith('exec-abc123', { reason: 'Stopped by user via Discord', closedBy: 'human-command' }); + expect(mockAgentStop).toHaveBeenCalledWith('exec-abc123'); + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + expect(mockEventBusPublish).not.toHaveBeenCalledWith('events:goal-execution', expect.anything()); + }); + + it('/studio stop 终态 WU → 拒绝且不关闭', async () => { + const exec = makeSnapshot('exec-done', { status: 'done' }); + mockFileStore.getIndex.mockResolvedValue([exec]); + + const res = await postInteraction({ + type: 2, + data: { + name: 'studio', + options: [{ name: 'stop', options: [{ name: 'execution_id', value: 'exec-done' }] }], + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('Cannot stop'); + expect(mockClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/discord/routes.ts b/apps/api/src/modules/discord/routes.ts index 356f174d8..0b0196054 100644 --- a/apps/api/src/modules/discord/routes.ts +++ b/apps/api/src/modules/discord/routes.ts @@ -6,10 +6,12 @@ */ import express, { Router, Request, Response } from 'express'; -import { FileStore, eventBus, type WorkUnitSnapshot } from '@dommaker/studio-shared'; +import { FileStore } from '@dommaker/studio-shared'; import { logger } from '../../utils/logger.js'; +import { WorkUnitService } from '../workunit/workunit.service.js'; const router = express.Router(); const fileStore = new FileStore(); +const workUnitService = new WorkUnitService(fileStore); // Discord Interaction Types const InteractionType = { @@ -207,15 +209,10 @@ router.post('/interactions', async (req: Request, res: Response): Promise res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `Cannot stop execution with status: ${match.status}` } }); return; } - await closeAndEmit(match.id, 'Stopped by user via Discord'); + await closeWorkUnit(match.id, 'Stopped by user via Discord'); // Try to kill running child process (agentRunner 持有真实的 runningProcesses) const { agentRunner } = await import('@dommaker/studio-agent'); await agentRunner.stop(match.id); - // Publish event - eventBus.publish('events:goal-execution', { - event_type: 'goal-execution.updated', - data: { executionId: match.id, status: 'failed', error: 'Stopped by user via Discord' }, - }); res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `✅ Stopped execution \`${match.id.slice(0, 8)}\`` } }); return; } @@ -228,18 +225,12 @@ router.post('/interactions', async (req: Request, res: Response): Promise return; } - await closeAndEmit(exec.id, 'Stopped by user via Discord'); + await closeWorkUnit(exec.id, 'Stopped by user via Discord'); // Try to kill running child process (agentRunner 持有真实的 runningProcesses) const { agentRunner } = await import('@dommaker/studio-agent'); await agentRunner.stop(exec.id); - // Publish event so GoalScheduler picks up the change - eventBus.publish('events:goal-execution', { - event_type: 'goal-execution.updated', - data: { executionId: exec.id, status: 'failed', error: 'Stopped by user via Discord' }, - }); - res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `✅ Stopped execution \`${exec.id.slice(0, 8)}\`` } }); } catch (err: any) { res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `❌ Failed to stop: ${err.message?.slice(0, 300) || String(err)}` } }); @@ -273,20 +264,29 @@ router.post('/interactions', async (req: Request, res: Response): Promise if (action === 'retry') { const extraRounds = parseInt(parts[2] || '2', 10); - await updateWorkUnitStatus(targetId, 'unassigned', { resumeAfterRetry: true, extraRounds }); + await resetForRetry(targetId, { extraRounds }); res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `🔁 已重置,再给 ${extraRounds} 轮` } }); return; } if (action === 'retry-new') { - await updateWorkUnitStatus(targetId, 'unassigned', { resumeAfterRetry: true, freshPrompt: true }); + await resetForRetry(targetId, { freshPrompt: true }); res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `🔄 已重置,换了新方向` } }); return; } if (action === 'abandon') { - await closeAndEmit(targetId, 'Abandoned by user via Discord'); - res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `❌ 已放弃` } }); + const result = await closeWorkUnit(targetId, 'Abandoned by user via Discord'); + // #555:区分「不存在」与「状态机拒绝/落库失败」——后者回真实原因,不误报 not found + const content = result.closed + ? `❌ 已放弃` + : result.error + ? `⚠️ 关闭失败: ${result.error.slice(0, 300)}` + : `WorkUnit not found: ${targetId}`; + res.json({ + type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content }, + }); return; } @@ -301,53 +301,34 @@ router.post('/interactions', async (req: Request, res: Response): Promise }); /** - * 关闭 WorkUnit 并发布事件 + * 关闭 WorkUnit(#550,ADR 2026-09-15 决策 2 收口延续):走 WorkUnitService.close + * 状态机单口——补 closedAt、发 status_changed、落 workunit:closed 结构化记录、频道出声; + * decision/spec 无 closed 边 → 状态机拒绝(抛错)。旧实现(closeAndEmit)手拼快照整写: + * metadata 整写覆盖摧毁既有 metadata、不写 closedAt、不发任何事件;legacy + * events:goal-execution 随 Goal 体系退役停发。 + * #555:返回值区分三种结局——closed(成功)、not found(error 缺省)、 + * 状态机拒绝/落库失败(error 携带真实原因),调用方不再把后两者混报为 not found。 */ -async function closeAndEmit(wuId: string, reason: string): Promise { +async function closeWorkUnit(wuId: string, reason: string): Promise<{ closed: boolean; error?: string }> { const snap = (await fileStore.getIndex({ id: wuId }))[0]; - if (!snap) return; - - const now = new Date().toISOString(); - const updated: WorkUnitSnapshot = { - ...snap, - status: 'closed', - metadata: JSON.stringify({ error: reason }), - updatedAt: now, - }; - - await fileStore.commitSnapshot( - { type: 'closed', wuId, timestamp: now, data: updated as unknown as Record }, - updated, - ); - eventBus.publish('events:goal-execution', { - event_type: 'goal-execution.updated', - data: { executionId: wuId, status: 'failed', error: reason }, - }); + if (!snap) return { closed: false }; + try { + await workUnitService.close(wuId, { reason, closedBy: 'human-command' }); + return { closed: true }; + } catch (err) { + logger.warn({ workUnitId: wuId, error: String(err) }, '[Discord] close workUnit failed'); + return { closed: false, error: err instanceof Error ? err.message : String(err) }; + } } /** - * 更新 WorkUnit 状态和 metadata + * 重置 WorkUnit 回池重试(#538,ADR 2026-09-15 决策 2):unclaim 回 unassigned + * (清 assignee + 发 status_changed)+ 重试标记经 updateMetadata 语义口锁内合并 + * (#554:patch 基于锁内最新 metadata 并入,既有键保留——旧实现整写覆盖摧毁它们)。 */ -async function updateWorkUnitStatus(wuId: string, status: string, extraMeta: Record): Promise { - const snap = (await fileStore.getIndex({ id: wuId }))[0]; - if (!snap) throw new Error(`WorkUnit not found: ${wuId}`); - - const now = new Date().toISOString(); - let meta: Record = {}; - try { meta = snap.metadata ? JSON.parse(snap.metadata) : {}; } catch {} - Object.assign(meta, extraMeta); - - const updated: WorkUnitSnapshot = { - ...snap, - status, - metadata: JSON.stringify(meta), - updatedAt: now, - }; - - await fileStore.commitSnapshot( - { type: 'updated', wuId, timestamp: now, data: updated as unknown as Record }, - updated, - ); +async function resetForRetry(wuId: string, retryMeta: Record): Promise { + await workUnitService.unclaim(wuId); + await workUnitService.updateMetadata(wuId, { resumeAfterRetry: true, ...retryMeta }); } export default router; diff --git a/apps/api/src/modules/events/CONTEXT.md b/apps/api/src/modules/events/CONTEXT.md index f41d80619..b39eab7a6 100644 --- a/apps/api/src/modules/events/CONTEXT.md +++ b/apps/api/src/modules/events/CONTEXT.md @@ -14,7 +14,7 @@ | sse.routes.ts | GET /api/v1/events/stream | SSE 实时事件流(#491 起消费 Last-Event-ID:重连补发 replay buffer 窗口内遗漏事件) | | sse.routes.ts | GET /api/v1/events/clients | SSE 客户端列表 (debug) | | sse-replay-buffer.ts | SseReplayBuffer | #491:SSE 短窗口内存 replay buffer(环形,容量 500,id = 服务端单调 seq) | -| workunit-events-bridge.ts | initWorkunitEventsBridge() | eventBus 的 workunit.created/status_changed + requirement.created/updated(2026-08-24 SSE 负载加深,REQ chips SSE 驱动)→ 'events' 频道(前端 WU 列表/抽屉/REQ chips 实时刷新);index.ts 启动时调用,幂等 | +| workunit-events-bridge.ts | initWorkunitEventsBridge() | eventBus 的 workunit.created/status_changed + workunit:removed(#538 决策 5:service.delete 删除出声,负载 { id, channelId },前端 workunitStore/channelWorkStore 删行,不进频道)+ requirement.created/updated(2026-08-24 SSE 负载加深,REQ chips SSE 驱动)→ 'events' 频道(前端 WU 列表/抽屉/REQ chips 实时刷新);index.ts 启动时调用,幂等 | | lock-events-bridge.ts | initLockEventsBridge() | #169: eventBus 的 lock.stale_reclaimed/lock.acquire_timeout → 结构化字段落统一事件流 + dispatchMonitorAlerts 全管线(warning 级,不设 critical);index.ts 启动时调用,幂等 | | (agent-loop 直发) | workunit.execution.step | WU 执行步事件(思考/工具/skill/用量):agent-loop 每步结束经 eventBus.publish 直发(不经过桥),`workunit.` 前缀自动落 workunits topic;落盘形态 `workunit:execution_step` 供 GET /events 回放 | | (agent-loop 直发) | workunit.execution.stream | WU 步内流式 chunk(Layer B,2026-07-30):step 执行中 CLI stdout 按行提炼 thinking/text/tool/result 直发,**SSE-only 不落盘**(行级体量防膨胀;步级归档走 execution.step);同前缀落 workunits topic | @@ -37,8 +37,8 @@ |------|--------|---------| | `__tests__/event.routes.test.ts` | 30 | POST/GET/agent-events: 创建/查询/验证/空 payload 拒收(D18)/错误路径;#180 起 GET 用真临时文件 + STUDIO_EVENTS_FILE 缝(过滤/游标/鉴权栈) | | `__tests__/session-summary-generator.test.ts` | 17 | classifyPattern 13种模式 + generateSessionSummary 边界情况 | -| `__tests__/workunit-events-bridge.test.ts` | 2 | workunit.* + requirement.* 事件转发 'events' 频道(信封形状);桥 started 幂等是模块态,同文件后续用例 init 为 no-op 靠订阅残留生效 | -| `__tests__/sse-routes.test.ts` | 11 | getTopicFromEventType 映射表锁定;#324 背压断开慢客户端;#491 重连 replay(按序补发/topics 过滤/不可解析游标/有洞不补发);#524 stream chunk 不入 replay buffer | +| `__tests__/workunit-events-bridge.test.ts` | 3 | workunit.* + workunit:removed + requirement.* 事件转发 'events' 频道(信封形状);桥 started 幂等是模块态,同文件后续用例 init 为 no-op 靠订阅残留生效 | +| `__tests__/sse-routes.test.ts` | 13 | getTopicFromEventType 映射表锁定;#324 背压断开慢客户端;#491 重连 replay(按序补发/topics 过滤/不可解析游标/有洞不补发);#524 stream chunk 不入 replay buffer;B6 广播每事件序列化一次 | | `__tests__/sse-replay-buffer.test.ts` | 7 | #491 replay buffer:seq 单调递增、环形淘汰、replay 窗口语义(有洞 → null) | | `__tests__/lock-events-bridge.test.ts` | 1 | #169: lock.* 事件 → 结构化事件流 + dispatchMonitorAlerts 全管线(warning + notifyAlert)、init 幂等 | @@ -47,7 +47,7 @@ - StudioEvent 用 jsonl 文件存储(D18 起统一经 `../../utils/studio-events.js` 的 writeStudioEvent 写入;空 payload 拒绝落盘) - POST /api/v1/events 的 payload 为空({} / null / 缺失 / '{}')→ 400(D18:空事件不产信号只产噪音,调用方自查) - SSE 使用 EventBus pub/sub (B0-002),不依赖数据库 -- **SSE 帧格式(2026-07-29 修复)**:只写 `id:` + `data:` 匿名事件(不写 `event:` 命名行——EventSource.onmessage 只收匿名事件),且 data 是完整信封 `{event_type, event_id, timestamp, data}`(此前只发内层 payload,客户端按 event_type 分发恒失败,全站 SSE 实际不通)。topic 映射(`getTopicFromEventType` 纯前缀,已导出供单测锁定):execution./runtime.→executions、node.→nodes、task.→tasks、goal.→goals、knowledge.→knowledge、workunit.→workunits(含 workunit.tokens / workunit.execution.*)、channel.→channels、requirement.→requirements(2026-08-24 新增)、其余→all(客户端默认订阅 all 全收) +- **SSE 帧格式(2026-07-29 修复)**:只写 `id:` + `data:` 匿名事件(不写 `event:` 命名行——EventSource.onmessage 只收匿名事件),且 data 是完整信封 `{event_type, event_id, timestamp, data}`(此前只发内层 payload,客户端按 event_type 分发恒失败,全站 SSE 实际不通)。topic 映射(`getTopicFromEventType` 纯前缀,已导出供单测锁定):execution./runtime.→executions、node.→nodes、task.→tasks、goal.→goals、knowledge.→knowledge、workunit.→workunits(含 workunit.tokens / workunit.execution.*)、channel.→channels、requirement.→requirements(2026-08-24 新增)、其余→all(客户端默认订阅 all 全收)。**B6(2026-09-16 channel 性能审计)**:广播循环每事件仅 1 次 `JSON.stringify`(惰性 memo——零匹配客户端零成本),各客户端复用同一字符串;原为逐客户端重复序列化且在发消息热路径上 - session:summary 在 session:end 时触发,fire-and-forget - **SSE replay(#491,2026-09-11)**:`id:` 行自本票起为服务端单调 seq(不再是信封 event_id;event_id 仍在 data 信封内供前端幂等去重)。事件广播时先入 `sseReplayBuffer`(sse-replay-buffer.ts,环形 500 条,进程级内存、不落盘、重启即清),重连带 `Last-Event-ID` 时按 seq 升序补发窗口内遗漏事件(按订阅 topics 过滤,补发先于 connection.established——后者以 currentSeq 为 id,先发会让游标越过补发事件);游标早于 buffer 最老事件(有洞)或无法解析(旧版 uuid)则不补发,由前端 onReconnect 全量 refetch 兜底(决策 9)。**#524 P1-3(#516 项⑥)**:`workunit.execution.stream` chunk 不入 replay buffer(sse.routes.ts 入队处一处过滤,不占 seq)——补发价值最低流量最大(Layer B 不落盘、前端纯内存、重连走全量 refetch 兜底),关键事件独占 500 条补发窗口,高 stream 负载下重连补发恢复有效;直播广播照常(id 取 currentSeq,游标语义不变) - **SSE 与全局 compression(#263 / 根因 #259,2026-08-19)**:app.ts 全局 compression 中间件必须带 `filter: shouldCompress`(`apps/api/src/middleware/compression-filter.ts`)——默认 compressible 对 `text/event-stream` 经 `^text/` fallback 返回 true 会缓冲 SSE 流,频道实时推送全灭。/events/stream 与 /mcp/sse 均经此中间件覆盖;新增 SSE 端点只要走同一 app 即自动生效 diff --git a/apps/api/src/modules/events/__tests__/sse-routes.test.ts b/apps/api/src/modules/events/__tests__/sse-routes.test.ts index f6c7c4ef4..c9dac7af1 100644 --- a/apps/api/src/modules/events/__tests__/sse-routes.test.ts +++ b/apps/api/src/modules/events/__tests__/sse-routes.test.ts @@ -140,6 +140,39 @@ describe('SSE /stream 背压(#324)', () => { }); }); +// ── B6(2026-09-16 channel 性能审计):广播循环每事件只序列化一次 ── +describe('SSE 广播序列化一次(B6)', () => { + it('同一事件向多个订阅客户端广播,JSON.stringify 只调用一次(不逐客户端重复)', () => { + connectClient(); + connectClient(); + connectClient(); + const spy = vi.spyOn(JSON, 'stringify'); + try { + eventBus.publish('events', { + event_type: 'task.updated', event_id: 'e-b6', + timestamp: '2026-09-16T00:00:00Z', data: { taskId: 't-b6' }, + }); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); + + it('无订阅客户端的 topic 不序列化(零客户端零成本)', () => { + connectClient({}, 'channels'); // 只订 channels + const spy = vi.spyOn(JSON, 'stringify'); + try { + eventBus.publish('events', { + event_type: 'task.updated', event_id: 'e-b6b', + timestamp: '2026-09-16T00:00:00Z', data: { taskId: 't-b6b' }, + }); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); +}); + // ── #491:断线窗口事件 replay——重连带 Last-Event-ID 补发窗口内遗漏事件 ── describe('SSE /stream 断线 replay(#491)', () => { function publish(eventType: string, eventId: string, data: Record) { diff --git a/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts b/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts index 95199fe43..fdbaf7d64 100644 --- a/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts +++ b/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts @@ -28,6 +28,21 @@ describe('workunit-events-bridge', () => { expect(typeof (received[0] as unknown as { event_id: string }).event_id).toBe('string'); }); + it('workunit:removed 转发到 events 频道(负载 id + channelId,#538 决策 5)', async () => { + initWorkunitEventsBridge(); // 幂等 no-op(首个用例已注册),订阅仍生效 + + const received: Array<{ event_type: string; data: { id: string; channelId: string | null } }> = []; + eventBus.subscribe('events', (envelope: { event_type?: string; data: { id: string; channelId: string | null } }) => { + if (envelope.event_type === 'workunit:removed') received.push(envelope as (typeof received)[number]); + }); + + eventBus.publish('workunit:removed', { id: 'wu-gone', channelId: 'ch-1' }); + await new Promise(r => setTimeout(r, 20)); + + expect(received.map(e => e.event_type)).toEqual(['workunit:removed']); + expect(received[0].data).toEqual({ id: 'wu-gone', channelId: 'ch-1' }); + }); + it('requirement.created / updated 转发到 events 频道(data.requirement 含 id/channelId/title/status)', async () => { initWorkunitEventsBridge(); // 幂等 no-op(首个用例已注册),订阅仍生效 diff --git a/apps/api/src/modules/events/sse.routes.ts b/apps/api/src/modules/events/sse.routes.ts index 033800525..32a78aa74 100644 --- a/apps/api/src/modules/events/sse.routes.ts +++ b/apps/api/src/modules/events/sse.routes.ts @@ -57,7 +57,7 @@ function ensureEventSubscription() { if (eventSubStarted) return; eventSubStarted = true; - // #324:直订 eventBus(对象 payload,全程仅 sendSSE 内 1 次 JSON.stringify) + // #324:直订 eventBus(对象 payload;B6 起广播循环每事件仅 1 次 JSON.stringify,复用字符串) eventBus.subscribe('events', (event: { event_type: string; event_id?: string }) => { // eventBus 精确匹配走 EventEmitter.emit,handler 抛异常会向上抛——内部 try/catch 护住 try { @@ -68,10 +68,15 @@ function ensureEventSubscription() { const isStreamChunk = event.event_type === EXECUTION_STREAM_SSE_TYPE; // #491:先入 replay buffer(分配单调 seq 作为 SSE id 行),再广播 const seq = isStreamChunk ? sseReplayBuffer.currentSeq : sseReplayBuffer.push(topic, event.event_type, event); + // B6(2026-09-16 channel 性能审计):同一负载每事件只序列化一次, + // 循环内复用字符串(原为逐客户端 JSON.stringify,且在发消息热路径上); + // 惰性 memo:零匹配客户端时零成本。 + let serialized: string | null = null; for (const client of clients.values()) { if (client.topics.has('all') || client.topics.has(topic)) { // 转发完整信封(event_type/event_id/timestamp/data)——客户端按 event_type 分发 - sendSSE(client, event.event_type, event, String(seq)); + serialized ??= JSON.stringify(event); + sendSSE(client, event.event_type, event, String(seq), serialized); } } } catch (error) { @@ -81,7 +86,7 @@ function ensureEventSubscription() { logger.info('[SSE] Event subscription established'); } -function sendSSE(client: SSEClient, eventType: string, data: any, eventId: string) { +function sendSSE(client: SSEClient, eventType: string, data: any, eventId: string, serialized?: string) { try { // `id:` 行 = 服务端单调 seq(#491,重连 Last-Event-ID 游标);信封 event_id 留在 data 内。 // 背压(#324 决策):任一 write 返回 false(内核缓冲区满)即断开慢客户端, @@ -89,7 +94,8 @@ function sendSSE(client: SSEClient, eventType: string, data: any, eventId: strin if (!client.res.write(`id: ${eventId}\n`)) return disconnectSlowClient(client); // 不写 `event:` 行(匿名事件):EventSource.onmessage 只接收匿名事件, // 命名事件必须按类型逐个 addEventListener —— 前端统一从 data.event_type 分发。 - if (!client.res.write(`data: ${JSON.stringify(data)}\n\n`)) return disconnectSlowClient(client); + // B6:广播路径传入预序列化字符串(每事件一次);缺省(replay/连接帧)现场序列化。 + if (!client.res.write(`data: ${serialized ?? JSON.stringify(data)}\n\n`)) return disconnectSlowClient(client); client.lastEventId = eventId; } catch { // Client disconnected diff --git a/apps/api/src/modules/events/workunit-events-bridge.ts b/apps/api/src/modules/events/workunit-events-bridge.ts index 8f9d2aaac..bf62e92d6 100644 --- a/apps/api/src/modules/events/workunit-events-bridge.ts +++ b/apps/api/src/modules/events/workunit-events-bridge.ts @@ -13,6 +13,8 @@ import { v4 as uuidv4 } from 'uuid'; const FORWARDED_EVENTS = [ 'workunit.created', 'workunit.status_changed', + // #538(ADR 2026-09-15 决策 5):GC/TTL 删除出声——负载 { id, channelId },前端两 store 删行 + 'workunit:removed', // SSE 负载加深(2026-08-24 计划):REQ chips 改 SSE 驱动,负载 = { requirement }(含 id/title/status/channelId) 'requirement.created', 'requirement.updated', ] as const; diff --git a/apps/api/src/modules/evolution/signals.ts b/apps/api/src/modules/evolution/signals.ts index 1c1ede27c..b0b313b4c 100644 --- a/apps/api/src/modules/evolution/signals.ts +++ b/apps/api/src/modules/evolution/signals.ts @@ -41,7 +41,9 @@ export function resolveEvolutionPaths(overrides?: Partial): Evol constraintsFile: overrides?.constraintsFile ?? path.join(repoRoot, '.harness', 'custom-constraints.yml'), traceFile: overrides?.traceFile ?? path.join(repoRoot, '.harness', 'logs', 'traces.log'), rolesDir: overrides?.rolesDir ?? path.join(repoRoot, '.agents', 'roles'), - eventsDir: overrides?.eventsDir ?? studioPath('events'), + // events/ 语义迁移(契约 §8):统一事件流正本在 logs/;本字段 @deprecated 不再被读取, + // 缺省值同步指 logs/(#571),events/ ensureDir 已从启动链移除 + eventsDir: overrides?.eventsDir ?? studioPath('logs'), studioEventsFile: overrides?.studioEventsFile ?? resolveStudioLogFile('studio-events.jsonl'), }; } diff --git a/apps/api/src/modules/knowledge/CONTEXT.md b/apps/api/src/modules/knowledge/CONTEXT.md index 4744155e5..d4c5b37b6 100644 --- a/apps/api/src/modules/knowledge/CONTEXT.md +++ b/apps/api/src/modules/knowledge/CONTEXT.md @@ -15,15 +15,15 @@ | 模块 | 路径 | 职责 | |------|------|------| -| `knowledge-singletons` | `knowledge-singletons.ts` | 共享单例唯一所有者(sharedStore 等)+ 向量库同步 + 统一质量门(R4) | -| `MtimeMemoKnowledgeStore` | `knowledge-store-memo.ts` | sharedStore 的 mtime 校验聚合 memo 包装(#343,缓存 seam ADR 外部包条款):mtime+size 指纹兜底跨进程外部写 + 本进程写穿透失效;命中深克隆保持「每次读全新对象」契约;readEntriesFromDisk/snapshot 直通不缓存 | +| `knowledge-singletons` | `knowledge-singletons.ts` | 共享单例唯一所有者(sharedStore 等)+ 向量库同步 + 统一质量门(R4)+ `publishKnowledgeEntryChanged`(条目变更 SSE 广播,见注意事项 Step 2 条) | +| `MtimeMemoKnowledgeStore` | `knowledge-store-memo.ts` | sharedStore 的 mtime 校验聚合 memo 包装(#343,缓存 seam ADR 外部包条款):mtime+size 指纹兜底跨进程外部写 + 本进程写穿透失效;命中深克隆保持「每次读全新对象」契约;readEntriesFromDisk/snapshot 直通不缓存;harness 1.8.0 起补齐 KnowledgeStore 15 成员——`applyAll`(写穿透失效,与 saveAll 同口径)+ `getConsumptionStats`(直通不缓存:stats 文件不在 .md/index.json 指纹内,缓存必过期) | | `UnifiedQuery` | `engine/unified-query.ts` | 双存储统一查询(Prisma + KnowledgeStore),knowledgeService 的 query 引擎(R4 修复接线) | | `knowledgeService.injectContext` | `knowledge-service.ts` | 统一 prompt 注入入口(absorbed from prompt-builder);E2:有注入时附「何时查知识库」指引(`KNOWLEDGE_QUERY_GUIDANCE`);#91:maxTokens 由 prompt-composer 按分段定额传入(knowledge 1000 + 池余量),`knowledge:inject-trimmed` 事件补 originalTokens/keptTokens 尺寸字段,返回值带 `usage` 供 `prompt:section_trimmed` 埋点 | | `knowledgeService.semanticSearch` | `knowledge-service.ts` | mcp-local-rag 语义检索;E2:可用性探测(进程内缓存 5min)+ 失败降级关键词检索,不再静默返回 [] | | `knowledge-types` | `knowledge-types.ts` | KnowledgeService 的 Studio 侧类型 + `KnowledgeServiceDeps` + `ENTRY_TYPE_MAP`(knowledge-service.ts 拆出,门面 re-export) | | `knowledge-data-layer` | `knowledge-data-layer.ts` | 数据层:`writeTrendData`(data/trends 趋势写入)+ resolution 影子库 FileStore helpers + 共享 `fileStore`/`STUDIO_EVENTS_JSONL`(knowledge-service.ts 拆出) | | `knowledge-forms` | `knowledge-forms.ts` | 知识形态门禁 `validateKnowledgeForm`(knowledge/data/skill/rule,代码层判断不调 LLM)(knowledge-service.ts 拆出,门面 re-export) | -| `conversation-extractor` | `conversation-extractor.ts` | R3 会话提取:transcript 构建 + 单条入库 proposal 闸门(knowledge-service.ts 拆出;提案卡 #355 起归 review-adapter) | +| `conversation-extractor` | `conversation-extractor.ts` | R3 会话提取:transcript 构建 + 单条入库 proposal 闸门(knowledge-service.ts 拆出;提案卡 #355 起归 review-adapter)。总开关 `STUDIO_KNOWLEDGE_EXTRACTION=false`(默认开,P7/P8 批次 2026-09-16):整体跳过 LLM 提取——无凭证/fake-provider 环境豁免真实 CLI 空转 | | `review-adapter` | `review-adapter.ts` | #355:knowledge 人审提案 adapter(kind='knowledge'),接线 review-proposal 正本——聚合卡渲染(knowledge_proposal 旧文案,cardData 旧形状 + proposalId)+ onApprove 逐条目 promote / onReject 逐条目 demote;存取物化 /knowledge-proposals.jsonl;knowledge-service.ts 模块加载即注册 | | `knowledge-metrics` | `knowledge-metrics.ts` | R1/M1 事件流度量纯函数:computeOutcomeMetrics(hitRate/improvement)+ scanKnowledgeEvents(审计计数)(knowledge-service.ts 拆出) | | `knowledge-search-helpers` | `knowledge-search-helpers.ts` | 检索 helpers:关键词抽取(STOP_WORDS)/TYPE_WEIGHT + mcp-local-rag 探测与关键词降级映射(knowledge-service.ts 拆出) | @@ -53,7 +53,7 @@ knowledge/ ├── knowledge-service.routes.ts # KnowledgeService HTTP API + SSE ├── knowledge-query.service.ts # 5 类缺口查询(query/getStats) ├── knowledge-sync.service.ts # 自动同步 + 新鲜度检测 -├── resolution.service.ts # 解法库(独立子系统);#361 起错误匹配核心(regex 回退子串)下沉 studio-shared/resolutions.ts,与 runner-output queryResolutionHints 共用 +├── resolution.service.ts # 解法库(独立子系统);#361 起错误匹配核心(regex 回退子串)下沉 studio-shared/resolutions.ts,本模块是该共享核心的唯一生产消费方(#587 后) ├── evolution-scheduler.ts # 周期任务调度(G-005 模式挖掘 + eval spring cleaning) ├── improver-scheduler.service.ts # 自文档化调度器(refreshStaleContext + runArchDocs) ├── preference-observer.ts # Producer: 用户偏好 @@ -79,6 +79,8 @@ knowledge/ ### 注意事项 +- **知识条目变更 SSE 事件(2026-09 web-ux-optional-fixes Step 2)**:`publishKnowledgeEntryChanged(action, { entryId?, entryType?, title? })`(knowledge-singletons)经 eventBus `events` 频道广播 `knowledge.entry_changed`(sse.routes 前缀映射进 knowledge topic,载荷只带轻量元信息 = 前端「重拉信号」,信封不带 event_id 同 knowledge bridge 惯例)。三个发射点:`ingestWithQualityGate` 成功入库(agent 产出主路径)、`knowledgeService.promote/demote` 成熟度迁移实际发生(不在迁移表不发)、`POST /knowledge/unified` 人工创建。绕过门面直调 `sharedStore.save` 的机器流(pattern-miner/rule-scanner/decision-chain-extractor/audit-subscriber)**不广播**——audit-subscriber 每条审计事件都写库,store 层 chokepoint 广播会造成事件风暴,是有意排除。消费方:KnowledgePage 订阅后防抖重拉当前 tab。 + - **交互模式条目判别口径(2026-09-10 生产 /search 500 根因修复)**:tag `pattern` 是自由命名空间——guideline 条目(session-summary 等)合法携带且正文为 markdown;交互模式条目的唯一可靠判别是结构化字段 `type='pattern'`(off-schema,harness `KnowledgeSubsystem` 词表未收录,upsertPattern 以 `as any` 写入)。消费方(search.routes /search、pattern-miner 5 处)统一走 `pattern-entry.ts` 的 `listInteractionPatterns`(types 口径)+ `parsePatternContent`(正文 JSON 解析失败 = 数据损坏,返回 null 跳过,不抛异常打垮读路径)。同类裸 `JSON.parse(entry.content)` 在 rule-scanner / decision-chain-extractor / unified-query 仍存在,其 tag 词表(rule/decision 等)当前无冲突条目,属同类潜在 hazard。 - **分页口径统一(#359,2026-08-26)**:knowledge 全部 limit 入口(/export、/gaps/:type、/unified、/knowledge-service/search、/knowledge-service/entries)统一走 `utils/pagination.ts parsePagination`(clamp 1..100,缺省 20)。修复前三处三个口径(search clamp 50 / entries|unified clamp 100 / export 无 clamp)。缺省值变化:search 10→20、export 100→20、unified 50→20、entries 无 limit 时从不设限 → 20。 diff --git a/apps/api/src/modules/knowledge/__tests__/entries.routes.test.ts b/apps/api/src/modules/knowledge/__tests__/entries.routes.test.ts index 53cfb1638..b9c71430a 100644 --- a/apps/api/src/modules/knowledge/__tests__/entries.routes.test.ts +++ b/apps/api/src/modules/knowledge/__tests__/entries.routes.test.ts @@ -68,6 +68,26 @@ describe('entries.routes', () => { expect(res.json.consumptionMode).toBe('reference'); }); + it('POST /unified 201 后广播 knowledge.entry_changed(Step 2:他端 KnowledgePage 重拉信号)', async () => { + const { eventBus } = await import('@dommaker/studio-shared'); + const received: any[] = []; + const handler = (e: any) => { if (e?.event_type === 'knowledge.entry_changed') received.push(e); }; + eventBus.subscribe('events', handler); + try { + const res = await api('POST', '/unified', { + type: 'pitfall', title: '广播条目', content: 'SSE 广播验证', + consumptionMode: 'signal', tags: [], + }); + expect(res.status).toBe(201); + expect(received).toHaveLength(1); + expect(received[0].data).toEqual({ + action: 'created', entryId: res.json.id, entryType: 'pitfall', title: '广播条目', + }); + } finally { + eventBus.unsubscribe('events', handler); + } + }); + it('GET /unified lists created entry (agent: tag preserved)', async () => { const res = await api('GET', '/unified'); expect(res.status).toBe(200); diff --git a/apps/api/src/modules/knowledge/__tests__/knowledge-quality-gate-events.test.ts b/apps/api/src/modules/knowledge/__tests__/knowledge-quality-gate-events.test.ts index 87f70ce65..19a199bf2 100644 --- a/apps/api/src/modules/knowledge/__tests__/knowledge-quality-gate-events.test.ts +++ b/apps/api/src/modules/knowledge/__tests__/knowledge-quality-gate-events.test.ts @@ -13,9 +13,10 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; -const { mockAppendJsonl, mockIngestEntry } = vi.hoisted(() => ({ +const { mockAppendJsonl, mockIngestEntry, mockEventBusPublish } = vi.hoisted(() => ({ mockAppendJsonl: vi.fn().mockResolvedValue(undefined), mockIngestEntry: vi.fn().mockReturnValue({ id: 'entry-1', lastReferenced: null, contributors: ['test'] }), + mockEventBusPublish: vi.fn(), })); vi.mock('@dommaker/studio-shared', () => ({ @@ -23,6 +24,7 @@ vi.mock('@dommaker/studio-shared', () => ({ FileStore: class { appendJsonl = mockAppendJsonl; }, + eventBus: { publish: mockEventBusPublish, subscribe: vi.fn(), unsubscribe: vi.fn() }, // #361 薄壳转发后 knowledge-singletons 经 utils/studio-log-path re-export 取用; // 模块加载期即调用,partial mock 必须提供 resolveStudioLogFile: (name: string) => `/tmp/test-studio-logs/${name}`, @@ -119,3 +121,42 @@ describe('ingestWithQualityGate event emission', () => { expect(mockIngestEntry).not.toHaveBeenCalled(); }); }); + +describe('ingestWithQualityGate SSE 广播(Step 2:knowledge.entry_changed → KnowledgePage 重拉信号)', () => { + test('成功 ingest → eventBus 广播 knowledge.entry_changed(action=created + 元信息)', () => { + mockIngestEntry.mockReturnValue({ id: 'new-1', lastReferenced: null, contributors: ['test'] }); + + ingestWithQualityGate( + { ingest: { ingestEntry: mockIngestEntry } as any }, + { type: 'guideline', title: 'Good pattern', content: 'valid content', tags: ['pattern'], source: 'monitor', entryType: 'pattern' }, + ); + + expect(mockEventBusPublish).toHaveBeenCalledWith('events', expect.objectContaining({ + event_type: 'knowledge.entry_changed', + data: { action: 'created', entryId: 'new-1', entryType: 'pattern', title: 'Good pattern' }, + })); + }); + + test('质量门拒绝 → 不广播', () => { + mockIngestEntry.mockReturnValue({ __rejected: true, __rejectReasons: ['Bad content'] }); + + ingestWithQualityGate( + { ingest: { ingestEntry: mockIngestEntry } as any }, + { type: 'guideline', title: 'rejected', content: 'bad', tags: ['pattern'], source: 'monitor', entryType: 'pattern' }, + ); + + expect(mockEventBusPublish).not.toHaveBeenCalled(); + }); + + test('eventBus.publish 抛异常 → best-effort,ingest 主流程不受影响', () => { + mockIngestEntry.mockReturnValue({ id: 'new-2', lastReferenced: null, contributors: ['test'] }); + mockEventBusPublish.mockImplementation(() => { throw new Error('subscriber boom'); }); + + const saved = ingestWithQualityGate( + { ingest: { ingestEntry: mockIngestEntry } as any }, + { type: 'guideline', title: 'Good pattern', content: 'valid content', tags: ['pattern'], source: 'monitor', entryType: 'pattern' }, + ); + + expect(saved).toMatchObject({ id: 'new-2' }); + }); +}); diff --git a/apps/api/src/modules/knowledge/__tests__/knowledge-service-extract-conversation.test.ts b/apps/api/src/modules/knowledge/__tests__/knowledge-service-extract-conversation.test.ts index c8c406753..bf673d33e 100644 --- a/apps/api/src/modules/knowledge/__tests__/knowledge-service-extract-conversation.test.ts +++ b/apps/api/src/modules/knowledge/__tests__/knowledge-service-extract-conversation.test.ts @@ -409,6 +409,20 @@ describe('R3: extractFromConversation', () => { await ks.extractFromConversation(MESSAGES, { workUnitId: 'wu-empty' }); expect(mockCreateCardMessage).not.toHaveBeenCalled(); }); + + it('P8: STUDIO_KNOWLEDGE_EXTRACTION=false → 整体跳过(不调 LLM、不入库、不记事件)', async () => { + vi.stubEnv('STUDIO_KNOWLEDGE_EXTRACTION', 'false'); + try { + const { ks, ingest, eventEmitter } = createKS(); + await ks.extractFromConversation(MESSAGES, { workUnitId: 'wu-p8' }); + expect(mockRun).not.toHaveBeenCalled(); + expect(ingest.ingestEntry).not.toHaveBeenCalled(); + expect(eventEmitter.emit).not.toHaveBeenCalled(); + expect(mockAppendJsonl).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); }); describe('R3: 提案注入闸门(draft 不参与 injectContext)', () => { diff --git a/apps/api/src/modules/knowledge/__tests__/knowledge-service.test.ts b/apps/api/src/modules/knowledge/__tests__/knowledge-service.test.ts index 89568dcea..a161cd19e 100644 --- a/apps/api/src/modules/knowledge/__tests__/knowledge-service.test.ts +++ b/apps/api/src/modules/knowledge/__tests__/knowledge-service.test.ts @@ -38,6 +38,7 @@ vi.mock('@dommaker/studio-shared', async (importOriginal) => { import { KnowledgeService } from '../knowledge-service.js'; import type { PatternEntry, IncidentEntry, TrendEntry } from '../knowledge-service.js'; +import { eventBus } from '@dommaker/studio-shared'; // ── Mock factories ── @@ -1079,3 +1080,51 @@ describe('KnowledgeService Phase 0: contract', () => { }); }); }); + +// ── Step 2(2026-09 web-ux-optional-fixes):promote/demote 成熟度变更 → SSE 广播 ── + +describe('promote/demote SSE 广播(knowledge.entry_changed → KnowledgePage 重拉信号)', () => { + it('promote:draft→verified 落库后广播 promoted', async () => { + const spy = vi.spyOn(eventBus, 'publish'); + try { + const { ks, store } = createKS({ entries: [{ id: 'e1', maturity: 'draft', type: 'guideline', title: 'T1' }] }); + await ks.promote('e1'); + expect(store.update).toHaveBeenCalledWith('e1', { maturity: 'verified' }); + expect(spy).toHaveBeenCalledWith('events', expect.objectContaining({ + event_type: 'knowledge.entry_changed', + data: { action: 'promoted', entryId: 'e1', entryType: 'guideline', title: 'T1' }, + })); + } finally { + spy.mockRestore(); + } + }); + + it('demote:draft→archived 落库后广播 demoted', async () => { + const spy = vi.spyOn(eventBus, 'publish'); + try { + const { ks, store } = createKS({ entries: [{ id: 'e2', maturity: 'draft', type: 'pitfall', title: 'T2' }] }); + await ks.demote('e2'); + expect(store.update).toHaveBeenCalledWith('e2', { maturity: 'archived' }); + expect(spy).toHaveBeenCalledWith('events', expect.objectContaining({ + event_type: 'knowledge.entry_changed', + data: { action: 'demoted', entryId: 'e2', entryType: 'pitfall', title: 'T2' }, + })); + } finally { + spy.mockRestore(); + } + }); + + it('maturity 不在迁移表(archived)→ 不更新也不广播', async () => { + const spy = vi.spyOn(eventBus, 'publish'); + try { + const { ks, store } = createKS({ entries: [{ id: 'e3', maturity: 'archived', type: 'guideline', title: 'T3' }] }); + await ks.promote('e3'); + expect(store.update).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalledWith('events', expect.objectContaining({ + event_type: 'knowledge.entry_changed', + })); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/apps/api/src/modules/knowledge/__tests__/knowledge-store-memo.test.ts b/apps/api/src/modules/knowledge/__tests__/knowledge-store-memo.test.ts index a94caea2c..245e82e19 100644 --- a/apps/api/src/modules/knowledge/__tests__/knowledge-store-memo.test.ts +++ b/apps/api/src/modules/knowledge/__tests__/knowledge-store-memo.test.ts @@ -132,6 +132,27 @@ describe('MtimeMemoKnowledgeStore', () => { expect(spy).toHaveBeenCalledTimes(2); }); + it('write-through: applyAll (harness#134) patches entries and busts the memo', () => { + const spy = vi.spyOn(raw, 'list'); + store.list({}); + store.applyAll([{ id: 'kb1', partial: { title: 'patched-title' } }]); + const fresh = store.list({}); // 写后首读 = memo 已失效重扫,非旧值 + expect(fresh.find(e => e.id === 'kb1')?.title).toBe('patched-title'); + expect(spy).toHaveBeenCalledTimes(2); + // 不存在的 id 被底层跳过,不抛错 + expect(() => store.applyAll([{ id: 'kb-missing', partial: { title: 'x' } }])).not.toThrow(); + }); + + it('getConsumptionStats (harness#134) passes through without caching', () => { + expect(store.getConsumptionStats()).toBeUndefined(); + const spy = vi.spyOn(raw, 'getConsumptionStats'); + store.getConsumptionStats(); + store.getConsumptionStats(); + expect(spy).toHaveBeenCalledTimes(2); + fs.writeFileSync(path.join(dir, '.consumption-stats.json'), JSON.stringify({ date: '2026-01-01', dailyEvents: 3, searchHits: 2 }), 'utf-8'); + expect(store.getConsumptionStats()).toEqual({ dailyEvents: 3 }); + }); + it('write-through: rebuildIndex picks up hand-written files', () => { const orphan = makeEntry({ id: 'kb9' }); const frontmatter = [ diff --git a/apps/api/src/modules/knowledge/entries.routes.ts b/apps/api/src/modules/knowledge/entries.routes.ts index cc30273b9..367d5f01f 100644 --- a/apps/api/src/modules/knowledge/entries.routes.ts +++ b/apps/api/src/modules/knowledge/entries.routes.ts @@ -12,7 +12,7 @@ import { Router } from 'express'; import { logger } from '@dommaker/studio-shared'; -import { sharedStore } from './knowledge-singletons.js'; +import { sharedStore, publishKnowledgeEntryChanged } from './knowledge-singletons.js'; import { getSystemExecutor } from '../agents/system-executor.js'; import { requireAuth, requireNotGuest } from '../../middleware/auth.js'; import { parsePagination } from '../../utils/pagination.js'; @@ -241,6 +241,9 @@ entriesRoutes.post('/unified', requireAuth(), requireNotGuest(), async (req, res origin: 'human', }); + // Step 2:人工创建同样广播(他端开着的 KnowledgePage 实时刷新;本端提交后本就会 reload) + publishKnowledgeEntryChanged('created', { entryId: id, entryType: type, title }); + res.status(201).json({ id, title, consumptionMode }); } catch (error) { res.status(500).json({ error: 'Creation failed', detail: String(error) }); diff --git a/apps/api/src/modules/knowledge/knowledge-service.ts b/apps/api/src/modules/knowledge/knowledge-service.ts index b86a4e8cf..f39438ae4 100644 --- a/apps/api/src/modules/knowledge/knowledge-service.ts +++ b/apps/api/src/modules/knowledge/knowledge-service.ts @@ -39,7 +39,7 @@ import { FileStore, logger } from '@dommaker/studio-shared'; import { getSystemExecutor, StudioRoleNotConfiguredError } from '../agents/system-executor.js'; import { resolveStudioLogFile } from '../../utils/studio-log-path.js'; import type { CreateResolutionInput } from '@dommaker/studio-shared'; -import { scheduleVectorDbSync, ingestWithQualityGate } from './knowledge-singletons.js'; +import { scheduleVectorDbSync, ingestWithQualityGate, publishKnowledgeEntryChanged } from './knowledge-singletons.js'; import { computeOutcomeMetrics, scanKnowledgeEvents, @@ -343,12 +343,19 @@ export class KnowledgeService { * - 提取开销(tokens/duration)以 knowledge:extraction 事件单独度量, * 不计入 2K 注入红线。 * - 永不抛出:LLM 未配置/调用失败仅记日志(e2e 无 LLM 时静默跳过)。 + * - P8(2026-09-16 perf 实测):总开关 `STUDIO_KNOWLEDGE_EXTRACTION=false` + * 整体跳过——无凭证/fake-provider 环境里 studio 角色 provider 仍指向真实 + * CLI,每次 WU 完成都起真实 LLM 进程挂到超时,纯空转+日志噪声。 */ async extractFromConversation( messages: { role: string; content: string }[], ctx?: ConversationExtractionCtx, ): Promise { const source = ctx?.source ?? `conversation:${ctx?.workUnitId ?? 'unknown'}`; + if (process.env.STUDIO_KNOWLEDGE_EXTRACTION === 'false') { + logger.info('[KnowledgeService] extractFromConversation skipped: STUDIO_KNOWLEDGE_EXTRACTION=false', { source }); + return; + } try { const transcript = buildConversationTranscript(messages); if (!transcript) return; @@ -941,6 +948,7 @@ export class KnowledgeService { const target = next[entry.maturity]; if (target) { this.store.update(entryId, { maturity: target }); + publishKnowledgeEntryChanged('promoted', { entryId, entryType: entry.type, title: entry.title }); } } @@ -955,6 +963,7 @@ export class KnowledgeService { const target = prev[entry.maturity]; if (target) { this.store.update(entryId, { maturity: target }); + publishKnowledgeEntryChanged('demoted', { entryId, entryType: entry.type, title: entry.title }); } } diff --git a/apps/api/src/modules/knowledge/knowledge-singletons.ts b/apps/api/src/modules/knowledge/knowledge-singletons.ts index 46619f78d..64b618788 100644 --- a/apps/api/src/modules/knowledge/knowledge-singletons.ts +++ b/apps/api/src/modules/knowledge/knowledge-singletons.ts @@ -19,7 +19,7 @@ import { FileKnowledgeStore, KnowledgeIngest, KnowledgeLifecycle, KnowledgeQuery, KnowledgeInjector, KnowledgeLinter, ReferenceTracker } from '@dommaker/harness'; import type { KnowledgeEntry, KnowledgeOrigin, KnowledgeSubsystem, MaturityLevel } from '@dommaker/harness'; -import { FileStore, logger } from '@dommaker/studio-shared'; +import { FileStore, logger, eventBus } from '@dommaker/studio-shared'; import { wrapWithSegmentSpan } from '@dommaker/studio-shared/read-metrics'; import { execFile, execFileSync } from 'child_process'; import * as path from 'path'; @@ -336,9 +336,10 @@ export function ingestWithQualityGate( return null; } - // 3. 成功:同步向量库 + entry_created 事件 + // 3. 成功:同步向量库 + entry_created 事件 + SSE 广播 scheduleVectorDbSync(); appendKnowledgeEvent('knowledge:entry_created', { entryType, title: input.title }); + publishKnowledgeEntryChanged('created', { entryId: saved?.id, entryType, title: input.title }); return saved; } @@ -353,3 +354,29 @@ export function appendKnowledgeEvent(type: string, payload: Record[0]): void { + this.underlying.applyAll(updates); + this.invalidate(); + } + delete(id: string): boolean { const removed = this.underlying.delete(id); this.invalidate(); @@ -95,6 +102,15 @@ export class MtimeMemoKnowledgeStore implements KnowledgeStore { return this.underlying.getSurvivalRate(daysAgo); } + /** + * harness#134(1.8.0 KnowledgeStore 15 成员)消费计数:直通不缓存—— + * .consumption-stats.json 不在目录指纹(.md + index.json)覆盖内, + * 缓存会返回过期计数;调用方(KnowledgeAudit 打分)低频。 + */ + getConsumptionStats(): ReturnType { + return this.underlying.getConsumptionStats(); + } + // ── memo 内核 ────────────────────────────────────────── private invalidate(): void { diff --git a/apps/api/src/modules/knowledge/resolution.service.ts b/apps/api/src/modules/knowledge/resolution.service.ts index 509f4e9cd..d5a34c2e8 100644 --- a/apps/api/src/modules/knowledge/resolution.service.ts +++ b/apps/api/src/modules/knowledge/resolution.service.ts @@ -131,8 +131,8 @@ export class ResolutionService { (!errorClass || r.errorClass === errorClass) ); - // #361: 匹配核心(regex 失败回退子串)下沉 studio-shared,与 studio-agent - // queryResolutionHints 的逐字重复实现收一。 + // #361: 匹配核心(regex 失败回退子串)下沉 studio-shared,当时 studio-agent 侧 + // 还有一份逐字重复的实现,一并收进本模块(那份随 #562/#587 摘除,本处是唯一消费方)。 const matched: Resolution[] = matchResolutionPatterns(candidates, errorMessage); const promptSnippet = matched.length > 0 diff --git a/apps/api/src/modules/mcp/CONTEXT.md b/apps/api/src/modules/mcp/CONTEXT.md index 830e09582..14c6568a1 100644 --- a/apps/api/src/modules/mcp/CONTEXT.md +++ b/apps/api/src/modules/mcp/CONTEXT.md @@ -27,16 +27,20 @@ | `system.tools.ts` | 2 | systemHealth / emitEvent | | `devops.tools.ts` | 1 | publishPackage | | `skill.tools.ts` | 1 | loadSkill | -| `workunit.tools.ts` | 1 | createWorkUnit | -| **合计** | **19** | | +| `workunit.tools.ts` | 3 | createWorkUnit / getWorkUnit / listWorkUnits | +| `channel.tools.ts` | 1 | getChannelMessages(热层尾部,新→旧) | +| `requirement.tools.ts` | 2 | getRequirement / listRequirements | +| **合计** | **24** | | > #149(2026-08-15):`knowledge.tools.ts`(5 个知识工具,全是 document-store CRUD)随 document-store 退役删除。 > 2026-08-19:checkGuardrail / getSandboxLevel 随 harness 1.2.0 删除 InputGuardrail/OutputGuardrail/Sandbox(ADR-0003)移除(21 → 19)。 +> #566 P2(2026-09-16):补只读查询 5 个(getWorkUnit/listWorkUnits/getChannelMessages/getRequirement/listRequirements),全部 exposure='external'(19 → 24)。 > #172(2026-08-15):`loadSkill` 入参加可选 `workUnitId`(透传 skill-loader,skill_used 事件补 WU 归属,#60 决策 Q2)。 +> 度量地基票 A(2026-09-15):`loadSkill` cache 命中路径(常态路径)也发射 `knowledge:skill_used`(channel=loadSkill,level=info)——此前仅文件回退路径发射,事件结构性为 0;文件路径维持 skill-loader.ts 发射,每调用恰好一次。 ### 核心导出 -- `getToolSchemas()` — 获取所有 tool 的 JSON schema(不含 handler) +- `getToolSchemas(audience?)` — 获取 tool 的 JSON schema(不含 handler);audience='external' 只出 exposure=external 子集,缺省全量 - `executeTool(name, input, roleId?, traceCtx?)` — 按名称执行 tool(含权限检查 + 限流 + 审计) - `MCPToolRegistry` / `toolRegistry` — Tool 注册与生命周期管理 - `mcpPermissionService` — 权限与审计 @@ -53,4 +57,5 @@ - 风险级别按工具名前缀自动分配(create/store/extract/approve/assign/update 等 → medium,delete/drop/truncate → high,其余 → low)。 - 权限模型默认 executor(本地 Agent)可调用普通 tool;**危险工具收口(2026-08-25)**:publishPackage 仅 admin/deploy 默认允许,其余系统角色 seed 为 allowed:false,且历史过度授权记录启动时强制纠正(permission.service.ts seedDefaultPermissions,旧 seed 只增不改不会自愈)。publishPackage 本体同步收口:bumpType 白名单 fail-fast + 全部 execFileSync 数组参数 + git tag 前 semver 校验;dist 完整性校验调 @dommaker/harness 的 verifyReleaseArtifacts 自检(清单由包声明面运行时推导,不硬编码,#425)。 - **HTTP 端点鉴权分层(2026-07-24 收紧;2026-08-25 补洞)**:`GET /tools`、`GET /health` 保持公开(Lurk);`POST /tools/:name` → `requireAuth+requireAdmin`;`POST /messages`、`GET /sse`、**`POST /`(完整 JSON-RPC 面,2026-08-25 补挂)** → `requireLocalhost`(真实客户端为本机 agent,`STUDIO_MCP_URL` 默认 localhost SSE);`/admin/*` → `requireAuth+requireAdmin`。permission.service 的 RBAC 是 agent 角色维度,与 HTTP 用户鉴权是两层。**requireLocalhost 语义(2026-08-25 修复)**:拒绝携带 X-Forwarded-For/CF-Connecting-IP 的请求——同机反代下公网流量 TCP 对端也是 127.0.0.1,单靠 IP 判不出,须靠转发头识别(middleware/auth.ts)。 +- **对外只读入口(#566,2026-09-16,方案 docs/plans/2026-09-mcp-installer.md)**:tool 定义加 `exposure?: 'internal'|'external'`(缺省 internal,D1 default-deny 同构),`GET /external/sse` + `POST /external/messages` 挂 requireLocalhost 并钉死 `roleId='external'`(忽略自声明,D2)+ tools/list 只出 external 子集;RBAC seed 的 external 角色权限由 exposure 标记派生且双向纠正漂移。外放子集 = 8 个既有读 tool(listProjects/getProjectStatus/getTaskBoard/getTaskStats/getSpecStatus/listSpecs/systemHealth/loadSkill)+ P2 新增 5 个只读 tool;getBalance/checkConstraint 不外放(Q1 决策)。内部入口(/sse /messages /)行为不变。配套 CLI:`studio mcp install`(cli/mcp-install.ts)。 - tool-store.ts(#362,2026-08-27):`listJsonFiles`/`getEntity`/`writeEntity` 全部委托 FileStore 原语(`listJsonInDir`/`readJson`/`writeJson`),不再自持 readdir 副本;本地 ensureDir 已删(writeJson 自带建目录)。行为差异:list 走读穿缓存 + 并发读、目录不存在返回 [] 且不建目录。 diff --git a/apps/api/src/modules/mcp/__tests__/channel.tools.test.ts b/apps/api/src/modules/mcp/__tests__/channel.tools.test.ts new file mode 100644 index 000000000..f8d3722a8 --- /dev/null +++ b/apps/api/src/modules/mcp/__tests__/channel.tools.test.ts @@ -0,0 +1,53 @@ +/** + * channel.tools 单元测试(P2,#566)。 + * + * 覆盖 getChannelMessages:包装 fileStore.readMessagesTail(热层尾部,新→旧), + * limit 钳制 [1,100] 默认 20。tool-store 的 fileStore 被 mock。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockReadMessagesTail } = vi.hoisted(() => ({ + mockReadMessagesTail: vi.fn(), +})); + +vi.mock('../tool-store.js', () => ({ + fileStore: { readMessagesTail: mockReadMessagesTail }, +})); + +import { channelTools } from '../channel.tools.js'; + +const getChannelMessages = channelTools[0]; + +describe('channel.tools', () => { + beforeEach(() => vi.clearAllMocks()); + + it('仅导出 getChannelMessages,exposure=external,required=[channelId]', () => { + expect(channelTools.map(t => t.name)).toEqual(['getChannelMessages']); + expect(getChannelMessages.exposure).toBe('external'); + expect(getChannelMessages.inputSchema.required).toEqual(['channelId']); + }); + + it('透传 channelId 与 limit,返回 { messages, total }', async () => { + const messages = [{ id: 'm2' }, { id: 'm1' }]; + mockReadMessagesTail.mockResolvedValue({ messages, exhausted: false }); + const result = await getChannelMessages.handler({ channelId: 'ch-1', limit: 5 }); + expect(mockReadMessagesTail).toHaveBeenCalledWith('ch-1', { limit: 5 }); + expect(result).toEqual({ messages, total: 2 }); + }); + + it('limit 缺省 20,越界钳制到 [1, 100]', async () => { + mockReadMessagesTail.mockResolvedValue({ messages: [], exhausted: true }); + await getChannelMessages.handler({ channelId: 'ch-1' }); + expect(mockReadMessagesTail).toHaveBeenCalledWith('ch-1', { limit: 20 }); + await getChannelMessages.handler({ channelId: 'ch-1', limit: 9999 }); + expect(mockReadMessagesTail).toHaveBeenCalledWith('ch-1', { limit: 100 }); + await getChannelMessages.handler({ channelId: 'ch-1', limit: 0 }); + expect(mockReadMessagesTail).toHaveBeenCalledWith('ch-1', { limit: 20 }); + }); + + it('频道不存在(ENOENT → 空页)返回空数组不抛错', async () => { + mockReadMessagesTail.mockResolvedValue({ messages: [], exhausted: true }); + expect(await getChannelMessages.handler({ channelId: 'ch-ghost' })) + .toEqual({ messages: [], total: 0 }); + }); +}); diff --git a/apps/api/src/modules/mcp/__tests__/external-exposure.test.ts b/apps/api/src/modules/mcp/__tests__/external-exposure.test.ts new file mode 100644 index 000000000..3528dd235 --- /dev/null +++ b/apps/api/src/modules/mcp/__tests__/external-exposure.test.ts @@ -0,0 +1,50 @@ +/** + * P1(#566):exposure 标记测试。 + * + * - MCPToolRegistry.getSchemas(audience):external 只出 exposure=external 子集,缺省全量 + * - tools.ts 门面:外放子集恰为方案选定集合,getBalance/checkConstraint 不外放(Q1 决策) + * + * external 角色 seed 派生测试在 permission-seed.test.ts(避开门面后台 seed 干扰)。 + */ +import { describe, it, expect } from 'vitest'; +import { MCPToolRegistry, toolRegistry } from '../tool-registry.js'; +import '../tools.js'; // 触发门面注册 + +describe('MCPToolRegistry.getSchemas audience 过滤', () => { + it('external 只出 exposure=external,缺省 internal 出全量', () => { + const reg = new MCPToolRegistry(); + reg.register({ name: 'readA', description: '', inputSchema: {}, handler: async () => null, exposure: 'external' }); + reg.register({ name: 'readB', description: '', inputSchema: {}, handler: async () => null, exposure: 'external' }); + reg.register({ name: 'writeC', description: '', inputSchema: {}, handler: async () => null }); + + expect(reg.getSchemas('external').map(s => s.name)).toEqual(['readA', 'readB']); + expect(reg.getSchemas().map(s => s.name)).toEqual(['readA', 'readB', 'writeC']); + expect(reg.getSchemas('internal').map(s => s.name)).toEqual(['readA', 'readB', 'writeC']); + }); +}); + +describe('tools.ts 门面 exposure 标记', () => { + const EXPECTED_EXTERNAL = [ + 'listProjects', 'getProjectStatus', + 'getTaskBoard', 'getTaskStats', + 'getSpecStatus', 'listSpecs', + 'systemHealth', 'loadSkill', + // P2 新增读 tool + 'getWorkUnit', 'listWorkUnits', + 'getChannelMessages', + 'getRequirement', 'listRequirements', + ]; + + it('外放子集恰为方案选定集合,getBalance/checkConstraint 不外放', () => { + const external = toolRegistry.getSchemas('external').map(s => s.name).sort(); + expect(external).toEqual([...EXPECTED_EXTERNAL].sort()); + expect(toolRegistry.get('getBalance')?.exposure).toBeUndefined(); + expect(toolRegistry.get('checkConstraint')?.exposure).toBeUndefined(); + }); + + it('外放子集不含任何写 tool(create/assign/update/approve/publish/emit 前缀)', () => { + for (const s of toolRegistry.getSchemas('external')) { + expect(s.name).not.toMatch(/^(create|assign|update|approve|publish|emit|delete)/); + } + }); +}); diff --git a/apps/api/src/modules/mcp/__tests__/external-routes.test.ts b/apps/api/src/modules/mcp/__tests__/external-routes.test.ts new file mode 100644 index 000000000..17f1d346c --- /dev/null +++ b/apps/api/src/modules/mcp/__tests__/external-routes.test.ts @@ -0,0 +1,89 @@ +/** + * P1(#566):/api/v1/mcp/external/* 外部只读入口接线测试。 + * + * - GET /external/sse + POST /external/messages 路由存在且挂 requireLocalhost 中间件 + * - 外部 messages 处理器钉死 { pinnedRoleId: 'external', audience: 'external' } + * - 内部 /messages 处理器不传 ctx(现状不变) + * - AC6:外部入口中间件拒绝带 X-Forwarded-For 的请求 + * + * server.js 被 mock(仅断言 handleRequest 入参);tools.js 走真实门面注册。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; + +const { mockHandleRequest } = vi.hoisted(() => ({ + mockHandleRequest: vi.fn().mockResolvedValue({ jsonrpc: '2.0', id: 1, result: {} }), +})); + +vi.mock('../server.js', () => ({ + mcpServer: { handleRequest: mockHandleRequest }, +})); + +import router from '../routes.js'; + +interface RouteLayer { + path: string; + methods: Record; + stack: Array<{ handle: (req: any, res: any, next?: any) => any }>; +} + +function findRoute(method: string, path: string): RouteLayer { + const layer = (router as any).stack.find( + (l: any) => l.route && l.route.path === path && l.route.methods[method], + ); + if (!layer) throw new Error(`route not found: ${method.toUpperCase()} ${path}`); + return layer.route as RouteLayer; +} + +function fakeRes() { + const res: any = { + statusCode: 200, + body: undefined, + status(code: number) { res.statusCode = code; return res; }, + json(payload: any) { res.body = payload; return res; }, + writeHead() { return res; }, + write() { return true; }, + }; + return res as Response; +} + +describe('/mcp/external/* 入口接线(#566)', () => { + beforeEach(() => vi.clearAllMocks()); + + it('GET /external/sse 与 POST /external/messages 存在,且 handler 前挂中间件(requireLocalhost)', () => { + expect(findRoute('get', '/external/sse').stack.length).toBeGreaterThanOrEqual(2); + expect(findRoute('post', '/external/messages').stack.length).toBeGreaterThanOrEqual(2); + }); + + it('外部 messages 处理器钉死 external 角色 + external audience', async () => { + const route = findRoute('post', '/external/messages'); + const handler = route.stack[route.stack.length - 1].handle; + const body = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; + await handler({ query: {}, body } as unknown as Request, fakeRes()); + expect(mockHandleRequest).toHaveBeenCalledWith(body, { pinnedRoleId: 'external', audience: 'external' }); + }); + + it('内部 /messages 处理器不传 ctx(行为不变)', async () => { + const route = findRoute('post', '/messages'); + const handler = route.stack[route.stack.length - 1].handle; + const body = { jsonrpc: '2.0', id: 2, method: 'tools/list' }; + await handler({ query: {}, body } as unknown as Request, fakeRes()); + expect(mockHandleRequest).toHaveBeenCalledWith(body, undefined); + }); + + it('AC6:外部入口中间件拒绝带 X-Forwarded-For 的请求', async () => { + const route = findRoute('get', '/external/sse'); + const localhostMw = route.stack[0].handle; + const res = fakeRes(); + const next = vi.fn(); + await localhostMw({ headers: { 'x-forwarded-for': '203.0.113.9' }, ip: '127.0.0.1' }, res, next); + expect(res.statusCode).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('内部 /sse /messages / POST / 路由保持注册(回归)', () => { + expect(findRoute('get', '/sse')).toBeTruthy(); + expect(findRoute('post', '/messages')).toBeTruthy(); + expect(findRoute('post', '/')).toBeTruthy(); + }); +}); diff --git a/apps/api/src/modules/mcp/__tests__/permission-seed.test.ts b/apps/api/src/modules/mcp/__tests__/permission-seed.test.ts index 7c54c04b4..0406344b4 100644 --- a/apps/api/src/modules/mcp/__tests__/permission-seed.test.ts +++ b/apps/api/src/modules/mcp/__tests__/permission-seed.test.ts @@ -69,3 +69,34 @@ describe('seedDefaultPermissions 危险工具默认授权', () => { expect(readPerms().length).toBe(first); }); }); + +// P1(#566 D2):external 角色权限完全由 exposure 子集派生 +describe('seedDefaultPermissions external 角色', () => { + it('external 角色仅 external 子集 allowed,其余 default-deny', async () => { + await seedDefaultPermissions(['listProjects', 'createWorkUnit'], ['listProjects']); + const perms = readPerms(); + + expect(perms.find(p => p.roleId === 'external' && p.toolName === 'listProjects')?.allowed).toBe(true); + expect(perms.find(p => p.roleId === 'external' && p.toolName === 'createWorkUnit')?.allowed).toBe(false); + }); + + it('漂移双向纠正:tool 移出子集后残留 allowed:true 被收回,新入子集补放', async () => { + fs.writeFileSync(permsPath, JSON.stringify([ + { id: '1', roleId: 'external', toolName: 'listProjects', allowed: true }, + { id: '2', roleId: 'external', toolName: 'getBalance', allowed: true }, + ])); + + await seedDefaultPermissions(['listProjects', 'getBalance'], ['getBalance']); + const perms = readPerms(); + + expect(perms.find(p => p.roleId === 'external' && p.toolName === 'listProjects')?.allowed).toBe(false); + expect(perms.find(p => p.roleId === 'external' && p.toolName === 'getBalance')?.allowed).toBe(true); + }); + + it('幂等:重复 seed 不重复添加 external 记录', async () => { + await seedDefaultPermissions(['listProjects'], ['listProjects']); + const first = readPerms().length; + await seedDefaultPermissions(['listProjects'], ['listProjects']); + expect(readPerms().length).toBe(first); + }); +}); diff --git a/apps/api/src/modules/mcp/__tests__/requirement.tools.test.ts b/apps/api/src/modules/mcp/__tests__/requirement.tools.test.ts new file mode 100644 index 000000000..6bb0477cd --- /dev/null +++ b/apps/api/src/modules/mcp/__tests__/requirement.tools.test.ts @@ -0,0 +1,57 @@ +/** + * requirement.tools 单元测试(P2,#566)。 + * + * 覆盖 getRequirement/listRequirements 只读包装(RequirementService 被 mock), + * 均 exposure='external'。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGet = vi.fn(); +const mockList = vi.fn(); + +vi.mock('../../requirements/requirement.service.js', () => ({ + RequirementService: vi.fn().mockImplementation(function () { + return { get: mockGet, list: mockList }; + }), +})); + +import { requirementTools } from '../requirement.tools.js'; + +const getRequirement = requirementTools[0]; +const listRequirements = requirementTools[1]; + +describe('requirement.tools', () => { + beforeEach(() => vi.clearAllMocks()); + + it('导出 getRequirement + listRequirements,均 exposure=external', () => { + expect(requirementTools.map(t => t.name)).toEqual(['getRequirement', 'listRequirements']); + expect(getRequirement.exposure).toBe('external'); + expect(listRequirements.exposure).toBe('external'); + expect(getRequirement.inputSchema.required).toEqual(['id']); + }); + + it('getRequirement 命中返回服务层数据', async () => { + const req = { id: 'REQ-7', title: 'T', status: 'in-progress' }; + mockGet.mockResolvedValue(req); + expect(await getRequirement.handler({ id: 'REQ-7' })).toEqual(req); + expect(mockGet).toHaveBeenCalledWith('REQ-7'); + }); + + it('getRequirement 未命中抛 not found', async () => { + mockGet.mockResolvedValue(null); + await expect(getRequirement.handler({ id: 'REQ-999' })).rejects.toThrow('Requirement not found: REQ-999'); + }); + + it('listRequirements 透传过滤并返回 { requirements, total }', async () => { + mockList.mockResolvedValue([{ id: 'REQ-1' }, { id: 'REQ-2' }]); + const result = await listRequirements.handler({ status: 'open', channelId: 'ch-1' }); + expect(mockList).toHaveBeenCalledWith({ status: 'open', channelId: 'ch-1' }); + expect(result).toEqual({ requirements: [{ id: 'REQ-1' }, { id: 'REQ-2' }], total: 2 }); + }); + + it('listRequirements 无过滤透传 undefined 字段', async () => { + mockList.mockResolvedValue([]); + await listRequirements.handler({}); + expect(mockList).toHaveBeenCalledWith({ status: undefined, channelId: undefined }); + }); +}); diff --git a/apps/api/src/modules/mcp/__tests__/server-external.test.ts b/apps/api/src/modules/mcp/__tests__/server-external.test.ts new file mode 100644 index 000000000..c440a6a8a --- /dev/null +++ b/apps/api/src/modules/mcp/__tests__/server-external.test.ts @@ -0,0 +1,59 @@ +/** + * P1(#566):MCPServer 入口侧上下文测试。 + * + * - tools/list 带 audience='external' → 调 getToolSchemas('external') + * - tools/call 带 pinnedRoleId → 忽略 params.roleId 自声明(自声明 admin 无效) + * - 无 ctx → 行为与现状一致(roleId 自声明透传) + * + * tools.js 门面被 mock,仅断言调用参数。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockGetToolSchemas, mockExecuteTool } = vi.hoisted(() => ({ + mockGetToolSchemas: vi.fn().mockReturnValue([{ name: 'listProjects', description: '', inputSchema: {} }]), + mockExecuteTool: vi.fn().mockResolvedValue({ success: true, result: { ok: 1 }, duration: 1 }), +})); + +vi.mock('../tools.js', () => ({ + getToolSchemas: mockGetToolSchemas, + executeTool: mockExecuteTool, +})); + +import { MCPServer } from '../server.js'; + +describe('MCPServer 入口 ctx(#566 D2)', () => { + beforeEach(() => vi.clearAllMocks()); + + it('tools/list 带 audience=external 透传过滤参数', async () => { + const server = new MCPServer(); + const res = await server.handleRequest( + { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + { audience: 'external', pinnedRoleId: 'external' }, + ); + expect(mockGetToolSchemas).toHaveBeenCalledWith('external'); + expect(res.result?.tools).toHaveLength(1); + }); + + it('tools/list 无 ctx 走默认(全量)', async () => { + const server = new MCPServer(); + await server.handleRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(mockGetToolSchemas).toHaveBeenCalledWith(undefined); + }); + + it('tools/call 带 pinnedRoleId 时忽略自声明 roleId', async () => { + const server = new MCPServer(); + await server.handleRequest( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'createWorkUnit', arguments: {}, roleId: 'admin' } }, + { pinnedRoleId: 'external' }, + ); + expect(mockExecuteTool).toHaveBeenCalledWith('createWorkUnit', {}, 'external'); + }); + + it('tools/call 无 ctx 时自声明 roleId 透传(内部入口现状不变)', async () => { + const server = new MCPServer(); + await server.handleRequest( + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'listProjects', arguments: {}, roleId: 'executor' } }, + ); + expect(mockExecuteTool).toHaveBeenCalledWith('listProjects', {}, 'executor'); + }); +}); diff --git a/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts b/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts index e2cddd340..8088da1a5 100644 --- a/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts +++ b/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts @@ -3,11 +3,13 @@ * * 覆盖 loadSkill 的两级加载(包内缓存 → 文件加载)与未命中分支。 * skillLoader / skillLoaderService 动态 import 被 mock。 + * skill 度量地基票 A:cache 命中路径也发射 knowledge:skill_used(level=info signal)。 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockGetFullPrompt = vi.fn(); const mockLoadSkill = vi.fn(); +const mockWriteStudioEvent = vi.fn().mockResolvedValue(true); vi.mock('@dommaker/studio-skill', () => ({ skillLoader: { getFullPrompt: mockGetFullPrompt }, @@ -17,6 +19,11 @@ vi.mock('../../skills/skill-loader.js', () => ({ skillLoaderService: { loadSkill: mockLoadSkill }, })); +vi.mock('@dommaker/studio-shared', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeStudioEvent: mockWriteStudioEvent }; +}); + import { skillTools } from '../skill.tools.js'; const loadSkill = skillTools[0]; @@ -65,4 +72,37 @@ describe('skill.tools', () => { workUnitId: 'wu-42', })); }); + + it('票A: cache 命中也发射 skill_used(level=info signal,channel=loadSkill,带 workUnitId)', async () => { + mockGetFullPrompt.mockReturnValue('# TDD Workflow\n...'); + await loadSkill.handler({ skillName: 'tdd-workflow', workUnitId: 'wu-7' }); + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', { + skillName: 'tdd-workflow', + workUnitId: 'wu-7', + channel: 'loadSkill', + }, expect.objectContaining({ source: 'skill-tools', level: 'info' })); + }); + + it('票A: cache 命中无 workUnitId 也发射(legacy 口径,聚合侧每条计 1)', async () => { + mockGetFullPrompt.mockReturnValue('# TDD Workflow\n...'); + await loadSkill.handler({ skillName: 'tdd-workflow' }); + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', { + skillName: 'tdd-workflow', + channel: 'loadSkill', + }, expect.objectContaining({ source: 'skill-tools', level: 'info' })); + }); + + it('票A: 两级均未命中 → 不发射 skill_used', async () => { + mockGetFullPrompt.mockReturnValue(null); + mockLoadSkill.mockResolvedValue(null); + await loadSkill.handler({ skillName: 'ghost' }); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); + + it('票A: 文件加载路径不在本层重复发射(skill-loader.ts 已发射,每调用恰好一次)', async () => { + mockGetFullPrompt.mockReturnValue(null); + mockLoadSkill.mockResolvedValue({ prompt: 'file prompt' }); + await loadSkill.handler({ skillName: 'custom' }); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/modules/mcp/__tests__/tools.test.ts b/apps/api/src/modules/mcp/__tests__/tools.test.ts index 5d6ad2ae1..df8b4fc5a 100644 --- a/apps/api/src/modules/mcp/__tests__/tools.test.ts +++ b/apps/api/src/modules/mcp/__tests__/tools.test.ts @@ -74,13 +74,20 @@ const EXPECTED_ORDER: Array<[string, 'low' | 'medium']> = [ ['publishPackage', 'low'], // Skill 按需加载 (1) ['loadSkill', 'low'], - // WorkUnit (1) + // WorkUnit (3) ['createWorkUnit', 'medium'], + ['getWorkUnit', 'low'], + ['listWorkUnits', 'low'], + // Channel 消息 (1) + ['getChannelMessages', 'low'], + // Requirement 需求 (2) + ['getRequirement', 'low'], + ['listRequirements', 'low'], ]; describe('tools.ts 注册门面', () => { - it('注册 19 个 tool,顺序与拆分前一致', () => { - expect(toolRegistry.toolCount).toBe(19); + it('注册 24 个 tool,顺序与拆分前一致', () => { + expect(toolRegistry.toolCount).toBe(24); expect(getToolSchemas().map(s => s.name)).toEqual(EXPECTED_ORDER.map(([name]) => name)); }); @@ -131,8 +138,12 @@ describe('tools.ts 注册门面', () => { })); }); - it('模块加载时种子默认权限(19 个 tool 名)', async () => { + it('模块加载时种子默认权限(24 个 tool 名 + external 子集名单)', async () => { await vi.waitFor(() => expect(mockSeed).toHaveBeenCalled()); - expect(mockSeed).toHaveBeenCalledWith(EXPECTED_ORDER.map(([name]) => name)); + expect(mockSeed).toHaveBeenCalledWith( + EXPECTED_ORDER.map(([name]) => name), + ['listProjects', 'getProjectStatus', 'getTaskBoard', 'getTaskStats', 'getSpecStatus', 'listSpecs', 'systemHealth', 'loadSkill', + 'getWorkUnit', 'listWorkUnits', 'getChannelMessages', 'getRequirement', 'listRequirements'], + ); }); }); diff --git a/apps/api/src/modules/mcp/__tests__/workunit.tools.test.ts b/apps/api/src/modules/mcp/__tests__/workunit.tools.test.ts index 8bd61a98d..03825b67b 100644 --- a/apps/api/src/modules/mcp/__tests__/workunit.tools.test.ts +++ b/apps/api/src/modules/mcp/__tests__/workunit.tools.test.ts @@ -2,20 +2,26 @@ * workunit.tools 单元测试(T3 拆分新增,pre-commit TDD 门禁)。 * * 覆盖 createWorkUnit:WorkUnitService 以共享 fileStore 构造、 - * 输入映射与返回字段裁剪。WorkUnitService 被 mock。 + * 输入映射与返回字段裁剪;P2(#566)新增 getWorkUnit/listWorkUnits 只读包装。 + * WorkUnitService 被 mock。 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockCreate = vi.fn().mockResolvedValue({ id: 'wu-1', type: 'task', scope: 'S', status: 'unassigned' }); +const mockGetById = vi.fn(); +const mockList = vi.fn(); vi.mock('../../workunit/workunit.service.js', () => ({ - WorkUnitService: vi.fn().mockImplementation(function () { return { create: mockCreate }; }), + WorkUnitService: vi.fn().mockImplementation(function () { + return { create: mockCreate, getById: mockGetById, list: mockList }; + }), })); import { workunitTools } from '../workunit.tools.js'; -import { fileStore } from '../tool-store.js'; const createWorkUnit = workunitTools[0]; +const getWorkUnit = workunitTools[1]; +const listWorkUnits = workunitTools[2]; describe('workunit.tools', () => { beforeEach(() => { @@ -23,8 +29,11 @@ describe('workunit.tools', () => { mockCreate.mockResolvedValue({ id: 'wu-1', type: 'task', scope: 'S', status: 'unassigned' }); }); - it('仅导出 createWorkUnit,schema required=[type, scope]', () => { - expect(workunitTools.map(t => t.name)).toEqual(['createWorkUnit']); + it('导出 createWorkUnit + getWorkUnit + listWorkUnits,写 tool internal、读 tool external', () => { + expect(workunitTools.map(t => t.name)).toEqual(['createWorkUnit', 'getWorkUnit', 'listWorkUnits']); + expect(createWorkUnit.exposure).toBeUndefined(); + expect(getWorkUnit.exposure).toBe('external'); + expect(listWorkUnits.exposure).toBe('external'); expect(createWorkUnit.inputSchema.required).toEqual(['type', 'scope']); expect(createWorkUnit.inputSchema.properties.type.enum) .toEqual(['task', 'plan', 'analysis', 'monitor', 'discussion']); @@ -43,4 +52,26 @@ describe('workunit.tools', () => { type: 'analysis', scope: 'A', status: 'unassigned', })); }); + + it('getWorkUnit 命中返回服务层数据', async () => { + const wu = { id: 'wu-9', scope: 'X', status: 'active' }; + mockGetById.mockResolvedValue(wu); + expect(await getWorkUnit.handler({ id: 'wu-9' })).toEqual(wu); + expect(mockGetById).toHaveBeenCalledWith('wu-9'); + }); + + it('getWorkUnit 未命中抛 not found', async () => { + mockGetById.mockResolvedValue(null); + await expect(getWorkUnit.handler({ id: 'ghost' })).rejects.toThrow('WorkUnit not found: ghost'); + }); + + it('listWorkUnits 透传过滤参数并返回服务层分页结果', async () => { + const page = { data: [{ id: 'wu-1' }], total: 1 }; + mockList.mockResolvedValue(page); + const input = { status: 'active', channelId: 'ch-1', q: 'abc', page: 2, limit: 5 }; + expect(await listWorkUnits.handler(input)).toEqual(page); + expect(mockList).toHaveBeenCalledWith(expect.objectContaining({ + status: 'active', channelId: 'ch-1', q: 'abc', page: 2, limit: 5, + })); + }); }); diff --git a/apps/api/src/modules/mcp/channel.tools.ts b/apps/api/src/modules/mcp/channel.tools.ts new file mode 100644 index 000000000..dfd7dd244 --- /dev/null +++ b/apps/api/src/modules/mcp/channel.tools.ts @@ -0,0 +1,34 @@ +/** + * MCP Tools — Channel 消息(P2,#566) + * + * 只读:getChannelMessages 包 FileStore.readMessagesTail(热层尾部倒扫,新→旧)。 + * 冷层穿透分页不外放(方案 §P2:先热层)。 + */ + +import type { RegisteredTool } from './tool-registry.js'; +import { fileStore } from './tool-store.js'; + +const MAX_LIMIT = 100; + +const getChannelMessages: RegisteredTool = { + name: 'getChannelMessages', + exposure: 'external', + description: '读取频道最新消息(热层尾部,返回新→旧顺序;冷层归档不含)', + inputSchema: { + type: 'object', + properties: { + channelId: { type: 'string', description: 'Channel ID' }, + limit: { type: 'number', description: `返回条数(默认 20,上限 ${MAX_LIMIT})` }, + }, + required: ['channelId'], + }, + handler: async (input) => { + const limit = Math.min(Math.max(Number(input.limit) || 20, 1), MAX_LIMIT); + const { messages } = await fileStore.readMessagesTail(input.channelId, { limit }); + return { messages, total: messages.length }; + }, +}; + +export const channelTools: RegisteredTool[] = [ + getChannelMessages, +]; diff --git a/apps/api/src/modules/mcp/permission.service.ts b/apps/api/src/modules/mcp/permission.service.ts index 36df3320d..8e118b956 100644 --- a/apps/api/src/modules/mcp/permission.service.ts +++ b/apps/api/src/modules/mcp/permission.service.ts @@ -208,7 +208,7 @@ export const mcpPermissionService = new MCPPermissionService(); const PRIVILEGED_ROLES = new Set(['admin', 'deploy']); const DANGEROUS_TOOLS = new Set(['publishPackage']); -export async function seedDefaultPermissions(toolNames: string[]): Promise { +export async function seedDefaultPermissions(toolNames: string[], externalToolNames: string[] = []): Promise { const systemRoles = ['admin', 'analyst', 'executor', 'reviewer', 'auditor', 'monitor', 'deploy', 'triage']; let seeded = 0; let corrected = 0; @@ -229,6 +229,21 @@ export async function seedDefaultPermissions(toolNames: string[]): Promise } } + // D2(#566):external 角色权限完全由 exposure 标记派生——仅 external 子集 allowed, + // 且双向纠正漂移(tool 移出子集后残留的 allowed:true 会经钉死角色的外部入口越权) + const externalSet = new Set(externalToolNames); + for (const toolName of toolNames) { + const derived = externalSet.has(toolName); + const existing = perms.find(p => p.roleId === 'external' && p.toolName === toolName); + if (!existing) { + perms.push({ id: randomUUID(), roleId: 'external', toolName, allowed: derived }); + seeded++; + } else if (existing.allowed !== derived) { + existing.allowed = derived; + corrected++; + } + } + if (seeded > 0 || corrected > 0) { await fileStore.writeJson(PERMS_PATH, perms); logger.info(`[MCP Permission] Seeded ${seeded} default permissions for ${systemRoles.length} roles, corrected ${corrected} dangerous-tool grants`); diff --git a/apps/api/src/modules/mcp/pmo.tools.ts b/apps/api/src/modules/mcp/pmo.tools.ts index b5525a2a6..dc4e555de 100644 --- a/apps/api/src/modules/mcp/pmo.tools.ts +++ b/apps/api/src/modules/mcp/pmo.tools.ts @@ -33,6 +33,7 @@ const createProject: RegisteredTool = { const listProjects: RegisteredTool = { name: 'listProjects', + exposure: 'external', description: '列出所有项目', inputSchema: { type: 'object', @@ -54,6 +55,7 @@ const listProjects: RegisteredTool = { const getProjectStatus: RegisteredTool = { name: 'getProjectStatus', + exposure: 'external', description: '获取项目详情和当前状态', inputSchema: { type: 'object', diff --git a/apps/api/src/modules/mcp/requirement.tools.ts b/apps/api/src/modules/mcp/requirement.tools.ts new file mode 100644 index 000000000..6e97e99cf --- /dev/null +++ b/apps/api/src/modules/mcp/requirement.tools.ts @@ -0,0 +1,54 @@ +/** + * MCP Tools — Requirement 需求(P2,#566) + * + * 只读:getRequirement/listRequirements 包 RequirementService.get/list, + * 均 exposure='external' 外放。写操作(create/update 等)维持 REST 面不进 MCP。 + */ + +import { RequirementService } from '../requirements/requirement.service.js'; +import type { RegisteredTool } from './tool-registry.js'; +import { fileStore } from './tool-store.js'; + +const getRequirement: RegisteredTool = { + name: 'getRequirement', + exposure: 'external', + description: '按 ID 查询需求详情(REQ- 统一编号或 legacy id)', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: '需求 ID(REQ- 或 legacy id)' }, + }, + required: ['id'], + }, + handler: async (input) => { + const requirement = await new RequirementService(fileStore).get(input.id); + if (!requirement) throw new Error(`Requirement not found: ${input.id}`); + return requirement; + }, +}; + +const listRequirements: RegisteredTool = { + name: 'listRequirements', + exposure: 'external', + description: '列出需求(可按状态/频道过滤;legacy REQ 与统一编号 PMO 别名合并)', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', description: '状态过滤' }, + channelId: { type: 'string', description: '频道过滤' }, + }, + required: [], + }, + handler: async (input) => { + const requirements = await new RequirementService(fileStore).list({ + status: input.status, + channelId: input.channelId, + }); + return { requirements, total: requirements.length }; + }, +}; + +export const requirementTools: RegisteredTool[] = [ + getRequirement, + listRequirements, +]; diff --git a/apps/api/src/modules/mcp/routes.ts b/apps/api/src/modules/mcp/routes.ts index 9ea8678e9..4c2e14b1f 100644 --- a/apps/api/src/modules/mcp/routes.ts +++ b/apps/api/src/modules/mcp/routes.ts @@ -12,7 +12,7 @@ */ import { Router, Request, Response } from 'express'; -import { mcpServer } from './server.js'; +import { mcpServer, type MCPRequestContext } from './server.js'; import { getToolSchemas, executeTool } from './tools.js'; import { toolRegistry } from './tool-registry.js'; import { logger } from '@dommaker/studio-shared'; @@ -28,33 +28,71 @@ router.use(mcpRateLimit); // ─── SSE Transport ─── const sseClients = new Map(); +// D2(#566):外部只读入口独立的连接池,与内部 /sse 互不相通 +const externalSseClients = new Map(); + +function createSseConnectHandler(clients: Map, messagesPath: string) { + return (req: Request, res: Response) => { + const clientId = `sse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + + // Send endpoint event with the message URL + const messageUrl = `${messagesPath}?clientId=${clientId}`; + res.write(`event: endpoint\ndata: ${messageUrl}\n\n`); + + clients.set(clientId, res); + logger.info('[MCP SSE] Client connected', { clientId }); + + req.on('close', () => { + clients.delete(clientId); + logger.info('[MCP SSE] Client disconnected', { clientId }); + }); + }; +} + +function createSseMessageHandler(clients: Map, ctx?: MCPRequestContext) { + return async (req: Request, res: Response) => { + const clientId = req.query.clientId as string; + const client = clientId ? clients.get(clientId) : null; + + try { + const response = await mcpServer.handleRequest(req.body, ctx); + + // Send response via SSE if client connected + if (client) { + client.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`); + } + + // Also return in HTTP response + res.json(response); + } catch (error) { + logger.error('MCP SSE message failed', { error: String(error) }); + const errorResponse = { + jsonrpc: '2.0', + id: req.body?.id || 0, + error: { code: -32603, message: String(error) }, + }; + + if (client) { + client.write(`event: message\ndata: ${JSON.stringify(errorResponse)}\n\n`); + } + + res.status(500).json(errorResponse); + } + }; +} /** * GET /api/v1/mcp/sse * SSE transport endpoint — Claude CLI connects here * 2026-07 收紧:与 /messages 配对限回环(公网匿名握连无意义且可耗资源)。 */ -router.get('/sse', requireLocalhost(), (req: Request, res: Response) => { - const clientId = `sse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }); - - // Send endpoint event with the message URL - const messageUrl = `/api/v1/mcp/messages?clientId=${clientId}`; - res.write(`event: endpoint\ndata: ${messageUrl}\n\n`); - - sseClients.set(clientId, res); - logger.info('[MCP SSE] Client connected', { clientId }); - - req.on('close', () => { - sseClients.delete(clientId); - logger.info('[MCP SSE] Client disconnected', { clientId }); - }); -}); +router.get('/sse', requireLocalhost(), createSseConnectHandler(sseClients, '/api/v1/mcp/messages')); /** * POST /api/v1/mcp/messages @@ -62,35 +100,24 @@ router.get('/sse', requireLocalhost(), (req: Request, res: Response) => { * 2026-07 收紧:tools/call 与 REST 执行等价(executor seed 默认全允许), * 真实客户端为本机 agent(worktree-resolver STUDIO_MCP_URL=localhost),限回环。 */ -router.post('/messages', requireLocalhost(), async (req: Request, res: Response) => { - const clientId = req.query.clientId as string; - const client = clientId ? sseClients.get(clientId) : null; - - try { - const response = await mcpServer.handleRequest(req.body); - - // Send response via SSE if client connected - if (client) { - client.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`); - } +router.post('/messages', requireLocalhost(), createSseMessageHandler(sseClients)); - // Also return in HTTP response - res.json(response); - } catch (error) { - logger.error('MCP SSE message failed', { error: String(error) }); - const errorResponse = { - jsonrpc: '2.0', - id: req.body?.id || 0, - error: { code: -32603, message: String(error) }, - }; +// ─── D2(#566):外部只读入口 ─── +// 钉死 roleId='external'(忽略自声明)+ tools/list 只出 exposure=external 子集。 +// 写工具三层堵:tools/list 不见 → external 角色 RBAC 仅放 external 子集 → default-deny。 +// 内部 /sse /messages / 路径行为不变。 - if (client) { - client.write(`event: message\ndata: ${JSON.stringify(errorResponse)}\n\n`); - } +/** + * GET /api/v1/mcp/external/sse + * 外部 agent(用户终端的 claude/kimi 会话)SSE 连接点,限回环。 + */ +router.get('/external/sse', requireLocalhost(), createSseConnectHandler(externalSseClients, '/api/v1/mcp/external/messages')); - res.status(500).json(errorResponse); - } -}); +/** + * POST /api/v1/mcp/external/messages + * 外部入口消息点:pinnedRoleId='external' + audience='external'。 + */ +router.post('/external/messages', requireLocalhost(), createSseMessageHandler(externalSseClients, { pinnedRoleId: 'external', audience: 'external' })); /** * POST /api/v1/mcp diff --git a/apps/api/src/modules/mcp/server.ts b/apps/api/src/modules/mcp/server.ts index cffccecfa..17c82a94c 100644 --- a/apps/api/src/modules/mcp/server.ts +++ b/apps/api/src/modules/mcp/server.ts @@ -43,6 +43,16 @@ const MCPErrorCode = { // ─── MCP Server ─── +/** + * D2(#566):入口侧请求上下文。 + * pinnedRoleId — 外部入口钉死角色,忽略 params.roleId 自声明; + * audience — tools/list 可见性过滤(external 只出 exposure=external 子集)。 + */ +export interface MCPRequestContext { + pinnedRoleId?: string; + audience?: 'internal' | 'external'; +} + export class MCPServer { private name: string; private version: string; @@ -55,15 +65,15 @@ export class MCPServer { /** * 处理 MCP 请求 */ - async handleRequest(request: MCPRequest): Promise { + async handleRequest(request: MCPRequest, ctx?: MCPRequestContext): Promise { try { switch (request.method) { case 'initialize': return this.handleInitialize(request); case 'tools/list': - return this.handleToolsList(request); + return this.handleToolsList(request, ctx); case 'tools/call': - return this.handleToolsCall(request); + return this.handleToolsCall(request, ctx); default: return { jsonrpc: '2.0', @@ -107,10 +117,10 @@ export class MCPServer { } /** - * tools/list - 返回所有可用 tools + * tools/list - 返回所有可用 tools(external audience 只出外放子集) */ - private handleToolsList(request: MCPRequest): MCPResponse { - const tools = getToolSchemas(); + private handleToolsList(request: MCPRequest, ctx?: MCPRequestContext): MCPResponse { + const tools = getToolSchemas(ctx?.audience); return { jsonrpc: '2.0', id: request.id, @@ -121,7 +131,7 @@ export class MCPServer { /** * tools/call - 执行指定 tool */ - private async handleToolsCall(request: MCPRequest): Promise { + private async handleToolsCall(request: MCPRequest, ctx?: MCPRequestContext): Promise { const { name, arguments: args } = request.params as MCPToolCall || {}; if (!name) { @@ -135,7 +145,8 @@ export class MCPServer { }; } - const result = await executeTool(name, args || {}, (request.params as any)?.roleId); + // D2(#566):pinnedRoleId 优先于自声明 roleId(外部入口钉死 external) + const result = await executeTool(name, args || {}, ctx?.pinnedRoleId ?? (request.params as any)?.roleId); if (!result.success) { return { diff --git a/apps/api/src/modules/mcp/skill.tools.ts b/apps/api/src/modules/mcp/skill.tools.ts index 14277508e..e95eb91b0 100644 --- a/apps/api/src/modules/mcp/skill.tools.ts +++ b/apps/api/src/modules/mcp/skill.tools.ts @@ -10,6 +10,7 @@ import type { RegisteredTool } from './tool-registry.js'; const loadSkill: RegisteredTool = { name: 'loadSkill', + exposure: 'external', description: '按需加载 Skill 完整内容。Agent 看到 skill 索引后,调用此工具获取具体工作流指令。', inputSchema: { type: 'object', @@ -30,6 +31,16 @@ const loadSkill: RegisteredTool = { const { skillLoader } = await import('@dommaker/studio-skill'); const fullPrompt = skillLoader.getFullPrompt(skillName); if (fullPrompt) { + // skill 度量地基票 A:cache 命中是 loadSkill 常态路径(包级 loader 预热后覆盖全部 + // 磁盘 skill),此前只有路径 2 发射 skill_used → 事件结构性为 0。发射点上移到 + // 工具边界:cache 路径在本层补发射(level=info 提为 signal,不归噪声 7 天滚); + // 路径 2 维持 skill-loader.ts 既有发射,每调用恰好一次。 + const { writeStudioEvent } = await import('@dommaker/studio-shared'); + void writeStudioEvent('knowledge:skill_used', { + skillName, + ...(typeof input.workUnitId === 'string' && input.workUnitId ? { workUnitId: input.workUnitId } : {}), + channel: 'loadSkill', + }, { source: 'skill-tools', level: 'info' }).catch(() => {}); return { skillName, content: fullPrompt, source: 'cache' }; } diff --git a/apps/api/src/modules/mcp/spec.tools.ts b/apps/api/src/modules/mcp/spec.tools.ts index cefc842c8..c868852ba 100644 --- a/apps/api/src/modules/mcp/spec.tools.ts +++ b/apps/api/src/modules/mcp/spec.tools.ts @@ -129,6 +129,7 @@ const approveSpec: RegisteredTool = { const getSpecStatus: RegisteredTool = { name: 'getSpecStatus', + exposure: 'external', description: '获取规格审查状态', inputSchema: { type: 'object', @@ -156,6 +157,7 @@ const getSpecStatus: RegisteredTool = { const listSpecs: RegisteredTool = { name: 'listSpecs', + exposure: 'external', description: '列出规格审查', inputSchema: { type: 'object', diff --git a/apps/api/src/modules/mcp/system.tools.ts b/apps/api/src/modules/mcp/system.tools.ts index 271c1d6ed..468bd972f 100644 --- a/apps/api/src/modules/mcp/system.tools.ts +++ b/apps/api/src/modules/mcp/system.tools.ts @@ -12,6 +12,7 @@ import type { RegisteredTool } from './tool-registry.js'; const systemHealth: RegisteredTool = { name: 'systemHealth', + exposure: 'external', description: 'Agent-first 系统健康检查:API 状态、知识库统计、Agent 运行状态、管线阶段。Agent 在任何关键操作前应调此 tool 确认系统在线。', inputSchema: { type: 'object', diff --git a/apps/api/src/modules/mcp/task.tools.ts b/apps/api/src/modules/mcp/task.tools.ts index 2b25343bf..d3df4ccc8 100644 --- a/apps/api/src/modules/mcp/task.tools.ts +++ b/apps/api/src/modules/mcp/task.tools.ts @@ -38,6 +38,7 @@ interface TaskData { const getTaskBoard: RegisteredTool = { name: 'getTaskBoard', + exposure: 'external', description: '获取任务看板状态', inputSchema: { type: 'object', @@ -156,6 +157,7 @@ const updateTaskStatus: RegisteredTool = { const getTaskStats: RegisteredTool = { name: 'getTaskStats', + exposure: 'external', description: '获取任务统计信息', inputSchema: { type: 'object', diff --git a/apps/api/src/modules/mcp/tool-registry.ts b/apps/api/src/modules/mcp/tool-registry.ts index 33f892340..b031bd57c 100644 --- a/apps/api/src/modules/mcp/tool-registry.ts +++ b/apps/api/src/modules/mcp/tool-registry.ts @@ -8,6 +8,9 @@ import { writeStudioEvent } from '../../utils/studio-events.js'; export type ToolRiskLevel = 'low' | 'medium' | 'high'; +/** D1(#566):tool 外放可见性。缺省 internal(default-deny 同构),external = 对外只读入口可见 */ +export type ToolExposure = 'internal' | 'external'; + export interface RegisteredTool { name: string; description: string; @@ -19,6 +22,8 @@ export interface RegisteredTool { requiredPermissions?: string[]; /** G2: 工具风险级别。high=破坏性操作需确认,low=只读安全 */ riskLevel?: ToolRiskLevel; + /** D1(#566):缺省 internal;标 external 的只读子集经 /mcp/external/* 入口外放 */ + exposure?: ToolExposure; } interface ToolStats { @@ -106,13 +111,16 @@ export class MCPToolRegistry { /** * Get tool schemas (for MCP tools/list) + * D1(#566):audience='external' 只出 exposure=external 的只读子集;缺省 internal 出全量(现状不变) */ - getSchemas(): Array<{ name: string; description: string; inputSchema: Record }> { - return this.list().map(({ name, description, inputSchema }) => ({ - name, - description, - inputSchema, - })); + getSchemas(audience: ToolExposure = 'internal'): Array<{ name: string; description: string; inputSchema: Record }> { + return this.list() + .filter(t => audience === 'internal' || t.exposure === 'external') + .map(({ name, description, inputSchema }) => ({ + name, + description, + inputSchema, + })); } /** diff --git a/apps/api/src/modules/mcp/tools.ts b/apps/api/src/modules/mcp/tools.ts index aedca9fc4..1fb291a56 100644 --- a/apps/api/src/modules/mcp/tools.ts +++ b/apps/api/src/modules/mcp/tools.ts @@ -19,6 +19,8 @@ import { systemTools } from './system.tools.js'; import { devopsTools } from './devops.tools.js'; import { skillTools } from './skill.tools.js'; import { workunitTools } from './workunit.tools.js'; +import { channelTools } from './channel.tools.js'; +import { requirementTools } from './requirement.tools.js'; // ─── 类型(向后兼容) ─── @@ -45,6 +47,10 @@ const allTools: RegisteredTool[] = [ ...skillTools, // WorkUnit ...workunitTools, + // Channel 消息(只读) + ...channelTools, + // Requirement 需求(只读) + ...requirementTools, ]; // FL-026: Register all tools into the registry on module load @@ -61,17 +67,20 @@ for (const tool of allTools) { toolRegistry.registerAll(allTools); // BP3: 种子 default-deny 权限 — 系统角色默认允许所有工具 +// D2(#566):external 角色仅 exposure=external 子集 allowed(由 exposure 标记派生,双向纠正漂移) import('./permission.service.js').then(({ seedDefaultPermissions }) => { - seedDefaultPermissions(allTools.map(t => t.name)).catch((e) => { + const externalNames = allTools.filter(t => t.exposure === 'external').map(t => t.name); + seedDefaultPermissions(allTools.map(t => t.name), externalNames).catch((e) => { logger.warn('[MCP] seedDefaultPermissions failed — tools may lack default permissions', { error: String(e) }); }); }); /** * 获取所有 tool 的 schema(不含 handler)— 向后兼容 + * D1(#566):audience='external' 只出外放子集;缺省全量(现状不变) */ -export function getToolSchemas() { - return toolRegistry.getSchemas(); +export function getToolSchemas(audience?: 'internal' | 'external') { + return toolRegistry.getSchemas(audience); } /** diff --git a/apps/api/src/modules/mcp/workunit.tools.ts b/apps/api/src/modules/mcp/workunit.tools.ts index 522051331..a241948c5 100644 --- a/apps/api/src/modules/mcp/workunit.tools.ts +++ b/apps/api/src/modules/mcp/workunit.tools.ts @@ -2,6 +2,7 @@ * MCP Tools — WorkUnit * * T3 拆分:自 tools.ts 原样提取(createWorkUnit)。 + * P2(#566):补只读查询 getWorkUnit/listWorkUnits,均 exposure='external' 外放。 */ import { WorkUnitService } from '../workunit/workunit.service.js'; @@ -43,6 +44,62 @@ const createWorkUnit: RegisteredTool = { }, }; +const getWorkUnit: RegisteredTool = { + name: 'getWorkUnit', + exposure: 'external', + description: '按 ID 查询单个 WorkUnit 详情(状态、认领人、类型、scope 等)', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'WorkUnit ID' }, + }, + required: ['id'], + }, + handler: async (input) => { + const workUnitService = new WorkUnitService(fileStore); + const workunit = await workUnitService.getById(input.id); + if (!workunit) throw new Error(`WorkUnit not found: ${input.id}`); + return workunit; + }, +}; + +const listWorkUnits: RegisteredTool = { + name: 'listWorkUnits', + exposure: 'external', + description: '列出 WorkUnit(可按类型/状态/频道/归属项目过滤,按创建时间倒序分页)', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', description: '类型过滤(task/plan/analysis/monitor/discussion)' }, + status: { type: 'string', description: '状态过滤' }, + assigneeId: { type: 'string', description: '认领人过滤' }, + channelId: { type: 'string', description: '频道过滤' }, + parentId: { type: 'string', description: '父 WorkUnit 过滤' }, + projectId: { type: 'string', description: 'PMO 项目归属过滤' }, + q: { type: 'string', description: 'scope 子串过滤(大小写不敏感)' }, + page: { type: 'number', description: '页码(默认 1)' }, + limit: { type: 'number', description: '每页数量(默认 20)' }, + }, + required: [], + }, + handler: async (input) => { + const workUnitService = new WorkUnitService(fileStore); + return workUnitService.list({ + type: input.type, + status: input.status, + assigneeId: input.assigneeId, + channelId: input.channelId, + parentId: input.parentId, + projectId: input.projectId, + q: input.q, + page: input.page, + limit: input.limit, + }); + }, +}; + export const workunitTools: RegisteredTool[] = [ createWorkUnit, + getWorkUnit, + listWorkUnits, ]; diff --git a/apps/api/src/modules/pmo/CONTEXT.md b/apps/api/src/modules/pmo/CONTEXT.md index 44ef2658a..cc40d0c5d 100644 --- a/apps/api/src/modules/pmo/CONTEXT.md +++ b/apps/api/src/modules/pmo/CONTEXT.md @@ -21,6 +21,7 @@ | `DecisionResolution` / `initDecisionResolution` | `decision-resolution.ts` | 决策单状态推进 + 落 decisions[] + 雾消解 + 全清自动建 spec 单(#471 起只服务存量在飞链——新链不再派生 decision WU);#457 起事件路径零防御性重读(payload 即真相:reviewPassed 台账 l3 与 done 迁移同一快照原子落盘后发布,pmoId/fogId 建单落档不可变;map 写围栏在 projectId 串行链上) | | `MapOpening` / `initMapOpening` / `parseMapOpening` | `map-opening.ts` | analysis/plan done -> 初始化探路台账(提取 DESTINATION:/FOG: 清单,#401 起兼容中文别名 目标:/待决:);#471 起降级为纯台账——不再逐条建 decision 单,fog[].wuId 恒 null | | `applyPlanRuling` / `validateRulingItems` / `PlanRulingError` | `plan-ruling.ts` | #467 裁决轮提交唯一正本:一次性裁决(采纳/打回重议)批量落探路台账(decisions[] + fog resolved/open,map 未建就地初始化)+ 组合结果文本复活 plan 同会话(pendingReplies 注入) | +| `applyPlanDirection` / `validateDirectionPick` / `PlanDirectionError` | `plan-direction.ts` | #567 方向锁定提交唯一正本:选定方向落探路台账(decisions[] 追加「方向:…(人锁定)」,方向抉择点命中 fog 置 resolved、缺失不补建;幂等不双写)+ 组合选定文本复活 plan 同会话 | | `SpecMaterialization` / `initSpecMaterialization` / `parseSpecTasks` / `serializeSpecTasks` | `spec-materialization.ts` | spec done -> 批量建 task 子 WU(提取 TASK:/AC:/BLOCKEDBY:/LEG: 清单;#471 起只服务存量 spec 单);#463 起哨兵改「无 TASK 行不落档」(人审有意不物化 ≠ 未处理,补确认可再触发),serializeSpecTasks 为确认表单后端序列化出口(parse 逆运算) | | `getDeliveryStatus` / `deliverProject` / `markProjectDelivered` | `delivery.ts` | 交付台账(证据齐缺 + gaps)+ auto-merge 交付(逐腿独立合并)+ #469 branch-only 人工落档(填 commit 写 deliveredAt/By/Commit,幂等拒绝重复,成功发播报);#376 起响应带 `archived`(终态项目实时重算零 WU = 历史任务数据已清理,前端显示归档提示而非全 0);#469 起响应带 `channelId`(交付播报取数) | | `postProjectMilestone` | `delivery-notify.ts` | #469 项目里程碑出声统一出口:频道面(channelId 非空 → 'Studio' 系统消息,meta pmoId+atHuman)+ 持久面(createForAllUsers type 'system',link `/pmo/project/:id`),全程 best-effort | diff --git a/apps/api/src/modules/pmo/__tests__/plan-direction.test.ts b/apps/api/src/modules/pmo/__tests__/plan-direction.test.ts new file mode 100644 index 000000000..a34546d45 --- /dev/null +++ b/apps/api/src/modules/pmo/__tests__/plan-direction.test.ts @@ -0,0 +1,218 @@ +/** + * PlanDirection 单测(#567:方向锁定——plan 裁决轮前置的一次性方向人闸) + * + * 覆盖(方案 docs/plans/2026-09-plan-direction-picker.md P2 + AC4): + * - 选定方向落账:map.decisions[] 追加「方向:——

(人锁定)」结论 + * (map 未建则就地初始化),方向抉择点命中 fog 条目 → 置 resolved(缺失不补建) + * - 复活同会话:composeDirectionReply 文本经 resumeWaitingWorkUnit 注入 pendingReplies + * → active(sessionId 不动),waitingReason/planDirections 清除,频道里程碑留痕 + * - 幂等:同 wuId 同结论不双写 decisions[] + * - 无 pmoId:只复活不写台账;非 blocked / 无 planDirections → 拒绝 + * - 载荷校验:choice 必填非空 / 必须在候选 name 集合内 / note 可选 → PlanDirectionError + * + * 约定同 plan-ruling.test.ts:PMO 项目经 projectService 写入(落隔离根 projects/), + * afterEach 统一删除。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { FileStore } from '@dommaker/studio-shared'; +import { WorkUnitService, type WorkUnitMetadata, type WorkUnitData } from '../../workunit/workunit.service.js'; +import { projectService, PROJECT_STATUS, type ProjectData, type PmoMap } from '../project.service.js'; +import { applyPlanDirection, validateDirectionPick, PlanDirectionError } from '../plan-direction.js'; + +let tmpDir: string; +let fileStore: FileStore; +let wuService: WorkUnitService; +const createdProjectIds: string[] = []; + +function metaOf(raw: string | null): WorkUnitMetadata { + return raw ? JSON.parse(raw) as WorkUnitMetadata : {}; +} + +async function createProject(): Promise { + const project = await projectService.create({ + title: `direction-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + }); + createdProjectIds.push(project.id); + return (await projectService.get(project.id))!; +} + +const DIRECTIONS = { + question: '自研还是引入依赖?', + options: [ + { name: '自研', summary: '自研调度内核', tradeoffs: '可控但慢', impact: '触及 scheduler 模块', recommended: true }, + { name: '引入依赖', summary: '引入 bullmq', tradeoffs: '快但多一个依赖', impact: '触及 api 与 daemon', recommended: false }, + ], +}; + +async function createDirectionWu(project?: ProjectData): Promise { + return wuService.create({ + type: 'plan', + scope: '规划需求 PMO-1: 测试', + channelId: 'ch-test', + status: 'blocked', + assigneeId: 'instance-1', + metadata: { + ...(project ? { pmoId: project.id } : {}), + waitingForInput: true, + waitingQuestion: '方向锁定——请选定本票方向', + waitingReason: 'plan-direction', + planDirections: DIRECTIONS, + sessionId: 'sess-1', + }, + }); +} + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-direction-')); + fileStore = new FileStore(tmpDir); + wuService = new WorkUnitService(fileStore); + await fileStore.createChannel({ + id: 'ch-test', name: '#test', type: 'rnd', + defaultWorkspaceId: null, defaultPath: null, + discordChannelId: null, discordWebhookUrl: null, members: '[]', + createdAt: '2026-07-01T00:00:00Z', updatedAt: '2026-07-01T00:00:00Z', + }); +}); + +afterEach(async () => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + for (const id of createdProjectIds.splice(0)) { + await projectService.update(id, { status: PROJECT_STATUS.PENDING }).catch(() => {}); + await projectService.delete(id).catch(() => { /* 忽略 */ }); + } +}); + +describe('#567 applyPlanDirection:方向选定落账 + 同会话复活', () => { + it('选定方向:map 就地初始化 + decisions 落「方向:…(人锁定)」结论,fog 命中置 resolved', async () => { + const project = await createProject(); + await projectService.update(project.id, { + map: { + destination: project.title, + decisions: [], + fog: [{ id: 'fog-1', question: '自研还是引入依赖?', wuId: null, status: 'open' }], + } satisfies PmoMap, + }); + const wu = await createDirectionWu(project); + + const updated = await applyPlanDirection(wu.id, { choice: '自研', note: '要可控' }, fileStore); + + const map = (await projectService.get(project.id))!.map!; + expect(map.decisions).toHaveLength(1); + expect(map.decisions[0].summary).toBe('方向:自研——自研调度内核(人锁定)'); + expect(map.decisions[0].wuId).toBe(wu.id); + expect(map.decisions[0].resolvedAt).toBeTruthy(); + expect(map.fog.find(f => f.id === 'fog-1')!.status).toBe('resolved'); + + // 复活:active + 选定方向注入 pendingReplies + 挂起/方向标记清除 + 会话不动 + expect(updated.status).toBe('active'); + const meta = metaOf(updated.metadata); + expect(meta.waitingForInput).toBeFalsy(); + expect(meta.waitingReason).toBeUndefined(); + expect(meta.planDirections).toBeUndefined(); + expect(meta.sessionId).toBe('sess-1'); + const replies = meta.pendingReplies ?? []; + expect(replies).toHaveLength(1); + expect(replies[0]).toContain('方向锁定结果'); + expect(replies[0]).toContain('自研'); + expect(replies[0]).toContain('是 agent 推荐方向'); + expect(replies[0]).toContain('要可控'); + + // 频道里程碑留痕(Web 按钮动作双出声) + const messages = await fileStore.queryMessages('ch-test', { workUnitId: wu.id }); + expect(messages.some(m => m.content.includes('方向锁定') && m.content.includes('自研'))).toBe(true); + }); + + it('方向抉择点不在 fog:不补建 fog 条目,只落 decisions', async () => { + const project = await createProject(); + const wu = await createDirectionWu(project); + + await applyPlanDirection(wu.id, { choice: '引入依赖' }, fileStore); + + const map = (await projectService.get(project.id))!.map!; + expect(map.destination).toBe(project.title); // 就地初始化回退项目标题 + expect(map.fog).toHaveLength(0); // 方向题不一定来自 fog——缺失不补建 + expect(map.decisions).toHaveLength(1); + expect(map.decisions[0].summary).toBe('方向:引入依赖——引入 bullmq(人锁定)'); + + const meta = metaOf((await wuService.getById(wu.id))!.metadata); + expect(meta.pendingReplies![0]).toContain('引入依赖'); + expect(meta.pendingReplies![0]).toContain('不是 agent 推荐方向'); // 非推荐方向如实标注 + }); + + it('幂等:同 wuId 同结论不双写 decisions[]', async () => { + const project = await createProject(); + const wu = await createDirectionWu(project); + await projectService.update(project.id, { + map: { + destination: project.title, + decisions: [{ wuId: wu.id, summary: '方向:自研——自研调度内核(人锁定)', resolvedAt: '2026-09-16T00:00:00Z' }], + fog: [], + } satisfies PmoMap, + }); + + await applyPlanDirection(wu.id, { choice: '自研' }, fileStore); + + const map = (await projectService.get(project.id))!.map!; + expect(map.decisions).toHaveLength(1); // 不双写 + }); + + it('无 pmoId:只复活同会话,不写台账不炸', async () => { + const wu = await createDirectionWu(); + + const updated = await applyPlanDirection(wu.id, { choice: '自研' }, fileStore); + + expect(updated.status).toBe('active'); + const meta = metaOf(updated.metadata); + expect(meta.pendingReplies![0]).toContain('自研'); + expect(meta.planDirections).toBeUndefined(); + }); + + it('拒绝:非 blocked / 无待选 planDirections', async () => { + const project = await createProject(); + const active = await wuService.create({ + type: 'plan', scope: 'x', channelId: 'ch-test', status: 'active', + metadata: { pmoId: project.id, planDirections: DIRECTIONS }, + }); + await expect(applyPlanDirection(active.id, { choice: '自研' }, fileStore)).rejects.toThrow(/blocked/); + + const noDirections = await wuService.create({ + type: 'plan', scope: 'y', channelId: 'ch-test', status: 'blocked', + metadata: { pmoId: project.id, waitingForInput: true }, + }); + await expect(applyPlanDirection(noDirections.id, { choice: '自研' }, fileStore)).rejects.toThrow(/direction/i); + }); +}); + +describe('#567 validateDirectionPick:载荷校验', () => { + it('合法载荷原样通过(note 可省)', () => { + expect(validateDirectionPick({ choice: '自研', note: '要可控' }, DIRECTIONS)) + .toEqual({ choice: '自研', note: '要可控' }); + expect(validateDirectionPick({ choice: '引入依赖' }, DIRECTIONS)) + .toEqual({ choice: '引入依赖' }); + }); + + it('非法载荷抛 PlanDirectionError(400 类别)', () => { + expect(() => validateDirectionPick(undefined, DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick(null, DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick('自研', DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick({}, DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick({ choice: '' }, DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick({ choice: ' ' }, DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick({ choice: 42 }, DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick({ note: 'x' }, DIRECTIONS)).toThrow(PlanDirectionError); + }); + + it('choice 必须在候选 name 集合内', () => { + expect(() => validateDirectionPick({ choice: '第三条路' }, DIRECTIONS)).toThrow(PlanDirectionError); + expect(() => validateDirectionPick({ choice: '第三条路' }, DIRECTIONS)).toThrow(/不在候选方向内/); + }); + + it('字段截 500 字符(choice/note)', () => { + const long = 'x'.repeat(600); + const pick = validateDirectionPick({ choice: '自研', note: long }, DIRECTIONS); + expect(pick.note).toHaveLength(500); + }); +}); diff --git a/apps/api/src/modules/pmo/plan-direction.ts b/apps/api/src/modules/pmo/plan-direction.ts new file mode 100644 index 000000000..128ccccc7 --- /dev/null +++ b/apps/api/src/modules/pmo/plan-direction.ts @@ -0,0 +1,178 @@ +/** + * Plan Direction — 方向锁定(#567):plan 一脉会话裁决轮前置的一次性方向人闸。 + * + * 方案正本 docs/plans/2026-09-plan-direction-picker.md:存在互斥大方向时 agent 先出一次 + * 方向卡(NEED_INPUT + DIRECTION: 行,契约见 prompt-composer PLAN_DIRECTION_SECTION; + * 解析落 metadata.planDirections + waitingReason='plan-direction',见 agent-loop), + * 前端方向接力卡/弹窗让人单选一个方向(可附补充说明)。本模块是方向提交的唯一正本 + * (POST /workunits/:id/direction → applyPlanDirection),链路全仿 #467 plan-ruling: + * + * 1) 证据链落账(有 pmoId 时):map.decisions[] 追加 + * { wuId: planWuId, summary: `方向:——(人锁定)`, resolvedAt }; + * 方向抉择点命中 fog 条目 → 置 resolved(缺失不补建——方向题不一定来自 fog, + * 与 ruling 的「缺失按 question 补建」不同);map 未建则就地初始化 + * (destination 回退项目标题,同 map-opening 缺省口径)。 + * 幂等:decisions[] 已有同 wuId+同结论条目 → 不双写。 + * 2) 同会话复活:composeDirectionReply(选定方向 + 是否推荐方向 + 补充说明)经 + * resumeWaitingWorkUnit 注入 pendingReplies → active(sessionId 不动 = 同会话续跑, + * #94);waitingReason/planDirections 随提交清除。频道自由文本回复仍是 fallback + * (waiting-input 通用路径)。 + * 3) 双出声:Web 按钮动作在频道不可见 → 复活后发 Studio 里程碑消息留痕 + * (照 resumeBlockedWorkUnitFromWeb / applyPlanRuling 先例)。 + * + * 无 pmoId 的 plan(非 PMO 链)只复活不写台账。map 写按 projectId 串行化 + * (照 plan-ruling / decision-resolution 链式排队)。 + */ + +import { logger, FileStore } from '@dommaker/studio-shared'; +import { WorkUnitService, type WorkUnitData } from '../workunit/workunit.service.js'; +import { parseWuMetadata } from '../workunit/wu-metadata.js'; +import { resumeWaitingWorkUnit } from '../workunit/waiting-input.js'; +import { postWuSystemMessage } from '../workunit/wu-messenger.js'; +import { projectService, type PmoMap } from './project.service.js'; +import { createKeyedEnqueue } from './keyed-enqueue.js'; + +/** 载荷校验失败(路由转 400) */ +export class PlanDirectionError extends Error {} + +/** 方向提交形态:选定的候选方向名 + 可选补充说明 */ +export interface PlanDirectionPickInput { + choice: string; + note?: string; +} + +/** 方向锁定的落档形态(与 workunit.types.ts WorkUnitMetadata.planDirections 同构) */ +export interface PlanDirections { + question: string; + options: { name: string; summary: string; tradeoffs: string; impact: string; recommended: boolean }[]; +} + +const DIRECTION_FIELD_MAX_CHARS = 500; + +/** + * 校验方向提交载荷(路由层 400 契约):raw 须为对象、choice 必填非空 string、 + * note 可选 string;choice 必须在 planDirections.options 的 name 集合内。 + */ +export function validateDirectionPick(raw: unknown, planDirections: PlanDirections): PlanDirectionPickInput { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new PlanDirectionError('载荷必须是对象 {choice, note?}'); + } + const body = raw as Record; + const choice = typeof body.choice === 'string' ? body.choice.trim() : ''; + if (!choice) throw new PlanDirectionError('choice 必填非空(选定的方向名)'); + const names = planDirections.options.map(o => o.name); + if (!names.includes(choice)) { + throw new PlanDirectionError(`choice「${choice}」不在候选方向内(可选:${names.join(' / ')})`); + } + const note = typeof body.note === 'string' ? body.note.trim() : ''; + return { + choice: choice.slice(0, DIRECTION_FIELD_MAX_CHARS), + ...(note ? { note: note.slice(0, DIRECTION_FIELD_MAX_CHARS) } : {}), + }; +} + +/** + * 应用方向选定:落账(有 pmoId 时)→ 清方向标记 → 组合选定文本复活同会话 → 频道里程碑。 + * @throws Error WU 不存在 / 非 blocked / 无待选 planDirections(路由按文案映射 404/409) + */ +export async function applyPlanDirection( + workUnitId: string, + pick: PlanDirectionPickInput, + fs?: FileStore, +): Promise { + const fileStore = fs ?? new FileStore(); + const wuService = new WorkUnitService(fileStore); + const wu = await wuService.getById(workUnitId); + if (!wu) throw new Error(`WorkUnit ${workUnitId} not found`); + if (wu.status !== 'blocked') { + throw new Error(`WorkUnit 当前状态为 ${wu.status},仅 blocked(方向锁定挂起)可提交方向选定`); + } + const meta = parseWuMetadata(wu.metadata); + const planDirections = meta.planDirections as PlanDirections | undefined; + if (!planDirections || !Array.isArray(planDirections.options) || planDirections.options.length === 0) { + throw new Error(`WorkUnit ${workUnitId} 无待选的方向锁定(no pending direction)`); + } + const selected = planDirections.options.find(o => o.name === pick.choice)!; // 路由层已校验在集合内 + + const pmoId = typeof meta.pmoId === 'string' ? meta.pmoId : ''; + if (pmoId) { + await enqueue(pmoId, () => writeDirectionToMap(pmoId, workUnitId, planDirections, selected)); + } + + // 清方向标记(waitingReason/planDirections;waitingForInput 由复活原语清除) + await fileStore.updateMetadata(workUnitId, latest => ({ + ...latest, + waitingReason: undefined, // JSON 序列化丢弃 undefined → 清除 + planDirections: undefined, + })); + + // 同会话复活:选定方向文本进 pendingReplies 注入下一步 prompt(sessionId 不动) + const replyText = composeDirectionReply(pick, planDirections, selected); + const resumed = await resumeWaitingWorkUnit(workUnitId, replyText, fileStore); + if (!resumed) { + // 竞态(状态在守卫检查后变化):方向已落账,复活失败只记日志人工可补 + logger.warn('[PlanDirection] resume failed after direction applied (non-blocking)', { workUnitId }); + } + + if (wu.channelId) { + const title = (meta.title ?? wu.scope).slice(0, 50); + await postWuSystemMessage( + wu, + `任务「${title}」方向锁定已确认:选定「${selected.name}」,规划会话继续`, + { milestone: true, fileStore }, + ).catch(err => + logger.warn('[PlanDirection] milestone notice failed (non-blocking)', { workUnitId, error: String(err) }), + ); + } + + logger.info('[PlanDirection] Direction applied', { workUnitId, pmoId: pmoId || undefined, choice: selected.name }); + return (await wuService.getById(workUnitId))!; +} + +/** 组合注入会话的方向选定文本(人的一次操作 → agent 下一步的直接上下文) */ +function composeDirectionReply( + pick: PlanDirectionPickInput, + planDirections: PlanDirections, + selected: PlanDirections['options'][number], +): string { + const lines = ['[方向锁定结果] 人类已选定本票方向:']; + lines.push(`- 抉择点「${planDirections.question}」→ 选定方向:「${selected.name}」——${selected.summary}`); + lines.push(`- 该方向${selected.recommended ? '是' : '不是'} agent 推荐方向`); + if (pick.note) lines.push(`- 补充说明:${pick.note}`); + lines.push('方向结论已落探路台账,请按选定方向继续裁决轮 / spec 成文与 TASK 拆单。'); + return lines.join('\n'); +} + +/** + * 落账:decisions[] 追加「方向:——(人锁定)」结论; + * 方向抉择点命中 fog 条目 → 置 resolved(缺失不补建——方向题不一定来自 fog)。 + * map 未建则就地初始化(destination 回退项目标题)。 + * 幂等:decisions[] 已有同 wuId+同结论条目 → 跳过不双写。 + */ +async function writeDirectionToMap( + projectId: string, + wuId: string, + planDirections: PlanDirections, + selected: PlanDirections['options'][number], +): Promise { + const project = await projectService.get(projectId); + if (!project) { + logger.warn('[PlanDirection] project not found (skip map write)', { projectId, wuId }); + return; + } + const base: PmoMap = project.map ?? { destination: project.title, decisions: [], fog: [] }; + const fog = [...base.fog]; + const decisions = [...base.decisions]; + + const summary = `方向:${selected.name}——${selected.summary}(人锁定)`; + const dup = decisions.some(d => d.wuId === wuId && d.summary === summary); + if (dup) return; // 幂等:重复提交不双写(fog resolved 状态不变,无需落账) + const idx = fog.findIndex(f => f.question === planDirections.question); + if (idx >= 0) fog[idx] = { ...fog[idx], status: 'resolved' }; // 缺失不补建 + decisions.push({ wuId, summary, resolvedAt: new Date().toISOString() }); + + await projectService.update(projectId, { map: { ...base, fog, decisions } }); +} + +/** 同 PMO 的 map 写串行化(共享实现 keyed-enqueue,前序失败不阻断后续) */ +const enqueue = createKeyedEnqueue(); diff --git a/apps/api/src/modules/skills/CONTEXT.md b/apps/api/src/modules/skills/CONTEXT.md index c931a07a7..a9e461d72 100644 --- a/apps/api/src/modules/skills/CONTEXT.md +++ b/apps/api/src/modules/skills/CONTEXT.md @@ -6,8 +6,8 @@ skills 模块负责技能(Skill)的完整生命周期管理,包括基于 ### 词汇表 -- **Skill 加载(loaded)**:agent 经 MCP 工具 `loadSkill` 显式拉取 SKILL.md 正文。`knowledge:skill_used` 事件的唯一语义(#60 决策,2026-08-09;#172 落地):发射点 = `skill-loader.ts` 的 `loadSkill`,payload 携带 `workUnitId`(调用方已知时),envelope level=debug。 -- **Skill 曝光(exposed)**:已废除的概念。旧口径把「skill 索引条目进入 prompt」记为 skill_used,实测为常量集合(14/20 skill,零信息量),发射已删除(#60 决策,#172 落地,prompt-composer.ts)。基于曝光口径的降级提案统计无效,修复归知识飞轮 handoff。 +- **Skill 使用(used)**:agent 在 WU 执行期间获取 SKILL.md 全文,渠道无关(loadSkill / Read / cat 均算)。`knowledge:skill_used` 事件(#60 决策,2026-08-09;#172 落地)发射点三处:`skill-loader.ts`(loadSkill 文件路径)、`mcp/skill-tools.ts`(loadSkill cache 路径,度量地基票 A 补)、`skill-usage-scan.ts`(WU done transcript 后验扫描,主口径——agent 直读文件绕开 loadSkill 时仍能在 rawOutput 路径痕迹中测到)。payload 携带 `workUnitId`(已知时)+ `channel`;envelope level=info(度量地基票 C 提为 signal:热 30 天 → 月度 gz 归档,不再随噪声 7 天滚)。 +- **Skill 曝光(exposed)**:曝光事件发射已废除(旧口径把「skill 索引条目进入 prompt」记为 skill_used,实测为常量集合零信息量,#60 决策,#172 落地)。度量地基票 D 起曝光以 `exposures` 统计字段恢复(matchedSkills 命中计数),仅降级提案人审参考,不参与判定;successRate 归因同步从曝光改为使用(有 skill_used 的 WU 才背成败)。 ### 核心导出 @@ -25,7 +25,8 @@ skills 模块负责技能(Skill)的完整生命周期管理,包括基于 | GET /skills/manifest | routes.ts | #462:skills MANIFEST 只读清单(name/description/agentTypes/triggers),角色编辑 UI 的 skill 多选数据源;loop-consumer 不进候选(与注入口径一致);注册在 /:id 之前 | | SkillRecord, SkillCreateInput, SkillUpdateInput | skill-store.ts | 技能元数据的类型定义及文件型 CRUD | | LoadedSkill, SessionSkillState, LoadSkillOptions | skill-loader.ts | 技能加载相关的类型定义;#361 起磁盘加载归 `@dommaker/studio-skill` 包加载器(loadSingle),本文件只留会话级 load 缓存 + skill_used 事件发射(第三份 frontmatter 解析器已删) | -| aggregateSkillUsage, scanSkillDemotions, approveDemotion, rejectDemotion, DemotionProposalStore | skill-demotion.ts | §10.6 降级通路:skill_used 事件 + WU 终态聚合 → 降级提案(只提案不自动生效;approve 改 frontmatter status,正文逐字节保留);提案存 ~/.studio/data/skills/demotion-proposals.json | +| aggregateSkillUsage, scanSkillDemotions, approveDemotion, rejectDemotion, DemotionProposalStore | skill-demotion.ts | §10.6 降级通路:skill_used 事件 + WU 终态聚合 → 降级提案(只提案不自动生效;approve 改 frontmatter status,正文逐字节保留);票 D 起 successRate 使用归因(usedWuIds ∩ 终态)+ exposures 曝光计数仅展示;提案存 ~/.studio/data/skills/demotion-proposals.json | +| SkillUsageScanner, initSkillUsageScan, extractUsedSkillNames | skill-usage-scan.ts | 度量地基票 B:WU done → readTranscript 扫 rawOutput 中 `skills//SKILL.md` 痕迹 → 每 skill 一条 skill_used(channel=transcript,level=info);纯确定性零 LLM,eventBus 钩子(index.ts 挂载),聚合侧 (skill, WU) 去重吸收重复触发 | | router | skill-demotion-routes.ts | 降级提案列表(?scan=true 触发扫描)/ 审批路由,挂载至 /api/v1/skills/demotion-proposals(先于 /api/v1/skills 注册) | ### 依赖关系 @@ -60,3 +61,4 @@ skills 模块负责技能(Skill)的完整生命周期管理,包括基于 - **鉴权(2026-07-24 收紧)**:skills 8 条写(POST /、PATCH、DELETE、publish、deprecate、restore、usage、retract/decide)+ demotion-proposals approve/reject + proposals 写端点(scan/extract/retract;approve/reject 已于 #354 删除,审批走正本通用端点)已收 requireAuth+requireNotGuest。GET /api/v1/skills/proposals 被 skills 的 GET /:id 遮蔽,属路由顺序 bug(未修)。 - **retract 决策闭环(#278,决策 #250 D2)**:`POST /:id/retract/decide {decision, messageId?, channelId?}`(routes.ts)补上 retract 下半截——守卫 `status==='under_review'`,confirm→`deprecated`、reject→`published`;messageId 提供时经 `channelMessageService.updateMessageMeta` 回写 retract_confirm 卡 meta.status(非阻断);#524 P1-1 起 channelId 可选透传(前端 useChannelCardActions 带上)→ 回写按频道直查免全频道扇出,缺省保留扇出兼容。注意 retract 端点本体在 proposals 挂载点下,实际路径是 `/api/v1/skills/proposals/:id/retract`(与文件内注释声称的 `/skills/:id/retract` 不符,历史遗留)。 - **手改 SKILL.md 后一次性重生成 MANIFEST.md(#306,2026-08-24)**:在 `apps/api` 目录内用 tsx 跑临时脚本调 `generateManifest()`(manifest-generator.ts:54)——脚本必须放项目目录内,tsx 按脚本位置解析相对导入,放 /tmp 会 MODULE_NOT_FOUND。免手工路径:API 启动时 seed hash 有变更自动重生成。 +- **CLI 流动三件套(#568)**:`studio skill validate/export/install` 落 `apps/api/src/cli/skill.ts`(纯本地文件操作,daemon 离线可用);install 只落目录不写 skills-index.json(与 seed 对齐:目录即注册,REST CRUD 面看不到 install 来的 skill,语义与内置 skill 一致)。 diff --git a/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts b/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts index 0fa0e1d39..3be3871b6 100644 --- a/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts +++ b/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts @@ -3,7 +3,8 @@ * * tmp 目录构造:fixture events jsonl + FileStore WU 索引 + 磁盘 skill(SKILLS_DIR 风格 tmp 目录)。 * 覆盖: - * - 聚合:uses / lastUsedAt / successRate(done=成功,closed/blocked=不成功,未终态不计) + * - 聚合:uses / lastUsedAt / exposures;票 D 起 successRate 使用归因 + * (有 skill_used 的 WU 才计入:done=成功,closed/blocked=不成功,未终态不计) * - 规则边界:恰好 5 次使用 + 成功率恰好 0.3 → 不提案;29 天零使用 → 不提案 * - 幂等:重复扫描不重复产提案 * - approve:frontmatter status 改写且正文逐字节保留(hash 对比) @@ -118,29 +119,67 @@ afterEach(() => { }); describe('§10.6 aggregateSkillUsage', () => { - it('聚合 uses / lastUsedAt / successRate(done=成功,closed=不成功,in_review 不计)', async () => { + it('聚合 uses / lastUsedAt(channel 字段仅记录来源,不影响计数)', async () => { writeEvents([ skillUsedEvent('skill-a', '2026-07-19T10:00:00.000Z'), skillUsedEvent('skill-a', '2026-07-20T10:00:00.000Z'), - skillUsedEvent('skill-b', '2026-07-18T10:00:00.000Z'), - ]); - writeIndex([ - makeWu('wu-1', 'done', ['skill-a']), - makeWu('wu-2', 'closed', ['skill-a']), - makeWu('wu-3', 'in_review', ['skill-a']), // 未终态不计入 - makeWu('wu-4', 'blocked', ['skill-b']), - makeWu('wu-5', 'done'), // 无 matchedSkills + skillUsedEventForWu('skill-b', 'wu-4', '2026-07-18T10:00:00.000Z'), ]); + writeIndex([]); const usage = await aggregateSkillUsage({ eventsFile, fileStore }); const a = usage.get('skill-a')!; expect(a.uses).toBe(2); expect(a.lastUsedAt).toBe('2026-07-20T10:00:00.000Z'); - expect(a.successRate).toBe(0.5); // 1 done / 2 终态 + expect(a.exposures).toBe(0); const b = usage.get('skill-b')!; expect(b.uses).toBe(1); - expect(b.successRate).toBe(0); // 0 done / 1 终态(blocked) + }); + + it('票D: successRate 使用归因——有 skill_used 的 WU 才计入(done=成功,closed/blocked=不成功,未终态不计)', async () => { + writeEvents([ + skillUsedEventForWu('skill-a', 'wu-1', '2026-07-19T10:00:00.000Z'), // wu-1 done → 成功 + skillUsedEventForWu('skill-a', 'wu-2', '2026-07-19T11:00:00.000Z'), // wu-2 closed → 不成功 + skillUsedEventForWu('skill-a', 'wu-3', '2026-07-19T12:00:00.000Z'), // wu-3 in_review → 未终态不计 + skillUsedEventForWu('skill-b', 'wu-4', '2026-07-18T10:00:00.000Z'), // wu-4 blocked → 不成功 + ]); + writeIndex([ + makeWu('wu-1', 'done'), // 无 matchedSkills,但真用了 skill-a → 计入 + makeWu('wu-2', 'closed'), + makeWu('wu-3', 'in_review'), + makeWu('wu-4', 'blocked', ['skill-b']), + makeWu('wu-5', 'closed', ['skill-a']), // 注入了 skill-a 但没读(无 skill_used)→ 不计入成功率 + ]); + + const usage = await aggregateSkillUsage({ eventsFile, fileStore }); + expect(usage.get('skill-a')!.successRate).toBe(0.5); // 1 done / 2 终态(wu-5 不算) + expect(usage.get('skill-b')!.successRate).toBe(0); // 0 done / 1 终态(blocked) + }); + + it('票D: 仅 legacy 事件(无 workUnitId)→ successRate=null(无法归因,未知不编造)', async () => { + writeEvents([skillUsedEvent('skill-a', '2026-07-19T10:00:00.000Z')]); + writeIndex([makeWu('wu-1', 'done', ['skill-a'])]); + + const usage = await aggregateSkillUsage({ eventsFile, fileStore }); + expect(usage.get('skill-a')!.successRate).toBeNull(); + }); + + it('票D: exposures = matchedSkills 命中计数(曝光侧,仅展示;含未终态 WU)', async () => { + writeEvents([skillUsedEventForWu('skill-a', 'wu-1', '2026-07-19T10:00:00.000Z')]); + writeIndex([ + makeWu('wu-1', 'done', ['skill-a']), + makeWu('wu-2', 'in_review', ['skill-a']), + makeWu('wu-3', 'closed'), // 无 matchedSkills + makeWu('wu-4', 'done', ['skill-b']), // skill-b 仅曝光未使用 + ]); + + const usage = await aggregateSkillUsage({ eventsFile, fileStore }); + expect(usage.get('skill-a')!.exposures).toBe(2); + const b = usage.get('skill-b')!; + expect(b.exposures).toBe(1); + expect(b.uses).toBe(0); + expect(b.successRate).toBeNull(); }); it('uses 口径:带 workUnitId 按 (skill, WU) 去重;legacy 无 workUnitId 每条计 1', async () => { @@ -177,16 +216,14 @@ describe('§10.6 scanSkillDemotions 规则边界', () => { expect(result.proposals[0].suggestedStatus).toBe('archived'); }); - it('恰好 5 次使用 + 成功率恰好 0.3 → 不提案;成功率 < 0.3 → demote 提案', async () => { - // skill-edge: 5 次使用,10 个终态 WU 3 成功 → 0.3,不满足 < 0.3 - // skill-bad: 5 次使用,10 个终态 WU 2 成功 → 0.2,满足 + it('恰好 5 次使用 + 成功率恰好 0.3 → 不提案;成功率 < 0.3 → demote 提案(票D: 使用归因)', async () => { + // skill-edge: 10 个使用 WU(终态)3 成功 → 0.3,不满足 < 0.3 + // skill-bad: 10 个使用 WU(终态)2 成功 → 0.2,满足 const events: string[] = []; const wus: WorkUnitSnapshot[] = []; - for (let i = 0; i < 5; i++) { - events.push(skillUsedEvent('skill-edge', new Date(NOW - i * 1000).toISOString())); - events.push(skillUsedEvent('skill-bad', new Date(NOW - i * 1000).toISOString())); - } for (let i = 0; i < 10; i++) { + events.push(skillUsedEventForWu('skill-edge', `edge-${i}`, new Date(NOW - i * 1000).toISOString())); + events.push(skillUsedEventForWu('skill-bad', `bad-${i}`, new Date(NOW - i * 1000).toISOString())); wus.push(makeWu(`edge-${i}`, i < 3 ? 'done' : 'closed', ['skill-edge'])); wus.push(makeWu(`bad-${i}`, i < 2 ? 'done' : 'closed', ['skill-bad'])); } @@ -202,12 +239,13 @@ describe('§10.6 scanSkillDemotions 规则边界', () => { expect(result.proposals[0].stats.successRate).toBe(0.2); }); - it('uses < 5 即使成功率 0 → 不提案;successRate=null(无终态 WU)→ 不提案', async () => { - writeIndex([makeWu('wu-1', 'closed', ['skill-few'])]); + it('uses < 5 即使成功率 0 → 不提案;successRate=null(legacy 事件无法归因)→ 不提案', async () => { + // skill-few: 4 个使用 WU 全 closed → successRate=0 但 uses=4 < 5 + writeIndex(Array.from({ length: 4 }, (_, i) => makeWu(`few-${i}`, 'closed'))); writeSkill('skill-few', { ageDays: 5 }); writeSkill('skill-no-outcome', { ageDays: 5 }); writeEvents([ - ...Array.from({ length: 4 }, (_, i) => skillUsedEvent('skill-few', new Date(NOW - i * 1000).toISOString())), + ...Array.from({ length: 4 }, (_, i) => skillUsedEventForWu('skill-few', `few-${i}`, new Date(NOW - i * 1000).toISOString())), ...Array.from({ length: 6 }, (_, i) => skillUsedEvent('skill-no-outcome', new Date(NOW - i * 1000).toISOString())), ]); diff --git a/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts b/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts index 74ee90436..216f1c6e8 100644 --- a/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts +++ b/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts @@ -139,7 +139,7 @@ describe('Skill event emission', () => { expect(payload.skillName).toBe('test-skill'); }); - test('#172(#60 决策):loadSkill 的 skill_used 携带 workUnitId(传入时)+ envelope level=debug', async () => { + test('#172(#60 决策)+ 度量地基票 C:loadSkill 的 skill_used 携带 workUnitId(传入时)+ envelope level=info(signal 提级)', async () => { const { SkillLoaderService } = await import('../../skills/skill-loader.js'); const loader = new SkillLoaderService(); @@ -151,7 +151,7 @@ describe('Skill event emission', () => { expect(result).not.toBeNull(); const row = await waitForEvent('knowledge:skill_used'); - expect(row.level).toBe('debug'); // knowledge:* 默认 debug 分级(写口按 type 赋级) + expect(row.level).toBe('info'); // 票 C:skill_used 显式提级 signal(归档保留,不 7 天滚) const payload = JSON.parse(row!.payload); expect(payload.skillName).toBe('test-skill'); expect(payload.workUnitId).toBe('wu-42'); diff --git a/apps/api/src/modules/skills/__tests__/skill-usage-scan.test.ts b/apps/api/src/modules/skills/__tests__/skill-usage-scan.test.ts new file mode 100644 index 000000000..e2f76975b --- /dev/null +++ b/apps/api/src/modules/skills/__tests__/skill-usage-scan.test.ts @@ -0,0 +1,125 @@ +/** + * skill-usage-scan(skill 度量地基票 B)单测 + * + * transcript 后验使用扫描:WU done → readTranscript → 扫 rawOutput 中的 + * skills//SKILL.md 痕迹 → 每 skill 一条 knowledge:skill_used + * (channel=transcript,level=info signal)。纯确定性零 LLM。 + * eventBus / writeStudioEvent / readTranscript 均 mock,不落盘。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockWriteStudioEvent, mockReadTranscript, mockSubscribe, state } = vi.hoisted(() => ({ + mockWriteStudioEvent: vi.fn().mockResolvedValue(true), + mockReadTranscript: vi.fn(), + mockSubscribe: vi.fn(), + state: { statusChangedHandler: null as null | ((payload: { workunit: unknown }) => void) }, +})); + +vi.mock('@dommaker/studio-shared', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeStudioEvent: mockWriteStudioEvent, + eventBus: { + subscribe: (event: string, handler: (payload: { workunit: unknown }) => void) => { + mockSubscribe(event, handler); + if (event === 'workunit.status_changed') state.statusChangedHandler = handler; + }, + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock('../../transcripts/transcript-archive.js', () => ({ + readTranscript: mockReadTranscript, +})); + +import { SkillUsageScanner, extractUsedSkillNames, initSkillUsageScan } from '../skill-usage-scan.js'; + +describe('extractUsedSkillNames', () => { + it('命中绝对/相对路径,按 skill 名去重', () => { + const text = [ + 'Read /root/.studio/skills/code-review/SKILL.md 成功', + 'cat ~/.studio/skills/code-review/SKILL.md', + '见 skills/tdd-implement/SKILL.md 全文', + ].join('\n'); + expect(extractUsedSkillNames(text)).toEqual(['code-review', 'tdd-implement']); + }); + + it('无命中 → 空数组', () => { + expect(extractUsedSkillNames('读取了 src/index.ts 和 README.md')).toEqual([]); + }); + + it('_ 前缀目录(_deprecated 等)不计入', () => { + expect(extractUsedSkillNames('skills/_deprecated/old-skill/SKILL.md')).toEqual([]); + }); +}); + +describe('SkillUsageScanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.statusChangedHandler = null; + }); + + it('done WU → 每命中 skill 恰好一条 skill_used(多步读同一 skill 只一条)', async () => { + mockReadTranscript.mockResolvedValue([ + { workUnitId: 'wu-1', step: 1, rawOutput: '读取 skills/code-review/SKILL.md' }, + { workUnitId: 'wu-1', step: 2, rawOutput: '再次引用 /root/.studio/skills/code-review/SKILL.md 和 skills/research/SKILL.md' }, + ]); + const scanner = new SkillUsageScanner(); + const used = await scanner.scan({ id: 'wu-1' } as never); + expect(used).toEqual(['code-review', 'research']); + expect(mockWriteStudioEvent).toHaveBeenCalledTimes(2); + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', { + skillName: 'code-review', workUnitId: 'wu-1', channel: 'transcript', + }, { source: 'skill-usage-scan', level: 'info' }); + }); + + it('transcript 无命中 → 不发射事件', async () => { + mockReadTranscript.mockResolvedValue([{ workUnitId: 'wu-2', step: 1, rawOutput: '普通输出' }]); + const scanner = new SkillUsageScanner(); + expect(await scanner.scan({ id: 'wu-2' } as never)).toEqual([]); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); + + it('transcript 缺失(readTranscript 返回 [])→ 不发射不抛错', async () => { + mockReadTranscript.mockResolvedValue([]); + const scanner = new SkillUsageScanner(); + expect(await scanner.scan({ id: 'wu-ghost' } as never)).toEqual([]); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); + + it('订阅 workunit.status_changed:done 触发扫描,非 done 不触发', async () => { + mockReadTranscript.mockResolvedValue([ + { workUnitId: 'wu-3', step: 1, rawOutput: 'skills/tdd-implement/SKILL.md' }, + ]); + const scanner = new SkillUsageScanner(); + scanner.subscribeToEvents(); + expect(state.statusChangedHandler).not.toBeNull(); + + state.statusChangedHandler!({ workunit: { id: 'wu-skip', status: 'active' } }); + expect(mockReadTranscript).not.toHaveBeenCalled(); + + state.statusChangedHandler!({ workunit: { id: 'wu-3', status: 'done' } }); + await vi.waitFor(() => { + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', + expect.objectContaining({ skillName: 'tdd-implement', workUnitId: 'wu-3' }), + expect.objectContaining({ level: 'info' })); + }); + }); + + it('subscribeToEvents 幂等(重复订阅只挂一次)', () => { + const scanner = new SkillUsageScanner(); + scanner.subscribeToEvents(); + const first = state.statusChangedHandler; + scanner.subscribeToEvents(); + expect(state.statusChangedHandler).toBe(first); + }); + + it('initSkillUsageScan 返回单例并完成订阅', () => { + const a = initSkillUsageScan(); + const b = initSkillUsageScan(); + expect(a).toBe(b); + expect(state.statusChangedHandler).not.toBeNull(); + }); +}); diff --git a/apps/api/src/modules/skills/skill-demotion.ts b/apps/api/src/modules/skills/skill-demotion.ts index 62491d514..408151381 100644 --- a/apps/api/src/modules/skills/skill-demotion.ts +++ b/apps/api/src/modules/skills/skill-demotion.ts @@ -1,14 +1,18 @@ /** * §10.6 skill 生命周期降级通路(聚合 + 降级提案)。 * - * 数据流: - * knowledge:skill_used 事件(payload: { skillName, workUnitId? },agent-loop.ts step 注入时发射) - * → 每 skill 使用次数 / lastUsedAt。口径(2026-07-27 校准):uses = 使用 WU 数—— - * 带 workUnitId 的事件按 (skill, workUnitId) 去重(同一 WU 多 step 注入只计一次); - * legacy 事件(无 workUnitId,source=skill-loader)每条计 1 次。 + * 数据流(skill 度量地基票 D,2026-09-15 口径重定义:曝光 vs 使用分离): + * knowledge:skill_used 事件(payload: { skillName, workUnitId?, channel? }) + * 发射点:skill-loader.ts(loadSkill 文件路径)/ skill-tools.ts(loadSkill cache + * 路径)/ skill-usage-scan.ts(WU done transcript 后验扫描,主口径)。 + * → uses = 使用 WU 数:带 workUnitId 的事件按 (skill, workUnitId) 去重(同一 WU + * 多渠道/多步只计一次);legacy 事件(无 workUnitId)每条计 1 次。 + * → successRate 使用归因:终态 WU 关联 = 该 WU 有该 skill 的 skill_used 事件 + * (注入了没被读的 skill 不背 WU 成败);done = 成功,closed/blocked = 不成功, + * 其余(active/in_review/unassigned 等)未终态不计入。 * WU 索引 metadata.matchedSkills(决策 7:agent-loop step 时匹配并落盘) - * → skill ↔ WU 关联;终态口径:done = 成功,closed/blocked = 不成功, - * 其余(active/in_review/unassigned 等)未终态不计入成功率。 + * → exposures 曝光计数(注入名单命中次数,含未终态 WU),仅人审参考, + * 不参与 demote 判定。 * 无关联终态 WU 时 successRate = null(未知,不参与 demote 判定,不编造)。 * * 降级规则(只产提案,绝不自动生效;人审通过才改 frontmatter): @@ -39,9 +43,11 @@ export const DEMOTE_MAX_SUCCESS_RATE = 0.3; export interface SkillUsageStats { uses: number; - /** 终态 WU 成功率;无关联终态 WU → null(未知) */ + /** 终态 WU 成功率(使用归因:有 skill_used 的 WU 才计入);无关联终态 WU → null(未知) */ successRate: number | null; lastUsedAt: string | null; + /** 曝光次数(注入名单 matchedSkills 命中计数,含未终态 WU);仅人审参考,不参与 demote 判定 */ + exposures: number; } export type DemotionKind = 'archive' | 'demote'; @@ -144,7 +150,7 @@ export interface AggregateOptions { } /** - * 聚合 knowledge:skill_used 事件 + WU 终态 → 每 skill { uses, successRate, lastUsedAt }。 + * 聚合 knowledge:skill_used 事件 + WU 终态 → 每 skill { uses, successRate, lastUsedAt, exposures }。 * 事件文件/索引不可读 → 返回空 Map,不抛错。 */ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise> { @@ -155,7 +161,7 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise { let s = stats.get(name); if (!s) { - s = { uses: 0, successRate: null, lastUsedAt: null }; + s = { uses: 0, successRate: null, lastUsedAt: null, exposures: 0 }; stats.set(name, s); } return s; @@ -168,8 +174,10 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise(); + const usedWuIds = new Map>(); for (const row of rows) { if (row?.type !== 'knowledge:skill_used') continue; @@ -189,18 +197,24 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise s.lastUsedAt)) s.lastUsedAt = tsRaw; } - // WU 终态关联:metadata.matchedSkills 命中的 skill 记一次终态结果 - const outcomes = new Map(); + // WU 索引:终态状态表(successRate 使用归因)+ 曝光计数(matchedSkills 命中,仅展示) + const wuStatus = new Map(); const wus = await fileStore.getIndex().catch(() => []); for (const wu of wus) { - if (wu.status !== 'done' && wu.status !== 'closed' && wu.status !== 'blocked') continue; + wuStatus.set(wu.id, wu.status); if (!wu.metadata) continue; let matched: unknown; try { @@ -211,15 +225,25 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise 0) stats.get(name)!.successRate = success / final; } return stats; @@ -293,7 +317,7 @@ export async function scanSkillDemotions(opts?: ScanOptions): Promise<{ // 已归档的 skill 不再产提案 if (skill.status === 'archived') continue; - const stats = usage.get(skill.name) ?? { uses: 0, successRate: null, lastUsedAt: null }; + const stats = usage.get(skill.name) ?? { uses: 0, successRate: null, lastUsedAt: null, exposures: 0 }; const ageDays = (now - skill.mtimeMs) / 86_400_000; const statsWithAge = { ...stats, ageDays: Math.round(ageDays * 10) / 10 }; diff --git a/apps/api/src/modules/skills/skill-loader.ts b/apps/api/src/modules/skills/skill-loader.ts index 2b9d7c035..fdecb21b2 100644 --- a/apps/api/src/modules/skills/skill-loader.ts +++ b/apps/api/src/modules/skills/skill-loader.ts @@ -8,8 +8,11 @@ * * #75: loadSkill lifecycle * #172(#60 决策 Q2):knowledge:skill_used 唯一语义 = Skill 加载(本文件发射点), - * 携带 workUnitId(调用方已知时);经 writeStudioEvent 落盘(envelope level=debug), + * 携带 workUnitId(调用方已知时);经 writeStudioEvent 落盘, * 替代模块加载期固化的直连路径(修复测试期假 id 写入生产事件文件的污染漏洞)。 + * skill 度量地基票 C:skill_used 自本票起显式 level=info(signal——热 30 天 → 月度 + * gz 归档永久保留),不再随 knowledge:* 大类默认 debug 归噪声 7 天滚动删除; + * 大类默认不动(其余 knowledge:* 维持 noise)。 */ import { logger, writeStudioEvent } from '@dommaker/studio-shared'; @@ -103,12 +106,13 @@ export class SkillLoaderService { state.loaded.set(skillName, loaded); // S3 Gap 3c + #172(#60 决策 Q2): skill_used 唯一语义 = Skill 加载,补 workUnitId; - // 经 writeStudioEvent 统一入口落盘(envelope level=debug,测试期走隔离事件文件) + // 经 writeStudioEvent 统一入口落盘(测试期走隔离事件文件); + // skill 度量地基票 C:显式 level=info 提为 signal(归档保留,不 7 天滚) void writeStudioEvent('knowledge:skill_used', { skillName, skillId, ...(options.workUnitId ? { workUnitId: options.workUnitId } : {}), - }, { source: 'skill-loader' }).catch(() => {}); + }, { source: 'skill-loader', level: 'info' }).catch(() => {}); logger.info('[SkillLoader] Loaded skill', { sessionId, diff --git a/apps/api/src/modules/skills/skill-usage-scan.ts b/apps/api/src/modules/skills/skill-usage-scan.ts new file mode 100644 index 000000000..ec3daffba --- /dev/null +++ b/apps/api/src/modules/skills/skill-usage-scan.ts @@ -0,0 +1,89 @@ +/** + * skill-usage-scan — transcript 后验使用扫描(skill 度量地基票 B,usage 主口径)。 + * + * 背景:skill_used 仅靠 loadSkill 发射测不准——注入段/MANIFEST 直接给 SKILL.md + * 路径,agent 可 Read/cat 绕开 MCP 工具(渠道旁路);且 cache 命中路径历史上 + * 不发射(票 A 已修)。本扫描把「使用」定义为**获取全文、渠道无关**:agent 读 + * 文件必在 rawOutput 留痕(Read 调用 / cat 命令 / 路径引用),rawOutput 是 + * provider 无关的既有归档产物(transcript-archive.ts)。 + * + * 形态:eventBus 钩子(仿 completion-extraction / distill-runtime), + * 纯确定性、零 LLM、无预算熔断;fire-and-forget 不阻断收尾订阅链。 + * + * 幂等:WU done 重发只产重复事件,聚合侧(skill-demotion.ts)按 + * (skill, workUnitId) 去重吸收,不加哨兵。 + * + * 宽口径说明:agent 在文本中提及路径而未真读也算命中(罕见,接受);数据积累后 + * 如需收紧另议。 + */ +import { eventBus, logger, writeStudioEvent } from '@dommaker/studio-shared'; +import { readTranscript } from '../transcripts/transcript-archive.js'; +import type { WorkUnitData } from '../workunit/workunit.service.js'; + +/** + * skill 全文路径痕迹:匹配 `skills//SKILL.md`(绝对/相对路径均可命中)。 + * name 首字符限 [a-z0-9]:_ 前缀目录(_deprecated 等,manifest-loader 同口径)不计入。 + */ +const SKILL_PATH_RE = /skills\/([a-z0-9][a-z0-9-]*)\/SKILL\.md/g; + +/** 从文本中提取使用过的 skill 名(按名去重,保序) */ +export function extractUsedSkillNames(text: string): string[] { + const names = new Set(); + for (const m of text.matchAll(SKILL_PATH_RE)) { + names.add(m[1]); + } + return [...names]; +} + +export class SkillUsageScanner { + private subscribed = false; + + /** 订阅 workunit.status_changed(done 触发)。幂等。 */ + subscribeToEvents(): void { + if (this.subscribed) return; + this.subscribed = true; + + eventBus.subscribe('workunit.status_changed', (payload: { workunit: WorkUnitData }) => { + const wu = payload.workunit; + if (!wu || wu.status !== 'done') return; + // fire-and-forget:扫描/写事件绝不 await 阻断收尾订阅链 + void this.scan(wu).catch(err => + logger.warn('[SkillUsageScan] scan failed (non-blocking)', { wuId: wu.id, error: String(err) }), + ); + }); + } + + /** + * 扫一个 WU 的归档 transcript,每命中 skill 发射一条 knowledge:skill_used + * (channel=transcript,level=info signal——提级后归信号档:热 30 天 → 月度 gz 归档, + * 不再随噪声 7 天滚动删除)。返回命中名单(测试/观测用)。 + */ + async scan(wu: WorkUnitData): Promise { + const entries = await readTranscript(wu.id); + const text = entries.map(e => e.rawOutput ?? '').join('\n'); + const names = extractUsedSkillNames(text); + for (const skillName of names) { + await writeStudioEvent('knowledge:skill_used', { + skillName, + workUnitId: wu.id, + channel: 'transcript', + }, { source: 'skill-usage-scan', level: 'info' }).catch(err => { + logger.warn('[SkillUsageScan] emit skill_used failed', { wuId: wu.id, skillName, error: String(err) }); + return false; + }); + } + if (names.length > 0) { + logger.info('[SkillUsageScan] skill usage emitted', { wuId: wu.id, skills: names }); + } + return names; + } +} + +// 单例(懒初始化,形态同 initWuCompletionExtraction) +let _scanner: SkillUsageScanner | null = null; + +export function initSkillUsageScan(): SkillUsageScanner { + if (!_scanner) _scanner = new SkillUsageScanner(); + _scanner.subscribeToEvents(); + return _scanner; +} diff --git a/apps/api/src/modules/transcripts/CONTEXT.md b/apps/api/src/modules/transcripts/CONTEXT.md index bb8008f45..816f3ed12 100644 --- a/apps/api/src/modules/transcripts/CONTEXT.md +++ b/apps/api/src/modules/transcripts/CONTEXT.md @@ -2,7 +2,7 @@ ### 职责 -transcript 归档器(#97,#88 子票):把会话原文落盘到数据区(经 `studioDir()`/`studioPath()`),供三个消费方共用——#99 WU 收尾批量提取(要全文)、handoff 摘要(要对话)、#85 执行质量评估(要执行痕迹)。本模块只建归档器 + 读取接口,不实现消费方提取逻辑。 +transcript 归档器(#97,#88 子票):把会话原文落盘到数据区(经 `studioDir()`/`studioPath()`),供四个消费方共用——#99 WU 收尾批量提取(要全文)、handoff 摘要(要对话)、#85 执行质量评估(要执行痕迹)、skill 度量地基票 B skill 使用扫描(skills/skill-usage-scan.ts,扫 rawOutput 中 SKILL.md 路径痕迹)。本模块只建归档器 + 读取接口,不实现消费方提取逻辑。 另提供 HTTP 只读查看路由(#174,#60 C5):`GET /api/v1/transcripts/:workUnitId`(认证,query `offset`/`limit` 分页,上限 100 — #359 起统一 `parsePagination`,原上限 50),经 `readTranscript` 读全文后 slice,文件不存在返回 200 空列表;workUnitId 拒绝含 `/`、`..` 的 id(防路径穿越)。注册见 `route-registry.ts`;前端查看器 `apps/web/src/components/workunit/TranscriptViewer.tsx`。 @@ -40,7 +40,7 @@ session:start/end 事件链路(#174):agent-loop 把 `transcriptPath(wu.id) - `apps/api/src/modules/agents/loop/agent-loop.ts`(写入方:每步成功执行后 `appendTranscriptStep`) **下游**: -- #99 WU 收尾批量提取(`readTranscript` 读取方,已落地:role-memory/completion-extraction.ts)、handoff 摘要、#85 执行质量评估(后续实现) +- #99 WU 收尾批量提取(`readTranscript` 读取方,已落地:role-memory/completion-extraction.ts)、handoff 摘要、#85 执行质量评估(后续实现)、skill 使用扫描(已落地:skills/skill-usage-scan.ts,度量地基票 B) ### 注意事项 diff --git a/apps/api/src/modules/transcripts/transcript-archive.ts b/apps/api/src/modules/transcripts/transcript-archive.ts index 6d846b3be..fe864d340 100644 --- a/apps/api/src/modules/transcripts/transcript-archive.ts +++ b/apps/api/src/modules/transcripts/transcript-archive.ts @@ -1,10 +1,11 @@ /** * transcript-archive — transcript 归档器(#97,#88 子票) * - * 会话原文落盘到数据区(经 studioDir()/studioPath()),供三个消费方共用: + * 会话原文落盘到数据区(经 studioDir()/studioPath()),供四个消费方共用: * - #99 WU 收尾批量提取(要全文) * - handoff 摘要(要对话) * - #85 执行质量评估(要执行痕迹) + * - skill 度量地基票 B:skill 使用扫描(skills/skill-usage-scan.ts) * * 数据源选型:agent-loop 每步 `result.rawOutput`(raw CLI stdout,provider 无关)。 * 单一来源同时满足三方:rawOutput 含完整对话 + 工具调用/执行痕迹,非摘要级截断, diff --git a/apps/api/src/modules/triggers/CONTEXT.md b/apps/api/src/modules/triggers/CONTEXT.md index 29272d739..4502f7fd6 100644 --- a/apps/api/src/modules/triggers/CONTEXT.md +++ b/apps/api/src/modules/triggers/CONTEXT.md @@ -11,7 +11,7 @@ Trigger 子系统(AS-026,3.28c-4):SCHEDULE(cron)+ EVENT(EventBus - `trigger-store.ts` — YAML 持久化 - `trigger-scheduler.ts` — SCHEDULE tick + EVENT EventBus 订阅 - `trigger-registry.ts` — 单例 TriggerScheduler(注入 eventBus) -- `trigger-action.ts` — CREATE 动作执行(从 trigger payload 创建 WorkUnit;#162 T8-E1:建单显式 `status='pending'` 人闸,按来源不按类型,人工确认 pending→unassigned 后才可认领) +- `trigger-action.ts` — CREATE 动作执行(从 trigger payload 创建 WorkUnit;#162 T8-E1:建单显式 `status='pending'` 人闸,按来源不按类型,人工确认 pending→unassigned 后才可认领)+ UPDATE 动作执行(#538 ADR 决策 6:禁改 status——TriggerStore 注册校验 + executeUpdateAction 执行守卫双保险;其余字段走 patchSnapshot 白名单经 service.update,metadata 走 WorkUnitService `updateMetadata` 语义口增量合并(#554 起不再直摸 FileStore 原语),白名单外字段丢弃并 warn) - `trigger-assignee-check.ts` — 指名执行者可运行性自检 `checkTriggerAssignees`(2026-09-10,doc-semantic-review 滞留 143h 事故根治):CREATE 类 trigger 的 `assigneeRole` 指名 = 独占认领(仅该角色 loop 可见),角色不存在/loop 非 running 即结构性死单;index.ts 启动挂载 loop 后调用,问题逐条 logger.error - `trigger.routes.ts` — Trigger 管理 REST API diff --git a/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts b/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts index 42c8cad1b..62d1057e5 100644 --- a/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts +++ b/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts @@ -105,4 +105,30 @@ describe('TriggerStore', () => { } as TriggerConfig; expect(() => store.save(invalid)).toThrow(/cron/); }); + + // #538(ADR 2026-09-15 决策 6):UPDATE 禁改 status——注册校验拒(第一层双保险) + it('rejects UPDATE action whose update payload touches workunit status', () => { + const withStatus = { + ...sampleTrigger, + id: 'update-status', + action: { + type: 'UPDATE', + target: 'workunit', + config: { query: { status: 'active' }, update: { status: 'unassigned' } }, + }, + } as unknown as TriggerConfig; + expect(() => store.save(withStatus)).toThrow(/status/); + + // 白名单字段(非 status)不受影响,照常注册 + const benign = { + ...sampleTrigger, + id: 'update-benign', + action: { + type: 'UPDATE', + target: 'workunit', + config: { query: { status: 'active' }, update: { assigneeId: null } }, + }, + } as unknown as TriggerConfig; + expect(() => store.save(benign)).not.toThrow(); + }); }); diff --git a/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts b/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts index 4b8d09d39..9d5cb098c 100644 --- a/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts +++ b/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts @@ -111,12 +111,14 @@ describe('executeUpdateAction(P0:操作符查询集成)', () => { wuSnapshot('wu-no-timeout', null), ]); + // #538(ADR 决策 6):UPDATE 禁改 status——本用例聚焦操作符匹配,update 载荷 + // 改用白名单字段(assigneeId 释放);status 写拒绝由 trigger-update.test.ts 锁定 const action: TriggerAction = { type: 'UPDATE', target: 'workunit', config: { query: { status: 'active', timeoutAt: { lt: NOW_PLACEHOLDER } }, - update: { status: 'unassigned', assigneeId: null }, + update: { assigneeId: null }, }, }; @@ -125,7 +127,7 @@ describe('executeUpdateAction(P0:操作符查询集成)', () => { expect(mockFileStore.commitSnapshot).toHaveBeenCalledTimes(1); const updated = mockFileStore.commitSnapshot.mock.calls[0][1]; expect(updated.id).toBe('wu-expired'); - expect(updated.status).toBe('unassigned'); + expect(updated.status).toBe('active'); // status 不可经 UPDATE 改写 expect(updated.assigneeId).toBeNull(); }); }); diff --git a/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts b/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts index 8c2162513..e1a13261a 100644 --- a/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts +++ b/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts @@ -1,4 +1,6 @@ // AC-2: UPDATE action tests +// #538(ADR 2026-09-15 决策 6):禁改 status(执行守卫);其余字段走 patchSnapshot 白名单, +// metadata 走 updateMetadata 合并——原「可写 status」断言随语义变更反转(非删测试) import { describe, it, expect, vi, beforeEach } from 'vitest'; import { executeUpdateAction } from '../trigger-action'; import type { TriggerAction } from '../trigger.types'; @@ -12,6 +14,8 @@ const mockFileStore = vi.hoisted(() => ({ claimWorkUnit: vi.fn(), // #170:写路径改走锁内成对原语 commitSnapshot: vi.fn(), + // #538:metadata 增量合并写 + updateMetadata: vi.fn(), })); vi.mock('@dommaker/studio-shared', async (importOriginal) => { @@ -28,53 +32,94 @@ vi.mock('@dommaker/studio-shared', async (importOriginal) => { }; }); +function seedSnapshot(overrides: Record = {}) { + return { + id: 'test-wu-1', + parentId: null, + type: 'task', + scope: 'update-test', + assigneeId: 'agent-1', + status: 'active', + failureType: null, + retryCount: 0, + timeoutAt: null, + channelId: null, + projectPath: null, + metadata: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + claimedAt: new Date().toISOString(), + completedAt: null, + ...overrides, + }; +} + describe('Trigger UPDATE action', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('executes FileStore update with static query/update', async () => { - const wuId = 'test-wu-1'; - - // Seed the mock FileStore with a WorkUnit snapshot - mockFileStore.getIndex.mockResolvedValue([ - { - id: wuId, - parentId: null, - type: 'task', - scope: 'update-test', - assigneeId: 'agent-1', - status: 'active', - failureType: null, - retryCount: 0, - timeoutAt: null, - channelId: null, - projectPath: null, - metadata: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - claimedAt: new Date().toISOString(), - completedAt: null, - }, - ]); + it('禁改 status:执行守卫拒绝且零写入(ADR 决策 6,原「可写 status」断言反转)', async () => { + mockFileStore.getIndex.mockResolvedValue([seedSnapshot()]); const action: TriggerAction = { type: 'UPDATE', target: 'workunit', config: { - query: { id: wuId }, + query: { id: 'test-wu-1' }, update: { status: 'unassigned', assigneeId: null }, }, }; + await expect(executeUpdateAction(action, {})).rejects.toThrow(/status/); + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + expect(mockFileStore.updateMetadata).not.toHaveBeenCalled(); + }); + + it('白名单字段经 service.update 落盘;白名单外字段丢弃不进快照', async () => { + mockFileStore.getIndex.mockResolvedValue([seedSnapshot()]); + + const action: TriggerAction = { + type: 'UPDATE', + target: 'workunit', + config: { + query: { id: 'test-wu-1' }, + update: { scope: '改后标题', assigneeId: null, hack: 'not-a-field' }, + }, + }; + await executeUpdateAction(action, {}); // #170:更新经锁内成对写落盘(appendEvent + upsertSnapshot 同锁) - expect(mockFileStore.commitSnapshot).toHaveBeenCalled(); - + expect(mockFileStore.commitSnapshot).toHaveBeenCalledTimes(1); const updatedSnapshot = mockFileStore.commitSnapshot.mock.calls[0][1]; - expect(updatedSnapshot.status).toBe('unassigned'); + expect(updatedSnapshot.scope).toBe('改后标题'); expect(updatedSnapshot.assigneeId).toBeNull(); + expect(updatedSnapshot.status).toBe('active'); // 未被触碰 + expect((updatedSnapshot as Record).hack).toBeUndefined(); + }); + + it('metadata 走 updateMetadata 增量合并(不整写覆盖既有键)', async () => { + mockFileStore.getIndex.mockResolvedValue([seedSnapshot({ metadata: '{"a":1}' })]); + + const action: TriggerAction = { + type: 'UPDATE', + target: 'workunit', + config: { + query: { id: 'test-wu-1' }, + update: { metadata: { b: 2 } }, + }, + }; + + await executeUpdateAction(action, {}); + + expect(mockFileStore.updateMetadata).toHaveBeenCalledTimes(1); + const [wuId, mutator] = mockFileStore.updateMetadata.mock.calls[0]; + expect(wuId).toBe('test-wu-1'); + // mutator 基于锁内最新 metadata 合并:既有键保留,新键并入 + expect(mutator({ a: 1 })).toEqual({ a: 1, b: 2 }); + // 无白名单字段 → 不走整快照写 + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/modules/triggers/trigger-action.ts b/apps/api/src/modules/triggers/trigger-action.ts index 45956f6b9..0e5114869 100644 --- a/apps/api/src/modules/triggers/trigger-action.ts +++ b/apps/api/src/modules/triggers/trigger-action.ts @@ -1,8 +1,9 @@ // Trigger Action — execute trigger actions (3.28c-4, AS-026 extended) // Supports: CREATE WorkUnit, EXECUTE handler, UPDATE entity -import { FileStore, logger, type WorkUnitEvent, type WorkUnitSnapshot } from '@dommaker/studio-shared'; +import { FileStore, logger } from '@dommaker/studio-shared'; import type { TriggerAction, TriggerExecuteHandler } from './trigger.types.js'; import { WorkUnitService } from '../workunit/workunit.service.js'; +import type { WorkUnitMetadata } from '../workunit/workunit.service.js'; /** Handler registry for EXECUTE actions */ const executeHandlers = new Map(); @@ -200,8 +201,27 @@ export async function executeExecuteAction( await handler(context); } +/** + * #538(ADR 2026-09-15 决策 6):UPDATE 可写字段白名单——与 service.update 的 + * UpdateWorkUnitInput 一一对应(patchSnapshot 白名单)。status 不在列: + * 状态机归 WorkUnitService,UPDATE 禁改(注册校验 + 执行守卫双保险)。 + */ +const UPDATE_FIELD_WHITELIST = [ + 'type', 'scope', 'assigneeId', 'channelId', 'parentId', 'projectPath', + 'workspaceId', 'reqId', 'failureType', 'retryCount', 'timeoutAt', 'completedAt', +] as const; + +/** YAML/JSON 配置里的日期是 ISO 字符串;UpdateWorkUnitInput 要 Date(patchSnapshot 调 toISOString) */ +function coerceDateField(value: unknown): Date | null { + if (value === null) return null; + if (value instanceof Date) return value; + return new Date(String(value)); +} + /** * Execute an UPDATE action — updates entity via FileStore. + * #538:workunit 目标禁改 status(执行守卫);其余字段走 patchSnapshot 白名单 + * (service.update),metadata 走 updateMetadata 增量合并(不整写覆盖)。 * @param action - The trigger action definition (must be UPDATE type) */ export async function executeUpdateAction( @@ -217,6 +237,18 @@ export async function executeUpdateAction( // Only support workunit entity for MVP if (action.target === 'workunit') { + if ('status' in update) { + throw new Error('UPDATE action must not modify workunit status (state machine owned by WorkUnitService)'); + } + const { metadata, ...fields } = update as Record & { metadata?: unknown }; + if (metadata !== undefined && (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata))) { + throw new Error('UPDATE action metadata must be an object (merged via updateMetadata)'); + } + const unknownKeys = Object.keys(fields).filter(k => !(UPDATE_FIELD_WHITELIST as readonly string[]).includes(k)); + if (unknownKeys.length > 0) { + logger.warn(`[TriggerAction] UPDATE workunit: dropping non-whitelisted fields: ${unknownKeys.join(', ')}`); + } + const snapshots = await fileStore.getIndex(); const now = new Date().toISOString(); @@ -232,18 +264,19 @@ export async function executeUpdateAction( } if (!matches) continue; - const updatedSnapshot: WorkUnitSnapshot = { - ...s, - ...update as Partial, - updatedAt: now, - }; - const event: WorkUnitEvent = { - type: 'updated', - wuId: s.id, - timestamp: now, - data: updatedSnapshot as unknown as Record, - }; - await fileStore.commitSnapshot(event, updatedSnapshot); + const input: Record = {}; + for (const key of UPDATE_FIELD_WHITELIST) { + if (!(key in fields)) continue; + const raw = (fields as Record)[key]; + input[key] = (key === 'timeoutAt' || key === 'completedAt') ? coerceDateField(raw) : raw; + } + if (Object.keys(input).length > 0) { + await workUnitService.update(s.id, input); + } + if (metadata !== undefined) { + // metadata 增量合并(#554:走 service 语义口,锁内基于最新 metadata 并入,既有键保留) + await workUnitService.updateMetadata(s.id, metadata as WorkUnitMetadata); + } } } else { logger.warn(`[TriggerAction] Unknown UPDATE target: ${action.target}`); diff --git a/apps/api/src/modules/triggers/trigger-store.ts b/apps/api/src/modules/triggers/trigger-store.ts index 1f9bea329..17689b421 100644 --- a/apps/api/src/modules/triggers/trigger-store.ts +++ b/apps/api/src/modules/triggers/trigger-store.ts @@ -49,6 +49,12 @@ function validateTrigger(config: TriggerConfig): void { if (!config.action.config?.query || !config.action.config?.update) { throw new Error('UPDATE action must have config with query and update'); } + // #538(ADR 2026-09-15 决策 6):禁改 status——状态机归 WorkUnitService, + // 注册校验拒(第一层);executeUpdateAction 执行守卫为第二层。零消费者先收紧, + // 将来真需要状态变更再转 transitionStatus + if (config.action.target === 'workunit' && 'status' in config.action.config.update) { + throw new Error('UPDATE action must not modify workunit status (state machine owned by WorkUnitService)'); + } } else { throw new Error(`Unknown action type: ${(config.action as { type: string }).type}`); } diff --git a/apps/api/src/modules/workspaces/CONTEXT.md b/apps/api/src/modules/workspaces/CONTEXT.md index 4d401ed6a..e89d9dcd9 100644 --- a/apps/api/src/modules/workspaces/CONTEXT.md +++ b/apps/api/src/modules/workspaces/CONTEXT.md @@ -30,6 +30,8 @@ - **'VPS' 命名约定的唯一属主在 studio-shared(2026-08 seam 修复)**:判定"哪条记录是本机 VPS workspace"(name='VPS' 且无 tokenId)由 `@dommaker/studio-shared/node` 的 `resolveVpsWorkspace()` 统一定义;本模块的 `local-workspace.findLocalWorkspace` 委托给它。改写存储格式/重命名 VPS workspace 时需同步该函数。 - 本地 CLI 扫描链(2026-07 修复后):`local-workspace.scanLocalRuntimes()` 复用 `daemon/cli-scanner.scanAllProviders()`(provider 注册表驱动:内置 claude/kimi/codex/opencode,用户可经 `~/.studio/providers.json` 扩展),结果**全量替换 local workspace 记录的 `runtimes` 数组**;每次启动重扫 + `GET /workspaces/runtimes` 返回前 best-effort 重扫。扫描在 Server 所在机器执行:线上扫服务器,本地起服务扫本地。 - **`GET /workspaces/runtimes` = 本机 CLI 清单,不是节点聚合(2026-09-10 收敛)**:原语义「聚合所有 workspace 记录的 runtimes」(AC-2.6)是远程节点时代的产物,遍历 `listWorkspaces()` 全表。多节点执行已无活路径(bdaf0dd3 2026-08-04 判死),全表聚合的实际后果是:任何历史 daemon 注册记录(含 `status: offline` 的失效节点)的 runtimes 会**永久**出现在角色创建候选列表里且无回收机制——生产数据区一条 2026-07 的遗留记录躺了 54 天仍可勾选。现数据源改为 `resolveVpsWorkspace()`(本机那条),响应项收敛为 `{provider, version}`,`nodeId`/`workspaceName` 不再下发(创建角色只需 provider,见 `channelApi.createAgent`)。测试:`__tests__/runtimes-local-only.test.ts`。 +- **runtimes 条目增登录态三态(#565,2026-09-16)**:cli-scanner 按注册表 `authProbe` 声明探测(exit 0 且不命中未登录模式 → ok;命中/非零 exit → failed + authHint;未声明/spawn 失败 → unknown,不猜配置目录),`local-workspace` 把 `auth`/`authHint`/`authCheckedAt` 随 runtimes 记录持久(节奏 = 启动扫描 + 手动 rescan,已决策不做每 GET 实时探);`GET /workspaces/runtimes` 响应项增 `auth`(旧记录缺省回落 unknown)+ 条件 `authHint`。声明现状:claude `auth status`(JSON loggedIn)、codex `login status`(未登录 exit 1 + "Not logged in")均实测;kimi(login 无 status 子命令)/opencode(auth list 与 env 凭证混杂不可靠)不声明恒 unknown。同票 cli-scanner 还挂了能力探测 warm(`capabilityProbe` 声明的 provider 随扫描 warm studio-shared capability-probe 缓存)。测试:`__tests__/local-workspace-auth.test.ts`、`daemon/__tests__/cli-scanner.test.ts` auth describe。 +- **runtimes 条目增模型清单(#574,2026-09-16)**:cli-scanner detectProvider 调 studio-shared model-probe `getProviderModels`(注册表 `listModels` 声明驱动:codex `debug models` / kimi `provider list --json` / opencode `models`,逐 CLI 实测;claude 无列表命令不声明),进程内缓存 key=binary@version——GET 前重扫命中缓存不重跑探测进程;失败/未声明回退注册表 `fallbackModels`。`local-workspace` 把 `models`/`modelsSource`(live/fallback)随 runtimes 记录持久,`GET /workspaces/runtimes` 条件透出(旧记录无字段则不带);展示面 = WorkspacePage runtime 卡片(模型行 + 实测/静态标注)。测试:`daemon/__tests__/cli-scanner.test.ts` models probe describe、`__tests__/runtimes-local-only.test.ts` #574 用例。 - **workspace token 子系统已随 #481 删除(2026-09-11)**:`token.routes.ts`(生成/列表/撤销 API)+ route-registry 挂载 + `workspaceAuth()`(middleware/auth.ts)+ `WorkspaceTokenData`/`WORKSPACE_TOKENS_DIR` + 前端零引用的 `workspaceTokenApi` + 两处自证测试,全部移除(零行为变化:2026-09-10 盘点确认 `workspaceAuth()` 零路由引用、磁盘 token 文件全部缺 `workspaceId` 字段恒 401、UI 无入口)。无读取方的数据目录:`~/.studio/workspace-tokens/`、`~/.studio/data/workspace-tokens/`、`~/.studio/data/workspaces/`(后两个属旧 STUDIO_DATA_DIR 根)不夹代码 PR 处理。 - **承重面(勿删)**:`workspace.routes.ts` 的只读/删除端点(真实 HTTP 级测试 `__tests__/workspace-routes.test.ts`,#481 由自建内存 store 自证重写)+ `workspace-store.ts`——剩余链路:`channel.defaultWorkspaceId` 绑定存在性校验(channel PATCH)、file-ref-vocabulary legacy 候选集、wu-verification 按 `wu.workspaceId` 取 workspace 级 `verifyCommands` 覆盖(#481 未动;新 WU 已不再落 workspaceId,该覆盖只作用于历史绑定记录,后续另票评估)。 - **鉴权级别(2026-07 安全修复)**:本模块所有面向 UI 的端点(workspace 只读/删除/runtimes)= `requireAuth() + requireAdmin()` —— 生产环境必须 Admin 角色。⚠️ 前提修正(2026-07-24):guest session `userId=null` 查不到用户记录,guest token **实际过不了 `requireAuth()`/Lurk Wall 大门**(等同匿名);requireAdmin 的真实防线意义在于防御未来 User 角色账号与大门逻辑回归(workspace 记录含 workspaceRoot/runtimes.path/仓库路径等服务器信息)。 diff --git a/apps/api/src/modules/workspaces/__tests__/local-workspace-auth.test.ts b/apps/api/src/modules/workspaces/__tests__/local-workspace-auth.test.ts new file mode 100644 index 000000000..a36417412 --- /dev/null +++ b/apps/api/src/modules/workspaces/__tests__/local-workspace-auth.test.ts @@ -0,0 +1,86 @@ +/** + * local-workspace auth 透传(#565 AC4 数据流中段) + * + * scanAllProviders 的三态 auth 结果须随 runtimes 记录持久化到 workspace JSON, + * 供 GET /workspaces/runtimes 读出。STUDIO_HOME 指向临时目录,不真扫 CLI + * (cli-scanner mock)。 + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const { mockScan } = vi.hoisted(() => ({ mockScan: vi.fn() })); + +vi.mock('../../../daemon/cli-scanner.js', () => ({ + scanAllProviders: mockScan, +})); + +let tmpHome = ''; +let prevHome: string | undefined; +let wsDir = ''; +let rescanLocalRuntimes: () => Promise; + +beforeAll(async () => { + prevHome = process.env.STUDIO_HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-local-ws-auth-')); + process.env.STUDIO_HOME = tmpHome; + const { resolveWorkspacesDir } = await import('@dommaker/studio-shared/node'); + wsDir = resolveWorkspacesDir(); + fs.mkdirSync(wsDir, { recursive: true }); + ({ rescanLocalRuntimes } = await import('../local-workspace.js')); +}); + +afterAll(() => { + if (prevHome === undefined) delete process.env.STUDIO_HOME; + else process.env.STUDIO_HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +beforeEach(() => { + vi.clearAllMocks(); + for (const f of fs.readdirSync(wsDir)) fs.rmSync(path.join(wsDir, f), { recursive: true, force: true }); +}); + +function seedVpsWorkspace(): string { + const id = 'ws_vps'; + fs.writeFileSync(path.join(wsDir, `${id}.json`), JSON.stringify({ + id, + name: 'VPS', + workspaceRoot: '/seed', + status: 'idle', + tokenId: null, + runtimes: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + return id; +} + +describe('scanLocalRuntimes auth 透传', () => { + it('auth 三态 + hint + authCheckedAt 写入 runtimes 记录', async () => { + const id = seedVpsWorkspace(); + mockScan.mockReturnValue([ + { + provider: 'claude', path: '/usr/local/bin/claude', version: '2.1.273', + auth: 'failed', authHint: '运行 claude login 重新登录', authCheckedAt: '2026-09-16T01:00:00.000Z', + }, + { + provider: 'kimi', path: '/bin/kimi', version: '0.38.0', + auth: 'unknown', authCheckedAt: '2026-09-16T01:00:00.000Z', + }, + ]); + + await rescanLocalRuntimes(); + + const ws = JSON.parse(fs.readFileSync(path.join(wsDir, `${id}.json`), 'utf-8')); + expect(ws.runtimes).toHaveLength(2); + const claude = ws.runtimes.find((r: Record) => r.provider === 'claude'); + expect(claude.auth).toBe('failed'); + expect(claude.authHint).toBe('运行 claude login 重新登录'); + expect(claude.authCheckedAt).toBe('2026-09-16T01:00:00.000Z'); + const kimi = ws.runtimes.find((r: Record) => r.provider === 'kimi'); + expect(kimi.auth).toBe('unknown'); + expect(kimi.authHint).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/workspaces/__tests__/runtimes-local-only.test.ts b/apps/api/src/modules/workspaces/__tests__/runtimes-local-only.test.ts index 718b2a185..5e0075b4a 100644 --- a/apps/api/src/modules/workspaces/__tests__/runtimes-local-only.test.ts +++ b/apps/api/src/modules/workspaces/__tests__/runtimes-local-only.test.ts @@ -100,8 +100,45 @@ describe('GET /workspaces/runtimes — 本机 CLI 清单', () => { const { status, body } = await getRuntimes(); expect(status).toBe(200); expect(body.runtimes).toEqual([ - { provider: 'claude', version: '2.1.80' }, - { provider: 'kimi', version: '0.31.0' }, + // 旧记录无 auth 字段 → 回落 unknown(#565 AC4) + { provider: 'claude', version: '2.1.80', auth: 'unknown' }, + { provider: 'kimi', version: '0.31.0', auth: 'unknown' }, + ]); + }); + + it('AC4: 记录中的 auth 三态与修复 hint 透传到响应', async () => { + seedWorkspace({ + id: 'ws_vps', name: 'VPS', + runtimes: [ + { id: 'ws_vps_claude', provider: 'claude', version: '2.1.273', status: 'online', auth: 'ok', authCheckedAt: '2026-09-16T00:00:00Z' }, + { id: 'ws_vps_codex', provider: 'codex', version: '0.147.0', status: 'online', auth: 'failed', authHint: '运行 codex login 登录', authCheckedAt: '2026-09-16T00:00:00Z' }, + ], + }); + + const { status, body } = await getRuntimes(); + expect(status).toBe(200); + expect(body.runtimes).toEqual([ + { provider: 'claude', version: '2.1.273', auth: 'ok' }, + { provider: 'codex', version: '0.147.0', auth: 'failed', authHint: '运行 codex login 登录' }, + ]); + }); + + it('#574: 记录中的 models / modelsSource 透传到响应;无 models 的记录不带该字段', async () => { + seedWorkspace({ + id: 'ws_vps', name: 'VPS', + runtimes: [ + { id: 'ws_vps_codex', provider: 'codex', version: '0.147.0', status: 'online', auth: 'ok', models: ['gpt-5.6-sol', 'gpt-5.5'], modelsSource: 'live' }, + { id: 'ws_vps_claude', provider: 'claude', version: '2.1.273', status: 'online', auth: 'ok', models: ['opus', 'sonnet'], modelsSource: 'fallback' }, + { id: 'ws_vps_openclaw', provider: 'openclaw', version: '1.0.0', status: 'online' }, + ], + }); + + const { status, body } = await getRuntimes(); + expect(status).toBe(200); + expect(body.runtimes).toEqual([ + { provider: 'codex', version: '0.147.0', auth: 'ok', models: ['gpt-5.6-sol', 'gpt-5.5'], modelsSource: 'live' }, + { provider: 'claude', version: '2.1.273', auth: 'ok', models: ['opus', 'sonnet'], modelsSource: 'fallback' }, + { provider: 'openclaw', version: '1.0.0', auth: 'unknown' }, ]); }); @@ -153,7 +190,7 @@ describe('GET /workspaces/runtimes — 本机 CLI 清单', () => { mockRescan.mockRejectedValueOnce(new Error('scan boom')); const res1 = await getRuntimes(); expect(res1.status).toBe(200); - expect(res1.body.runtimes).toEqual([{ provider: 'kimi', version: '0.31.0' }]); + expect(res1.body.runtimes).toEqual([{ provider: 'kimi', version: '0.31.0', auth: 'unknown' }]); expect(mockRescan).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/api/src/modules/workspaces/local-workspace.ts b/apps/api/src/modules/workspaces/local-workspace.ts index acfd7f849..7b109ac52 100644 --- a/apps/api/src/modules/workspaces/local-workspace.ts +++ b/apps/api/src/modules/workspaces/local-workspace.ts @@ -118,6 +118,12 @@ async function scanLocalRuntimes(workspaceId: string): Promise { version: rt.version, path: rt.path, status: 'online', + // #565: 登录态三态随 runtimes 记录持久(节奏 = 启动扫描 + 手动 rescan) + auth: rt.auth, + ...(rt.authHint ? { authHint: rt.authHint } : {}), + authCheckedAt: rt.authCheckedAt, + // #574: 模型清单 + 来源标注随 runtimes 记录持久(探测节奏同上) + ...(rt.models ? { models: rt.models, modelsSource: rt.modelsSource } : {}), lastSeenAt: now, createdAt: prev?.createdAt ?? now, updatedAt: now, diff --git a/apps/api/src/modules/workspaces/workspace.routes.ts b/apps/api/src/modules/workspaces/workspace.routes.ts index b70b153dc..1390425ac 100644 --- a/apps/api/src/modules/workspaces/workspace.routes.ts +++ b/apps/api/src/modules/workspaces/workspace.routes.ts @@ -167,10 +167,23 @@ router.get('/runtimes', requireAuth(), requireAdmin(), apiCache(60), async (_req // 遍历全表会让历史/离线节点的 runtimes 永久出现在候选列表里。 // nodeId / workspaceName 随之不再下发——创建角色只需要 provider。 const ws = await resolveVpsWorkspace(); - const recorded = (ws?.runtimes ?? []) as Array<{ provider?: string; version?: string }>; + const recorded = (ws?.runtimes ?? []) as Array<{ + provider?: string; version?: string; auth?: string; authHint?: string; + models?: string[]; modelsSource?: 'live' | 'fallback'; + }>; const runtimes = recorded .filter((rt) => typeof rt.provider === 'string' && rt.provider.length > 0) - .map((rt) => ({ provider: rt.provider as string, version: rt.version ?? '' })); + // #565 AC4: auth 三态透出(旧记录无 auth 字段 → unknown),failed 附修复 hint + .map((rt) => ({ + provider: rt.provider as string, + version: rt.version ?? '', + auth: rt.auth ?? 'unknown', + ...(rt.authHint ? { authHint: rt.authHint } : {}), + // #574: 模型清单 + 来源标注透出(旧记录无字段则不带) + ...(Array.isArray(rt.models) && rt.models.length > 0 + ? { models: rt.models, modelsSource: rt.modelsSource ?? 'fallback' } + : {}), + })); return res.json({ runtimes }); } catch (error) { diff --git a/apps/api/src/modules/workunit/CONTEXT.md b/apps/api/src/modules/workunit/CONTEXT.md index d0f57a576..7e200b312 100644 --- a/apps/api/src/modules/workunit/CONTEXT.md +++ b/apps/api/src/modules/workunit/CONTEXT.md @@ -6,21 +6,20 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N ### 核心导出 -- `workunit.service.ts` - Service 门面: CRUD + Claim + 状态机, claim 写 5min 租约(持有方 30s 心跳推前), 收口 reviewPassed/reviewRejected/attestation 幂等补写/markMergeConflict/blockForManualRelease。导出 `waitForReviewPassSettled()` 供测试等待异步自动合并收尾。 -- `workunit.types.ts` - 类型契约 + 状态机表: WorkUnitMetadata / DTO / VALID_TRANSITIONS / TYPE_VALID_TRANSITIONS + resolveValidTransitions / WU_LEASE_TTL_MS(5min) / ANALYSIS_TASKS_MAX(8) / INSPECTION_OPPORTUNITIES_MAX / MANUAL_GATE_TYPES(#471: 人工 L3 验收类 = analysis/decision/spec/plan, 不派自动评审 + 豁免 l2 + 不出评审建议片的单一事实源) / PLAN_STEP_LIMIT(#471: plan 步数额度 60, agent-loop 前置守卫与 waiting-input 续期双消费)。 +- `workunit.service.ts` - Service 门面: CRUD + Claim + 状态机, claim 写 5min 租约(持有方 30s 心跳推前), 收口 reviewPassed/reviewRejected/attestation 幂等补写/markMergeConflict/blockForManualRelease + close(#550: 系统侧关闭唯一入口, 收编 wu-closure——状态机校验 + 统一尾部 + workunit:closed 事件 + 频道说明, 已 closed 幂等直返) + verifyManually(#551: POST /:id/verify 业务下沉——守卫链 存在性→代码类type→worktree落档→可跑命令 以 ManualVerifyResult 判别联合 kind 返回不抛错, 命令执行后无论成败落台账 l1, HTTP 语义归路由层)。导出 `waitForReviewPassSettled()` 供测试等待异步自动合并收尾。 +- `workunit.types.ts` - 类型契约 + 状态机表: WorkUnitMetadata / VALID_TRANSITIONS / TYPE_VALID_TRANSITIONS + resolveValidTransitions / WU_LEASE_TTL_MS(5min) / ANALYSIS_TASKS_MAX(8) / INSPECTION_OPPORTUNITIES_MAX / MANUAL_GATE_TYPES(#471: 人工 L3 验收类 = analysis/decision/spec/plan, 不派自动评审 + 豁免 l2 + 不出评审建议片的单一事实源) / PLAN_STEP_LIMIT(#471: plan 步数额度 60, agent-loop 前置守卫与 waiting-input 续期双消费) / WORKUNIT_CLOSED_EVENT_TYPE + WorkUnitClosedBy + CloseWorkUnitOptions(#550 自 wu-closure 归置, close() 入参与事件词表)。DTO(CreateWorkUnitInput/UpdateWorkUnitInput/WorkUnitData)唯一正本在 workunit-crud.ts(#537 删漂移副本)。 - `workunit.mappers.ts` - 快照 <-> DTO 转换: snapshotToData / inputToSnapshot / patchSnapshot。 -- `workunit-crud.ts` - WorkUnitCrudService(基类): CRUD + Claim(flock 悲观互斥锁) + 快照转换 + publishStatusChanged + aggregateParentStatus。#464 出声: create 落 pending 且带频道 → 发「待确认」milestone 系统消息(atHuman + 行动中心通知双写); expandRoutingHead 未配置 implement 路由也发频道提示(此前仅「配错」出声, 未配置静默);#525 P2-5 起该提示挂 #497 同款 1h 冷却闸(同频道同档首次照出、窗内不重复, 闸实现在 channels/routing.ts)。 +- `workunit-crud.ts` - WorkUnitCrudService(基类): CRUD + Claim(flock 悲观互斥锁) + updateMetadata(id, patch) metadata 增量合并语义口(#554, ADR 决策 1/6——patch 锁内浅并入, WU 不存在返回 null, 业务模块不直摸 FileStore 原语) + 快照转换 + publishStatusChanged + aggregateParentStatus。DTO 三件套(CreateWorkUnitInput/UpdateWorkUnitInput/WorkUnitData)唯一定义点, 经 workunit.service.js re-export 供跨模块消费。#464 出声: create 落 pending 且带频道 → 发「待确认」milestone 系统消息(atHuman + 行动中心通知双写); expandRoutingHead 未配置 implement 路由也发频道提示(此前仅「配错」出声, 未配置静默);#525 P2-5 起该提示挂 #497 同款 1h 冷却闸(同频道同档首次照出、窗内不重复, 闸实现在 channels/routing.ts)。 - `in-review-inbox.ts` - #464 无频道 in_review(非 analysis) → Web「需要处理」收件箱: 订阅 workunit.status_changed, 命中即 dispatchMonitorAlerts(warning, source=in_review_orphan——与 #181 review_stagnation 滞留探针区分, relatedTaskIds 带 wuId → 行动中心直链); analysis 走 analysis-handoff 既有路径不重复。index.ts 启动注册, 幂等。 -- `workunit.routes.ts` - API 路由。 +- `workunit.routes.ts` - API 路由(#551 起薄层:只做 HTTP 翻译,820→~470 行)。错误契约收口 `http-helpers.ts`——一张映射表 WORKUNIT_ERROR_MAPS(全部端点「错误文案/类别 → 状态码+code」唯一正本)+ sendMappedError 唯一翻译出口 + route() 包装器 + requireHuman 中间件(A2A §4.4 human-only 守卫,4 处复制收敛);handler 内可预期业务拒绝抛 HttpRouteError 直出。verify 业务下沉 WorkUnitService.verifyManually(判别联合 kind 返回,路由只映射 kind→响应;脱 HTTP 直测在 __tests__/workunit-api.test.ts)。 - `waiting-input.ts` - F5 双向沟通 + 回复即复活(全 blocked 类型): 线程人类回复 -> active + pendingReplies 注入; ownership 挂起按工程归属解析(project-discovery 唯一命中 -> 绑定 + 写回 Requirement.projectId + 置 unassigned); wu-token-budget 按人三选分流; plan-step-limit(#471, plan 步数额度到线) 回复即续期(planStepAllowance += PLAN_STEP_LIMIT 后走通用复活); Web 按钮 POST /:id/resume + /:id/close 复用原语。 - `gate-escalation.ts` - #523 P0-3(#516 决议③④) 人闸催办与认领滞留分层扫描 scanGateEscalationReminders(SCHEDULE workunit-gate-escalation 的 handler): in_review∩MANUAL_GATE_TYPES 超 30min / unassigned 超 15min -> tier1(NotificationService gate_reminder 全用户通知带 wuId 直链 + SSE notification.created 信封供前端铃铛重拉与浏览器原生通知), 两者超 4h -> tier2(notifyAlert warning 既有通路); 计时锚 = updatedAt, 一次性标记 gateReminderTier1At/gateReminderTier2At 锁内合并写(touchUpdatedAt:false 不扰锚), 每层独立不重发; 不自动确认不动状态机; 阈值写死 30min/15min/4h(#525 review 收口:env 旋钮属 scope creep 已删)。 - `blocked-cta.ts` - blocked 消息统一 CTA 模板: withBlockedCta / buildDeadLetterNotice。 -- `wu-closure.ts` - 系统推 WU -> closed 统一出口 closeWorkUnitWithNotice(快照 + workunit:closed 事件 + 频道说明, 各步 best-effort)。 - `wu-messenger.ts` - WU 频道系统消息统一出口 postWuSystemMessage(走 ChannelMessageService.createAgentMessage: append + eventBus + SSE); 线程锚点优先级:显式 opts.replyToId > metadata.anchorMessageId(#494 派单建单落档,消 created 事件先于派发消息落库的 findAnchorMessage 竞态) > 首条根消息查找; milestone:true 解析 pmoId + atHuman:true 且 #468 起同步落 NotificationService(type=wu_milestone 全用户,带 wuId/channelId/highlight link,best-effort 不阻断发帖); lazy import pmo-branch-resolver 避环。 - `claim-announce.ts` - 「认领即发声」原语 claimWorkUnitAndAnnounce(#445 自 agent-loop 私有方法提升, #175 决策 1 同一行为): claim 成功 -> WU 线程发「『认领方名』已认领任务,开始执行」普通系统消息(发声 best-effort 只 warn 不阻断, claim 失败原样抛出)。两入口同路径: AgentLoop.claimAndAnnounce(涌现, 署角色名) 与 REST POST /:id/claim(人工引导片认领, 署用户显示名; #445 起 body.agentId 可省略, 缺省 = 会话用户 id)。注意: 人工认领同样写 5min 租约, 无心跳续租, timeout-release 到期会释放回池(改释放语义属 WU 状态机/自动化行为改动, #441 Out of Scope, 留作后续决策点)。 - `timeout-release.ts` - 超时 WU 释放回 unassigned(≥3 次转 blocked), 释放即杀原 holder 进程组(kill(-pid, SIGKILL); pid 复用按 /proc 启动时间与 startedAt ±10min 比对兜底); decision/plan(#471) 单不进扫描。导出 `pidStartMatchesInstance` 供 agents/instance-timeout-scan 复用。 - `delegation-gate.ts` - A2A 委派闸门(纯代码): 成员/自派生/深度1/宽度3/树8/环/重复委派校验 + 树预算(TREE_TOKEN_BUDGET 400K; #320 起树已耗读 token 账本 `utils/token-ledger`, 不再每次委派全扫事件流; 账本同步失败兜底 treeTotal=0/pass, 与原读失败语义一致)。 -- `merge-on-review-pass.ts` - 评审通过后自动合并: task/ --no-ff 合并回目标分支, 冲突 rebase 重试一次, 仍冲突置 blocked(转人工走 markMergeConflict); pmoBranch 走 PMO 分支交合 worktree(`/pmo-`)。 +- `merge-on-review-pass.ts` - 评审通过后自动合并: task/ --no-ff 合并回目标分支, 冲突 rebase 重试一次, 仍冲突**保留现场交 LLM 解**(system-executor 一次性会话 + resolving-merge-conflicts skill, 审计落 metadata.mergeResolution), LLM 失败/超时回退 abort+markMergeConflict 转人工; 合并成功后落 mergedAt 前在合并目标目录重跑 verify(runWuVerification), 红 → 同款 blocked 转人工(blockReason 前缀 merge-verify-failed); pmoBranch 走 PMO 分支交合 worktree(`/pmo-`)。 - `wu-metadata.ts` - metadata 访问器(零依赖叶子): parseWuMetadata(容错解析) / parseWuTitle(展示名 title??scope 唯一出口, #312 起 getAgentSummary 与 status_changed 负载共用) / clearSessionBookkeeping(16 字段会话簿记权威清单, review 子 WU 不继承) / mergedWuView(持久化 + metadataUpdates 合并视图)。 - `wu-dependencies.ts` - 接单依赖判定(零依赖叶子): parseBlockedBy / buildStatusById / hasUnfinishedDeps / resolveClaimable。 - `assignee-resolver.ts` - assigneeId 双语义批量解析器: buildAssigneeProfileResolver -> (assigneeId) => profileId | null。 @@ -35,7 +34,7 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N - **assigneeId 双语义**: unassigned 时 = 被指名 profile.id; 认领后 = 认领方 loop 的 instance.id。认领口径补充(2026-09-10): claim 时把认领方实例的 roleId 冗余快照为 WU 的 assigneeRoleId(人工 REST 认领 agentId=用户 id 无实例档案 -> null), unclaim 与显式 assigneeId=null 的 patch 连带清空; 展示层优先按快照解析角色名, 不再点查实例档案(实例回收后档案不存在)。claim 锁是 flock 悲观互斥锁(mkdir 原子目录跨进程互斥)。token 归因按双语义解析, 批量消费方统一走 assignee-resolver, 勿各自再建 map。 - 状态变更发 workunit.status_changed 事件(claim/unclaim/reviewRejected 也发), requirements/rollup 据此汇总 REQ 状态。订阅方: events/workunit-events-bridge(->SSE)、pmo/analysis-handoff(分析接力)。 - **租约 + 代际令牌(fencing)**: timeoutAt 语义 = 租约到期, claim 写固定 5min(WU_LEASE_TTL_MS), 持有方 loop 每 30s 心跳推前(#314 起 refreshWorkUnitLease 只写内存缓冲, flushWorkUnitLeases 默认 60s 窗口锁内复核 fencing 后合并落盘——持久化 timeoutAt 滞后 ≤60s ≪ TTL, 扫描逻辑零改动)。claimedAt 作 fencing token, 三处校验: 心跳前(快速路) / 步结果回写前(stillHoldsLease) / 状态迁移前(transitionIfHeld)。易主即杀 CLI 进程组(Executor.stopProcessGroup -> kill(-pid)) + 停心跳 + 静默退出该 WU。释放即杀: 释放/转 blocked 后顺 assigneeId 杀原 holder。 -- **blocked 恢复**: 不做自动恢复。回复即复活 -- 全 blocked 类型, 线程人类回复 -> active + pendingReplies 注入, 回复「关闭」= 显式关闭指令(decision/spec 无 closed -> 拒绝并说明)。复活重置 consecutiveStuck/blockReason, 记 resumeCount(不限次), timeoutReleaseCount 终身保留。CTA 统一 blocked-cta 模板(blocked 里程碑/30min 提醒/24h 死信)。24h 死信自动关闭(计时基准 = metadata.blockedAt, 无则回退 createdAt; decision/spec 豁免), 经 wu-closure 双出声。checkTotalExecutionTime 2.5h 强杀同出口。复活后凭 metadata.sessionId 优先续用旧会话(不靠清零 sessionCount 放行)。**active 回复缓冲(#499)**: active WU 的线程人类回复一律锁内追加 pendingReplies(下轮 prompt 必注入, recordResult 三段合成保留步期间新到回复)——不再依赖 observe newReplies 水位线(updatedAt 被步簿记推进后回复低于水位线被吞, #493 残留盲区); in_review/done/closed 等无下一步状态仍不缓冲。Web resume 通道保持 blocked-only(resumeBlockedWorkUnitFromWeb 前置守卫, 路由层同口径 409)。 +- **blocked 恢复**: 不做自动恢复。回复即复活 -- 全 blocked 类型, 线程人类回复 -> active + pendingReplies 注入, 回复「关闭」= 显式关闭指令(decision/spec 无 closed -> 拒绝并说明)。复活重置 consecutiveStuck/blockReason, 记 resumeCount(不限次), timeoutReleaseCount 终身保留。CTA 统一 blocked-cta 模板(blocked 里程碑/30min 提醒/24h 死信)。24h 死信自动关闭(计时基准 = metadata.blockedAt, 无则回退 createdAt; decision/spec 豁免), 经 WorkUnitService.close 双出声(#550 收编 wu-closure)。checkTotalExecutionTime 2.5h 强杀同出口。复活后凭 metadata.sessionId 优先续用旧会话(不靠清零 sessionCount 放行)。**active 回复缓冲(#499)**: active WU 的线程人类回复一律锁内追加 pendingReplies(下轮 prompt 必注入, recordResult 三段合成保留步期间新到回复)——不再依赖 observe newReplies 水位线(updatedAt 被步簿记推进后回复低于水位线被吞, #493 残留盲区); in_review/done/closed 等无下一步状态仍不缓冲。Web resume 通道保持 blocked-only(resumeBlockedWorkUnitFromWeb 前置守卫, 路由层同口径 409)。 - **decision/spec 类型裁剪状态机**: `unassigned -> active ⇄ blocked -> in_review -> done`, 无 closed(决策可等关键人多天, 不进死信/关闭)。TYPE_VALID_TRANSITIONS 按 type 覆盖, transitionStatus 经 resolveValidTransitions(type, status) 查表, 未列出 type 回落全局 VALID_TRANSITIONS。不在 CODE_WORKTREE_TYPES(无 worktree/无 L1 验证 -> merge-on-review-pass 自然旁路), ReviewDispatcher 不派评审子 WU, PMO 证据口径豁免 l2。 - **pending 待确认人闸**: PENDING_CONFIRM_TYPES = feature/task/spec 创建未显式给 status -> 落 pending(状态机仅 `pending -> unassigned|closed`, spec 走裁剪机仅 `-> unassigned`), 人工确认才进 frontier。feature 落 pending 不展开频道工单路由(#466 routing.implement, 吞并 AC-6.3 defaultPipeline; 展开入口 = workunit-crud.expandRoutingHead, 回退语义与提醒见 channels/CONTEXT), 确认时 transitionStatus 补展开(幂等)。resolveInitialStatus(type, explicit?) 是初始状态唯一决策入口(workunit.types.ts)。 - **F6 证据台账**: metadata 增 attestations(l1 自动验证 / l2 agent 评审 / l3 人工确认)。展示/指标只准过 studio-shared 的 deriveDisplayState(), 禁止各自解释。5 处写入(reviewPassed/writeHumanConfirmation/writeAgentReviewAttestation/recordL1Verification/reviewRejected)收敛为 buildAttestationEntry / persistSnapshot。 @@ -43,15 +42,16 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N - **开图/物化/收尾哨兵**: analysis/plan(#471) WU done 且 l3.summary 含 `FOG:`/`DESTINATION:` -> 初始化 PMO map 台账(#471 起不再逐条建 decision 单, fog[].wuId 恒 null; metadata 落 mapOpenedAt 哨兵); spec WU done 且 l3.summary 含 `TASK:|AC:|BLOCKEDBY:|LEG:` -> 批量建 task 单(specTasksSpawnedAt 哨兵; #463 起无 TASK 行不落哨兵 = 人审有意不物化, 补确认 l3 补写再发 status_changed 可再触发物化; #471 起只服务存量 spec 单); done 触发角色记忆提取(memoryExtractedAt 哨兵)。各哨兵写入走「写入前重读合并」防同事件互覆。 - **#463 确认表单结构化**: review-passed 端点收可选 `confirm` body(decision 结论 / spec 物化 tasks / analysis·plan destination+fog+tasks——#471 起 plan 与 analysis 同形同契约)——校验与序列化正本在 confirm-payload.ts(resolveReviewConfirm), 后端把表单数据序列化进 l3.summary(存储契约不变, 下游 parseSpecTasks/parseMapOpening/decision-resolution 原样消费), confirm 与裸 summary 并存时 confirm 优先; analysis/plan 的 tasks 经 reviewPassed options.analysisTasks 覆写 metadata.analysisTasks(人审剔除/行内编辑生效)。预填数据源 = agent COMPLETE 落档的 specTasks(spec 输出 TASK 物化行)/decisionSuggestion(decision 输出 `## 结论摘要` 段)——prompt 契约见 agents/loop/prompt-composer CONTRACT_TEMPLATES。 - **#467 裁决轮提交**: `POST /:id/ruling`(human-only)——plan 裁决轮挂起(blocked + metadata.planRulings 非空 + waitingReason='plan-ruling')时收 `items:[{question, action:accept|reopen, conclusion?}]`, 校验(validateRulingItems, 400)后交 pmo/plan-ruling.applyPlanRuling: 批量落探路台账 + 组合裁决结果文本复活同会话。409 = 非 blocked / 无待裁 rulings。 -- **并发写收口**: 全部快照写路径走 FileStore 锁内 commitSnapshot/commitRemoval(appendEvent + upsertSnapshot 同一把 workunits flock 成对, 删除落 closed+deleted 墓碑); metadata 增量写走 updateMetadata(id, mutator)(mutator 基于锁内最新 metadata, stepCount/consecutiveStuck 锁内重计, progressLog/pendingReplies 锁内尾部追加); createGuarded(input, guard) 支撑 review 建子 WU 锁内 check-then-create; 启动对账 reconcileIndex 接 apps/api index.ts。 +- **#567 方向锁定提交**: `POST /:id/direction`(human-only, 仿 /:id/ruling)——plan 方向锁定挂起(blocked + metadata.planDirections 非空 + waitingReason='plan-direction')时收 `{choice, note?}`, 校验(validateDirectionPick, choice 须在候选 name 集合内, 400)后交 pmo/plan-direction.applyPlanDirection: 落「方向:…(人锁定)」结论 + 复活同会话。409 = 非 blocked(NOT_BLOCKED) / 无待选 planDirections(NO_PENDING_DIRECTION)。 +- **写单口与并发收口**: WU 全部写路径唯一出口 = WorkUnitService 语义方法(create/transitionStatus/update/delete/unclaim/claim + close 系统侧关闭(#550) + updateMetadata 增量), 业务模块直调 FileStore 写原语即违规——docs/adr/2026-09-15-workunit-write-single-mouth.md(2026-09-15 定稿, #523 建单收口同源)。#538 已落地: discord 按钮回调(retry=unclaim+updateMetadata 合并 / abandon=close human-command)/monitor TTL/ops GC/trigger UPDATE 四绕过点全部收口(legacy events:goal-execution 停发); #554 补齐 updateMetadata(id, patch) service 语义口(workunit-crud.ts, patch 锁内浅并入, WU 不存在返回 null 不抛错), discord retry 标记与 trigger UPDATE metadata patch 两处跨模块直调 FileStore 原语收口(其余模块的锁内 metadata 簿记直调属存量, 收口另议)。#550 关闭单口: 全部关闭路径(手工 PATCH/24h 死信/2.5h 强杀/频道「关闭」指令/Web 按钮/Discord abandon)经同一个状态机校验入口 close()/transitionStatus——decision/spec 无 closed 边由裁剪状态机天然拒绝(waiting-input 不再手写查表守卫, 拒绝说明行为保留); 系统关闭与手工关闭同权发 workunit.status_changed; 已 closed 幂等直返不重复出声。**迁移类写路径统一尾部 persistSnapshot(#550)**: transitionStatus/close/markMergeConflict/blockForManualRelease/blockForAllUnfit 共用「快照构建(status/closedAt/completedAt 落锚 + patch 覆盖) → commitSnapshot 锁内成对 → status_changed → 父聚合(fire-and-forget) → reopen thaw」, 各方法只留策略(守卫/metadata/事件类型/特有级联); 新迁移入口不得再抄第四遍尾巴, 不变式单点断言在 __tests__/workunit-migration-tail.test.ts。删除口径: delete(id, { reason }) 墓碑事件行(closed + deleted:true + 可选 reason)由 service 单点构造, 删后发 workunit:removed(负载 id + channelId, 经 events/workunit-events-bridge 转发白名单进 SSE, 前端 workunitStore/channelWorkStore 删行; 只走事件流不进频道出声)。锁内口径: 全部快照写路径走 FileStore 锁内 commitSnapshot/commitRemoval(appendEvent + upsertSnapshot 同一把 workunits flock 成对, 删除落 closed+deleted 墓碑); metadata 增量写走 updateMetadata(id, mutator)(mutator 基于锁内最新 metadata, stepCount/consecutiveStuck 锁内重计, progressLog/pendingReplies 锁内尾部追加); createGuarded(input, guard) 支撑 review 建子 WU 锁内 check-then-create; 启动对账 reconcileIndex 接 apps/api index.ts。trigger UPDATE 禁改 status(TriggerStore 注册校验 + executeUpdateAction 执行守卫双保险), 其余字段走 patchSnapshot 白名单、metadata 走 updateMetadata 合并(ADR 决策 6)。 - **创建期父戳继承(#402)**: create/createGuarded 对带 parentId 的建单, 显式 metadata 未带归因戳(pmoId ‖ legacy ownershipProjectId)时从父 WU 继承, legacy 归一为 canonical pmoId(解析走 requirements/wu-pmo-attribution.ts 零依赖叶子, 无环)。创建期一次性落档语义不变, 显式传入优先, 父缺失/无戳不动(无归属 WU 合法, 不伪造)。覆盖默认管线展开、MCP/DELEGATE 子单等一切带父建单路径, 新增派生入口无需各自传戳。 -- **metadata 字段承载**: workspaceRoot/ownershipSource/waitingReason(工程归属); worktreePath/worktreeBranch/worktreeBaseBranch/worktreeBaseRepo + verifyCommands/verifyReport/verifyFailCount/verifyFailHint(代码类 WU worktree 与自动验证); mergedAt/mergeCommit/mergeConflict/conflictFiles(自动合并); pmoId(canonical PMO 归因戳, 唯一直读 key; legacy `ownershipProjectId` 同位名仅读取侧兼容); pmoBranch(PMO 分支合并目标); blockedBy/ac(接单依赖与验收标准, 机制只存不解释); progressLog(前序进展, 环形保留最近 5 条); sessionSummary(溢出滚动摘要); defaultTaskAssigneeId(analysis 确认默认执行角色); manualRelease/manualReleaseReason(转人工留痕); analysisTasks/analysisTasksSpawned/analysisTasksSpawnedAt/analysisFog/analysisDestination/autoConfirmedBy/autoConfirmedAt(analysis 派生哨兵集; #471 起 plan 同字段同口径); tokenBudget(#162 WU 级 token 额度; #471 起 publish 对 plan 默认落 1M, env STUDIO_PLAN_TOKEN_BUDGET 覆盖)/planStepAllowance(#471 plan 步数续期授权额度); specTasks/decisionSuggestion(#463 确认弹窗结构化预填, agent COMPLETE 落档)。 -- **鉴权**: 15 条写端点(CRUD/claim/unclaim/review/status/讨论区发消息/编辑消息/opportunities adopt·ignore/resume/close) = requireAuth()+requireNotGuest(); GET 只读保持大门层。review-passed/review-rejected/POST /:id/status 拒绝 authorType=agent(403, A2A §4.4: 验收权只在人; 服务层 transitionStatus 不限 agent 内部合法迁移)。authorType/agentName 是自声明身份(不作凭证, 已知局限)。 +- **metadata 字段承载**: workspaceRoot/ownershipSource/waitingReason(工程归属); worktreePath/worktreeBranch/worktreeBaseBranch/worktreeBaseRepo + verifyCommands/verifyReport/verifyFailCount/verifyFailHint(代码类 WU worktree 与自动验证); mergedAt/mergeCommit/mergeConflict/conflictFiles/mergeResolution(自动合并 + LLM 解冲突审计); pmoId(canonical PMO 归因戳, 唯一直读 key; legacy `ownershipProjectId` 同位名仅读取侧兼容); pmoBranch(PMO 分支合并目标); blockedBy/ac(接单依赖与验收标准, 机制只存不解释); progressLog(前序进展, 环形保留最近 5 条); sessionSummary(溢出滚动摘要); defaultTaskAssigneeId(analysis 确认默认执行角色); manualRelease/manualReleaseReason(转人工留痕); analysisTasks/analysisTasksSpawned/analysisTasksSpawnedAt/analysisFog/analysisDestination/autoConfirmedBy/autoConfirmedAt(analysis 派生哨兵集; #471 起 plan 同字段同口径); tokenBudget(#162 WU 级 token 额度; #471 起 publish 对 plan 默认落 1M, env STUDIO_PLAN_TOKEN_BUDGET 覆盖)/planStepAllowance(#471 plan 步数续期授权额度); specTasks/decisionSuggestion(#463 确认弹窗结构化预填, agent COMPLETE 落档)。 +- **鉴权**: 15 条写端点(CRUD/claim/unclaim/review/status/讨论区发消息/编辑消息/opportunities adopt·ignore/resume/close) = requireAuth()+requireNotGuest(); GET 只读保持大门层。review-passed/review-rejected/POST /:id/status 拒绝 authorType=agent(403, A2A §4.4: 验收权只在人; 服务层 transitionStatus 不限 agent 内部合法迁移; #551 起全部 human-only 守卫 = http-helpers.ts requireHuman 中间件)。authorType/agentName 是自声明身份(不作凭证, 已知局限)。 - **批量最近完成(#387)**: GET /last-done?assigneeIds= 每 assignee 最近一条 done/completed(completedAt ?? updatedAt 降序取首; lastDoneByAssignee @ workunit.service.ts 全量索引一次扫描, 顺带消掉旧「limit=20 先截后滤」漏单口径)。roster 空闲卡「最近完成」专用, 消逐实例 GET /?assigneeId= 的 N+1; 单次 id 上限 MAX_BATCH_IDS=100。GET 只读, 大门层鉴权不变。 - **未归属过滤(#428, #402 决策 4 补交)**: GET /?attributed=false 服务端过滤未归属 WU(口径 = 无 reqId 且归因戳 parseWuPmoId 解析为 null, 含 legacy ownershipProjectId 同位名); attributed=true 反向; 缺省/非法值不过滤。计数复用分页响应 total(过滤后计数), 无独立 count 端点; 前端 #405 消费。reqId 悬挂(指向不存在 REQ)暂按有 reqId 计为已归属, 如需改口径先回 #402 决策层。 - **标题搜索(批次 D-2 项4, docs/plans/2026-09-ui-interaction-polish.md)**: GET /?q= 对 scope 做大小写不敏感子串过滤(service.list 内存过滤, 与 status/type/attributed 等取交集); 路由层 trim, 空白 q 按不过滤; 计数同样复用分页 total(过滤后计数)。前端 WorkUnitListPage 搜索框消费(apps/web workunitStore searchQuery)。 - **projectId 过滤(#456)**: GET /?projectId= 按 PMO 项目服务端过滤, 归属口径与 PMO 台账唯一同源(pmo/evidence-summary.selectProjectSnapshots: reqId 绑定优先 → pmoId 戳兜底); requirements 经 RequirementService 仅带 projectId 时加载, 动态引入避环(workunit.service → requirement.service → project.service → workunit.service); 空串按不过滤, 计数复用分页 total。前端 ProjectDetailPage next-action 候选消费(消「拉全系统 unassigned 池客户端过滤」)。 -- **blockForManualRelease**: AgentInstanceService.terminate unclaim 后经本方法置 blocked 转人工(unassigned->blocked 不在 VALID_TRANSITIONS, 语义方法直写快照 + appendEvent('blocked') + publishStatusChanged; assigneeId/claimedAt 清空, metadata.manualRelease/manualReleaseReason 留痕; 终态 done/closed 不动)。blocked 不在 loop 认领集合内。 +- **blockForManualRelease**: AgentInstanceService.terminate unclaim 后经本方法置 blocked 转人工(unassigned->blocked 不在 VALID_TRANSITIONS, 语义方法经 persistSnapshot 统一尾部落 blocked 事件 + status_changed + 父聚合; assigneeId/claimedAt 清空, metadata.manualRelease/manualReleaseReason 留痕; 终态 done/closed 不动)。blocked 不在 loop 认领集合内。 - **rebindSourceChannel(from, to)**: 频道删除兜底时 WU 重挂唯一入口, 解析 metadata 按 context.sourceChannelId 字段相等匹配顶层 task WU(替代 raw JSON includes 子串匹配, 杜绝误伤), metadata-only 更新不发 status_changed(appendEvent('updated') + upsertSnapshot 走 update() 惯例)。 -- **closedAt 归档计龄锚点(#327, 2026-08-25)**: `WorkUnitSnapshot.closedAt?` / `WorkUnitData.closedAt?`——转入 closed 落锚(transitionStatus 与 wu-closure 统一关闭出口同写, 与 completedAt 同刻); closed→unassigned(reopen)清 null; 旧快照缺字段兼容(归档 sweep 对 closed 但无 closedAt 的遗产数据回退 updatedAt 计龄)。reopen 成功迁移后 best-effort 调 `FileStore.thawWorkUnitMessages(id)` 把该 WU 已归档消息从冷文件搬回热层(失败记日志不阻断迁移)——规则一条线: 活 WU 的消息永远在热层。 +- **closedAt 归档计龄锚点(#327, 2026-08-25)**: `WorkUnitSnapshot.closedAt?` / `WorkUnitData.closedAt?`——转入 closed 落锚(transitionStatus 与 close 系统侧关闭单口同写, #550 起同走 persistSnapshot 统一尾部, 与 completedAt 同刻); closed→unassigned(reopen)清 null; 旧快照缺字段兼容(归档 sweep 对 closed 但无 closedAt 的遗产数据回退 updatedAt 计龄)。reopen 成功迁移后 best-effort 调 `FileStore.thawWorkUnitMessages(id)` 把该 WU 已归档消息从冷文件搬回热层(失败记日志不阻断迁移)——规则一条线: 活 WU 的消息永远在热层。 - **消息关联 WU 统一路径(#333)**: `createFromMessage` 关联段不再裸 `fileStore.appendMessage`, 改走 `ChannelMessageService.linkWorkUnit`(append 保留 createdAt + eventBus/SSE 双发 `channel.message_updated`, 前端原位刷新); 构造器第二参可注入 messageService(缺省 fileStore 新建, 无 fileStore 用单例, 口径:可注入,缺省 fileStore 新建/无 fileStore 用单例)。#524 P1-1 起 `createFromMessage` 接受 `options.channelId`(POST /from-message 路由 body 透传)→ 按频道直查免全频道扇出, 缺省保留扇出兼容。 diff --git a/apps/api/src/modules/workunit/__tests__/direction.routes.test.ts b/apps/api/src/modules/workunit/__tests__/direction.routes.test.ts new file mode 100644 index 000000000..e9d0d9ed2 --- /dev/null +++ b/apps/api/src/modules/workunit/__tests__/direction.routes.test.ts @@ -0,0 +1,108 @@ +// #567:POST /:id/direction 路由——方向锁定结构化提交(human-only,仿 /:id/ruling 通道)。 +// WorkUnitService mock 掉(形态照 ruling.routes.test.ts);plan-direction 模块只 mock +// applyPlanDirection(落账+复活属服务层,单测见 pmo/__tests__/plan-direction.test.ts), +// validateDirectionPick/PlanDirectionError 用真身测路由层 400 契约。 +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import express from 'express'; +import type { Server } from 'node:http'; + +const { mockGetById, mockApplyPlanDirection } = vi.hoisted(() => ({ + mockGetById: vi.fn(), + mockApplyPlanDirection: vi.fn(), +})); + +vi.mock('../workunit.service.js', () => ({ + WorkUnitService: class { + getById = mockGetById; + }, + ANALYSIS_TASKS_MAX: 8, +})); + +vi.mock('../../pmo/plan-direction.js', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, applyPlanDirection: mockApplyPlanDirection }; +}); + +import router from '../workunit.routes.js'; + +const DIRECTIONS = { + question: '自研还是引入依赖?', + options: [ + { name: '自研', summary: '自研调度内核', tradeoffs: '可控但慢', impact: '触及 scheduler 模块', recommended: true }, + { name: '引入依赖', summary: '引入 bullmq', tradeoffs: '快但多一个依赖', impact: '触及 api 与 daemon', recommended: false }, + ], +}; + +const BLOCKED_DIRECTION_WU = { + id: 'wu-1', + status: 'blocked', + metadata: JSON.stringify({ + pmoId: 'proj-1', + waitingForInput: true, + waitingReason: 'plan-direction', + planDirections: DIRECTIONS, + }), +}; + +describe('#567 POST /workunits/:id/direction(方向锁定提交)', () => { + let server: Server; + let base: string; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/workunits', router); + await new Promise(resolve => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + base = `http://127.0.0.1:${port}/workunits`; + }); + + afterAll(async () => { + await new Promise(resolve => server.close(resolve)); + }); + + function post(id: string, body?: unknown) { + return fetch(`${base}/${id}/direction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + it('200:blocked + 待选 planDirections + 合法 choice → applyPlanDirection 被调并返回更新后 WU', async () => { + mockGetById.mockResolvedValue(BLOCKED_DIRECTION_WU); + mockApplyPlanDirection.mockResolvedValue({ ...BLOCKED_DIRECTION_WU, status: 'active' }); + + const res = await post('wu-1', { choice: '自研', note: '要可控' }); + expect(res.status).toBe(200); + expect(mockApplyPlanDirection).toHaveBeenCalledWith('wu-1', { choice: '自研', note: '要可控' }, expect.anything()); + const body = await res.json(); + expect(body.status).toBe('active'); + }); + + it('404:WU 不存在', async () => { + mockGetById.mockResolvedValue(null); + const res = await post('wu-x', { choice: '自研' }); + expect(res.status).toBe(404); + }); + + it('409:非 blocked / 无待选 planDirections', async () => { + mockGetById.mockResolvedValue({ ...BLOCKED_DIRECTION_WU, status: 'active' }); + expect((await post('wu-1', { choice: '自研' })).status).toBe(409); + + mockGetById.mockResolvedValue({ ...BLOCKED_DIRECTION_WU, metadata: JSON.stringify({ waitingForInput: true }) }); + expect((await post('wu-1', { choice: '自研' })).status).toBe(409); + }); + + it('400:载荷非法(缺 choice / choice 不在候选内 / 非对象)', async () => { + mockGetById.mockResolvedValue(BLOCKED_DIRECTION_WU); + mockApplyPlanDirection.mockClear(); + expect((await post('wu-1', {})).status).toBe(400); + expect((await post('wu-1', { choice: '第三条路' })).status).toBe(400); + expect((await post('wu-1', { note: '只有说明' })).status).toBe(400); + expect(mockApplyPlanDirection).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/workunit/__tests__/http-helpers.test.ts b/apps/api/src/modules/workunit/__tests__/http-helpers.test.ts new file mode 100644 index 000000000..a8a1a74ce --- /dev/null +++ b/apps/api/src/modules/workunit/__tests__/http-helpers.test.ts @@ -0,0 +1,143 @@ +// #551:routes 错误契约收口单测——一张映射表(WORKUNIT_ERROR_MAPS)覆盖全部端点。 +// 文案 ↔ 状态码解耦的契约点:状态码只由表项决定,sendMappedError 是唯一翻译出口。 +import { describe, it, expect, vi } from 'vitest'; +import type { NextFunction, Request, Response } from 'express'; +import { + HttpRouteError, + WORKUNIT_ERROR_MAPS, + sendMappedError, + route, + resolveCallerAuthorType, + requireHuman, + type ErrorMapping, +} from '../http-helpers.js'; +import { ConfirmPayloadError } from '../confirm-payload.js'; +import { PlanRulingError } from '../../pmo/plan-ruling.js'; +import { PlanDirectionError } from '../../pmo/plan-direction.js'; + +function mockRes() { + const res = { + statusCode: 0, + body: undefined as unknown, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: unknown) { + res.body = payload; + return res; + }, + }; + return res as unknown as Response & { statusCode: number; body: unknown }; +} + +function mockReq(init?: { body?: unknown; headers?: Record }): Request { + return { body: init?.body, headers: init?.headers ?? {} } as unknown as Request; +} + +describe('sendMappedError(#551 统一翻译层)', () => { + it('HttpRouteError → 自带 status/code/message 直出,不查表', () => { + const res = mockRes(); + sendMappedError(res, new HttpRouteError(409, 'NOT_BLOCKED', 'WorkUnit 当前状态为 active'), []); + expect(res.statusCode).toBe(409); + expect(res.body).toEqual({ error: { code: 'NOT_BLOCKED', message: 'WorkUnit 当前状态为 active' } }); + }); + + it('未命中任何表项 → 500 INTERNAL_ERROR,message 取 getErrorMessage', () => { + const res = mockRes(); + sendMappedError(res, new Error('totally unexpected'), WORKUNIT_ERROR_MAPS.update); + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: { code: 'INTERNAL_ERROR', message: 'totally unexpected' } }); + }); + + it('非 Error 抛出 → message 兜底 Unknown error', () => { + const res = mockRes(); + sendMappedError(res, 42, []); + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: { code: 'INTERNAL_ERROR', message: 'Unknown error' } }); + }); + + // 全端点映射表逐条覆盖:字符串匹配器用自身作 message 必命中(includes 自反), + // Error 类匹配器实例化必命中(instanceof)。每张表、每个表项都过一遍翻译出口。 + const classSamples: ReadonlyMap = new Map([ + [ConfirmPayloadError, new ConfirmPayloadError('confirm bad')], + [PlanRulingError, new PlanRulingError('ruling bad')], + [PlanDirectionError, new PlanDirectionError('direction bad')], + ]); + + for (const [endpoint, mappings] of Object.entries(WORKUNIT_ERROR_MAPS)) { + describe(`端点映射表: ${endpoint}`, () => { + for (const mapping of mappings as readonly ErrorMapping[]) { + const label = typeof mapping.match === 'string' ? `'${mapping.match}'` : String(mapping.match); + it(`${label} → ${mapping.status} ${mapping.code}`, () => { + const err = typeof mapping.match === 'string' + ? new Error(mapping.match) + : classSamples.get(mapping.match); + expect(err, `缺少类匹配器 ${label} 的样例`).toBeDefined(); + const res = mockRes(); + sendMappedError(res, err, mappings); + expect(res.statusCode).toBe(mapping.status); + expect(res.body).toEqual({ error: { code: mapping.code, message: (err as Error).message } }); + }); + } + }); + } + + it('表项按声明顺序优先(reviewPassed:ConfirmPayloadError 先于字符串项判定)', () => { + const res = mockRes(); + sendMappedError(res, new ConfirmPayloadError('not found in payload'), WORKUNIT_ERROR_MAPS.reviewPassed); + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: { code: 'INVALID_CONFIRM', message: 'not found in payload' } }); + }); +}); + +describe('route() 包装器', () => { + it('handler 正常返回 → 不干预响应', async () => { + const res = mockRes(); + const handler = route([], async (_req, r) => { + r.json({ ok: true }); + }); + await handler(mockReq(), res); + expect(res.body).toEqual({ ok: true }); + }); + + it('handler 抛错 → 经映射表翻译', async () => { + const res = mockRes(); + const handler = route(WORKUNIT_ERROR_MAPS.update, async () => { + throw new Error('WorkUnit wu-1 not found'); + }); + await handler(mockReq(), res); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: { code: 'NOT_FOUND', message: 'WorkUnit wu-1 not found' } }); + }); +}); + +describe('resolveCallerAuthorType / requireHuman(#551 human-only 中间件)', () => { + it('body.authorType 优先;其次 x-author-type header;缺省 human', () => { + expect(resolveCallerAuthorType(mockReq({ body: { authorType: 'agent' }, headers: { 'x-author-type': 'human' } }))).toBe('agent'); + expect(resolveCallerAuthorType(mockReq({ headers: { 'x-author-type': 'agent' } }))).toBe('agent'); + expect(resolveCallerAuthorType(mockReq())).toBe('human'); + expect(resolveCallerAuthorType(mockReq({ body: { authorType: 123 } }))).toBe('human'); + }); + + it('requireHuman:agent → 403 FORBIDDEN + 定制 message,next 不调', () => { + const res = mockRes(); + const next = vi.fn() as unknown as NextFunction; + requireHuman('Review actions are human-only (authorType=agent rejected)')( + mockReq({ body: { authorType: 'agent' } }), res, next, + ); + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ + error: { code: 'FORBIDDEN', message: 'Review actions are human-only (authorType=agent rejected)' }, + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('requireHuman:human/缺省 → next(),不写响应', () => { + const res = mockRes(); + const next = vi.fn() as unknown as NextFunction; + requireHuman('x')(mockReq(), res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(0); + }); +}); diff --git a/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts b/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts index 5d35758c6..1aee43228 100644 --- a/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts +++ b/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts @@ -10,10 +10,12 @@ import { FileStore } from '@dommaker/studio-shared'; import { WorkUnitService, waitForReviewPassSettled, type WorkUnitMetadata, type WorkUnitData } from '../workunit.service.js'; import { mergeWorktreeBranchOnReviewPass, cleanupPrototypeWorktreeOnReviewPass } from '../merge-on-review-pass.js'; -const { mockExecSh, mockPostWuSystemMessage, mockResolvePmoProjectId } = vi.hoisted(() => ({ +const { mockExecSh, mockPostWuSystemMessage, mockResolvePmoProjectId, mockSystemExecRun, mockLoadSingleSkill } = vi.hoisted(() => ({ mockExecSh: vi.fn(), mockPostWuSystemMessage: vi.fn(), mockResolvePmoProjectId: vi.fn(), + mockSystemExecRun: vi.fn(), + mockLoadSingleSkill: vi.fn(), })); vi.mock('@dommaker/studio-shared/node', async (importOriginal) => { @@ -27,6 +29,17 @@ vi.mock('../../requirements/pmo-branch-resolver.js', () => ({ resolvePmoProjectIdForWU: mockResolvePmoProjectId, })); +// LLM 解冲突:spawn 复用 system-executor 一次性会话(mock 掉真实 LLM 调用) +vi.mock('../../agents/system-executor.js', () => ({ + getSystemExecutor: () => ({ run: mockSystemExecRun }), +})); + +// LLM 解冲突:skill 全文经 skillLoader.loadSingle 加载(mock 掉磁盘 seed 依赖; +// 全 mock 对齐 claim-skill-integration.test.ts 模式——本包无 src 别名,走 dist) +vi.mock('@dommaker/studio-skill', () => ({ + skillLoader: { loadSingle: mockLoadSingleSkill }, +})); + // wu-messenger 间谍包装:真实发送保留(消息断言不受影响),另断言委托参数(milestone 等) vi.mock('../wu-messenger.js', async (importOriginal) => { const orig = await importOriginal() as { postWuSystemMessage: (...args: unknown[]) => Promise }; @@ -91,6 +104,9 @@ beforeEach(async () => { return null; } }); + // LLM 解冲突默认实现:skill 可用 + spawn 成功(是否"解完"由各用例的 diff-filter=U mock 决定) + mockLoadSingleSkill.mockReturnValue({ id: 'resolving-merge-conflicts', name: 'resolving-merge-conflicts', prompt: 'SKILL 流程卡全文' }); + mockSystemExecRun.mockResolvedValue({ output: 'resolved; typecheck+test green', usage: undefined, durationMs: 1234 }); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-on-review-pass-')); fileStore = new FileStore(tmpDir); wuService = new WorkUnitService(fileStore); @@ -307,7 +323,7 @@ describe('B3b-ii: 评审通过后自动合并', () => { expect(meta.mergedAt).toBeDefined(); }); - it('冲突转人工(rebase 冲突):取冲突文件清单 → 清理现场 → WU blocked + 频道转人工', async () => { + it('冲突兜底转人工(rebase 冲突,LLM 解后仍有残留冲突):LLM 尝试过 → 清理现场 → WU blocked + 频道转人工', async () => { mockExecSh.mockImplementation(async (cmd: string) => { if (cmd.includes('merge --no-ff')) throw new Error('CONFLICT: content'); if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); @@ -321,8 +337,12 @@ describe('B3b-ii: 评审通过后自动合并', () => { const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); expect(outcome).toEqual({ attempted: true, merged: false, conflictFiles: ['src/a.ts', 'src/b.ts'] }); + // LLM 解冲突已尝试(spawn cwd = rebase 现场 worktree),但 mock 下冲突清单不变 → 判定未解完 → 兜底 + expect(mockLoadSingleSkill).toHaveBeenCalledWith('resolving-merge-conflicts'); + expect(mockSystemExecRun).toHaveBeenCalledTimes(1); + expect(mockSystemExecRun.mock.calls[0][1]).toEqual(expect.objectContaining({ cwd: WT, eventSource: 'merge-conflict-resolution' })); const cmds = calledCommands(); - // 现场清理:rebase --abort 执行过;不清理 worktree/分支(留人工) + // 现场清理:LLM 兜底后 rebase --abort 执行过;不清理 worktree/分支(留人工) expect(cmds.some(c => c.includes('rebase --abort'))).toBe(true); expect(cmds.some(c => c.includes('worktree remove'))).toBe(false); expect(cmds.some(c => c.includes('branch -d'))).toBe(false); @@ -333,6 +353,9 @@ describe('B3b-ii: 评审通过后自动合并', () => { expect(meta.mergeConflict).toBe(true); expect(meta.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']); expect(meta.mergedAt).toBeUndefined(); + // LLM 解冲突审计落档:未解完(仍有残留冲突) + expect(meta.mergeResolution).toEqual(expect.objectContaining({ ok: false, state: 'rebase', cwd: WT })); + expect(meta.mergeResolution!.error).toContain('仍有 2 个冲突文件'); const msgs = await studioMessages(wu.id); expect(msgs).toHaveLength(1); @@ -347,7 +370,7 @@ describe('B3b-ii: 评审通过后自动合并', () => { ); }); - it('冲突转人工(rebase 成功但二次 merge 仍冲突):冲突文件取自 baseRepo 现场', async () => { + it('冲突兜底转人工(rebase 成功但二次 merge 仍冲突,LLM 解后仍有残留):冲突文件取自合并目标现场', async () => { let mergeAttempts = 0; mockExecSh.mockImplementation(async (cmd: string) => { if (cmd.includes('merge --no-ff')) { @@ -375,6 +398,199 @@ describe('B3b-ii: 评审通过后自动合并', () => { expect(msgs[0].content).toContain('src/x.ts'); }); + it('冲突保留现场交 LLM 解(rebase 冲突):不先 abort;LLM 解完 rebase → 重放 merge 成功 → verify 绿 → merged', async () => { + let mergeAttempts = 0; + let llmCalled = false; + mockSystemExecRun.mockImplementation(async () => { + llmCalled = true; + return { output: 'resolved src/a.ts src/b.ts; typecheck+test green', usage: undefined, durationMs: 5678 }; + }); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) { + mergeAttempts++; + if (mergeAttempts === 1) throw new Error('CONFLICT: content'); + return { stdout: '', stderr: '' }; // LLM 解完 rebase 后重放成功 + } + if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); + if (cmd.includes('diff --name-only --diff-filter=U')) { + // LLM 调用前:rebase 现场有冲突;LLM 调用后:已解清 + return { stdout: llmCalled ? '' : 'src/a.ts\nsrc/b.ts\n', stderr: '' }; + } + if (cmd.includes('rev-parse HEAD')) return { stdout: `${HEAD}\n`, stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: true, mergeCommit: HEAD }); + expect(mergeAttempts).toBe(2); // 首次失败 + LLM 解完 rebase 后重放 + // spawn 一次性解冲突会话:skill 全文作 systemPrompt,cwd = rebase 现场(worktree) + expect(mockLoadSingleSkill).toHaveBeenCalledWith('resolving-merge-conflicts'); + expect(mockSystemExecRun).toHaveBeenCalledTimes(1); + const [prompt, spawnOpts] = mockSystemExecRun.mock.calls[0] as [string, Record]; + expect(spawnOpts).toEqual(expect.objectContaining({ + cwd: WT, + systemPrompt: 'SKILL 流程卡全文', + eventSource: 'merge-conflict-resolution', + })); + expect(prompt).toContain(`cwd: ${WT}`); + expect(prompt).toContain('rebase 进行中'); + expect(prompt).toContain(`目标分支: ${BASE}`); + expect(prompt).toContain(`来源分支: ${BRANCH}`); + expect(prompt).toContain('src/a.ts'); + const cmds = calledCommands(); + // 现场保留:rebase --abort 从未执行(LLM 解成功,无需兜底清理) + expect(cmds.some(c => c.includes('rebase --abort'))).toBe(false); + + const updated = await wuService.getById(wu.id); + expect(updated!.status).not.toBe('blocked'); + const meta = JSON.parse(updated!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeDefined(); + // LLM 解冲突审计落档:成功 + 输出摘要 + expect(meta.mergeResolution).toEqual(expect.objectContaining({ + ok: true, state: 'rebase', cwd: WT, durationMs: 5678, + conflictFiles: ['src/a.ts', 'src/b.ts'], + })); + expect(meta.mergeResolution!.summary).toContain('resolved src/a.ts'); + }); + + it('冲突保留现场交 LLM 解(二次 merge 冲突):LLM 在合并目标目录解 merge → merged', async () => { + let mergeAttempts = 0; + let llmCalled = false; + mockSystemExecRun.mockImplementation(async () => { + llmCalled = true; + return { output: 'merge committed', usage: undefined, durationMs: 100 }; + }); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) { + mergeAttempts++; + throw new Error('CONFLICT: content'); // 首次 + 二次均冲突;LLM 自己 commit merge(无第三次) + } + if (cmd.includes('diff --name-only --diff-filter=U')) { + return { stdout: llmCalled ? '' : 'src/x.ts\n', stderr: '' }; + } + if (cmd.includes('rev-parse HEAD')) return { stdout: `${HEAD}\n`, stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: true, mergeCommit: HEAD }); + expect(mergeAttempts).toBe(2); // LLM 解 merge 现场后无重放(merge 由 LLM commit) + // spawn cwd = 合并目标目录(baseRepo),prompt 指明 merge 现场 + const [prompt, spawnOpts] = mockSystemExecRun.mock.calls[0] as [string, Record]; + expect(spawnOpts).toEqual(expect.objectContaining({ cwd: REPO })); + expect(prompt).toContain('merge 进行中'); + const cmds = calledCommands(); + // 现场保留:LLM 调用后不再有 merge --abort(仅 rebase 重试前清首次现场那一次) + expect(cmds.filter(c => c.includes('merge --abort'))).toHaveLength(1); + + const meta = JSON.parse((await wuService.getById(wu.id))!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeDefined(); + expect(meta.mergeResolution).toEqual(expect.objectContaining({ ok: true, state: 'merge', cwd: REPO })); + }); + + it('LLM 解冲突失败/超时 → 回退现有 abort + markMergeConflict 转人工(兜底不变)', async () => { + mockSystemExecRun.mockRejectedValue(new Error('LLM session timeout')); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) throw new Error('CONFLICT: content'); + if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); + if (cmd.includes('diff --name-only --diff-filter=U')) return { stdout: 'src/a.ts\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: false, conflictFiles: ['src/a.ts'] }); + const cmds = calledCommands(); + // 兜底清理:rebase --abort 执行过;worktree/分支保留 + expect(cmds.some(c => c.includes('rebase --abort'))).toBe(true); + expect(cmds.some(c => c.includes('worktree remove'))).toBe(false); + expect(cmds.some(c => c.includes('branch -d'))).toBe(false); + + const updated = await wuService.getById(wu.id); + expect(updated!.status).toBe('blocked'); + const meta = JSON.parse(updated!.metadata!) as WorkUnitMetadata; + expect(meta.mergeConflict).toBe(true); + expect(meta.mergedAt).toBeUndefined(); + expect(meta.mergeResolution).toEqual(expect.objectContaining({ ok: false, state: 'rebase' })); + expect(meta.mergeResolution!.error).toContain('LLM session timeout'); + const msgs = await studioMessages(wu.id); + expect(msgs.some(m => m.content.includes('转人工'))).toBe(true); + }); + + it('skill 不可用 → 不 spawn LLM,直接回退 abort + 转人工', async () => { + mockLoadSingleSkill.mockReturnValue(null); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) throw new Error('CONFLICT: content'); + if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); + if (cmd.includes('diff --name-only --diff-filter=U')) return { stdout: 'src/a.ts\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome.merged).toBe(false); + expect(mockSystemExecRun).not.toHaveBeenCalled(); + expect(calledCommands().some(c => c.includes('rebase --abort'))).toBe(true); + const meta = JSON.parse((await wuService.getById(wu.id))!.metadata!) as WorkUnitMetadata; + expect(meta.mergeResolution!.ok).toBe(false); + expect(meta.mergeResolution!.error).toContain('resolving-merge-conflicts 不可用'); + expect((await wuService.getById(wu.id))!.status).toBe('blocked'); + }); + + it('合并后 verify 红 → 转人工(merge-verify-failed):不删 worktree/分支、不落 mergedAt', async () => { + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd === 'pnpm run typecheck') { + throw Object.assign(new Error('exit 1'), { stderr: 'TS2322: type mismatch in merged code' }); + } + if (cmd.includes('rev-parse HEAD')) return { stdout: `${HEAD}\n`, stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta({ verifyCommands: ['pnpm run typecheck'] })); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: false, conflictFiles: [], reason: 'merge-verify-failed' }); + const cmds = calledCommands(); + // merge 成功 + verify 在合并目标目录跑过;红 → 不删 worktree、不删分支 + expect(cmds.some(c => c.includes('merge --no-ff'))).toBe(true); + expect(mockExecSh).toHaveBeenCalledWith('pnpm run typecheck', expect.objectContaining({ cwd: REPO })); + expect(cmds.some(c => c.includes('worktree remove'))).toBe(false); + expect(cmds.some(c => c.includes('branch -d'))).toBe(false); + // 无冲突 → 不走 LLM 解冲突 + expect(mockSystemExecRun).not.toHaveBeenCalled(); + + const updated = await wuService.getById(wu.id); + expect(updated!.status).toBe('blocked'); + const meta = JSON.parse(updated!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeUndefined(); + expect(meta.blockReason).toContain('merge-verify-failed'); + expect(meta.blockReason).toContain('pnpm run typecheck'); + + const msgs = await studioMessages(wu.id); + expect(msgs).toHaveLength(1); + expect(msgs[0].content).toContain('合并后验证失败'); + expect(msgs[0].content).toContain('TS2322'); + }); + + it('合并后 verify:无冲突直合也重跑(合并目标目录),全绿 → merged', async () => { + const wu = await createWu(worktreeMeta({ verifyCommands: ['pnpm run test', 'pnpm run typecheck'] })); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: true, mergeCommit: HEAD }); + // verify 命令在合并目标目录(非 worktree)依次跑过 + expect(mockExecSh).toHaveBeenCalledWith('pnpm run test', expect.objectContaining({ cwd: REPO })); + expect(mockExecSh).toHaveBeenCalledWith('pnpm run typecheck', expect.objectContaining({ cwd: REPO })); + const meta = JSON.parse((await wuService.getById(wu.id))!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeDefined(); + }); + it('防重:metadata.mergedAt 已存在 → 跳过,无任何 git 调用', async () => { const wu = await createWu(worktreeMeta({ mergedAt: new Date().toISOString(), mergeCommit: HEAD })); diff --git a/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts index 34905e53e..79fdf4056 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts @@ -2,9 +2,6 @@ // Tests: CRUD + Claim + State machine + Review + from-message import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest'; -const isCI = !!process.env.CI; -const describeIf = isCI ? describe.skip : describe; - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -12,7 +9,7 @@ import { eventBus, FileStore } from '@dommaker/studio-shared'; import { WorkUnitService } from '../workunit.service.js'; import { channelMessageService } from '../../channels/channel-message.service.js'; -describeIf('WorkUnit API service', () => { +describe('WorkUnit API service', () => { let tmpDir: string; let fileStore: FileStore; let service: WorkUnitService; @@ -659,4 +656,94 @@ describeIf('WorkUnit API service', () => { }); }); + // ---- #551: verifyManually(POST /:id/verify 业务下沉 service,脱 HTTP 直测)---- + + describe('verifyManually (#551 verify 下沉 service)', () => { + it('WU 不存在 → not-found', async () => { + const result = await service.verifyManually('nonexistent-id', { by: 'tester' }); + expect(result.kind).toBe('not-found'); + }); + + it('非代码类 WU → not-code-type(带 wuType)', async () => { + const wu = await service.create({ scope: 'analysis no verify', type: 'analysis' }); + testIds.push(wu.id); + + const result = await service.verifyManually(wu.id, { by: 'tester' }); + expect(result).toEqual({ kind: 'not-code-type', wuType: 'analysis' }); + }); + + it('代码类但无 worktreePath 落档 → no-worktree', async () => { + const wu = await service.create({ scope: 'no worktree', type: 'task' }); + testIds.push(wu.id); + + const result = await service.verifyManually(wu.id, { by: 'tester' }); + expect(result.kind).toBe('no-worktree'); + }); + + it('worktree 无可跑命令 → no-commands,不落台账', async () => { + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wu-verify-empty-')); + const wu = await service.create({ + scope: 'no commands', type: 'task', + metadata: { worktreePath }, + }); + testIds.push(wu.id); + + const result = await service.verifyManually(wu.id, { by: 'tester' }); + expect(result.kind).toBe('no-commands'); + + const after = await service.getById(wu.id); + expect(JSON.parse(after!.metadata ?? '{}').attestations).toBeUndefined(); + }); + + it('全绿 → verified + 台账 l1 approved + verifyReport', async () => { + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wu-verify-ok-')); + const wu = await service.create({ + scope: 'verify ok', type: 'task', + metadata: { worktreePath, verifyCommands: ['echo ok'] }, + }); + testIds.push(wu.id); + + const result = await service.verifyManually(wu.id, { by: 'tester' }); + expect(result.kind).toBe('verified'); + if (result.kind !== 'verified') return; + expect(result.report).toMatchObject({ commands: ['echo ok'], source: 'override' }); + + const after = await service.getById(wu.id); + const meta = JSON.parse(after!.metadata ?? '{}'); + expect(meta.attestations.l1).toMatchObject({ verdict: 'approved', by: 'tester', kind: 'verify' }); + expect(meta.verifyReport.commands).toEqual(['echo ok']); + }); + + it('命令失败 → failed(首个失败命令 + tail)+ 台账 l1 rejected,不写 verifyReport', async () => { + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wu-verify-fail-')); + const wu = await service.create({ + scope: 'verify fail', type: 'bug', + metadata: { worktreePath, verifyCommands: ['echo first', 'exit 1'] }, + }); + testIds.push(wu.id); + + const result = await service.verifyManually(wu.id, { by: 'tester' }); + expect(result.kind).toBe('failed'); + if (result.kind !== 'failed') return; + expect(result.failure.command).toBe('exit 1'); + + const after = await service.getById(wu.id); + const meta = JSON.parse(after!.metadata ?? '{}'); + expect(meta.attestations.l1).toMatchObject({ verdict: 'rejected', by: 'tester', kind: 'verify' }); + expect(meta.verifyReport).toBeUndefined(); + }); + + it('opts.commands 视为 metadata.verifyCommands 覆盖', async () => { + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wu-verify-override-')); + const wu = await service.create({ + scope: 'verify override', type: 'feature', + metadata: { worktreePath, verifyCommands: ['exit 1'] }, + }); + testIds.push(wu.id); + + const result = await service.verifyManually(wu.id, { by: 'tester', commands: ['echo override'] }); + expect(result.kind).toBe('verified'); + }); + }); + }); diff --git a/apps/api/src/modules/workunit/__tests__/workunit-close.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-close.test.ts new file mode 100644 index 000000000..40f7dca69 --- /dev/null +++ b/apps/api/src/modules/workunit/__tests__/workunit-close.test.ts @@ -0,0 +1,178 @@ +// #550:WorkUnitService.close —— 系统侧关闭唯一入口(状态机单口 + 统一落库尾部)。 +// 覆盖票体 AC:三件套(closed 快照 + workunit:closed 结构化事件 + 频道说明)行为不变、 +// 系统关闭补发 workunit.status_changed、decision/spec 状态机拒绝、已 closed 幂等、父聚合。 +// 约定与 wu-closure.test.ts 一致:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { v4 as uuidv4 } from 'uuid'; +import { FileStore, eventBus, type ChannelMessageData } from '@dommaker/studio-shared'; +import { WorkUnitService, type WorkUnitData } from '../workunit.service.js'; +import { buildDeadLetterNotice } from '../blocked-cta.js'; + +let tmpDir: string; +let fileStore: FileStore; +let wuService: WorkUnitService; +let channelId: string; +let studioEventsFile: string; +let statusEvents: WorkUnitData[]; +let statusHandler: (payload: { workunit: WorkUnitData }) => void; + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wu-close-test-')); + studioEventsFile = path.join(tmpDir, 'studio-events.jsonl'); + process.env.STUDIO_EVENTS_FILE = studioEventsFile; + fileStore = new FileStore(tmpDir); + wuService = new WorkUnitService(fileStore); + channelId = `ch-close-${Date.now()}`; + await fileStore.createChannel({ + id: channelId, name: '#close-test', type: 'rnd', + defaultWorkspaceId: null, defaultPath: null, + discordChannelId: null, discordWebhookUrl: null, members: '[]', + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + statusEvents = []; + statusHandler = (payload) => { statusEvents.push(payload.workunit); }; + eventBus.subscribe('workunit.status_changed', statusHandler); +}); + +afterEach(() => { + eventBus.unsubscribe('workunit.status_changed', statusHandler); + delete process.env.STUDIO_EVENTS_FILE; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function createBlockedWu() { + const wu = await wuService.create({ + scope: '死信任务', channelId, type: 'task', status: 'active', assigneeId: 'inst-1', + metadata: { title: '死信任务' }, + }); + await wuService.transitionStatus(wu.id, 'blocked'); + await wuService.update(wu.id, { + metadata: { title: '死信任务', blockReason: 'stuck: 连续 3 步无进展', blockedAt: new Date().toISOString() }, + }); + const anchor: ChannelMessageData = { + id: uuidv4(), channelId, authorType: 'human', agentName: null, + content: '@agent 死信任务', replyToId: null, meta: '{}', + workUnitId: wu.id, createdAt: new Date().toISOString(), + }; + await fileStore.appendMessage(channelId, anchor); + return { wu, anchor }; +} + +function readStudioEvents(): Array<{ type: string; payload: string; level?: string }> { + if (!fs.existsSync(studioEventsFile)) return []; + return fs.readFileSync(studioEventsFile, 'utf8') + .split('\n').filter(Boolean) + .map(line => JSON.parse(line)); +} + +const flushAsync = () => new Promise(r => setTimeout(r, 50)); + +describe('WorkUnitService.close(#550 系统侧关闭唯一入口)', () => { + it('blocked WU:closed 快照 + workunit:closed 事件(含 reason/closedBy/blockedAt)+ 频道死信通知 + status_changed 广播', async () => { + const { wu, anchor } = await createBlockedWu(); + const message = buildDeadLetterNotice('死信任务', 'stuck: 连续 3 步无进展'); + statusEvents.length = 0; + + const closed = await wuService.close(wu.id, { + reason: 'blocked 超 24h 无人工介入,自动关闭', + closedBy: 'auto-abandon-stale-blocked', + message, + }); + + expect(closed.status).toBe('closed'); + // 1) 快照:status=closed + completedAt/closedAt 同刻落锚 + const after = (await fileStore.getIndex()).find(s => s.id === wu.id)!; + expect(after.status).toBe('closed'); + expect(after.completedAt).toBeTruthy(); + expect(after.closedAt).toBe(after.completedAt); + // 2) 结构化事件:type/level/payload 三要素(payload 形态与 wu-closure 时代一致) + const events = readStudioEvents() + .filter(e => e.type === 'workunit:closed') + .map(e => ({ ...e, payload: JSON.parse(e.payload) })); + expect(events).toHaveLength(1); + expect(events[0].level).toBe('warning'); + expect(events[0].payload).toMatchObject({ + workUnitId: wu.id, + reason: 'blocked 超 24h 无人工介入,自动关闭', + closedBy: 'auto-abandon-stale-blocked', + }); + expect(typeof events[0].payload.blockedAt).toBe('string'); + // 3) 频道出声:死信模板(已关闭 + 后续出路),挂在 anchor 线程 + const messages = await fileStore.queryMessages(channelId, { workUnitId: wu.id }); + const notice = messages.find(m => m.content.includes('已自动关闭')); + expect(notice).toBeDefined(); + expect(notice!.content).toContain('如需继续请重新派发'); + expect(notice!.content).toContain('stuck: 连续 3 步无进展'); + expect(notice!.authorType).toBe('agent'); + expect(notice!.replyToId).toBe(anchor.id); + // 4) status_changed 广播(#550:系统关闭与手工关闭同权,前端列表/REQ rollup/inspection-scan 实时收 closed) + const broadcast = statusEvents.find(e => e.id === wu.id && e.status === 'closed'); + expect(broadcast).toBeDefined(); + }); + + it('active WU 无频道(total-time-kill 形态):快照 + 事件仍落,跳过频道出声;无 blockedAt 时 payload 不带该键', async () => { + const wu = await wuService.create({ scope: '超时任务', type: 'task', status: 'active', assigneeId: 'inst-1' }); + + const closed = await wuService.close(wu.id, { + reason: '执行超过 2.5h,系统强制关闭', + closedBy: 'total-time-kill', + }); + + expect(closed.status).toBe('closed'); + expect((await fileStore.getIndex()).find(s => s.id === wu.id)!.status).toBe('closed'); + const events = readStudioEvents() + .filter(e => e.type === 'workunit:closed') + .map(e => ({ ...e, payload: JSON.parse(e.payload) })); + expect(events).toHaveLength(1); + expect(events[0].payload.closedBy).toBe('total-time-kill'); + expect(events[0].payload.blockedAt).toBeUndefined(); + expect(statusEvents.some(e => e.id === wu.id && e.status === 'closed')).toBe(true); + }); + + it('decision/spec 裁剪状态机无 closed → close() 抛 Invalid status transition,状态不变、零事件', async () => { + const wu = await wuService.create({ scope: '决策单', channelId, type: 'decision', status: 'active' }); + await wuService.transitionStatus(wu.id, 'blocked'); + statusEvents.length = 0; + + await expect(wuService.close(wu.id, { reason: '人类关闭指令', closedBy: 'human-command' })) + .rejects.toThrow('Invalid status transition: blocked → closed'); + + const after = (await fileStore.getIndex()).find(s => s.id === wu.id)!; + expect(after.status).toBe('blocked'); + expect(readStudioEvents().filter(e => e.type === 'workunit:closed')).toHaveLength(0); + expect(statusEvents.find(e => e.id === wu.id)).toBeUndefined(); + }); + + it('幂等:已 closed 的 WU 再 close() 直返现状,不重复发事件/广播/频道出声', async () => { + const { wu } = await createBlockedWu(); + await wuService.close(wu.id, { reason: '首次关闭', closedBy: 'auto-abandon-stale-blocked' }); + statusEvents.length = 0; + const messagesBefore = (await fileStore.queryMessages(channelId, { workUnitId: wu.id })).length; + + const again = await wuService.close(wu.id, { reason: '重复关闭', closedBy: 'auto-abandon-stale-blocked' }); + + expect(again.status).toBe('closed'); + expect(readStudioEvents().filter(e => e.type === 'workunit:closed')).toHaveLength(1); + expect(statusEvents.find(e => e.id === wu.id)).toBeUndefined(); + expect((await fileStore.queryMessages(channelId, { workUnitId: wu.id })).length).toBe(messagesBefore); + }); + + it('不存在的 WU → 抛 WorkUnit not found', async () => { + await expect(wuService.close('no-such-wu', { reason: 'x', closedBy: 'human-command' })) + .rejects.toThrow('WorkUnit not found'); + }); + + it('父聚合:子 WU 经 close() 关闭后父状态按既有规则聚合(全子 closed → 父 closed)', async () => { + const parent = await wuService.create({ scope: '父单', type: 'task', status: 'active' }); + const child = await wuService.create({ scope: '子单', type: 'task', status: 'active', parentId: parent.id }); + + await wuService.close(child.id, { reason: '系统关闭', closedBy: 'total-time-kill' }); + await flushAsync(); // aggregateParentStatus 是 fire-and-forget(同 transitionStatus 先例) + + const parentAfter = (await fileStore.getIndex()).find(s => s.id === parent.id)!; + expect(parentAfter.status).toBe('closed'); + }); +}); diff --git a/apps/api/src/modules/workunit/__tests__/workunit-closedat.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-closedat.test.ts index e6a73552b..ee0245cab 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-closedat.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-closedat.test.ts @@ -1,13 +1,12 @@ // #327 阶段1:WorkUnitSnapshot.closedAt —— WU 关闭时刻落快照,作为频道消息归档的计龄锚点 // (有 workUnitId 的消息按所属 WU closedAt + 30 天判超龄)。 -// 约定与 wu-closure.test.ts 一致:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 +// 约定:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { FileStore, type WorkUnitSnapshot } from '@dommaker/studio-shared'; import { WorkUnitService } from '../workunit.service.js'; -import { closeWorkUnitWithNotice } from '../wu-closure.js'; let tmpDir: string; let fileStore: FileStore; @@ -63,17 +62,16 @@ describe('WorkUnit closedAt(#327 归档计龄锚点)', () => { expect(snap.closedAt).toBeNull(); }); - it('closeWorkUnitWithNotice 统一关闭出口写 closedAt(= completedAt)', async () => { + it('close() 系统侧关闭单口写 closedAt(= completedAt)', async () => { const wu = await wuService.create({ scope: '死信任务', type: 'task', status: 'active', assigneeId: 'inst-1' }); await wuService.transitionStatus(wu.id, 'blocked'); - const snapshot = await snapshotOf(wu.id); - const ok = await closeWorkUnitWithNotice(fileStore, snapshot, { + const closed = await wuService.close(wu.id, { reason: 'blocked 超 24h 无人工介入,自动关闭', closedBy: 'auto-abandon-stale-blocked', }); - expect(ok).toBe(true); + expect(closed.status).toBe('closed'); const snap = await snapshotOf(wu.id); expect(snap.status).toBe('closed'); expect(snap.closedAt).toBe(snap.completedAt); diff --git a/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts index aedb1ecd0..a279a5fd0 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts @@ -527,6 +527,46 @@ describe('update', () => { }); }); +// ── updateMetadata 增量合并(#554,ADR 2026-09-15 决策 1/6 语义口)── + +describe('updateMetadata', () => { + it('patch 浅并入既有 metadata:既有键保留、同名键被 patch 覆盖,返回更新后 WorkUnitData', async () => { + const wu = await service.create({ + scope: 'metadata 增量', metadata: { priority: 'low', blockReason: 'stuck' }, + }); + await new Promise(r => setTimeout(r, 10)); + + const updated = await service.updateMetadata(wu.id, { priority: 'high', resumeAfterRetry: true }); + + expect(updated).not.toBeNull(); + expect(JSON.parse(updated!.metadata!)).toEqual({ + priority: 'high', // 同名键被 patch 覆盖 + blockReason: 'stuck', // 既有键保留(不整写覆盖) + resumeAfterRetry: true, // 新键并入 + }); + // 索引落盘值一致 + 事件流追加 updated 事件 + updatedAt 前进 + const snap = (await findSnapshot(wu.id))!; + expect(JSON.parse(snap.metadata!)).toEqual({ priority: 'high', blockReason: 'stuck', resumeAfterRetry: true }); + expect(readEvents().some(e => e.type === 'updated' && e.wuId === wu.id)).toBe(true); + expect(updated!.updatedAt.getTime()).toBeGreaterThan(wu.createdAt.getTime()); + }); + + it('metadata 为 null / 损坏 JSON 时按 {} 起评并入(FileStore 原语口径透传)', async () => { + const wu = await service.create({ scope: '空 metadata' }); + + const updated = await service.updateMetadata(wu.id, { tag: 'x' }); + + expect(JSON.parse(updated!.metadata!)).toEqual({ tag: 'x' }); + }); + + it('WorkUnit 不存在 → 返回 null(不抛错、不产生事件)', async () => { + const result = await service.updateMetadata('wu-missing', { tag: 'x' }); + + expect(result).toBeNull(); + expect(readEvents().filter(e => e.wuId === 'wu-missing')).toHaveLength(0); + }); +}); + // ── delete ── describe('delete', () => { @@ -544,6 +584,41 @@ describe('delete', () => { const closedEvt = events.find(e => e.type === 'closed' && e.wuId === wu.id); expect(closedEvt).toBeDefined(); }); + + // #538(ADR 2026-09-15 决策 3/4/5):delete 加 reason + 墓碑单点构造 + 发 workunit:removed + it('reason 落墓碑事件行 + 发布 workunit:removed(负载 id + channelId)', async () => { + const removed: Array<{ id: string; channelId: string | null }> = []; + const removedHandler = (payload: { id: string; channelId: string | null }) => { removed.push(payload); }; + eventBus.subscribe('workunit:removed', removedHandler); + try { + const wu = await service.create({ scope: 'TTL 待删', channelId: 'ch-1' }); + + await service.delete(wu.id, { reason: 'TTL: WorkUnit older than 90 days' }); + + const tombstone = readEvents().find(e => e.type === 'closed' && e.wuId === wu.id); + expect(tombstone?.data).toMatchObject({ deleted: true, reason: 'TTL: WorkUnit older than 90 days' }); + expect(removed).toEqual([{ id: wu.id, channelId: 'ch-1' }]); + } finally { + eventBus.unsubscribe('workunit:removed', removedHandler); + } + }); + + it('缺省 reason:墓碑仅 deleted:true,workunit:removed 仍发(channelId 缺省 null 透传)', async () => { + const removed: Array<{ id: string; channelId: string | null }> = []; + const removedHandler = (payload: { id: string; channelId: string | null }) => { removed.push(payload); }; + eventBus.subscribe('workunit:removed', removedHandler); + try { + const wu = await service.create({ scope: '无原因删除' }); + + await service.delete(wu.id); + + const tombstone = readEvents().find(e => e.type === 'closed' && e.wuId === wu.id); + expect(tombstone?.data).toEqual({ deleted: true }); + expect(removed).toEqual([{ id: wu.id, channelId: null }]); + } finally { + eventBus.unsubscribe('workunit:removed', removedHandler); + } + }); }); // ── claim / unclaim(#178:固定 5min 租约 timeoutAt)── diff --git a/apps/api/src/modules/workunit/__tests__/workunit-evidence.routes.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-evidence.routes.test.ts index 9bdfd3782..fc909bde1 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-evidence.routes.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-evidence.routes.test.ts @@ -1,35 +1,23 @@ // F6-c 证据断链修复路由契约测试: -// - POST /:id/verify(断点 2):human-only;404/400/409/422 守卫;全绿落 l1 approved + verifyReport, -// 失败落 l1 rejected;只动台账不动状态;by=登录用户名(STUDIO_AUTH=none → Local User) +// - POST /:id/verify(断点 2):human-only;service.verifyManually 判别联合 kind → +// 404/400/409/422/200 响应映射(#551 业务下沉后,本层只测 kind→HTTP 翻译; +// 守卫链/台账落写本身由 workunit-api.test.ts 的 verifyManually 直测覆盖) // - POST /:id/dispatch-review(断点 3):human-only;404/400/409 守卫;成功返回 { reviewWorkUnitId } -// WorkUnitService / wu-verification / review-dispatcher 均 mock(router 模块级单例会指向真实 ~/.studio/data),只测路由层契约。 +// WorkUnitService / review-dispatcher 均 mock(router 模块级单例会指向真实 ~/.studio/data),只测路由层契约。 import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import express from 'express'; import type { Server } from 'node:http'; -const { mockGetById, mockRecordL1 } = vi.hoisted(() => ({ - mockGetById: vi.fn(), - mockRecordL1: vi.fn(), +const { mockVerifyManually } = vi.hoisted(() => ({ + mockVerifyManually: vi.fn(), })); vi.mock('../workunit.service.js', () => ({ WorkUnitService: class { - getById = mockGetById; - recordL1Verification = mockRecordL1; + verifyManually = mockVerifyManually; }, })); -const { mockResolveVerifyCommands, mockRunWuVerification } = vi.hoisted(() => ({ - mockResolveVerifyCommands: vi.fn(), - mockRunWuVerification: vi.fn(), -})); - -vi.mock('../../agents/loop/wu-verification.js', () => ({ - CODE_WORKTREE_TYPES: new Set(['task', 'bug', 'feature', 'refactor']), - resolveVerifyCommands: mockResolveVerifyCommands, - runWuVerification: mockRunWuVerification, -})); - const { mockDispatchReviewNow } = vi.hoisted(() => ({ mockDispatchReviewNow: vi.fn(), })); @@ -40,13 +28,6 @@ vi.mock('../../agents/loop/review-dispatcher.js', () => ({ import router from '../workunit.routes.js'; -const codeWu = (metadata: Record = {}) => ({ - id: 'wu-1', - type: 'task', - status: 'in_review', - metadata: JSON.stringify({ worktreePath: '/tmp/wt-1', ...metadata }), -}); - describe('F6-c 证据断链修复路由(/verify + /dispatch-review)', () => { let server: Server; let base: string; @@ -79,109 +60,98 @@ describe('F6-c 证据断链修复路由(/verify + /dispatch-review)', () => }); } - // ─── POST /:id/verify ─── + // ─── POST /:id/verify(#551:kind → HTTP 翻译) ─── - it('verify:agent 身份 → 403,service/验证均未调用', async () => { + it('verify:agent 身份 → 403,service 未调用', async () => { const res = await post('/wu-1/verify', { body: { authorType: 'agent' } }); expect(res.status).toBe(403); const json = await res.json() as { error: { code: string } }; expect(json.error.code).toBe('FORBIDDEN'); - expect(mockGetById).not.toHaveBeenCalled(); - expect(mockRunWuVerification).not.toHaveBeenCalled(); + expect(mockVerifyManually).not.toHaveBeenCalled(); }); - it('verify:WU 不存在 → 404', async () => { - mockGetById.mockResolvedValue(null); + it('verify:not-found → 404 NOT_FOUND', async () => { + mockVerifyManually.mockResolvedValue({ kind: 'not-found' }); const res = await post('/wu-x/verify'); expect(res.status).toBe(404); - const json = await res.json() as { error: { code: string } }; + const json = await res.json() as { error: { code: string; message: string } }; expect(json.error.code).toBe('NOT_FOUND'); + expect(json.error.message).toBe('WorkUnit wu-x not found'); }); - it('verify:非代码类 WU(review)→ 400', async () => { - mockGetById.mockResolvedValue({ ...codeWu(), type: 'review' }); + it('verify:not-code-type → 400 INVALID_INPUT(message 带 wuType)', async () => { + mockVerifyManually.mockResolvedValue({ kind: 'not-code-type', wuType: 'review' }); const res = await post('/wu-1/verify'); expect(res.status).toBe(400); const json = await res.json() as { error: { code: string; message: string } }; expect(json.error.code).toBe('INVALID_INPUT'); - expect(mockRunWuVerification).not.toHaveBeenCalled(); + expect(json.error.message).toContain('type=review'); }); - it('verify:无 worktree 落档 → 409', async () => { - mockGetById.mockResolvedValue({ id: 'wu-1', type: 'task', status: 'in_review', metadata: '{}' }); + it('verify:no-worktree → 409 NO_WORKTREE', async () => { + mockVerifyManually.mockResolvedValue({ kind: 'no-worktree' }); const res = await post('/wu-1/verify'); expect(res.status).toBe(409); const json = await res.json() as { error: { code: string } }; expect(json.error.code).toBe('NO_WORKTREE'); - expect(mockRunWuVerification).not.toHaveBeenCalled(); }); - it('verify:无可跑命令 → 422 { verified:false, reason:no-commands, hint }', async () => { - mockGetById.mockResolvedValue(codeWu()); - mockResolveVerifyCommands.mockResolvedValue({ commands: [], source: 'convention' }); + it('verify:no-commands → 422 { verified:false, reason:no-commands, hint }', async () => { + mockVerifyManually.mockResolvedValue({ kind: 'no-commands' }); const res = await post('/wu-1/verify'); expect(res.status).toBe(422); const json = await res.json() as { verified: boolean; reason: string; hint: string }; expect(json.verified).toBe(false); expect(json.reason).toBe('no-commands'); expect(json.hint).toContain('verifyCommands'); - expect(mockRunWuVerification).not.toHaveBeenCalled(); - expect(mockRecordL1).not.toHaveBeenCalled(); }); - it('verify:全绿 → 200 { verified:true, report },落 l1 approved(by=登录用户名,kind=verify)', async () => { - mockGetById.mockResolvedValue(codeWu()); - mockResolveVerifyCommands.mockResolvedValue({ commands: ['pnpm run test'], source: 'convention' }); - mockRunWuVerification.mockResolvedValue({ ran: ['pnpm run test'], source: 'convention' }); - const verifyReport = { commands: ['pnpm run test'], source: 'convention', passedAt: '2026-07-30T00:00:00Z' }; - mockRecordL1.mockResolvedValue({ id: 'wu-1', metadata: JSON.stringify({ verifyReport }) }); + it('verify:failed → 200 { verified:false, failed:[{command,tail}] }', async () => { + mockVerifyManually.mockResolvedValue({ kind: 'failed', failure: { command: 'make check', tail: 'boom' } }); + const res = await post('/wu-1/verify'); + expect(res.status).toBe(200); + const json = await res.json() as { verified: boolean; failed: Array<{ command: string; tail: string }> }; + expect(json.verified).toBe(false); + expect(json.failed).toEqual([{ command: 'make check', tail: 'boom' }]); + }); + it('verify:verified → 200 { verified:true, report }', async () => { + const report = { commands: ['pnpm run test'], source: 'convention', passedAt: '2026-07-30T00:00:00Z' }; + mockVerifyManually.mockResolvedValue({ kind: 'verified', report }); const res = await post('/wu-1/verify'); expect(res.status).toBe(200); const json = await res.json() as { verified: boolean; report: { commands: string[] } }; expect(json.verified).toBe(true); expect(json.report.commands).toEqual(['pnpm run test']); - expect(mockRecordL1).toHaveBeenCalledWith('wu-1', { - by: 'Local User', // STUDIO_AUTH=none 本地模式回落 - ran: ['pnpm run test'], - source: 'convention', - failure: undefined, - }); }); - it('verify:有失败 → 200 { verified:false, failed:[{command,tail}] },落 l1 rejected', async () => { - mockGetById.mockResolvedValue(codeWu()); - mockResolveVerifyCommands.mockResolvedValue({ commands: ['make check'], source: 'override' }); - mockRunWuVerification.mockResolvedValue({ - ran: [], - source: 'override', - failure: { command: 'make check', tail: 'boom' }, - }); - mockRecordL1.mockResolvedValue({ id: 'wu-1', metadata: '{}' }); - - const res = await post('/wu-1/verify'); + it('verify:body.commands 过滤透传 + by=登录用户名(STUDIO_AUTH=none → Local User)', async () => { + mockVerifyManually.mockResolvedValue({ kind: 'verified', report: {} }); + const res = await post('/wu-1/verify', { body: { commands: ['./ci.sh', ' ', 42] } }); expect(res.status).toBe(200); - const json = await res.json() as { verified: boolean; failed: Array<{ command: string; tail: string }> }; - expect(json.verified).toBe(false); - expect(json.failed).toEqual([{ command: 'make check', tail: 'boom' }]); - expect(mockRecordL1).toHaveBeenCalledWith('wu-1', expect.objectContaining({ - failure: { command: 'make check', tail: 'boom' }, - })); + expect(mockVerifyManually).toHaveBeenCalledWith('wu-1', { + by: 'Local User', + commands: ['./ci.sh'], + }); }); - it('verify:body.commands 传入时视为 metadata.verifyCommands 覆盖', async () => { - mockGetById.mockResolvedValue(codeWu({ verifyCommands: ['old cmd'] })); - mockResolveVerifyCommands.mockResolvedValue({ commands: ['./ci.sh'], source: 'override' }); - mockRunWuVerification.mockResolvedValue({ ran: ['./ci.sh'], source: 'override' }); - mockRecordL1.mockResolvedValue({ id: 'wu-1', metadata: '{}' }); + it('verify:body.commands 缺省/空数组 → 不传 commands 键(不落覆盖语义)', async () => { + mockVerifyManually.mockResolvedValue({ kind: 'no-commands' }); + await post('/wu-1/verify'); + expect(mockVerifyManually).toHaveBeenCalledWith('wu-1', { by: 'Local User' }); - const res = await post('/wu-1/verify', { body: { commands: ['./ci.sh'] } }); - expect(res.status).toBe(200); - expect(mockRunWuVerification).toHaveBeenCalledWith( - expect.objectContaining({ id: 'wu-1' }), - expect.objectContaining({ verifyCommands: ['./ci.sh'] }), - '/tmp/wt-1', - ); + mockVerifyManually.mockClear(); + await post('/wu-1/verify', { body: { commands: [] } }); + expect(mockVerifyManually).toHaveBeenCalledWith('wu-1', { by: 'Local User' }); + }); + + it('verify:service 抛错 → 500 INTERNAL_ERROR', async () => { + mockVerifyManually.mockRejectedValue(new Error('disk gone')); + const res = await post('/wu-1/verify'); + expect(res.status).toBe(500); + const json = await res.json() as { error: { code: string; message: string } }; + expect(json.error.code).toBe('INTERNAL_ERROR'); + expect(json.error.message).toBe('disk gone'); }); // ─── POST /:id/dispatch-review ─── diff --git a/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts index 0cbcc221b..476b0983b 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts @@ -1,7 +1,7 @@ // 2026-07 PMO-flow UX(§4 terminate 语义修正):WorkUnitService.blockForManualRelease // 覆盖:active/unassigned → blocked(assigneeId/claimedAt 清空 + manualRelease 留痕 + 既有 metadata 保留)、 // 终态(done/closed)不动、WU 不存在抛错。 -// 模式同 workunit.service.test.ts:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 +// 模式同 workunit-api.test.ts:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/apps/api/src/modules/workunit/__tests__/workunit-migration-tail.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-migration-tail.test.ts new file mode 100644 index 000000000..10e798717 --- /dev/null +++ b/apps/api/src/modules/workunit/__tests__/workunit-migration-tail.test.ts @@ -0,0 +1,150 @@ +// #550:迁移类写路径统一落库尾部(persistSnapshot)的不变式单点断言—— +// 「校验 → 快照 → 事件 → 父聚合」尾部对全部迁移路径生效,新增迁移入口不再需要抄尾巴。 +// 覆盖不变式:状态落锚(closedAt/completedAt/blockedAt)、workunit.status_changed 发布、 +// 父状态聚合、reopen(closed → unassigned)closedAt 清除 + thaw。 +// 约定:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { FileStore, eventBus, type WorkUnitSnapshot } from '@dommaker/studio-shared'; +import { WorkUnitService, type WorkUnitData } from '../workunit.service.js'; +import { parseWuMetadata } from '../wu-metadata.js'; + +let tmpDir: string; +let fileStore: FileStore; +let wuService: WorkUnitService; +let statusEvents: WorkUnitData[]; +let statusHandler: (payload: { workunit: WorkUnitData }) => void; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wu-migration-tail-')); + process.env.STUDIO_EVENTS_FILE = path.join(tmpDir, 'studio-events.jsonl'); + fileStore = new FileStore(tmpDir); + wuService = new WorkUnitService(fileStore); + statusEvents = []; + statusHandler = (payload) => { statusEvents.push(payload.workunit); }; + eventBus.subscribe('workunit.status_changed', statusHandler); +}); + +afterEach(() => { + eventBus.unsubscribe('workunit.status_changed', statusHandler); + delete process.env.STUDIO_EVENTS_FILE; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function snapshotOf(id: string): Promise { + return (await fileStore.getIndex()).find(s => s.id === id)!; +} + +const flushAsync = () => new Promise(r => setTimeout(r, 50)); + +/** 关闭路径不变式:closedAt = completedAt 同刻落锚 + status_changed(closed) 已广播 */ +async function expectClosedInvariants(wuId: string) { + const snap = await snapshotOf(wuId); + expect(snap.status).toBe('closed'); + expect(typeof snap.closedAt).toBe('string'); + expect(snap.closedAt).toBe(snap.completedAt); + expect(statusEvents.some(e => e.id === wuId && e.status === 'closed')).toBe(true); +} + +/** blocked 路径不变式:metadata.blockedAt 死信计时锚落档 + status_changed(blocked) 已广播 */ +async function expectBlockedInvariants(wuId: string) { + const snap = await snapshotOf(wuId); + expect(snap.status).toBe('blocked'); + expect(typeof parseWuMetadata(snap.metadata).blockedAt).toBe('string'); + expect(statusEvents.some(e => e.id === wuId && e.status === 'blocked')).toBe(true); +} + +describe('迁移类写路径统一尾部(#550):不变式一处断言,全路径生效', () => { + it('transitionStatus → closed', async () => { + const wu = await wuService.create({ scope: '手工关闭', type: 'task', status: 'active' }); + statusEvents.length = 0; + await wuService.transitionStatus(wu.id, 'closed'); + await expectClosedInvariants(wu.id); + }); + + it('close() → closed', async () => { + const wu = await wuService.create({ scope: '系统关闭', type: 'task', status: 'active' }); + statusEvents.length = 0; + await wuService.close(wu.id, { reason: '执行超过 2.5h', closedBy: 'total-time-kill' }); + await expectClosedInvariants(wu.id); + }); + + it('transitionStatus → blocked:blockedAt 落锚', async () => { + const wu = await wuService.create({ scope: '挂起', type: 'task', status: 'active' }); + statusEvents.length = 0; + await wuService.transitionStatus(wu.id, 'blocked'); + await expectBlockedInvariants(wu.id); + }); + + it('markMergeConflict → blocked:blockedAt 落锚 + 冲突 metadata 保留', async () => { + const wu = await wuService.create({ scope: '合并冲突', type: 'task', status: 'active' }); + statusEvents.length = 0; + await wuService.markMergeConflict(wu.id, ['a.ts', 'b.ts']); + await expectBlockedInvariants(wu.id); + const meta = parseWuMetadata((await snapshotOf(wu.id)).metadata); + expect(meta.mergeConflict).toBe(true); + expect(meta.conflictFiles).toEqual(['a.ts', 'b.ts']); + }); + + it('blockForManualRelease → blocked:blockedAt 落锚 + assigneeId/claimedAt 清空 + manualRelease 留痕', async () => { + const wu = await wuService.create({ scope: '转人工', type: 'task', status: 'unassigned' }); + await wuService.claim(wu.id, 'inst-1'); + statusEvents.length = 0; + await wuService.blockForManualRelease(wu.id, 'terminate instance inst-1'); + await expectBlockedInvariants(wu.id); + const snap = await snapshotOf(wu.id); + expect(snap.assigneeId).toBeNull(); + expect(snap.claimedAt).toBeNull(); + expect(parseWuMetadata(snap.metadata).manualRelease).toBe(true); + }); + + it('blockForAllUnfit → blocked:blockedAt 落锚', async () => { + const wu = await wuService.create({ scope: '全员不适任', type: 'task', status: 'unassigned' }); + statusEvents.length = 0; + await wuService.blockForAllUnfit(wu.id, 'all-unfit: 频道成员均不适任'); + await expectBlockedInvariants(wu.id); + }); + + it('reviewRejected 连续 3 次 → blocked:blockedAt 落锚', async () => { + const wu = await wuService.create({ scope: '连续打回', type: 'task', status: 'active' }); + for (let i = 0; i < 3; i++) { + await wuService.transitionStatus(wu.id, 'in_review'); + statusEvents.length = 0; + await wuService.reviewRejected(wu.id, `第 ${i + 1} 次打回`); + } + await expectBlockedInvariants(wu.id); + }); + + it('父聚合对全部迁移路径生效(fire-and-forget,同 transitionStatus 先例)', async () => { + // close() 路径:全子 closed → 父 closed + const parentA = await wuService.create({ scope: '父A', type: 'task', status: 'active' }); + const childA = await wuService.create({ scope: '子A', type: 'task', status: 'active', parentId: parentA.id }); + await wuService.close(childA.id, { reason: '系统关闭', closedBy: 'total-time-kill' }); + await flushAsync(); + expect((await snapshotOf(parentA.id)).status).toBe('closed'); + + // blocked 路径(blockForManualRelease 代表语义方法):有子 blocked → 父 blocked + const parentB = await wuService.create({ scope: '父B', type: 'task', status: 'unassigned' }); + const childB = await wuService.create({ scope: '子B', type: 'task', status: 'unassigned', parentId: parentB.id }); + await wuService.blockForManualRelease(childB.id, 'terminate'); + await flushAsync(); + expect((await snapshotOf(parentB.id)).status).toBe('blocked'); + }); + + it('reopen(closed → unassigned):closedAt 清除 + thaw 解冻触发', async () => { + const thawSpy = vi.spyOn(fileStore, 'thawWorkUnitMessages'); + const wu = await wuService.create({ scope: '重开', type: 'task', status: 'active' }); + await wuService.transitionStatus(wu.id, 'closed'); + expect((await snapshotOf(wu.id)).closedAt).toBeTruthy(); + + await wuService.transitionStatus(wu.id, 'unassigned'); + + const snap = await snapshotOf(wu.id); + expect(snap.status).toBe('unassigned'); + expect(snap.closedAt).toBeNull(); + expect(thawSpy).toHaveBeenCalledWith(wu.id); + thawSpy.mockRestore(); + }); +}); diff --git a/apps/api/src/modules/workunit/__tests__/workunit.routes.test.ts b/apps/api/src/modules/workunit/__tests__/workunit.routes.test.ts deleted file mode 100644 index cf7d868d2..000000000 --- a/apps/api/src/modules/workunit/__tests__/workunit.routes.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, it } from 'vitest'; - -describe('workunit routes', () => { - it.skip('placeholder — see workunit-api.test.ts for coverage', () => {}); -}); diff --git a/apps/api/src/modules/workunit/__tests__/workunit.service.test.ts b/apps/api/src/modules/workunit/__tests__/workunit.service.test.ts deleted file mode 100644 index 66c128abc..000000000 --- a/apps/api/src/modules/workunit/__tests__/workunit.service.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, it } from 'vitest'; - -describe('workunit service', () => { - it.skip('placeholder — see workunit-api.test.ts for coverage', () => {}); -}); diff --git a/apps/api/src/modules/workunit/__tests__/wu-closure.test.ts b/apps/api/src/modules/workunit/__tests__/wu-closure.test.ts deleted file mode 100644 index 7eb3997b2..000000000 --- a/apps/api/src/modules/workunit/__tests__/wu-closure.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -// #176(决策 #62 §3 双出声原则):closeWorkUnitWithNotice —— 系统推 WU 向终态(closed) -// 的统一出口测试:closed 快照 + workunit:closed 结构化事件 + 频道说明,三件套缺一不可。 -// 约定与 waiting-input.test.ts 一致:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { v4 as uuidv4 } from 'uuid'; -import { FileStore, type ChannelMessageData } from '@dommaker/studio-shared'; -import { WorkUnitService } from '../workunit.service.js'; -import { closeWorkUnitWithNotice } from '../wu-closure.js'; -import { buildDeadLetterNotice } from '../blocked-cta.js'; - -let tmpDir: string; -let fileStore: FileStore; -let wuService: WorkUnitService; -let channelId: string; -let studioEventsFile: string; - -beforeEach(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wu-closure-test-')); - studioEventsFile = path.join(tmpDir, 'studio-events.jsonl'); - process.env.STUDIO_EVENTS_FILE = studioEventsFile; - fileStore = new FileStore(tmpDir); - wuService = new WorkUnitService(fileStore); - channelId = `ch-closure-${Date.now()}`; - await fileStore.createChannel({ - id: channelId, name: '#closure-test', type: 'rnd', - defaultWorkspaceId: null, defaultPath: null, - discordChannelId: null, discordWebhookUrl: null, members: '[]', - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }); -}); - -afterEach(() => { - delete process.env.STUDIO_EVENTS_FILE; - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -async function createBlockedWu() { - const wu = await wuService.create({ - scope: '死信任务', channelId, type: 'task', status: 'active', assigneeId: 'inst-1', - metadata: { title: '死信任务' }, - }); - await wuService.transitionStatus(wu.id, 'blocked'); - await wuService.update(wu.id, { - metadata: { title: '死信任务', blockReason: 'stuck: 连续 3 步无进展', blockedAt: new Date().toISOString() }, - }); - const anchor: ChannelMessageData = { - id: uuidv4(), channelId, authorType: 'human', agentName: null, - content: '@agent 死信任务', replyToId: null, meta: '{}', - workUnitId: wu.id, createdAt: new Date().toISOString(), - }; - await fileStore.appendMessage(channelId, anchor); - const snapshot = (await fileStore.getIndex()).find(s => s.id === wu.id)!; - return { wu, snapshot, anchor }; -} - -function readStudioEvents(): Array<{ type: string; payload: string; level?: string }> { - if (!fs.existsSync(studioEventsFile)) return []; - return fs.readFileSync(studioEventsFile, 'utf8') - .split('\n').filter(Boolean) - .map(line => JSON.parse(line)); -} - -describe('closeWorkUnitWithNotice(双出声统一出口)', () => { - it('closed 快照 + workunit:closed 事件(含 reason/closedBy/blockedAt)+ 频道死信通知', async () => { - const { wu, snapshot, anchor } = await createBlockedWu(); - const message = buildDeadLetterNotice('死信任务', 'stuck: 连续 3 步无进展'); - - const ok = await closeWorkUnitWithNotice(fileStore, snapshot, { - reason: 'blocked 超 24h 无人工介入,自动关闭', - closedBy: 'auto-abandon-stale-blocked', - message, - }); - - expect(ok).toBe(true); - // 1) 快照:status=closed + completedAt - const after = (await fileStore.getIndex()).find(s => s.id === wu.id)!; - expect(after.status).toBe('closed'); - expect(after.completedAt).toBeTruthy(); - // 2) 结构化事件:type/level/payload 三要素 - const events = readStudioEvents() - .filter(e => e.type === 'workunit:closed') - .map(e => ({ ...e, payload: JSON.parse(e.payload) })); - expect(events).toHaveLength(1); - expect(events[0].level).toBe('warning'); - expect(events[0].payload).toMatchObject({ - workUnitId: wu.id, - reason: 'blocked 超 24h 无人工介入,自动关闭', - closedBy: 'auto-abandon-stale-blocked', - }); - expect(typeof events[0].payload.blockedAt).toBe('string'); - // 3) 频道出声:死信模板(已关闭 + 后续出路),挂在 anchor 线程 - const messages = await fileStore.queryMessages(channelId, { workUnitId: wu.id }); - const notice = messages.find(m => m.content.includes('已自动关闭')); - expect(notice).toBeDefined(); - expect(notice!.content).toContain('如需继续请重新派发'); - expect(notice!.content).toContain('stuck: 连续 3 步无进展'); // 失败原因摘要 - expect(notice!.authorType).toBe('agent'); - expect(notice!.replyToId).toBe(anchor.id); - }); - - it('WU 无频道 → 快照 + 事件仍落,仅跳过频道出声', async () => { - const wu = await wuService.create({ scope: '无频道任务', type: 'task', status: 'active', assigneeId: 'inst-1' }); - await wuService.transitionStatus(wu.id, 'blocked'); - const snapshot = (await fileStore.getIndex()).find(s => s.id === wu.id)!; - - const ok = await closeWorkUnitWithNotice(fileStore, snapshot, { - reason: '执行超过 2.5h,系统强制关闭', - closedBy: 'total-time-kill', - }); - - expect(ok).toBe(true); - expect((await fileStore.getIndex()).find(s => s.id === wu.id)!.status).toBe('closed'); - const events = readStudioEvents().filter(e => e.type === 'workunit:closed'); - expect(events).toHaveLength(1); - }); -}); diff --git a/apps/api/src/modules/workunit/http-helpers.ts b/apps/api/src/modules/workunit/http-helpers.ts new file mode 100644 index 000000000..abb7c13e0 --- /dev/null +++ b/apps/api/src/modules/workunit/http-helpers.ts @@ -0,0 +1,157 @@ +/** + * #551:workunit 路由层 HTTP 助手——错误契约收口唯一正本。 + * + * 此前 20 个 handler 各自 try/catch + `msg.includes(...)` 映射状态码(service 改一句 + * 措辞就打穿全部端点)。现收口为一张映射表(WORKUNIT_ERROR_MAPS,覆盖全部端点)+ + * 唯一翻译出口(sendMappedError):handler 只抛错,文案 ↔ 状态码的对应关系只在表内。 + * + * 匹配器三态:字符串(message includes)/ RegExp / Error 类(instanceof)。 + * 路由内可预期的业务拒绝(404/409 前置守卫)直接抛 HttpRouteError,绕过查表。 + * + * 开放问题定夺(grilling 议题「service 抛带类别的错 vs 统一翻译层」,#551 守夜会话无人 + * 可 grilling):取统一翻译层——service 错误形态零改动、行为零漂移,文案↔状态码解耦 + * 由映射表单点承载;若日后 service 引入分类错误,表内类匹配器原位替换即可。 + */ + +import type { NextFunction, Request, Response } from 'express'; +import { getErrorMessage } from '../../utils/errors.js'; +import { ConfirmPayloadError } from './confirm-payload.js'; +import { PlanRulingError } from '../pmo/plan-ruling.js'; +import { PlanDirectionError } from '../pmo/plan-direction.js'; + +/** 路由内可预期的业务拒绝:自带 HTTP 语义,翻译层直出不查表 */ +export class HttpRouteError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export type ErrorMatcher = string | RegExp | (new (...args: never[]) => Error); + +export interface ErrorMapping { + match: ErrorMatcher; + status: number; + code: string; +} + +/** + * 全部端点的「错误文案/类别 → 状态码 + code」映射表(一张表,单测逐条覆盖)。 + * 表项按声明顺序首个命中生效——类匹配器须先于可能误吞的字符串项(对照原 handler + * 的 instanceof 优先判断)。空表 = 一律 500(错误一律意外,如 GET 只读端点)。 + */ +export const WORKUNIT_ERROR_MAPS = { + fromMessage: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + { match: 'already linked', status: 409, code: 'ALREADY_CONVERTED' }, + ], + update: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + ], + deleteWu: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + { match: 'Record to delete does not exist', status: 404, code: 'NOT_FOUND' }, + ], + opportunity: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + { match: 'already resolved', status: 409, code: 'INVALID_STATE' }, + { match: 'not an inspection', status: 409, code: 'INVALID_STATE' }, + ], + claim: [ + { match: 'Claim failed', status: 409, code: 'CLAIM_FAILED' }, + ], + unclaim: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + ], + reviewPassed: [ + { match: ConfirmPayloadError, status: 400, code: 'INVALID_CONFIRM' }, + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + { match: 'Cannot review', status: 400, code: 'INVALID_TRANSITION' }, + ], + reviewRejected: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + { match: 'Cannot review', status: 400, code: 'INVALID_TRANSITION' }, + ], + dispatchReview: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + { match: 'already', status: 409, code: 'ALREADY_SATISFIED' }, + { match: 'Cannot dispatch', status: 400, code: 'INVALID_INPUT' }, + { match: 'not reviewable', status: 400, code: 'INVALID_INPUT' }, + { match: 'no channel', status: 400, code: 'INVALID_INPUT' }, + ], + status: [ + { match: 'Invalid status transition', status: 400, code: 'INVALID_TRANSITION' }, + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + ], + ruling: [ + { match: PlanRulingError, status: 400, code: 'INVALID_RULING' }, + ], + direction: [ + { match: PlanDirectionError, status: 400, code: 'INVALID_DIRECTION' }, + ], + editMessage: [ + { match: 'not found', status: 404, code: 'NOT_FOUND' }, + ], +} as const satisfies Record; + +/** 唯一翻译出口:HttpRouteError 直出 → 表项首命中 → 500 兜底 */ +export function sendMappedError(res: Response, error: unknown, mappings: readonly ErrorMapping[]): void { + if (error instanceof HttpRouteError) { + res.status(error.status).json({ error: { code: error.code, message: error.message } }); + return; + } + const msg = getErrorMessage(error); + for (const m of mappings) { + const hit = typeof m.match === 'string' + ? msg.includes(m.match) + : m.match instanceof RegExp + ? m.match.test(msg) + : error instanceof m.match; + if (hit) { + res.status(m.status).json({ error: { code: m.code, message: msg } }); + return; + } + } + res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: msg } }); +} + +/** handler 包装器:业务写快乐路径并抛错,catch 全部经 sendMappedError 一处翻译 */ +export function route( + mappings: readonly ErrorMapping[], + handler: (req: Request, res: Response) => Promise, +) { + return async (req: Request, res: Response): Promise => { + try { + await handler(req, res); + } catch (error) { + sendMappedError(res, error, mappings); + } + }; +} + +/** + * A2A §4.4: 调用方 authorType 识别(body.authorType 优先,其次 x-author-type header)。 + * 与讨论空间发帖的 authorType 字段同约定;UI/人类调用不发送该字段 → 'human'。 + */ +export function resolveCallerAuthorType(req: Request): string { + const fromBody = typeof req.body?.authorType === 'string' ? req.body.authorType : undefined; + const fromHeader = req.headers['x-author-type']; + return fromBody ?? (typeof fromHeader === 'string' ? fromHeader : 'human'); +} + +/** + * A2A §4.4-2 / §8-Q3 human-only 守卫(#551:4 处复制收敛为中间件)。 + * agent 身份调用一律 403(验收权只在人);message 按端点动作定制(沿用原文案)。 + */ +export function requireHuman(message: string) { + return (req: Request, res: Response, next: NextFunction): void => { + if (resolveCallerAuthorType(req) === 'agent') { + res.status(403).json({ error: { code: 'FORBIDDEN', message } }); + return; + } + next(); + }; +} diff --git a/apps/api/src/modules/workunit/merge-on-review-pass.ts b/apps/api/src/modules/workunit/merge-on-review-pass.ts index 7d2974137..67f785dfa 100644 --- a/apps/api/src/modules/workunit/merge-on-review-pass.ts +++ b/apps/api/src/modules/workunit/merge-on-review-pass.ts @@ -15,12 +15,17 @@ * 流程: * 0. 数据防丢闸:worktree 有未提交改动(或 git status 失败)→ 不合并不强删, * WU 置 blocked + 频道列清单转人工(worktree/分支保留) - * 1. git merge --no-ff task/(baseRepo) - * 2. 失败 → merge --abort,重试一次:先在 worktree 把 task 分支 rebase 到 - * baseBranch,成功则回 baseRepo 再 merge - * 3. 仍失败 → 清理 rebase/merge 现场,取冲突文件清单(diff-filter=U), - * 频道发 Studio 系统消息转人工,WU 置 blocked(metadata.mergeConflict/conflictFiles) - * 4. 成功 → 移除 worktree、删除已合并 task 分支、metadata 记 mergedAt/mergeCommit, + * 1. git merge --no-ff task/(baseRepo 或 PMO 集成交合) + * 2. 失败 → abort 首次 merge 现场,重试一次:先在 worktree 把 task 分支 rebase 到 + * baseBranch,成功则回合并目标再 merge + * 3. 仍失败 → **保留冲突现场**(不再先 abort),spawn 一次性 LLM 会话解冲突 + * (复用 system-executor,skill = resolving-merge-conflicts;prompt 带 mergeContext + * + verify 命令来源说明);LLM 失败/超时/解完仍有残留冲突 → 回退现有 abort + + * markMergeConflict 转人工(兜底不变)。审计落 metadata.mergeResolution + * 4. 合并成功(含 LLM 解完)→ **落 mergedAt 前在合并目标目录重跑 verify** + * (runWuVerification;无 verify 命令可解析时放行,与执行环节同策略); + * 红 → 转人工(markMergeConflict 同款 blocked 路径,blockReason 前缀 merge-verify-failed) + * 5. 全绿 → 移除 worktree、删除已合并 task 分支、metadata 记 mergedAt/mergeCommit, * 频道发一条简短系统消息 * * 防重:metadata.mergedAt 存在即跳过(人工重复触发 / 事件重放均不再合并)。 @@ -38,7 +43,7 @@ import { execSh } from '@dommaker/studio-shared/node'; import { ensurePmoIntegrationWorktree } from '@dommaker/studio-agent'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { WorkUnitService, WorkUnitData } from './workunit.service.js'; +import type { WorkUnitService, WorkUnitData, WorkUnitMetadata } from './workunit.service.js'; import { postWuSystemMessage } from './wu-messenger.js'; import { parseWuMetadata } from './wu-metadata.js'; import { getErrorMessage } from '../../utils/errors.js'; @@ -56,7 +61,19 @@ const GIT_OP_TIMEOUT_MS = 15_000; export type MergeOnReviewPassOutcome = | { attempted: false; reason: 'no-worktree' | 'already-merged' | 'analysis-bypass' } | { attempted: true; merged: true; mergeCommit: string } - | { attempted: true; merged: false; conflictFiles: string[]; reason?: 'conflict' | 'uncommitted-changes' }; + | { attempted: true; merged: false; conflictFiles: string[]; reason?: 'conflict' | 'uncommitted-changes' | 'merge-verify-failed' }; + +/** 冲突现场(保留给 LLM 解,不再先 abort):merge = 合并目标目录;rebase = worktree */ +interface ConflictScene { + cwd: string; + state: 'merge' | 'rebase'; +} + +/** LLM 解冲突审计(落 metadata.mergeResolution;类型正本在 workunit.types.ts) */ +export type MergeResolutionAudit = NonNullable; + +/** LLM 输出摘要截断(mergeResolution.summary) */ +const MERGE_LLM_SUMMARY_CHARS = 500; /** shell 单引号转义(branch/路径/提交信息统一过它进 bash -c) */ function shq(s: string): string { @@ -237,20 +254,41 @@ export async function mergeWorktreeBranchOnReviewPass( const merged = await tryMergeWithRebaseRetry(mergeContext.cmd, mergeContext.cwd, mergeContext.targetBranch, branch, worktreePath); // 注:本包 tsconfig 未开 strict,真值判断不收窄可辨识联合,须用 === false 字面量比较 if (merged.ok === false) { - // 转人工:WU 置 blocked + 频道系统消息(冲突文件清单) - await wuService.markMergeConflict(wu.id, merged.conflictFiles); - const fileList = merged.conflictFiles.length > 0 - ? `\n冲突文件:\n${merged.conflictFiles.map(f => `- ${f}`).join('\n')}` - : '(未能获取冲突文件清单)'; - await postSystemMessage( - fileStore, + // 冲突现场已保留(tryMergeWithRebaseRetry 不再先 abort)→ spawn 一次性 LLM 会话解冲突; + // LLM 失败/超时/解完仍有残留冲突 → 回退现有 abort + markMergeConflict 转人工(兜底不变) + const { audit, resolved } = await resolveConflictSceneWithLlm({ wu, - `任务「${title}」自动合并到 ${mergeContext.targetBranch} 失败(重试后仍冲突),已转人工处理${fileList}`, - ).catch(err => logger.warn('[MergeOnReviewPass] post conflict message failed', { wuId: wu.id, error: String(err) })); - logger.warn('[MergeOnReviewPass] merge conflict escalated to human', { - wuId: wu.id, branch, targetBranch: mergeContext.targetBranch, conflictFiles: merged.conflictFiles, + meta, + scene: merged.scene, + conflictFiles: merged.conflictFiles, + mergeCmd: mergeContext.cmd, + mergeCwd: mergeContext.cwd, + sourceBranch: branch, + targetBranch: mergeContext.targetBranch, + scopeSummary, + }); + await recordMergeResolution(wuService, wu.id, audit); + if (!resolved) { + await abortMergeAndRebase(mergeContext.cwd, merged.scene.state === 'rebase' ? worktreePath : undefined); + // 转人工:WU 置 blocked + 频道系统消息(冲突文件清单) + await wuService.markMergeConflict(wu.id, merged.conflictFiles); + const fileList = merged.conflictFiles.length > 0 + ? `\n冲突文件:\n${merged.conflictFiles.map(f => `- ${f}`).join('\n')}` + : '(未能获取冲突文件清单)'; + await postSystemMessage( + fileStore, + wu, + `任务「${title}」自动合并到 ${mergeContext.targetBranch} 失败(重试后仍冲突),已转人工处理${fileList}`, + ).catch(err => logger.warn('[MergeOnReviewPass] post conflict message failed', { wuId: wu.id, error: String(err) })); + logger.warn('[MergeOnReviewPass] merge conflict escalated to human (LLM resolution failed)', { + wuId: wu.id, branch, targetBranch: mergeContext.targetBranch, conflictFiles: merged.conflictFiles, + llmError: audit.error, + }); + return { attempted: true, merged: false, conflictFiles: merged.conflictFiles }; + } + logger.info('[MergeOnReviewPass] conflict resolved by LLM', { + wuId: wu.id, state: merged.scene.state, durationMs: audit.durationMs, }); - return { attempted: true, merged: false, conflictFiles: merged.conflictFiles }; } // 合并成功:记录 mergeCommit(PMO-b:读集成交合 HEAD)→ 清理 worktree/分支 → 落档 metadata → 频道通知 @@ -264,6 +302,27 @@ export async function mergeWorktreeBranchOnReviewPass( logger.warn('[MergeOnReviewPass] rev-parse HEAD failed (mergeCommit 落空字符串)', { wuId: wu.id, error: String(err) }); } + // 合并后 verify(落 mergedAt 前):在合并目标目录重跑——原 worktree 下方即被 remove, + // 绝不能跑那边。无 verify 命令可解析时放行(与执行环节同策略,ran 为空即无命令)。 + // 红 → 转人工(markMergeConflict 同款 blocked 路径,blockReason 前缀 merge-verify-failed), + // 不删 worktree/分支、不落 mergedAt(合并 commit 已入目标分支,留人工处置)。 + const verify = await runMergeVerification(wu, meta, mergeContext.cwd); + if (verify.failure) { + await wuService.markMergeConflict(wu.id, [], { + blockReason: `merge-verify-failed: 合并后验证失败(${verify.failure.command})`.slice(0, 300), + }); + await postSystemMessage( + fileStore, + wu, + `任务「${title}」已合并到 ${mergeContext.targetBranch},但合并后验证失败(${verify.failure.command}),已转人工处理\n失败输出尾部:\n${verify.failure.tail.slice(0, 1000)}`, + ).catch(err => logger.warn('[MergeOnReviewPass] post verify-failed message failed', { wuId: wu.id, error: String(err) })); + logger.warn('[MergeOnReviewPass] post-merge verification failed, escalated to human', { + wuId: wu.id, branch, targetBranch: mergeContext.targetBranch, + failedCommand: verify.failure.command, ran: verify.ran, + }); + return { attempted: true, merged: false, conflictFiles: [], reason: 'merge-verify-failed' }; + } + if (worktreePath) { try { await execSh(`git -C ${shq(baseRepo)} worktree remove --force ${shq(worktreePath)}`, { @@ -300,47 +359,180 @@ export async function mergeWorktreeBranchOnReviewPass( return { attempted: true, merged: true, mergeCommit }; } -/** merge 一次;失败则 abort 后在 worktree rebase 到 baseBranch,再 merge 一次 */ +/** + * merge 一次;失败则 abort 首次现场后在 worktree rebase 到 baseBranch,再 merge 一次。 + * 终态失败**保留冲突现场**(不 abort)交 LLM 解——scene 指明现场目录与 merge/rebase 状态; + * 兜底清理由调用方在 LLM 解失败后做(abortMergeAndRebase)。 + */ async function tryMergeWithRebaseRetry( mergeCmd: string, - baseRepo: string, + mergeCwd: string, baseBranch: string, branch: string, worktreePath?: string, -): Promise<{ ok: true } | { ok: false; conflictFiles: string[] }> { +): Promise<{ ok: true } | { ok: false; conflictFiles: string[]; scene: ConflictScene }> { try { - await execSh(mergeCmd, { cwd: baseRepo, timeoutMs: MERGE_TIMEOUT_MS }); + await execSh(mergeCmd, { cwd: mergeCwd, timeoutMs: MERGE_TIMEOUT_MS }); return { ok: true }; } catch { /* 进入重试 */ } - // 清理首次 merge 现场;无 worktree 无法 rebase → 直接按冲突转人工 - await abortMergeAndRebase(baseRepo); + // 无 worktree 无法 rebase:保留 merge 冲突现场(合并目标目录)交 LLM 解 if (!worktreePath) { - const conflictFiles = await listConflictFiles(baseRepo, baseRepo); - return { ok: false, conflictFiles }; + const conflictFiles = await listConflictFiles(mergeCwd, mergeCwd); + return { ok: false, conflictFiles, scene: { cwd: mergeCwd, state: 'merge' } }; } + // 清理首次 merge 现场(rebase 重试的前提;终态失败现场在下方保留) + await abortMergeAndRebase(mergeCwd); try { await execSh(`git -C ${shq(worktreePath)} rebase ${shq(baseBranch)}`, { - cwd: baseRepo, timeoutMs: REBASE_TIMEOUT_MS, + cwd: mergeCwd, timeoutMs: REBASE_TIMEOUT_MS, }); } catch { - // rebase 冲突:先取清单(abort 后现场消失),再清理 - const conflictFiles = await listConflictFiles(worktreePath, baseRepo); - await abortMergeAndRebase(baseRepo, worktreePath); - logger.warn('[MergeOnReviewPass] rebase onto base failed', { branch, baseBranch, conflictFiles }); - return { ok: false, conflictFiles }; + // rebase 冲突:取清单后保留现场(worktree)交 LLM 解,不再 abort + const conflictFiles = await listConflictFiles(worktreePath, mergeCwd); + logger.warn('[MergeOnReviewPass] rebase onto base failed (scene kept for LLM)', { branch, baseBranch, conflictFiles }); + return { ok: false, conflictFiles, scene: { cwd: worktreePath, state: 'rebase' } }; } try { - await execSh(mergeCmd, { cwd: baseRepo, timeoutMs: MERGE_TIMEOUT_MS }); + await execSh(mergeCmd, { cwd: mergeCwd, timeoutMs: MERGE_TIMEOUT_MS }); return { ok: true }; } catch { - const conflictFiles = await listConflictFiles(baseRepo, baseRepo); - await abortMergeAndRebase(baseRepo); - logger.warn('[MergeOnReviewPass] merge after rebase still failed', { branch, baseBranch, conflictFiles }); - return { ok: false, conflictFiles }; + // 二次 merge 冲突:取清单后保留现场(合并目标目录)交 LLM 解,不再 abort + const conflictFiles = await listConflictFiles(mergeCwd, mergeCwd); + logger.warn('[MergeOnReviewPass] merge after rebase still failed (scene kept for LLM)', { branch, baseBranch, conflictFiles }); + return { ok: false, conflictFiles, scene: { cwd: mergeCwd, state: 'merge' } }; + } +} + +/** + * 冲突现场交 LLM 解(对齐 resolving-merge-conflicts skill:保留双方意图、永不 --abort、 + * 解完跑项目验证命令全绿才算完成)。spawn 复用蒸馏管线同款 system-executor 一次性会话 + * (skill 全文作 systemPrompt,mergeContext + verify 命令来源作 prompt,cwd = 冲突现场)。 + * 确定性校验收尾(不信 LLM 自报):冲突标记清零才算解完;rebase 现场解完后重放 merge。 + */ +async function resolveConflictSceneWithLlm(args: { + wu: WorkUnitData; + meta: WorkUnitMetadata; + scene: ConflictScene; + conflictFiles: string[]; + mergeCmd: string; + mergeCwd: string; + sourceBranch: string; + targetBranch: string; + scopeSummary: string; +}): Promise<{ audit: MergeResolutionAudit; resolved: boolean }> { + const { wu, meta, scene, conflictFiles, mergeCmd, mergeCwd, sourceBranch, targetBranch, scopeSummary } = args; + const startMs = Date.now(); + const base = { + attemptedAt: new Date().toISOString(), + state: scene.state, + cwd: scene.cwd, + conflictFiles, + }; + const fail = (error: string): { audit: MergeResolutionAudit; resolved: false } => ({ + audit: { ...base, ok: false, durationMs: Date.now() - startMs, error }, + resolved: false, + }); + + // skill 全文作 systemPrompt(lazy import 同下方循环依赖规避口径);拿不到不乱来—— + // 无流程卡约束 LLM 可能 --abort 毁现场,直接回退人工 + const { skillLoader } = await import('@dommaker/studio-skill'); + const skill = skillLoader.loadSingle('resolving-merge-conflicts'); + if (!skill?.prompt) return fail('skill resolving-merge-conflicts 不可用(未 seed?)'); + + // verify 命令来源说明(与执行环节同策略:metadata.verifyCommands > 目录 package.json 惯例)。 + // lazy import:本模块头部依赖说明——不静态引入 agent-loop 侧模块,避免重依赖链 + const { resolveVerifyCommands } = await import('../agents/loop/wu-verification.js'); + const { commands: verifyCommands, source: verifySource } = await resolveVerifyCommands(wu, meta, scene.cwd); + + // spawn 一次性解冲突会话(lazy import 同上;超时走 eventSource 注册表 600s) + const { getSystemExecutor } = await import('../agents/system-executor.js'); + let output: string; + let durationMs: number; + try { + const result = await getSystemExecutor().run( + buildMergeConflictPrompt({ scene, conflictFiles, sourceBranch, targetBranch, scopeSummary, verifyCommands, verifySource }), + { systemPrompt: skill.prompt, cwd: scene.cwd, eventSource: 'merge-conflict-resolution' }, + ); + output = result.output; + durationMs = result.durationMs; + } catch (err) { + return fail(`LLM 解冲突失败/超时: ${getErrorMessage(err).slice(0, 200)}`); + } + + // 确定性校验:冲突标记清零才算解完(不信 LLM 自报) + const remaining = await listConflictFiles(scene.cwd, scene.cwd); + if (remaining.length > 0) { + return fail(`LLM 解完仍有 ${remaining.length} 个冲突文件: ${remaining.join(', ').slice(0, 200)}`); + } + + // rebase 现场:LLM 完成 rebase(--continue 直到落完)后重放 merge(rebase 后应为干净合并) + if (scene.state === 'rebase') { + try { + await execSh(mergeCmd, { cwd: mergeCwd, timeoutMs: MERGE_TIMEOUT_MS }); + } catch (err) { + return fail(`rebase 解完后重放 merge 仍失败: ${getErrorMessage(err).slice(0, 200)}`); + } } + + return { + audit: { ...base, ok: true, durationMs, summary: output.slice(-MERGE_LLM_SUMMARY_CHARS) }, + resolved: true, + }; +} + +/** LLM 解冲突 prompt:mergeContext + verify 命令来源说明(skill 全文走 systemPrompt) */ +function buildMergeConflictPrompt(args: { + scene: ConflictScene; + conflictFiles: string[]; + sourceBranch: string; + targetBranch: string; + scopeSummary: string; + verifyCommands: string[]; + verifySource: 'override' | 'convention'; +}): string { + const { scene, conflictFiles, sourceBranch, targetBranch, scopeSummary, verifyCommands, verifySource } = args; + return [ + '自动合并产生冲突,请按系统提示词中的流程在现场解冲突并完成合并。', + '', + '## mergeContext', + `- cwd: ${scene.cwd}(冲突现场目录,在此工作)`, + `- 现场状态: ${scene.state === 'merge' ? 'merge 进行中(git merge 冲突)' : 'rebase 进行中(git rebase 冲突,解完后 rebase --continue 直到全部 commit 落完)'}`, + `- 目标分支: ${targetBranch}`, + `- 来源分支: ${sourceBranch}`, + `- 任务: ${scopeSummary}`, + '- 冲突文件:', + ...(conflictFiles.length > 0 ? conflictFiles.map(f => ` - ${f}`) : [' (未能获取清单,用 git status 确认)']), + '', + '## 验证命令(解完后在 cwd 依次跑,全绿才算完成)', + ...(verifyCommands.length > 0 + ? verifyCommands.map(c => `- ${c}`) + : ['- (未能解析出验证命令;至少确认 git status 干净、无残留冲突标记)']), + `命令来源: ${verifySource === 'override' ? 'WU metadata.verifyCommands 覆盖' : '目录 package.json scripts 惯例'}`, + ].join('\n'); +} + +/** LLM 解冲突审计落 metadata.mergeResolution(仿 distill runs.jsonl 审计思路,落 WU metadata;best-effort) */ +async function recordMergeResolution( + wuService: WorkUnitService, + wuId: string, + audit: MergeResolutionAudit, +): Promise { + try { + const fresh = await wuService.getById(wuId); + const freshMeta = parseWuMetadata(fresh?.metadata); + await wuService.update(wuId, { metadata: { ...freshMeta, mergeResolution: audit } }); + } catch (err) { + logger.warn('[MergeOnReviewPass] record mergeResolution failed (non-blocking)', { wuId, error: String(err) }); + } +} + +/** 合并后 verify(lazy import 同 pmo-branch-resolver 的循环依赖规避口径) */ +async function runMergeVerification(wu: WorkUnitData, meta: WorkUnitMetadata, mergeCwd: string) { + const { runWuVerification } = await import('../agents/loop/wu-verification.js'); + return runWuVerification(wu, meta, mergeCwd); } // ── #157(T6):analysis 原型单 reviewPassed 收尾 ── diff --git a/apps/api/src/modules/workunit/waiting-input.ts b/apps/api/src/modules/workunit/waiting-input.ts index 7a62873a5..a7f577184 100644 --- a/apps/api/src/modules/workunit/waiting-input.ts +++ b/apps/api/src/modules/workunit/waiting-input.ts @@ -35,11 +35,10 @@ */ import { logger, FileStore } from '@dommaker/studio-shared'; import { WorkUnitService, type WorkUnitData, type WorkUnitMetadata } from './workunit.service.js'; -import { resolveValidTransitions, PLAN_STEP_LIMIT } from './workunit.types.js'; +import { PLAN_STEP_LIMIT } from './workunit.types.js'; import { postWuSystemMessage } from './wu-messenger.js'; import { parseWuMetadata } from './wu-metadata.js'; import { withBlockedCta } from './blocked-cta.js'; -import { closeWorkUnitWithNotice } from './wu-closure.js'; import { ProjectDiscoveryService, matchProjectByReply, type LocalProject } from '../projects/project-discovery.service.js'; import { RequirementService } from '../requirements/requirement.service.js'; import { projectService } from '../pmo/project.service.js'; @@ -237,8 +236,10 @@ async function resolveBudgetChoiceFromReply( /** * #176(决策 #57 D2):「关闭」指令 —— 显式关闭 blocked WU。 - * 双出声(决策 #62 §3):workunit:closed 结构化事件 + 频道说明(经 wu-closure 统一出口)。 - * decision/spec 裁剪状态机无 closed(#108:可能等关键人多天)→ 拒绝并频道说明,状态不变。 + * 双出声(决策 #62 §3):workunit:closed 结构化事件 + 频道说明(#550 起经 + * WorkUnitService.close 状态机单口)。 + * decision/spec 裁剪状态机无 closed(#108:可能等关键人多天)→ 状态机拒绝, + * 拒绝说明行为保留(不再靠本模块手写查表守卫),状态不变。 * 指令不进入 pendingReplies(不复活、无下一步可注入)。 * #185(决策 #87 D2):返回值细化为三态(Web 按钮通道复用同一关闭路径,需区分拒绝原因); * opts.reason 覆盖关闭原因文案(Web 按钮 ≠ 频道回复)。 @@ -251,28 +252,34 @@ async function closeOnHumanCommand( ): Promise { const title = (metadata.title ?? wu.scope).slice(0, 50); - if (!(resolveValidTransitions(wu.type, 'blocked') ?? []).includes('closed')) { - if (wu.channelId) { - await postWuSystemMessage( - wu, - `任务「${title}」是 ${wu.type} 类型(人工验收类,无 closed 状态),不支持「关闭」指令;如需继续请直接回复指导意见。`, - { fileStore }, - ).catch(err => - logger.warn('[WaitingInput] Close-reject notice failed (non-blocking)', { workUnitId: wu.id, error: String(err) }) - ); - } - logger.info('[WaitingInput] Close command rejected (type has no closed state)', { workUnitId: wu.id, type: wu.type }); - return 'rejected-no-closed-state'; - } - const snapshot = (await fileStore.getIndex({ id: wu.id }))[0]; if (!snapshot || snapshot.status !== 'blocked') return 'not-found-or-not-blocked'; - const closed = await closeWorkUnitWithNotice(fileStore, snapshot, { - reason: opts?.reason ?? '人类在线程内回复「关闭」指令,显式关闭', - closedBy: 'human-command', - message: `任务「${title}」已按你的要求关闭。如需继续请重新派发。`, - }); - logger.info('[WaitingInput] WorkUnit closed by human command', { workUnitId: wu.id, closed }); + try { + await new WorkUnitService(fileStore).close(wu.id, { + reason: opts?.reason ?? '人类在线程内回复「关闭」指令,显式关闭', + closedBy: 'human-command', + message: `任务「${title}」已按你的要求关闭。如需继续请重新派发。`, + }); + } catch (err) { + // decision/spec 无 closed 边 → close() 状态机拒绝:频道说明 + 三态返回(行为同 #176 手写守卫时代) + if (err instanceof Error && err.message.startsWith('Invalid status transition')) { + if (wu.channelId) { + await postWuSystemMessage( + wu, + `任务「${title}」是 ${wu.type} 类型(人工验收类,无 closed 状态),不支持「关闭」指令;如需继续请直接回复指导意见。`, + { fileStore }, + ).catch(postErr => + logger.warn('[WaitingInput] Close-reject notice failed (non-blocking)', { workUnitId: wu.id, error: String(postErr) }) + ); + } + logger.info('[WaitingInput] Close command rejected (type has no closed state)', { workUnitId: wu.id, type: wu.type }); + return 'rejected-no-closed-state'; + } + // 其余失败(落库异常等):记日志按已消费处理(同 wu-closure 时代 closed=false 口径,不回头) + logger.error('[WaitingInput] Close command failed', { workUnitId: wu.id, error: String(err) }); + return 'closed'; + } + logger.info('[WaitingInput] WorkUnit closed by human command', { workUnitId: wu.id }); return 'closed'; } diff --git a/apps/api/src/modules/workunit/workunit-crud.ts b/apps/api/src/modules/workunit/workunit-crud.ts index 35f9af7d5..1f3f9a9a6 100644 --- a/apps/api/src/modules/workunit/workunit-crud.ts +++ b/apps/api/src/modules/workunit/workunit-crud.ts @@ -4,8 +4,8 @@ * AS-025 §3.28c-1 Task 2-4 * 存储迁移: 已从 Prisma 迁移到 FileStore (Event Sourcing) * - * 本文件:create/update/delete + claim/unclaim、workunit.created 与 - * workunit.status_changed 事件发布、父状态聚合(aggregateParentStatus), + * 本文件:create/update/delete + claim/unclaim + updateMetadata 增量合并语义口(#554)、 + * workunit.created 与 workunit.status_changed 事件发布、父状态聚合(aggregateParentStatus), * 以及快照转换函数与输入/数据类型。状态机迁移(transitionStatus)、 * 查询(getById/list)与评审验收收口在 workunit.service.ts 的 WorkUnitService。 */ @@ -77,6 +77,11 @@ export interface WorkUnitData { updatedAt: Date; claimedAt: Date | null; completedAt: Date | null; + /** #327(additive):关闭时刻——归档计龄锚点。仅 closed 状态有值,reopen 清除 */ + closedAt?: Date | null; + /** #318(additive,ADR D2):可认领标记——仅事件负载(workunit.created/status_changed)与 GET / 列表项附带; + unassigned 且无未了结依赖才 true,其余状态恒 false;snapshotToData 本体不产此字段 */ + claimable?: boolean; } /** @@ -416,10 +421,27 @@ export class WorkUnitCrudService { return snapshotToData(updated); } + /** + * metadata 增量合并语义口(#554,ADR 2026-09-15 决策 1/6):patch 浅并入既有 + * metadata——既有键保留、同名键被 patch 覆盖、undefined 值键序列化时丢弃(清除语义)。 + * 委托 FileStore.updateMetadata 锁内原语(mutator 基于锁内最新值求值,消读-改-写竞态); + * 业务模块不直摸该原语。不动状态机/业务守卫,updatedAt 沿用原语缺省刷新。 + * @returns 更新后的 WorkUnitData;WU 不存在返回 null(不抛错,与 FileStore 原语同口径, + * 与 update() 的抛错口径不同——批量/标记类调用方按忽略语义处理) + */ + async updateMetadata(id: string, patch: WorkUnitMetadata): Promise { + const updated = await this.fileStore.updateMetadata(id, latest => ({ ...latest, ...patch })); + return updated ? snapshotToData(updated) : null; + } + /** * Delete a WorkUnit. + * #538(ADR 2026-09-15 决策 3/4/5):墓碑事件行(closed + deleted:true)由本方法 + * 单点构造,调用方不自拼;reason 可选落墓碑(GC/TTL 死因留痕)。删除后发 + * workunit:removed(负载 id + channelId,经 SSE 桥转发前端删行)——只走事件流, + * 不进频道出声(数据卫生非对账修复)。 */ - async delete(id: string): Promise { + async delete(id: string, opts?: { reason?: string }): Promise { const existing = (await this.fileStore.getIndex({ id }))[0]; if (!existing) throw new Error(`WorkUnit not found: ${id}`); @@ -431,9 +453,19 @@ export class WorkUnitCrudService { type: 'closed', wuId: id, timestamp: now.toISOString(), - data: { deleted: true }, + data: { deleted: true, ...(opts?.reason ? { reason: opts.reason } : {}) }, }; await this.fileStore.commitRemoval(event, id); + + // best-effort:删除出声失败只记日志(同 publishStatusChanged 口径) + try { + eventBus.publish('workunit:removed', { id, channelId: existing.channelId ?? null }); + } catch (err) { + logger.warn('[WorkUnit] Failed to publish workunit:removed (non-blocking)', { + workUnitId: id, + error: String(err), + }); + } } /** diff --git a/apps/api/src/modules/workunit/workunit.mappers.ts b/apps/api/src/modules/workunit/workunit.mappers.ts index 527c4ee8c..70d083c38 100644 --- a/apps/api/src/modules/workunit/workunit.mappers.ts +++ b/apps/api/src/modules/workunit/workunit.mappers.ts @@ -4,7 +4,7 @@ */ import type { WorkUnitSnapshot } from '@dommaker/studio-shared'; -import type { CreateWorkUnitInput, UpdateWorkUnitInput, WorkUnitData } from './workunit.types.js'; +import type { CreateWorkUnitInput, UpdateWorkUnitInput, WorkUnitData } from './workunit-crud.js'; // ── 转换函数 ── diff --git a/apps/api/src/modules/workunit/workunit.routes.ts b/apps/api/src/modules/workunit/workunit.routes.ts index 577df5500..8b0830ba3 100644 --- a/apps/api/src/modules/workunit/workunit.routes.ts +++ b/apps/api/src/modules/workunit/workunit.routes.ts @@ -20,6 +20,7 @@ * POST /api/v1/workunits/:id/resume — #185(决策 #87 D2):Web 按钮通道「继续执行」(与回复路径共享复活原语) * POST /api/v1/workunits/:id/close — #185(决策 #87 D2):Web 按钮通道「关闭任务」(死信显式关闭路径) * POST /api/v1/workunits/:id/ruling — #467:裁决轮一次性提交(采纳/打回重议;批量落探路台账 + 复活同会话) + * POST /api/v1/workunits/:id/direction — #567:方向锁定提交(选定方向落探路台账 + 复活同会话) * * 涌现路径 (AS-025 §5.15): * POST /api/v1/workunits/from-message — convert ChannelMessage to WorkUnit @@ -28,25 +29,28 @@ * GET /api/v1/workunits/:id/messages — list messages by workUnitId * POST /api/v1/workunits/:id/messages — send message (auto-associate workUnitId) * PATCH /api/v1/workunits/:id/messages/:messageId — edit message + * + * #551:本层只做 HTTP 翻译——错误契约收口 http-helpers.ts(一张映射表 + sendMappedError + * 唯一出口),human-only 守卫 = requireHuman 中间件,verify 业务下沉 WorkUnitService.verifyManually。 */ -import { Router, type Request, type Response } from 'express'; +import { Router, type Request } from 'express'; import { FileStore } from '@dommaker/studio-shared'; -import { WorkUnitService, type WorkUnitMetadata } from './workunit.service.js'; +import { WorkUnitService, type WorkUnitData } from './workunit.service.js'; import { parseWuMetadata } from './wu-metadata.js'; import { resolveClaimable, buildStatusById } from './wu-dependencies.js'; import { adoptInspectionOpportunity, ignoreInspectionOpportunity } from './inspection-opportunities.js'; -import { resolveReviewConfirm, ConfirmPayloadError } from './confirm-payload.js'; +import { resolveReviewConfirm } from './confirm-payload.js'; import { aggregateTreeTokens } from '../agents/token-usage.service.js'; -import { CODE_WORKTREE_TYPES, resolveVerifyCommands, runWuVerification } from '../agents/loop/wu-verification.js'; import { channelMessageService } from '../channels/channel-message.service.js'; import { resumeBlockedWorkUnitFromWeb, closeBlockedWorkUnitFromWeb } from './waiting-input.js'; -import { applyPlanRuling, validateRulingItems, PlanRulingError } from '../pmo/plan-ruling.js'; +import { applyPlanRuling, validateRulingItems } from '../pmo/plan-ruling.js'; +import { applyPlanDirection, validateDirectionPick } from '../pmo/plan-direction.js'; import { claimWorkUnitAndAnnounce } from './claim-announce.js'; import { listWorkUnitChangedFiles } from './wu-changed-files.js'; -import { getErrorMessage } from '../../utils/errors.js'; import { parsePagination, formatPaginatedResponse } from '../../utils/pagination.js'; import { requireAuth, requireNotGuest, type AuthRequest } from '../../middleware/auth.js'; +import { HttpRouteError, WORKUNIT_ERROR_MAPS, route, requireHuman } from './http-helpers.js'; const router = Router(); const fileStore = new FileStore(); @@ -54,495 +58,257 @@ const service = new WorkUnitService(fileStore); // #387: 单次批量 id 上限(调用方单页规模 ≤ 数十,留余量;超出静默截断) const MAX_BATCH_IDS = 100; -/** - * A2A §4.4: 调用方 authorType 识别(body.authorType 优先,其次 x-author-type header)。 - * 与讨论空间发帖的 authorType 字段同约定;UI/人类调用不发送该字段 → 'human'。 - */ -function resolveCallerAuthorType(req: Request): string { - const fromBody = typeof req.body?.authorType === 'string' ? req.body.authorType : undefined; - const fromHeader = req.headers['x-author-type']; - return fromBody ?? (typeof fromHeader === 'string' ? fromHeader : 'human'); +/** human-only 端点的 403 文案(A2A §4.4-2,沿用原内联守卫措辞) */ +const REVIEW_HUMAN_ONLY = 'Review actions are human-only (authorType=agent rejected)'; + +/** getById 或抛 404——各端点「WU 必须存在」前置守卫的统一写法 */ +async function mustGetWu(id: string): Promise { + const wu = await service.getById(id); + if (!wu) throw new HttpRouteError(404, 'NOT_FOUND', `WorkUnit ${id} not found`); + return wu; +} + +/** requireAuth 后取署名(本地模式回落 Local User/id),review/verify 落台账 by 字段共用 */ +function callerName(req: Request): string { + const user = (req as AuthRequest).user; + return user?.name ?? user?.email ?? user?.id ?? 'human'; } /** GET / — list WorkUnits */ -router.get('/', async (req: Request, res: Response) => { - try { - const { type, status, assigneeId, channelId, parentId, attributed, projectId, q } = req.query; - const { page, limit } = parsePagination(req); - - const result = await service.list({ - type: type as string, - status: status as string, - assigneeId: assigneeId as string, - channelId: channelId as string, - parentId: parentId as string, - // #428:attributed=true/false 显式布尔;其他值(含缺省)= undefined 不过滤 - attributed: attributed === 'true' ? true : attributed === 'false' ? false : undefined, - // #456:PMO 项目归属过滤(空串不过滤) - projectId: typeof projectId === 'string' && projectId ? projectId : undefined, - // 批次 D-2 项4:标题搜索(scope 子串,大小写不敏感);空白 q 不过滤 - q: typeof q === 'string' && q.trim() ? q.trim() : undefined, - page, - limit, - }); - - // #109:列表项附「可认领」标记(claimable)供 UI 使用 —— unassigned 且 - // blockedBy 依赖全了结才为 true;profile 无关(认领侧仍由 loop observe 判定)。 - // 仅当本页含 unassigned 行才读 index 做依赖判定(其余行 claimable 恒 false) - const statusById = result.data.some(w => w.status === 'unassigned') - ? buildStatusById(await fileStore.getIndex()) - : new Map(); - const data = result.data.map(w => ({ ...w, claimable: resolveClaimable(w, statusById) })); - - res.json(formatPaginatedResponse(data, result.total, page, limit)); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); - } -}); +router.get('/', route([], async (req, res) => { + const { type, status, assigneeId, channelId, parentId, attributed, projectId, q } = req.query; + const { page, limit } = parsePagination(req); + + const result = await service.list({ + type: type as string, + status: status as string, + assigneeId: assigneeId as string, + channelId: channelId as string, + parentId: parentId as string, + // #428:attributed=true/false 显式布尔;其他值(含缺省)= undefined 不过滤 + attributed: attributed === 'true' ? true : attributed === 'false' ? false : undefined, + // #456:PMO 项目归属过滤(空串不过滤) + projectId: typeof projectId === 'string' && projectId ? projectId : undefined, + // 批次 D-2 项4:标题搜索(scope 子串,大小写不敏感);空白 q 不过滤 + q: typeof q === 'string' && q.trim() ? q.trim() : undefined, + page, + limit, + }); + + // #109:列表项附「可认领」标记(claimable)供 UI 使用 —— unassigned 且 + // blockedBy 依赖全了结才为 true;profile 无关(认领侧仍由 loop observe 判定)。 + // 仅当本页含 unassigned 行才读 index 做依赖判定(其余行 claimable 恒 false) + const statusById = result.data.some(w => w.status === 'unassigned') + ? buildStatusById(await fileStore.getIndex()) + : new Map(); + const data = result.data.map(w => ({ ...w, claimable: resolveClaimable(w, statusById) })); + + res.json(formatPaginatedResponse(data, result.total, page, limit)); +})); /** POST / — create WorkUnit */ -router.post('/', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const { scope, type, assigneeId, status, channelId, parentId, metadata, projectPath } = req.body; - - if (!scope || typeof scope !== 'string') { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'scope is required and must be a string' }, - }); - } +router.post('/', requireAuth(), requireNotGuest(), route([], async (req, res) => { + const { scope, type, assigneeId, status, channelId, parentId, metadata, projectPath } = req.body; - const wu = await service.create({ - scope, - type, - assigneeId, - status, - channelId, - parentId, - metadata, - projectPath, - }); - - res.status(201).json(wu); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); + if (!scope || typeof scope !== 'string') { + throw new HttpRouteError(400, 'INVALID_INPUT', 'scope is required and must be a string'); } -}); -/** POST /from-message — convert ChannelMessage to WorkUnit (emergence path) */ -router.post('/from-message', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const { messageId, type, metadata, channelId } = req.body; + const wu = await service.create({ scope, type, assigneeId, status, channelId, parentId, metadata, projectPath }); + res.status(201).json(wu); +})); - if (!messageId || typeof messageId !== 'string') { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'messageId is required' }, - }); - } +/** POST /from-message — convert ChannelMessage to WorkUnit (emergence path) */ +router.post('/from-message', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.fromMessage, async (req, res) => { + const { messageId, type, metadata, channelId } = req.body; - // #524 P1-1:body.channelId 可选透传 → 按频道直查,免全频道扫描反查 - const wu = await service.createFromMessage(messageId, { - type, metadata, - channelId: typeof channelId === 'string' && channelId ? channelId : undefined, - }); - res.status(201).json(wu); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found')) { - return res.status(404).json({ error: { code: 'NOT_FOUND', message: msg } }); - } - if (msg.includes('already linked')) { - return res.status(409).json({ error: { code: 'ALREADY_CONVERTED', message: msg } }); - } - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: msg } }); + if (!messageId || typeof messageId !== 'string') { + throw new HttpRouteError(400, 'INVALID_INPUT', 'messageId is required'); } -}); + + // #524 P1-1:body.channelId 可选透传 → 按频道直查,免全频道扫描反查 + const wu = await service.createFromMessage(messageId, { + type, metadata, + channelId: typeof channelId === 'string' && channelId ? channelId : undefined, + }); + res.status(201).json(wu); +})); /** * GET /last-done?assigneeIds=a,b,c — #387 批量聚合:每 assignee 最近一条完成 WU * (done/completed,completedAt ?? updatedAt 降序取首条;无完成记录 → null)。 * roster 空闲卡「最近完成」专用,消逐实例 GET /workunits?assigneeId= 的 N+1。 */ -router.get('/last-done', async (req: Request, res: Response) => { - try { - const raw = req.query.assigneeIds; - const ids = typeof raw === 'string' - ? raw.split(',').map(s => s.trim()).filter(s => s.length > 0) - : []; - if (ids.length === 0) { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'assigneeIds is required (comma-separated ids)' }, - }); - } - const data = await service.lastDoneByAssignee(ids.slice(0, MAX_BATCH_IDS)); - res.json({ success: true, data }); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); +router.get('/last-done', route([], async (req, res) => { + const raw = req.query.assigneeIds; + const ids = typeof raw === 'string' + ? raw.split(',').map(s => s.trim()).filter(s => s.length > 0) + : []; + if (ids.length === 0) { + throw new HttpRouteError(400, 'INVALID_INPUT', 'assigneeIds is required (comma-separated ids)'); } -}); + const data = await service.lastDoneByAssignee(ids.slice(0, MAX_BATCH_IDS)); + res.json({ success: true, data }); +})); /** GET /:id — get WorkUnit by id */ -router.get('/:id', async (req: Request, res: Response) => { - try { - const wu = await service.getById(req.params.id); - - if (!wu) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `WorkUnit ${req.params.id} not found` }, - }); - } - - res.json(wu); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); - } -}); +router.get('/:id', route([], async (req, res) => { + res.json(await mustGetWu(req.params.id)); +})); /** PUT /:id — update WorkUnit */ -router.put('/:id', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const wu = await service.update(req.params.id, req.body); - res.json(wu); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found') || msg.includes('Record to update not found')) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: msg }, - }); - } - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: msg }, - }); - } -}); +router.put('/:id', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.update, async (req, res) => { + res.json(await service.update(req.params.id, req.body)); +})); /** * #163(T8-E2,#130 决策 6):巡检机会采纳——建 feature 子单(显式 unassigned 进 * frontier,采纳动作即人工闸),源条目记 wuId。机制在 inspection-opportunities.ts。 */ -router.post('/:id/opportunities/:oppId/adopt', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const result = await adoptInspectionOpportunity(service, req.params.id, req.params.oppId); - res.status(201).json(result); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found')) { - return res.status(404).json({ error: { code: 'NOT_FOUND', message: msg } }); - } - if (msg.includes('already resolved') || msg.includes('not an inspection')) { - return res.status(409).json({ error: { code: 'INVALID_STATE', message: msg } }); - } - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: msg } }); - } -}); +router.post('/:id/opportunities/:oppId/adopt', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.opportunity, async (req, res) => { + const result = await adoptInspectionOpportunity(service, req.params.id, req.params.oppId); + res.status(201).json(result); +})); /** #163(T8-E2):巡检机会忽略——终态,可附理由(body.reason,下轮巡检不重复上报的判据) */ -router.post('/:id/opportunities/:oppId/ignore', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const reason = typeof req.body?.reason === 'string' ? req.body.reason : undefined; - const result = await ignoreInspectionOpportunity(service, req.params.id, req.params.oppId, reason); - res.json(result); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found')) { - return res.status(404).json({ error: { code: 'NOT_FOUND', message: msg } }); - } - if (msg.includes('already resolved') || msg.includes('not an inspection')) { - return res.status(409).json({ error: { code: 'INVALID_STATE', message: msg } }); - } - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: msg } }); - } -}); +router.post('/:id/opportunities/:oppId/ignore', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.opportunity, async (req, res) => { + const reason = typeof req.body?.reason === 'string' ? req.body.reason : undefined; + const result = await ignoreInspectionOpportunity(service, req.params.id, req.params.oppId, reason); + res.json(result); +})); /** GET /:id/tree-tokens - 树级 token 开销聚合(AC-5.4, §8.4.4) */ -router.get('/:id/tree-tokens', async (req: Request, res: Response) => { - try { - const wu = await service.getById(req.params.id); - if (!wu) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `WorkUnit ${req.params.id} not found` }, - }); - } - const meta = parseWuMetadata(wu.metadata); - const rootId = meta.collab?.rootId ?? wu.id; - const report = await aggregateTreeTokens(rootId, fileStore); - res.json(report); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); - } -}); +router.get('/:id/tree-tokens', route([], async (req, res) => { + const wu = await mustGetWu(req.params.id); + const meta = parseWuMetadata(wu.metadata); + const rootId = meta.collab?.rootId ?? wu.id; + const report = await aggregateTreeTokens(rootId, fileStore); + res.json(report); +})); /** * GET /:id/changed-files — #285 AC4(决策 #249 §5):per-WU 产出/修改文件集 * (session:start.workUnitId → file:change 绝对路径;无数据/读取失败 → 空数组, * 前端文件 chip 降级候选集词表)。只读,匿名公开(与 GET /:id 同口径)。 */ -router.get('/:id/changed-files', async (req: Request, res: Response) => { - try { - const files = await listWorkUnitChangedFiles(req.params.id); - res.json({ success: true, data: { files } }); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); - } -}); +router.get('/:id/changed-files', route([], async (req, res) => { + const files = await listWorkUnitChangedFiles(req.params.id); + res.json({ success: true, data: { files } }); +})); /** DELETE /:id — delete WorkUnit */ -router.delete('/:id', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - await service.delete(req.params.id); - res.status(204).send(); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found') || msg.includes('Record to delete does not exist')) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: msg }, - }); - } - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: msg }, - }); - } -}); +router.delete('/:id', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.deleteWu, async (req, res) => { + await service.delete(req.params.id); + res.status(204).send(); +})); /** POST /:id/claim — claim WorkUnit(flock 悲观互斥锁);#445:认领即发声原语接入(与 loop 自动认领同路径) */ -router.post('/:id/claim', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - // #445:agentId 可省略——缺省 = 当前登录用户(人工引导片认领,身份诚实归因会话用户); - // 显式传入保持旧契约(认领给指定 id)。requireAuth 保证 req.user 存在(none 模式注入 local)。 - const bodyAgentId = typeof req.body?.agentId === 'string' && req.body.agentId ? req.body.agentId : undefined; - const claimerId = bodyAgentId ?? req.user?.id; - if (!claimerId) { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'agentId is required' }, - }); - } - // 发声署名:人工认领署用户显示名;显式 agentId 旧契约无法廉价解析角色名 → 退化为 id - const claimerName = bodyAgentId ?? req.user?.name ?? req.user?.email ?? claimerId; - - const wu = await claimWorkUnitAndAnnounce(req.params.id, claimerId, claimerName, { wuService: service, fileStore }); - res.json(wu); - } catch (error) { - const msg = getErrorMessage(error); - if (msg === 'Claim failed') { - return res.status(409).json({ - error: { code: 'CLAIM_FAILED', message: msg }, - }); - } - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: msg }, - }); +router.post('/:id/claim', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.claim, async (req, res) => { + // #445:agentId 可省略——缺省 = 当前登录用户(人工引导片认领,身份诚实归因会话用户); + // 显式传入保持旧契约(认领给指定 id)。requireAuth 保证 req.user 存在(none 模式注入 local)。 + const bodyAgentId = typeof req.body?.agentId === 'string' && req.body.agentId ? req.body.agentId : undefined; + const claimerId = bodyAgentId ?? req.user?.id; + if (!claimerId) { + throw new HttpRouteError(400, 'INVALID_INPUT', 'agentId is required'); } -}); + // 发声署名:人工认领署用户显示名;显式 agentId 旧契约无法廉价解析角色名 → 退化为 id + const claimerName = bodyAgentId ?? req.user?.name ?? req.user?.email ?? claimerId; + + const wu = await claimWorkUnitAndAnnounce(req.params.id, claimerId, claimerName, { wuService: service, fileStore }); + res.json(wu); +})); /** POST /:id/unclaim — unclaim WorkUnit */ -router.post('/:id/unclaim', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const wu = await service.unclaim(req.params.id); - res.json(wu); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found') || msg.includes('Record to update not found')) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: msg }, - }); - } - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: msg }, - }); - } -}); +router.post('/:id/unclaim', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.unclaim, async (req, res) => { + res.json(await service.unclaim(req.params.id)); +})); /** POST /:id/review-passed — review approved (in_review → done) */ -router.post('/:id/review-passed', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - // A2A §4.4-2 / §8-Q3: 验收权只在人 —— agent 身份调用一律 403。 - // 身份约定:调用方在 body.authorType 或 x-author-type header 声明; - // UI/人类调用不发送该字段(或发送 'human')。 - if (resolveCallerAuthorType(req) === 'agent') { - return res.status(403).json({ - error: { code: 'FORBIDDEN', message: 'Review actions are human-only (authorType=agent rejected)' }, - }); - } - // F6(决策 1):人工确认落台账 l3 —— by 取登录用户名(本地模式回落 Local User/id) - // #110:可选 body.summary(人点通过时填写的结论文本)穿透进 l3 台账—— - // pmo/decision-resolution 订阅器据此把 decision 单结论原样写入探路地图 decisions[] - // #463:可选 body.confirm 结构化评审表单(decision/spec/analysis)——后端序列化为 - // l3.summary(存储契约不变,人不接触魔法行);与裸 summary 并存时 confirm 优先; - // analysis 的 tasks 经 options.analysisTasks 透传覆写 metadata.analysisTasks。 - const user = (req as AuthRequest).user; - const confirm = resolveReviewConfirm(req.body?.confirm); - const rawSummary = req.body?.summary; - const summary = confirm.summary - ?? (typeof rawSummary === 'string' && rawSummary.trim() ? rawSummary : undefined); - // #177:可选 defaultAssigneeId(profile id)——analysis 确认处「默认执行角色」, - // 落 WU metadata.defaultTaskAssigneeId,analysis-handoff 应用于全部派生 task 子 WU - const defaultAssigneeId = req.body?.defaultAssigneeId; - const options = { - ...(typeof defaultAssigneeId === 'string' && defaultAssigneeId.trim() - ? { defaultTaskAssigneeId: defaultAssigneeId.trim() } : {}), - ...(confirm.analysisTasks !== undefined ? { analysisTasks: confirm.analysisTasks } : {}), - }; - const wu = await service.reviewPassed(req.params.id, { - by: user?.name ?? user?.email ?? user?.id ?? 'human', - kind: 'human-confirm', - ...(summary ? { summary } : {}), - }, Object.keys(options).length > 0 ? options : undefined); - res.json(wu); - } catch (error) { - const msg = getErrorMessage(error); - if (error instanceof ConfirmPayloadError) { - return res.status(400).json({ error: { code: 'INVALID_CONFIRM', message: msg } }); - } - if (msg.includes('not found')) { - return res.status(404).json({ error: { code: 'NOT_FOUND', message: msg } }); - } - if (msg.includes('Cannot review')) { - return res.status(400).json({ error: { code: 'INVALID_TRANSITION', message: msg } }); - } - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: msg } }); - } -}); +router.post('/:id/review-passed', requireAuth(), requireNotGuest(), requireHuman(REVIEW_HUMAN_ONLY), route(WORKUNIT_ERROR_MAPS.reviewPassed, async (req, res) => { + // F6(决策 1):人工确认落台账 l3 —— by 取登录用户名(本地模式回落 Local User/id) + // #110:可选 body.summary(人点通过时填写的结论文本)穿透进 l3 台账—— + // pmo/decision-resolution 订阅器据此把 decision 单结论原样写入探路地图 decisions[] + // #463:可选 body.confirm 结构化评审表单(decision/spec/analysis)——后端序列化为 + // l3.summary(存储契约不变,人不接触魔法行);与裸 summary 并存时 confirm 优先; + // analysis 的 tasks 经 options.analysisTasks 透传覆写 metadata.analysisTasks。 + const confirm = resolveReviewConfirm(req.body?.confirm); + const rawSummary = req.body?.summary; + const summary = confirm.summary + ?? (typeof rawSummary === 'string' && rawSummary.trim() ? rawSummary : undefined); + // #177:可选 defaultAssigneeId(profile id)——analysis 确认处「默认执行角色」, + // 落 WU metadata.defaultTaskAssigneeId,analysis-handoff 应用于全部派生 task 子 WU + const defaultAssigneeId = req.body?.defaultAssigneeId; + const options = { + ...(typeof defaultAssigneeId === 'string' && defaultAssigneeId.trim() + ? { defaultTaskAssigneeId: defaultAssigneeId.trim() } : {}), + ...(confirm.analysisTasks !== undefined ? { analysisTasks: confirm.analysisTasks } : {}), + }; + const wu = await service.reviewPassed(req.params.id, { + by: callerName(req), + kind: 'human-confirm', + ...(summary ? { summary } : {}), + }, Object.keys(options).length > 0 ? options : undefined); + res.json(wu); +})); /** POST /:id/review-rejected — review rejected (in_review → active, or blocked after 3) */ -router.post('/:id/review-rejected', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - // A2A §4.4-2 / §8-Q3: 同 review-passed,agent 身份调用一律 403 - if (resolveCallerAuthorType(req) === 'agent') { - return res.status(403).json({ - error: { code: 'FORBIDDEN', message: 'Review actions are human-only (authorType=agent rejected)' }, - }); - } - // F6(决策 1):人工否决同样落台账 l3(rejected 留痕) - const user = (req as AuthRequest).user; - const wu = await service.reviewRejected(req.params.id, req.body?.reason, { - by: user?.name ?? user?.email ?? user?.id ?? 'human', - kind: 'human-confirm', - }); - res.json(wu); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found')) { - return res.status(404).json({ error: { code: 'NOT_FOUND', message: msg } }); - } - if (msg.includes('Cannot review')) { - return res.status(400).json({ error: { code: 'INVALID_TRANSITION', message: msg } }); - } - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: msg } }); - } -}); +router.post('/:id/review-rejected', requireAuth(), requireNotGuest(), requireHuman(REVIEW_HUMAN_ONLY), route(WORKUNIT_ERROR_MAPS.reviewRejected, async (req, res) => { + // F6(决策 1):人工否决同样落台账 l3(rejected 留痕) + const wu = await service.reviewRejected(req.params.id, req.body?.reason, { + by: callerName(req), + kind: 'human-confirm', + }); + res.json(wu); +})); /** * POST /:id/verify — F6-c(断点 2):人工重跑 L1 自动验证(human-only,验收权只在人同 A2A §4.4)。 * 仅代码类 WU(task/bug/feature/refactor)且有 worktree 落档;body.commands 可选 * (传了视为 metadata.verifyCommands 覆盖)。只补写台账 l1/verifyReport,不动 WU status; * 写完发 status_changed(状态值不变也发)让 pmo rollup 按证据齐备度重估。 + * #551:业务下沉 WorkUnitService.verifyManually(脱 HTTP 可直测),本层只映射 kind → 响应。 */ -router.post('/:id/verify', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - if (resolveCallerAuthorType(req) === 'agent') { - return res.status(403).json({ - error: { code: 'FORBIDDEN', message: 'Verify actions are human-only (authorType=agent rejected)' }, - }); - } - const wu = await service.getById(req.params.id); - if (!wu) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `WorkUnit ${req.params.id} not found` }, - }); - } - if (!CODE_WORKTREE_TYPES.has(wu.type)) { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: `仅代码类 WU(task/bug/feature/refactor)支持 L1 验证(当前 type=${wu.type})` }, - }); - } - const metadata: WorkUnitMetadata = parseWuMetadata(wu.metadata); - const worktreePath = typeof metadata.worktreePath === 'string' && metadata.worktreePath.length > 0 - ? metadata.worktreePath - : null; - if (!worktreePath) { - return res.status(409).json({ - error: { code: 'NO_WORKTREE', message: 'WU 无 worktree 落档(metadata.worktreePath 为空),无法验证' }, - }); - } - // body.commands 视为 metadata.verifyCommands 覆盖(同 resolveVerifyCommands 的覆盖语义) - const bodyCommands = Array.isArray(req.body?.commands) - ? (req.body.commands as unknown[]).filter((c): c is string => typeof c === 'string' && c.trim().length > 0) - : []; - const effectiveMeta: WorkUnitMetadata = bodyCommands.length > 0 - ? { ...metadata, verifyCommands: bodyCommands } - : metadata; - - const { commands } = await resolveVerifyCommands(wu, effectiveMeta, worktreePath); - if (commands.length === 0) { - return res.status(422).json({ +router.post('/:id/verify', requireAuth(), requireNotGuest(), requireHuman('Verify actions are human-only (authorType=agent rejected)'), route([], async (req, res) => { + const bodyCommands = Array.isArray(req.body?.commands) + ? (req.body.commands as unknown[]).filter((c): c is string => typeof c === 'string' && c.trim().length > 0) + : []; + const result = await service.verifyManually(req.params.id, { + by: callerName(req), + ...(bodyCommands.length > 0 ? { commands: bodyCommands } : {}), + }); + switch (result.kind) { + case 'not-found': + throw new HttpRouteError(404, 'NOT_FOUND', `WorkUnit ${req.params.id} not found`); + case 'not-code-type': + throw new HttpRouteError(400, 'INVALID_INPUT', `仅代码类 WU(task/bug/feature/refactor)支持 L1 验证(当前 type=${result.wuType})`); + case 'no-worktree': + throw new HttpRouteError(409, 'NO_WORKTREE', 'WU 无 worktree 落档(metadata.worktreePath 为空),无法验证'); + case 'no-commands': + res.status(422).json({ verified: false, reason: 'no-commands', hint: '请在 WU metadata.verifyCommands 或 worktree 的 package.json scripts(test/typecheck/lint)中配置验证命令', }); - } - - const outcome = await runWuVerification(wu, effectiveMeta, worktreePath); - // F6(决策 1):人工重跑同样落台账 l1——by 取登录用户名(本地模式回落 Local User/id) - const user = (req as AuthRequest).user; - const updated = await service.recordL1Verification(wu.id, { - by: user?.name ?? user?.email ?? user?.id ?? 'human', - ran: outcome.ran, - source: outcome.source, - failure: outcome.failure, - }); - - if (outcome.failure) { - return res.json({ verified: false, failed: [outcome.failure] }); - } - const report = parseWuMetadata(updated.metadata).verifyReport - ?? { commands: outcome.ran, source: outcome.source }; - res.json({ verified: true, report }); - } catch (error) { - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) } }); + return; + case 'failed': + res.json({ verified: false, failed: [result.failure] }); + return; + case 'verified': + res.json({ verified: true, report: result.report }); } -}); +})); /** * POST /:id/dispatch-review — F6-c(断点 3):人工补派 agent 评审(human-only)。 * 父 WU 被人工直推 done(或 in_review 但评审子 WU 缺失)时补建 review 子 WU, * 走与 ReviewDispatcher 路径 A 相同的未指派涌现 + excludeAssignee/自评兜底逻辑。 */ -router.post('/:id/dispatch-review', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - if (resolveCallerAuthorType(req) === 'agent') { - return res.status(403).json({ - error: { code: 'FORBIDDEN', message: 'Review actions are human-only (authorType=agent rejected)' }, - }); - } - // 与 index.ts 启动时同款动态 import:避免路由模块加载时拉起整个 agents 模块图 - const { getReviewDispatcher } = await import('../agents/loop/review-dispatcher.js') as typeof import('../agents/loop/review-dispatcher.js'); - const child = await getReviewDispatcher().dispatchReviewNow(req.params.id); - res.json({ reviewWorkUnitId: child.id }); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found')) { - return res.status(404).json({ error: { code: 'NOT_FOUND', message: msg } }); - } - if (msg.includes('already')) { - return res.status(409).json({ error: { code: 'ALREADY_SATISFIED', message: msg } }); - } - if (msg.includes('Cannot dispatch') || msg.includes('not reviewable') || msg.includes('no channel')) { - return res.status(400).json({ error: { code: 'INVALID_INPUT', message: msg } }); - } - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: msg } }); - } -}); +router.post('/:id/dispatch-review', requireAuth(), requireNotGuest(), requireHuman(REVIEW_HUMAN_ONLY), route(WORKUNIT_ERROR_MAPS.dispatchReview, async (req, res) => { + // 与 index.ts 启动时同款动态 import:避免路由模块加载时拉起整个 agents 模块图 + const { getReviewDispatcher } = await import('../agents/loop/review-dispatcher.js') as typeof import('../agents/loop/review-dispatcher.js'); + const child = await getReviewDispatcher().dispatchReviewNow(req.params.id); + res.json({ reviewWorkUnitId: child.id }); +})); /** * POST /:id/resume — #185(决策 #87 D2):Web 按钮通道「继续执行」(纯授权复活,human-only)。 @@ -550,31 +316,17 @@ router.post('/:id/dispatch-review', requireAuth(), requireNotGuest(), async (req * timeoutReleaseCount 终身保留),pendingReplies 注入固定占位文案;复活后发 Studio 系统消息里程碑。 * 分类型显示是 UI 层决策(D3),端点不设类型门槛;归属等待型按回复语义不被纯授权复活 → 409。 */ -router.post('/:id/resume', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const wu = await service.getById(req.params.id); - if (!wu) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `WorkUnit ${req.params.id} not found` }, - }); - } - if (wu.status !== 'blocked') { - return res.status(409).json({ - error: { code: 'NOT_BLOCKED', message: `WorkUnit 当前状态为 ${wu.status},仅 blocked 可继续执行` }, - }); - } - const resumed = await resumeBlockedWorkUnitFromWeb(req.params.id, fileStore); - if (!resumed) { - return res.status(409).json({ - error: { code: 'RESUME_REJECTED', message: '复活未完成(等待工程归属的任务请在频道回复工程名或路径)' }, - }); - } - const updated = await service.getById(req.params.id); - res.json(updated); - } catch (error) { - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) } }); +router.post('/:id/resume', requireAuth(), requireNotGuest(), route([], async (req, res) => { + const wu = await mustGetWu(req.params.id); + if (wu.status !== 'blocked') { + throw new HttpRouteError(409, 'NOT_BLOCKED', `WorkUnit 当前状态为 ${wu.status},仅 blocked 可继续执行`); + } + const resumed = await resumeBlockedWorkUnitFromWeb(req.params.id, fileStore); + if (!resumed) { + throw new HttpRouteError(409, 'RESUME_REJECTED', '复活未完成(等待工程归属的任务请在频道回复工程名或路径)'); } -}); + res.json(await service.getById(req.params.id)); +})); /** * POST /:id/ruling — #467:裁决轮一次性提交(human-only,结构化表单通道)。 @@ -583,71 +335,60 @@ router.post('/:id/resume', requireAuth(), requireNotGuest(), async (req: Request * (decisions[] + fog resolved/open)+ 组合裁决结果文本复活同会话(pendingReplies 注入)。 * 前置守卫:仅 blocked 且 metadata.planRulings 非空(裁决轮挂起中);载荷非法 → 400。 */ -router.post('/:id/ruling', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const wu = await service.getById(req.params.id); - if (!wu) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `WorkUnit ${req.params.id} not found` }, - }); - } - if (wu.status !== 'blocked') { - return res.status(409).json({ - error: { code: 'NOT_BLOCKED', message: `WorkUnit 当前状态为 ${wu.status},仅 blocked(裁决轮挂起)可提交裁决` }, - }); - } - const meta = parseWuMetadata(wu.metadata); - if (!Array.isArray(meta.planRulings) || meta.planRulings.length === 0) { - return res.status(409).json({ - error: { code: 'NO_PENDING_RULING', message: '该任务无待裁的裁决轮(planRulings 为空)' }, - }); - } - const items = validateRulingItems(req.body?.items); - const updated = await applyPlanRuling(req.params.id, items, fileStore); - res.json(updated); - } catch (error) { - if (error instanceof PlanRulingError) { - return res.status(400).json({ error: { code: 'INVALID_RULING', message: getErrorMessage(error) } }); - } - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) } }); +router.post('/:id/ruling', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.ruling, async (req, res) => { + const wu = await mustGetWu(req.params.id); + if (wu.status !== 'blocked') { + throw new HttpRouteError(409, 'NOT_BLOCKED', `WorkUnit 当前状态为 ${wu.status},仅 blocked(裁决轮挂起)可提交裁决`); + } + const meta = parseWuMetadata(wu.metadata); + if (!Array.isArray(meta.planRulings) || meta.planRulings.length === 0) { + throw new HttpRouteError(409, 'NO_PENDING_RULING', '该任务无待裁的裁决轮(planRulings 为空)'); + } + const items = validateRulingItems(req.body?.items); + const updated = await applyPlanRuling(req.params.id, items, fileStore); + res.json(updated); +})); + +/** + * POST /:id/direction — #567:方向锁定提交(human-only,结构化表单通道,仿 /:id/ruling)。 + * plan 会话存在互斥大方向时先出一次方向卡(NEED_INPUT + DIRECTION 行 → metadata.planDirections); + * 人单选一个方向(可附补充说明)经本端点提交:applyPlanDirection 落探路台账 + * (decisions[] 追加「方向:…(人锁定)」结论)+ 组合选定文本复活同会话(pendingReplies 注入)。 + * 前置守卫:仅 blocked 且 metadata.planDirections 非空(方向锁定挂起中);载荷非法 → 400。 + */ +router.post('/:id/direction', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.direction, async (req, res) => { + const wu = await mustGetWu(req.params.id); + if (wu.status !== 'blocked') { + throw new HttpRouteError(409, 'NOT_BLOCKED', `WorkUnit 当前状态为 ${wu.status},仅 blocked(方向锁定挂起)可提交方向选定`); + } + const meta = parseWuMetadata(wu.metadata); + if (!meta.planDirections || !Array.isArray(meta.planDirections.options) || meta.planDirections.options.length === 0) { + throw new HttpRouteError(409, 'NO_PENDING_DIRECTION', '该任务无待选的方向锁定(planDirections 为空)'); } -}); + const pick = validateDirectionPick(req.body, meta.planDirections); + const updated = await applyPlanDirection(req.params.id, pick, fileStore); + res.json(updated); +})); /** * POST /:id/close — #185(决策 #87 D2):Web 按钮通道「关闭任务」(死信显式关闭路径,human-only)。 * 复用 #57 D4 关闭路径:显式状态迁移 + 频道通知 + workunit:closed 结构化事件(不靠文本魔法串)。 * decision/spec 裁剪状态机无 closed → 409 NO_CLOSED_STATE(拒绝说明已同步发到频道)。 */ -router.post('/:id/close', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const wu = await service.getById(req.params.id); - if (!wu) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `WorkUnit ${req.params.id} not found` }, - }); - } - if (wu.status !== 'blocked') { - return res.status(409).json({ - error: { code: 'NOT_BLOCKED', message: `WorkUnit 当前状态为 ${wu.status},仅 blocked 可关闭` }, - }); - } - const outcome = await closeBlockedWorkUnitFromWeb(req.params.id, fileStore); - if (outcome === 'rejected-no-closed-state') { - return res.status(409).json({ - error: { code: 'NO_CLOSED_STATE', message: `该类型(${wu.type},人工验收类)无 closed 状态,不支持关闭;如需继续请回复指导意见` }, - }); - } - if (outcome !== 'closed') { - return res.status(409).json({ - error: { code: 'NOT_BLOCKED', message: 'WorkUnit 状态已变化,关闭未完成' }, - }); - } - const updated = await service.getById(req.params.id); - res.json(updated); - } catch (error) { - res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) } }); +router.post('/:id/close', requireAuth(), requireNotGuest(), route([], async (req, res) => { + const wu = await mustGetWu(req.params.id); + if (wu.status !== 'blocked') { + throw new HttpRouteError(409, 'NOT_BLOCKED', `WorkUnit 当前状态为 ${wu.status},仅 blocked 可关闭`); } -}); + const outcome = await closeBlockedWorkUnitFromWeb(req.params.id, fileStore); + if (outcome === 'rejected-no-closed-state') { + throw new HttpRouteError(409, 'NO_CLOSED_STATE', `该类型(${wu.type},人工验收类)无 closed 状态,不支持关闭;如需继续请回复指导意见`); + } + if (outcome !== 'closed') { + throw new HttpRouteError(409, 'NOT_BLOCKED', 'WorkUnit 状态已变化,关闭未完成'); + } + res.json(await service.getById(req.params.id)); +})); /** * POST /:id/status — transition WorkUnit status (state machine) @@ -656,165 +397,99 @@ router.post('/:id/close', requireAuth(), requireNotGuest(), async (req: Request, * reviewPassed/reviewRejected 落账),故收口。agent 内部合法迁移走服务层 * transitionStatus,不经 REST,不受影响。 */ -router.post('/:id/status', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - if (resolveCallerAuthorType(req) === 'agent') { - return res.status(403).json({ - error: { code: 'FORBIDDEN', message: 'Status transitions are human-only (authorType=agent rejected)' }, - }); - } - const { status } = req.body; - if (!status || typeof status !== 'string') { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'status is required' }, - }); - } - - const wu = await service.transitionStatus(req.params.id, status); - res.json(wu); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('Invalid status transition')) { - return res.status(400).json({ - error: { code: 'INVALID_TRANSITION', message: msg }, - }); - } - if (msg.includes('not found')) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: msg }, - }); - } - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: msg }, - }); +router.post('/:id/status', requireAuth(), requireNotGuest(), requireHuman('Status transitions are human-only (authorType=agent rejected)'), route(WORKUNIT_ERROR_MAPS.status, async (req, res) => { + const { status } = req.body; + if (!status || typeof status !== 'string') { + throw new HttpRouteError(400, 'INVALID_INPUT', 'status is required'); } -}); + res.json(await service.transitionStatus(req.params.id, status)); +})); // ── 讨论空间 (AS-025 §5.16) ── /** GET /:id/messages — list messages in discussion space (workUnitId grouping) */ -router.get('/:id/messages', async (req: Request, res: Response) => { - try { - const { before, limit = '50' } = req.query; - const take = Math.min(Number(limit), 100); - - const beforeDate = before ? new Date(before as string) : undefined; - - // #529:从 WU 解析频道归属(一等列 channelId,与写侧 POST /:id/messages 同字段、 - // 读写对称)传给直查;wu 不存在或无 channelId(legacy/手工单)→ undefined 走扇出 fallback。 - const wu = await service.getById(req.params.id); - const result = await channelMessageService.listByWorkUnitId(req.params.id, { - channelId: wu?.channelId ?? undefined, - before: beforeDate, - limit: take, - }); - - res.json({ - success: true, - data: result.data, - total: result.total, - hasMore: result.data.length < result.total, - }); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); - } -}); +router.get('/:id/messages', route([], async (req, res) => { + const { before, limit = '50' } = req.query; + const take = Math.min(Number(limit), 100); + const beforeDate = before ? new Date(before as string) : undefined; + + // #529:从 WU 解析频道归属(一等列 channelId,与写侧 POST /:id/messages 同字段、 + // 读写对称)传给直查;wu 不存在或无 channelId(legacy/手工单)→ undefined 走扇出 fallback。 + const wu = await service.getById(req.params.id); + const result = await channelMessageService.listByWorkUnitId(req.params.id, { + channelId: wu?.channelId ?? undefined, + before: beforeDate, + limit: take, + }); + + res.json({ + success: true, + data: result.data, + total: result.total, + hasMore: result.data.length < result.total, + }); +})); /** POST /:id/messages — send message in discussion space (auto-associate workUnitId) */ -router.post('/:id/messages', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const { content, replyToId, authorType = 'human', agentName } = req.body; - - if (!content || typeof content !== 'string' || !content.trim()) { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'content is required' }, - }); - } +router.post('/:id/messages', requireAuth(), requireNotGuest(), route([], async (req, res) => { + const { content, replyToId, authorType = 'human', agentName } = req.body; - // Verify WorkUnit exists - const wu = await service.getById(req.params.id); - if (!wu) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `WorkUnit ${req.params.id} not found` }, - }); - } + if (!content || typeof content !== 'string' || !content.trim()) { + throw new HttpRouteError(400, 'INVALID_INPUT', 'content is required'); + } - // Need a channelId — use WorkUnit's channelId or fallback to system channel - let channelId = wu.channelId; - if (!channelId) { - const rndChannels = await fileStore.listChannels({ type: 'rnd' }); - const sysChannel = rndChannels.length > 0 ? rndChannels[0] : null; - if (!sysChannel) { - return res.status(400).json({ - error: { code: 'NO_CHANNEL', message: 'No channel available for discussion messages' }, - }); - } - channelId = sysChannel.id; - } + // Verify WorkUnit exists + const wu = await mustGetWu(req.params.id); - let message; - if (authorType === 'agent' && agentName) { - message = await channelMessageService.createAgentMessage( - channelId, agentName, content.trim(), - { replyToId, workUnitId: req.params.id }, - ); - } else { - message = await channelMessageService.createHumanMessage( - channelId, content.trim(), replyToId, req.params.id, - ); + // Need a channelId — use WorkUnit's channelId or fallback to system channel + let channelId = wu.channelId; + if (!channelId) { + const rndChannels = await fileStore.listChannels({ type: 'rnd' }); + const sysChannel = rndChannels.length > 0 ? rndChannels[0] : null; + if (!sysChannel) { + throw new HttpRouteError(400, 'NO_CHANNEL', 'No channel available for discussion messages'); } + channelId = sysChannel.id; + } - res.status(201).json(message); - } catch (error) { - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) }, - }); + let message; + if (authorType === 'agent' && agentName) { + message = await channelMessageService.createAgentMessage( + channelId, agentName, content.trim(), + { replyToId, workUnitId: req.params.id }, + ); + } else { + message = await channelMessageService.createHumanMessage( + channelId, content.trim(), replyToId, req.params.id, + ); } -}); -/** PATCH /:id/messages/:messageId — edit message in discussion space */ -router.patch('/:id/messages/:messageId', requireAuth(), requireNotGuest(), async (req: Request, res: Response) => { - try { - const { content, meta } = req.body; + res.status(201).json(message); +})); - if (content === undefined && meta === undefined) { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'content or meta is required' }, - }); - } +/** PATCH /:id/messages/:messageId — edit message in discussion space */ +router.patch('/:id/messages/:messageId', requireAuth(), requireNotGuest(), route(WORKUNIT_ERROR_MAPS.editMessage, async (req, res) => { + const { content, meta } = req.body; - // Verify message belongs to this WorkUnit - const found = await fileStore.getMessageById(req.params.messageId); - if (!found) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: `Message ${req.params.messageId} not found` }, - }); - } - if (found.message.workUnitId !== req.params.id) { - return res.status(400).json({ - error: { code: 'INVALID_INPUT', message: 'Message does not belong to this WorkUnit' }, - }); - } + if (content === undefined && meta === undefined) { + throw new HttpRouteError(400, 'INVALID_INPUT', 'content or meta is required'); + } - const updated = await channelMessageService.updateMessage(req.params.messageId, { - content, - meta, - }, found.channelId); - - res.json(updated); - } catch (error) { - const msg = getErrorMessage(error); - if (msg.includes('not found')) { - return res.status(404).json({ - error: { code: 'NOT_FOUND', message: msg }, - }); - } - res.status(500).json({ - error: { code: 'INTERNAL_ERROR', message: msg }, - }); + // Verify message belongs to this WorkUnit + const found = await fileStore.getMessageById(req.params.messageId); + if (!found) { + throw new HttpRouteError(404, 'NOT_FOUND', `Message ${req.params.messageId} not found`); + } + if (found.message.workUnitId !== req.params.id) { + throw new HttpRouteError(400, 'INVALID_INPUT', 'Message does not belong to this WorkUnit'); } -}); + + const updated = await channelMessageService.updateMessage(req.params.messageId, { + content, + meta, + }, found.channelId); + + res.json(updated); +})); export default router; diff --git a/apps/api/src/modules/workunit/workunit.service.ts b/apps/api/src/modules/workunit/workunit.service.ts index e3b666407..8dc916969 100644 --- a/apps/api/src/modules/workunit/workunit.service.ts +++ b/apps/api/src/modules/workunit/workunit.service.ts @@ -5,27 +5,30 @@ * 存储迁移: 已从 Prisma 迁移到 FileStore (Event Sourcing) * * 拆分说明(纯代码移动,行为不变):create/update/delete、claim/unclaim、 - * workunit.created/status_changed 事件发布与父状态聚合已迁至 workunit-crud.ts - * (WorkUnitCrudService 基类);头部类型/常量/转换层已抽至 workunit.types.ts / + * updateMetadata 增量合并语义口(#554)、workunit.created/status_changed 事件发布 + * 与父状态聚合已迁至 workunit-crud.ts(WorkUnitCrudService 基类);头部类型/常量/转换层已抽至 workunit.types.ts / * workunit.mappers.ts(工单 30)。本文件保留查询(getById/list)、状态机迁移 - * (transitionStatus)与评审验收(reviewPassed/reviewRejected/attestation 补写/ - * recordL1Verification/markMergeConflict/blockForManualRelease/rebindSourceChannel), + * (transitionStatus)、系统侧关闭唯一入口(close,#550 收编 wu-closure)与评审验收 + * (reviewPassed/reviewRejected/attestation 补写/recordL1Verification/markMergeConflict/ + * blockForManualRelease/rebindSourceChannel),迁移类写路径统一走 persistSnapshot 尾部, * 并 re-export 全部迁出符号,导入面不变。 */ import { logger, withAttestation, deriveDisplayState, createSettledTracker, type AttestationEntry, type WorkUnitSnapshot, type WorkUnitEvent } from '@dommaker/studio-shared'; import { mergeWorktreeBranchOnReviewPass, cleanupPrototypeWorktreeOnReviewPass } from './merge-on-review-pass.js'; import { parseWuMetadata } from './wu-metadata.js'; -import { resolveValidTransitions, type WorkUnitMetadata, type ReviewAttestationSource } from './workunit.types.js'; +import { resolveValidTransitions, WORKUNIT_CLOSED_EVENT_TYPE, type WorkUnitMetadata, type ReviewAttestationSource, type CloseWorkUnitOptions } from './workunit.types.js'; import { snapshotToData } from './workunit.mappers.js'; import { WorkUnitCrudService, type WorkUnitData } from './workunit-crud.js'; +import { postWuSystemMessage } from './wu-messenger.js'; +import { writeStudioEvent } from '../../utils/studio-events.js'; // #428:未归属口径的戳解析复用 requirements 的零依赖叶子(无循环依赖风险,见该文件头注释) import { parseWuPmoId } from '../requirements/wu-pmo-attribution.js'; // re-export:保持既有消费方(agent-loop / routes / 测试等)从 workunit.service 导入的路径不变 export { snapshotToData } from './workunit.mappers.js'; export { ANALYSIS_TASKS_MAX, INSPECTION_OPPORTUNITIES_MAX } from './workunit.types.js'; -export type { WorkUnitMetadata, ReviewAttestationSource, InspectionOpportunity } from './workunit.types.js'; +export type { WorkUnitMetadata, ReviewAttestationSource, InspectionOpportunity, CloseWorkUnitOptions, WorkUnitClosedBy } from './workunit.types.js'; /** * #228 测试可观测性(纯增量,不改变行为):登记 reviewPassed 的 best-effort @@ -35,6 +38,15 @@ export type { WorkUnitMetadata, ReviewAttestationSource, InspectionOpportunity } */ const reviewPassTracker = createSettledTracker(); +/** #551:verifyManually 的判别联合结果——kind 承载守卫/执行结论,HTTP 语义由路由层映射 */ +export type ManualVerifyResult = + | { kind: 'not-found' } + | { kind: 'not-code-type'; wuType: string } + | { kind: 'no-worktree' } + | { kind: 'no-commands' } + | { kind: 'failed'; failure: { command: string; tail: string } } + | { kind: 'verified'; report: unknown }; + function trackReviewPassEffect(effect: Promise): void { reviewPassTracker.track(effect); } @@ -168,38 +180,23 @@ export class WorkUnitService extends WorkUnitCrudService { ); } - const now = new Date(); - const isoNow = now.toISOString(); - const eventType: WorkUnitEvent['type'] = newStatus === 'done' || newStatus === 'closed' ? 'completed' : newStatus === 'blocked' ? 'blocked' : 'updated'; - const updated: WorkUnitSnapshot = { - ...current, - status: newStatus, - completedAt: (newStatus === 'done' || newStatus === 'closed') ? isoNow : current.completedAt, - // #327:closedAt 是归档计龄锚点——转入 closed 落锚;从 closed 迁出(reopen,状态机唯一 - // 出口 closed→unassigned)清除,活 WU 的消息不按陈旧锚点被归档 - closedAt: newStatus === 'closed' ? isoNow : current.status === 'closed' ? null : current.closedAt ?? null, - // #176(决策 #57 D4):转入 blocked 统一落死信计时基准 metadata.blockedAt - // (24h 自动关闭与 30min 提醒均以此为锚;复活后再次 blocked 刷新) - metadata: newStatus === 'blocked' - ? JSON.stringify({ ...parseWuMetadata(current.metadata), blockedAt: isoNow }) - : current.metadata, - updatedAt: isoNow, - }; + // #176(决策 #57 D4):转入 blocked 统一落死信计时基准 metadata.blockedAt + // (24h 自动关闭与 30min 提醒均以此为锚;复活后再次 blocked 刷新); + // 非 blocked 迁移不动 metadata(原串透传,见 persistSnapshot 口径) + const metadata: WorkUnitMetadata | string | null = newStatus === 'blocked' + ? { ...parseWuMetadata(current.metadata), blockedAt: new Date().toISOString() } + : current.metadata; - const event: WorkUnitEvent = { - type: eventType, - wuId: id, - timestamp: isoNow, - data: updated as unknown as Record, - }; - await this.fileStore.commitSnapshot(event, updated); - - // Publish status-change event(REQ roll-up 等订阅消费,best-effort) - await this.publishStatusChanged(updated); + // #550:closedAt/completedAt 落锚、status_changed 发布、父聚合、reopen thaw 全在统一尾部 + const updated = await this.persistSnapshot(current, metadata, { + eventType, + status: newStatus, + markCompleted: newStatus === 'done' || newStatus === 'closed', + }); // #126(T4):人工确认(pending → unassigned)解除人闸——feature 单此时补展开 // 频道工单路由第一跳(创建时落 pending 跳过展开;expandRoutingHead 幂等)。 @@ -213,26 +210,70 @@ export class WorkUnitService extends WorkUnitCrudService { ); } - // #327:reopen(closed → unassigned)自动解冻——该 WU 已归档消息从冷文件搬回热文件, - // 规则保持一条线:活 WU 的消息永远在热层。best-effort:失败记日志不阻断迁移 - if (current.status === 'closed' && newStatus === 'unassigned') { - await this.fileStore.thawWorkUnitMessages(id).catch(err => - logger.warn('[WorkUnit] Thaw archived messages on reopen failed (non-blocking)', { - workUnitId: id, error: String(err), - }) - ); + return snapshotToData(updated); + } + + /** + * #550:系统侧关闭唯一入口——与手工 PATCH 同走状态机校验 + 统一落库尾部 + * (closedAt/completedAt 落锚 + status_changed 广播 + 父聚合),随后双出声 + * (#176 决策 #62 §3):workunit:closed 结构化事件(payload 与 wu-closure 时代一致) + * + 频道里程碑说明,各步 best-effort。 + * decision/spec 裁剪状态机无 closed → resolveValidTransitions 天然拒绝 + * (守卫不再散落调用方手写查表)。 + * 幂等:目标已是 closed → 直返现状,不重复发事件/广播/出声(巡检探针撞上已关单是 + * 常态,与 blockForManualRelease 终态不动先例一致)。 + */ + async close(id: string, opts: CloseWorkUnitOptions): Promise { + const current = (await this.fileStore.getIndex({ id }))[0]; + if (!current) throw new Error('WorkUnit not found'); + if (current.status === 'closed') return snapshotToData(current); + + const allowed = resolveValidTransitions(current.type, current.status); + if (!allowed || !allowed.includes('closed')) { + throw new Error(`Invalid status transition: ${current.status} → closed`); } - // Cascade: parent status aggregation on any status change that affects parent - if (['active', 'blocked', 'done', 'closed'].includes(newStatus)) { - this.aggregateParentStatus(id).catch(err => - logger.warn('[WorkUnit] aggregateParentStatus failed', { workUnitId: id, error: String(err) }) + // metadata 原串透传(关闭不动 metadata,与 wu-closure 直写口径一致;blockedAt 由事件发射处另解析) + const updated = await this.persistSnapshot(current, current.metadata, { + eventType: 'completed', + status: 'closed', + markCompleted: true, + }); + + await this.writeWorkUnitClosedEvent(current, opts, updated.closedAt ?? updated.updatedAt); + + if (current.channelId) { + await postWuSystemMessage(snapshotToData(updated), opts.message ?? opts.reason, { + milestone: true, + fileStore: this.fileStore, + }).catch(err => + logger.warn('[WorkUnit] Close channel notice failed (non-blocking)', { workUnitId: id, error: String(err) }) ); } return snapshotToData(updated); } + /** workunit:closed 结构化事件(level=warning,对齐 workunit:failed 分级;fire-and-forget) */ + private async writeWorkUnitClosedEvent( + snapshot: WorkUnitSnapshot, + opts: CloseWorkUnitOptions, + closedAt: string, + ): Promise { + try { + const blockedAt = parseWuMetadata(snapshot.metadata).blockedAt; + await writeStudioEvent(WORKUNIT_CLOSED_EVENT_TYPE, { + workUnitId: snapshot.id, + reason: opts.reason, + closedBy: opts.closedBy, + ...(typeof blockedAt === 'string' ? { blockedAt } : {}), + closedAt, + }, { source: 'wu-closure', level: 'warning' }); + } catch (err) { + logger.warn('[WorkUnit] Closed event emit failed (non-blocking)', { workUnitId: snapshot.id, error: String(err) }); + } + } + /** * Review passed: in_review → done. Emits workunit.review.passed. * Resets consecutive rejection counter. @@ -289,11 +330,6 @@ export class WorkUnitService extends WorkUnitCrudService { markCompleted: true, }); - // Cascade: parent aggregation (best-effort) - this.aggregateParentStatus(id).catch(err => - logger.warn('[WorkUnit] aggregateParentStatus failed', { workUnitId: id, error: String(err) }) - ); - // B3b-ii(决策 D1/D3 后半):评审通过 → task 分支自动合并回 base 分支。 // best-effort:无 worktree 落档的 WU 在 merge 模块内旁路;冲突由模块自行置 blocked 转人工; // 任何失败只记日志,不阻断本方法的 done 迁移。 @@ -399,40 +435,63 @@ export class WorkUnitService extends WorkUnitCrudService { const updated = await this.persistSnapshot(current, metadata, { eventType: 'updated' }); return snapshotToData(updated); } - async markMergeConflict(id: string, conflictFiles: string[]): Promise { + + /** + * #551:POST /:id/verify 的业务下沉(F6-c 断点 2,人工重跑 L1,脱 HTTP 可直测)。 + * 守卫链:存在性 → 代码类 type → worktree 落档 → 有可跑命令,失败以判别联合 kind + * 返回(不抛错、不携带 HTTP 语义,状态码由路由层映射);命令实际执行后无论成败 + * 都经 recordL1Verification 落台账 l1(失败= rejected,全绿= approved + verifyReport)。 + * opts.commands 非空时视为 metadata.verifyCommands 覆盖(同 resolveVerifyCommands 语义)。 + * wu-verification 动态 import:与 merge-on-review-pass 同例,避免拉起 agents 模块图。 + */ + async verifyManually(id: string, opts: { by: string; commands?: string[] }): Promise { + const wu = await this.getById(id); + if (!wu) return { kind: 'not-found' }; + + const { CODE_WORKTREE_TYPES, resolveVerifyCommands, runWuVerification } = + await import('../agents/loop/wu-verification.js'); + + if (!CODE_WORKTREE_TYPES.has(wu.type)) return { kind: 'not-code-type', wuType: wu.type }; + + const metadata: WorkUnitMetadata = parseWuMetadata(wu.metadata); + const worktreePath = typeof metadata.worktreePath === 'string' && metadata.worktreePath.length > 0 + ? metadata.worktreePath + : null; + if (!worktreePath) return { kind: 'no-worktree' }; + + const effectiveMeta: WorkUnitMetadata = opts.commands && opts.commands.length > 0 + ? { ...metadata, verifyCommands: opts.commands } + : metadata; + const { commands } = await resolveVerifyCommands(wu, effectiveMeta, worktreePath); + if (commands.length === 0) return { kind: 'no-commands' }; + + const outcome = await runWuVerification(wu, effectiveMeta, worktreePath); + const updated = await this.recordL1Verification(wu.id, { + by: opts.by, + ran: outcome.ran, + source: outcome.source, + failure: outcome.failure, + }); + + if (outcome.failure) return { kind: 'failed', failure: outcome.failure }; + const report = parseWuMetadata(updated.metadata).verifyReport + ?? { commands: outcome.ran, source: outcome.source }; + return { kind: 'verified', report }; + } + + async markMergeConflict(id: string, conflictFiles: string[], opts?: { blockReason?: string }): Promise { const current = (await this.fileStore.getIndex({ id }))[0]; if (!current) throw new Error('WorkUnit not found'); const metadata: WorkUnitMetadata = parseWuMetadata(current.metadata); metadata.mergeConflict = true; metadata.conflictFiles = conflictFiles; - // B4: blocked 原因落盘(2026-08-03 token-burn issue P0-2) - metadata.blockReason = `merge-conflict: 自动合并冲突(${conflictFiles.length} 个文件)`; - - const now = new Date(); - const isoNow = now.toISOString(); - metadata.blockedAt = isoNow; // #176(决策 #57 D4):死信计时基准 - const updated: WorkUnitSnapshot = { - ...current, - status: 'blocked', - metadata: JSON.stringify(metadata), - updatedAt: isoNow, - }; - - const event: WorkUnitEvent = { - type: 'blocked', - wuId: id, - timestamp: isoNow, - data: updated as unknown as Record, - }; - await this.fileStore.commitSnapshot(event, updated); - - await this.publishStatusChanged(updated); - - this.aggregateParentStatus(id).catch(err => - logger.warn('[WorkUnit] aggregateParentStatus failed', { workUnitId: id, error: String(err) }) - ); + // B4: blocked 原因落盘(2026-08-03 token-burn issue P0-2); + // opts.blockReason 覆盖默认文案(如合并后 verify 失败走同路径但前缀区分 merge-verify-failed) + metadata.blockReason = opts?.blockReason ?? `merge-conflict: 自动合并冲突(${conflictFiles.length} 个文件)`; + metadata.blockedAt = new Date().toISOString(); // #176(决策 #57 D4):死信计时基准 + const updated = await this.persistSnapshot(current, metadata, { eventType: 'blocked', status: 'blocked' }); return snapshotToData(updated); } @@ -457,33 +516,34 @@ export class WorkUnitService extends WorkUnitCrudService { metadata.manualReleaseReason = reason; // B4: blocked 原因落盘(2026-08-03 token-burn issue P0-2) metadata.blockReason = `manual-release: ${reason}`; + metadata.blockedAt = new Date().toISOString(); // #176(决策 #57 D4):死信计时基准 - const now = new Date(); - const isoNow = now.toISOString(); - metadata.blockedAt = isoNow; // #176(决策 #57 D4):死信计时基准 - const updated: WorkUnitSnapshot = { - ...current, + const updated = await this.persistSnapshot(current, metadata, { + eventType: 'blocked', status: 'blocked', - assigneeId: null, - claimedAt: null, - metadata: JSON.stringify(metadata), - updatedAt: isoNow, - }; - - const event: WorkUnitEvent = { - type: 'blocked', - wuId: id, - timestamp: isoNow, - data: updated as unknown as Record, - }; - await this.fileStore.commitSnapshot(event, updated); + patch: { assigneeId: null, claimedAt: null }, + }); + return snapshotToData(updated); + } - await this.publishStatusChanged(updated); + /** + * 决策 14 认领前适任判断:全员不适任转人工——频道全部 active 成员都被判不适任, + * unassigned WU 直接置 blocked(unassigned → blocked 不在 VALID_TRANSITIONS, + * 语义方法直写,形态同 blockForManualRelease/markMergeConflict 先例)。 + * 仅 unassigned 可转(active/blocked 各有既有出口);metadata 只补 blockReason/blockedAt, + * unfitRoles 已由调用方(agent-loop recordClaimUnfit)先行落档。 + */ + async blockForAllUnfit(id: string, reason: string): Promise { + const current = (await this.fileStore.getIndex({ id }))[0]; + if (!current) throw new Error('WorkUnit not found'); + if (current.status !== 'unassigned') return null; - this.aggregateParentStatus(id).catch(err => - logger.warn('[WorkUnit] aggregateParentStatus failed', { workUnitId: id, error: String(err) }) - ); + const metadata: WorkUnitMetadata = parseWuMetadata(current.metadata); + // B4: blocked 原因落盘(2026-08-03 token-burn issue P0-2) + metadata.blockReason = reason; + metadata.blockedAt = new Date().toISOString(); // #176(决策 #57 D4):死信计时基准 + const updated = await this.persistSnapshot(current, metadata, { eventType: 'blocked', status: 'blocked' }); return snapshotToData(updated); } @@ -523,9 +583,10 @@ export class WorkUnitService extends WorkUnitCrudService { metadata.blockedAt = new Date().toISOString(); // #176(决策 #57 D4):死信计时基准 } - // in_review → active/blocked 也是状态变化:status_changed 由 persistSnapshot 尾部补发(列表实时刷新) + // in_review → active/blocked 也是状态变化:status_changed 由 persistSnapshot 尾部补发(列表实时刷新)。 + // 本方法历史口径不做父聚合(aggregateParent: false 保留原行为,勿顺手改) const eventType: WorkUnitEvent['type'] = newStatus === 'blocked' ? 'blocked' : 'updated'; - const updated = await this.persistSnapshot(current, metadata, { eventType, status: newStatus }); + const updated = await this.persistSnapshot(current, metadata, { eventType, status: newStatus, aggregateParent: false }); if (newStatus === 'blocked') { logger.warn('[WorkUnit] Auto-blocked after 3 consecutive review rejections', { workUnitId: id }); @@ -560,22 +621,47 @@ export class WorkUnitService extends WorkUnitCrudService { } /** - * 评审/验证写入的共用落库尾部:构建 updated 快照(updatedAt=now,可选 status 覆盖 / - * markCompleted 置 completedAt=同一此刻)→ commitSnapshot(#170:appendEvent + - * upsertSnapshot 同锁成对)+ publishStatusChanged。 - * 各调用方只保留自身策略:守卫、metadata 变更、事件类型、后续级联(父状态聚合/合并触发)。 + * 迁移类写路径的唯一落库尾部(#550 扩面:原仅评审/验证路径,现覆盖 transitionStatus / + * close / markMergeConflict / blockForManualRelease / blockForAllUnfit 全部迁移类写): + * 构建 updated 快照(updatedAt=now;可选 status 覆盖 / markCompleted 置 completedAt=同一此刻 / + * closedAt 落锚规则 / patch 额外字段覆盖)→ commitSnapshot(#170:appendEvent + + * upsertSnapshot 同锁成对)→ publishStatusChanged → 父状态聚合(status ∈ + * active/blocked/done/closed 且未显式关闭时,fire-and-forget 同原 transitionStatus 先例)→ + * reopen(closed → unassigned)thaw 解冻。 + * 各调用方只保留自身策略:状态机/业务守卫、metadata 变更、事件类型、特有级联 + * (如 expandRoutingHead、合并触发)——新迁移入口不得再抄本尾部。 */ private async persistSnapshot( current: WorkUnitSnapshot, - metadata: WorkUnitMetadata, - opts: { eventType: WorkUnitEvent['type']; status?: string; markCompleted?: boolean }, + metadata: WorkUnitMetadata | string | null, + opts: { + eventType: WorkUnitEvent['type']; + status?: string; + markCompleted?: boolean; + /** 额外快照字段覆盖(如 blockForManualRelease 清 assigneeId/claimedAt) */ + patch?: Partial; + /** 父状态聚合开关;缺省 = status ∈ active/blocked/done/closed 时聚合 */ + aggregateParent?: boolean; + }, ): Promise { const isoNow = new Date().toISOString(); + const nextStatus = opts.status ?? current.status; + // string/null 原样透传(调用方未动 metadata 时保持原字节,null 不归一为 '{}'); + // 对象形态由调用方做过策略变更,序列化落盘 + const metadataRaw: string | null = metadata === null + ? null + : typeof metadata === 'string' + ? metadata + : JSON.stringify(metadata); const updated: WorkUnitSnapshot = { ...current, ...(opts.status !== undefined ? { status: opts.status } : {}), - metadata: JSON.stringify(metadata), + ...opts.patch, + metadata: metadataRaw, ...(opts.markCompleted ? { completedAt: isoNow } : {}), + // #327:closedAt 是归档计龄锚点——转入 closed 落锚;从 closed 迁出(reopen,状态机唯一 + // 出口 closed→unassigned)清除,活 WU 的消息不按陈旧锚点被归档;其余迁移保持原值 + closedAt: nextStatus === 'closed' ? isoNow : current.status === 'closed' ? null : current.closedAt, updatedAt: isoNow, }; const event: WorkUnitEvent = { @@ -586,6 +672,26 @@ export class WorkUnitService extends WorkUnitCrudService { }; await this.fileStore.commitSnapshot(event, updated); await this.publishStatusChanged(updated); + + // Cascade: parent status aggregation(fire-and-forget,与原 transitionStatus 先例一致) + const shouldAggregate = opts.aggregateParent + ?? (opts.status !== undefined && ['active', 'blocked', 'done', 'closed'].includes(nextStatus)); + if (shouldAggregate) { + this.aggregateParentStatus(current.id).catch(err => + logger.warn('[WorkUnit] aggregateParentStatus failed', { workUnitId: current.id, error: String(err) }) + ); + } + + // #327:reopen(closed → unassigned)自动解冻——该 WU 已归档消息从冷文件搬回热文件, + // 规则保持一条线:活 WU 的消息永远在热层。best-effort:失败记日志不阻断迁移 + if (current.status === 'closed' && nextStatus === 'unassigned') { + await this.fileStore.thawWorkUnitMessages(current.id).catch(err => + logger.warn('[WorkUnit] Thaw archived messages on reopen failed (non-blocking)', { + workUnitId: current.id, error: String(err), + }) + ); + } + return updated; } diff --git a/apps/api/src/modules/workunit/workunit.types.ts b/apps/api/src/modules/workunit/workunit.types.ts index f0c89dc84..b4d241819 100644 --- a/apps/api/src/modules/workunit/workunit.types.ts +++ b/apps/api/src/modules/workunit/workunit.types.ts @@ -1,6 +1,7 @@ /** * WorkUnit 类型契约 + 状态机表/超时常量(工单 30 自 workunit.service.ts 头部抽出,纯搬运零逻辑变更)。 - * 内容:WorkUnitMetadata / 输入输出 DTO / VALID_TRANSITIONS(+ #108 按 type 覆盖表 DECISION_SPEC_TYPES/TYPE_VALID_TRANSITIONS)/ 租约常量(#178 WU_LEASE_TTL_MS)/ ANALYSIS_TASKS_MAX。 + * 内容:WorkUnitMetadata / VALID_TRANSITIONS(+ #108 按 type 覆盖表 DECISION_SPEC_TYPES/TYPE_VALID_TRANSITIONS)/ 租约常量(#178 WU_LEASE_TTL_MS)/ ANALYSIS_TASKS_MAX。 + * 输入输出 DTO(CreateWorkUnitInput / UpdateWorkUnitInput / WorkUnitData)唯一正本在 workunit-crud.ts(#537 删除本文件的漂移副本)。 * 零服务依赖,供 service 与跨模块类型级消费方直接引用。 */ @@ -65,10 +66,19 @@ export interface WorkUnitMetadata { // 两层互相独立;gate-escalation.ts 锁内合并写,不推进 updatedAt 计时锚) gateReminderTier1At?: string; // tier1 已发(Web 铃铛 + SSE + 浏览器原生通知) gateReminderTier2At?: string; // tier2 已发(notifyAlert 告警通路:企微/告警频道/行动中心) - waitingReason?: string; // 挂起原因:'ownership' = B3a 等待工程归属;'wu-token-budget' = #162 WU 级 token 预算到线(三选分流见 waiting-input.ts);'plan-step-limit' = #471 plan 步数额度到线(回复即续期);'plan-ruling' = #467 裁决轮待裁(结构化提交见 pmo/plan-ruling.ts)(缺省 = agent 提问) + waitingReason?: string; // 挂起原因:'ownership' = B3a 等待工程归属;'wu-token-budget' = #162 WU 级 token 预算到线(三选分流见 waiting-input.ts);'plan-step-limit' = #471 plan 步数额度到线(回复即续期);'plan-ruling' = #467 裁决轮待裁(结构化提交见 pmo/plan-ruling.ts);'plan-direction' = #567 方向锁定待人选定(结构化提交见 pmo/plan-direction.ts)(缺省 = agent 提问) // #467:裁决轮——plan 会话 NEED_INPUT 携 RULING: 行时落档的问题清单 + 每题建议结论 + 默认值 // (裁决接力卡/裁决弹窗预填数据源;人提交裁决后由 pmo/plan-ruling.ts 清除) planRulings?: { question: string; suggestion: string; default?: string }[]; + // #567:方向锁定——plan 会话 NEED_INPUT 携 DIRECTION: 行时落档的方向抉择点 + 候选方向清单 + // (方向接力卡/方向弹窗预填数据源;人提交选定后由 pmo/plan-direction.ts 清除) + planDirections?: { + question: string; + options: { name: string; summary: string; tradeoffs: string; impact: string; recommended: boolean }[]; + }; + // #567:方向锁定开关——派单时显式 directionPick:false 关闭 plan 契约的方向锁定段 + // (prompt-composer buildContractSection 替换为「本单已关闭方向锁定」提示) + directionPick?: boolean; // #471(Triage 定稿 1):plan 步数续期授权额度——缺省 = PLAN_STEP_LIMIT;到线挂起后人回复 // 续期 += PLAN_STEP_LIMIT(waiting-input.ts)。非会话簿记(不随 clearSessionBookkeeping 清除), // 语义同 tokenBudget = 人工授权额度 @@ -131,12 +141,30 @@ export interface WorkUnitMetadata { mergeCommit?: string; // 合并后 baseRepo HEAD(merge commit 全哈希) mergeConflict?: boolean; // 自动合并(含 rebase 重试)仍冲突,已转人工(WU 置 blocked) conflictFiles?: string[]; // 合并冲突文件清单(diff-filter=U) + mergeResolution?: { // 合并冲突 LLM 解审计(merge/rebase 冲突现场交 LLM 解时落档,仿 distill runs 审计思路) + attemptedAt: string; // 尝试开始时间 ISO 8601 + ok: boolean; // 是否解完(确定性校验:冲突标记清零;rebase 现场含重放 merge 成功) + state: 'merge' | 'rebase';// 冲突现场类型(merge = 合并目标目录;rebase = worktree) + cwd: string; // 冲突现场目录(LLM 会话 cwd) + conflictFiles: string[]; // 交给 LLM 时的冲突文件清单 + durationMs: number; // LLM 会话耗时 + summary?: string; // LLM 输出摘要(截断,成功时落) + error?: string; // 失败原因(spawn 失败/超时/仍有残留冲突/重放 merge 失败) + }; knowledgeExtractedAt?: string; // R3: 会话知识提取已触达时间戳(去重——同一 WorkUnit 只提取一次) memoryExtractedAt?: string; // #99: WU 收尾角色记忆批量提取已触达时间戳(去重——同一 WorkUnit 只提取一次;区别于 R3 的 knowledgeExtractedAt) matchedSkills?: string[]; // 决策 7: step 时域匹配命中并实际注入的 skill 名(agent-loop 落盘,度量用) lastCommitHash?: string; // §10.5: PROGRESS 无提交监视 — 上次观察到的 worktree HEAD noCommitSteps?: number; // §10.5: 连续无新提交步数(满 3 步频道提醒一次并归零) commitGuardHint?: string; // §10.5: COMPLETE 被提交守卫打回时的提示(注入下一轮 prompt 后清除) + // 收口闸(产出实)闸 1:代码类 COMPLETE 但 base..HEAD 无任何提交 → 打回(hint 注入下轮 prompt 后清除), + // diffEmptyCount ≥3 → blocked 转人工(模式同 verifyFailCount/verifyFailHint) + diffEmptyCount?: number; // 空 diff 连续打回计数 + diffEmptyHint?: string; // 空 diff 打回提示 + // 收口闸(产出实)闸 2:非代码类契约产物锚点缺失(reviewReport/analysisTasks/specTasks/decisionSuggestion/调研报告落盘) + // → 打回(hint 注入下轮 prompt 后清除),contractArtifactCount ≥3 → blocked 转人工 + contractArtifactCount?: number; // 契约产物缺失连续打回计数 + contractArtifactHint?: string; // 契约产物缺失打回提示 // A2A 协作(2026-07-agent-to-agent-collab-design §5) collab?: { // 协作树追踪(DELEGATE 派生的 WU 携带;根 WU 首次委派后补记) rootId: string; // 协作树根 WU id @@ -207,6 +235,10 @@ export interface WorkUnitMetadata { traceId?: string; // P0 修复 6: 链路追踪 id(频道消息 req → WU → agent-loop 日志;与 audit requestId 同值) // F4 reviewer 解锚(2026-07-28 分析文档,决策 5):评审 WU 未指派走 claim 涌现时的约束/标记 excludeAssignee?: string; // 禁止认领的 profile id(评审排除实现者;agent-loop observe 未指派过滤据此剔除) + // 决策 14 认领前适任判断:被判不适任的角色名单(约束挂 WU 不挂角色身份,合规 ADR D2/决策 10)—— + // 写入方:agent-loop claim 前一次性 LLM 判断(agents/loop/claim-fitness.ts);消费方: + // observe 第 7 道过滤(含本 role.id 即不可见)+ 全员不适任转 blocked 判定 + unfitRoles?: UnfitRoleEntry[]; selfReview?: boolean; // 本评审 WU 未排除实现者(频道内无其他 active 成员)→ 可能是自评,台账/提醒据此标记 reviewInput?: { mode: string; skill: string }; // R3: 评审输入契约落档(diff-only + code-review),审计用 reviewRedispatchAttempts?: number; // #183(#66 决议①):review 对账重跑连续失败次数,≥3 停跑并升 critical @@ -241,68 +273,29 @@ export interface ReviewAttestationSource { summary?: string; } -export interface CreateWorkUnitInput { - type?: string; - scope: string; - assigneeId?: string; - status?: string; - channelId?: string | null; - parentId?: string | null; - projectPath?: string | null; - workspaceId?: string | null; // F6: 绑定工程(显式指定或频道默认) - reqId?: string | null; // REQ 需求编号(vision §5.3:显式/#REQ-XXXX/自动新建) - failureType?: string; - retryCount?: number; - timeoutAt?: Date | null; - completedAt?: Date | null; - metadata?: WorkUnitMetadata; +/** + * 决策 14 认领前适任判断的不适任名单条目。 + * roleId = profile id(与 observe 过滤的 this.role.id 同口径);reason = 判断器一行理由; + * at = 落档时间 ISO 8601。 + */ +export interface UnfitRoleEntry { + roleId: string; + reason: string; + at: string; } -export interface UpdateWorkUnitInput { - type?: string; - scope?: string; - assigneeId?: string | null; - channelId?: string | null; - parentId?: string | null; - projectPath?: string | null; - workspaceId?: string | null; - reqId?: string | null; // REQ 需求编号 - failureType?: string | null; - retryCount?: number; - timeoutAt?: Date | null; - completedAt?: Date | null; - metadata?: WorkUnitMetadata; -} +/** #550(自 wu-closure.ts 归置):结构化关闭事件类型(REST 回放:GET /api/v1/events?type=workunit:closed) */ +export const WORKUNIT_CLOSED_EVENT_TYPE = 'workunit:closed'; -/** - * WorkUnitData — 与 Prisma WorkUnit 类型兼容的平面字段(无 relations)。 - * 日期字段使用 Date 对象(与 Prisma 行为一致),来源是 FileStore 的字符串日期。 - */ -export interface WorkUnitData { - id: string; - parentId: string | null; - type: string; - scope: string; - assigneeId: string | null; - status: string; - failureType: string | null; - retryCount: number; - timeoutAt: Date | null; - channelId: string | null; - projectPath: string | null; - workspaceId?: string | null; // F6: 绑定工程(旧 WorkUnit 无此字段 → null) - reqId?: string | null; // REQ 需求编号(旧 WorkUnit 无此字段 → null) - assigneeRoleId?: string | null; // 认领时冗余的认领方 roleId 快照(旧 WorkUnit 无此字段 → null) - metadata: string | null; - createdAt: Date; - updatedAt: Date; - claimedAt: Date | null; - completedAt: Date | null; - /** #327(additive):关闭时刻——归档计龄锚点。仅 closed 状态有值,reopen 清除 */ - closedAt?: Date | null; - /** #318(additive,ADR D2):可认领标记——仅事件负载(workunit.created/status_changed)与 GET / 列表项附带; - unassigned 且无未了结依赖才 true,其余状态恒 false;snapshotToData 本体不产此字段 */ - claimable?: boolean; +/** #550(自 wu-closure.ts 归置):关闭来源——24h 死信 / 2.5h 总时长强杀 / 人类「关闭」指令 */ +export type WorkUnitClosedBy = 'auto-abandon-stale-blocked' | 'total-time-kill' | 'human-command'; + +/** #550:WorkUnitService.close 入参(事件 payload 与缺省频道文案共用 reason;message 覆盖频道说明全文) */ +export interface CloseWorkUnitOptions { + reason: string; + closedBy: WorkUnitClosedBy; + /** 频道说明全文(缺省 = reason;死信场景传 buildDeadLetterNotice 产物) */ + message?: string; } /** Valid status transitions map */ diff --git a/apps/api/src/modules/workunit/wu-closure.ts b/apps/api/src/modules/workunit/wu-closure.ts deleted file mode 100644 index fd0f9f843..000000000 --- a/apps/api/src/modules/workunit/wu-closure.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * #176(决策 #57 D4 + #62 §3「系统自作主张」双出声原则):系统把 WU 推向终态(closed) - * 的统一关闭出口。 - * - * 双出声 = 落结构化事件(workunit:closed,带 reason/closedBy/blockedAt;schema 归 #60, - * 本票只定「要有」)+ 频道说明(复用 #57 CTA 文案模板,经 wu-messenger 里程碑形态)。 - * 没有例外——autoAbandonStaleBlocked 与 checkTotalExecutionTime 2.5h 强杀均走这里; - * 此原则即未来同类代码的验收标准。 - * - * 快照写入沿用 #170 锁内成对原语(completed 事件 + 索引快照同锁,消除分叉窗口)。 - * 三步各自 best-effort:任一步失败只记日志,不阻断其余(出声尽力而为,关闭不回头)。 - */ -import { logger, type FileStore, type WorkUnitSnapshot, type WorkUnitEvent } from '@dommaker/studio-shared'; -import { postWuSystemMessage } from './wu-messenger.js'; -import { parseWuMetadata } from './wu-metadata.js'; -import { snapshotToData } from './workunit.mappers.js'; -import { writeStudioEvent } from '../../utils/studio-events.js'; - -/** 结构化关闭事件类型(REST 回放:GET /api/v1/events?type=workunit:closed) */ -export const WORKUNIT_CLOSED_EVENT_TYPE = 'workunit:closed'; - -/** 关闭来源:24h 死信 / 2.5h 总时长强杀 / 人类「关闭」指令 */ -export type WorkUnitClosedBy = 'auto-abandon-stale-blocked' | 'total-time-kill' | 'human-command'; - -export interface CloseWorkUnitWithNoticeOptions { - /** 关闭原因(事件 payload 与缺省频道文案共用) */ - reason: string; - closedBy: WorkUnitClosedBy; - /** 频道说明全文(缺省 = reason;死信场景传 buildDeadLetterNotice 产物) */ - message?: string; -} - -/** - * 系统侧关闭 WorkUnit:completed 事件 + closed 快照(锁内成对)→ workunit:closed - * 结构化事件 → 频道里程碑说明。 - * @returns 快照关闭是否成功(事件/频道失败不影响返回值,只记日志) - */ -export async function closeWorkUnitWithNotice( - fileStore: FileStore, - snapshot: WorkUnitSnapshot, - opts: CloseWorkUnitWithNoticeOptions, -): Promise { - const closedAt = new Date().toISOString(); - // #327:closedAt 归档计龄锚点与 completedAt 同刻落盘 - const closed: WorkUnitSnapshot = { ...snapshot, status: 'closed', completedAt: closedAt, closedAt, updatedAt: closedAt }; - - try { - await fileStore.commitSnapshot({ - type: 'completed', - wuId: snapshot.id, - timestamp: closedAt, - data: closed as unknown as Record, - } as WorkUnitEvent, closed); - } catch (err) { - logger.error('[WuClosure] Failed to close workUnit snapshot', { workUnitId: snapshot.id, error: String(err) }); - return false; - } - - await emitWorkUnitClosedEvent(snapshot, opts, closedAt); - - if (snapshot.channelId) { - await postWuSystemMessage(snapshotToData(closed), opts.message ?? opts.reason, { - milestone: true, - fileStore, - }).catch(err => - logger.warn('[WuClosure] Channel notice failed (non-blocking)', { workUnitId: snapshot.id, error: String(err) }) - ); - } - return true; -} - -/** workunit:closed 结构化事件(level=warning,对齐 workunit:failed 分级;fire-and-forget) */ -async function emitWorkUnitClosedEvent( - snapshot: WorkUnitSnapshot, - opts: CloseWorkUnitWithNoticeOptions, - closedAt: string, -): Promise { - try { - const blockedAt = parseWuMetadata(snapshot.metadata).blockedAt; - await writeStudioEvent(WORKUNIT_CLOSED_EVENT_TYPE, { - workUnitId: snapshot.id, - reason: opts.reason, - closedBy: opts.closedBy, - ...(typeof blockedAt === 'string' ? { blockedAt } : {}), - closedAt, - }, { source: 'wu-closure', level: 'warning' }); - } catch (err) { - logger.warn('[WuClosure] Closed event emit failed (non-blocking)', { workUnitId: snapshot.id, error: String(err) }); - } -} diff --git a/apps/api/src/modules/workunit/wu-dependencies.ts b/apps/api/src/modules/workunit/wu-dependencies.ts index b57df969b..5f9e5f33e 100644 --- a/apps/api/src/modules/workunit/wu-dependencies.ts +++ b/apps/api/src/modules/workunit/wu-dependencies.ts @@ -8,7 +8,7 @@ * 本模块是零运行时依赖的叶子(仅 type import),agent-loop 与 workunit 路由共用。 */ -import type { WorkUnitData } from './workunit.types.js'; +import type { WorkUnitData } from './workunit-crud.js'; /** * 容错解析 metadata.blockedBy:字符串/对象入参皆可;缺失/坏 JSON/非数组 → []; diff --git a/apps/api/src/utils/__tests__/cloudflared.test.ts b/apps/api/src/utils/__tests__/cloudflared.test.ts new file mode 100644 index 000000000..864b7f8b0 --- /dev/null +++ b/apps/api/src/utils/__tests__/cloudflared.test.ts @@ -0,0 +1,41 @@ +/** + * cloudflared.ts 单元测试(#571 冲突 5 冻结结论:外联隧道默认关)。 + * + * 行为翻转:仅显式 CLOUDFLARED_ENABLED=true 才启用; + * 未设置 / 'false' / 其他值(含 '1'、'yes')一律不启用。 + */ +import { describe, it, expect } from 'vitest'; +import { isCloudflaredEnabled } from '../cloudflared.js'; + +describe('isCloudflaredEnabled', () => { + it('未设置 → 默认关', () => { + expect(isCloudflaredEnabled({})).toBe(false); + }); + + it("CLOUDFLARED_ENABLED='true' → 开", () => { + expect(isCloudflaredEnabled({ CLOUDFLARED_ENABLED: 'true' })).toBe(true); + }); + + it("CLOUDFLARED_ENABLED='false' → 关", () => { + expect(isCloudflaredEnabled({ CLOUDFLARED_ENABLED: 'false' })).toBe(false); + }); + + it("其他值('1'/'yes'/空串)→ 关(仅精确 'true' 开启)", () => { + expect(isCloudflaredEnabled({ CLOUDFLARED_ENABLED: '1' })).toBe(false); + expect(isCloudflaredEnabled({ CLOUDFLARED_ENABLED: 'yes' })).toBe(false); + expect(isCloudflaredEnabled({ CLOUDFLARED_ENABLED: '' })).toBe(false); + }); + + it('缺省读 process.env', () => { + const prev = process.env.CLOUDFLARED_ENABLED; + delete process.env.CLOUDFLARED_ENABLED; + try { + expect(isCloudflaredEnabled()).toBe(false); + process.env.CLOUDFLARED_ENABLED = 'true'; + expect(isCloudflaredEnabled()).toBe(true); + } finally { + if (prev === undefined) delete process.env.CLOUDFLARED_ENABLED; + else process.env.CLOUDFLARED_ENABLED = prev; + } + }); +}); diff --git a/apps/api/src/utils/__tests__/frontend-dist.test.ts b/apps/api/src/utils/__tests__/frontend-dist.test.ts new file mode 100644 index 000000000..8325c93f7 --- /dev/null +++ b/apps/api/src/utils/__tests__/frontend-dist.test.ts @@ -0,0 +1,65 @@ +/** + * frontend-dist.ts 单元测试(#571 冲突 8 冻结结论:preflight auto-build 语义翻转)。 + * + * - dist 存在 → 直接通过; + * - dist 缺失且 monorepo dev 形态(apps/web 源码在)→ auto-build 分支保留; + * - dist 缺失且无 apps/web 源码(npm 形态)→ 包损坏:报错 + 重装提示,不现场构建。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { ensureFrontendDist } from '../frontend-dist.js'; + +let tmp: string; +let frontendDist: string; +let repoDir: string; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-frontend-dist-')); + frontendDist = path.join(tmp, 'pkg/frontend/dist'); + repoDir = path.join(tmp, 'repo'); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('ensureFrontendDist', () => { + it('dist/index.html 存在 → ok,不触发构建', () => { + fs.mkdirSync(frontendDist, { recursive: true }); + fs.writeFileSync(path.join(frontendDist, 'index.html'), ''); + let built = false; + const r = ensureFrontendDist(frontendDist, repoDir, () => { built = true; }); + expect(r.ok).toBe(true); + expect(r.autoBuilt).toBeFalsy(); + expect(built).toBe(false); + }); + + it('dist 缺失 + apps/web 源码在(monorepo dev)→ 调 builder auto-build', () => { + fs.mkdirSync(path.join(repoDir, 'apps/web'), { recursive: true }); + let builtWith: string | null = null; + const r = ensureFrontendDist(frontendDist, repoDir, (webDir) => { builtWith = webDir; }); + expect(r.ok).toBe(true); + expect(r.autoBuilt).toBe(true); + expect(builtWith).toBe(path.join(repoDir, 'apps/web')); + }); + + it('auto-build 抛错 → 不 ok,文案含失败原因', () => { + fs.mkdirSync(path.join(repoDir, 'apps/web'), { recursive: true }); + const r = ensureFrontendDist(frontendDist, repoDir, () => { throw new Error('vite boom'); }); + expect(r.ok).toBe(false); + expect(r.corrupt).toBeFalsy(); + expect(r.message).toContain('vite boom'); + }); + + it('dist 缺失 + 无 apps/web(npm 形态)→ 包损坏:不构建、报错含重装提示', () => { + let built = false; + const r = ensureFrontendDist(frontendDist, repoDir, () => { built = true; }); + expect(r.ok).toBe(false); + expect(r.corrupt).toBe(true); + expect(built).toBe(false); + expect(r.message).toMatch(/损坏|corrupt/i); + expect(r.message).toMatch(/重装|reinstall|npm install/i); + }); +}); diff --git a/apps/api/src/utils/__tests__/listen-error.test.ts b/apps/api/src/utils/__tests__/listen-error.test.ts new file mode 100644 index 000000000..9306e62c1 --- /dev/null +++ b/apps/api/src/utils/__tests__/listen-error.test.ts @@ -0,0 +1,41 @@ +/** + * listen-error.ts 单元测试(#573 端口双口径收口)。 + * + * 冻结语义(docs/architecture/data-directory-contract.md §7 / 摸底冲突 4): + * 启动前端口已由 port-probe 单口径解析(默认动态顺延 / 显式占用即拒启), + * listen 时再撞 EADDRINUSE = 与启动前探测竞态——报错拒启,删除 3s 无限重试。 + */ +import { describe, it, expect, vi } from 'vitest'; +import { handleServerListenError } from '../listen-error.js'; + +function makeDeps() { + const errors: Array<{ msg: string; meta?: Record }> = []; + const exit = vi.fn(); + const logger = { error: (msg: string, meta?: Record) => { errors.push({ msg, meta }); } }; + return { errors, exit, logger }; +} + +describe('handleServerListenError', () => { + it('EADDRINUSE → 报错拒启(exit 1),不无限重试', () => { + const { errors, exit, logger } = makeDeps(); + handleServerListenError( + { code: 'EADDRINUSE', message: 'listen EADDRINUSE: address in use' }, + { port: 3001, host: '127.0.0.1', logger, exit }, + ); + expect(exit).toHaveBeenCalledWith(1); + expect(errors).toHaveLength(1); + expect(errors[0].msg).toContain('3001'); + expect(errors[0].msg).toMatch(/refus|拒启/i); + }); + + it('非 EADDRINUSE → 只记日志,不拒启', () => { + const { errors, exit, logger } = makeDeps(); + handleServerListenError( + { code: 'EACCES', message: 'permission denied' }, + { port: 80, host: '127.0.0.1', logger, exit }, + ); + expect(exit).not.toHaveBeenCalled(); + expect(errors).toHaveLength(1); + expect(errors[0].meta).toMatchObject({ code: 'EACCES', port: 80 }); + }); +}); diff --git a/apps/api/src/utils/__tests__/runtime-paths.test.ts b/apps/api/src/utils/__tests__/runtime-paths.test.ts new file mode 100644 index 000000000..03187d153 --- /dev/null +++ b/apps/api/src/utils/__tests__/runtime-paths.test.ts @@ -0,0 +1,57 @@ +/** + * runtime-paths.ts 单元测试(#571:KNOWLEDGE_DIR / TUNNEL_URL_FILE 归数据根)。 + * + * 冻结结论(data-directory-contract.md §8 待归位): + * - KNOWLEDGE_DIR 缺省 = studioPath('harness-knowledge'),env 可覆盖; + * - TUNNEL_URL_FILE 缺省 = studioPath('tunnel-url')(自 ~/.claude/tunnel-url 迁入),env 可覆盖。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; + +let prevStudioHome: string | undefined; +let prevKnowledgeDir: string | undefined; +let prevTunnelFile: string | undefined; +const TEST_HOME = path.join(__dirname, '.tmp-runtime-paths-home'); + +beforeEach(() => { + prevStudioHome = process.env.STUDIO_HOME; + prevKnowledgeDir = process.env.KNOWLEDGE_DIR; + prevTunnelFile = process.env.TUNNEL_URL_FILE; + process.env.STUDIO_HOME = TEST_HOME; + delete process.env.KNOWLEDGE_DIR; + delete process.env.TUNNEL_URL_FILE; +}); + +afterEach(() => { + if (prevStudioHome === undefined) delete process.env.STUDIO_HOME; else process.env.STUDIO_HOME = prevStudioHome; + if (prevKnowledgeDir === undefined) delete process.env.KNOWLEDGE_DIR; else process.env.KNOWLEDGE_DIR = prevKnowledgeDir; + if (prevTunnelFile === undefined) delete process.env.TUNNEL_URL_FILE; else process.env.TUNNEL_URL_FILE = prevTunnelFile; +}); + +describe('defaultKnowledgeDir', () => { + it('KNOWLEDGE_DIR 未设 → studioPath(harness-knowledge)', async () => { + const { defaultKnowledgeDir } = await import('../runtime-paths.js'); + expect(defaultKnowledgeDir()).toBe(path.join(TEST_HOME, 'harness-knowledge')); + }); + + it('KNOWLEDGE_DIR 显式设置 → env 优先', async () => { + process.env.KNOWLEDGE_DIR = '/custom/knowledge'; + const { defaultKnowledgeDir } = await import('../runtime-paths.js'); + expect(defaultKnowledgeDir()).toBe('/custom/knowledge'); + }); +}); + +describe('tunnelUrlFile', () => { + it('TUNNEL_URL_FILE 未设 → studioPath(tunnel-url),不再落 ~/.claude', async () => { + const { tunnelUrlFile } = await import('../runtime-paths.js'); + const p = tunnelUrlFile(); + expect(p).toBe(path.join(TEST_HOME, 'tunnel-url')); + expect(p).not.toContain('.claude'); + }); + + it('TUNNEL_URL_FILE 显式设置 → env 优先', async () => { + process.env.TUNNEL_URL_FILE = '/custom/tunnel-url'; + const { tunnelUrlFile } = await import('../runtime-paths.js'); + expect(tunnelUrlFile()).toBe('/custom/tunnel-url'); + }); +}); diff --git a/apps/api/src/utils/__tests__/studio-events-rotation.test.ts b/apps/api/src/utils/__tests__/studio-events-rotation.test.ts index 369de22af..730ca80dc 100644 --- a/apps/api/src/utils/__tests__/studio-events-rotation.test.ts +++ b/apps/api/src/utils/__tests__/studio-events-rotation.test.ts @@ -77,6 +77,10 @@ describe('classifyStudioEventForRetention(#60 决议口径:level=debug 为 expect(classifyStudioEventForRetention({ type: 'knowledge:outcome', level: 'warning' })).toBe('signal'); }); + it('票C: skill_used 显式 level=info → signal(发射点提级,type 大类默认不变)', () => { + expect(classifyStudioEventForRetention({ type: 'knowledge:skill_used', level: 'info' })).toBe('signal'); + }); + it('type 缺失/非字符串 → signal(宁可永久留,不静默丢数据)', () => { expect(classifyStudioEventForRetention({})).toBe('signal'); expect(classifyStudioEventForRetention({ type: 42 })).toBe('signal'); @@ -121,6 +125,20 @@ describe('rotateStudioEvents', () => { expect(readHotLines(file)).toEqual([]); }); + it('票C: level=info 的 skill_used 超 7 天不滚(归 signal 热 30 天),超 30 天进归档不删除', async () => { + const hotSignal = eventLine('knowledge:skill_used', isoDaysAgo(NOISE_RETENTION_DAYS + 1), { level: 'info' }); + const oldSignal = eventLine('knowledge:skill_used', isoDaysAgo(SIGNAL_HOT_DAYS + 1), { level: 'info' }); + writeHot([hotSignal, oldSignal]); + + const result = await rotateStudioEvents({ file, now: NOW }); + + expect(result.noiseDropped).toBe(0); + expect(result.signalArchived).toBe(1); + expect(readHotLines(file)).toEqual([hotSignal]); + const month = new Date(NOW.getTime() - (SIGNAL_HOT_DAYS + 1) * DAY_MS).toISOString().slice(0, 7); + expect(readGzLines(path.join(archiveDir, `studio-events-${month}.jsonl.gz`))).toEqual([oldSignal]); + }); + it('7 天规则不伤信号:8 天前的信号事件保留在热文件', async () => { const signal = eventLine('workunit:execution_step', isoDaysAgo(NOISE_RETENTION_DAYS + 1)); writeHot([signal]); diff --git a/apps/api/src/utils/cloudflared.ts b/apps/api/src/utils/cloudflared.ts new file mode 100644 index 000000000..9f7b990bf --- /dev/null +++ b/apps/api/src/utils/cloudflared.ts @@ -0,0 +1,10 @@ +/** + * cloudflared — 隧道启停判定(#571 冲突 5 冻结结论:外联隧道默认关) + * + * 仅显式 CLOUDFLARED_ENABLED=true 才拉起隧道;未设置/其他值一律不启用。 + * 现有服务器形态部署需在其 config.env 显式开启(行为翻转,发布说明标注)。 + */ + +export function isCloudflaredEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.CLOUDFLARED_ENABLED === 'true'; +} diff --git a/apps/api/src/utils/frontend-dist.ts b/apps/api/src/utils/frontend-dist.ts new file mode 100644 index 000000000..f2971abb5 --- /dev/null +++ b/apps/api/src/utils/frontend-dist.ts @@ -0,0 +1,65 @@ +/** + * frontend-dist — 前端产物存在性检查与构建分支(#571 冲突 8 冻结结论) + * + * - dist 存在 → 通过; + * - dist 缺失且 monorepo dev 形态(/apps/web 源码在)→ auto-build 分支保留; + * - dist 缺失且无 apps/web 源码(npm 形态)→ 包损坏:报错退出并提示重装,不现场构建。 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +export interface FrontendDistResult { + ok: boolean; + message: string; + /** 走了 monorepo dev 现场构建分支 */ + autoBuilt?: boolean; + /** npm 形态包损坏(缺 dist 且无源码可构建) */ + corrupt?: boolean; +} + +/** 构建器:在 webDir 执行 vite build 并把产物落到 frontendDistPath(可注入以便测试) */ +export type FrontendBuilder = (webDir: string, frontendDistPath: string) => void; + +const defaultBuilder: FrontendBuilder = (webDir, frontendDistPath) => { + execSync('npx vite build', { cwd: webDir, stdio: 'pipe', timeout: 120_000 }); + const srcDist = path.join(webDir, 'dist'); + if (!fs.existsSync(srcDist)) throw new Error('Frontend build produced no dist'); + fs.mkdirSync(path.dirname(frontendDistPath), { recursive: true }); + execSync(`cp -r "${srcDist}/"* "${frontendDistPath}/"`, { stdio: 'pipe' }); +}; + +export function ensureFrontendDist( + frontendDistPath: string, + repoDir: string, + build: FrontendBuilder = defaultBuilder, +): FrontendDistResult { + const indexHtml = path.join(frontendDistPath, 'index.html'); + if (fs.existsSync(indexHtml)) { + return { ok: true, message: 'Frontend dist exists' }; + } + + const webDir = path.join(repoDir, 'apps/web'); + if (!fs.existsSync(webDir)) { + // npm 形态:包内应自带预构建 dist;缺失 = 包损坏,不现场构建(冻结:冲突 8) + return { + ok: false, + corrupt: true, + message: + '❌ Frontend dist missing and no apps/web source to build from — ' + + 'the installed package is incomplete/corrupt(包损坏). ' + + 'Reinstall(重装): npm install -g @dommaker/studio', + }; + } + + try { + build(webDir, frontendDistPath); + return { ok: true, autoBuilt: true, message: 'Frontend built and deployed' }; + } catch (e: any) { + return { + ok: false, + message: `⚠️ Frontend dist missing and auto-build failed: ${String(e?.message ?? e).slice(0, 100)}`, + }; + } +} diff --git a/apps/api/src/utils/listen-error.ts b/apps/api/src/utils/listen-error.ts new file mode 100644 index 000000000..9965b1d74 --- /dev/null +++ b/apps/api/src/utils/listen-error.ts @@ -0,0 +1,36 @@ +/** + * listen-error — server.listen 错误处理(#573 端口双口径收口) + * + * 冻结语义(docs/architecture/data-directory-contract.md §7 / 摸底冲突 4): + * 启动前端口已由 port-probe 单口径解析(默认 3001 动态顺延上限 +100; + * 显式 --port/PORT 占用即拒启)。listen 时再撞 EADDRINUSE = 与启动前探测 + * 竞态——报错拒启;旧 index.ts 的 3s 无限重试已删除,全系统只此一套口径。 + */ + +export interface ListenErrorLogger { + error(msg: string, meta?: Record): void; +} + +export interface ListenErrorOptions { + port: number | string; + host: string; + logger: ListenErrorLogger; + /** 默认 process.exit;测试注入 spy */ + exit?: (code: number) => void; +} + +export function handleServerListenError( + err: { code?: string; message?: string } | undefined, + opts: ListenErrorOptions, +): void { + if (err?.code === 'EADDRINUSE') { + opts.logger.error( + `Port ${opts.port} on ${opts.host} became unavailable at listen time — refusing to start ` + + '(拒启:端口在启动前探测后又被占用;无自动重试,端口口径见 cli/port-probe)', + { code: err.code, port: opts.port, host: opts.host }, + ); + (opts.exit ?? process.exit)(1); + return; + } + opts.logger.error('Server listen error', { code: err?.code, message: err?.message, port: opts.port }); +} diff --git a/apps/api/src/utils/runtime-paths.ts b/apps/api/src/utils/runtime-paths.ts new file mode 100644 index 000000000..0f7475fd7 --- /dev/null +++ b/apps/api/src/utils/runtime-paths.ts @@ -0,0 +1,19 @@ +/** + * runtime-paths — KNOWLEDGE_DIR / TUNNEL_URL_FILE 归数据根(#571) + * + * 冻结结论(docs/architecture/data-directory-contract.md §8 待归位): + * - KNOWLEDGE_DIR 缺省自 monorepo 相对路径迁至 studioPath('harness-knowledge'),env 可覆盖; + * - TUNNEL_URL_FILE 自 ~/.claude/tunnel-url 迁至 studioPath('tunnel-url'),env 可覆盖。 + */ + +import { studioPath } from '@dommaker/studio-shared/studio-dir'; + +/** KNOWLEDGE_DIR 缺省值(env 已设则原样返回) */ +export function defaultKnowledgeDir(env: NodeJS.ProcessEnv = process.env): string { + return env.KNOWLEDGE_DIR || studioPath('harness-knowledge'); +} + +/** tunnel URL 落盘文件(契约 §2② 新条目 tunnel-url) */ +export function tunnelUrlFile(env: NodeJS.ProcessEnv = process.env): string { + return env.TUNNEL_URL_FILE || studioPath('tunnel-url'); +} diff --git a/apps/api/src/utils/studio-events-rotation.ts b/apps/api/src/utils/studio-events-rotation.ts index d66d0b355..a4ac66205 100644 --- a/apps/api/src/utils/studio-events-rotation.ts +++ b/apps/api/src/utils/studio-events-rotation.ts @@ -5,6 +5,8 @@ * * - 分类口径 = #172 落地的 envelope level:level=debug(knowledge:*、tool:call 默认分级) * 为噪声;其余(缺省 info / warning / critical)为信号。显式 level 字段优先于 type 默认分级。 + * 例外:knowledge:skill_used 自 skill 度量地基票 C(2026-09-15)起发射点显式 + * level=info → 归 signal(skill 使用度量需归档保留);type 大类默认不变。 * - 噪声:热文件内超过 7 天(NOISE_RETENTION_DAYS)即滚动删除,不进归档。 * - 信号:热文件保留 30 天(SIGNAL_HOT_DAYS,趋势探测/复盘直读纯文本),超期按事件月份 * 切 `archive/studio-events-YYYY-MM.jsonl.gz`(相对热文件目录)永久保留——冷包只增不删; diff --git a/apps/api/tests/review-gate.test.ts b/apps/api/tests/review-gate.test.ts index bdd982faa..b378a5238 100644 --- a/apps/api/tests/review-gate.test.ts +++ b/apps/api/tests/review-gate.test.ts @@ -1,7 +1,14 @@ /** * AS-007 ReviewGate 集成测试 - * + * * 验证 PR 创建后 ReviewGate 验证审查状态 + * + * 注意(#535 追根结论):AS-007 原始需求文档不在本仓可检索范围(docs/sdd、 + * GitHub issues、归档均零命中),且 studio 生产代码零引用 ReviewGate/GateContext + * ——本文件自初始提交起即是「自调 mock 再断言 mock 被调」的形状,钉的是 + * harness `ReviewGate.check(ctx: GateContext): Promise` 的公开 API + * 契约(harness 侧 check() 保留为报告层,evaluate() 由它推导),作为将来 + * studio 接线 ReviewGate 时的集成契约草图保留,不声称钉任何 studio 生产接缝。 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -34,9 +41,13 @@ describe('AS-007: ReviewGate Integration', () => { }); /** - * AC-001:createPullRequest 调用 ReviewGate.check() + * AC-001:ReviewGate.check() 接受 GateContext 并返回审查结果 + * + * 原描述「createPullRequest 调用 ReviewGate.check()」钉的是 studio 生产接缝, + * 但 studio 生产代码从未接线 ReviewGate(全仓 grep 零命中,见文件头注); + * 本用例实际钉的是 harness check() 的入参/返回形状,故如实改名保留。 */ - it('AC-001: should call ReviewGate.check() after PR creation', async () => { + it('AC-001: ReviewGate.check() accepts GateContext and returns GateResult', async () => { const project = { id: 'test-project', pmoNumber: 'PM-001', @@ -57,10 +68,9 @@ describe('AS-007: ReviewGate Integration', () => { // 验证 ReviewGate.check 被调用 expect(reviewGate.check).toBeDefined(); - + const gateContext: GateContext = { - projectId: project.id, - projectPath: `/root/projects/${project.pmoNumber}`, + projectPath: `projects/${project.pmoNumber}`, prNumber: 123, }; @@ -75,8 +85,7 @@ describe('AS-007: ReviewGate Integration', () => { */ it('AC-002: should return reviewStatus in PR result', async () => { const gateContext: GateContext = { - projectId: 'test-project', - projectPath: '/root/projects/PM-001', + projectPath: 'projects/PM-001', prNumber: 123, }; @@ -124,14 +133,14 @@ describe('AS-007: ReviewGate Integration', () => { }); const workDirs = JSON.parse(process.env.PROJECT_WORKDIRS || '{}'); - const path = workDirs[projectId] || `/root/projects/${pmoNumber}`; + const path = workDirs[projectId] || `projects/${pmoNumber}`; expect(path).toBe('/custom/path/PM-001'); // 方案 B: 默认规则 delete process.env.PROJECT_WORKDIRS; - const defaultPath = `/root/projects/${pmoNumber}`; - expect(defaultPath).toBe('/root/projects/PM-001'); + const defaultPath = `projects/${pmoNumber}`; + expect(defaultPath).toBe('projects/PM-001'); }); /** @@ -139,8 +148,7 @@ describe('AS-007: ReviewGate Integration', () => { */ it('AC-005: should not block PR creation when ReviewGate fails', async () => { const gateContext: GateContext = { - projectId: 'test-project', - projectPath: '/root/projects/PM-001', + projectPath: 'projects/PM-001', prNumber: 123, }; @@ -160,8 +168,7 @@ describe('AS-007: ReviewGate Integration', () => { }; const gateContext: GateContext = { - projectId: 'test-project', - projectPath: '/root/projects/PM-001', + projectPath: 'projects/PM-001', prNumber: 123, }; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b3958c4b7..d6bc6b480 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -40,6 +40,7 @@ import { WebSocketProvider } from './api/websocket'; import { channelApi } from './api/channel'; import { useRosterStore } from './stores/rosterStore'; import { useRequirementChainStoreSync } from './hooks/useRequirementChainStoreSync'; +import { useWorkUnitStoreSync } from './hooks/useWorkUnitStoreSync'; import { usePmoDataStoreSync } from './hooks/usePmoDataStoreSync'; import { useGlobalShortcuts } from './hooks/useGlobalShortcuts'; import { StudioRoleSetupModal } from './components/setup/StudioRoleSetupModal'; @@ -60,6 +61,13 @@ function PmoDataSync() { return null; } +// #549:WU 数据面 SSE 被动写收口(App 级单点、零渲染;消费方分布在 ListPage/Drawer, +// 事件路由唯一一份在 useWorkUnitStoreSync,页面退回 store 订阅者) +function WorkUnitStoreSync() { + useWorkUnitStoreSync(); + return null; +} + // #474:/project/:id 旧路由收编——双路由渲染同一页面已合并为 /pmo/project/:id 唯一入口, // 存量链接(书签/外部引用)在此 301 式重定向(replace 不留历史栈) function LegacyProjectRedirect() { @@ -176,6 +184,8 @@ export default function App() { {/* #456:PMO 数据面接线(App 级单点) */} + {/* #549:WU 数据面 SSE 接线(App 级单点) */} + {/* AC-2.2: studio 角色 provider=null 弹框 */} ` 整页跳改 `onOpenWu(wuId)` 开就地抽屉(AgentDashboardPage 自挂 `.ac-drawer-host` 懒加载 WorkUnitDrawer,「↗ 详情页」深链在抽屉 subject 行保留);onOpenWu 是 memo 契约 prop(页面 useCallback 稳定引用);PMO/频道/角色名/无 WU 动态落点仍 `` 跳页 | | `CreateRoleModal` | `components/monitoring/CreateRoleModal.tsx` | 创建角色弹框(#397 §6.4,E8-3 后唯一主路径——原替代的 /setup/roles 僵尸向导页已随 E8 删除):勾选 /workspaces/runtimes + 命名 → channelApi.createAgent;保存=关弹框+onCreated 就地刷新,不跳页。**E8-4 起为创建角色表单唯一正本**(WorkspacePage 内嵌 dialog 已归并):可选 `presetProvider` prop 供调用方锁定 CLI(WorkspacePage 行内「设为角色」)——跳过 runtime 清单拉取、单项固定预选、provider 只读展示;preset 单项命名区不依赖 agent-dashboard.css 的 agd-cr-* 类(该样式仅由 AgentDashboardPage 引入),只用全局 utility 类。**2026-09-10 起候选清单只含本机 CLI 且按 provider 去重**(端点收敛,`nodeId`/`workspaceName` 不再下发,行内 `@ workspace` 节点名展示随之删除);空态区分「拉取失败」与「一个都没装」 | | `RoleSkillsModal` | `components/monitoring/RoleSkillsModal.tsx` | 角色技能编辑弹框(#462:role.skills = 注入索引候选,与 WU +skill 点名同权):候选 = GET /skills/manifest(loop-consumer 已被服务端过滤),已声明但不在 MANIFEST 的名并入候选防静默丢声明;保存 = channelApi.updateAgent(id, { skills })([] 清空)。入口 = AgentDetailPage 右栏「技能」卡;acceptedTypes 不进 UI(数据模型保留做长尾排序与子单 type 推导) | | `useAssigneeDisplay` / `AssigneeLabel` | `hooks/useAssigneeDisplay.ts` / `components/workunit/AssigneeLabel.tsx` | 负责人 id → 角色名解析,顺序:⓪ assigneeRoleId 认领快照(2026-09-10 起 claim 时把认领方 roleId 冗余到 WU,实例回收后仍可解析)→ ① 运行实例 → ①.5 profile 直配(assigneeId 双语义——未认领指名 WU 的 assigneeId = profile.id)→ 短 UUID;原「离线实例档案点查」段已删除(实例回收后点查必失败,纯 console 噪音);WU 详情/抽屉/REQ 链路/列表行(#474 起,替代行内截断 hash)共用 | -| `useChannelList` / `useChannelLiveExecutions` / `useDetectedProviders` | `hooks/` | 频道列表 / live 执行 / provider 探测(#403 起支持 `{ enabled }` 懒挂载:false 不发请求,展开才扫且每次挂载只扫一次) | +| `useChannelList` / `useChannelLiveExecutions` / `useDetectedProviders` | `hooks/` | 频道列表 / live 执行 / provider 探测(#403 起支持 `{ enabled }` 懒挂载:false 不发请求,展开才扫且每次挂载只扫一次)。**F3/F4(2026-09-16 性能体检)**:`useChannelLiveExecutions` step 事件加等值守卫(同 wuId step+action 全等值跳过 setState);`useChannelList` 不再订阅 unreadCounts 整对象(任一频道 message_sent 会穿透消费方整栏重渲),未读徽章由 ChannelRail 频道行内 per-channel selector 自取。**#565**:`buildProviderOptions` 对 `auth==='failed'` 的 provider 标「⚠ 未登录」徽标(label 后缀)+ 选项 title 放修复 hint(`authHint`,Select 选项行 tooltip;unknown/ok 不标) | | `ChannelHomeRedirect` / `lastChannel` | `pages/ChannelHomeRedirect.tsx` / `utils/lastChannel.ts` | #393:`/` 与 `/channels` 重定向进频道工作区(频道列表页已删除,spec §2);落点 = 最近访问(ChannelDetailPage 进页写 localStorage)→ rnd → 首频道 → 零频道空态内联 CreateChannelForm | | `BackButton` | `components/ui/BackButton.tsx` | #393 详情页统一左上「← 返回」(spec §4.4):history.state.idx>0 → navigate(-1),直开/书签回落 fallback 列表页;PMO 项目/Agent/WU/阅览室文档/Workspace 五页接入 | -| `deriveStreamView` | `utils/streamView.ts` | 消息流管线纯函数(#322):消息集+折叠/筛选 UI 状态 → 渲染就绪 items(归组/过程折叠/连续合并/日期分隔/可见性)。**2026-09 折叠层级 4→2**(docs/plans/2026-09-ui-smoothness.md Step 3):`StreamUiState.expandedThreads` → `collapsedThreads` 语义反转——线程默认展开,集合只存手动收起的锚点 id。**Phase 2 归组泛化**(2026-09 channel 上下游优化 AC2,docs/plans/2026-09-channel-upstream-downstream-ux.md):`groupIntoThreads` 两遍扫描,anchor 条件放宽为 `!replyToId && (workUnitId || 被回复过)`——任意消息可成线程 anchor,多层回复沿 replyToId 链拍平进线程根(`rootAnchorIdOf(msg, byId)` 导出,ChannelDetailPage 定位展开链路共用);链断裂(父消息未加载)落主流兜底。**Phase 3 告警折叠+键盘导航**(AC3/AC5):`isMonitorAlert`(Studio 署名无卡非等待 + [CRITICAL]/[WARNING] 前缀)主流连续 ≥3 条经 `foldAlertGroups`(日期分隔计算之后的聚合 pass,组继承首条日期分隔站位,组内跨天展开时自渲染分隔)收成 `alert-group` StreamItem(key `alerts-<首条id>`,severity 计数,expanded 读 `expandedAlertGroups`);`navigableIdsOf(items)` 导出 j/k 可导航 id 序列(折叠组/日期分隔跳过) | +| `ApproveDialogShell` | `components/ui/ApproveDialogShell.tsx` | 审批弹窗公共壳(2026-09 web-ux-optional-fixes Step 3,Analysis/Decision/Spec/PlanRuling 四弹窗同构骨架收敛):壳承载 submitting/submitError 状态 + run 提交包装(防重入、失败内联保持打开)+ 提交中关窗屏蔽(遮罩/Escape/✕,批次 I-2 语义)+ footer 固定「取消」键 + body 末尾统一错误行;差异区 = title/maxWidth/children + actions render prop(取消键之后的动作键,ctx 取 {submitting, run}) | +| `deriveStreamView` | `utils/streamView.ts` | 消息流管线纯函数(#322):消息集+折叠/筛选 UI 状态 → 渲染就绪 items(归组/过程折叠/连续合并/日期分隔/可见性)。**2026-09 折叠层级 4→2**(docs/plans/2026-09-ui-smoothness.md Step 3):`StreamUiState.expandedThreads` → `collapsedThreads` 语义反转——线程默认展开,集合只存手动收起的锚点 id。**Phase 2 归组泛化**(2026-09 channel 上下游优化 AC2,docs/plans/2026-09-channel-upstream-downstream-ux.md):`groupIntoThreads` 两遍扫描,anchor 条件放宽为 `!replyToId && (workUnitId || 被回复过)`——任意消息可成线程 anchor,多层回复沿 replyToId 链拍平进线程根(`rootAnchorIdOf(msg, byId)` 导出,ChannelDetailPage 定位展开链路共用);链断裂(父消息未加载)落主流兜底。**Phase 3 告警折叠+键盘导航**(AC3/AC5):`isMonitorAlert`(Studio 署名无卡非等待 + [CRITICAL]/[WARNING] 前缀)主流连续 ≥3 条经 `foldAlertGroups`(日期分隔计算之后的聚合 pass,组继承首条日期分隔站位,组内跨天展开时自渲染分隔)收成 `alert-group` StreamItem(key `alerts-<首条id>`,severity 计数,expanded 读 `expandedAlertGroups`);`navigableIdsOf(items)` 导出 j/k 可导航 id 序列(折叠组/日期分隔跳过)。**2026-09-16 性能体检 F1**:completed 判定 Set 化单遍过滤(旧 includes 嵌套 O(n×c));输入升序不变量下免全量 sort(乱序输入兜底排序保旧契约);`toLocaleDateString` 按 id+createdAt 缓存(2k 消息温热态派生本机 ~195ms → ~30ms,测试有宽松阈值防回归) | | `usePersistentStreamUI` | `hooks/usePersistentStreamUI.ts` | 频道折叠 UI 状态按频道持久化(Step 3):showCompleted/collapsedThreads/expandedProcGroups/expandedAlertGroups(#channel-ux P3,旧存档缺字段回退空集)四项存 localStorage(key `mc-stream-ui:v1:`,Set↔数组 JSON),切频道渲染期换装存档、损坏/缺字段回退默认;setter 与 useState 的 Dispatch 签名对齐,页面零改造迁移 | | `resolveDisplayStatus` / `DISPLAY_STATUS_LABELS` / `DisplayStatusKey` | `utils/agentStatus.ts` | Agent 状态 7+1 细分态 → UI 4 态展示层(工作中/待处理/空闲/离线,Step 1):细分态保留作细分依据(详情页小字/error 红点角标),`CARD_TO_DISPLAY_STATUS` 唯一映射;`AGENT_STATUS_RANK` 收窄 4 态、`StatusFilter`='all'‖4 态,pill/页头筛选 chip/排序同源消费 | | `digestContent` / `CONSUMPTION_MODE_CHART` / `UnifiedEntryContent` / `MATURITY_BADGE_CLASSES` | `utils/knowledgeContent.ts` / `components/knowledge/UnifiedEntryContent.tsx` / `components/knowledge/GapCards.tsx` | #435 B7:知识库统一视图内容消化——content 为 JSON 对象 → 键值列表(字段 >6 默认收起前 6 个 + 展开全部 N 字段),其余文本 >200 字符截断+展开/收起,不直出存储层原文;consumptionMode 徽标改 --chart-* 类别色文字+中性底(rule=2 紫 / signal=7 青 / context=1 蓝,§6.5 类别不占状态色),无映射归中性;`MATURITY_BADGE_CLASSES`(E5)= 成熟度徽标词表唯一出口(pending/draft→warn,verified→accent,canonical→ok,deprecated→中性),ResolutionCard 与 KnowledgePage 统一视图同源消费 | @@ -52,18 +57,20 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `CommandPalette` / `useGlobalShortcuts` | `components/CommandPalette.tsx` / `hooks/useGlobalShortcuts.ts` | 批次 D-2 项 8:Cmd/Ctrl+K 全局搜索面板 + 全局快捷键注册底座(细节见运行时约定「全局搜索」条) | | `useStreamFollow` / `useChannelCardActions` | `hooks/` | 频道流滚动状态机(#322 自 ChannelDetailPage 整块搬移)/ 卡片 action 路由(dispatch 单一入口;#352 起人审提案分支经 PROPOSAL_ACTION_INDEX 参数化调用 proposalCardConfigs.exec) | | `useMessageLocate` | `hooks/useMessageLocate.ts` | 消息定位单入口(#530,架构评审 2026-09-14 候选 2):mid→可见 完整语义——`locate(mid)` 已加载 → 根锚解析展开收起线程 + 高亮(滚动 + 2s 消退 + unpin 全内化),未加载 → #439 翻页定位循环(上限 10 页)翻到底/超限 → toast 兜底;#533 起对外只暴露 `locate(mid)`——wu→mid 锚点收口后端 action-center 下发 messageId,业务口径 find 退休,locateBy 收回模块内私有(翻页期间按新快照重评估);防重入单飞台账(同 key 跳过、后到者赢)。useStreamFollow 底座不动(unpin 由它提供,模块只负责调用时机)。入参产物(unpin/virtualizer/messageToItemIndex/setCollapsedThreads)#531 起由 useChannelStream 契约供给,平级单向依赖 | -| `useChannelStream` / `ChannelStreamBody` | `hooks/useChannelStream.ts` / `components/channel/ChannelStreamBody.tsx` | 频道消息流组合层(#531,架构评审 2026-09-14 候选 3):「messages 到手之后到渲染之前」全部装配一个 hook——六根线顺序不变量由模块结构承载(deriveStreamView memo → buildMessageToItemIndex → streamHead ResizeObserver → useStreamFollow → firstVisibleMid 三 kind 反解 → syncPruning 仅虚拟路径),折叠 UI 状态(usePersistentStreamUI)与三 toggle 回调一并收编;入参 = channelId + 取数产物(取数 useChannelMessages 留页面)+ promotedQuestionIds/isWaitingForInput;返回契约 = 结构(streamHeadRef/items/completedCount/showCompleted)+ 滚动跟随(streamRef/handleStreamScroll/回底浮钮三件套/handleLoadMore/ownSendPendingRef)+ 特性消费产物(messageToItemIndex/virtualizer/virtualEnabled/setCollapsedThreads——locate/键盘导航/highlight 即插即用口)+ 结构渲染产物(streamInnerRef/streamHeadH/三 toggle,ChannelStreamBody 专属)。ChannelStreamBody 拥有 items→DOM 全部结构分支(virtual/non-virtual + spacer/translateY + measureElement + renderStreamItem 三 kind 分派 + skeleton 占位含骨架高亮),props = stream + renderMessage + highlightId。底座六件与取数一律不动 | +| `useChannelStream` / `ChannelStreamBody` | `hooks/useChannelStream.ts` / `components/channel/ChannelStreamBody.tsx` | 频道消息流组合层(#531,架构评审 2026-09-14 候选 3):「messages 到手之后到渲染之前」全部装配一个 hook——六根线顺序不变量由模块结构承载(deriveStreamView memo → buildMessageToItemIndex → streamHead ResizeObserver → useStreamFollow → firstVisibleMid 三 kind 反解 → syncPruning 仅虚拟路径),折叠 UI 状态(usePersistentStreamUI)与三 toggle 回调一并收编;入参 = channelId + 取数产物(取数 useChannelMessages 留页面)+ promotedQuestionIds/isWaitingForInput;返回契约 = 结构(streamHeadRef/items/completedCount/showCompleted)+ 滚动跟随(streamRef/handleStreamScroll/回底浮钮三件套/handleLoadMore/ownSendPendingRef)+ 特性消费产物(messageToItemIndex/virtualizer/virtualEnabled/setCollapsedThreads——locate/键盘导航/highlight 即插即用口)+ 结构渲染产物(streamInnerRef/streamHeadH/三 toggle,ChannelStreamBody 专属)。ChannelStreamBody 拥有 items→DOM 全部结构分支(virtual/non-virtual + spacer/translateY + measureElement + renderStreamItem 三 kind 分派 + skeleton 占位含骨架高亮),props = stream + renderMessage + highlightId。底座六件与取数一律不动。vc6 角色分框(2026-09-16 频道视觉批次增量五):agent 消息与 `.mc-reply-chain` 外壳升「完整盒子」(底 `--bg-tertiary` + 全周 1px `--border-default` + 左 3px 角色色条),色条 = `--mc-agent-color` 由 ChannelMessageItem / ChannelStreamBody 内联注入(该 agent identicon 同号 `--chart-*` 类别色,§4.8 不占 accent,与头像同源);人类气泡加右 3px `--border-emphasis` 边条(方位对称信号:右+青绿=我 / 左+角色色=agent) | +| `ChannelMessageEnv` | `components/channel/ChannelMessageEnv.tsx` | 频道消息环境 Context(#547,架构评审 2026-09-15 候选 B3,grilling 已决):横切值(onAction/onReply/findMessage/channelId/三个开 WU 抽屉回调/onOpenRequirement/onInlineReply/onQuoteClick)单 Provider 下发,ChannelMessageItem `useChannelMessageEnv()` 自取——公开 Props 收窄为 message + 5 个 per-message 派生值(waitingForInput/wuChangedFiles/highlight/fresh/focused)+ 6 个结构 props;fileVocabulary 不进 Context,消息项 `useChannelDataStore` selector 自取;ChannelStreamBody extra 收紧为 6 键封闭 `StreamMessageExtra` Pick(Partial 逃生口已删)。**#322 契约不变量**:Provider value 由 ChannelDetailPage useMemo 组装且成员全稳定引用(useCallback/镜像 ref),identity 不变 = 零扇出;highlightId/focusedId/freshMsgIds 等 volatile state 禁入本 Context(翻牌走 per-message boolean props),render-count + focus-fanout 测试兜底 | | `useProposalReview` / `ReviewProposalCard` | `hooks/useProposalReview.ts` / `components/channel/ReviewProposalCard.tsx` | 人审提案卡合一(#352,ADR 2026-08-25 决策 5):6 卡坍缩为壳 + `proposalCardConfigs` 纯数据配置(#356 auditor_suggestion 并入,AuditorSuggestionCard 删除);reviewed/pending/armed 生命周期 + 挂载期派生已审态单点化;act 执行未全绿时按提案状态重派生一次,命中终态即时收敛;memory 卡 exec 逐草稿结算含 not-pending 同向终态幂等跳过 + failed 终态标签(#367,正本闸门不可逆) | -| `ChannelWorkBar` | `components/channel/` | 频道工作条(2026-09 合并 ChannelLiveBars + ChannelStageBar,docs/plans/2026-09-channel-workbar.md):自持有 useChannelLiveExecutions(#322 重渲边界不变,step 事件不触达页面);渲染规则 = 无 currentWu 无 active 留占位(#474,原整条静默消失;#488 起占位三态——可选 `wuIdle` prop,语义 = 建议端点已成功返回且 currentWuId=null,ChannelDetailPage 按 `suggestionsResolvedFor === id` 记账(切频道自动回加载态、请求失败 catch / #490 degraded=true 响应均不落账 → 不误显空闲)→ 空闲文案「频道暂无进行中的工作」,否则加载态「状态同步中…」)/ 无 currentWu 仅 live 列表 / 有 currentWu stepper 主区(阶段语义 deriveDisplayState 同口径)+ 自身 active 叠加「第 N 步 · 动作」+ 其他 active「+N 进行中」chip 展开列表;currentWuId 未命中 fail-closed 主区不渲染。**2026-09 批次1 stepper 视觉强化**(docs/plans/2026-09-channel-visual-polish.md ①):全部经 `.mc-workbar` 作用域覆盖 wu-detail.css 的 wu-stepper-bar/wu-bstep-line/wu-st-* 类(紧凑定长连线左对齐 + max-width 640 / 当前站点径 12px+发光 / 连线提对比 / 时间戳 opacity 弱化 / 底 hairline 提一档)——**全局 stepper 类不动,WU 详情页不受影响**,后续工作条视觉调整沿用此作用域模式。**E1 站点可点**(2026-09 页面重设计):stepper 经 StationStepper 可选 `onStationClick` 渲染为按钮,点击开 currentWu 抽屉(与 live 徽标统一入口);按钮/hover/focus 样式同样走 `.mc-workbar` 作用域覆盖。**批次 D-2 项6 频道内闸门 1 击化**(docs/plans/2026-09-ui-interaction-polish.md):可选 `gate` prop(`WorkBarGateHandlers` = 三个写路径回调,ChannelDetailPage 注入——直调 API + 响应体就地并入 channelWus,status_changed SSE + 建议重拉兜底);currentWu 处于闸门态(pending / in_review / done 缺 l3,与 WuGateActions 内部分支同口径)时工作条右端 `.mc-workbar-gate` 直挂共享 WuGateActions(btn-sm 紧凑档),不离开消息流完成闸门;工作条内 `.modal-overlay` 作用域提 z-index 400(全站 50 < 抽屉 300,覆盖式抽屉同开时弹窗防被盖) | +| `ChannelWorkBar` | `components/channel/` | 频道工作条(2026-09 合并 ChannelLiveBars + ChannelStageBar,docs/plans/2026-09-channel-workbar.md):自持有 useChannelLiveExecutions(#322 重渲边界不变,step 事件不触达页面);渲染规则 = 无 currentWu 无 active 留占位(#474,原整条静默消失;#488 起占位三态——可选 `wuIdle` prop,语义 = 建议端点已成功返回且 currentWuId=null,ChannelDetailPage 按 `suggestionsResolvedFor === id` 记账(切频道自动回加载态、请求失败 catch / #490 degraded=true 响应均不落账 → 不误显空闲)→ 空闲文案「频道暂无进行中的工作」,否则加载态「状态同步中…」)/ 无 currentWu 仅 live 列表 / 有 currentWu stepper 主区(阶段语义 deriveDisplayState 同口径)+ 自身 active 叠加「第 N 步 · 动作」+ 其他 active「+N 进行中」chip 展开列表;currentWuId 未命中 fail-closed 主区不渲染。**2026-09 批次1 stepper 视觉强化**(docs/plans/2026-09-channel-visual-polish.md ①):全部经 `.mc-workbar` 作用域覆盖 wu-detail.css 的 wu-stepper-bar/wu-bstep-line/wu-st-* 类(紧凑定长连线左对齐 + max-width 640 / 当前站点径 12px+发光 / 连线提对比 / 时间戳 opacity 弱化 / 底 hairline 提一档)——**全局 stepper 类不动,WU 详情页不受影响**,后续工作条视觉调整沿用此作用域模式。**E1 站点可点**(2026-09 页面重设计):stepper 经 StationStepper 可选 `onStationClick` 渲染为按钮,点击开 currentWu 抽屉(与 live 徽标统一入口);按钮/hover/focus 样式同样走 `.mc-workbar` 作用域覆盖。**批次 D-2 项6 频道内闸门 1 击化**(docs/plans/2026-09-ui-interaction-polish.md):可选 `gate` prop(`WorkBarGateHandlers` = 三个写路径回调,ChannelDetailPage 注入——直调 API + 响应体就地并入 channelWus,status_changed SSE + 建议重拉兜底);currentWu 处于闸门态(pending / in_review / done 缺 l3,与 WuGateActions 内部分支同口径)时工作条右端 `.mc-workbar-gate` 直挂共享 WuGateActions(btn-sm 紧凑档),不离开消息流完成闸门;工作条内 `.modal-overlay` 作用域提 z-index 400(全站 50 < 抽屉 300,覆盖式抽屉同开时弹窗防被盖)。**2026-09 视觉层次批次**(docs/plans/2026-09-channel-visual-hierarchy.md 问题 6):stepper 左侧新增 `.mc-workbar-wu` 归属 chip(mono 短 id,点击开抽屉,样式作用域同 stepper 覆盖模式);站点点径 9→11px(current 维持 12+glow)、连线 1→2px,均 `.mc-workbar` 作用域覆盖 | | `ChannelTopbarMenu` | `components/channel/` | E1 顶栏 ⋯ 菜单(2026-09 页面重设计):收纳成员管理(ChannelMemberManager `triggerClassName` 换菜单行形态)/ 工单路由(ChannelRoutingEditor,#466 三档 plan/implement/review 下拉,候选=频道成员,乐观更新失败回滚,存量配置面板展开时经 channelApi.get 拉取)/ 默认工程(ChannelDefaultProjectSelect)/ 频道动态入口(<1024,外层 `.mc-act-open` 沿用既有显隐);点外部收起,`.select-panel`(Select portal 面板)点击豁免。**#474:「当前 PMO」chip 不再收进本菜单**——提升为顶栏可见位(ChannelDetailPage 直挂 ChannelCurrentPmoChip,与待办 chip 同排) | | `NeedsAttentionSection` | `components/monitoring/` | 监控页「需要处理」区;#456 stuck/failure 计数读 /monitoring/overview 扩段(服务端单源,消先截后滤漏单),告警行明细仍前端翻页;#398 告警按归一化 message 签名分组(`alertGrouping.ts`:数字/hex id 占位归一,×N + 最近时间,>3 组折叠「还有 N 类」,纯前端不动探针口径)。**E4 告警下钻**(2026-09 页面重设计):可选 `onAlertClick(group)`——告警行渲染为按钮,点击 → MonitoringPage 切事件检索 tab 并经 `EventSearchPanel` 新 `initialFilters` prop 预填 `type=monitor:alert` + level=warning + 签名关键词(`alertSignatureKeyword`:原文切除 hex id/数字段取最长文本段,同组共享稳定段),面板带 initialFilters 挂载即自动检索一次;手动回概览时清 seed 防旧预填重放 | | `MonitorSection` / `charts` | `components/monitoring/MonitorSection.tsx` / `charts.tsx` | #398 监控页区块容器(§7.5:术语标题 + 白话副标题 + 可选 22px 主数字 --fs-stat+mono)与手搓图表三件套(UsageBar 预算用量条 / DayBars byDay 柱 / HBars 横条,零图表库,样式 `mc-*` 在 mission-control.css,承 #396 先例) | | `ProjectMap` / `NextActionCard` | `components/pmo/ProjectMap.tsx` | PMO 地图 + 下一个该干什么。E3(2026-09 页面重设计):NextActionCard 升为详情页主行动区(accent 左边线卡 + `mc-block-label` 标题,与中性信息卡的地图区层级分工),节标题统一去 emoji | -| `ChannelRail` / `WorkUnitDrawer` | `components/channel/` | 左栏频道列表(#395 可选 `onNavigate`:并入 Sidebar 时选频道收起 overlay)/ 右抽屉 WU 详情(证据/审查/执行/会话原文/token/REQ;#395 <768 全屏化带「← 返回」)。E2(2026-09 页面重设计):WorkUnitListPage 行点击同挂本抽屉(props 自包含自取数,`.mc-drawer` 样式全局);ReviewHint(in_review 且频道无 reviewer)自列表展开区挪入闸门动作区上方(成员 best-effort 拉取,失败留 null 不渲染防误报),onSetupClick 链 `/agents`;ExecutionSteps 后补「会话原文」折叠节(TranscriptViewer);闸门动作走共享 WuGateActions。**批次 D-2(行动中心就地化)**:subject 行补「↗ 详情页」深链(`/workunits/:id`,与列表行尾 ↗ 同语义);新增可选 `todoNav` prop(`DrawerTodoNav` = host 算好的「下一个待办」导航数据,仅当前 WU 处理完才传入)——抽屉头与主体之间渲染串办导航条(`.ac-todo-nav`),next 存在出「下一个 →」btn-secondary(onOpenNext 同抽屉换 WU),next=null 出收口文案,REQ 链路态不渲染 | +| `ChannelRail` / `WorkUnitDrawer` | `components/channel/` | 左栏频道列表(#395 可选 `onNavigate`:并入 Sidebar 时选频道收起 overlay;**F4(2026-09-16 性能体检)渲染边界**:频道行 = memo 子组件 ChannelRow + 行内 per-channel unread selector(`s.unreadCounts[ch.id]`),他频道来消息只重渲对应行;members JSON 解析随 channels 切片 memo,render-count 测试以 formatChannelName 调用计数为探针)/ 右抽屉 WU 详情(证据/审查/执行/会话原文/token/REQ;#395 <768 全屏化带「← 返回」)。E2(2026-09 页面重设计):WorkUnitListPage 行点击同挂本抽屉(props 自包含自取数,`.mc-drawer` 样式全局);ReviewHint(in_review 且频道无 reviewer)自列表展开区挪入闸门动作区上方(成员 best-effort 拉取,失败留 null 不渲染防误报),onSetupClick 链 `/agents`;ExecutionSteps 后补「会话原文」折叠节(TranscriptViewer);闸门动作走共享 WuGateActions。**批次 D-2(行动中心就地化)**:subject 行补「↗ 详情页」深链(`/workunits/:id`,与列表行尾 ↗ 同语义);新增可选 `todoNav` prop(`DrawerTodoNav` = host 算好的「下一个待办」导航数据,仅当前 WU 处理完才传入)——抽屉头与主体之间渲染串办导航条(`.ac-todo-nav`),next 存在出「下一个 →」btn-secondary(onOpenNext 同抽屉换 WU),next=null 出收口文案,REQ 链路态不渲染 | | `WuGateActions` | `components/workunit/WuGateActions.tsx` | E2-4 闸门动作合一(列表行 / 抽屉 / 详情页左栏;**批次 D-2 项6 起第四处 = 频道工作条 `.mc-workbar-gate`**,currentWu 闸门态时直挂,文案与视觉唯一 = btn-primary/btn-danger btn-sm):pending「确认并开放领取」/ in_review「通过验收+拒绝(带原因弹窗;#463 起 analysis/decision/spec 走各自结构化确认弹窗——AnalysisApproveDialog 双栏 FOG+TASK 预览三按钮 / DecisionApproveDialog 建议结论采纳 / SpecApproveDialog 物化卡片墙,confirm 载荷由后端序列化进 l3.summary,人不接触魔法行;#471 起 plan 复用 AnalysisApproveDialog,confirmKind=plan 同形契约)」/ done 缺 l3「人工验收确认」(#473 起按钮说人话,内部机制词不上按钮);含批次A 模式 pending 锁存 + gateError 内联 + 弹窗成功才关;autoApprove 一次性「打开即弹」(决策 #250 D6,analysis + #471 plan)迁入组件;根节点吞点击冒泡(列表行整行可点);blocked 处置(BlockedActions)不在此列各页自挂;写路径经 props 注入(列表=store 重拉 / 抽屉=响应体直替 / 详情页=actionTick 重拉) | | `ChannelActivityRail` / `activityRail` | `components/channel/` | #394 右栏「频道动态」(spec §4.1–4.3):REQ 链路卡(四站 stepper 讨论→REQ→WU n/m→交付,纯函数 `deriveChainSteps`)+ 动态归属分流(`attributeActivity`,无归属落「其他动态」);混合交互:REQ/WU→就地抽屉,PMO/Agent→↗ 跳页(PMO 链 chain.projectId→req.projectId→频道 current-pmo 兜底,项目记录拉不到不渲染 badge 防死按钮;agent 经 resolveAssignee 取名)。#416 渲染边界:整栏 memo + 卡内静态部分 `ReqCardStatic` memo(卡头/stepper/meta,message_sent 下零重渲,render-count 测试以 `deriveChainSteps` 计数为探针);消息面只收「消息摘要投影」`messageItems`(`projectActivityMessages` 纯函数 + 页面侧 `hooks/useActivityMessageItems`,投影等值即引用稳定,无关 message_sent 不掀动右栏),不再消费全量 messages。**「其他动态」降噪**(2026-09 批次1 ②):`deriveActivityRows` 纯函数——同类相邻折叠(pinned 按 wuId 不互折 / card 按卡型 / 其余按 kind+全文,组内首条=最新作代表,UI 追加 ×N)+ 信号分级 tone(signal=pinned 或 需要输入/等待人工/阻塞/blocked → warning 色点提权;routine=card 例行播报 → 降权暗字;其余 normal),组件只消费行集 | | `EvidenceLedger` / `BlockedByList` / `AuthModal` | `components/` | 证据台账 / 依赖清单 / 认证模态框 | | `PlanRulingCard` / `PlanRulingDialog` | `components/channel/PlanRulingCard.tsx` / `components/pmo/PlanRulingDialog.tsx` | #467 裁决轮(plan 会话内一次性人闸):cardType `plan_ruling` 接力卡「去裁决」→ 抽屉 `autoRuling` 打开即弹;弹窗逐题预填 agent 建议结论(改 = 单题修改)+ 每题「打回重议」勾选(只重调该题)+「全部采纳」一键全对;提交 = `workunitApi.submitRuling`(POST /:id/ruling,后端批量落探路台账并复活同会话)。挂载点 = BlockedActions(waitingReason='plan-ruling' 且 planRulings 非空时出「去裁决」) | +| `PlanDirectionCard` / `PlanDirectionDialog` | `components/channel/PlanDirectionCard.tsx` / `components/pmo/PlanDirectionDialog.tsx` | #567 方向锁定(plan 会话内方向人闸,裁决轮前置环节):cardType `plan_direction` 接力卡「去选定」(卡面静态文案,里程碑 meta 只带 cardType)→ 抽屉 `autoDirection` 打开即弹;弹窗候选方向卡并排(name/summary/tradeoffs/impact + 推荐徽标)单选(默认选中推荐项)+ 可选补充说明,主键动态标签「锁定推荐方向」/「锁定所选方向」,「都不合适」= onCancel 关窗转自由文本回复(通用复活路径);提交 = `workunitApi.submitDirection`(POST /:id/direction,后端落探路台账并复活同会话)。挂载点 = BlockedActions(waitingReason='plan-direction' 且 planDirections.options 非空时出「去选定」) | | `buildLifecycle` / `stationIndex` | `utils/wuLifecycle.ts` | #396 WU 生命周期四站模型(待领取→进行中→待验收→完成)+ 关键事件派生;「待验收」站时间戳口径 = `attestations.l2.at ?? l1.at ?? null`(§5.6.2 定口径处) | | `StationStepper` / `LifecycleEventChips` | `components/workunit/StationStepper.tsx` | #396 四站横向 stepper(全页共享定位条)+ stepper 下关键事件 chip 横排行(无事件不占行)。E1:可选 `onStationClick`——传则站点渲染为按钮(频道工作条开 WU 抽屉),不传纯展示(WU 详情页) | | `ExecutionFlow` | `components/workunit/ExecutionFlow.tsx` | #396 WU 详情页执行过程新形态:stat 摘要行 + 纵向 step 链(thinking 折叠/tool 成行/失败红节点);数据路径同 ExecutionSteps(REST+SSE 步事件直更),不接 Layer-B 步内 chunk | @@ -86,21 +93,21 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 ### 运行时约定 - 路由用 `React.lazy` 代码分割;API token 从 `localStorage` 直读。**API seam**:端点知识只在 `api/*` 适配模块,页面/hooks 不直接拼 URL。 -- **样式规范文档本地性**(2026-09-11 批次 F/G 探明):`docs/specs/ui/style-guide.md` 被 `.gitignore` 的 `docs/*` 忽略、**不入 git**——新组件类/规范登记写它只落本地磁盘,不进 commit(批次 F-5/G-3/G-4 的类登记均如此);别指望经 commit 同步它。 +- **样式规范文档已入 git**(2026-09-16 批次 I-1 起,治理人闸当场过):`docs/specs/ui/style-guide.md` 原被 `.gitignore` 的 `docs/*` 忽略、只落本地磁盘不进 commit(批次 F-5/G-3/G-4 的类登记均如此);批次 I-1 起经定向豁免(`.gitignore` 加 `!docs/specs/ui/style-guide.md`,其余 spec 源维持忽略)入 git——规范条文/类登记变更自此走 commit 留痕,可评审可回溯。 - **SSE 单连接**:全应用仅根部 `WebSocketProvider` 建一个 EventSource(/events/stream),禁止开第二条。**2026-08-25**:/events/stream 已移出 PUBLIC_API,URL 经 `?token=` 携带 JWT(buildSseUrl 每次连接现取 authStore token,EventSource 无法设 Authorization 头)。 - **SSE 事件负载消费约定**(2026-08 负载深化批 2,决策 4-6):`workunit.execution.step` 负载带可选 `channelId`——`useChannelLiveExecutions` 带上时按频道过滤、缺省不过滤(向后兼容);`workunit.status_changed` 负载 = 全量 WorkUnitData(`parseLiveWuRef` 解析出 id/status/channelId/metadata/type/scope/parentId——parentId 自 #442 起解析留存;#447 起前端不再判活跃 review 子工单(该判定后端化),parentId 仅作负载透传字段),终态清理 step 条目不限频道(防他频道残留);ChannelDetailPage 的 waitingWus chip 与 channelReqs(#394 起喂右栏链路卡)= 进频道一次 REST 打底 + SSE 增量维护(`status_changed` / `requirement.created|updated`),不再依赖 messages.length 触发重拉。**requirement.\* 负载 = `{ requirement }` 信封全量 RequirementData**(#415 修正:信封自桥接入起即嵌套全量,旧 `parseRequirementRef` 读顶层平铺字段从不匹配 → 补拉是死代码且事件被静默丢弃;今 `parseRequirementPayload` 解信封就地 upsert 零补拉,channelId 缺省不落 key 防合并抹归属)。 - **SSE 负载消费约定·批 3**(决策 7-9):RequirementsDocCard 纯静态渲染(5s 轮询与 executing 进度分支已删——#278 后无 `meta.status='executing'` 写入方,死代码);WorkUnitDrawer = 开抽屉一次性 REST 打底(wu 详情/token 历史/overhead 各一次)+ `workunit.status_changed`(同 id 负载直替本地 wu)/ `workunit.tokens`(复用 `parseWorkunitTokenEvents` 防御解析,他 WU/缺字段跳过)SSE 增量,无 eventTick 防抖重拉,`getOverhead` 不随事件重拉;`useWebSocket`/`WebSocketProvider` 暴露 `onReconnect` 注册口(首次 onopen 不触发,重连 onopen 触发一次),ChannelDetailPage 重连落点 = messages `refresh`(chips 缺口靠后续 SSE 增量自愈,强制对齐留收尾批)。 -- **SSE 负载消费约定·批 4**(#318,#313 follow-up):`useWorkUnitEvents` 门铃 hook 已删——三处剩余消费面全部改负载直更。ExecutionSteps:`workunit.execution.step` 负载经 `parseExecutionStepEvents` 就地 append(executionId-step 去重后者覆盖、步号升序、首拉在途事件暂存并入),重连一次性 refetch;WorkUnitListPage:store `applyWorkunitEvent`——`status_changed` 直替/移除行(未知行不插入防跨页重复)、`created` 插头部,过滤不符就地移除/不插入,旧形状负载缺 `claimable` 保留原值(ADR D2 回退);**取舍 a**:分页 total 本地 ±1 近似维护,页边界不追齐——本页无轮询兜底,自愈靠 SSE 重连 refetch、过滤切换/创建/审查等操作触发的 loadWorkUnits 与路由重进首拉;**取舍 c**:`status_changed` 未知行(不在当前页)即使新进过滤集也不插入(服务端过滤 + 分页下无法判定页内归属,插入会跨页重复),同靠上述路径自愈;AgentDetailPage:instance/当前卡状态面已上移 rosterStore(#346),本页保留 `workunit.status_changed` 历史行就地更新;**取舍 b**:历史任务「最近 20 条 + total」窗口无事件语义——命中本实例时 800ms 防抖只重拉历史区 1 接口对齐,不再整页 5 接口。后端 additive:`workunit.created/status_changed` 负载附 `claimable`(口径同 GET / 列表路由:unassigned 且无未了结依赖才 true,其余恒 false 不读 index)。 +- **SSE 负载消费约定·批 4**(#318,#313 follow-up):`useWorkUnitEvents` 门铃 hook 已删——三处剩余消费面全部改负载直更。ExecutionSteps:`workunit.execution.step` 负载经 `parseExecutionStepEvents` 就地 append(executionId-step 去重后者覆盖、步号升序、首拉在途事件暂存并入),重连一次性 refetch;WorkUnitListPage:store `applyWorkunitEvent`——`status_changed` 直替/移除行(未知行不插入防跨页重复)、`created` 插头部,过滤不符就地移除/不插入,旧形状负载缺 `claimable` 保留原值(ADR D2 回退);**取舍 a**:分页 total 本地 ±1 近似维护,页边界不追齐——本页无轮询兜底,自愈靠 SSE 重连 refetch、过滤切换/创建/审查等操作触发的 loadWorkUnits 与路由重进首拉;**取舍 c**:`status_changed` 未知行(不在当前页)即使新进过滤集也不插入(服务端过滤 + 分页下无法判定页内归属,插入会跨页重复),同靠上述路径自愈;AgentDetailPage:instance/当前卡状态面已上移 rosterStore(#346),本页保留 `workunit.status_changed` 历史行就地更新;**取舍 b**:历史任务「最近 20 条 + total」窗口无事件语义——命中本实例时 800ms 防抖只重拉历史区 1 接口对齐,不再整页 5 接口。后端 additive:`workunit.created/status_changed` 负载附 `claimable`(口径同 GET / 列表路由:unassigned 且无未了结依赖才 true,其余恒 false 不读 index)。**workunit:removed(#538,ADR 2026-09-15 决策 5)**:GC/TTL 删除出声——负载 { id, channelId },WorkUnitListPage → store `removeWorkunit` 删行(total/allTotal 各 -1 近似维护,同取舍 a),不再悬挂到重连 refetch;只走事件流不进频道出声。**#549(B5 收口)**:被动写 SSE 路由唯一一份 = App 级 `useWorkUnitStoreSync`(status_changed / created / workunit:removed 三路 + created → `markWuFresh`);WorkUnitListPage 内联 onEvent + 重连三接口扇出、WuDetail 内联 status_changed 直替与 REST 自打底全删——drawer 改读 store detail slice(`detailById`:`loadWorkUnitDetail` REST 打底不做 TTL、事件就地 upsert、未打开 no-op),两侧退回订阅者;与 B1 主动写(gateWriter)同一组 store action = ADR 2026-09-15-web-gate-write-module 决策 2「单份 upsert」的完整含义。 - **未归属过滤(#405,消费 #428 API)**:WorkUnitListPage 状态 pill 行新增「未归属」pill——服务端过滤(store `unattributedOnly` → `attributed=false` 参数,与 status/type 过滤交集组合),计数徽标 = 服务端 pagination.total(`unattributedTotal`,挂载/重连经 `loadUnattributedCount` limit=1 轻量拉取;loadWorkUnits 仅在无 status/type 组合过滤时顺带同步,组合过滤下 total 是交集计数不得覆盖徽标;best-effort 失败留旧值,null 不显数字)。`applyWorkunitEvent` 过滤匹配新增归属维度:过滤态下 SSE 增量行须过 `isUnattributedWu`(无 reqId 且戳 pmoId‖legacy ownershipProjectId 为 null,与服务端 parseWuPmoId 同口径的本地镜像)。 - **WorkUnit 列表页行形态**(2026-09 UI 丝滑化 Step 2,docs/plans/2026-09-ui-smoothness.md):筛选合一——第二套 pill 筛选行已删,统计条改可点击 `StatChip`(总数/待确认/待领取/进行中/待验收/待人工,点击过滤/再点回全部);行从 card 边框块改 `.wu-row` 无边框行(行间细分隔线 + 左侧 3px 状态色条 + hover 微底色)。样式独立文件 `styles/workunits.css`(页面内 import),状态色与 `WU_STATUS_COLORS` 语义一致。**E2 页面重设计**(docs/plans/2026-09-page-redesign.md):整行展开区(MetaStrip 9 字段/metadata toggle/ExecutionSteps/DiscussionPanel/ReviewHint)已删——行点击直跳 `/workunits/:id` 详情页(**2026-09-10 起抽屉形态已撤**:列表行原挂的 `WorkUnitDrawer` 与行尾「↗」深链均已删,行本体即唯一入口,本页零 Drawer 引用),行内闸门按钮(WuGateActions)保留作快速处置;分页 E2-5 = 底部「加载更多」追加式(store `loadMoreWorkUnits` 拉 page+1 按 id 去重拼接)+「已加载 X / 共 N」(共 N = pagination.total,与「总数」chip 同口径)。**批次 D-1.6 视觉试点**(docs/plans/2026-09-ui-interaction-polish.md,纯视觉层不动数据流):① StatChip 数字档归 `--fs-title`(16px)+font-mono(`--fs-stat` 按 style-guide §2.3 仅统计卡大数字用,chip 非统计卡;label 保持 `text-sm`=--fs-sm),激活 chip 追加 accent 底线(border-bottom 2px var(--accent-primary) + padding-bottom 4→3 补偿防抖动),#472 颜色语义不动(**2026-09-11 起 AgentDashboard StatFilter 对齐同档**:数字 --fs-stat→--fs-title 归 `.agd-stat-num`,激活态补同款 accent 底线,两页页头 chip 视觉一致);② 「待人工」行级提权——`derived.needsHuman`(in_review ‖ done 缺 l3,与统计 chip 同口径)行加 `.wu-row-human` 类,整行 `--warning-dim` 底色,hover 仍走 `--bg-hover` 不透明覆盖提亮(显式 `.wu-row.wu-row-human:hover` 规则锁叠加顺序);行 hover 即 `--bg-hover`(无私有 hover 值)。**批次 D-2 项4 标题搜索**(docs/plans/2026-09-ui-interaction-polish.md):页头 chip 行下加 `.input` 搜索框,300ms 防抖(LibraryPage 先例:debounceRef + 跳首次 effect)进 store `searchQuery`(`setSearchQuery` 归一 trim/空白→null,词未变 no-op 不重拉),loadWorkUnits/loadMoreWorkUnits 带 `q` 走服务端过滤(与 status/type/attributed 交集,total 即过滤后计数,「已加载 X / 共 N」随过滤自然变化);`applyWorkunitEvent` 的 matches 增 q 维度(scope 大小写不敏感子串,与服务端同口径——搜索态 SSE 增量不匹配不插入/不匹配即移除);搜索激活时 chip 行「计数口径」标注同 #472 规则出现。 - **「已沉睡」徽标 StaleSleepBadge**(#464,components/workunit/StaleSleepBadge.tsx,挂列表行 + 详情页头):72h 认领陈旧守卫命中的 WU 此前仍显示「待领取」。判定与后端 monitor-probes 同源——`metadata.staleGuardBlockedAt === updatedAt` 且 unassigned(守卫落盘的沉睡锚点);任何写刷新 updatedAt 即复活、徽标自消,无额外同步机制。频道侧出声由后端 stale_claim_guard 探针发 milestone 提醒。 -- **共享门禁轮询 `useGatedPoll`**(#313,架构评审候选 8):组件级私有轮询已收敛——`useRosterStoreSync`(#346 起 ChannelRail/useAgentRoster 的 30s 轮询经 store 接线单点走该 hook)、`useChannelEvents`(10s);语义 = 挂载首拉 + 仅当(visible ∧ SSE≠connected)轮询 + 回 visible 立即补拉;消费方 403 终态逻辑留在各自 fetch 闭包(短路请求,不新增依赖)。新轮询需求一律复用,禁止再写裸 setInterval 轮询(NotificationBell UI flash 定时器除外)。**useChannelEvents refetch = 合并语义**(#328):同频道 refetch 最新一页按 id 归并(已存在的以服务端版本刷新、新消息按 createdAt 有序插入),prepend 的历史页不丢;首拉/频道切换仍替换。hasMore 仅当本地最老消息落在最新一页内才以响应为准(页的 hasMore 描述头部方向,prepend 出页外时直接覆盖会错误重置 prepend 方向状态)。**useChannelMessages 暴露 `error` 态**(#482):首拉/兜底轮询共用 fetchMessages,失败置 error(消息不清空),成功/频道切换清——ChannelDetailPage 据此三态分流:加载中骨架 / error 且 messages 空 → 错误态+重试钮(走 refresh)/ 真空态示例 chip;已有消息时轮询失败不整屏替换。已知留白:SSE 连着时 AgentDashboardPage 上新建 profile 要等 remount 才发现(无 `agent.profile.created` 事件,决策接受)。 -- **一次性拉取 `useAsyncData`**(#350,架构评审候选 F5):useGatedPoll 之外的姊妹 hook,吃掉 `useState(data/loading/error)+useCallback(load)+useEffect(load)` 样板——6 处消费方归一(MonitoringPage / NeedsAttentionSection / ProjectDetailPage / PMOPage / KnowledgePage / AgentDetailPage 历史窗口)。语义:挂载首拉(微任务推迟,纪律同 useGatedPoll)+ deps 渲染期重置(切参当帧清数据置回加载,prevX 微模式内建)+ `reload()`(重拉保留旧数据、即清 error,刷新按钮/事件路径复用)+ `setData`(SSE 就地更新绕过 fetch 生命周期)。约定:**错误上屏与静默的分工留在 fetcher**——主拉取直接 reject(hook 落 `error.message`,要友好文案就在 fetcher 内 `throw new Error(...)`),best-effort 子拉取 fetcher 内自 catch 落 null(hook error 不上屏)。依赖驱动级联:ProjectDetailPage 子拉取 deps=[project.data](身份变化即级联重拉,替代原 loadData 全量串行)。新的一次性拉取一律复用,禁止再写裸 useState+useEffect 组合。已知行为差:拉取失败后成功刷新会清 error(原 MonitoringPage 永不清理的潜在 bug 顺带修正);ProjectDetail 子拉取由串行改并行(首屏更快,pop-in 顺序不变)。 -- **SSE 负载消费约定·message_updated**(#315,ADR 2026-08-24 D1/D2):`useChannelMessages` 处理 `channel.message_updated` 优先读负载 `message` 全量本体(meta 为后端合并后真值)整体原位替换;旧形状(无 `message` 字段)回退顶层增量 `content`/`meta` patch。`createdAt` 为不可变诞生时刻(#317,ADR 2026-08-24 message-createdat-immutable),原位替换与刷新归位因此恒一致。 +- **共享门禁轮询 `useGatedPoll`**(#313,架构评审候选 8):组件级私有轮询已收敛——`useRosterStoreSync`(#346 起 ChannelRail/useAgentRoster 的 30s 轮询经 store 接线单点走该 hook)、`useChannelEvents`(10s);语义 = 挂载首拉 + 仅当(visible ∧ SSE≠connected)轮询 + 回 visible 立即补拉;消费方 403 终态逻辑留在各自 fetch 闭包(短路请求,不新增依赖)。新轮询需求一律复用,禁止再写裸 setInterval 轮询(NotificationBell UI flash 定时器除外)。**useChannelEvents refetch = 合并语义**(#328):同频道 refetch 最新一页按 id 归并(已存在的以服务端版本刷新、新消息按 createdAt 有序插入),prepend 的历史页不丢;首拉/频道切换仍替换。hasMore 仅当本地最老消息落在最新一页内才以响应为准(页的 hasMore 描述头部方向,prepend 出页外时直接覆盖会错误重置 prepend 方向状态)。**useChannelMessages 暴露 `error` 态**(#482):首拉/兜底轮询共用 fetchMessages,失败置 error(消息不清空),成功/频道切换清——ChannelDetailPage 据此三态分流:加载中骨架 / error 且 messages 空 → 错误态+重试钮(走 refresh)/ 真空态示例 chip;已有消息时轮询失败不整屏替换。**#548:合并/error 语义本体已迁 channelMessageStore**(useChannelMessages 退薄接线,行为口径不变)。已知留白:SSE 连着时 AgentDashboardPage 上新建 profile 要等 remount 才发现(无 `agent.profile.created` 事件,决策接受)。 +- **一次性拉取 `useAsyncData`**(#350,架构评审候选 F5):useGatedPoll 之外的姊妹 hook,吃掉 `useState(data/loading/error)+useCallback(load)+useEffect(load)` 样板——6 处消费方归一(MonitoringPage / NeedsAttentionSection / ProjectDetailPage / PMOPage / KnowledgePage / AgentDetailPage 历史窗口)。语义:挂载首拉(微任务推迟,纪律同 useGatedPoll)+ deps 渲染期重置(切参当帧清数据置回加载,prevX 微模式内建)+ `reload()`(重拉保留旧数据、即清 error,刷新按钮/事件路径复用)+ `setData`(SSE 就地更新绕过 fetch 生命周期)。约定:**错误上屏与静默的分工留在 fetcher**——主拉取直接 reject(hook 落 `error.message`,要友好文案就在 fetcher 内 `throw new Error(...)`);子拉取 2026-09 web-ux-optional-fixes Step 1 起不再 fetcher 内自 catch 落 null 静默(MonitoringPage 五查询 / KnowledgePage costsQ / AuditLogsPage stats+options 已收口)——统一 reject 落 error,区块内渲染最小错误行(u-err-dim 红条 + 重试=`reload()`),区分「没数据」与「加载失败」。依赖驱动级联:ProjectDetailPage 子拉取 deps=[project.data](身份变化即级联重拉,替代原 loadData 全量串行)。新的一次性拉取一律复用,禁止再写裸 useState+useEffect 组合。已知行为差:拉取失败后成功刷新会清 error(原 MonitoringPage 永不清理的潜在 bug 顺带修正);ProjectDetail 子拉取由串行改并行(首屏更快,pop-in 顺序不变)。 +- **SSE 负载消费约定·message_updated**(#315,ADR 2026-08-24 D1/D2):`channel.message_updated` 优先读负载 `message` 全量本体(meta 为后端合并后真值)整体原位替换;旧形状(无 `message` 字段)回退顶层增量 `content`/`meta` patch。消费落点 #548 起 = `channelMessageStore.applyMessageUpdated`(自 useChannelMessages 迁入,口径不变)。`createdAt` 为不可变诞生时刻(#317,ADR 2026-08-24 message-createdat-immutable),原位替换与刷新归位因此恒一致。 - **SSE 负载消费约定·instance status_changed**(#312,ADR 2026-08-24 体检;#313 修订):`agent.instance.status_changed` 负载带 `currentWorkUnit` 快照/`channelId`/`lastError` 系字段——#346 起就地更新唯一一份在 `rosterStore.applyInstanceStatusEvent`:以负载快照为准(含悬空 null),仅旧事件(无快照字段)回退补查;instanceId ‖ roleId 匹配(error 事件带新 instanceId 时RoleId 兜底并切 id),**未匹配实例 = 新角色实例,用负载合成条目插头部**(轮询不承担发现职责);原 useAgentRoster/ChannelRail 两副本已删。消费方 selector 订阅(roster 卡片/ChannelRail 状态点/AgentDetailPage),online 计数等派生面各自 useMemo。30s 轮询经 useRosterStoreSync→useGatedPoll 保留为 SSE 断开兜底。 - **频道列表唯一数据面 = rosterStore channels 切片**(#455,ADR 2026-08-31「同域数据不住第二份」覆盖率收口):PMOPage(Publish 弹窗频道列表)/ WorkUnitDetailPage(频道名解析)/ PublishProjectDialog(选项)/ CommandPalette(频道域搜索)的 4 处裸 `channelApi.list` 已全部改读切片 + `ensureFresh()`——30s TTL + single-flight 复用 fetchDiscipline 零新机制,新建频道经 appendChannel 写穿不滞后;PublishProjectDialog 切片为空时回退 props(对齐原拉取失败回退口径)。**例外维持现状**:`components/setup/joinChannel.ts`(#465 一次性首用流程,triage 决议)。新频道列表消费方一律订阅切片,禁止再写裸 `channelApi.list`(验收 grep:除 stores/ 与 __tests__/ 外唯一命中就是 joinChannel)。 - **视觉体系**:`theme.css` 深色变量 + `mission-control.css` 三栏布局(mc-*)与工具类(u-*)。#380/#392 起正文/UI = `--font-sans`,mono 限定代码/ID/时间戳/统计数字(`--fs-stat` 消费方须带 `font-mono`)。禁止写死浅色 Tailwind 类。规范:`docs/specs/ui/style-guide.md`。**2026-09 批次C**(docs/plans/2026-09-ui-ux-polish.md):① `index.css` 加 `@theme` 把 Tailwind `text-xs/sm/base/lg/xl` 重映射到字号 token 阶梯(11/12/13/16/22),`text-2xl+` 保留默认值仅限装饰图标;② 死 CSS 清理——`animations.css` 整文件删除(`prefers-reduced-motion` 段并入 theme.css),theme.css 删 `.tag-*`/`.nav-tab`/`.list-item`/`.table-*`/`.hero-*`/`.feature-*`/`.animate-*`/`.stagger-*`/`.card-glow` 零消费死族;③ toast 全 token 化(`--*-dim/-border`、圆角 4px;**「无进出场动效」决策 2026-09-10 批次 E-3 已推翻**——toast 允许 opacity 进出场 fade,`--motion-base` + `data-closing` 幂等守卫防重复关闭,归入白名单场景②语义扩展;治理变更当场批准,commit `11483225` 带 `Governance-Approved: session`);④ `.mc-status-running` 全局统一 accent 绿(running 单义),warning 黄只留「待处理/需介入」语义。 -- **新内容进场渐隐高亮模式**(批次 E-3,2026-09-10 治理确认):SSE 新插入 WU 行 `.wu-row-new`(workunits.css)/ 频道新消息 `.mc-msg-new`(mission-control.css)= `--accent-dim` 底色,页面 2s 后移类、经基类既有 `background-color`/`outline-color` 过渡(`--motion-base`)渐隐而非瞬隐;`.mc-msg-highlight` 同模式(基类挂透明 outline 提供过渡起点)。按 style-guide §2.5 白名单场景③「状态色切换」语义落地——仅颜色属性过渡,无 transform/位移;首拉历史行/消息不标 fresh,测试锁定。 +- **新内容进场渐隐高亮模式**(批次 E-3,2026-09-10 治理确认):SSE 新插入 WU 行 `.wu-row-new`(workunits.css)/ 频道新消息 `.mc-msg-new`(mission-control.css)= `--accent-dim` 底色,页面 2s 后移类、经基类既有 `background-color`/`outline-color` 过渡(`--motion-base`)渐隐而非瞬隐;`.mc-msg-highlight` 同模式(基类挂透明 outline 提供过渡起点)。按 style-guide §2.5 白名单场景③「状态色切换」语义落地——仅颜色属性过渡,无 transform/位移;首拉历史行/消息不标 fresh,测试锁定(#548:频道消息 fresh 集合本体 = `hooks/useFreshMessageIds.ts`,自 ChannelDetailPage 迁出)。**#549 机制合一**:核心件唯一一份 = `utils/freshIds.ts`(id 集 + per-id 2s 自清,语义统一 per-id——替代消息侧原「单 timer 全清」),WU 侧集合驻 workunitStore(`freshWuIds`,created 路由驱动),页面只读集合。 - **批次 E 汇总**(docs/plans/2026-09-ui-polish-batch-e.md,2026-09-10,三 commit):E-1 `1a615e18` token 合规清扫——workunits.css/responsive.css 硬编码 transition 归 `--motion-*`,BackButton/ProjectDetailPage/ProjectMap 内联间距字号归 token;E-2 `748ce59c` 状态反馈统一——Skeleton 骨架替代「加载中...」、WU 列表/详情与 ProjectDetail 错误条补重试、DiscussionPanel 失败 toast、WU 列表空态去 emoji 改 IconClipboard+双语境 CTA 与 PMO OKR 空态 CTA、WU 列表创建/加载更多改 ui/Button loading;E-3 `11483225` 微交互——wu-detail.css stepper/hover 白名单过渡 + `.wu-detail-headslot` 首屏占位、PMOPage tab 归 `.u-tab/.u-tab-active` 去内联 borderBottom、「✓ 已复制」u-accent 染色+过渡、toast 进出场 fade(决策变更见批次C 条目③)、新内容渐隐高亮(见上条)、useStreamFollow `awayNewCount` 回底浮钮未读计数。 - **批次 F-1**(docs/plans/2026-09-11-ui-polish-batch-fg.md,2026-09-11,`15873980`)假空态修复:LibraryPage fetchDocs / LibraryDocPage fetchDoc 的 catch 原先只 console.error,失败落「暂无文档 / 文档未找到」假空态——补 error state + `u-err-dim` 错误条 + 重试按钮(PMOPage/ProjectDetailPage 正本模式),错误时不再渲染空态文案;KnowledgePage 全局搜索 catch 原先静默 `setSearchResults([])` 落「无匹配结果」假空态——改 toast.error(serverErrorMessage 优先,对齐 handleLoadMore 批次A 模式)+ 回 tab 视图保留输入词;KnowledgePage tabQ.error 原先全程未渲染——内容区补错误条 + `tabQ.reload` 重试,unified/gap 两分支空态均加 `tabQ.error` 门控。 - **批次 F-2**(docs/plans/2026-09-11-ui-polish-batch-fg.md,2026-09-11)Modal a11y 基座:`ui/Modal` 补 Escape 关闭(document keydown,卸载清理)+ `role="dialog"`/`aria-modal="true"` + 焦点管理(打开聚焦弹窗内首个可聚焦元素、无则弹窗本体 tabIndex=-1;关闭/卸载还焦触发前 document.activeElement;焦点 effect 仅随 open 翻转执行,回调身份变化不抢焦)——props 契约不变,CreateRoleModal/RoleSkillsModal/ConfirmDialog 等消费方零改动受益;AuditLogsPage 手搓详情弹层归并 ui/Modal(获得遮罩点击关闭 + 标题栏 ✕ + Escape),表格行补 tabIndex+onKeyDown(Enter/Space 开详情);WorkUnitDrawer/NotificationBell 面板/MoreDropdown 补 Escape 关闭(抽屉内 Escape 事件源在 `.modal-overlay` 内时让弹层自管,避免一按双关),MoreDropdown 触发器补 `aria-expanded`。 @@ -116,14 +123,22 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **批次 H-1**(2026-09-11,治理人闸当场过)样式规范根部修正四项——把规矩改对而非给现状开口子:① §2.5 动效白名单 4 类扩 5 类,新增场景⑤「加载与进度反馈」(spinner 旋转 theme.css `spin 0.8s`、`.mc-dot-busy` pulse、进度条 `transition-all` 宽度/填充过渡收编——历史遗留白名单外动效名分落地,代码零改动);② §4.8 补豁免——统计卡大数字(`--fs-stat` 档)数据强调允许 accent(AgentDetailPage/MonitoringPage/AuditLogsPage 等统计卡现状收编);③ §2.3 补豁免——长文阅读正文(markdown 渲染、文档阅读页)允许 `lineHeight: 1.8`(MarkdownBody/LibraryDocPage 正本);④ §4.2 label 写法根治——原条文给的四属性内联示例与规则 2「禁内联布局样式」自相矛盾,theme.css「输入组件」区新增 `.form-label` 组件类(§4.4 登记),代码迁移 4 处(FirstRoleSetupModal ×3、StudioRoleSetupModal ×1,原 `u-text` + 四属性内联 → 单类;全仓 grep 复核无其他命中)。**留痕说明**:style-guide.md 被 .gitignore `docs/*` 忽略不入 git,条文修正只落本地磁盘;决策留痕 = 本条 + commit trailer `Governance-Approved: session`。 - **批次 H-2**(2026-09-11,docs/plans/2026-09-channel-attachments.md)频道截图粘贴/上传:ChannelInput 加 onPaste(clipboardData.files 有图 → 上传 → 光标处插入 markdown 图片语法 `![name](url)`,无图不拦默认文本粘贴)+ 图片选择按钮(隐藏 input[type=file],ui/icons 新增 IconImage);上传 = `channelApi.uploadAttachment`(JSON base64,POST /channels/:id/attachments),客户端先拦类型白名单/5MB 上限即时 toast,失败 toast 不动草稿(草稿从未被触碰,比发送失败回灌更简单),上传中 hint 行出「上传图片中…」占位。渲染双侧出图——agent 侧 MarkdownBody img 渲染器接 `resolveAttachmentSrc`(`utils/attachmentUrl.ts`:命中频道附件相对 URL 现拼 `?token=`(buildSseUrl 同款现取 authStore token,token 不落消息文本),其余 src 原样透传);人类/系统侧纯文本渲染自 `renderWithMentions` 升级为 `renderWithMentionsAndImages`(`utils/messageImages.tsx`:只识别本系统附件 URL 的图片语法 → ,任意外链图不渲染按文本原样输出,mention chip 共存)。消息体存相对 URL 即完整事实源,刷新后重渲现拼 token,天然持久。 - **#485**(2026-09-11)composer 文件引用台账可视化:ChannelInput 已挂 fileRefs 渲染为输入框上方 chip(`.mc-fileref-chips`/`.mc-fileref-chip`,路径 + 工程 basename),✕ 单独移除(只动台账不动正文,发送过滤自然不携带);正文被编辑得不再含路径时 chip 标灰 + 「已失效」标记(`.mc-fileref-chip-invalid`),不静默丢弃;发送仍只上送正文含路径的有效引用(行为不变)。 +- **批次 I-1**(2026-09-16,治理人闸当场过,docs/plans/2026-09-style-guide-layering.md)全局视觉走查落规范:全仓静态审计——class 三体系并存(Tailwind 工具类 785 / `u-*` 847 / 页面 BEM 867 / 内联 style 133 处)、`ui/Button` 仅 4 文件消费 vs 原生 ` )} @@ -324,7 +324,7 @@ export function NotificationBell() { {/* 导航列表 */} diff --git a/apps/web/src/components/TopNav.tsx b/apps/web/src/components/TopNav.tsx index 842707cec..85c1bbdf0 100644 --- a/apps/web/src/components/TopNav.tsx +++ b/apps/web/src/components/TopNav.tsx @@ -6,7 +6,6 @@ import { MoreDropdown } from './MoreDropdown'; import { NotificationBell } from './NotificationBell'; import { useWebSocketContext } from '../api/websocketHooks'; import { IconZap } from './ui/icons'; -import '../styles/theme.css'; interface TopNavProps { onMenuClick?: () => void; // MR-009: 汉堡菜单回调 diff --git a/apps/web/src/components/TriageBanner.tsx b/apps/web/src/components/TriageBanner.tsx index f11cc8519..6444d34e4 100644 --- a/apps/web/src/components/TriageBanner.tsx +++ b/apps/web/src/components/TriageBanner.tsx @@ -33,7 +33,7 @@ export function TriageBanner() { diff --git a/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx b/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx index 86baff09e..e5827eaa8 100644 --- a/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx +++ b/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx @@ -19,7 +19,16 @@ vi.mock('../channel/KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => vi.mock('../channel/ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); import { ChannelMessageItem } from '../channel/ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../channel/ChannelMessageEnv'; +import type { ChannelMessageEnv } from '../channel/ChannelMessageEnv'; import type { ChannelMessage } from '../../api/channel'; +import type { ReactElement } from 'react'; + +// #547:onAction/onInlineReply/onOpenWorkUnit 等横切值改经 Context 下发——包 Provider 渲染; +// 消息本体与 per-message 派生值(waitingForInput/highlight)仍走 props +const wrap = (node: ReactElement, env: Partial = {}) => ( + {node} +); const baseMessage: ChannelMessage = { id: 'msg-1', @@ -40,13 +49,13 @@ beforeEach(() => { describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { // E1:消息头「等待回复」badge 已删——内嵌回复框本身是流内信号与行动点,待办强信号唯一位 = 顶栏 chip it('waitingForInput → 渲染内嵌回复框(流内信号本体),无消息头 badge', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); expect(screen.queryByText('等待回复')).not.toBeInTheDocument(); }); it('非等待态不渲染内嵌回复框', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.queryByLabelText('回复 wu-1')).not.toBeInTheDocument(); }); @@ -58,7 +67,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise(resolve => { resolveReply = resolve; }), ); - render(); + render(wrap(, { onInlineReply })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); @@ -77,7 +86,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise((_, reject) => { rejectReply = reject; }), ); - render(); + render(wrap(, { onInlineReply })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); @@ -95,7 +104,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { // #276 AC1:文案不再含未来时假承诺「WorkUnit 将继续执行」 it('#276 文案不再含「WorkUnit 将继续执行」假承诺(已回复文本 + placeholder)', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.queryByText(/WorkUnit 将继续执行/)).not.toBeInTheDocument(); const input = screen.getByPlaceholderText(/直接在此回复/) as HTMLInputElement; expect(input).toBeTruthy(); @@ -108,7 +117,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise(resolve => { resolveReply = resolve; }), ); - render(); + render(wrap(, { onInlineReply })); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.keyDown(screen.getByLabelText('回复 wu-1'), { key: 'Enter' }); await waitFor(() => expect(onInlineReply).toHaveBeenCalledTimes(1)); @@ -126,16 +135,16 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise(resolve => { resolveReply = resolve; }), ); - const { rerender } = render(); + const { rerender } = render(wrap(, { onInlineReply })); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); resolveReply(); expect(await screen.findByText(/已回复/)).toBeInTheDocument(); // WU 复活 -> 等待区整体收起 - rerender(); + rerender(wrap(, { onInlineReply })); expect(screen.queryByText(/已回复/)).not.toBeInTheDocument(); // WU 再度挂起(无新提问,仍落本条)-> 回到等待回复态,表单可再次使用 - rerender(); + rerender(wrap(, { onInlineReply })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); expect(screen.queryByText(/已回复/)).not.toBeInTheDocument(); }); @@ -146,7 +155,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise((_, reject) => { rejectReply = reject; }), ); - render(); + render(wrap(, { onInlineReply })); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); await waitFor(() => expect(screen.getByText('发送中…')).toBeInTheDocument()); @@ -157,7 +166,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { // #279(决策 #250 D4):顶栏 chip 定位高亮——highlight prop 挂 mc-msg-highlight class it('highlight prop → 消息根元素带 mc-msg-highlight class', () => { - const { container } = render(); + const { container } = render(wrap()); expect(container.querySelector('.mc-msg.mc-msg-highlight')).toBeTruthy(); expect(container.querySelector('[data-message-id="msg-1"]')).toBeTruthy(); }); @@ -167,35 +176,35 @@ describe('ChannelMessageItem — §5.7 WU/PMO chip', () => { // 2026-09 视觉批次1 ⑤:WU chip 旁 ↗ 直跳钮已删(同目的地不双入口),chip 自承载开抽屉 it('有 workUnitId 时渲染 WU chip,点击经 onOpenWorkUnit 开任务抽屉', () => { const onOpenWorkUnit = vi.fn(); - render(); + render(wrap(, { onOpenWorkUnit })); fireEvent.click(screen.getByTitle('打开任务详情:wu-1')); expect(onOpenWorkUnit).toHaveBeenCalledWith('wu-1'); }); it('无 workUnitId 时不渲染 WU chip', () => { - render(); + render(wrap(, { onOpenWorkUnit: vi.fn() })); expect(screen.queryByTitle(/^打开任务详情/)).not.toBeInTheDocument(); }); it('meta.pmoId 存在时渲染 PMO chip,点击跳 /pmo/project/:id', () => { const msg: ChannelMessage = { ...baseMessage, meta: JSON.stringify({ pmoId: 'proj-1' }) }; - render(); + render(wrap()); fireEvent.click(screen.getByTitle('打开项目详情')); expect(mockNavigate).toHaveBeenCalledWith('/pmo/project/proj-1'); }); it('老消息 meta 无 pmoId 时不渲染 PMO chip', () => { - render(); + render(wrap()); expect(screen.queryByTitle('打开项目详情')).not.toBeInTheDocument(); }); it('meta 缺失/非法 JSON 时不渲染 PMO chip(防御)', () => { const noMeta: ChannelMessage = { ...baseMessage, meta: undefined }; - render(); + render(wrap()); expect(screen.queryByTitle('打开项目详情')).not.toBeInTheDocument(); const badMeta: ChannelMessage = { ...baseMessage, meta: '{broken' }; - render(); + render(wrap()); expect(screen.queryByTitle('打开项目详情')).not.toBeInTheDocument(); }); }); @@ -211,7 +220,7 @@ describe('ChannelMessageItem — #264 object meta 双型兼容(线上 REST/SSE cardData: { entries: [{ id: 'k-1', title: 'session 过期未刷新导致 401', type: 'pitfall' }] }, }, }; - render(); + render(wrap()); // 卡片渲染出审批按钮;纯文本回退时只显示 content,无按钮 expect(screen.getByText('通过')).toBeTruthy(); expect(screen.getByText('拒绝')).toBeTruthy(); @@ -227,13 +236,13 @@ describe('ChannelMessageItem — #264 object meta 双型兼容(线上 REST/SSE cardData: { entries: [{ id: 'k-1', title: 'session 过期未刷新导致 401', type: 'pitfall' }] }, }), }; - render(); + render(wrap()); expect(screen.getByText('通过')).toBeTruthy(); }); it('meta 为 object 且含 pmoId 时渲染 PMO chip,点击跳 /pmo/project/:id', () => { const msg: ChannelMessage = { ...baseMessage, meta: { pmoId: 'proj-9' } }; - render(); + render(wrap()); fireEvent.click(screen.getByTitle('打开项目详情')); expect(mockNavigate).toHaveBeenCalledWith('/pmo/project/proj-9'); }); @@ -252,7 +261,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio ]; it('waitingForInput + object meta 带 options → 渲染选项卡而非纯文本列表', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByText('studio')).toBeTruthy(); expect(screen.getByText('/root/projects/studio-config')).toBeTruthy(); expect(screen.getByText('自定义…')).toBeTruthy(); @@ -264,7 +273,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio it('点选选项 → onInlineReply(message, value) 走现有内嵌回复通道', async () => { const onInlineReply = vi.fn(); const msg = optionsMessage({ options: OPTIONS }); - render(); + render(wrap(, { onInlineReply })); // #276:点击后经 async 微任务置位状态,act 包裹 flush 微任务 await act(async () => { fireEvent.click(screen.getByText('studio-config')); @@ -275,7 +284,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio // #276:选项卡发送成功 -> needSent 置位 -> 收起为已回复提示 it('#276 选项卡发送成功 -> 收起为已回复提示', async () => { const onInlineReply = vi.fn().mockResolvedValue(undefined); - render(); + render(wrap(, { onInlineReply })); fireEvent.click(screen.getByText('studio')); // await resolve 后:needSent=true -> 已回复,选项卡收起 expect(await screen.findByText(/已回复/)).toBeTruthy(); @@ -288,7 +297,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio const onInlineReply = vi.fn().mockImplementation( () => new Promise((_, reject) => { rejectReply = reject; }), ); - render(); + render(wrap(, { onInlineReply })); fireEvent.click(screen.getByText('studio')); // 发送中:选项禁用 await waitFor(() => expect((screen.getByText('studio-config').closest('button') as HTMLButtonElement).disabled).toBe(true)); @@ -299,19 +308,19 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio }); it('string 形态 meta 带 options 同样渲染(存量形态回归)', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByText('studio')).toBeTruthy(); }); it('无 options 时保持现有单行回复框 fallback', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByPlaceholderText(/直接在此回复/)).toBeTruthy(); expect(screen.queryByText('自定义…')).toBeNull(); }); it('options 元素非法(缺 label)时防御性过滤,不崩溃', () => { const msg = optionsMessage({ options: [{ description: '/x' }, { label: 'studio' }] }); - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByText('studio')).toBeTruthy(); expect(screen.queryByText('/x')).toBeNull(); }); @@ -319,13 +328,13 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio describe('ChannelMessageItem — #241 footer WU 链接截短显示', () => { it('长 UUID 截短为前 8 位 + …,title 保留全量 id', () => { const uuid = '160eeee8-aaaa-bbbb-cccc-dddddddddddd'; - render(); + render(wrap(, { onOpenWorkUnit: vi.fn() })); const link = screen.getByTitle(`打开任务详情:${uuid}`); expect(link.textContent).toBe('160eeee8… ›'); }); it('短 id(WU-N 形态)原样显示不截短', () => { - render(); + render(wrap(, { onOpenWorkUnit: vi.fn() })); expect(screen.getByText('wu-1 ›')).toBeTruthy(); }); }); @@ -343,10 +352,11 @@ describe('ChannelMessageItem — #278 D5 机制派生回执 = 系统播报形态 it('Studio 署名无卡消息(带 workUnitId 的派生回执)渲染为 mc-msg-system 且无消息头', () => { const { container } = render( - , + wrap( + , + ), ); expect(container.querySelector('.mc-msg-system')).toBeTruthy(); expect(container.querySelector('.mc-msg-head')).toBeNull(); @@ -356,13 +366,14 @@ describe('ChannelMessageItem — #278 D5 机制派生回执 = 系统播报形态 it('卡片消息不归系统播报(全宽卡片形态优先)', () => { const { container } = render( - , + wrap( + , + ), ); expect(container.querySelector('.mc-msg-system')).toBeNull(); expect(container.querySelector('.mc-msg-card')).toBeTruthy(); diff --git a/apps/web/src/components/channel/ChannelInput.tsx b/apps/web/src/components/channel/ChannelInput.tsx index 49879ce8b..029d1ce34 100644 --- a/apps/web/src/components/channel/ChannelInput.tsx +++ b/apps/web/src/components/channel/ChannelInput.tsx @@ -14,7 +14,7 @@ import { useRosterStore, activeAgentsOf } from '../../stores/rosterStore'; import { useChannelDataStore } from '../../stores/channelDataStore'; import { toast } from '../../utils/toast'; import { emitSendClick } from '../../utils/clientPerf'; -import { IconImage } from '../ui/icons'; +import { IconImage, IconX } from '../ui/icons'; interface Props { onSend: (content: string, replyToId?: string, files?: FileRef[]) => void | Promise; @@ -358,7 +358,7 @@ export function ChannelInput({ onSend, sending, replyTo, onCancelReply, channelI )} )} @@ -383,7 +383,7 @@ export function ChannelInput({ onSend, sending, replyTo, onCancelReply, channelI aria-label={`移除引用 ${ref.path}`} onClick={() => removeFileRef(ref)} > - ✕ + ); diff --git a/apps/web/src/components/channel/ChannelMemberManager.tsx b/apps/web/src/components/channel/ChannelMemberManager.tsx index da6ef3244..708c2609e 100644 --- a/apps/web/src/components/channel/ChannelMemberManager.tsx +++ b/apps/web/src/components/channel/ChannelMemberManager.tsx @@ -12,6 +12,7 @@ import { useChannelDataStore } from '../../stores/channelDataStore'; import { Select } from '../ui'; import { toast } from '../../utils/toast'; import { serverErrorMessage } from '../../utils/errorMessage'; +import { IconX } from '../ui/icons'; interface ChannelMemberManagerProps { channelId: string; @@ -180,7 +181,7 @@ export const ChannelMemberManager: React.FC = ({ chan className="mc-icon-btn" title="移除" > - ✕ + ))} diff --git a/apps/web/src/components/channel/ChannelMessageEnv.tsx b/apps/web/src/components/channel/ChannelMessageEnv.tsx new file mode 100644 index 000000000..a02901046 --- /dev/null +++ b/apps/web/src/components/channel/ChannelMessageEnv.tsx @@ -0,0 +1,40 @@ +// 频道消息环境(#547,架构评审 2026-09-15 候选 B3,grilling 已决): +// 横切值(卡片 action 路由 / 回复 / 消息查找 / 抽屉开启回调 / quote 定位)单 Provider 下发, +// ChannelMessageItem 经 useChannelMessageEnv() 自取——公开 Props 收窄到消息本体 + per-message 派生值, +// 页面 renderMessageItem 不再逐 prop 手喂横切值。 +// #322 契约不变量:Provider 的 value 必须全部由稳定引用组成(useMemo 组装 + useCallback 成员), +// identity 不变 = 零重渲扇出;高频 volatile state(highlightId/focusedId/freshMsgIds)禁止进本 Context +// (value 一变全量消息项扇出重渲),per-message boolean 仍走 props(memo 友好)。 +// fileVocabulary 不进本 Context——消息项直接复用 useChannelDataStore 既有 selector 模式自取(回调不进 store)。 +import { createContext, useContext } from 'react'; +import type { ChannelMessage } from '../../api/channel'; + +export interface ChannelMessageEnv { + /** 统一卡片 action 路由(useChannelCardActions dispatch 单一入口) */ + onAction: (messageId: string, action: string) => void; + onReply?: (message: ChannelMessage) => void; + findMessage?: (id: string) => ChannelMessage | undefined; + channelId?: string; + /** Mission Control: 打开右抽屉(WorkUnit 详情) */ + onOpenWorkUnit?: (workUnitId: string) => void; + /** #284(决策 #250 D6):analysis_confirm 接力卡「去确认」 */ + onOpenWorkUnitConfirm?: (workUnitId: string) => void; + /** #467:plan_ruling 裁决轮接力卡「去裁决」 */ + onOpenWorkUnitRuling?: (workUnitId: string) => void; + /** #567:plan_direction 方向锁定接力卡「去选定」 */ + onOpenWorkUnitDirection?: (workUnitId: string) => void; + onOpenRequirement?: (reqId: string) => void; + /** F5: NEED_INPUT 卡片内嵌回复;#276:返回 Promise 以便按真实成功置位「已回复」 */ + onInlineReply?: (message: ChannelMessage, content: string) => void | Promise; + /** channel 上下游优化 Phase 1(AC1):quote 引用块点击定位上游消息 */ + onQuoteClick?: (messageId: string) => void; +} + +const ChannelMessageEnvContext = createContext(null); + +export const ChannelMessageEnvProvider = ChannelMessageEnvContext.Provider; + +/** 缺 Provider(不应出现于生产——频道页装配层必挂)→ null,消息项各横切行为 fail-closed 不渲染 */ +export function useChannelMessageEnv(): ChannelMessageEnv | null { + return useContext(ChannelMessageEnvContext); +} diff --git a/apps/web/src/components/channel/ChannelMessageItem.tsx b/apps/web/src/components/channel/ChannelMessageItem.tsx index 2f5139805..44c0d2ad9 100644 --- a/apps/web/src/components/channel/ChannelMessageItem.tsx +++ b/apps/web/src/components/channel/ChannelMessageItem.tsx @@ -2,9 +2,12 @@ // 2026-07 视觉重构(方向 A Mission Control):纯文本行 + 卡片族视觉重绘;交互语义零变更 // #277(决策 #248 D1/D2/D3/D5):分侧布局——人右轻气泡 / agent 左无气泡文档流 / 系统播报 // (Studio 无卡非等待消息)淡色小字一行(#437 起左对齐随文档流,不再居中)/ 卡片全宽不参与分侧;compact 省略重复头;双侧 @name 染 mention chip。 -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { ChannelFileVocabulary, ChannelMessage } from '../../api/channel'; +import type { ChannelMessage } from '../../api/channel'; +import { avatarPattern } from '../../utils/avatar'; +import { useChannelDataStore } from '../../stores/channelDataStore'; +import { useChannelMessageEnv, type ChannelMessageEnv } from './ChannelMessageEnv'; import { toast } from '../../utils/toast'; import { AuthorAvatar } from './AuthorAvatar'; import { FileRefChip } from './FileRefChip'; @@ -13,9 +16,11 @@ import { matchFileRefToken } from '../../utils/fileChipMatch'; import { renderWithMentionsAndImages } from '../../utils/messageImages'; import { RequirementsDocCard } from './RequirementsDocCard'; import { KnowledgeConfirmCard } from './KnowledgeConfirmCard'; +import { IconCheck } from '../ui/icons'; import { ReviewProposalCard } from './ReviewProposalCard'; import { AnalysisConfirmCard } from './AnalysisConfirmCard'; import { PlanRulingCard } from './PlanRulingCard'; +import { PlanDirectionCard } from './PlanDirectionCard'; import { ConvertToTaskDialog } from './ConvertToTaskDialog'; import { NeedInputOptions } from './NeedInputOptions'; import { PmoChip } from '../pmo/PmoChip'; @@ -24,54 +29,46 @@ import { parseMeta, type CardMeta, type MetaOption } from '../../utils/messageMe import { parseSeverityPrefix } from '../../utils/severityPrefix'; import { useImeEnterGuard } from '../../hooks/useImeEnterGuard'; -interface Props { +// #547(架构评审 2026-09-15 候选 B3,grilling 已决):公开 Props 收窄到消息本体 + +// per-message 派生值(5)+ 结构 props(6,ChannelStreamBody 经封闭 extra 通道喂入); +// 11 个横切值(onAction/onReply/findMessage/channelId/抽屉回调/onInlineReply/onQuoteClick) +// 经 ChannelMessageEnv Context 自取,fileVocabulary 经 useChannelDataStore selector 自取。 +export interface ChannelMessageItemProps { message: ChannelMessage; - onAction: (messageId: string, action: string) => void; - onReply?: (message: ChannelMessage) => void; - findMessage?: (id: string) => ChannelMessage | undefined; - channelId?: string; - /** AC-C3: thread rendering */ - isThreadAnchor?: boolean; - threadReplyCount?: number; - isExpanded?: boolean; - /** #322:稳定 props 契约——收锚点 id(父组件直传 useCallback 的 toggleThread,不再内联闭包) */ - onToggleThread?: (anchorId: string) => void; - isThreadReply?: boolean; /** F5: 关联 WorkUnit 挂起等待人类回复(NEED_INPUT) */ waitingForInput?: boolean; - /** Mission Control: 打开右抽屉(WorkUnit 详情 / REQ 全链路) */ - onOpenWorkUnit?: (workUnitId: string) => void; - /** #284(决策 #250 D6):analysis_confirm 接力卡「去确认」——开 WU 抽屉并自动弹确认对话框 */ - onOpenWorkUnitConfirm?: (workUnitId: string) => void; - /** #467:plan_ruling 裁决轮接力卡「去裁决」——开 WU 抽屉并自动弹 PlanRulingDialog */ - onOpenWorkUnitRuling?: (workUnitId: string) => void; - onOpenRequirement?: (reqId: string) => void; - /** F5: NEED_INPUT 卡片内嵌回复(与回复按钮同链路:sendMessage + replyToId); - * #276:返回 Promise 以便按真实成功置位「已回复」(失败可重试,不发假承诺) */ - onInlineReply?: (message: ChannelMessage, content: string) => void | Promise; - /** #285: agent 消息 inline-code 文件 chip 词表;经 MarkdownBody renderInlineCode 挂载(#271) */ - fileVocabulary?: ChannelFileVocabulary; /** #285 AC4: 该消息所属 WU 的产出/修改文件集(chip 第一优先词表,绝对路径;空/缺省 → 仅用候选集词表) */ wuChangedFiles?: string[]; - /** #277(决策 #248 D2):连续合并——省略重复头(头像/署名/时间),动作保留 */ - compact?: boolean; /** #279(决策 #250 D4):顶栏待办 chip 定位高亮 */ highlight?: boolean; - /** channel 上下游优化 Phase 1(AC1):quote 引用块点击定位上游消息—— - * 提供且父消息已加载(findMessage 命中)时 quote button 化;父组件须传 useCallback 稳定引用(#322 契约) */ - onQuoteClick?: (messageId: string) => void; /** 批次 E-3:SSE 新到达消息渐隐高亮(.mc-msg-new,accent-dim 底色,页面 2s 后自清) */ fresh?: boolean; /** Phase 3(AC5):j/k 键盘导航焦点环(.mc-msg-focused,accent outline;布尔按消息变化只影响焦点迁移的两条) */ focused?: boolean; + // —— 结构 props(#547 封闭集:ChannelStreamBody 的 StreamMessageExtra 只允许这 6 键)—— + /** AC-C3: thread rendering */ + isThreadAnchor?: boolean; + threadReplyCount?: number; + isExpanded?: boolean; + /** #322:稳定 props 契约——收锚点 id(父组件直传 useCallback 的 toggleThread,不再内联闭包) */ + onToggleThread?: (anchorId: string) => void; + /** #277(决策 #248 D2):连续合并——省略重复头(头像/署名/时间),动作保留 */ + compact?: boolean; + isThreadReply?: boolean; + /** 2026-09 视觉批次 vc7:线程回复所属的 anchor id——组内消息 quote 的父消息就是 anchor 时 + * 抑制该 quote(`.mc-thread-context` 归属头已统一承载且可点定位 anchor; + * 原形态每条回复重复同一行 anchor 引用,「谁回复谁」被噪音淹没)。 + * 父消息非 anchor(回复的是组内另一条消息)时 quote 照常渲染 */ + threadAnchorId?: string; } function renderCard( meta: CardMeta, message: ChannelMessage, - onAction: Props['onAction'], - onOpenWorkUnitConfirm: Props['onOpenWorkUnitConfirm'], - onOpenWorkUnitRuling: Props['onOpenWorkUnitRuling'], + onAction: ChannelMessageEnv['onAction'], + onOpenWorkUnitConfirm: ChannelMessageEnv['onOpenWorkUnitConfirm'], + onOpenWorkUnitRuling: ChannelMessageEnv['onOpenWorkUnitRuling'], + onOpenWorkUnitDirection: ChannelMessageEnv['onOpenWorkUnitDirection'], ) { switch (meta.cardType) { case 'requirements_doc': @@ -91,28 +88,43 @@ function renderCard( return ; case 'plan_ruling': // #467 裁决轮接力卡 return ; + case 'plan_direction': // #567 方向锁定接力卡 + return ; default: return null; } } // #322:React.memo 化——父组件已建立稳定 props 契约(useCallback/提升 + 镜像 ref), -// step 事件/无关 state 变化不再重渲既有消息项(render-count 测试兜底) +// step 事件/无关 state 变化不再重渲既有消息项(render-count 测试兜底)。 +// #547:横切值改经 ChannelMessageEnv Context——env value 全稳定引用时 context 不触发扇出, +// memo 边界不变;per-message boolean 翻牌只命中个别消息(focus-fanout 测试兜底)。 export const ChannelMessageItem = memo(function ChannelMessageItem({ - message, onAction, onReply, findMessage, channelId, - isThreadAnchor, threadReplyCount, isExpanded, onToggleThread, isThreadReply, - waitingForInput, onOpenWorkUnit, onOpenWorkUnitConfirm, onOpenWorkUnitRuling, onOpenRequirement, onInlineReply, fileVocabulary, wuChangedFiles, compact, highlight, onQuoteClick, fresh, focused, -}: Props) { + message, + waitingForInput, wuChangedFiles, highlight, fresh, focused, + isThreadAnchor, threadReplyCount, isExpanded, onToggleThread, compact, isThreadReply, threadAnchorId, +}: ChannelMessageItemProps) { + // #547:横切值自取——Context(回调/查找/channelId)+ 数据面 store(fileVocabulary,selector 既有模式) + const env = useChannelMessageEnv(); + const { + onAction, onReply, findMessage, channelId, + onOpenWorkUnit, onOpenWorkUnitConfirm, onOpenWorkUnitRuling, onOpenWorkUnitDirection, onOpenRequirement, + onInlineReply, onQuoteClick, + } = env ?? {}; + // #285: agent 消息 inline-code 文件 chip 词表;按 channelId 键控(无跨频道串词表); + // 缺 Provider/词表未加载 → undefined,chip 不渲染(失败静默降级语义不变) + const fileVocabulary = useChannelDataStore(s => (channelId ? s.vocabulary[channelId] : undefined)); const isHuman = message.authorType === 'human'; const meta = parseMeta(message.meta); - const card = renderCard(meta, message, onAction, onOpenWorkUnitConfirm, onOpenWorkUnitRuling); + // 缺 Provider(env=null)→ onAction 缺省 → 卡片不渲染(fail-closed;生产装配层必挂 Provider) + const card = onAction ? renderCard(meta, message, onAction, onOpenWorkUnitConfirm, onOpenWorkUnitRuling, onOpenWorkUnitDirection) : null; const parentMessage = message.replyToId && findMessage ? findMessage(message.replyToId) : undefined; const [convertOpen, setConvertOpen] = useState(false); const [needDraft, setNeedDraft] = useState(''); const [needSent, setNeedSent] = useState(false); // #276(P2 #15):发送中状态——禁用表单防重复触发,await 真实结果后才置位「已回复」 const [needSending, setNeedSending] = useState(false); - // Phase 3(AC4):复制反馈——成功后按钮变 ✓ 约 1.5s 恢复(本地 state + 定时器,卸载清理) + // Phase 3(AC4):复制反馈——成功后按钮变对勾图标约 1.5s 恢复(本地 state + 定时器,卸载清理;批次 I-6 ✓ → IconCheck) const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); useEffect(() => () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); }, []); @@ -141,7 +153,7 @@ export const ChannelMessageItem = memo(function ChannelMessageItem({ title="复制" aria-label="复制消息内容" > - {copied ? '✓' : '⧉'} + {copied ? : '⧉'} ); // #270:NEED_INPUT 内嵌回复框共享 composer 同款 IME 守卫 @@ -154,8 +166,8 @@ export const ChannelMessageItem = memo(function ChannelMessageItem({ const handleConverted = () => { setConvertOpen(false); - // Parent will refresh messages via onAction - onAction(message.id, 'converted'); + // Parent will refresh messages via onAction(canConvert 要求 channelId,即 Provider 在场,onAction 必有) + onAction?.(message.id, 'converted'); }; // F5: 卡片内嵌回复 —— 走与回复按钮完全相同的链路(sendMessage + replyToId), @@ -214,6 +226,12 @@ export const ChannelMessageItem = memo(function ChannelMessageItem({ // #277 D1:分侧类——卡片全宽不参与分侧 // mc-msg-card:无样式规则,测试 DOM 钩子(ChannelMessageItem.test.tsx 断言用,#431 定性保留,删类会红测试) const sideClass = card ? 'mc-msg-card' : isSystem ? 'mc-msg-system' : isHuman ? 'mc-msg-human' : 'mc-msg-agent'; + // 2026-09 视觉批次 vc6:agent 框左色条 = 该角色 identicon 同号 --chart-* 类别色(§4.8 不占 accent)—— + // 与头像同色同源(avatarPattern 同名恒同号),compact 无头消息也一眼认角色;人类/系统/卡片不设。 + // 内联 style 仅承载 per-message 参数(规范 §2.2 豁免位),色值本体仍是 token + const agentColorStyle: CSSProperties | undefined = sideClass === 'mc-msg-agent' + ? ({ '--mc-agent-color': `var(--chart-${avatarPattern(message.agentName || 'Agent').paletteIndex + 1})` } as CSSProperties) + : undefined; const actionButtons = ( <> @@ -245,11 +263,14 @@ export const ChannelMessageItem = memo(function ChannelMessageItem({
{/* Quote block (reply reference) channel 上下游优化 Phase 1(AC1):提供 onQuoteClick 且父消息已加载时 button 化——点击定位上游消息; - 父消息掉出已加载分页(findMessage 未命中)不渲染 quote,不可点 */} - {parentMessage && ( + 父消息掉出已加载分页(findMessage 未命中)不渲染 quote,不可点。 + vc7:线程内父消息 = anchor 时抑制——归属头 .mc-thread-context 已统一承载「回复谁」且可点定位, + 逐条重复同一行 anchor 引用是纯噪音(2026-09-16 走查:用户读不出谁回复谁) */} + {parentMessage && parentMessage.id !== threadAnchorId && ( onQuoteClick ? ( + ); +}); + interface Props { activeChannelId?: string; // #395:并入全局 Sidebar(<768)时选中频道/创建完成需收起 sidebar overlay——导航后回调 @@ -26,7 +76,7 @@ interface Props { } export function ChannelRail({ activeChannelId, onNavigate }: Props) { - const { channels, loading, unreadCounts, clearUnread, createChannel } = useChannelList(); + const { channels, loading, clearUnread, createChannel } = useChannelList(); useRosterStoreSync(); const agents = useRosterStore((s) => s.agents); const agentsLoadedOnce = useRosterStore((s) => s.agentsLoadedOnce); @@ -55,27 +105,18 @@ export function ChannelRail({ activeChannelId, onNavigate }: Props) { const onlineCount = visibleAgents.filter(a => a.status === 'idle' || a.status === 'active').length; - // 频道 agent 在线数:members ∩ 非 terminated agent(无 members 配置则不显示,不编造) - const chanCounts = (membersJson?: string): { online: number; total: number } | null => { - if (!membersJson) return null; - try { - const ids = JSON.parse(membersJson) as string[]; - if (!Array.isArray(ids) || ids.length === 0) return null; - const online = ids.filter(id => { - const s = agentStatusById.get(id); - return !!s && s !== 'terminated'; - }).length; - return { online, total: ids.length }; - } catch { - return null; - } - }; + // F4:members JSON 解析随 channels 切片 memo(引用稳定 → ChannelRow memo 生效) + const memberIdsByChannel = useMemo(() => { + const m = new Map(); + for (const ch of channels) m.set(ch.id, parseMembers(ch.members)); + return m; + }, [channels]); - const handleSelect = (id: string) => { + const handleSelect = useCallback((id: string) => { clearUnread(id); if (id !== activeChannelId) navigate(`/channels/${id}`); onNavigate?.(); - }; + }, [clearUnread, activeChannelId, navigate, onNavigate]); return ( @@ -140,66 +140,52 @@ export function WorkUnitDrawer({ drawer, onClose, onOpenWu, onOpenReq, todoNav } // ── WorkUnit 详情 ── -function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { id: string; autoApprove?: boolean; autoRuling?: boolean; onOpenReq: (reqId: string) => void }) { +function WuDetail({ id, autoApprove = false, autoRuling = false, autoDirection = false, onOpenReq }: { id: string; autoApprove?: boolean; autoRuling?: boolean; autoDirection?: boolean; onOpenReq: (reqId: string) => void }) { const navigate = useNavigate(); - const [wu, setWu] = useState(null); + // #549(B5 收口):WU 详情读 workunitStore detail slice——REST 打底(loadWorkUnitDetail) + // 与 SSE status_changed 就地 upsert 的落点合一(路由在 App 级 useWorkUnitStoreSync); + // 本地 wu 直替已删,drawer 退回 store 订阅者。workunit.tokens 订阅保留本地(唯一消费方) + const detail = useWorkUnitStore(s => s.detailById[id]); const [tokens, setTokens] = useState(null); const [overhead, setOverhead] = useState(null); // #275(#251 断点2):「#频道名」回频道入口的频道名(best-effort,失败退回 id 截短显示) const [channelName, setChannelName] = useState(null); - const [error, setError] = useState(''); - // #241: 悬空 WU 引用(历史清理后消息 footer 指向已不存在的 WU)——404 单列友好态 - const [notFound, setNotFound] = useState(false); const [showTreeTokens, setShowTreeTokens] = useState(false); // E2-1:ReviewHint 自列表展开区挪入——频道成员(in_review 时判断是否有人可认领评审); // null = 未拉到(含 best-effort 失败)→ 不渲染提醒(空数组会误报「无人可认领」) const [channelMembers, setChannelMembers] = useState(null); - // 决策 8(2026-08 SSE 负载加深):SSE 事件订阅——status_changed 负载 = 全量 WorkUnit - // (同 workunitApi.get 形状)按 id 匹配直接替换本地 wu;workunit.tokens 复用 - // parseWorkunitTokenEvents 防御解析(他 WU / 缺字段负载跳过,聚合保持现有值)。 - // 替代原 eventTick(400ms 防抖)驱动的整组重拉。 const { onEvent } = useWebSocketContext(); - // 渲染期按 id 重置(替代原 effect 内同步重置):SSE 事件触发的更新不重置, - // 消除每次事件都闪"加载中…"的骨架闪烁——事件刷新静默进行,旧数据留到新数据到达 + // 渲染期按 id 重置本地切片(替代原 effect 内同步重置):SSE 事件触发的更新不重置, + // 消除每次事件都闪"加载中…"的骨架闪烁——事件刷新静默进行,旧数据留到新数据到达; + // detail slice 由 store 按 id 分区,无需重置 const [prevId, setPrevId] = useState(id); if (prevId !== id) { setPrevId(id); - setWu(null); setTokens(null); setChannelName(null); setChannelMembers(null); - setError(''); - setNotFound(false); } - // 开抽屉一次性打底:WU 详情(+ 频道名/频道成员 best-effort) + // 开抽屉一次性打底:WU 详情进 store detail slice(打开/换 id 即拉,不做 TTL 门禁) useEffect(() => { + void useWorkUnitStore.getState().loadWorkUnitDetail(id); + }, [id]); + + // WU 落库后补频道面(best-effort,随 channelId 派生,不随 SSE 快照重拉): + // #275 频道名(失败退回 id 截短)+ E2-1 ReviewHint 频道成员(失败留 null 不渲染提醒,防误报) + const channelId = detail?.wu?.channelId ?? null; + useEffect(() => { + if (!channelId) return; let alive = true; - workunitApi.get(id) - .then(r => { - if (!alive) return; - setWu(r.data); - setError(''); - if (r.data.channelId) { - // #275(#251 断点2):频道名 best-effort(频道已删/无权限时保留 null,链接退回 id 截短) - channelApi.get(r.data.channelId) - .then(res => { if (alive) setChannelName(res.data.data.name); }) - .catch(() => { /* best-effort */ }); - // E2-1:ReviewHint 频道成员判断(best-effort;失败留 null 不渲染提醒,防误报) - channelApi.listAgents(r.data.channelId) - .then(res => { if (alive) setChannelMembers(res.data.data); }) - .catch(() => { /* best-effort */ }); - } - }) - .catch(e => { - if (!alive) return; - if (axios.isAxiosError(e) && e.response?.status === 404) setNotFound(true); - // 批次A 项6:错误文案走 errorMessage 正本(服务端 error.message 优先,不再直拼 axios 裸 message) - else setError(errorMessage(e)); - }); + channelApi.get(channelId) + .then(res => { if (alive) setChannelName(res.data.data.name); }) + .catch(() => { /* best-effort */ }); + channelApi.listAgents(channelId) + .then(res => { if (alive) setChannelMembers(res.data.data); }) + .catch(() => { /* best-effort */ }); return () => { alive = false; }; - }, [id]); + }, [channelId]); // 开抽屉一次性打底:token 度量历史(此后增量走 workunit.tokens SSE) useEffect(() => { @@ -219,20 +205,17 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { return () => { alive = false; }; }, [id]); - // SSE 增量订阅(决策 8) + // SSE 增量订阅:workunit.tokens 聚合累加(决策 8;status_changed 直替已收口进 store,#549) useEffect(() => onEvent((msg) => { - if (msg.event_type === 'workunit.status_changed') { - const data = msg.data as { workunit?: WorkUnit } | null; - if (data?.workunit && data.workunit.id === id) setWu(data.workunit); - } else if (msg.event_type === 'workunit.tokens') { - const [ev] = parseWorkunitTokenEvents([{ payload: msg.data }], id); - if (ev) setTokens(prev => [...(prev ?? []), ev]); - } + if (msg.event_type !== 'workunit.tokens') return; + const [ev] = parseWorkunitTokenEvents([{ payload: msg.data }], id); + if (ev) setTokens(prev => [...(prev ?? []), ev]); }), [onEvent, id]); - if (notFound) return
该任务不存在或已被清理(id:{id})
; - if (error) return
加载失败: {error}
; - if (!wu) return ; + if (detail?.notFound) return
该任务不存在或已被清理(id:{id})
; + if (detail?.error) return
加载失败: {detail.error}
; + if (!detail?.wu) return ; + const wu = detail.wu; const meta = parseWuMeta(wu.metadata); const title = meta.title || wu.scope; @@ -243,16 +226,11 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { const totalSum = (tokens ?? []).reduce((s, t) => s + t.totalTokens, 0); const maxBar = Math.max(totalSum, 1); - /** E2-4:闸门动作写路径 = 直调 API + 响应体直替本地 wu(决策 8;状态变化另有 status_changed SSE 兜底), - * 分支/锁存/错误内联/弹窗全部在共享 WuGateActions(与列表行/详情页同一组件) */ + /** E2-4:闸门动作写路径 #545 起内建于共享 WuGateActions(gateWriter 双写落点—— + * store applyWorkunitEvent 覆盖 detail slice 就地更新(#549),drawer 无需 onUpdated sink; + * 决策 8:状态变化另有 status_changed SSE 兜底);分支/锁存/错误内联/弹窗全部组件自带 */ const gateActions = ( - { const r = await workunitApi.reviewPassed(id, summary, assigneeId, confirm); setWu(r.data); }} - onReviewRejected={async (reason) => { const r = await workunitApi.reviewRejected(id, reason); setWu(r.data); }} - onConfirmPending={async () => { const r = await workunitApi.transitionStatus(id, 'unassigned'); setWu(r.data); }} - /> + ); return ( @@ -328,10 +306,11 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: {
{gateActions}
{/* #185(决策 #87 D4):blocked 处置组件(继续执行/关闭任务),与详情页同一组件; - #467:plan-ruling 挂起时另出「去裁决」(PlanRulingDialog,autoRuling = 接力卡打开即弹)。 + #467:plan-ruling 挂起时另出「去裁决」(PlanRulingDialog,autoRuling = 接力卡打开即弹); + #567:plan-direction 挂起时另出「去选定」(PlanDirectionDialog,autoDirection = 接力卡打开即弹)。 动作成功后重拉一次详情兜底(状态变化另有 status_changed SSE 负载直更) */} - { - workunitApi.get(id).then(r => setWu(r.data)).catch(() => {}); + { + void useWorkUnitStore.getState().loadWorkUnitDetail(id); }} /> {/* WU 过程可视化:执行步事件流(思考/工具调用/skill 注入/用量),SSE 步级刷新。 @@ -410,7 +389,7 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { 封装开销 {overhead.avgOverheadRatio !== null ? `${overhead.avgOverheadRatio.toFixed(2)}x` : '—'}(直连 1.0x) - 红线 {overhead.overheadBudget}x {overhead.avgOverheadRatio !== null && overhead.avgOverheadRatio <= overhead.overheadBudget ? '✓' : '✗'} + 红线 {overhead.overheadBudget}x {overhead.avgOverheadRatio !== null && overhead.avgOverheadRatio <= overhead.overheadBudget ? : }
diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageEnv.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageEnv.test.tsx new file mode 100644 index 000000000..bdec87cd3 --- /dev/null +++ b/apps/web/src/components/channel/__tests__/ChannelMessageEnv.test.tsx @@ -0,0 +1,28 @@ +// ChannelMessageEnv(#547):Context 模块本体契约——缺 Provider → null(消息项各横切行为 fail-closed); +// 挂 Provider → value 原样下发(消息项 useChannelMessageEnv() 自取横切值)。 +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { ChannelMessageEnvProvider, useChannelMessageEnv, type ChannelMessageEnv } from '../ChannelMessageEnv'; + +describe('ChannelMessageEnv(#547 频道消息环境)', () => { + it('缺 Provider → useChannelMessageEnv 返回 null(fail-closed)', () => { + const { result } = renderHook(() => useChannelMessageEnv()); + expect(result.current).toBeNull(); + }); + + it('挂 Provider → value 原样下发(引用不变)', () => { + const env: ChannelMessageEnv = { + onAction: vi.fn(), + onReply: vi.fn(), + channelId: 'ch-1', + onQuoteClick: vi.fn(), + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useChannelMessageEnv(), { wrapper }); + expect(result.current).toBe(env); + expect(result.current?.channelId).toBe('ch-1'); + }); +}); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx index ccbb2d6f3..9e332f4fe 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx @@ -17,6 +17,8 @@ vi.mock('../../../utils/toast', () => ({ }), })); +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; +import type { ChannelMessageEnv } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const base: ChannelMessage = { @@ -37,10 +39,16 @@ const systemMsg: ChannelMessage = { content: '[CRITICAL] **[Monitor]** 队列积压', }; -const renderItem = (message: ChannelMessage, extra: Record = {}) => +const renderItem = ( + message: ChannelMessage, + extra: Record = {}, + envExtra: Partial = {}, +) => render( - + + + , ); @@ -59,16 +67,17 @@ describe('ChannelMessageItem — 复制按钮(Phase 3 / AC4)', () => { }); afterEach(() => vi.useRealTimers()); - it('点击复制 → writeText 写入消息全文;按钮变 ✓ 约 1.5s 后恢复', async () => { + it('点击复制 → writeText 写入消息全文;按钮变对勾图标约 1.5s 后恢复(批次 I-6 ✓ → IconCheck SVG)', async () => { vi.useFakeTimers(); renderItem(base); const btn = screen.getByLabelText('复制消息内容'); fireEvent.click(btn); expect(mockWriteText).toHaveBeenCalledWith('正文内容-待复制'); await act(async () => {}); // writeText promise 落地 + state 提交 - expect(btn.textContent).toBe('✓'); + expect(btn.querySelector('svg')).toBeTruthy(); await act(async () => { vi.advanceTimersByTime(1600); }); - expect(btn.textContent).not.toBe('✓'); + expect(btn.querySelector('svg')).toBeNull(); + expect(btn.textContent).toBe('⧉'); }); it('clipboard API 不可用 → toast.warning 提示,不静默', async () => { @@ -88,20 +97,20 @@ describe('ChannelMessageItem — 复制按钮(Phase 3 / AC4)', () => { }); it('系统播报消息:有复制入口,无回复/转任务钮(即使传了 onReply)', () => { - renderItem(systemMsg, { onReply: vi.fn() }); + renderItem(systemMsg, {}, { onReply: vi.fn() }); expect(screen.getByLabelText('复制消息内容')).toBeTruthy(); expect(screen.queryByLabelText('回复消息')).toBeNull(); expect(screen.queryByLabelText('转为任务')).toBeNull(); }); it('compact(连续合并)消息:复制按钮随角落动作保留', () => { - renderItem(base, { compact: true, onReply: vi.fn() }); + renderItem(base, { compact: true }, { onReply: vi.fn() }); expect(screen.getByLabelText('复制消息内容')).toBeTruthy(); expect(screen.getByLabelText('回复消息')).toBeTruthy(); }); it('pending 乐观消息不出复制按钮(同既有回复/转任务守卫)', () => { - renderItem({ ...base, pending: true } as ChannelMessage, { onReply: vi.fn() }); + renderItem({ ...base, pending: true } as ChannelMessage, {}, { onReply: vi.fn() }); expect(screen.queryByLabelText('复制消息内容')).toBeNull(); }); }); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-flags.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-flags.test.tsx new file mode 100644 index 000000000..a14c2b06d --- /dev/null +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-flags.test.tsx @@ -0,0 +1,58 @@ +// ChannelMessageItem — #547:per-message 派生 flags 的消息项级断言(自页面分册迁移/补齐)—— +// highlight(#279 顶栏待办定位)→ mc-msg-highlight;fresh(批次 E-3 SSE 新到达渐隐)→ mc-msg-new; +// focused(Phase 3 AC5 键盘导航焦点环)→ mc-msg-focused。三 flag 互不染指,缺省全不带。 +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageItem } from '../ChannelMessageItem'; + +const base: ChannelMessage = { + id: 'm-1', + channelId: 'ch-1', + authorType: 'agent', + agentName: 'dev-agent', + content: '正文内容', + replyToId: null, + meta: '{}', + createdAt: '2026-08-19T00:00:00.000Z', +}; + +const renderItem = (flags: { highlight?: boolean; fresh?: boolean; focused?: boolean } = {}) => + render( + + + , + ); + +const rootClass = (container: HTMLElement) => + (container.querySelector('[data-message-id="m-1"]') as HTMLElement).className; + +describe('ChannelMessageItem — per-message flags(#547 消息项级)', () => { + it('highlight → mc-msg-highlight(顶栏待办 chip 定位高亮)', () => { + const { container } = renderItem({ highlight: true }); + expect(rootClass(container)).toContain('mc-msg-highlight'); + expect(rootClass(container)).not.toContain('mc-msg-new'); + expect(rootClass(container)).not.toContain('mc-msg-focused'); + }); + + it('fresh → mc-msg-new(SSE 新到达渐隐高亮)', () => { + const { container } = renderItem({ fresh: true }); + expect(rootClass(container)).toContain('mc-msg-new'); + expect(rootClass(container)).not.toContain('mc-msg-highlight'); + }); + + it('focused → mc-msg-focused(j/k 键盘导航焦点环)', () => { + const { container } = renderItem({ focused: true }); + expect(rootClass(container)).toContain('mc-msg-focused'); + expect(rootClass(container)).not.toContain('mc-msg-highlight'); + }); + + it('缺省三 flag 全不带', () => { + const { container } = renderItem(); + const cls = rootClass(container); + expect(cls).not.toContain('mc-msg-highlight'); + expect(cls).not.toContain('mc-msg-new'); + expect(cls).not.toContain('mc-msg-focused'); + }); +}); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx index b6fb1fc16..39bf86096 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx @@ -5,6 +5,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const msgWithMeta = (meta: Record): ChannelMessage => ({ @@ -23,7 +24,11 @@ const renderItem = (message: ChannelMessage, onOpenRequirement?: (reqId: string) } + element={ + + + + } /> 项目页
} /> @@ -62,7 +67,13 @@ describe('ChannelMessageItem — footer REQ›/PMO›(#275 断点3)', () => } + element={ + + + + } /> 项目页} /> diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx index 2e9168631..e27e00a49 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; import { useAuthStore } from '../../../stores/authStore'; @@ -20,7 +21,9 @@ const humanMsg = (content: string): ChannelMessage => ({ const renderItem = (message: ChannelMessage) => render( - + + + , ); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx index b5424ecdb..0e4dabc31 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const message: ChannelMessage = { @@ -20,12 +21,12 @@ function setup() { const onInlineReply = vi.fn(); render( - + + + , ); const input = screen.getByLabelText('回复 wu-1'); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx index d776ace11..b159e651b 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx @@ -5,6 +5,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; +import type { ChannelMessageEnv } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const base: ChannelMessage = { @@ -18,10 +20,16 @@ const base: ChannelMessage = { createdAt: '2026-08-19T00:00:00.000Z', }; -const renderItem = (message: ChannelMessage, extra: Record = {}) => +const renderItem = ( + message: ChannelMessage, + extra: Record = {}, + envExtra: Partial = {}, +) => render( - + + + , ); @@ -81,7 +89,7 @@ describe('ChannelMessageItem — 头部规则(#277 D2)', () => { it('compact(连续合并)省略重复头:无头像/时间,回复动作仍可用', () => { const onReply = vi.fn(); - const { container } = renderItem(base, { compact: true, onReply }); + const { container } = renderItem(base, { compact: true }, { onReply }); const root = rootOf(container, 'm-1'); expect(root.classList.contains('mc-msg-compact')).toBe(true); expect(root.querySelector('.mc-msg-head')).toBeNull(); @@ -113,6 +121,7 @@ describe('ChannelMessageItem — 系统播报形态(#277 D3;#437 起左对 it('NEED_INPUT 等待回复的 Studio 消息不判系统(保留 agent 头与回复框)', () => { const { container } = renderItem(studioMsg({ content: '选哪个工程?' }), { waitingForInput: true, + }, { onInlineReply: vi.fn(), }); const root = rootOf(container, 'm-1'); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx index b204a83bf..e211d1e88 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx @@ -4,6 +4,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelFileVocabulary, ChannelMessage } from '../../../api/channel'; +import { useChannelDataStore } from '../../../stores/channelDataStore'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const mockWriteText = vi.hoisted(() => vi.fn()); @@ -17,12 +19,17 @@ const agentMsg = (content: string): ChannelMessage => ({ createdAt: '2026-08-19T00:00:00.000Z', }); -const renderItem = (message: ChannelMessage, fileVocabulary?: ChannelFileVocabulary) => - render( +const renderItem = (message: ChannelMessage, fileVocabulary?: ChannelFileVocabulary) => { + // #547:fileVocabulary 改经 channelDataStore 自取——测试按 env channelId 预置词表(无词表置空防串例) + useChannelDataStore.setState({ vocabulary: fileVocabulary ? { 'ch-1': fileVocabulary } : {} }); + return render( - + + + , ); +}; describe('ChannelMessageItem — agent 消息 Markdown 渲染(#271)', () => { beforeEach(() => { @@ -73,6 +80,21 @@ describe('ChannelMessageItem — agent 消息 Markdown 渲染(#271)', () => renderItem(agentMsg('改了 `src/index.ts`'), vocab); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); }); + + it('#547:fileVocabulary 不经 props——消息项按 env channelId 自 useChannelDataStore 取词表染 chip', () => { + const vocab: ChannelFileVocabulary = { + repos: [{ repo: '/repo/studio', files: ['src/store.ts'] }], + }; + useChannelDataStore.setState({ vocabulary: { 'ch-1': vocab } }); + render( + + + + + , + ); + expect(screen.getByRole('button', { name: 'src/store.ts' })).toBeTruthy(); + }); }); describe('ChannelMessageItem — 人类消息保持纯文本(#271)', () => { diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx index e5ae875cf..21d9ce897 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx @@ -16,6 +16,7 @@ vi.mock('../KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => null })) vi.mock('../ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); import { ChannelMessageItem } from '../ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import type { ChannelMessage } from '../../../api/channel'; const pendingMessage: ChannelMessage = { @@ -33,7 +34,9 @@ const pendingMessage: ChannelMessage = { describe('ChannelMessageItem — pending 乐观回显(#486)', () => { it('pending 消息带 mc-msg-pending 类与「发送中」标记', () => { const { container } = render( - , + + + , ); const row = container.querySelector('.mc-msg'); expect(row?.className).toContain('mc-msg-pending'); @@ -41,14 +44,20 @@ describe('ChannelMessageItem — pending 乐观回显(#486)', () => { }); it('pending 消息不出回复动作(本地 id 服务端不存在,回复无的放矢)', () => { - render(); + render( + + + , + ); expect(screen.queryByLabelText('回复消息')).toBeNull(); }); it('非 pending 消息行为不变:无 pending 类、有回复动作', () => { const normal: ChannelMessage = { ...pendingMessage, id: 'm1', pending: undefined }; const { container } = render( - , + + + , ); expect(container.querySelector('.mc-msg')?.className).not.toContain('mc-msg-pending'); expect(screen.queryByText('发送中…')).toBeNull(); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx index 5c61d9e39..503fbd744 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx @@ -16,6 +16,7 @@ vi.mock('../KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => null })) vi.mock('../ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); import { ChannelMessageItem } from '../ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import type { ChannelMessage } from '../../../api/channel'; const parentMessage: ChannelMessage = { @@ -47,12 +48,9 @@ describe('ChannelMessageItem — quote 引用块点击定位(Phase 1 / AC1)' it('提供 onQuoteClick 且父消息已加载 → quote 渲染为 button,点击回调带父消息 id', () => { const onQuoteClick = vi.fn(); const { container } = render( - , + + + , ); const quote = container.querySelector('.mc-quote'); expect(quote?.tagName).toBe('BUTTON'); @@ -63,7 +61,9 @@ describe('ChannelMessageItem — quote 引用块点击定位(Phase 1 / AC1)' it('未提供 onQuoteClick → quote 保持纯 div 展示(不可点)', () => { const { container } = render( - , + + + , ); const quote = container.querySelector('.mc-quote'); expect(quote).not.toBeNull(); @@ -73,12 +73,9 @@ describe('ChannelMessageItem — quote 引用块点击定位(Phase 1 / AC1)' it('父消息掉出已加载分页(findMessage 未命中)→ 不渲染 quote', () => { const onQuoteClick = vi.fn(); const { container } = render( - undefined} - onQuoteClick={onQuoteClick} - />, + undefined, onQuoteClick }}> + + , ); expect(container.querySelector('.mc-quote')).toBeNull(); }); diff --git a/apps/web/src/components/channel/__tests__/ChannelRail-render-count.test.tsx b/apps/web/src/components/channel/__tests__/ChannelRail-render-count.test.tsx new file mode 100644 index 000000000..3d25da618 --- /dev/null +++ b/apps/web/src/components/channel/__tests__/ChannelRail-render-count.test.tsx @@ -0,0 +1,114 @@ +// ChannelRail — F4 渲染边界 render-count 测试(频道行 memo + per-channel unread selector): +// ① 他频道 unread 变化只重渲对应行(其余行零重渲,以 formatChannelName 调用计数为探针); +// ② 父级无关重渲(展开/收起新建表单)时 props 稳定的频道行 memo 零重渲。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const { mockUseChannelList, mockNavigate, mockOnEvent, fmtCalls } = vi.hoisted(() => ({ + mockUseChannelList: vi.fn(), + mockNavigate: vi.fn(), + mockOnEvent: vi.fn(), + fmtCalls: [] as string[], +})); + +vi.mock('../../../hooks/useChannelList', () => ({ + useChannelList: () => mockUseChannelList(), +})); + +vi.mock('../../../api/websocketHooks', () => ({ + useWebSocketContext: () => ({ onEvent: mockOnEvent, status: 'disconnected' }), +})); + +vi.mock('../../../api/monitoring', () => ({ + monitoringApi: { getAgentSummary: vi.fn().mockResolvedValue({ data: { agents: [], summary: {} } }) }, +})); + +// CreateChannelForm 加载本地工程发现候选——单测置空;rosterStore 其余端点本套件不关心 +vi.mock('../../../api/channel', () => ({ + channelApi: { + discoverProjects: vi.fn().mockResolvedValue({ data: { success: true, data: [] } }), + listAllAgents: vi.fn().mockRejectedValue(new Error('not mocked here')), + list: vi.fn().mockRejectedValue(new Error('not mocked here')), + }, +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +// 探针:频道行每次渲染必调 formatChannelName(实现本体放行,行为不变) +vi.mock('@dommaker/studio-shared/web', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + formatChannelName: (name: string) => { + fmtCalls.push(name); + return mod.formatChannelName(name); + }, + }; +}); + +import { ChannelRail } from '../ChannelRail'; +import { useRosterStore } from '../../../stores/rosterStore'; +import { useUnreadStore } from '../../../stores/unreadStore'; + +const CHANNELS = [ + { id: 'ch-1', name: 'rnd-主研发', type: 'rnd' }, + { id: 'ch-2', name: 'decision-架构决策', type: 'decision' }, +]; + +const callsFor = (name: string) => fmtCalls.filter(n => n === name).length; + +describe('ChannelRail — F4 渲染边界', () => { + beforeEach(() => { + vi.clearAllMocks(); + fmtCalls.length = 0; + useRosterStore.setState({ + profiles: [], agents: [], channels: [], + loading: false, error: null, forbidden: false, + loadedAt: null, channelsLoadedOnce: true, agentsLoadedOnce: true, + inflight: null, lastToken: null, + }); + useUnreadStore.setState({ unreadCounts: {}, activeChannelId: null }); + mockUseChannelList.mockReturnValue({ + channels: CHANNELS, loading: false, + clearUnread: vi.fn(), createChannel: vi.fn(), + }); + }); + + const renderRail = () => + render( + + + , + ); + + it('他频道 unread 变化 → 只重渲对应行,其余频道行零重渲', () => { + renderRail(); + expect(callsFor('rnd-主研发')).toBe(1); + expect(callsFor('decision-架构决策')).toBe(1); + + // ch-2 来一条 agent 消息(非 active 频道)→ unread 0→1 + act(() => useUnreadStore.getState().applyMessageSent('ch-2', 'agent')); + + // 对应行更新:badge 出现 + const ch2Row = screen.getByText('decision-架构决策').closest('button')!; + expect(ch2Row.textContent).toContain('1'); + // 只重渲 ch-2 行;ch-1 行零重渲 + expect(callsFor('decision-架构决策')).toBe(2); + expect(callsFor('rnd-主研发')).toBe(1); + }); + + it('父级无关重渲(展开新建表单)→ props 稳定的频道行 memo 零重渲', () => { + renderRail(); + expect(fmtCalls).toHaveLength(2); + + fireEvent.click(screen.getByText('+ 新频道')); + expect(screen.getByLabelText('频道名称')).toBeTruthy(); // 父级确实重渲(表单出现) + + // 频道行 props 全稳定 → memo 零重渲 + expect(fmtCalls).toHaveLength(2); + }); +}); diff --git a/apps/web/src/components/channel/__tests__/ChannelRail.test.tsx b/apps/web/src/components/channel/__tests__/ChannelRail.test.tsx index 5ff2d4cb7..05daa6986 100644 --- a/apps/web/src/components/channel/__tests__/ChannelRail.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelRail.test.tsx @@ -40,6 +40,7 @@ vi.mock('react-router-dom', async () => { import { ChannelRail } from '../ChannelRail'; import { useRosterStore } from '../../../stores/rosterStore'; +import { useUnreadStore } from '../../../stores/unreadStore'; import type { ChannelListItem } from '../../../hooks/useChannelList'; const CHANNELS = [ @@ -75,10 +76,11 @@ describe('ChannelRail', () => { loadedAt: null, channelsLoadedOnce: false, agentsLoadedOnce: false, inflight: null, lastToken: null, }); + // F4:未读徽章由频道行内 per-channel selector 直读 unreadStore(模块级单例),每测重置 + useUnreadStore.setState({ unreadCounts: { 'ch-2': 3 }, activeChannelId: null }); mockUseChannelList.mockReturnValue({ channels: CHANNELS, loading: false, - unreadCounts: { 'ch-2': 3 }, clearUnread: vi.fn(), createChannel: vi.fn().mockResolvedValue({ id: 'ch-9', name: 'new', type: 'rnd' }), }); @@ -103,7 +105,7 @@ describe('ChannelRail', () => { it('navigates and clears unread on channel select', () => { const clearUnread = vi.fn(); mockUseChannelList.mockReturnValue({ - channels: CHANNELS, loading: false, unreadCounts: { 'ch-2': 3 }, + channels: CHANNELS, loading: false, clearUnread, createChannel: vi.fn(), }); renderRail(); @@ -121,7 +123,7 @@ describe('ChannelRail', () => { it('creates a new channel via the inline form and navigates to it', async () => { const createChannel = vi.fn().mockResolvedValue({ id: 'ch-9', name: 'ops', type: 'system' }); mockUseChannelList.mockReturnValue({ - channels: CHANNELS, loading: false, unreadCounts: {}, + channels: CHANNELS, loading: false, clearUnread: vi.fn(), createChannel, }); renderRail(); @@ -142,7 +144,7 @@ describe('ChannelRail', () => { () => new Promise(resolve => { resolveCreate = resolve; }) ); mockUseChannelList.mockReturnValue({ - channels: CHANNELS, loading: false, unreadCounts: {}, + channels: CHANNELS, loading: false, clearUnread: vi.fn(), createChannel, }); renderRail(); diff --git a/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx b/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx index 1b13f4ad0..3f103a41a 100644 --- a/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx @@ -4,14 +4,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -const { mockUseChannelLiveExecutions } = vi.hoisted(() => ({ +const { mockUseChannelLiveExecutions, mockReviewPassed, mockReviewRejected, mockConfirmPending } = vi.hoisted(() => ({ mockUseChannelLiveExecutions: vi.fn(), + mockReviewPassed: vi.fn(), + mockReviewRejected: vi.fn(), + mockConfirmPending: vi.fn(), })); vi.mock('../../../hooks/useChannelLiveExecutions', () => ({ useChannelLiveExecutions: (channelId: string | null) => mockUseChannelLiveExecutions(channelId), })); +// #545:写路径内建 gateWriter——本文件 mock 写模块,工作条只断「闸门态挂载 WuGateActions + 调用形状」 +vi.mock('../../../utils/gateWriter', () => ({ + createGateWriter: () => ({ + reviewPassed: mockReviewPassed, + reviewRejected: mockReviewRejected, + confirmPending: mockConfirmPending, + }), +})); + import { ChannelWorkBar } from '../ChannelWorkBar'; import type { WorkUnit } from '../../../api/workunit'; @@ -207,31 +219,23 @@ describe('ChannelWorkBar — 频道工作条', () => { expect(onOpenWorkUnit).toHaveBeenCalledWith('WU-1'); }); - // D-2 项6:currentWu 闸门态 → 工作条直挂共享 WuGateActions(写路径经 gate prop 注入) - const gateHandlers = () => ({ - onReviewPassed: vi.fn().mockResolvedValue({}), - onReviewRejected: vi.fn().mockResolvedValue({}), - onConfirmPending: vi.fn().mockResolvedValue({}), - }); - - it('D-2 项6:pending currentWu + gate → 工作条渲染「确认并开放领取」,点击调 onConfirmPending', async () => { + // D-2 项6:currentWu 闸门态 → 工作条直挂共享 WuGateActions(#545 起写路径内建 gateWriter,无需注入) + it('D-2 项6:pending currentWu → 工作条渲染「确认并开放领取」,点击调 gateWriter.confirmPending', async () => { mockUseChannelLiveExecutions.mockReturnValue([]); - const gate = gateHandlers(); - const { container } = render( {}} gate={gate} />); + const { container } = render( {}} />); const btn = screen.getByRole('button', { name: '确认并开放领取' }); expect(btn.className).toContain('btn-sm'); expect(container.querySelector('.mc-workbar-gate')).not.toBeNull(); fireEvent.click(btn); - await waitFor(() => expect(gate.onConfirmPending).toHaveBeenCalledTimes(1)); - expect(gate.onReviewPassed).not.toHaveBeenCalled(); + await waitFor(() => expect(mockConfirmPending).toHaveBeenCalledWith('WU-1')); + expect(mockReviewPassed).not.toHaveBeenCalled(); }); - it('D-2 项6:in_review currentWu(task 类型)→ 渲染「通过验收/拒绝」,通过直调 onReviewPassed', async () => { + it('D-2 项6:in_review currentWu(task 类型)→ 渲染「通过验收/拒绝」,通过直调 gateWriter.reviewPassed', async () => { mockUseChannelLiveExecutions.mockReturnValue([]); - const gate = gateHandlers(); - render( {}} gate={gate} />); + render( {}} />); fireEvent.click(screen.getByRole('button', { name: '通过验收' })); - await waitFor(() => expect(gate.onReviewPassed).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('WU-1')); expect(screen.getByRole('button', { name: '拒绝' })).toBeTruthy(); }); @@ -241,7 +245,7 @@ describe('ChannelWorkBar — 频道工作条', () => { attestations: { l2: { verdict: 'approved', by: 'rev', at: '2026-09-01T01:30:00Z', kind: 'agent-review' } }, }); const { unmount } = render( - {}} gate={gateHandlers()} />, + {}} />, ); expect(screen.getByRole('button', { name: '人工验收确认' })).toBeTruthy(); unmount(); @@ -253,20 +257,16 @@ describe('ChannelWorkBar — 频道工作条', () => { }, }); const { container } = render( - {}} gate={gateHandlers()} />, + {}} />, ); expect(container.querySelector('.mc-workbar-gate')).toBeNull(); }); - it('D-2 项6:active currentWu(非闸门态)→ 不渲染闸门区;无 gate prop → 闸门态也不渲染', () => { + it('D-2 项6:active currentWu(非闸门态)→ 不渲染闸门区', () => { mockUseChannelLiveExecutions.mockReturnValue([]); - const { container, unmount } = render( - {}} gate={gateHandlers()} />, + const { container } = render( + {}} />, ); expect(container.querySelector('.mc-workbar-gate')).toBeNull(); - unmount(); - - const { container: c2 } = render( {}} />); - expect(c2.querySelector('.mc-workbar-gate')).toBeNull(); }); }); diff --git a/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx b/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx index e7f8cb88a..965a8c1f4 100644 --- a/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx +++ b/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx @@ -26,7 +26,9 @@ vi.mock('../../../api', () => ({ import type { ChannelFileVocabulary, ChannelMessage } from '../../../api/channel'; import { FileRefChip } from '../FileRefChip'; import { ChannelMessageItem } from '../ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { usePmoDataStore } from '../../../stores/pmoDataStore'; +import { useChannelDataStore } from '../../../stores/channelDataStore'; const vocab: ChannelFileVocabulary = { repos: [ @@ -104,19 +106,27 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 content: '完成 `src/index.ts` 的修改', createdAt: '2026-08-19T00:00:00.000Z', }; + // #547:fileVocabulary prop 已删——消息项按 env channelId 自 useChannelDataStore 取词表; + // 每测渲染前显式 setState(无词表置 {})防同文件串状态 it('agent 消息 + 词表 → 正文命中 token 染 chip', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); render( - + + + , ); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); }); it('人类消息 + 词表 → 正文不动,无 chip', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); const { container } = render( - + + + , ); expect(container.querySelector('.mc-file-chip')).toBeNull(); @@ -124,9 +134,12 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 }); it('agent 消息不传词表 → inline-code 走默认 chip 样式,无文件 chip', () => { + useChannelDataStore.setState({ vocabulary: {} }); const { container } = render( - + + + , ); expect(container.querySelector('.mc-file-chip')).toBeNull(); @@ -135,14 +148,15 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 }); it('AC4:WU 文件集优先——token 不在候选集词表但在 wuChangedFiles → 仍染 chip(绝对路径 tooltip)', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); render( - + + + , ); const chip = screen.getByRole('button', { name: 'docs/wu-only.md' }); @@ -150,27 +164,30 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 }); it('AC4:不传候选集词表但 wuChangedFiles 有命中 → 染 chip(WU 层不依赖词表)', () => { + useChannelDataStore.setState({ vocabulary: {} }); render( - + + + , ); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); }); it('AC4:wuChangedFiles 为空数组 → 回退候选集词表行为', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); render( - + + + , ); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); diff --git a/apps/web/src/components/channel/__tests__/PlanDirectionCard.test.tsx b/apps/web/src/components/channel/__tests__/PlanDirectionCard.test.tsx new file mode 100644 index 000000000..37ff65f4f --- /dev/null +++ b/apps/web/src/components/channel/__tests__/PlanDirectionCard.test.tsx @@ -0,0 +1,47 @@ +// PlanDirectionCard — #567:方向锁定接力卡(plan 会话内方向人闸,裁决轮前置环节) +// 契约:cardType 'plan_direction';「去选定」→ onOpenDirection(workUnitId)(开 WU 抽屉并自动弹 PlanDirectionDialog) +// 卡面照 PlanRulingCard(#467)先例 = 静态文案 + message.content;候选明细在抽屉弹窗经 WU metadata 取 +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PlanDirectionCard } from '../PlanDirectionCard'; +import type { ChannelMessage } from '../../../api/channel'; + +const baseMessage: ChannelMessage = { + id: 'msg-pd-1', + channelId: 'ch-1', + workUnitId: 'wu-plan-1', + authorType: 'agent', + agentName: 'Planner', + content: '需要输入: 方向锁定——请选定本票方向', + replyToId: null, + meta: JSON.stringify({ cardType: 'plan_direction' }), + createdAt: new Date().toISOString(), +}; + +describe('PlanDirectionCard(#567 方向锁定接力卡)', () => { + it('卡面 = 「方向待锁定」+ 引导文案 + 「去选定」按钮', () => { + render(); + expect(screen.getByText('方向待锁定')).toBeTruthy(); + expect(screen.getByText(/请选定本票方向/)).toBeTruthy(); + expect(screen.getByText('去选定')).toBeTruthy(); + }); + + it('点「去选定」→ onOpenDirection(workUnitId)', () => { + const onOpenDirection = vi.fn(); + render(); + fireEvent.click(screen.getByText('去选定')); + expect(onOpenDirection).toHaveBeenCalledWith('wu-plan-1'); + }); + + it('缺 workUnitId(异常数据)→ 不渲染「去选定」按钮', () => { + render( + , + ); + expect(screen.getByText('方向待锁定')).toBeTruthy(); + expect(screen.queryByText('去选定')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx b/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx index 8dc57c11d..939b7092f 100644 --- a/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx +++ b/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx @@ -44,6 +44,14 @@ vi.mock('../../../api/requirements', () => ({ requirementApi: { getChain: mockGetChain }, })); +// #545:gateWriter 的 channelWorkStore 双写落点在模块级测试单点覆盖;本文件 stub 掉该 store, +// 防真实 markSuggestionsDirty 防抖定时器跨测悬挂(API 透传仍走真实 gateWriter)。 +// #549:workunitStore 用真实实现——drawer 已是 detail slice 订阅者,SSE 事件经挂载的 +// useWorkUnitStoreSync(真实 hook)路由进 store,契约即集成断言 +vi.mock('../../../stores/channelWorkStore', () => ({ + useChannelWorkStore: { getState: () => ({ applyWorkunitSnapshot: vi.fn(), markSuggestionsDirty: vi.fn() }) }, +})); + vi.mock('../../../api/monitoring', () => ({ monitoringApi: { getOverhead: mockGetOverhead, @@ -91,6 +99,8 @@ import { WorkUnitDrawer } from '../WorkUnitDrawer'; import type { DrawerState, DrawerTodoNav } from '../WorkUnitDrawer'; import { useRosterStore } from '../../../stores/rosterStore'; import { useRequirementChainStore } from '../../../stores/requirementChainStore'; +import { useWorkUnitStore } from '../../../stores/workunitStore'; +import { useWorkUnitStoreSync } from '../../../hooks/useWorkUnitStoreSync'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; // 决策 8:SSE 事件捕获(mockOnEvent 注册的回调,用例手工驱动)。 @@ -153,9 +163,17 @@ const CHAIN = { ], }; +// #549:真实 sync hook 挂载(App 级形状镜像)——SSE status_changed/created/removed 路由进 +// workunitStore,drawer 测试保持「发事件 → 断言 UI」的集成口径 +function WorkUnitSyncBridge() { + useWorkUnitStoreSync(); + return null; +} + const renderDrawer = (drawer: DrawerState, extra: { onClose?: () => void; onOpenWu?: (id: string) => void; onOpenReq?: (id: string) => void; todoNav?: DrawerTodoNav } = {}) => render( + { loadedAt: null, channelsLoadedOnce: false, agentsLoadedOnce: false, inflight: null, lastToken: null, }); + // #549:drawer 读真实 workunitStore detail slice(模块级单例),每测重置防快照跨测串味 + useWorkUnitStore.getState().__resetForTests(); + useWorkUnitStore.setState({ + workunits: [], total: 0, page: 1, limit: 20, + statusFilter: null, typeFilter: null, + unattributedOnly: false, unattributedTotal: null, allTotal: null, + searchQuery: null, loading: false, error: null, + }); sseHandlers = []; mockOnEvent.mockImplementation((h: (msg: { event_type: string; data?: unknown }) => void) => { sseHandlers.push(h); @@ -475,7 +501,7 @@ describe('WorkUnitDrawer', () => { }); renderDrawer({ kind: 'wu', id: 'WU-1017' }); await waitFor(() => expect(screen.getByText(/评审结论:实现正确/)).toBeTruthy()); - expect(screen.getByText(/✓ agent-review · 76d96d3/)).toBeTruthy(); + expect(screen.getByText(/agent-review · 76d96d3/)).toBeTruthy(); const btn = screen.getByText('人工验收确认'); fireEvent.click(btn); await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('WU-1017', undefined, undefined, undefined)); diff --git a/apps/web/src/components/knowledge/MarkdownBody.tsx b/apps/web/src/components/knowledge/MarkdownBody.tsx index 5871eb473..8b519ef3d 100644 --- a/apps/web/src/components/knowledge/MarkdownBody.tsx +++ b/apps/web/src/components/knowledge/MarkdownBody.tsx @@ -39,7 +39,9 @@ const LINK_CLASS = 'u-accent underline'; const COPIED_FEEDBACK_MS = 1500; const PRE_CLASS = 'p-3 rounded overflow-x-auto text-xs font-mono my-2'; -const PRE_STYLE = { background: 'var(--bg-secondary)', border: '1px solid var(--border-subtle)' } as const; +/* 2026-09 视觉层次批次:bg-secondary→bg-tertiary + border-subtle→border-default—— + 旧档贴近页底(#0a0a0e vs #050507),日志/命令块与正文糊成一片;升一档与正文明确分层 */ +const PRE_STYLE = { background: 'var(--bg-tertiary)', border: '1px solid var(--border-default)' } as const; /** 带「复制」按钮的围栏代码块容器(codeCopy 开启时替换 pre 渲染器) */ function CopyablePre({ children }: { children?: ReactNode }) { @@ -65,18 +67,8 @@ function CopyablePre({ children }: { children?: ReactNode }) { diff --git a/apps/web/src/components/monitoring/NeedsAttentionSection.tsx b/apps/web/src/components/monitoring/NeedsAttentionSection.tsx index 248c488af..afd671b7f 100644 --- a/apps/web/src/components/monitoring/NeedsAttentionSection.tsx +++ b/apps/web/src/components/monitoring/NeedsAttentionSection.tsx @@ -109,7 +109,7 @@ export function NeedsAttentionSection({ onAlertClick }: { onAlertClick?: (group: )} diff --git a/apps/web/src/components/monitoring/RoleCard.tsx b/apps/web/src/components/monitoring/RoleCard.tsx index d9e15f7e7..6dc10bcc6 100644 --- a/apps/web/src/components/monitoring/RoleCard.tsx +++ b/apps/web/src/components/monitoring/RoleCard.tsx @@ -3,7 +3,7 @@ // ② 视觉锚点:当前 WU 标题(批次 D-2 项7 起开就地抽屉 onOpenWu,不再整页跳;「↗ 详情页」深链在抽屉内) // + 类型 chip + 已耗时,次行 PMO · #频道(仍 ↗ 跳页,与右栏先例一致); // ③ 最近动态 3 条迷你列表,每条可点(有当前 WU → 开该 WU 抽屉,无 → 角色详情 ↗); -// ④ 错误行(⚠ lastError,与卡片状态同色)。 +// ④ 错误行(IconAlertTriangle + lastError,与卡片状态同色;批次 I-6 ⚠ → SVG)。 // 状态色经 data-status(4 态展示键)+ --st 驱动(§6.5 单义延续):待处理整卡上色、空闲/离线压扁由 CSS 承担; // 内部 7+1 细分态保留——细分=error 时角色名前加红点角标(.agd-dot-err)。 // 渲染边界(#348 契约不变):动态订阅卡片自持(useRosterActivities 按 roleId 切片)——stream chunk @@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'; import { formatChannelName } from '@dommaker/studio-shared/web'; import { useRosterActivities } from '../../stores/rosterActivityStore'; import { AgentAvatar } from '../channel/AgentAvatar'; +import { IconAlertTriangle } from '../ui/icons'; import type { RosterRole } from '../../hooks/useAgentRoster'; import type { WorkUnit } from '../../api/workunit'; import { @@ -100,7 +101,7 @@ export const RoleCard = memo(function RoleCard({ role, lastDone, channelNames, o {runtime?.status === 'idle' && lastDone && ( <> {' · 最近完成 '} - + )} @@ -124,7 +125,7 @@ export const RoleCard = memo(function RoleCard({ role, lastDone, channelNames, o )} {/* ④ 错误行(与卡片同色) */} - {lastError &&
⚠ {lastError}
} + {lastError &&
{lastError}
} ); }); diff --git a/apps/web/src/components/monitoring/__tests__/RoleCard.test.tsx b/apps/web/src/components/monitoring/__tests__/RoleCard.test.tsx index 07e50c37f..5d6228c03 100644 --- a/apps/web/src/components/monitoring/__tests__/RoleCard.test.tsx +++ b/apps/web/src/components/monitoring/__tests__/RoleCard.test.tsx @@ -71,7 +71,7 @@ describe('RoleCard(信息全卡)', () => { expect(mockOpenWu).toHaveBeenCalledWith('wu-1'); expect(within(card).getByText('DEV')).toBeDefined(); expect(within(card).getByText(/已耗时/)).toBeDefined(); - expect(within(card).queryByText(/^⚠/)).toBeNull(); + expect(card.querySelector('.agd-error')).toBeNull(); // 批次 I-6:⚠ → IconAlertTriangle SVG,改按错误行类断言 }); it('空闲空态:等待派活 + 最近完成入口(D-2 项7:点击开抽屉不整页跳)', () => { @@ -91,14 +91,15 @@ describe('RoleCard(信息全卡)', () => { expect(mockOpenWu).toHaveBeenCalledWith('wu-9'); }); - it('异常空态:实例异常文案 + 红点角标 + 错误行(⚠ lastError;data-status 挂 4 态 attention)', () => { + it('异常空态:实例异常文案 + 红点角标 + 错误行(⚠ → IconAlertTriangle SVG + lastError,批次 I-6;data-status 挂 4 态 attention)', () => { const role = idleRole('p1', 'ops-agent'); role.runtime!.status = 'error'; role.runtime!.lastError = 'spawn ENOENT'; const { container } = render(); expect(screen.getByText(/实例异常/)).toBeDefined(); - const err = screen.getByText(/⚠ spawn ENOENT/); + const err = screen.getByText(/spawn ENOENT/); expect(err.className).toContain('agd-error'); + expect(err.querySelector('svg')).toBeTruthy(); const card = container.querySelector('[data-testid="agent-card"]')!; expect(card.getAttribute('data-status')).toBe('attention'); // 细分=error → 角色名前红点角标 diff --git a/apps/web/src/components/pmo/AnalysisApproveDialog.tsx b/apps/web/src/components/pmo/AnalysisApproveDialog.tsx index 18a56b3b8..bbcb3522b 100644 --- a/apps/web/src/components/pmo/AnalysisApproveDialog.tsx +++ b/apps/web/src/components/pmo/AnalysisApproveDialog.tsx @@ -11,11 +11,11 @@ // #177 保留:带 channelId 时可选「默认执行角色」下拉(候选=频道成员,留空=涌现)。 // 批次A 项7 保留:onConfirm/onReject 可返回 Promise——提交期间禁用 + 失败内联保持打开。 // 入口:WuGateActions(列表行/抽屉/详情页三处合一,E2-4)/ DeliveryPanel 缺口「人工确认」。 +// 公共骨架(submitting/取消键/关窗屏蔽/错误行)走 ui/ApproveDialogShell(Step 3 收敛)。 import { useEffect, useState } from 'react'; import { channelApi, type AgentProfile } from '../../api/channel'; -import { Button, Select } from '../ui'; +import { ApproveDialogShell, Button, Select } from '../ui'; import { resolveChannelResponders } from './channelResponders'; -import { errorMessage } from '../../utils/errorMessage'; import type { ReviewConfirmPayload } from '../../api/workunit'; import type { AnalysisConfirmPrefill } from './mapUtils'; @@ -49,9 +49,6 @@ export function AnalysisApproveDialog({ prefill, channelId, confirmKind = 'analy // #177:默认执行角色候选(频道成员);'' = 留空涌现 const [assigneeId, setAssigneeId] = useState(''); const [candidates, setCandidates] = useState([]); - // 批次A 项7:提交反馈(loading + 失败内联错误) - const [submitting, setSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(''); useEffect(() => { if (!channelId) return; @@ -66,119 +63,16 @@ export function AnalysisApproveDialog({ prefill, channelId, confirmKind = 'analy return () => { cancelled = true; }; }, [channelId]); - const run = async (fn: () => void | Promise) => { - if (submitting) return; - setSubmitting(true); - setSubmitError(''); - try { - await fn(); - } catch (e) { - setSubmitError(errorMessage(e)); - } finally { - setSubmitting(false); - } - }; - const cleanFog = fog.map(s => s.trim()).filter(Boolean); const includedTasks = tasks.filter(t => t.included && t.text.trim()).map(t => t.text.trim()); return ( -
-
e.stopPropagation()}> -
-

{confirmKind === 'plan' ? '确认规划结论' : '确认分析结论'}

- -
-
-

- 分析结论已拆解为待决问题(左栏,可增删改)与派工任务(右栏,可逐条修改、勾选剔除)。 - 点「确认开图」后系统据此生成探路地图:待决问题在规划会话里逐条裁决,不再单独立决策单;清空待决问题 = 不需要探路,只按任务清单派工。结论有问题请点「打回补充」。 -

-
- {/* 左栏:FOG 待决清单 */} -
- setDestination(e.target.value)} - aria-label="目标" - /> - {fog.map((q, i) => ( -
- setFog(prev => prev.map((x, k) => (k === i ? e.target.value : x)))} - aria-label={`待决问题 ${i + 1}`} - /> - -
- ))} - -
- {/* 右栏:TASK 拆分预览(勾选剔除 + 行内编辑) */} -
-

派工预览({includedTasks.length} 条将派工)

- {tasks.length === 0 && ( -

agent 未输出 TASK 拆分——确认后不自动派工,可手动转任务

- )} - {tasks.map((t, i) => ( -
- setTasks(prev => prev.map((x, k) => (k === i ? { ...x, included: e.target.checked } : x)))} - aria-label={`纳入派工 ${i + 1}`} - /> - setTasks(prev => prev.map((x, k) => (k === i ? { ...x, text: e.target.value } : x)))} - aria-label={`派工任务 ${i + 1}`} - /> -
- ))} -
-
- {channelId && ( -
-

- 默认执行角色(可选,不选则由频道成员自动认领;选中则应用于确认后拆出的全部任务) -

- setDestination(e.target.value)} + aria-label="目标" + /> + {fog.map((q, i) => ( +
+ setFog(prev => prev.map((x, k) => (k === i ? e.target.value : x)))} + aria-label={`待决问题 ${i + 1}`} + /> + +
+ ))} + +
+ {/* 右栏:TASK 拆分预览(勾选剔除 + 行内编辑) */} +
+

派工预览({includedTasks.length} 条将派工)

+ {tasks.length === 0 && ( +

agent 未输出 TASK 拆分——确认后不自动派工,可手动转任务

+ )} + {tasks.map((t, i) => ( +
+ setTasks(prev => prev.map((x, k) => (k === i ? { ...x, included: e.target.checked } : x)))} + aria-label={`纳入派工 ${i + 1}`} + /> + setTasks(prev => prev.map((x, k) => (k === i ? { ...x, text: e.target.value } : x)))} + aria-label={`派工任务 ${i + 1}`} + /> +
+ ))}
-
+ {channelId && ( +
+

+ 默认执行角色(可选,不选则由频道成员自动认领;选中则应用于确认后拆出的全部任务) +

+