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

112 lines
17 KiB
Markdown

# 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`) | **已修** db5352e |
| 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` | 部分:W2-0 提供 `capabilitySpec(id)`,各模块在 `registerCapabilities` 里注册;注册数断言留给 W2-D |
| 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/` | **已修** 42ecc93(映射集中在新包 `@umt/mapping`,domain 未改名) |
| 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 | **已修** 9858b84 |
| 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` 入口 | **已修** fa92210 |
| 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 时同步删除 | **已修** fa92210 |
| 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 映射 | **已修** db5352e、da8e53b |
## 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` | **已修** da8e53b:并入 db `0013_tasks_tables`,`migrateTasksTables` 已删除,tasks 测试用 db 迁移器建表 |
| 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 差异。
## 7. W2-0 处理结果(2026-09-27)
W2 打底,目标是让 W2-A/B/C 在互不冲突的前提下并行开工。`deno task ci` 全绿。
| # | 处理 | 提交 |
|---|---|---|
| B3 | contracts 为唯一定义:`CapabilityDescriptor` 增加 `replay`;`CapabilitySpec<I,O>`(描述符 + 真实 zod schema)与 `capabilitySpec(id)`(CAPABILITY_CATALOG + CAPABILITY_IO 组装);删除 `CapabilityDefinition`/`CapabilityRisk`。agent 删除自己的描述类型,注册中心直接收 `CapabilitySpec`,作用域改称 `kind`/`allowedKinds`,approve 类同样必须授权;agent 的 `AgentRun` 端口改名 `AgentRunHandle`,用量改用 contracts `AgentUsageTotals` | db5352e |
| B4 | 未做 `registerAllCapabilities`;改由各模块 `registerCapabilities` 用 `capabilitySpec(id)` 注册,注册数断言留给 W2-D | — |
| B5 | 新包 `@umt/mapping`(只依赖 domain + contracts):下载状态 + 暂停原因、证据等级、媒体类型、身份解析状态、Slot 状态/规则列/评估结果、ParsedRelease ↔ `source_release.parsed`、SiteRelease ↔ source_release、PathBinding、OrganizePlan → 整理记录/文件结果/organize.plan 输出。映射表为 `Record<联合类型, …>`,任一侧增值都会让 typecheck 或测试失败。domain 的 `EvidenceLevel` 没有改名,mapping 里以 `DomainMatchLevel` 称呼 | 42ecc93 |
| B6 | `apps/server/src/modules/` 骨架:`ServerModule`、`ServerContext`、`SERVER_MODULES`(四个占位模块);`bootstrap()` 装配上下文并连接引擎;worker 启动引擎 worker + OutboxRelay + 模块 `start()`;分工规则写在 apps/server/README.md | 9858b84 |
| B7 | `assertSafeToDeleteData({ contentPath, libraryRoots, importVerified })`;`delete({ deleteFiles: true, safety })` 必须逐 hash 附检查输入,不通过抛 `DownloaderError('unsafe_delete')`,请求不发出 | fa92210 |
| B8 | mock qB `complete(hash, { files, writeFiles })` 在 save_path 下写随机内容文件(默认关闭、有总量上限、防路径越界);`deleteFiles=true` 同步删除 | fa92210 |
| B9 | 迁移 `0014_agent_run_event`(主键 (run_id, seq),删 run 级联);contracts `AgentRunEvent`/`AgentRunEventRow` + 行转换 + SSE 映射(`agentRunEventToBusEvent`,tool phase 统一为 started/succeeded/failed/denied);agent `onEvent` 收 contracts 事件、按 seq 串行投递,`firstSeq` 续接序号 | db5352e、da8e53b |
| S2 | tasks 三张表并入 db `0013_tasks_tables` | da8e53b |
注意事项:
- `apps/server` 启动时连接任务引擎,数据库不可达会直接退出;`--role migrate` 顺带迁移 openworkflow schema,`api`/`worker` 分进程时不再各自迁移。
- `ctx.secrets` 仍是占位(调用即抛错),W2-A 在 `modules/auth-settings/provided.ts` 实现;`ctx.capabilities` 暂无 authorize,propose/approve/execute 一律拒绝,W2-D 在 `modules/agent/capability-registry.ts` 接入。
- 引入 @umt/db 后 pg 的 int8 全局解析为 number,依赖 `count(*)` 字符串结果的测试需改用 `Number()`。