Files
UHD_Media_Tool/docs/plan/01-w1-review.md
T
2026-09-27 19:03:08 +08:00

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 差异。