14 KiB
14 KiB
W1 波末独立审查
审查日期:2026-09-27。基线提交 569bb29,审查后提交:608e567(收尾)、31729f2、9310814、c33034a(阻塞项修复)。
对照依据:任务书 00-first-version-plan.md 的 §2 约定、§3.1 第 2-9 条、§5 W2 拆分,以及 REQ v0.7 §2.1 的五条原则。deno task ci 全绿。
0. 结论摘要
- 数据模型:§3.1 第 2 条列的 36 张表都已建好(db 共 41 张),
src/types.ts由迁移反射生成。schema.test和contracts/test/db-sync.test会拦截类型与迁移之间的漂移,这部分一致性良好。 - domain 没有 IO,也没有深路径 import。adapters、testkit、server、web 都没有跨包深路径 import,web 没有手写 DTO。
- 五条原则总体被遵守。例外有三处:qB 删数据缺少防护,跨卷移动曾只按大小判断目标一致(已修),目录扫描出错时仍报 complete。
- W2 的主要风险有两类:
- 类型映射:能力描述有三套定义;domain 和 contracts 的状态枚举不一致。
- 装配缺口:14 个能力都没有注册;server 的
modules/骨架还没有;agent_run 事件没有落库。
1. 阻塞 W2
| # | 问题 | 位置 | 修复建议 | 状态 |
|---|---|---|---|---|
| B1 | OutboxRelay 只看 published_at,没有用 db 0002 新增的 status/next_attempt_at/attempts/idempotency_key/last_error。结果是:投递后状态一直停在 pending;失败后每个 tick 都立即重试,没有退避;行上的幂等键也没用上。测试建的表和 db 的表结构不同 |
packages/tasks/src/outbox-relay.ts |
按 ADR-002 §2.1 对齐:认领 pending 且到期的行;成功置 dispatched;失败按指数退避重试,达到上限置 dead | 已修 31729f2 |
| B2 | contracts 没有 release.resolved 事件。W2-B 负责发出、W2-C 负责消费,两边并行开发会各自发明一种 payload |
packages/contracts/src/events.ts |
新增 ReleaseResolvedPayload,字段为 sourceReleaseId、observationId、mediaEntityId、contentUnitIds、evidenceLevel |
已修 9310814 |
| B3 | 能力描述有三套定义,且互相冲突:agent 的 CapabilityDescriptor(scope 为 read/preview/propose/execute,risk 为 low…critical);contracts 的 CapabilityDefinition(risk 为 read/preview/write/destructive);contracts 的 CapabilityDescriptor(kind 含 approve,scope 用 ApiScope)。agent 与 contracts 还有同名的 AgentRun/AgentUsage |
packages/agent/src/capability/types.ts:11-33、packages/contracts/src/capability.ts:28-96、packages/agent/src/runtime/port.ts |
以 contracts 的 CAPABILITY_CATALOG + CAPABILITY_IO 为唯一来源,删除 CapabilityDefinition;agent 改为派生描述,重名类型改名(如 AgentRunHandle) |
未修(涉及设计取舍) |
| B4 | 14 个能力一个都没注册,连 stub 都没有。README 示例里的 registerAllCapabilities 并不存在;测试用的是手写 schema |
packages/agent/src/、packages/agent/README.md:56、packages/agent/test/helpers.ts:36-70 |
在 W2-D 开工前(或 W2-A 期间)补上 registerAllCapabilities(registry, handlers),schema 取自 CAPABILITY_IO;加测试断言注册数等于 CAPABILITY_IDS |
未修 |
| B5 | domain 与 contracts 的枚举和字段不一致,server 必须写映射。具体有:下载状态(domain 的 paused_by_threshold/paused_by_user/error/missing_files 对 contracts 的 paused+PauseReason/stalled/failed/missing/submitting/importing/imported);证据等级(needs_confirm 对 needs_review/manual,且两边都叫 EvidenceLevel);身份结果(auto/ambiguous/no_match 对 resolved/proposed/ambiguous/unidentifiable);PathBinding 字段名(entityId/scope/season/templateVersion:number/active 对 mediaEntityId/bindingKind/seasonNumber/templateVersion:string/status);媒体类型(movie/tv 对 movie/series/variety/…) |
packages/domain/src/download-state/index.ts:2、domain/src/release-parser/types.ts:48、domain/src/identity/match.ts:7,19,158、domain/src/path/binding.ts:10-19、contracts/src/execution.ts:131、contracts/src/identity.ts:12、contracts/src/assets.ts:152 |
下载状态以 contracts 为准,domain 改为输出 status + pauseReason;domain 的 EvidenceLevel 改名为 MatchLevel;在 contracts 测试里加一条 domain↔contracts 枚举对照测试;字段映射集中写在 apps/server/src/mappers/ |
未修(需要定方向) |
| B6 | apps/server/src/modules/ 骨架和模块列表还没有。http/app.ts 的路由是硬编码;worker/index.ts 只有心跳,没有接入 tasks 引擎,注释"W1 接入"已过时。A/B/C 并行开发时会在 app.ts/worker 上发生冲突 |
apps/server/src/http/app.ts:98-113、apps/server/src/worker/index.ts:12 |
W2 开工第一件事:先定好 ServerModule { registerRoutes(app, deps); registerTasks(registry, deps) },在 app.ts 和 worker 里各留一个模块数组,并把 TaskEngine 和 OutboxRelay 接进 worker |
未修(接口签名属于设计) |
| B7 | qB delete(…, { deleteFiles: true }) 没有任何防护。注释里说的 assertSafeToDeleteData 在全仓库都找不到。接管任务的 save_path 如果落在媒体库内,会连带删掉库文件,违背 REQ §2.1 第 5 条 |
packages/adapters/src/qbittorrent/client.ts:164,589-593 |
实现 assertSafeToDeleteData:检查 content_path 不在任何 library_root 下,已验证入库,且没有被 asset_location 引用。W2-C 的所有删除都只经过一个 safeDeleteTorrent 入口 |
未修 |
| B8 | mock qB 的 complete() 只改进度,不在磁盘上生成文件;带 deleteFiles 删除时也不删文件。这样"下载完成→Copy 入库"的 e2e 没有源文件可用 |
packages/testkit/src/qbittorrent/mock-qb.ts:91,161-171,402-405 |
增加 materializeTo(root) 或 complete(hash, { writeFiles: true }),按 save_path/files 写出临时文件;deleteFiles 为 true 时同步删除 |
未修(W2-D 前补) |
| B9 | agent_run 事件没有落库路径。README 提到的 agent_run_event 表不存在,events() 只在内存里回放;agent 的事件类型(tool_call_start/end/rejected)与 contracts 的 AgentToolCallPayload.phase(started/succeeded/failed/denied)对不上 |
packages/agent/src/pi/runtime.ts:38、packages/agent/src/runtime/port.ts:16-45、packages/contracts/src/events.ts |
W2-D 新增迁移 agent_run_event(run_id, seq, type, payload),唯一键为 (run_id, seq),并统一 phase 映射 |
未修 |
2. 应修
| # | 问题 | 位置 | 修复建议 |
|---|---|---|---|
| S1 | 跨卷移动时,已存在的目标只按大小比较就判为一致,随后删源(同尺寸、不同内容会丢数据) | packages/adapters/src/fs/move.ts |
已修 c33034a:预先存在的目标强制用 sha256 比较,并补了测试 |
| S2 | tasks 自带的三张表(tasks_run_meta/tasks_fact/tasks_op_ledger)仍走独立迁移器,没有并入 db,types.ts 里也没有这些表 |
packages/tasks/migrations/0001_tasks_tables.ts |
W2-A 在 db 新增 0013 迁移并入这三张表,migrateTasksTables 只保留给测试用 |
| S3 | tasks_op_ledger 与 asset_operation 职责重叠,两者都有 attempt_token 和 attempts |
packages/db/migrations/0009_execution.ts、packages/tasks/src/op-ledger.ts |
建议不合并表,按层分工:tasks_op_ledger 只做通用 fencing,op_key 等于 asset_operation.operation_id(或 download_task:<id>:submit);asset_operation 是业务时间线和结果的事实来源,在 withAttemptToken 的 commit 回调里同事务更新。W2-C 开工前写进 ADR-002 |
| S4 | domain 下载状态机的 ALLOWED 允许除 removed 外任意两个状态互转,等于没有约束 |
packages/domain/src/download-state/index.ts:97 |
随 B5 一起收紧迁移表 |
| S5 | organize/plan 在调用方没传 existingTargets 时,按"目标不存在"处理(违背"失败≠不存在") |
packages/domain/src/organize/plan.ts:39,162 |
改为必填,或者不传时输出 hold |
| S6 | scanIncremental 在 readdir/lstat 出错时只记录 errors,status 仍为 complete,上层可能把读不到的目录当成缺失 |
packages/adapters/src/fs/scan.ts:81,104,144 |
errors 非空时 status 置为 partial |
| S7 | TMDB 的 v3 api_key 放在 query 里,网络错误时 e.message 会带着完整 URL 拼进错误信息 |
packages/adapters/src/tmdb/client.ts:165,176 |
错误信息先经过 redact,或者只用 Bearer |
| S8 | 凭证接入方式不统一:站点走 SecretResolver,qB、TMDB、Emby、RSS 都是明文参数;脱敏实现有三份(adapters/rss、agent、server/logger) |
packages/adapters/src/rss/redact.ts、packages/agent/src/redact.ts、apps/server/src/logger.ts:11-40 |
在 contracts 或新的共享模块里只保留一份 redact(补上 pass 和裸 c_secure_pass= 两类);W2-A 的 secret 服务统一对外提供 resolver |
| S9 | adapters 的类型与 contracts 同名但含义不同:SiteAdapter/SiteHealth、SiteKind(mteam/api 对 mteam_api/generic_rss)、凭证 apiKey 对 api_key、熔断状态缺 half_open、MediaKind 为 movie/tv/music |
packages/adapters/src/sites/types.ts:109,122,174、adapters/src/rss/types.ts:474、adapters/src/sites/spider/spider.ts:29 |
adapters 里的端口类型加 Port 前缀,并在 adapters 提供 toContract* 映射函数 |
| S10 | 自动化总开关、audit_event 写入、审计查询在 server 里都还没有;agent 的 authorize 也没读总开关 | apps/server/src、packages/agent/src/capability/registry.ts |
由 W2-A 实现,并把总开关放进 authorize 的第一道检查 |
| S11 | SSE 总线是进程内的环形缓冲。--role api|worker 分开跑时会丢事件,重启后序号也会断 |
apps/server/src/http/sse.ts:111-141 |
已列入 W2-A:改为 PG NOTIFY + 持久化 seq |
| S12 | 模型可以直接传 approvalId |
packages/contracts/src/capabilities-io.ts:95,121 |
authorize 要校验 approval 与 plan、payloadHash、expectedRevision 一致,并补一条拒绝用例 |
| S13 | agent 默认幂等键不含任务或计划的上下文,跨会话的相同输入会被去重 | packages/agent/src/capability/registry.ts:51 |
有副作用的能力改用 planId/taskId 生成幂等键 |
| S14 | RSS 遇到 401/403 统一归为 http_error,passkey 失效无法单独识别 |
packages/adapters/src/rss/fetch.ts:192 |
新增 auth 错误类别,来源健康状态里单独展示 |
| S15 | agent README 描述的装配方式和实际不一致(见 B4、B9) | packages/agent/README.md:46-61 |
随 B4/B9 一起更新 |
3. 建议
hasScope中 organize.execute 不包含 read,持这类 key 调 whoami 会得到 403(apps/server/src/http/auth.ts:82)。建议对照 REQ 确认 scope 的包含关系。- web 端把 API Key 存在 localStorage,fetch 没有带 credentials(
apps/web/app/lib/api.ts:6-15)。W2-A 改用会话 cookie 时要一并调整。 - adapters 和 testkit 的 README 只有三行,testkit 的 README 没提已经导出的 Emby mock。
- mock NexusPHP 没有 RSS 端点,e2e 需要把 mock RSS 的 enclosure 手动串到 download.php。testkit 里有两套 bencode 实现(
sites/torrent.ts、qbittorrent/bencode.ts),可以合并。 packages/adapters/src/sites/spiders/testing.ts放在 src 下,却 import 了@umt/testkit,建议移到 test 目录。- domain 的身份匹配要求本地候选也必须带 externalId,"本地优先"只是平局时的规则。建议以
localEntityId作为第一键(原则 1)。 - contracts 的能力目录测试只比对了 input,没有比对 output。"所有 schema 正反例"这条测试偏冒烟。
- qB 没有"接管"的高层接口,W2-C 需要用 list+addTags 自己组合。
4. 五原则核对
| 原则 | 现状 |
|---|---|
| 身份≠名字 | 表都用 uuid 主键。external_identity 的 (provider, type, id) 在 accepted 状态下部分唯一。media_entity 合并时通过 merged_into_id 跳转。domain 的"本地优先"做得不彻底(见建议) |
| 路径绑定固定 | path_binding 表和 domain/path/binding.ts:94 都优先使用已有绑定,出现多个历史目录时返回 conflict,测试覆盖了"改译名后仍用原目录" |
| 模型不是事实唯一来源 | registry 在没有 authorize 时拒绝 propose/execute,模型自称"已同意"会被拒绝(有测试)。风险是 approvalId 校验还没实现(S12) |
| 失败≠不存在 | TMDB、Emby、站点、statSafe 都能区分错误和不存在(Emby 返回 unknown,站点抛 auth_expired,挂载离线返回 root_offline)。例外:S5、S6 |
| 清理按明确授权 | 缓存清理的授权逻辑还没实现(W2-C)。qB 删数据没有防护(B7);跨卷删源已改为 sha256 核验(S1) |
5. 抽查测试(10 个)
| 用例 | 结论 |
|---|---|
| domain slot 状态机 | 有效:覆盖主路径、升级、attention、部分覆盖和 11 条非法迁移 |
| domain 路径绑定固定 | 有效:改译名后返回 existing,多个目录时返回 conflict,停用的绑定被忽略 |
| domain 身份歧义 | 有效:同等级不同身份判为 ambiguous,弱证据和无候选分支都有断言 |
| contracts 能力目录覆盖 14 个 | 部分有效:只比对了 input schema |
| contracts 全 schema 正反例 | 偏冒烟 |
| agent registry 授权拒绝 | 有效:模型自称已同意时被拒,handler 没有执行,审计里记为 denied |
| agent fake provider 闭环 | 有效:断言了事件顺序、seq、JSONL 和审计,但 schema 不是取自 contracts |
| server auth | 有效:覆盖 401、撤销、403 和生产环境禁用 dev key;SSE 鉴权没有覆盖 |
| adapters RSS 退避/熔断/去重 | 有效,并断言了错误信息里不含 passkey |
| adapters fs 跨卷 | 有效:EXDEV 不降级,目标不一致时不删源。同尺寸的情况已在 c33034a 补上;以 root 运行时,"删源失败"这条用例会被跳过 |
| tasks outbox | 修复后覆盖了并发下不重复投递、dispatched 状态、退避和 dead |
6. 收尾项说明
任务里说的"sites/mp-sync 有 9 处相对 import 缺扩展名",在基线里已经不存在了:no-sloppy-imports 零命中,deno lint 报的 9 条都是 require-await。所以这次收尾只做了以下几件事:删除 biome override;删除 --unstable-sloppy-imports;mp-sync 改为 deno shebang 并加可执行位;把 YAML 改成静态 import;更新 README 里的调用命令。check.mjs --to e6820a8 结果为 49 个未变,movieclaw-import --check 结果为 0 差异。