Skip to content

/dashboard 飞书卡片支持控制面板#263

Open
zhengkaidi123 wants to merge 73 commits into
deepcoldy:masterfrom
zhengkaidi123:feat/dashboard-card-models-pr1
Open

/dashboard 飞书卡片支持控制面板#263
zhengkaidi123 wants to merge 73 commits into
deepcoldy:masterfrom
zhengkaidi123:feat/dashboard-card-models-pr1

Conversation

@zhengkaidi123

Copy link
Copy Markdown

概述

为botmux增加/dashboard飞书交互卡片控制面板,把dashboard的web中会话/定时/工作流/群组/总览/设置 搬到飞书卡片中

主要内容

  • /dashboard命令入口 + 子模块 (overview/sessions/schedules/workflows/groups/settings)
  • 权限对齐/botconfig
  • i18n(en/zh 完整配对) + 单测

kaidizheng and others added 30 commits June 9, 2026 00:09
dashboard 卡片化第一阶段:抽出 6 个模块(settings / sessions / schedules / groups / workflows / overview)的纯函数 model + DTO 类型 + shared types,零 IO 副作用,为 PR2 的 `/dashboard` 命令入口和内部 `/__daemon/*` HMAC 基建做地基。

新增文件(13 个 / ~2400 行):
- src/dashboard/card-model-types.ts — shared types: PaginationParams/Meta、StatusDot、ButtonState、NowContext、SectionLimit
- src/dashboard/settings-card-model.ts — composeSections + helper 三个(autoUpdate/autoRestart/time fallback)
- src/dashboard/session-card-model.ts — composeEntries/filter*/sortByStatus/paginate/composeDetail,含 5 个按钮可用性矩阵
- src/dashboard/schedule-card-model.ts — filter/paginate/projection + computeNextNRuns(croner,Asia/Shanghai 固定 nowMs)
- src/dashboard/groups-card-model.ts — 矩阵过滤/分页/buildGroupRow/buildGroupDetail/三段解散匹配
- src/dashboard/workflow-card-model.ts — RunStatus 映射/elapsed format/action availability(cancel/approve/reject,v1 无 attempt resume)
- src/dashboard/aggregator-snapshot.ts — 6 段 overview snapshot(hero/aiTeam/attention/active/moment/upcoming),对齐 web/overview.ts:41 BUSY_STATUSES + 升序 attention
- test/*.test.ts × 6 — 73 个 vitest 用例(8 条/模块 plan 矩阵 + 跨模块不变式:immutability/JSON 往返/zero-IO/nowMs 确定性)

约束(严格保持):
- 零 runtime IO(fs/http/child_process/process.env/Date.now() 完全缺席)
- 不动 src/im/lark/* / card-handler/card-builder / dashboard-ipc-server / scheduler.ts / 既有命令语义
- 不注册 /dashboard 命令、不新增 /__daemon/* endpoint(留给 PR2)
- 所有外部依赖均为 `import type`,唯一例外是 schedules 的 `import { Cron } from 'croner'`(package.json 已有依赖)

测试:
- pnpm vitest run test/{aggregator-snapshot,session-card-model,workflow-card-model,groups-card-model,schedule-card-model,settings-card-model}.test.ts → 6 files / 73 tests passed
- pnpm tsc --noEmit → 通过
- 6 个新文件 grep fs/node:http/child_process/process.env/Date.now( 零命中

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
落地 /__daemon/* 内部 API 的鉴权核心:
- signDaemonRequest(input): 全字段签名(ts/nonce/method/pathWithQuery/sha256(body)),返回 wire base64url + raw Buffer
- verifyDaemonRequest(req, secret, store): body stream 单次读 + 1 MiB 上限 + loopback + ts ±60s + nonce TTL 防重放 + checkSig timing-safe
- createNonceStore + lazy GC + ClockLike 注入(便于单测 fake clock)
- readBodyRaw / checkSig / isLoopback 辅助函数

设计点:
- 与现有 auth.ts:verifyHmac (/__cli/rotate) 复用同一 .dashboard-secret + 同样 wire base64url / server raw digest timingSafeEqual 模式,但签名材料不同(5 字段 vs ts:nonce),故意不兼容防换 path 重放
- body 单次读契约 (B1):verifyDaemonRequest 内部消费 stream,返回 bodyRaw 供 dispatch 使用,不允许二次读 req
- 1 MiB body cap → 413 body_too_large
- 拒绝码完整列表:missing_header(400) / remote_not_loopback(403) / ts_malformed(401) / ts_window(401) / replay(401) / sig_mismatch(401) / body_too_large(413)

测试(29 用例 / vitest):
- sign 黄金值 + 5 字段篡改灵敏(body/method/nonce/ts/query 顺序)
- checkSig 长度不等不抛 + 恶意 base64url 不抛
- isLoopback 4 形式
- nonce store 加+查+TTL 过期 + lazy GC
- verify happy path + 10 个拒绝场景
- single-read 契约:verify 后再读 req 返回空字符串
- body_too_large 413
- nonce TTL 过后可复用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
B1: 修复 nonce replay 并发窗口
- verifyDaemonRequest 内 `has → sign/checkSig → add` 之间不再有 await
- 把 `nonceStore.has(nonce)` 检查移到 `readBodyRaw` 之后
- 微任务调度保证并发同 nonce 请求只有一个能通过 has() + add()
- 新增测试:两个相同 nonce 相同签名 Promise.all → 一个 ok 一个 replay

B2: ts 解析过宽 → 改用 Number + isInteger
- `Number.parseInt('123abc', 10)` 会返回 123,被误判合法
- 改为 `Number(ts)` + `Number.isFinite` + `Number.isInteger`
- 拒绝 '3000000abc' / '3000000.5' / '' / 'NaN' 等非纯整数串
- 新增 3 测试:trailing garbage / non-integer / empty(走 missing_header 分支)

B3: headerStr 拒绝数组(重复 header)
- 原实现遇数组 header 取第一个,攻击者可注入重复 sig
- 改为 `typeof v === 'string' ? v : undefined`,数组返回 undefined → missing_header
- 新增测试:duplicate x-botmux-daemon-sig 数组 → missing_header

非阻塞: sign 黄金值加固定 expected wire
- 之前只断言"两次输出相同",无法防签名材料顺序漂移
- 加入 frozen expected 'NbtzoO3kRO4e1aUWs8PXoNC2s95dPgSC9_DZBJhyzdc'

测试:29 → 34 用例,全部通过
- pnpm vitest run test/daemon-internal-auth.test.ts → 34 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
….ts 接入

抽 dashboard.ts:460-498 PUT /api/settings 完整写入逻辑到独立 helper:
- src/dashboard/settings-write-applier.ts (~160 行)
  - applySettingsWrite(body, deps) → ApplySettingsWriteResult
  - SettingsWriteApplierDeps 接口(全 IO 通过 deps 注入)
  - defaultSettingsWriteApplierDeps(resolveDashboardSettings) 工厂
- src/dashboard.ts
  - PUT /api/settings 改为 applySettingsWrite(parsed, deps) 调用
  - 行为完全等价:错码、merge 顺序、empty_patch 兜底全保留
  - 抽出 settingsWriteApplierDeps 模块级常量供后续 /__daemon/settings-write 复用
  - 删除不再使用的 mergeDashboardConfig/mergeMaintenanceConfig/parseMaintenancePatch/DashboardGlobalConfig import

错码 1:1 等价:
- invalid_publicReadOnly(非 boolean)
- invalid_openTerminalInFeishu(非 boolean)
- local_dev_no_autoupdate(autoUpdate.enabled=true + isLocalDevInstall=true)
- autoupdate_required(autoRestart.enabled=true 但 autoUpdate 未开)
- empty_patch(既无 dashboard 字段也无 maintenance)
- parseMaintenancePatch 错码透传(invalid_task/invalid_enabled/invalid_time 等)

测试(17 用例 / vitest):
- happy: publicReadOnly / openTerminalInFeishu / 两字段同 patch / maintenance autoUpdate
- 5 个错码: invalid_publicReadOnly / invalid_openTerminalInFeishu / local_dev_no_autoupdate / autoupdate_required(autoUpdate 未开)/ empty_patch / parseMaintenancePatch 错码透传
- autoRestart 跨场景:与 autoUpdate 同 patch 启用 OK / stored 已开启 OK
- IO 表面:mock deps 验证只在通过路径写入;非 object 输入兜底 empty_patch;mergeDashboard/Maintenance 调用次数;零 fs/path 依赖

复跑:
- pnpm vitest run test/settings-write-applier.test.ts → 17 passed
- pnpm vitest run test/{settings-write-applier,daemon-internal-auth}.test.ts → 2 files / 51 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
抽 dashboard.ts:678-832 的 4 个 groups action 到独立 helper:
- src/dashboard/groups-action-helpers.ts (~200 行)
  - addBotsToGroup(chatId, bodyRaw, deps): 找 inChat daemon → forward
  - disbandGroup(chatId, parsedBody, deps): proxy + cascade close 全 chat sessions
  - leaveGroup(chatId, parsedBody, deps): per-bot membership probe + leave + per-bot cascade close
  - bindOncall(chatId, appId, bodyRaw, deps): 外部 /api/groups/:chatId/oncall/:appId PUT → 内部 /api/oncall/:chatId PUT
  - unbindOncall(chatId, appId, deps): 外部 DELETE → 内部 /api/oncall/:chatId DELETE
  - 统一 GroupsActionDeps { registryList / registryGetByAppId / proxyToDaemon / closeSessionsMatching / fetch? }
  - 返回统一 HandlerResult { status, body, headers? }
- src/dashboard.ts
  - 4 个路由改为 helper 调用 + writeHandlerResult 渲染(净减 ~110 行)
  - 抽 groupsActionDeps 模块级常量供后续 /__daemon/groups/* 复用
  - 抽 writeHandlerResult 写响应辅助

response shape 100% 等价(codex C3 验收清单):
- add-bots 透传 upstream
- disband 返回 { ...upstreamJson, closedSessions }
- leave 返回 { result: PerBotResult[] }
- oncall PUT/DELETE 透传 upstream

错码 1:1 等价:
- bad_json / larkAppId_required / larkAppIds_required / no_proxy_bot
- per-bot: daemon_offline / not_in_chat / membership_check_failed: <msg> / http_<status>

测试(13 用例 / vitest,覆盖 codex 要求):
- addBotsToGroup: happy / no_proxy_bot / bad_json / skip non-ok membership
- disbandGroup: happy + cascade close / larkAppId_required / upstream fail 不 cascade
- leaveGroup: 3-bot 混合 (happy + daemon_offline + not_in_chat) + per-bot 仅成功 bot cascade close / larkAppIds_required 多形式 / membership_check_failed
- bindOncall: 内部 path /api/oncall/:chatId 正确 + workingDir body 转发 + 空 body 兜底 {}
- unbindOncall: 内部 DELETE path 正确

复跑:
- pnpm vitest run test/{groups-action-helpers,settings-write-applier,daemon-internal-auth}.test.ts → 3 files / 64 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
旧 dashboard.ts:789-800 仅在 upstream-proxy 之后的分支返回 closedSessions:
- daemon_offline / not_in_chat / membership_check_failed 这 3 个 pre-proxy 失败分支
  原本 {larkAppId, ok:false, error} —— 没有 closedSessions
- proxy 之后无论成功失败都带 closedSessions

C3 初版给所有失败分支都加了 closedSessions:[],造成 API shape drift。

修正:
- src/dashboard/groups-action-helpers.ts leaveGroup
  - 3 个 pre-proxy 失败分支不再加 closedSessions:[]
  - 注释明确:pre-proxy vs post-proxy 两套 shape
- test/groups-action-helpers.test.ts
  - 主测试改用 toEqual 精确 shape 断言(防止再漂移)
  - membership_check_failed 测试断言无 closedSessions 字段
  - 新增测试:upstream proxy 失败分支带 closedSessions:[]

测试 13 → 14,全绿。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…s 接入

抽 workflow-api.ts:184-465 的 list/snapshot/approve/reject/cancel 到独立 helper:
- src/dashboard/workflows-action-helpers.ts (~210 行)
  - listWorkflowRuns(query, deps): GET /api/workflows/runs
  - getRunSnapshot(runId, authed, deps): GET /api/workflows/runs/:id/snapshot
    - 保留 unauth scrub: authed ? snap : deps.scrubSnapshotForUnauthed(snap)
  - runApproveReject(runId, action, bodyRaw, deps): POST .../(approve|reject)
    - 400 bad_run_id / 400 bad_json / 404 unknown_run / 200 alreadyTerminal / 409 needs_lark_or_cli / proxy
  - runCancel(runId, bodyRaw, deps): POST .../cancel
    - 400 bad_run_id / 400 bad_json / 404 unknown_run / 200 alreadyTerminal / 409 needs_cli_cancel / proxy
  - WorkflowsActionDeps<TSnap extends RunSnapshotLike = RunSnapshotLike> 泛型,让调用方可用真实 RunSnapshotDTO
- src/dashboard/workflow-api.ts
  - 4 个路由改为 helper 调用 + writeHandlerResult
  - 抽 wfActionDeps + readBodyString 辅助
  - attempt resume 路由保持原样(plan v1.3 §3 冻结,不进 v1)

response shape 100% 等价(codex C4 验收清单):
- snapshot unauth scrub: scrubSnapshotForUnauthed 仅在 authed=false 调用
- approve/reject terminal: 200 alreadyTerminal + {resolution: approved|rejected, activityId:'', attemptId:'', resolvedAt, lastSeq, alreadyTerminal:true}
- cancel terminal: 200 alreadyTerminal + {ok:true, runId, status, alreadyTerminal:true, lastSeq}
- approve/reject 无 chatBinding: 409 needs_lark_or_cli + hint
- cancel 无 chatBinding: 409 needs_cli_cancel + hint with runId
- bad_run_id / bad_json / unknown_run / proxy upstream 透传 全部对齐

测试(21 用例 / vitest):
- listWorkflowRuns × 3: happy / status filter / listRuns 抛错 500
- getRunSnapshot × 3: authed full / unauth scrub / 404 unknown_run
- runApproveReject × 8: proxy happy / bad_run_id / bad_json / 404 / alreadyTerminal approve+reject / needs_lark_or_cli / 空 body 兜底
- runCancel × 7: proxy happy / 自定 reason / bad_run_id / bad_json / 404 / alreadyTerminal / needs_cli_cancel

复跑:
- pnpm vitest run test/{workflows,groups,settings-write-applier,daemon-internal-auth}-action-*.test.ts → 4 files / 86 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ner gate)

src/dashboard/settings-owner-resolver.ts (~55 行)
- isAuthorizedForGlobalSettings({senderUnionId}, deps?) → Promise<boolean>
- 严格 union_id 校验:仅 on_ 前缀通过;空串/null/undefined/非 on_ 前缀 → 直接 false(不调 resolver)
- 调 resolveOwnerCandidatesFromAllowedUsers() 获取 union_id 全局集合
- fail-closed:resolver 抛错 / 空集合 / 无匹配 → false
- 不回退 open_id(plan v1.3 §6.1 + §7 安全红线)
- deps.resolveOwnerCandidates 注入支持单测隔离

测试(12 用例 / vitest):
- happy: 命中候选 / trim 空白 / 多候选
- 拒绝矩阵: 未命中 / 空候选 / resolver 抛错 / undefined / null / 空串 / 纯空白
- 安全短路: 非 on_ 前缀(含 ou_ 假冒)→ resolver 不被调用(防 timing leak)
- prefix 边界: 'on_'/'on'/'admin' 等
- deps 注入 seam 验证

复跑:
- pnpm vitest run test/settings-owner-resolver.test.ts → 12 passed
- pnpm vitest run test/{settings-owner-resolver,workflows-action-helpers,groups-action-helpers,settings-write-applier,daemon-internal-auth}.test.ts → 5 files / 98 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ndpoints

src/dashboard/daemon-internal-api.ts (~370 行)
- createDaemonInternalApi(deps) 工厂返回 { handle, dispatchForTest }
- handle(req, res, url): verifyDaemonRequest → JSON.parse(bodyRaw) → typed dispatch → render
  - 严守 body 单次读契约(B1):dispatch 完全使用 verify.bodyRaw,不再触碰 req stream
- dispatchForTest(method, url, bodyRaw): 测试入口,跳过 HMAC
- dispatchDaemonInternalRequest(method, url, bodyRaw, deps): 纯路由
  - 顺序匹配 typed allowlist 17 个 RouteDef
  - bodyRaw → JSON.parse, parse 失败 → 400 bad_json
  - 路径匹配但方法不符 → 405 method_not_allowed
  - 路径不匹配 → 404 unknown_endpoint(不泄露 allowlist)
- 路由表 Object.freeze(运行时不可改)

20 typed endpoints (codex 锁定,绝无 generic forward):
- READ × 5: sessions-list / settings-snapshot / groups-matrix / workflows-runs-snapshot / overview-snapshot
- READ × 1 (bonus): workflows-runs/:id/snapshot
- WRITE × 1: settings-write (HMAC + union_id owner gate)
- WRITE × 3: sessions/:id/{close,resume,locate}(proxy ownerOf → 404 unknown_session 兜底)
- WRITE × 5: groups/:id/{leave,disband,add-bots,oncall/:appId/{bind,unbind}}(走 C3 helpers)
- WRITE × 3: workflows-runs/:id/{approve,reject,cancel}(走 C4 helpers)
- WRITE × 3: schedules/:id/{run,pause,resume}(proxy scheduleOwnerOf → 404 unknown_schedule 兜底)

settings-write 双闸(codex C6 验收清单):
1. HMAC verify(C1)
2. ownerUnionId 必须 on_ 前缀 + 在候选集(C5 isAuthorizedForGlobalSettings)
3. 然后调 C2 applySettingsWrite,错误透传 400

测试(36 用例 / vitest):
- 5 READ × happy + 1 bonus snapshot
- settings-write × 5: happy / 缺 ownerUnionId / 非 on_ / 未命中 / applier 透传 400
- sessions × 4: 3 verb happy + unknown_session
- groups × 5: leave/disband/add-bots/bind/unbind happy
- workflows × 3: approve/reject/cancel happy
- schedules × 4: 3 verb happy + unknown_schedule
- routing edge × 3: unknown 404 / method mismatch 405 / bad_json 400
- HMAC integration × 5: happy / 非 /__daemon/* 不处理 / bad sig 401 / replay 401 / body 篡改 401

复跑:
- pnpm vitest run test/daemon-internal-api.test.ts → 36 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
B1: HMAC verify 和 dispatch 路径单一 source of truth = req.url
- 原 handle() 用调用方传入的 url 判断 startsWith('/__daemon/') 并交给 dispatch
- verifyDaemonRequest 用 req.url 校验 HMAC
- 攻击向量:sig 为 path X 签发,调用方传 url = path Y → dispatch 走 Y,HMAC 误验通过

修:
- handle() 内 const reqPath = req.url ?? '/'
- const requestUrl = new URL(reqPath, url.origin)
- /__daemon/ gate 和 dispatch 都用 requestUrl
- 调用方传入的 url 仅用作 origin/base

回归测试:
- 签 sessions-list,第三参数传 settings-snapshot
- 验证返回 sessions-list 内容([{sessionId:'s1'}]),不含 settings 字段

B2: endpoint 数量超出 plan v3(20 → 21)
- 原 C6 多了 GET /__daemon/workflows-runs/:id/snapshot 的 "BONUS READ"
- plan v3 §B.1/§B.2 锁定 5 read + 15 write = 20

修:
- 移除 bonus snapshot 路由
- 移除对应测试
- 移除 snapshotRun import
- 总数回到 plan 范围内:5 read + 15 write = 20

复跑:
- pnpm vitest run test/daemon-internal-api.test.ts → 36 passed(一减一增同总数)
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/dashboard/daemon-internal-client.ts (~180 行)
- createDaemonClient(opts) 工厂返回 DaemonClient { request(opts) }
- 每次 request:mint 新鲜 ts + nonce + sig;fetch URL = dashboardUrl + pathWithQuery 字节一致;4 header 完整
- timeout:AbortController + setTimeout,per-request 可覆盖
- 默认值:dashboardUrl http://127.0.0.1:7891 / retries 3 / timeout 5s / backoff 100ms*2^n cap 5s
- secret 默认从 ~/.botmux/.dashboard-secret 读,可注入

retry policy(codex C7 锁定):
- GET 默认重试 + exp backoff,每次 retry 新 nonce/ts/sig
- 非 GET(POST/PUT/DELETE)默认不 retry;retryUnsafeWrites:true 显式才放宽
- 401 永不 retry(任何 method)
- 4xx 除 408/429 外永不 retry
- 5xx / 408 / 429 / network error → 按 retryAllowed
- exhausted: 返回最后一个失败 response(5xx)/ throw 最后一个 error(network)

签名 byte-preservation:
- pathWithQuery 字节传给 signDaemonRequest 和 fetch URL
- 不做 query 排序 / 重写
- 测试验证 fetch URL = dashboardUrl + pathWithQuery 完全一致

测试(21 用例 / vitest):
- happy × 4:GET / POST 含 body / 无 body / 非 JSON 响应
- GET retry × 8:5xx N 次 + 新 nonce / 5xx exhausted / ts+sig 每次刷新 / 401 不 retry / 4xx 不 retry / 408+429 retry / network error retry / network exhausted throw
- 非 GET × 5:POST 5xx 不 retry / POST 5xx + retryUnsafeWrites retry / POST 401 + opt-in 也不 retry / POST network error 不 retry / PUT+DELETE 默认不 retry
- 签名 byte-preservation × 1:fetch URL 与签名 pathWithQuery 完全一致(含乱序 query)
- timeout / abort × 2:GET timeout retry / POST timeout 不 retry throw
- header 完整性 × 1:4 header 都设置 + appId 正确

测试全 mock fetch,不打真实 dashboard。

复跑:
- pnpm vitest run test/daemon-internal-client.test.ts → 21 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
旧实现 Math.max(1, retries ?? defaultRetries) + 1 让 retries=0 仍产生 1 次额外尝试,
破坏两个语义:
- GET 无法显式关闭重试
- POST + retryUnsafeWrites:true + retries:0 仍可能重复执行写动作

修:
- const retryCount = Math.max(0, reqOpts.retries ?? defaultRetries);
- const maxAttempts = retryAllowed ? retryCount + 1 : 1;
- retries = 额外重试次数,0 表示无 retry,负数 clamp 到 0

注释说明 retries 语义。

回归测试(+4 用例):
- GET 503 + retries:0 → 1 call
- POST 503 + retryUnsafeWrites:true + retries:0 → 1 call
- per-request retries:0 覆盖 client 默认 retries=5
- 负数 retries clamp to 0

复跑:
- pnpm vitest run test/daemon-internal-client.test.ts → 25 passed (21 → 25)
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…moke

dashboard.ts 接通:
- 导入 createDaemonInternalApi + workflows ops-projection imports
- 抽 buildGroupsMatrix() 函数:browser GET /api/groups 和 Route B 共用
  - GET /api/groups 改为 const matrix = await buildGroupsMatrix(); ...(含 redact 不变)
- 模块级构造 daemonInternalApi:
  - secret 复用现有 SECRET(loadOrCreateSecret 32-byte base64url)
  - 启动时 SECRET 空 → logger.error + process.exit(1)(fail closed,不 silently 生成空 secret 然后 mount dispatcher)
  - 接 aggregator.getSessions/getSchedules/ownerOf/scheduleOwnerOf
  - 接 resolveDashboardSettings / buildGroupsMatrix
  - 接 settingsWriteApplierDeps(C2)/ groupsActionDeps(C3)/ defaultWorkflowsActionDeps(C4)
  - 接 proxyToDaemon
- handle 挂载位置:/__cli/rotate 之后,cookie/token gate 之前
  - 理由:HMAC + loopback 自包含;浏览器 token 是 cookie/SPA 协议,与 daemon HMAC 协议正交
  - 不让浏览器 gate 触碰 /__daemon/* → 避免 false negative 401 daemon 或 false positive 把 daemon 装成浏览器用户

src/dashboard/workflows-action-helpers.ts 新增 defaultWorkflowsActionDeps 工厂

scripts/daemon-internal-curl.sh(~85 行)
- 签名所有字段(ts/nonce/method/pathWithQuery/sha256(body))
- secret 经 env var 传给 node(不在 argv 暴露,ps -ef 看不到)
- 永不 stdout 打印 secret
- jq 美化可选;缺失时直接输出原始 JSON
- secret 缺失/空 → fail closed exit 70

test/daemon-internal-smoke.test.ts(~120 行 / 4 用例)
- 起真实 http.createServer 挂 createDaemonInternalApi
- 用真实 createDaemonClient 发签名请求
- 覆盖:
  - GET /__daemon/sessions-list → 200 + sessions
  - PUT /__daemon/settings-write 缺 ownerUnionId → 403 owner_only
  - PUT /__daemon/settings-write bogus on_xxx 未命中 → 403 owner_only
  - GET /__daemon/secrets unknown_endpoint → 404

复跑(PR2 完整测试合集):
- pnpm vitest run test/{daemon-internal-smoke,daemon-internal-client,daemon-internal-api,daemon-internal-auth,settings-owner-resolver,workflows-action-helpers,groups-action-helpers,settings-write-applier}.test.ts → 8 files / 163 tests passed
- pnpm tsc --noEmit → 通过

PR2 全部 8 commit 完成:C1 auth / C2 settings-applier / C3 groups-helpers / C4 workflows-helpers / C5 owner-resolver / C6 dispatcher / C7 client / C8 接通+smoke。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/daemon-internal-curl.sh:94 原本 curl ... || true,导致网络失败
(DNS 不可达 / connection refused / TLS 错误等)被自动化误判成功,
排查方向被带偏。

修法:
- 去掉 || true,让 set -e 保留 curl 原始退出码
- HTTP 4xx/5xx 默认仍走 rc=0 + 输出 body(curl 视为成功 round-trip),
  适合测试 owner_only / unknown_endpoint
- 网络层面失败(DNS / 连接 / TLS)才非 0,自动化脚本能感知

验证:
- bash -n scripts/daemon-internal-curl.sh → OK
- DASHBOARD_PORT=9 bash scripts/... GET /__daemon/sessions-list
  → "curl: (7) Failed to connect to 127.0.0.1 port 9" + rc=7
- PR2 8 文件测试 / pnpm tsc --noEmit → 全绿

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
新增 src/core/dashboard-command/:
- owner-gate.ts (~50 行) — ensureDashboardOwner(message, deps?) 三态:
  - missing_union_id (senderUnionId 缺失)
  - invalid_prefix (非 on_)
  - not_authorized (含 PR2 helper 内部 catch 的 resolver fail-closed;不再单独暴露 resolver_error reason)
- stub.ts (~50 行) — DASHBOARD_MODULES × 6 (overview/sessions/workflows/groups/schedules/settings) + buildStubText + buildHelpText (含 unknownModule 前缀)
- index.ts (~80 行) — handleDashboardCommand 顶层 dispatch:
  - 整体 owner gate (B1),help/stub/unknown/settings 全部不绕过
  - 路由 6 module → buildStubText(v4 B1 settings 也走 stub,C4 替换为真实 handler)
  - help → buildHelpText
  - unknown → buildHelpText({unknownModule})
  - 空 args 默认 overview

src/core/command-handler.ts:
- DAEMON_COMMANDS 加 /dashboard
- SESSIONLESS_DAEMON_COMMANDS 加 /dashboard (v2 B2 防 phantom session)
- case '/dashboard' dispatch handleDashboardCommand

src/i18n/{zh,en}.ts: +12 keys (v2 B8)
- card.dashboard.owner_only
- card.dashboard.{6 modules}.not_implemented_yet
- card.dashboard.help.body + .unknown_module

测试 (32 用例 / vitest):
- ensureDashboardOwner × 8: 3 状态分支 + spy 验证 short-circuit + authoriser throw propagates
- stub module list × 4: DASHBOARD_MODULES 顺序 + buildStubText × 6 + help 默认/含 unknown
- owner gate covers all subcommands × 6: 非 owner 调 help/sessions/settings/unknown/(空)/ou_prefix 全部 owner_only,断言不返回任何 stub/help 内容
- owner dispatch × 9: it.each 6 module stub + help + 默认 overview + unknown_module 前缀
- command set 注册 × 5: /dashboard 在 DAEMON_COMMANDS + SESSIONLESS_DAEMON_COMMANDS + 既有命令完整性 + /restart 非 sessionless

验收 (codex C1 清单):
- ✅ owner gate 5 状态分支测试 (missing/invalid_prefix/not_authorized/PR2 fail-closed/owner hit)
- ✅ 非 owner 对 help/sessions/unknown/settings/(空) 全部 owner_only
- ✅ owner 对 6 module + help 正确路由
- ✅ /dashboard 在两个 Set
- ✅ grep src/core/dashboard-command/ 无 senderOpenId / 无 active resolver_error
- ✅ vitest 32 passed + tsc 通过

不动 (硬边界):
- card-handler.ts / card-builder.ts 完全不动 (C2 才动)
- 不接真实 settings card / daemon client wrapper (C4/C3)
- 不创建 settings.ts (settings 走 stub,C4 替换)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
C1 注册 /dashboard 后 DAEMON_COMMANDS 从 21 → 22,但
test/command-handler.test.ts 旧测试仍期望 21,且 expected
列表缺 /dashboard。

修:
- test/command-handler.test.ts:445 expected 列表 +/dashboard
- test/command-handler.test.ts:466 size expect 21 → 22

复跑:
- pnpm vitest run test/command-handler.test.ts test/dashboard-command-c1.test.ts
  → 2 files / 147 tests passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rdOperatorUnionId 三态

src/im/lark/card-handler.ts:
- export interface CardActionData (PR3 公共接口,PR4+ 共享)
- operator 扩展 union_id?: string (Lark v2 verified payload 顶层字段)
- export interface CardOperatorIdentity { unionId?, openId? }
- export interface ResolveCardOperatorUnionIdDeps { resolveUserUnionId? } (测试 seam)
- export async function resolveCardOperatorUnionId(data, larkAppId, deps?)

三态语义 (plan v3 B2 + v3 §E.1):
1. verified operator.union_id 'on_xxx' → 直接信任,不调 fallback
2. verified 存在但非 'on_' (含 'ou_xxx' / 空串 / 'On_*' 大小写) → 拒绝,**不 fallback**
3. verified 缺失 → fallback resolveUserUnionId(larkAppId, openId)
   - 结果仅接受 'on_' 前缀
   - 非 on_ / 抛错 → fail-closed 返回 undefined

NEVER 读 action.value.* 任何 identity 字段(plan v1.3 §7 安全红线)。

新增 default import:
- import { resolveUserUnionId as defaultResolveUserUnionId } from './client.js'

测试 (17 用例 / vitest):
- 状态 1 verified on_: 直接返回 + 不调 fallback
- 状态 2 verified 非 on_: ou_xxx / 空 / 'On_alice' 大小写 / 'on' 等 6 个非法值都拒绝 + spy 不调 fallback
- 状态 3 fallback: 调用参数正确 / 只接受 on_ 前缀 / 空结果 / 抛错 fail-closed / undefined verified 视为缺失
- 缺失 operator / 缺失 open_id 兜底
- **红线测试 × 4**:action.value 塞 union_id/open_id/user_id/owner_id 都不影响结果
- default resolver wiring smoke

复跑:
- pnpm vitest run test/{card-handler-c2,dashboard-command-c1,command-handler}.test.ts → 3 files / 164 tests passed
- pnpm tsc --noEmit → 通过

边界保持:
- 仅扩展 CardActionData + 添加 helper + 导出类型
- 未动 handleCardAction dispatch 逻辑(C4 再加 dash_settings_* 入口)
- 未动 card-builder.ts
- 未接 settings 真实逻辑
- 未注册任何新 dispatch arm

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/daemon-internal-client-wrapper.ts (~80 行)
- resolveDashboardUrl(portPath?: string): string
  - 默认从 ~/.botmux/.dashboard-port 读
  - 严格 parser:Number(raw.trim()) + Number.isInteger + 1..65535 范围(v4 B4)
  - 任何不合法 → fallback 7891
- createDaemonClientFor(larkAppId, opts?): DaemonClient
  - 每次 createDaemonClient(无 cache,v3 B6 防 stale port)
  - appId 来自调用方 larkAppId
- ClientWrapperOptions { portPath?, createClient? } 测试 seam

测试 (22 用例 / vitest,全用 tmp dir 隔离,零真实 ~/.botmux):
- resolveDashboardUrl × 16:
  - 4 合法:基础 / 含 trim 空白 / port=1 / port=65535
  - 4 缺失/空:文件不存在 / 空文件 / 纯空白 / 'NaN' / 'Infinity'
  - 4 非法字符:'abc' / '7891abc'(v4 B4 关键回归)/ '7891.5' / 不允许越界
  - 越界:0 / -1 / 65536
  - '1e3' = 1000 接受(科学计数法+整数合法)
- createDaemonClientFor × 6:
  - 返回 DaemonClient 含 .request
  - createClient 调用参数:dashboardUrl + appId 正确
  - 不缓存:3 次调用 = 3 次 createDaemonClient(v3 B6)
  - 不同 larkAppId → 不同 appId header
  - 每次读最新 port(dashboard 重启换端口测试:9000 → 9001)
  - port 文件消失 → 第二次 fallback 7891

复跑:
- pnpm vitest run test/{daemon-internal-client-wrapper,card-handler-c2,dashboard-command-c1}.test.ts → 3 files / 71 tests passed
- pnpm tsc --noEmit → 通过

边界保持:
- 仅新增 wrapper,未动 dashboard.ts / card-handler.ts / card-builder.ts
- 未接 /dashboard settings
- 测试通过 portPath + createClient 注入 seam,零真实 ~/.botmux 读取
- 复用 PR2 createDaemonClient 不重新实现 HMAC

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ler 接入

新增 src/im/lark/settings-card.ts (~280 行)
- buildSettingsCard(dto, opts) → Feishu interactive card JSON
  - opts 仅 invokerOpenId / locale / canWrite,v3 B5 builder 不接 senderUnionId
  - action.value 只塞 invoker_open_id / field / next_value,不带任何 identity 字段
  - toggle.state.enabled=false 渲染 note,不带 action 按钮
  - autoUpdate 时间走 form_submit + 显式保存按钮(v3 B5)
- buildPatchFromAction(action, value, formValue) 纯函数
  - dash_settings_toggle: field 白名单 + next_value 严格 'true'|'false'(v3 B3,非法 → invalid_value)
  - dash_settings_set_time: HH:MM 严格正则(非法 → invalid_time,不静默 04:00)
  - 未知 action → invalid_action
- handleSettingsCardAction(data, larkAppId, deps)
  - invoker 锁卡 → C2 resolveCardOperatorUnionId → global owner gate
  - refresh 走 GET snapshot 不走 PUT(v3 B4)
  - toggle/time 走 PUT settings-write(PR2 client 默认非 GET 不 retry,符合 codex C7 retry policy)
  - ACK-then-patch:同步 toast + 异步 schedule(scheduleAsync 测试可注入 sync runner)
  - action.value.union_id/user_id/owner_id 等 identity 字段被完全忽略

新增 src/core/dashboard-command/settings.ts (~80 行)
- handleDashboardSettings(message, args, rootId, chatId, deps, larkAppId, testDeps?)
  - GET /__daemon/settings-snapshot via PR2 client
  - 调 PR1 composeSections + buildSettingsCard
  - invokerOpenId 来自 message.senderId(v4 B2)
  - 不接 senderUnionId 进 builder

src/core/dashboard-command/index.ts
- DashboardCommandDeps 加 settings?: DashboardSettingsCommandDeps test seam
- dispatch 中 'settings' 从 stub 替换为真实 handleDashboardSettings;其它 5 module 仍 stub

src/im/lark/card-handler.ts
- handleCardAction 内 dispatch dash_settings_* 进入 settings-card.ts(dynamic import 避免循环)
- 复用 PR2 wrapper createDaemonClientFor(larkAppId)

i18n: +25 keys 覆盖 settings card 文案(title / refresh / save_time / toggle on/off/disabled / saving / refreshing / not_invoker / owner_only / invalid_field/value/time/action / snapshot_failed + 9 个 settings.* PR1 DTO 引用的 key 在两份 zh/en 镜像)

测试 (29 用例 / vitest):
- buildPatchFromAction × 12:
  - 4 toggle 字段 happy
  - invalid_field
  - next_value 严格白名单:'yes'/'no'/'TRUE'/'False'/'1'/'0'/''/'lol' 全 invalid_value
  - missing next_value invalid_value
  - 3 time happy (HH:MM + 边界 00:00/23:59)
  - 7 invalid time 拒绝('25:00'/'12:60'/'1230'/'noon'/''/'4:00'/'4:5')
  - missing time
  - 未知 action
- buildSettingsCard × 5:
  - 含 title + toggle 行
  - **action.value 红线:扫 raw JSON 不含 union_id/senderUnionId/user_id/owner_id/open_id(仅 invoker_open_id)**
  - next_value 根据 DTO enabled 反转
  - disabled toggle 渲染 note 不带 button
  - refresh button 也不带 identity
- handleSettingsCardAction × 8:
  - invoker 锁卡 → not_invoker
  - 缺 verified union + fallback 拒绝 → owner_only
  - global owner gate 拒绝 → owner_only
  - happy toggle → PUT 含正确 patch + ownerUnionId
  - happy set_time → PUT 含 maintenance.autoUpdate.time
  - **invalid_time → 不 PUT**
  - **invalid_value (next_value='lol') → 不 PUT**
  - **refresh → 只 GET,无 PUT** (v3 B4)
  - **action.value.union_id='on_attacker' 等被忽略**,PUT body 用 verified union
- dashboard-command index dispatch × 3:
  - owner /dashboard settings → 真实卡片(含 'Dashboard',不含 🚧),调 GET snapshot
  - 非 owner /dashboard settings → owner_only + 不调 client
  - owner /dashboard sessions 仍走 stub

C1 测试调整:it.each 从 6 module 改 5 module(settings 移到 C4 测试)。

非阻塞清理:C3 '1e3' 测试标题改为 "accepted as 1000"。

复跑:
- pnpm vitest run test/{settings-card-c4,daemon-internal-client-wrapper,card-handler-c2,dashboard-command-c1,command-handler}.test.ts → 5 files / 214 tests passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
B1: ACK shape 修正
- 旧: { ack: { toast: ... } } 被 event-dispatcher 当 raw card patch
- 新: { toast: { type, content } } 直接被 event-dispatcher pass-through
- event-dispatcher.ts:390-395 只识别 {toast} 或 {card}
- 测试 +1: 验证返回顶层有 toast,无 ack 字段

B2: 生产路径接通 patchCard (ACK-then-patch 真实落地)
- card-handler.ts settings dispatch 内 wire patchCard
  - 从 PUT response.body.settings 取最新 / PUT 不带时 GET snapshot 重新拉
  - composeSections + buildSettingsCard 重建卡片
  - updateMessage(larkAppId, open_message_id, cardJson) 原地刷新
  - 缺 open_message_id 仅 logger.warn,不抛
- 测试 +5:
  - settings-card-c4: PUT 后 patchCard 被调用 + 收到 route-B response
  - refresh 后 patchCard 收到 GET snapshot response
  - settings-card-dispatch-c4(**新文件,3 用例**):
    - 端到端 happy toggle → updateMessage 收到重建的 card JSON (不是原始 response)
    - 端到端 refresh → 只 GET,updateMessage 收到重建卡
    - 缺 open_message_id → updateMessage 未调用,handler 不抛
  - 新测试 mock client.ts (updateMessage, resolveUserUnionId) +
    daemon-internal-client-wrapper + settings-owner-resolver

B3: invoker 锁卡 fail-closed
- 旧: invoker_open_id 或 operator.open_id 缺失 → 静默放行
- 新: 任一缺失/类型不对/不相等 → not_invoker,不调 client
- settings 是新卡,无 legacy 兼容需求
- 测试 +2: 缺 invoker_open_id / 缺 operator.open_id 都 not_invoker + 不调 createClient

测试总数:29 → 37 (settings-card-c4 + settings-card-dispatch-c4)

复跑:
- pnpm vitest run test/{settings-card-c4,settings-card-dispatch-c4,daemon-internal-client-wrapper,card-handler-c2,dashboard-command-c1,command-handler}.test.ts → 6 files / 222 tests passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/im/lark/settings-card.ts 文档清理:
- 顶部注释 "sync ack payload" → "sync toast payload"(对齐 B1 协议改动)

test/dashboard-i18n-c5.test.ts (~80 行 / 76 用例)
- REQUIRED_KEYS 列出 PR3 C1-C4 全部用到的 i18n key
- it.each 验证 zh/en 镜像都存在,无 MISSING fallback
- 35 个 key × 2 locale = 70 + 2 placeholder 插值(snapshot_failed {reason} + unknown_module {module})

test/dashboard-settings-smoke-c5.test.ts (~280 行 / 4 用例)
- 起真实 http.createServer + createDaemonInternalApi(Route B 完整 dispatcher)
- 完整端到端流程:
  - 命令路径:handleDashboardSettings → 真实 createDaemonClient → HMAC envelope
    → 服务端 GET /__daemon/settings-snapshot → composeSections → buildSettingsCard
    → sessionReply('interactive') 发卡
  - 回调路径:handleSettingsCardAction toggle → 真实 PUT → 服务端 state 真实更新
    → patchCard 收到合并后的 settings
  - refresh:只 GET,PUT 计数 0
  - 非 owner:本地 owner gate 拦截,服务端 0 hit,settings 不变
- 服务端用 in-memory state + stubbed PR2 deps(settings 路径完整,其它 endpoint 不需要)

PR3 完整测试合集复跑:
- pnpm vitest run test/{settings-card-c4,settings-card-dispatch-c4,daemon-internal-client-wrapper,card-handler-c2,dashboard-command-c1,command-handler,dashboard-i18n-c5,dashboard-settings-smoke-c5}.test.ts → 8 files / 302 tests passed
- pnpm tsc --noEmit → 通过

PR3 全 5 commit 完成 (C1-C5):
- C1 (ef75a07 + 9e29e10): /dashboard 命令组 + owner gate + 6 module stub
- C2 (92cf9ad): CardActionData 导出 + 扩展 union_id + 三态 resolver
- C3 (173e44a): daemon-internal-client-wrapper + 严格 port 解析
- C4 (1fe8425 + 5b0bb2f): settings 卡片 builder + handler + 真实 sub-handler + ACK shape/patch/fail-closed
- C5 (本): smoke + i18n + 文档

用户可测试方式(不 restart daemon):
1. 跑 vitest 看 302 tests
2. 跑 pnpm tsc --noEmit
3. 单测/smoke 已覆盖整条端到端路径

真实飞书 /dashboard settings 可点测试需要 pnpm build && pnpm daemon:restart(会打断活跃 session),执行前单独问用户。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
旧 t(key, undefined, locale) + !startsWith('MISSING:') 断言无效:
- src/i18n/index.ts:81-84 t() 的 fallback 是
  dictionaries[loc]?.[key] ?? dictionaries.zh[key] ?? key
- en 缺 key 但 zh 有 → t('en') 返回 zh 文案,测试照样通过
- zh/en 都缺 → t() 返回 key 本身,也不以 'MISSING:' 开头

新实现:直接 import { messages as zhMessages } / { messages as enMessages }
+ Object.prototype.hasOwnProperty.call(dict, key) 断言
+ dict[key] truthy

保留 t() placeholder 插值测试不变。

复跑:
- pnpm vitest run test/dashboard-i18n-c5.test.ts → 76 passed
- PR3 8 文件全合集 → 302 passed
- pnpm tsc --noEmit → 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
为什么改:
- 原方案:全局 union_id owner 集 — 任何在 .botmuxrc allowedOwners 里
  的用户都能 /dashboard 任何 bot;与「话题群 bot 默认私有」不符
- 新方案:每个 bot 的 owner 才能 /dashboard 那个 bot(沿用 /card 模式)
  - 同时把卡片私聊 owner,话题里只回一条「已私信」(用户原话)

核心改动
- owner-gate.ts: 从 isAuthorizedForGlobalSettings 切到
  bot-registry.getOwnerOpenId;DashboardOwnerCheck.reason 变成
  no_bot_owner | missing_sender | not_bot_owner
- index.ts: 统一把 owner-gated 回复改成 sendUserMessage 私聊 owner,
  topic 里发 i18n 短确认;ownerOpenId 透传给 settings handler
- settings.ts: invokerOpenId = ownerOpenId(不再 = message.senderId)
  → 卡片 invoker-lock 锚定到 owner;卡片走 sendUserMessage
- settings-card.ts: callback 的 owner gate 也切到 per-bot:
  operator.open_id === getOwnerOpenId(larkAppId);写路径 PUT body 仍需
  ownerUnionId(PR2 服务端约束),handler 内通过 resolveUserUnionId
  查询,failed/missing on_-prefix 走 patchCard 403
- card-handler.ts: patchCard rebuild 的 invokerOpenId 也用
  getOwnerOpenId(appId)(确保后续点击仍通过 invoker-lock)
- i18n: 新增 card.dashboard.{dm_sent,dm_failed} 和
  card.dashboard.settings.{dm_sent,dm_failed}

测试
- dashboard-command-c1.test.ts: 重写 owner-gate 测例 + 验证 owner /dashboard
  *  全部 DM、非 owner 全部 topic 拒绝;新增「bot A owner 不能用 bot B」用例
- settings-card-c4.test.ts: 加 lastScheduled/flushScheduled pattern 让
  写路径的异步 schedule 可被 await;deps 注入 getOwnerOpenId +
  resolveUserUnionId
- settings-card-dispatch-c4.test.ts: vi.mock bot-registry.getOwnerOpenId 和
  resolveUserUnionId 让 dispatch 走到写路径
- dashboard-settings-smoke-c5.test.ts: smoke 改用 getOwnerOpenId +
  sendUserMessage; 非 owner 用例用 ou_stranger 作为 operator 验证 gate
- dashboard-i18n-c5.test.ts: 新增 4 个 dm_* key

全套 151/151 绿(C1 26 + C4 34 + C4 dispatch 3 + C5 smoke 4 + C5 i18n 84);
tsc 干净。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
为什么改:
- 原 UI 单按钮 toggle 语义模糊("我点了会发生什么");用户图反馈:希望
  双按钮分段(开启 / 关闭)+ 当前态高亮 + 反向态置灰
- 原维护区在 localDev/autoUpdate=off 下整组消失 reason 文案,用户分不清
  "维护不可改"是 bug 还是业务规则
- 缺少头部摘要 / 尾部安全提示,用户得逐节扫才能看清当前 state

落地方案(与 codex C4 锁定):
- 当前值按钮:type=primary + disabled=true + ✓ 已开启/已关闭
  + 备用 dash_settings_noop action(万一某飞书客户端没忽略 disabled
  回调,handler 短路不发 PUT)
- 反向值按钮:type=default + 可点击 + dash_settings_toggle + next_value
- 整组 disable(如 localDev autoUpdate / autoUpdate=off 下的 autoRestart):
  两按钮都 disabled,没 toggle action,但视觉仍 primary/default 区分
- 每个 toggle 自己的 reasonKey(不再复用 generic「当前不可改」):
  - autoUpdate disabled → settings.autoUpdate.disabled.localDev
    「源码安装下不支持自动更新(npm 全局安装后可用)」
  - autoRestart disabled → settings.autoRestart.disabled.needsAutoUpdate
    「需先开启「每日自动更新」」
- autoUpdate 即使 disabled 也渲染只读「更新时间:04:00」(永远可见)
- 新增头部摘要行:「访问 1/1 · 卡片 0/1 · 维护 受限/N/M」
- 新增尾部安全 note:「仅 Bot Owner · 私聊回复 · ACK 自动刷新」

PR1 model 改动
- composeSections() 给 maintenance 两个 toggle 加 state.reasonKey
- ButtonState.reasonKey 已存在,零新增类型

settings-card.ts 重构
- 新增 SETTINGS_ACTION_NOOP = 'dash_settings_noop'
- buildToggleRow 改名为 buildSegmentedRow + 重写
- 新增 buildHeaderSummary(动态计数 + 「受限」label)
- 卡片首尾加 hr 分割 + 尾部 note
- handler 头部加 SETTINGS_ACTION_NOOP 短路(toast 不调 client)

测试
- settings-card-segmented-c4.test.ts 新增 9 case(codex C4 全 4 条):
  R1: publicReadOnly=true → ON primary+disabled+noop, OFF default+toggle+false
  R2: openTerminalInFeishu=false → OFF primary+disabled+noop, ON default+toggle+true
  R3: localDev autoUpdate disabled → 双 disabled + 无 toggle action +
      JSON 含 04:00 + local-dev 文案
  R3b: autoRestart disabled 原因必须是「需先开启」(不是 generic)
  R4: handler 收 noop → toast 不调 client
  R4b: 反向态点击 PUT 走 ownerUnionId+next_value
  + 3 个 header/footer 摘要测试
- settings-card-model.test.ts 增 reasonKey assertion
- dashboard-i18n-c5.test.ts 增 11 个新 key

全套绿(PR1 12 + C1 26 + C4 34 + C4 dispatch 3 + C4 segmented 9 + C5 smoke 4 +
C5 i18n 106 = 194/194);tsc 干净。

Fallback 计划:若真机渲染 disabled+primary 当前态被画成全灰看不出
on/off,按用户图反馈再切到「primary 可点 noop」方案(去掉 disabled)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
codex review 非阻塞提醒:注释还在描述旧的「verified union_id + global
owner gate」模型,但代码已经在 0af77ec 切到 per-bot owner(operator.open_id
=== getOwnerOpenId(larkAppId))。本 commit 只更新文档,零行为变化。

补充:
- 列出 noop 短路(dash_settings_noop)作为分段控件 fail-safe
- 明确写路径仍需通过 resolveUserUnionId 拿 ownerUnionId(PR2 服务端约束)
- 「invoker_open_id 是 owner 的 open_id」而非 sender 的 union_id

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
为什么改:用户反馈「访问 1/1 · 卡片 0/1 · 维护 受限」语义不明确——
分母分子相等永远没信息量,「1/1」「受限」需要二次理解。三方(用户 +
codex + Claude)一致同意去掉,section 已自解释。

改动
- src/im/lark/settings-card.ts:
  - 删 buildHeaderSummary 函数 + buildSettingsCard 顶部那次 push
  - 保留 footer 安全 note(「仅 Bot Owner 可见 · 私聊回复 · ACK 自动刷新」)
- src/i18n/{zh,en}.ts: 删 4 个总结 i18n key
  - card.dashboard.settings.header.summary
  - card.dashboard.settings.header.maintenance.ok
  - card.dashboard.settings.header.maintenance.restricted

保留(codex 验收点)
- section 内的限制原因仍然存在:「源码安装下不支持自动更新」
  「需先开启「每日自动更新」」(这些是真正有用的提示,不能跟首行混淆)

测试
- test/settings-card-segmented-c4.test.ts:
  - 3 个 header summary 用例 → 1 个负向断言(卡片 JSON 不包含
    `访问 1/1` / `卡片 0/1` / `维护 受限` 等历史片段)
  - 新增维护 section 限制原因仍存在的断言
  - 保留 footer security note 测试
- test/dashboard-i18n-c5.test.ts: REQUIRED_KEYS 同步删 3 key

全套 188/188 绿;tsc 干净。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
为什么改:用户反馈点击 /dashboard settings 按钮无 loading 动效,对比
/card 关闭会话有清晰的圆形 spinner。根因:spinner 是飞书客户端在
callback 未返回前的待响应态——/card close handler 同步等到 killWorker
完成(200-800ms),spinner 完整显示;settings handler 用 ACK-then-patch
立刻 return(~30ms),spinner 还没画就被撤掉。

最小改法(codex 收口):
- handleSettingsCardAction 把 refresh / toggle / set_time 成功路径直接
  await GET/PUT + await deps.patchCard,最后返回完成 toast
- 权限失败 / 参数非法 / noop 继续快速 toast(这些本来就不需要 spinner)
- 保留 patchCard 生产 wiring(composeSections + buildSettingsCard +
  updateMessage 仍在 card-handler.ts),不搬到 settings handler
- 删除 scheduleAsync / defaultScheduleAsync——彻底走同步路径
- event-dispatcher 的 2.5s ack-safe fallback 保持不动:Route B PUT
  超过 2.5s 时仍能 ACK 回 toast + 后台完成 patch

i18n 新增(同步等待后 toast 文案):
- card.dashboard.settings.saved: ✅ 已保存
- card.dashboard.settings.refreshed: ✅ 已刷新
- card.dashboard.settings.save_failed: ⚠️ 保存失败:{reason}

附加修复(codex C5):cardActionKey 加 field + next_value
- 原 key 不区分 settings toggle 的 field/next_value,sync 等待变长
  后,对不同 toggle 的连点会撞 in-flight dedupe
- 加上后,同一卡片对不同字段的快速点击不会被错误去重

Route B HMAC 本地回环 RTT 实测 30-80ms,远小于 2.5s 超时;正常情况
下用户能看到完整 spinner,超时也有 patch fallback 保命。

测试更新
- settings-card-c4.test.ts (34/34): 删 syncSchedule / 改 flushScheduled
  为 no-op;happy toggle/refresh 改断言 final toast ✅;syncSchedule
  注入清掉,deps 不再传 scheduleAsync
- settings-card-segmented-c4.test.ts (9/9): 删 pending / scheduleAsync,
  PUT 走 await 直接断言
- dashboard-settings-smoke-c5.test.ts (4/4): 删 pending,await 后
  直接断言 server state + patchCard payload,toast = Saved
- dashboard-i18n-c5.test.ts (106/106): 新 3 个 key 加 REQUIRED_KEYS
- event-dispatcher.test.ts (116/116) 跑过验证 dedupe key 改动无副作用

全 PR3 + PR1 测试 194/194 绿,tsc 干净。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
为什么改:用户反馈点 settings 按钮 spinner 走完后,卡片仍闪现旧态再
切到新态。根因:上一版 callback await 完 PUT + patchCard 才返回 toast,
但 patchCard 内部用 updateMessage 推送,updateMessage 服务端 ACK ≠
飞书客户端已重绘。回调 ACK 一到,spinner 撤掉客户端按缓存渲染旧卡,
随后 updateMessage push 才来,所以旧态闪一下。

修复方向(codex 同意):把 rebuilt card 塞进 callback response 自身。
event-dispatcher 已支持 `{toast, card}` envelope(line 390-395);飞书
客户端会原子应用——toast 弹出 + 卡片切终态在同一帧。

核心改动
- src/im/lark/settings-card.ts:
  - SettingsCardHandlerResult 加可选 `card?: { type: 'raw', data: object }`
  - 新增 successResult helper:从 Route B payload 提取 settings →
    composeSections + buildSettingsCard → 包成 { toast, card } 返回
  - refresh + write 成功路径走 successResult;错误/权限失败/参数非法/
    noop 仍只返回 toast(不需要带 card)
  - 删 patchCard 依赖(成功路径再也不调用 updateMessage)
- src/im/lark/card-handler.ts:
  - settings dispatch arm 不再传 patchCard wiring(30 行 compose +
    updateMessage 的代码全部干掉)
  - 仅传 createClient + locale

Slow fallback(codex 关键点):保留不动
- event-dispatcher.ts:404 patchTimedOutCardActionResult 在 handler 慢于
  2.5s ack 超时后,会调 updateMessage 兜底
- 我们 handler 最终返回的就是 shaped `{toast, card}`,dispatcher 自动
  走兜底分支,没有特殊处理

测试更新
- settings-card-dispatch-c4.test.ts: 重写——
  - 快速路径断言 `result.card.type === 'raw'`、卡片内容含 Dashboard
  - **关键**:fast path `updateMessage` 必须 0 调用(这是 spinner 改善的核心)
  - missing open_message_id 不再阻止 fast path(响应里有 card 就行)
- settings-card-c4.test.ts: 改 happy toggle / refresh 断言 result.card
  + 内容含 `✓ 已开启`(终态指示符);删 makeDeps 的 patchSpy
- dashboard-settings-smoke-c5.test.ts:
  - happy toggle 端到端断言 result.card.type 和 cardJson 含 `✓ On`
  - refresh path 断言 result.card 存在
  - non-owner deny 断言 r.card === undefined(toast-only)
- settings-card-segmented-c4.test.ts: R4b 删 patchCard

全 7 PR3 文件 + event-dispatcher 共 310/310 绿;tsc 干净。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…/ patchCard)

codex review 非阻塞清理:上一版 ac76917 把行为切到 sync `{toast, card}`
之后,3 处注释还在描述旧的 ACK-then-patch / scheduleAsync / patchCard
模型。本 commit 只更新文档,零行为变化。

- settings-card.ts 顶部 docblock 第 4 步改成「Sync handler」描述
- handleSettingsCardAction 的函数注释也同步
- dashboard-settings-smoke-c5.test.ts 文件头改成「callback 响应自带 card」

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
为什么:上一版 ac76917 把 callback 改成同时返回 `{toast, card}`,但用户
反馈仍闪一下旧态。codex 分析:飞书客户端把 toast 和 card 当作两次独立
render pass,spinner 撤掉 + card 替换之间存在客户端本地渲染窗口,旧
卡片被画一帧后才切到新卡片。

A/B 测试:成功路径只返 `{ card }`,不返 toast;用户反馈来确定这是不是
闪帧根因。
- successResult 不再放 toast
- 卡片本身的 `✓ 已开启 / ✓ 已关闭` 已经是终态反馈,足够清晰
- SettingsCardHandlerResult.toast 改成 optional
- 错误 / 权限失败 / 参数非法 / noop 仍只返 toast(没卡可刷)

如果用户仍复现闪帧,下一步走 codex 第二方案:**乐观态卡片** ——
callback 立刻返回切到目标态/「处理中」的卡片,后台再 PUT,
成功保持/失败回滚。绝不用 sleep hack。

测试更新(156/156 绿)
- settings-card-c4.test.ts: happy toggle/refresh 断言改为 `r.toast === undefined`
  + `r.card defined`;ACK shape 同理
- settings-card-dispatch-c4.test.ts: 同样断言去 toast
- dashboard-settings-smoke-c5.test.ts: 成功端到端断言去 toast;
  非 owner deny 仍是 toast-only(用 optional chain `r.toast?.content`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
kaidizheng and others added 29 commits June 10, 2026 01:19
…oss-bot 写 gate

按 codex pre-impl 4 条硬约束落地,pattern mirror sessions slice 2a + 收
紧 Route B 写端点。

核心实现
- src/dashboard/daemon-internal-api.ts:
  - **新增 cross-bot 写 gate**(codex 硬约束 deepcoldy#1):
    POST /__daemon/schedules/:id/(run|pause|resume) 加 callerAppId 检查
    * ctx.callerAppId !== undefined && owner !== ctx.callerAppId → 403
      `schedule_owner_mismatch`,不 proxy
    * test seam (callerAppId === undefined) 保留原行为(dispatchForTest 可信)
  - **没改** sessions 写端点(codex 只点了 schedules;sessions 同款的
    cross-bot 漏洞作为 follow-up 单独评估)

- src/im/lark/schedules-card.ts:
  - SCHEDULES_ACTION_DETAIL / PAUSE / RESUME / BACK_TO_LIST 四个新常量
  - buildSchedulesCard 列表每行新增 dash_schedules_detail 按钮
  - 新增 buildSchedulesDetailCard:header + key/value 块(name/enabled
    glyph/kind/displayExpr/nextRunAt/lastRunAt/lastStatus/repeat/prompt)
    + 下次运行 bullet 列表 + ⏸ 暂停 + ▶ 恢复 + 🔙 返回 + footer
  - 按钮状态由 PR1 computeButtonAvailability 矩阵驱动(enabled→pause
    primary/resume disabled;disabled→反之),disabled 时渲染对应
    reasonKey note
  - handleSchedulesCardAction 加 4 个分支:
    * DETAIL: GET schedules-list → composeDetail → render
    * PAUSE / RESUME: pre-POST GET snapshot → **server-side matrix 复核**
      (codex 硬约束 deepcoldy#3): actions.pause/resume.enabled !== true → toast-only
      不 POST 不重绘
    * PAUSE 成功:synth `{...before, enabled:false}` 重绘(nextRunAt 暂停后
      irrelevant,不 refetch)
    * RESUME 成功:**2nd GET 拿同 id row** 让 scheduler.computeNextRun 重算
      nextRunAt(codex 硬约束 deepcoldy#4 — IM 层不复制 cron 逻辑);GET 失败或行
      丢失时回退 synth + 注释说明 nextRunAt 可能一帧 stale
    * BACK_TO_LIST: GET → render list page 1
  - 所有分支:invoker_lock + per-bot owner gate;value.schedule_id 是
    untrusted routing key,upstream ownerOf + 上面 cross-bot gate 是权威

- src/im/lark/event-dispatcher.ts:
  - cardActionKey 加 `scheduleId: value?.schedule_id`(codex 硬约束 deepcoldy#2)

- src/i18n/zh.ts + en.ts: 22 个 zh + en key(detail 各项 label + btn.
  pause/resume/back + disabled 原因映射 + pause_failed/resume_failed/
  schedule_not_found)

测试 (566/566 绿,包含 i18n 324)
- schedules-card.test.ts: detail builder + 4 个 action handler 分支全套:
  happy/snapshot already-paused/already-enabled (security)/POST 404/500/
  throw/non-owner/invoker mismatch/schedule_not_found
- schedules-card-dispatch.test.ts: 5 case
  - detail dispatch / pause happy / pause snapshot already-paused
- event-dispatcher.test.ts: 不同 schedule_id 并发不互吞 + 同 schedule_id
  in-flight 仍 dedupe(dedupe key 回归)
- daemon-internal-api.test.ts: schedules 写 gate 回归:cli_a/cli_b
  mismatch → 403 不 proxy;caller match → 正常 proxy;test seam 全量
- dashboard-i18n-c5.test.ts: 22 新 key 加入 REQUIRED_KEYS

实现路径:Workflow tool 跑 sub-agents(impl + test),codex pre-impl
review 给了 4 条硬约束,主进程 TaskStop → 改 spec → 重跑后落地此版本。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
codex blocker:legacy schedule row(持久化时还没 larkAppId 字段)在读路径
被保留(scopeByCaller 短路)、卡片能看到、详情可点 pause/resume;但写
路由用 scheduleOwnerOf(id) 拿 row.larkAppId(aggregator.ts:69),legacy
返回 undefined → POST 走 unknown_schedule 404 死路。

修法:三态路由
- src/dashboard/aggregator.ts: 新增 scheduleExists(id) — 不看 larkAppId,
  只看 row 是否在 schedules map 里
- src/dashboard/daemon-internal-api.ts:
  - DaemonInternalApiDeps 加 scheduleExists 字段
  - 写 handler 分三态:
    * owner !== undefined + caller !== owner → 403 schedule_owner_mismatch(不变)
    * owner !== undefined + caller === owner(或 test seam)→ proxy owner(不变)
    * owner === undefined + scheduleExists(id) === true(legacy)+
      callerAppId !== undefined → **proxy callerAppId**(新分支,让 caller
      自己的 daemon 处理 legacy task;scheduler.belongsToOwner 已让
      primary daemon 接管无 owner task)
    * owner === undefined + scheduleExists(id) === true + callerAppId ===
      undefined(test seam)→ 404 unknown_schedule(保留 dispatchForTest
      的旧行为)
    * scheduleExists(id) === false → 404 unknown_schedule
- src/dashboard.ts: 把 aggregator.scheduleExists 拍进 deps

附带 cleanup(codex 提的):原 cross-bot gate 测试 loop 只覆盖
['pause','resume'],但 Route B 同一 handler 同时 gate run/pause/resume;
扩 loop 到 ['pause','resume','run'] + 改注释。UI slice 2a 仍只暴露
pause/resume(run-now 留给 slice 2b)。

测试 (67/67 绿,新增 9 个)
- legacy row + caller=cli_caller × {pause,resume} → proxy cli_caller,
  断言 proxyToDaemon 收到 cli_caller 而不是 owner=cli_b
- legacy row + test seam(无 callerAppId)× {pause,resume} → 404 兜底
- row 真不存在 + caller=cli_caller × {pause,resume} → 404
- 既有 cross-bot mismatch 测试 loop 扩到 run

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…gacy 兜底

codex 收口 follow-up:schedules 写路由已加 cross-bot gate (39fea70),
sessions 写路由 (/__daemon/sessions/:id/(close|resume|locate)) 同款漏洞
也补上。Pattern 与 schedules 完全镜像。

修法(三态路由)
- src/dashboard/aggregator.ts: 新增 sessionExists(id),区分「row 不存在」
  和「legacy row 无 larkAppId」
- src/dashboard/daemon-internal-api.ts:
  - DaemonInternalApiDeps 加 sessionExists
  - 写 handler 改三态:
    * owner !== undefined + caller mismatch → 403 session_owner_mismatch
    * owner !== undefined + caller match(或 test seam)→ proxy owner
    * owner === undefined + sessionExists + callerAppId 有值 → proxy
      callerAppId(legacy 分支)
    * legacy + test seam → 404(保留 dispatchForTest 旧行为)
    * 不存在 → 404 unknown_session
- src/dashboard.ts: aggregator.sessionExists 接进 deps

测试 (146/146 绿,新增 18 个:6 case × 3 verb)
- 原 sanity 测试「sessions write 不变 = ownerOf-only」翻过来:cross-bot
  → 403(之前的行为就是漏洞,pin 在测试里反而把漏洞锁死了)
- 三个 verb (close/resume/locate) × 6 case:
  * cross-bot mismatch → 403 不 proxy
  * caller match → proxy owner
  * test seam → proxy owner(back-compat)
  * legacy row + caller → proxy caller
  * legacy + test seam → 404
  * missing → 404

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… bot 写 gate

按 codex 多轮 pre-impl refinement 落地。Pattern 第三次套用(sessions /
schedules 之后),同时把 workflows write 路由全部三个 verb(cancel /
approve / reject)一并 hardening。

核心实现
- src/im/lark/workflows-card.ts:
  - WORKFLOWS_ACTION_DETAIL / CANCEL / BACK_TO_LIST 三个新常量
  - buildWorkflowsCard 列表每行加 dash_workflows_detail 按钮
  - 新增 buildWorkflowsDetailCard:状态点 + bold workflowId + runId
    monospace title + 二级 key/value(workflow/run/status/started/
    updated/finished if terminal/elapsed/progress/chat)+ 可选 nodes 段
    + ⏏ 取消 (V1 confirm + danger) + 🔙 返回
  - cancel disabled 矩阵:(matrixAllows AND hasOwner) — 任一假即 disabled +
    渲染对应 reasonKey note;missing-owner 文案优先于 terminal
  - handleWorkflowsCardAction 加 3 个分支:
    * DETAIL: GET ?all=1 → projectRunDetailDto → render
    * CANCEL(codex 多条约束):
      1. pre-POST GET ?all=1 拿 snapshot
      2. server-side matrix 双重 check:computeActionAvailability + 有 owner
      3. **任一 disabled → toast-only 不 POST**(defense in depth on Route B 409)
      4. POST cancel
      5. **成功后 2nd GET ?all=1** 拿 fresh row(cancel 异步落终态,pre
         snapshot status 还没更新)→ projectRunDetailDto → render
      6. 2nd GET 失败/找不到 → 回退 `{...before, status:'cancelled'}` synth +
         注释说明 one-cycle-stale 风险(同 schedules resume 模式)
      7. 非 200 → toast-only 不重绘
    * BACK_TO_LIST: GET ?all=1 → render list page 1
  - 所有 GET 全程保留 ?all=1(slice 1 invariant)

- src/dashboard/daemon-internal-api.ts:
  - workflows-runs/:id/cancel + workflows-runs/:id/(approve|reject) 都加
    callerAppId gate(codex 关键约束:UI 本轮只暴露 cancel,但 3 个 verb
    必须一并 hardening 避免后续 slice 漏洞)
  - 读 snapshot via readRunSnapshot → 拿 chatBinding.larkAppId 当 owner
  - owner !== undefined + caller mismatch → 403 workflow_owner_mismatch,
    不 proxy
  - **owner === undefined(无 chatBinding)→ NO legacy proxy**(codex 关键
    refinement:与 schedules 不同!workflow 无 chatBinding 不应被
    dashboard 写;让 helper 自己返 409 needs_lark_or_cli 既有语义)
  - snapshot null → 404 unknown_run
  - test seam 保留 pass-through

- src/im/lark/event-dispatcher.ts: cardActionKey 加 runId 字段(避免
  不同 run 的 cancel 互吞 dedupe)

- src/i18n/zh.ts + en.ts: 19+1 个新 key(含 detail 各 label + btn +
  confirm + cancel.disabled.{alreadyTerminal, noOwner} + cancel_failed +
  workflow_not_found)

身份硬约束沿用:invoker_open_id = owner;callback 双闸;escapeLarkMd;
value.run_id 是 untrusted routing key;upstream chatBinding.larkAppId +
新 Route B gate 是写端权威。

测试(655/655 绿,slice 2a 新增 84 case)
- workflows-card.test.ts (+24): builder detail + 4 handler 分支
  全套,含 server-side matrix 双 disabled (terminal / no chatBinding) →
  POST 0 次的安全断言
- workflows-card-dispatch.test.ts (+3): detail / cancel happy / cancel
  terminal snapshot
- event-dispatcher.test.ts (+2): 不同 run_id 不互吞 / 同 run_id in-flight
  仍 dedupe
- daemon-internal-api.test.ts (+15): 3 verb × 5 case:mismatch 403、match
  proxy、test seam proxy、unknown 404、no-chatBinding 走 helper(验证 NO
  legacy fallback)
- dashboard-i18n-c5.test.ts (+40): 20 新 key × zh+en

实现路径:Workflow tool 跑 sub-agents(impl + test),codex pre-impl 提
3 轮细化(cardActionKey 已有 / 不要做 legacy fallback / 终态 OR no
owner 双 disabled / approve+reject 一并 gate / cancel 后 2nd GET),主进程
2 次 TaskStop → 改 spec → 重跑后落定。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
codex 二次 review compat blocker:workflows Route B owner gate 在
helper 前 readRunSnapshot,非法 runId 会被底层 reader 当 null →
gate 返 404 unknown_run,shadow 掉既有的 400 bad_run_id 错误码,
破坏调用方对 bad input vs missing run 的区分(dashboard-workflow-api
test + workflows-action-helpers test 都锁了 400)。

- cancel + approve/reject handler decode 后先 isValidRunId
- false → 400 bad_run_id;readRunSnapshot / proxyToDaemon 都不调
- 测试:3 verb × {../x, r/cancel} → 400 bad_run_id + 0 IO
- 既有 tripwire 改用 proxySpy 作 helper-entered 信号,
  isValidRunId 计数语义同步更新(gate=1, gate+helper=2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
只影响从 /dashboard overview 点击进入的 sessions/schedules/settings
子卡。独立 /dashboard sessions、/dashboard schedules、/dashboard
settings 完全不动(仍 10/page、无返回按钮)。

- buildSessionsCard / buildSchedulesCard opts 加 pageSize? + origin?
- buildSettingsCard opts 加 origin?(单层无 pagination)
- overview 3 个 goto handler 传 origin='overview',sessions/schedules
  额外传 pageSize=5
- 所有子卡 action.value(page/refresh/detail/back/toggle/noop/set_time)
  thread origin + page_size,detail card 的 back/close 也透传
- footer 在 origin=overview 时加「🔙 返回总览」按钮,复用
  dash_overview_refresh 路由
- list card:totalPages > 2 && <= 50 时加 select_static 跳页,复用
  dash_*_page 行为 — handler 用 value.page ?? action.option ?? '1'
- event-dispatcher cardActionKey 加 origin + pageSize:standalone 和
  drilldown 同时打开同一模块时不会 hash-collide
- i18n:card.dashboard.overview.back_button + sessions.jump_page +
  schedules.jump_page(zh + en)

测试覆盖:drilldown card builder(5/page、返回总览、nav 透传、
select_static 边界、totalPages > 50 cap)+ detail back nav 透传 +
handler select_static option fallback + handler refresh/back/toggle
保留 origin + dedupe key 不 collision;独立卡片回归保护。
9 个 test file,323/323 绿。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
用户反馈:独立 /dashboard sessions 飞书卡片仍然 10 条/页,希望和
overview drilldown 一样改为 5。改 sessions-card.ts PAGE_SIZE 常量
即可:standalone 和 drilldown 共用默认 5/页;pageSize? opt 仍保留
作为 caller override 入口。

副作用(drilldown 子卡 wire format):drilldown 现在传 pageSize=5
== PAGE_SIZE 默认,effectivePageSize === PAGE_SIZE 分支命中,
button.value 不再塞 page_size 字段。origin=overview 仍是 drilldown
的 canonical 信号,dedupe key 不受影响(origin 字段已区分)。

只改 sessions;schedules / workflows / groups 维持原默认。

测试更新:
- buildSessionsCard "renders pagination > 10 rows" → "> 5 rows",断言
  "第 2/3 页" → "第 2/5 页"(25 rows / 5 = 5 pages)
- "first/last page disabled" 改用 8 rows(8/5=2 pages),更直观
- drilldown "standalone (no opts) → 10/page" → "default → 5/page"
- drilldown "every button.value carries origin + page_size" 拆成两个:
  default 时 page_size omitted(仅 origin),override pageSize=3 时
  携带两字段
- handler "page → 25 rows, page=2/3" → "page=2/5"
- handler "detail with origin=overview → page_size:5" → "page_size omitted"
  + 新增 override pageSize=3 的对照 case
- back_to_list "25 rows, page 1/3" → "page 1/5"
- sessions-card-dispatch "page=2/3" → "page=2/5"
- overview-card-dispatch goto_sessions 断言 page_size omitted + 注释
  说明 origin 是 canonical 信号

9 个 test file,326/326 绿,tsc 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
用户决策:所有 dashboard 飞书列表卡片最大展示 5 条/页,不再按入口
区分;overview drilldown 仍只通过 origin=overview 控制返回总览按钮,
与 page size 解耦。

src 改动:
- schedules-card.ts / workflows-card.ts / groups-card.ts: PAGE_SIZE 10 → 5
- overview-card.ts: goto_sessions / goto_schedules 移除显式 `pageSize: 5`
  (既然默认就 5,多余);仍传 `origin: 'overview'` 控制返回总览按钮

测试断言批量更新(25 rows 现在变 5 pages):
- schedules-card pagination "> 10" → "> 5","第 2/3 页" → "第 2/5 页",
  first/last page 用 8 tasks(8/5=2 pages)做 boundary,drilldown
  "pageSize=5 → 5/page vs standalone 10/page" 拆成 default→5 +
  override pageSize=3,detail back/pause/resume 在 default 下
  page_size omitted(origin 仍传)
- workflows-card "第 1/1 页" → "第 1/2 页"(7 rows),25 rows
  "第 2/3 页" → "第 2/5 页",last page next disabled 用 page=5
- groups-card "第 2/3 页" → "第 2/5 页",last page 用 page=5
- 3 个 dispatch test 同步更新
- overview-card-dispatch goto_schedules: page_size 不再期望存在
  (drilldown 用默认值,effective === PAGE_SIZE 分支不塞 page_size)

13 个 test file,394/394 绿,tsc 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
codex 二轮 review 发现 3 处旧语义文案:
- event-dispatcher.ts cardActionKey 注释仍写「drilldown uses 5/page
  while standalone stays on 10/page」,已不成立。改为泛化说明:
  origin 区分导航上下文,page_size 留作未来 override 时的 dedupe 信号
- sessions-card.test.ts overview drilldown 块头注释 "no longer uses
  10/page" → 改为 "standalone and drilldown both use default 5/page;
  origin only controls 返回总览"
- overview-card-dispatch.test.ts goto-thread 注释删除 "pageSize=5"
  字样,明确说明现在 drilldown 不再 override page size

无行为改动,3 个相关 test file 194/194 仍绿;tsc 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
用户反馈:Overview 卡片没有 Groups 和 Workflows 的入口。codex 拍板
slice:overview 一级卡补这两个入口按钮,复用刚定下来的 drilldown
pattern(origin=overview 透传 + 返回总览 + select_static 跳页)。

src 改动:
- overview-card.ts:新增 OVERVIEW_ACTION_GOTO_GROUPS / GOTO_WORKFLOWS
  常量,替换 placeholder 文字为 section + 入口按钮;两个 callback
  分别 GET /__daemon/groups-matrix 和 /__daemon/workflows-runs-snapshot?all=1,
  失败 toast-only 不渲染空卡;rebuild 时传 origin: 'overview'
- workflows-card.ts:opts 加 origin?/pageSize?;select_static 跳页
  (totalPages > 2 && <= 50);footer 返回总览;refresh/page/detail/
  back-to-list/cancel 全部 thread origin;detail card 自身不渲染返回
  总览但 back/cancel value 透传 origin;handler 支持 action.option
  fallback 解析 jump-page
- groups-card.ts:opts 加 origin?/pageSize?;select_static 跳页;
  footer 返回总览;page/refresh thread origin;handler 同样支持
  action.option fallback
- i18n zh+en:移除 workflows_placeholder / groups_placeholder(已
  实现入口),新增 workflows_section / groups_section / goto_workflows
  / goto_groups + workflows.jump_page / groups.jump_page

测试覆盖:
- workflows-card.test.ts +392 行:drilldown describe(default/override
  page size、origin footer、no-origin standalone、nav 透传、select_static
  边界)+ detail back/cancel nav 透传 + handler select_static fallback +
  origin propagation through refresh/back-to-list/detail
- groups-card.test.ts +184 行:相同 pattern,更简单(无 detail card)
- overview-card.test.ts:action rows 数从 4 → 6(goto sessions/schedules/
  settings/groups/workflows + refresh)
- overview-card-dispatch.test.ts +76 行:goto_groups / goto_workflows
  端到端,验证子卡含 5/page、返回总览、origin、?all=1(workflows)

10 个 test file,410/410 绿,tsc 通过。

工作流编排:workflows-card + groups-card 改动并行(独立文件 +
独立测试),主 loop 串行做 i18n + overview-card + overview-card-dispatch
test,最后统一 build + restart。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
codex review 阻塞:dashboard-i18n-c5.test.ts 还要旧的:
- card.dashboard.overview.groups_placeholder
- card.dashboard.overview.workflows_placeholder
但 placeholder 已替换为正式入口 zh/en 里删了,导致 i18n 一致性测试 fail。

更新 REQUIRED_KEYS:
- 删 groups_placeholder / workflows_placeholder
- 加 overview.groups_section / workflows_section
- 加 overview.goto_groups / goto_workflows
- 加 overview.back_button(前面 slice 落地的,REQUIRED_KEYS 漏加)
- 加 sessions.jump_page / schedules.jump_page(同上)
- 加 workflows.jump_page / groups.jump_page(本轮落地)

en.ts 顺手补齐 workflows.jump_page / groups.jump_page(之前 sub-agent
写 workflows-card / groups-card 时只动了 zh.ts,en.ts 漏了)。

378/378 绿,tsc 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
用户反馈:飞书 detail 卡只有「关闭 / 返回」,缺 Web Dashboard 的
「定位话题 / 终端 / 关闭 / (closed: 恢复)」。codex 拍板 slice 2b 收紧。

实施:
- src/im/lark/sessions-card.ts:
  - 新增 SESSIONS_ACTION_LOCATE / SESSIONS_ACTION_RESUME 常量
  - BuildSessionsDetailCardOpts 加 terminalUrl? / feishuChatLink?
  - buildSessionsDetailCard 重写 action row 为 4 按钮:
    locate / terminal / (active=close OR closed=resume) / back
    * locate scope=chat → multi_url(feishuChatLink) 直跳;
      scope=thread → callback action=dash_sessions_locate
    * terminal → multi_url(terminalMultiUrl(url));无 webPort 时 disabled +
      noPort reason note
    * close/resume 互斥:closed → resume(绿色 primary + confirm dialog);
      其他 → close(红色 danger + confirm)
  - handler validActions 加 LOCATE / RESUME;DETAIL/CLOSE/RESUME 计算
    terminalUrl + feishuChatLink 传给 builder
  - LOCATE handler: sync POST /__daemon/sessions/:id/locate → toast-only
    success/error,不重绘
  - RESUME handler: pre-GET + composeDetail matrix 复核(安全 gate)→
    POST resume → 2nd GET fresh row → fallback synth{status:'idle'};
    新卡反映 post-resume status
  - 新增 buildSessionTerminalUrl() helper:proxyPort 优先(+/s/sessionId),
    fallback webPort,用 config.web.externalHost
- src/im/lark/card-builder.ts: terminalMultiUrl 由 internal 改 export
  (无行为变化,仅供 sessions-card 复用)
- src/i18n/zh.ts + en.ts: 加 10 个 key(btn.locate/terminal/resume、
  confirm.resume.{title,text}、locate.success/_failed、resume_failed、
  terminal.disabled.noPort、resume.disabled.onlyClosed)
- test/dashboard-i18n-c5.test.ts: REQUIRED_KEYS 同步

测试新增(test/sessions-card.test.ts):
- builder slice 2b: active 卡 4 按钮序 / closed 卡 resume 替代 close /
  terminal noPort disabled / chat scope multi_url(feishuChatLink) /
  thread scope callback / origin=overview thread locate+resume value
  carries origin
- handler LOCATE: happy 成功 toast / 429 cooldown error / POST throws /
  missing session_id
- handler RESUME: happy 3-call sequence (GET+POST+GET) → fresh card /
  active state replay matrix 拦截 0 POST / POST 500 error / 2nd GET
  vanished → fallback synth / missing session_id

测试更新:
- "disabled close (closed status)" → "closed status → resume replaces close"
- close happy → 期望 dash_sessions_resume 出现(而不是 disabled close)
- sessions-card-dispatch close 同步更新

10 个 test file 822/822 绿,tsc 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ide 复核

codex 二次 review 2 个 blocking。

**Blocker deepcoldy#1 — closed session 可能展示可点终端(死链)**
根因:daemon 的 closeSession 只翻 status='closed',不清 webPort/proxyPort,
但 PR1 composeDetail 的 openTerminal 只看 `webPort != null`,结果 closed
detail 卡片可能渲一个可点 multi_url 终端按钮 → 链接已死。

修复(三层防线):
- src/dashboard/session-card-model.ts:composeDetail openTerminal 加
  `!isClosed` gate(matrix 层)
- src/im/lark/sessions-card.ts:buildSessionTerminalUrl 加 status==='closed'
  → null(fence line URL builder)
- src/im/lark/sessions-card.ts close handler synth 清掉 webPort:null +
  proxyPort:undefined(卡片数据本身一致)

**Blocker deepcoldy#2 — locate callback 缺 server-side scope 复核**
builder 对 chat-scope 用 multi_url(feishuChatLink),正常路径不发 callback;
但 hand-crafted / 旧卡 callback 仍可能命中 LOCATE handler,会把 thread
POST 打到 chat-scope row(错位 @mention)。

修复:LOCATE handler 加 pre-GET + composeDetail,只有
`locateMode==='openTopic'` 才 POST;'openChat' 直接 toast-only
`locate_failed reason=chat_scope_not_supported`,POST 0 次。

**Cleanup**
- 顶部注释更新到 slice 2b 实际形态(locate/terminal/resume/close/back +
  multi_url + 安全 matrix 复核)
- close synth 的旧 `origin: navOrigin` 重复(之前已被 locate 改动覆盖
  消失,无需额外动作)

测试:
- builder regression:closed 行带 stale webPort 仍 terminal disabled,
  无 multi_url
- close happy synth:rebuilt card 无 multi_url + 有 noPort note
- locate chat-scope crafted callback → toast 含 chat_scope_not_supported,
  0 POST
- locate happy 改为 2 calls (GET + POST),重命名为 (thread-scope)
- locate vanished row → session_not_found, 0 POST

6 个 test file 635/635 绿,session-card-model 11/11 绿,tsc 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
codex 拍板的 slice:用户语义上 \`/dashboard\` 是 Bot Owner 的全局
工具面板,不应按触发 bot 过滤。先收紧到 schedules 模块;sessions/
workflows/groups 随后按同模式补齐。

Route B 侧(daemon-internal-api.ts):
- /__daemon/schedules-list?scope=global → 跳过 scopeByCaller,返
  cross-bot rows
- /__daemon/overview-snapshot?scope=global → schedules 跨 bot
  (sessions/workflows/groups 仍 per-bot 直到自己 slice)
- POST /__daemon/schedules/:id/(run|pause|resume)?scope=global →
  bypass cross-bot 403,proxy 到 row TRUE owner;legacy 三态
  保留(owner→proxy owner / legacy→fallback caller / missing→404)
- 未识别 scope 值(typo) → 回落 per-bot,避免意外放宽

UI 侧:
- schedule-card-model.ts: ScheduleCardTaskInput 加 botName?
- schedules-card.ts:
  - opts 加 scope?: 'global'
  - navFields 加 dashboard_scope=global,thread 到所有 button.value
    (page/refresh/detail/back/pause/resume/jump select)
  - safeGetSchedulesList 接 pathSuffix;handler 解析 dashboard_scope
    后给 GET 加 ?scope=global,POST 同样
  - renderRow scope=global 时显示 bot label(botName 优先,回落
    bot:<larkAppId 后 6 位>)
- overview-card.ts: goto_schedules + rebuildOverview 改用
  ?scope=global,goto_schedules 传 scope: 'global' 给 builder
- dashboard-command/schedules.ts: 入口注入 scope='global' 给
  buildSchedulesCard + GET 加 ?scope=global
- event-dispatcher.ts: cardActionKey 加 dashboard_scope 避免
  global 和 per-bot 同时打开同模块时 dedupe 误吞
- i18n zh + en: 加 schedules.bot_label '🤖 {bot}'
- dashboard-i18n-c5 REQUIRED_KEYS 同步

测试:
- daemon-internal-api:
  - schedules-list ?scope=global → 全 rows (cli_a + cli_b + legacy)
  - ?scope=globalish typo → 回落 per-bot(无 surprise widen)
  - overview-snapshot ?scope=global → schedules 跨 bot,
    sessions 仍 per-bot(slice 边界回归保护)
  - schedules write ?scope=global × 3 verb × 3 case:
    owner+cross-caller → proxy owner(不 403),legacy → fallback caller,
    missing → 404;typo scope → 403 回归
- schedules-card:
  - scope=global → 每个 child button.value 含 dashboard_scope=global
  - scope=global → 行显示 botName
  - scope=global + 无 botName → bot:<larkAppId 后 6 位> fallback
  - 无 scope → 无 bot label + 无 dashboard_scope(回归保护)
- overview-card / dispatch 路径同步更新 ?scope=global

9 个 test file 881/881 绿,tsc 通过。
sessions/workflows/groups 全局化后续补齐。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
codex 复核 blocker:之前只改了 overview card refresh callback 的 URL
为 ?scope=global,但首次打开 /dashboard 的 command path
(handleDashboardOverview) 仍是裸 /__daemon/overview-snapshot →
首次开卡片仍按 callerAppId 过滤,刷新后才变 global。用户语义是
/dashboard 本身全局,首次开必须立刻全局。

修:
- src/core/dashboard-command/overview.ts:57 GET 路径加 ?scope=global
- 顶部 docblock 同步说清楚:scope=global 让 schedules 跨 bot;
  sessions/workflows/groups 仍 per-bot(待后续 slice)
- test/dashboard-overview-command.test.ts happy path 锁住 ?scope=global
- test/dashboard-schedules-command.test.ts happy path 补 request
  path 断言 ?scope=global(之前只断 calledOnce,没锁入口 scope,
  容易让 standalone /dashboard schedules 入口悄悄回落)

6 个 test file 241/241 绿,tsc 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- oncall 绑定表单提交按钮改裸 action_type=form_submit(对齐 role 表单 + CLAUDE.md 移动端指引),同步更新测试断言
- 删本 PR 引入的 dead import buildSessionClosedCard(card-handler / command-handler)
- 移除无关改动:删调试脚本 scripts/daemon-internal-curl.sh、还原 game/index.html 末尾空白行

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applySettingsWrite 为 async(locale reload 需 await),但测试未 await,导致 r 为 Promise、r.ok 恒为 undefined,14/17 用例失败。属 PR 既有问题(之前 dashboard/card 测试过滤未覆盖此文件,故 codex 与我均漏检)。生产调用方(dashboard.ts:910 / daemon-internal-api.ts:362)已正确 await,仅测试需修。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zhengkaidi123 zhengkaidi123 requested a review from deepcoldy as a code owner June 21, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants