diff --git a/docs/sessions/261007-notify-slim/repair-rounds-disposition.md b/docs/sessions/261007-notify-slim/repair-rounds-disposition.md new file mode 100644 index 00000000..6b9cfaf9 --- /dev/null +++ b/docs/sessions/261007-notify-slim/repair-rounds-disposition.md @@ -0,0 +1,41 @@ +# 通知修复轮(r1/r2)分支处置记录 + +261007-notify-slim 修复轮产出两个远程分支,分支删除前在此归档处置结论, +删除后不丢任何决策上下文。 + +## r1:origin/card/VideoTranscriptAPI-261007-notify-r1(顶提交 1f25f4ea) + +**分支做的事**:成功终态缺持久化 result 时,`_render_success_body` 由 raise +改为打 `COMPLETION-BODY-MISSING` 警告并按 `completion_body=None` 渲染 +(仅抬头 + 查看链接),照常发送并标记已发送,不再每轮补发渲染失败无限重试; +补发路径对成功行不再拼接 completed_at 错误注记(避免渠道侧把成功消息标成 +失败表情);channel.py 注明超长正文分段由 wecom_notifier 库负责。 + +**main 现状:已被等价实现覆盖并演进**(`src/video_transcript_api/api/services/terminal_status.py`): + +- `_render_success_body`(约 267 行起)保留 `COMPLETION-BODY-MISSING` 警告与 + 「missing result 确定性缺失、不为 body 重试;其他渲染异常仍传播并保持待发」 + 语义;降级产物从「`completion_body=None` 纯抬头+链接」演进为显式降级文案 + 「⚠️ 总结未能载入,请在网页查看」+ 查看链接,空 dict 也纳入 missing(N2)。 +- `_compose_dispatcher_error` 成功行返回 `None` 原样保留(约 469-470 行)。 +- 行为由 `tests/unit/test_terminal_notification_outbox.py` 的 + `COMPLETION-BODY-MISSING` 断言(缺 result、非 dict result、空 dict result + 等用例)锁定。 + +## r2:origin/card/VideoTranscriptAPI-261007-notify-r2(顶提交 9f68fe7f,叠在 r1 之上) + +**分支做的事**:飞书 interactive 卡片超预算时在本层手写分段——新增 +`FEISHU_CARD_MAX_BYTES = 18000` 与 `_split_feishu_card_content`(按空行段落 +贪心打包,单段超限按 UTF-8 字符边界硬切),`send_rich` 拆成多张卡片发送, +第 2 张起标题加 "(i/n)" 后缀。 + +**main 现状:该路线已被显式否决**。`src/video_transcript_api/utils/notifications/channel.py` +约 234-235 行注明:「本层不截断 completion_body;分段由 wecom-notifier 库负责; +飞书 interactive 卡片暂不分段,企业微信超长单行有边界缺陷,见 +zlxlabs/wecom-notifier#2 #3、本仓 #195」。r2 的手写分段方案不再复活; +飞书分段若未来要做,应在 wecom-notifier 库侧实现。 + +## 结论 + +r1 的三个意图点均已在 main 有等价实现(部分演进增强)并有测试锁定; +r2 所代表的分段路线已被 main 注释显式否决。两分支可安全删除。 diff --git a/docs/sessions/261008-local-upload/diagnosis/F-local-consumers.md b/docs/sessions/261008-local-upload/diagnosis/F-local-consumers.md new file mode 100644 index 00000000..7736d361 --- /dev/null +++ b/docs/sessions/261008-local-upload/diagnosis/F-local-consumers.md @@ -0,0 +1,105 @@ + +# F:固定 H1 上传工具候选的有界真实诊断 + +## 结论 + +- A(同一恢复对象的消费者门槛):实测支持 gate 生效。原恢复测试只锁了 gate=false 的拒读及清理保护;没有同一个 restored cache/token/body 的 gate=true 可读对照。独立 H1 scratch 中,真实 CacheManager producer 生成 snapshot、撤销源记录、恢复 snapshot,再由 fresh Resolver 对同一恢复目录和同一 token 做 true/false 对照:true 读回正文,false 返回 NONE 并记录 `UPLOAD_DISABLED`;一个无效 token 的最小反例返回 NONE。清理后 never 正文与 root 仍在。 +- B(容量 CLI 等待自己的 server ready):未启动六次计划运行。官方 scratch 的网络命名空间前置检查被内核以 `Operation not permitted` 拒绝;没有尝试在可外连的宿主网络上运行 CLI/pytest。故这不是 CLI 失败,PID、实际绑定端口、socket/ready 时序及 stop callback 执行后状态均未知,根因仍未知。 +- 本报告任务结果为 `failed`:B 是本卡主要取证对象,因环境限制未完成;这与软件 pass/fail 正交。A 的实测成功不替代 B,也不证明工具稳定。没有产品代码、测试、配置或目标外文件改动;未启用生产上传。 + +## 基线与隔离 + +- 固定源:H1 `13e78042ac4ff97ae0fd42fa15031848259f985d`。目标分支:`card/vta-upload-F-diagnose-261009`。目标报告此前不存在。 +- 诊断开始前的官方 scratch manifest:`$HOME/scratchpad/vt-QHD1cx/worktree`,HEAD=H1,669 个 tracked file;`data`、`liveconfig`、`.venv`、`.pytest_cache`、`config/config.jsonc` 均不存在;无 tracked symlink、无外链,和 main/原工作树 tracked inode 交集均为 0。`git show H1:` 检出的白名单源 blob 与上列固定 SHA 对应。 +- A 的实测在另一个官方 scratch:`$HOME/scratchpad/vt-Fu6wa1/worktree`,HEAD=同一 H1;测试/CacheManager/Resolver 的运行时 `__file__` 均指向该 scratch。独立 venv 由 `uv sync --frozen --offline` 创建,Python 3.11.15,离线安装 71 个锁定包;无外网访问。producer DB、正文、审计库均位于本次专用 `/tmp/vta-upload-f-diag-20261009/restore-pair-k0myhwxl/`,不在业务树。 +- A 有两次未进入 producer 的准备失败,均保留并与软件结果分开:第一轮临时 harness 漏加 repo root 到 `sys.path`,报 `No module named 'src'`;修 harness 后系统 Python 3.12 环境缺 `loguru`。随后使用上述 scratch 独立离线 venv 完成唯一一次匹配 producer/consumer 对照;没有重复成功运行。 +- B 前置检查在官方 H1 scratch、`env -i` 裸环境执行 `unshare -n` 并尝试启用该 namespace 的 loopback;命令退出码 1,错误为 `unshare failed: Operation not permitted`,未创建网络命名空间。`uv sync --frozen --offline` 可用,故已区分出已验证的 namespace 阻塞与可用的依赖环境。`strace` 未调用;不把本机有该程序误作诊断证据。 +- 主干基线 API 在派发时不可用(卡面:`gh api request failed`)。继承红状态未能判定;本卡没有新增 CI run/job 结论。 + +## A:恢复对象与同对象 on/off 消费 + +### 原测试实际锁定范围 + +H1 `tests/integration/test_upload_restore.py` blob `b0b6ae6f2237e74b9a2d8ed059d02428bd90cffa`: + +- `test_snapshot_restore_keeps_external_gate_off_and_protects_long_result`(101 行起)调用 `_new_upload`、`_write_real_result`、`_age_real_consumers`(24–62 行);在撤销前复制 producer cache 与正文(108–114 行),对源 cache 撤销后把该 snapshot 复制为 restored cache(116–126 行)。 +- restored cache 的记录断言 `revoked_at is None`、`retention == "never"`,但 fresh 子进程只以 `VTA_UPLOADS_ENABLED=false` 调 `_resolver_process`;测试断言 `RESULT=NONE`、`BODY=NONE`、`UPLOAD_DISABLED`(128–139 行)。因此它缺少同一恢复对象、同一 token、同一正文的 true 可读对照。不同 cache、旧源码消费者或其他测试中的正例均不能补成这个同对象对照。 +- 测试随后断言 `cleanup_old_cache(days=1) == 0`、正文仍在、`cleanup_task_status(...) == 0`、root 仍在(141–148 行)。 + +### 本次真实匹配对照 + +- 使用上述 H1 测试文件中的真实 producer helper 生成本地 upload、`save_cache` 正文及 root;正文为 36 bytes,SHA-256 `89861627bcb6997502cb61976eee2f6e0ce22a0c691d415dc14b6735e5c155cc`。 +- `pre-revocation-snapshot/cache.db` 与恢复后的 `restored-cache/cache.db` 都是 98,304 bytes,SHA-256 均为 `1e0362bd41abd4e666de5776f0c4bf2b0eb98cf6fdf0180e9febd7231e2cc0fb`;恢复记录 `revoked_at=null`、`retention=never`。正文恢复路径为 `local_upload/2026/202610//transcript_capswriter.txt`,恢复后字节与 producer 完全一致。 +- true 与 false 均各启动 fresh Python 子进程,argv 指向同一个 `restored-cache`;token 均为本次合成 token,SHA-256 `e5626833c40cca7c9558020b99e3edc76f12d05a2008731b01c49a2a857c74f0`。子进程环境只在 `VTA_UPLOADS_ENABLED` 上分别设 `true` / `false`,`PYTHONPATH` 指向实际 H1 scratch 的 `src`;未输出原始 token。 + +| 新鲜消费者 | gate | 退出码 | Resolver 实际结果 | 来源/判据 | +| --- | --- | ---: | --- | --- | +| 同 restored cache/token/body | true | 0 | `RESULT=success`,`BODY=Producer-created restore transcript.` | fresh Resolver;CacheManager 与 Resolver `__file__` 均映射到 H1 scratch | +| 同 restored cache/token/body | false | 0 | `RESULT=NONE`,`BODY=NONE`;可 grep `UPLOAD_DISABLED` | 同目录、同 token SHA、同正文字节 | +| 最小反例:同 cache 的无效 token | true | 0 | `RESULT=NONE`,`BODY=NONE` | 一个最小真实反例;token SHA 与正例不同 | + +Resolver 运行时来源 SHA-256:CacheManager `082210a0aacfda08fa22ed13512bdb67096b099fbcf1da6594e1cb6131509243`(H1 blob `7afdfecf0697e618f7819202810390992ae13fe8`);`view_token_resolver.py` `6f699b5f971379c6462316ef40a0afa8eca6072ee9c54ea2fab5cede980083a2`(H1 blob `a767da1daa4f56cc3d4cbf7d7fa551887869c30d`)。消费进程执行的 `ViewTokenResolver(cm).get_view_data_by_token(token)` 与原 helper 的核心调用相同,额外只打印来源路径/hash、gate、token hash 和结果,不改请求、等待或清理行为。 + +- 同一 restored CacheManager 随后实际运行两个 cleaner:`cleanup_old_cache` 返回 0、`cleanup_task_status` 返回 0;正文仍与原 36 bytes 相同,root 仍可读取。A 的证据核验器对上述 true/false/invalid 分支、DB hash、正文 hash 与清理结果逐项断言为 PASS。 +- 结论限于单一本地 SQLite+正文对象与当前 H1 Resolver;不代表独立生产恢复域、生产数据或授权。 + +## B:容量 CLI 与聚焦 pytest 的有界运行 + +### 前置阻塞与六次分母 + +运行要求是同一新 H1 scratch、独立离线 venv、`env -i` 裸 shell、无外网网络命名空间;预定冷序列为直接 CLI 与现有 duration=20 聚焦 pytest 交替各 3 次。namespace 检查失败后没有启动任一 producer。下表的 `N/A` 表示未运行,不是退出码 0 或软件失败。 + +| 次序 | 计划对象与 argv | 结果/退出码 | PID/地址/ready/cleanup | +| ---: | --- | --- | --- | +| 1 | `python scripts/perf/local_upload_capacity.py --duration-seconds 20` | 未启动;namespace 前置阻塞;N/A | 无 producer,N/A | +| 2 | `pytest -q tests/integration/test_upload_restore.py::test_local_capacity_probe_exercises_real_http_payload_and_loopback_overlap` | 未启动;namespace 前置阻塞;N/A | 无 producer,N/A | +| 3 | 同次序 1 | 未启动;namespace 前置阻塞;N/A | 无 producer,N/A | +| 4 | 同次序 2 | 未启动;namespace 前置阻塞;N/A | 无 producer,N/A | +| 5 | 同次序 1 | 未启动;namespace 前置阻塞;N/A | 无 producer,N/A | +| 6 | 同次序 2 | 未启动;namespace 前置阻塞;N/A | 无 producer,N/A | + +因此本卡没有 CLI 异常、pytest 退出码、真实 argv/env/config bytes/hash、PID、实际绑定端口、socket 状态、服务就绪信号、请求前后时间戳或 child cleanup 后状态。没有读取或回显服务原始日志;没有慢启动对照,也没有把任何准备失败记成 CLI failure。 + +### H1 源码所能支持的有限事实(不是实测根因) + +- `scripts/perf/local_upload_capacity.py::run_experiment`(238 行起)在 ExitStack 内先建 loopback ASR rejector、合成 config/users/media fixture(257–298 行)。API child 由 `Popen([sys.executable, main.py, --start, --config, config_path], cwd=PROJECT_ROOT, env=clean_env, stdout=service_log, stderr=STDOUT)` 启动(300–306 行);`clean_env` 只显式放入 PATH/HOME/LANG/LC_ALL/TZ/PYTHONPATH/VTAPI_USERS_JSON/VTA_UPLOADS_ENABLED。配置由 tracked 示例派生,API、媒体与 ASR fixture 均指定 loopback;临时配置、用户、日志与 data 位于脚本的 `TemporaryDirectory`。这些是源代码路径,不是本卡某次启动的实际 payload。 +- 固定 `time.sleep(2)` 后只做 `process.poll()`;仍存活才发 `GET /api/uploads/capabilities`(308–324 行)。源码中没有在请求前轮询 listen socket 或以 readiness event 等待。focused pytest 在 `tests/integration/test_upload_restore.py:150–180` 启动同一个 CLI,duration=20,subprocess timeout=90 秒。 +- API child 的 `ExitStack` stop callback 在启动后立即登记(257、307 行)。`_stop_process`(210–213 行)在 callback 执行时先检查 `poll()`,仍存活才 `terminate()`,随后 `wait(timeout=10)`。本卡没有运行到该 callback,因此不能声称实际调用了它、child 在 callback 后已回收,或错误发生在进程退出之后。 +- 所以,固定 2 秒是可检验的启动敏感点,但没有本次 spawn/listen/capabilities/退出时间线,也没有可控延迟 listen 的对照;不能据此宣告它是另一次报告失败的根因。根因保持 unknown。 + +## 边界、后续与产物 + +- 最小后续范围仅是一次有界现场:提供能创建无外网 network namespace 且 loopback 可用的官方 runner,在该环境使用新的 H1 scratch 与离线 venv,按上表原序列执行六次,并在每次只采白名单 PID/phase/地址/时间差/进程退出与 stop callback 后状态。若仍不能提供 namespace,停止在已知环境阻塞,不把宿主网络当替代品、不改生产、不扩展候选机制。 +- 不改代码、不修复测试、不新增 seam;A 证据不能替代完整独审。M4/M5、真实生产容量/恢复域/授权仍未知,上传生产开关保持关闭。 +- 唯一仓库产物:`docs/sessions/261008-local-upload/diagnosis/F-local-consumers.md`。本报告的源证据 SHA 是 H1;最终报告提交 SHA、push remote/ref 与 clean 状态由派发结果回执记录,避免在被提交文件中写入会自我循环的 commit SHA。 + +## Task14续采样:环境参数修正与B实测 + +- 本章是本轮续采样,不改写首部 `delegate-outcome: failed` 或旧 B 的 `0/6`:旧数分母属于前一次 `unshare -n` 环境阻塞事实;本轮新分母另列。A 已完成,本轮没有重跑 A。 +- 本次实际 systemd 消费上下文:euid=1000,cgroup 中 unit 为 `delegate-dlg-20261009-053645-cba701.service`,dispatch 环境匹配本 id。官方 H1 scratch manifest:`$HOME/scratchpad/vt-RTkpnQ/worktree`,SHA=H1、669 tracked files;data/liveconfig/.venv/.pytest_cache/config/config.jsonc 均缺席,无 symlink/外链,与 main/原工作树 tracked inode 交集均为 0。独立 venv 使用 `uv sync --frozen --offline`,Python 3.11.15。路径约定:`$SCRATCH` 指该 H1 scratch,`$EVIDENCE` 指本轮隔离临时域,`$TMP` 指各次 CLI 自有 TemporaryDirectory。 +- 唯一一次修正后的 namespace 命令:`env -i ... unshare --user --map-root-user --net /usr/bin/bash "$EVIDENCE/namespace_sequence.sh"`。namespace 内 UID 映射为 0;`ip link set lo up` 后仅有 lo,地址只有 127.0.0.1/8 和 ::1/128;IPv4、IPv6 route 查询均为空。用户+网络 namespace 在本次真实 systemd unit 成功建立;旧 `unshare -n` EPERM 不能代表此结果。`strace` 未调用,六次 producer 都在此隔离 namespace 中启动或尝试启动。 +- 冷序列按 CLI→pytest 交替各 3 次,duration=20;运行总耗时约 10.409 秒(各命令实测 elapsed 相加),没有重试。第 1 次为无 sitecustomize 的原 CLI;第 2–6 次加载临时观测模块。`VTA_DIAG` 观测代码只在本次外部临时域,不改 H1 source;但其对 Python import 的实际影响如下,故不能把失败混成软件结论。 + +| 次序 | 实际命令/模式 | outer PID | 退出码 / elapsed | producer 与结论 | +| ---: | --- | ---: | --- | --- | +| 1 | `.venv/bin/python scripts/perf/local_upload_capacity.py --duration-seconds 20`(无 instrumentation) | 1144377 | 0 / 4.878s | 完成一次真实容量实验;API child 1144706,见下方 payload/时序 | +| 2 | `.venv/bin/pytest -q --basetemp=$EVIDENCE/pytest-tmp/run2-pytest tests/integration/test_upload_restore.py::test_local_capacity_probe_exercises_real_http_payload_and_loopback_overlap` | 1144829 | 4 / 1.213s | conftest import 阶段被观测代码打断;未运行目标 test、未 spawn API | +| 3 | 同次序 1(instrumented) | 1144886 | 1 / 1.161s | Python import `TypeError`;未 spawn API | +| 4 | 同次序 2(instrumented) | 1144911 | 4 / 0.978s | 同次序 2;未运行目标 test、未 spawn API | +| 5 | 同次序 1(instrumented) | 1144995 | 1 / 1.161s | 同次序 3;未 spawn API | +| 6 | 同次序 2(instrumented) | 1145052 | 4 / 1.018s | 同次序 2;未运行目标 test、未 spawn API | + +### 本轮唯一完整 CLI 运行(#1) + +- 实际 CLI argv:`$SCRATCH/.venv/bin/python $SCRATCH/scripts/perf/local_upload_capacity.py --duration-seconds 20`。outer 环境为 `env -i` 白名单:PATH、HOME、LANG、LC_ALL、TZ、`TMPDIR=$EVIDENCE/tmp/run1-cli`、`UV_CACHE_DIR=$HOME/.cache/uv`、`UV_OFFLINE=1`、VTA_DIAG_RUN_ID/VTA_DIAG_T0/VTA_DIAG_EVENT_FILE;实际 PATH 为 `/usr/bin:/bin:$HOME/.local/bin`。未注入 sitecustomize/PYTHONPATH。脚本启动自己的 `main.py --start --config `,cwd=$SCRATCH。 +- API child 实际 argv:`[$SCRATCH/.venv/bin/python, $SCRATCH/main.py, --start, --config, $TMP/local-upload-capacity-vmgqtky_/sandbox-config.json]`;PID=1144706。实际 child env key/value 白名单:PATH、HOME、LANG、LC_ALL、TZ、`PYTHONPATH=$SCRATCH/src`、`VTAPI_USERS_JSON=$TMP/.../sandbox-users.json`、`VTA_UPLOADS_ENABLED=true`;没有凭据值输出。配置实际读到 5,700 bytes,SHA-256 `621cad4b5a383a5c4ab86b17145aa155e5784e2aabb343906e06a1a0c5bc7f15`;安全字段 `api.host=127.0.0.1`、`api.port=45423`、`capswriter.server_url=ws://127.0.0.1:45315`。仅记录字节数/hash/白名单字段,没有打印配置内容。 +- 启动顺序的外部观测:CLI spawn 为 elapsed=0;API child 首次从 `/proc` 观察到在 1.654s;约 child 首见后 1.952s(CLI elapsed=3.606s)child PID 仍存在且 `127.0.0.1:45423` 为 LISTEN;CLI elapsed=3.659s 观察到首条到该 API port 的 ESTABLISHED TCP。未 instrument 该次的 HTTP 方法/path/response status 或源码 `process.poll()` 返回值;但 CLI 后续真实输出含 `raw_http_receipts=2_of_2`、`controlled_url_upload_overlap=confirmed`、`UPLOAD_ENABLE_BLOCKED`,因此该次原 CLI 自身完成了其 capabilities/上传断言路径。单次成功不证明稳定。 +- CLI 总 elapsed=4.878s 后,API PID 在 observer 的 `/proc` 检查中已不存在;这是 outer 退出后的状态,不等于直接观测到 stop callback。此无 instrumentation 运行没有捕到 `_stop_process` callback 的 after-event/returncode,故不宣称 callback 已调用或以哪个退出码回收;也不把请求前 child 存活解释成退出后泄漏。 + +### 观测器失败与证据边界 + +- 第 2–6 次的临时 `sitecustomize` 把 `subprocess.Popen` 类替换成函数。五次都只产生 `observer_loaded` 事件;CLI 3/5 在导入期间报 `TypeError: function() argument 'code' must be code, not str`,pytest 2/4/6 在 conftest import 阶段报同一 TypeError,退出码分别为上表所列;都没有 API child、config、listen、capabilities request 或 callback 事件。这是本次观测器改变了 import 行为的真实失败,不是原 CLI/pytest 软件结果。未读取 conftest 内容,也不推断具体依赖内部根因。 +- 六次都已消耗;遵守不重跑规则。新分母为 6 次调用,其中 1 次有效 CLI 观察完成,另外 5 次观测器失败且没有抵达 producer。不能把它写成六次原样候选测试,也不能声称六次全绿/全红。 +- 所有实际业务 producer 仍来自原 H1 CLI/test node,临时观测仅在 `$EVIDENCE`;但 namespace sequence 在六次结束后,汇总器因结果结构缺 `service_log_types` 报 `KeyError`,导致 wrapper 退出码 82,脚本末尾预设的 source-diff 命令未执行。该错误发生在六次结果落盘之后,不改其退出码。未对 H1 源文件做写入;不过本轮不能声称末尾 source-diff 检查已通过。 +- 结论:参数修正后的 user+net namespace 在真实 unit 可用;原候选有一条完整 CLI 成功样本,观察到 2 秒附近已有 loopback listener,之后确有本机 TCP 连接,outer 退出后 PID 不存在。但 request path/status、真实 `poll()`、callback after 未直接采集;其余 5 次被 observer 失真。启动敏感性根因仍未定,不能借一条成功反证其他失败。 +- 本 Task14 到此停止,不再探 namespace、不重跑六次、不切换 runner。新的最小决策是 lead 判断是否另发独立 instrumentation 修正任务;本章不是修复或独审。生产容量、恢复域、M4/M5 和授权继续 unknown,生产上传仍关闭。 diff --git a/docs/sessions/261008-local-upload/reviews/C-r1-verdict.md b/docs/sessions/261008-local-upload/reviews/C-r1-verdict.md new file mode 100644 index 00000000..a28cb1fb --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/C-r1-verdict.md @@ -0,0 +1,118 @@ +# C-r1 独立审查结论 + +failure-visibility: p2-only + +## 结论 + +固定审查范围:`c7b38563eb2f06f5ca36c06c6c7da7de9c5e9995..99780aff6b8dc567146b6a61e402007423d89db8`。Git 汇总为 18 个文件、1476 行新增、81 行删除。按审查卡要求,未读取 `docs/sessions/261008-local-upload/progress/C-progress.md` 的内容,且将该文件从人工审查和 OCR 输入中排除;其余 17 个文件已逐项审查。 + +发现 1 项 P2,未发现 P1。交付阻断:否(按 personal 风险档;建议在本地上传投入使用前修正明确的清理顺序不变量)。没有据此改写应用或测试。 + +## Finding P2:LLM 消费者可在源媒体删除前开始 + +- **不变式**:设计不变式 2 要求转录段结束时真实删除源媒体、音轨和 ASR 中间文件,并在 LLM 持有正文继续前完成删除。 +- **触发与代码**:上传分支在 `src/video_transcript_api/api/services/transcription.py:2557-2582` 调用 `_handoff_to_llm_stage`。该 helper 在 `779` 行后将已转录任务放入共享 LLM 队列;消费者可立即运行。源文件只在同一函数外层 `finally` 的 `2690-2706` 行清理。因此真实并发消费者开始 LLM 阶段时,`upload-source.bin` 仍可存在。清理会等整个转录 worker 返回;而这之前已经把任务交给 LLM。 +- **实际影响**:启用本地上传时,用户媒体在预期清理点后继续留在临时目录,至少覆盖 LLM 阶段启动与转录 worker 收尾之间的窗口;LLM 慢或阻塞时窗口也随之变长。这违反已批准的数据生命周期承诺。属于个人上传内容延长暂存,判 P2;不具备 P1 的广泛或高影响条件,当前证据也不支持按 P1 阻断交付。 +- **测试缺口**:`tests/integration/test_local_upload_lifecycle.py:463-645` 的真实 Transcriber 测试先等待 dispatcher future 完成,再断言文件不存在(619-623),随后才手动调用 `_handle_llm_task`(629-631)。因此它验证了 eventually cleanup,却没有并发观察“LLM 开始时文件是否已清理”。下面的隔离探针使用真实 dispatcher、`Transcriber`、队列消费者和 `_handle_llm_task`,断言当消费者进入实际 LLM `process` 方法时源路径仍存在;通过。 +- **建议修复判据**:将上传源及 ASR 临时文件的清理完成放到 LLM handoff 之前,并保留异常时的 finally 清理;增加并发队列消费者断言:消费者进入 LLM 前源文件和任务临时目录已不存在。不要仅把当前串行测试中的手动 LLM 调用提前或延后作为时序证明。 + +### P1 两问 + +1. 是否存在无交互即可触发的高影响安全/数据完整性损害?否;证据仅表明本地暂存清理晚于承诺时点,未发现媒体被送入 LLM 或暴露给其他用户。 +2. 是否会影响广泛用户、造成不可恢复数据损失或绕过主要访问边界?否;范围为启用本地上传的任务,临时文件最终删除,未发现持久化或公开读取旁路。 + +## 不变式与代码→测试核对 + +| 范围 | 实际代码 | 锁定的真实测试 / 结论 | +|---|---|---| +| 上传 admission、文件字节、处理选项、ASR 与 CapsWriter 结果 | `routes/uploads.py`;`transcription.py`;`cache_manager.py` | `test_upload_worker.py`;`test_local_upload_lifecycle.py` 使用真实 Transcriber 和 CapsWriter fixture,核对实际源字节、时长、选项、结果文本;未走 URL parser/downloader。join(tokens)/时间戳契约见 `test_capswriter_contract.py`。 | +| 临时空间预算、异常清理及 URL 调用者 | `context.py:reserve_upload_temp`;`transcription.py`;`utils/tempfile_manager.py` | `test_local_upload_policy.py`、`test_tempfile_manager.py`、`test_temp_cleanup_integration.py`。清理函数新抛错的生产调用点已逐一核对;URL 转录分支仍在原有 warning 边界内处理清理异常。 | +| 终态固定期限、never、启动孤儿、子任务和重复写入 | `terminal_status.py`;`cache_manager.py` | `test_local_upload_policy.py` 覆盖成功/失败终态、孤儿、never/revocation 和 child;`test_task_status_cleanup.py`、`test_cache_cleanup.py` 覆盖清理。新增 `terminal_at` 写者为迁移回填及终态更新;用来锁定一次性终态时钟(包括 never),未发现第三个镜像状态机写者。 | +| 空旧 token、Resolver 读/导出/历史、owner 先过滤 | `view_token_resolver.py`;`routes/audit.py`;`cache_manager.py:get_task_by_view_token` | `test_view_token_resolver.py`、生命周期测试中的 summary/export/history/close 序列、`test_upload_history_filters_owner_before_page_limit`。旧列为空、upload capability 独立;关闭/过期不返回正文。 | +| 三种 reprocess 与关闭并发排序 | `routes/tasks.py` 的 recalibrate/resummarize/generate_notes | `test_upload_reprocess_routes_use_owner_gate_and_never_persist_legacy_token`、`test_reprocess_admission_holds_sqlite_order_against_concurrent_close`;owner/share gate 与 child INSERT 在同一 SQLite 写事务内。 | +| 通知实际 producer 到最终 HTTP payload | `completion_share.py`;`task_formatter.py`;终态通知路径 | `test_notification_e2e_delivery.py` 实际捕获最终 HTTP payload,断言 upload 独立 URL、标题、来源标签、无本地路径/凭据,以及总结先于短完成回执。未新增 prompt/outbox/通知重试。 | +| A/B admission、队列和恢复合同 | `uploads.py`;`context.py`;`cache_manager.py` | 上传及 policy/lifecycle producer 测试核对 SQLite 真实结果和队列;未发现环境关闭时破坏已有 cache 保护或队列一次性 admission。 | + +## 验证记录 + +- 当前审查树 `git diff --check c7b38563eb2f06f5ca36c06c6c7da7de9c5e9995..99780aff6b8dc567146b6a61e402007423d89db8`:退出 0。 +- 在隔离 scratch worktree、网络 guard 生效下,以下窄测通过(退出 0):`tests/unit/test_upload_worker.py`、`tests/integration/test_local_upload_lifecycle.py`、`tests/unit/test_notification_e2e_delivery.py`、`tests/unit/test_capswriter_contract.py`、`tests/unit/test_local_upload_policy.py`、`tests/unit/test_tempfile_manager.py`、`tests/integration/test_temp_cleanup_integration.py`、`tests/unit/test_view_token_resolver.py`、`tests/api/test_history_routes.py`、`tests/unit/test_task_status_cleanup.py`、`tests/unit/test_cache_cleanup.py`。 +- 精确 base 红测:在 `c7b38563eb2f06f5ca36c06c6c7da7de9c5e9995` scratch worktree 拷入 `test_dispatcher_persists_named_local_upload_processing_failure` 后运行 `uv run pytest -q tests/unit/test_upload_worker.py::test_dispatcher_persists_named_local_upload_processing_failure`,预期退出 1:仅断言失败,实际 `error_message == '转录任务失败: ASR fixture failure'`,缺少 `UPLOAD_PROCESSING_FAILED`。不是导入或 schema 错误。 +- 裸 shell 缺省环境与真实子进程 producer fixture 已验证:`VTA_UPLOADS_ENABLED` 未设置时拒绝且响应正文无;显式 true 时成功且有正文。临时 systemd unit 使用 `--expand-environment=no` 和 false,子进程读到 false;producer 的真实子进程环境/SQLite 测试通过,且在子进程内断言缺省/true 两态。未改生产 unit。 +- OCR envelope:`status=skipped`、`findings=[]`;这不是 clean 结论。一次独立并发时序探针修正版通过;第一次无效尝试因探针重复调用 `queue.task_done()` 而失败,已修复探针并重跑。scratch 结束后由 helper 清除其临时 worktree;目标审查树和主树均未运行 runtime 测试、未改运行时配置或数据。 + +## 并发清理探针的可复制源码生成与运行命令 + +在隔离 scratch checkout 的固定 head `99780aff6b8dc567146b6a61e402007423d89db8` 执行。该命令从该 head 的现有真实生命周期测试生成临时 probe,仅把串行手动 LLM 调用替换为一个同步队列消费者;不改目标 checkout: + +```bash +set -euo pipefail +FIXED=99780aff6b8dc567146b6a61e402007423d89db8 +git show "$FIXED:tests/integration/test_local_upload_lifecycle.py" > tests/integration/test_local_upload_order_probe.py +python3 - <<'PY' +from pathlib import Path +p = Path("tests/integration/test_local_upload_order_probe.py") +s = p.read_text() +changes = [ +(" loop_state = {}\n from loguru import logger as test_logger\n", +""" loop_state = {} + llm_observation = {} + llm_started = threading.Event() + llm_done = threading.Event() + original_llm_put = llm_queue.put + def put_before_cleanup(item, *args, **kwargs): + original_llm_put(item, *args, **kwargs) + if not llm_started.wait(timeout=5): + raise TimeoutError("LLM consumer did not start before cleanup") + llm_queue.put = put_before_cleanup + from loguru import logger as test_logger +"""), +(" def process(self, **kwargs):\n assert kwargs[\"skip_calibration\"] is True\n", + " def process(self, **kwargs):\n" + " llm_observation[\"media_exists_during_llm\"] = source_path.exists()\n" + " llm_started.set()\n" + " assert kwargs[\"skip_calibration\"] is True\n"), +(" async def dispatch_and_wait():\n", +""" def consume_llm_task(): + task = llm_queue.get() + llm_observation["task"] = task + try: + from video_transcript_api.api.context import run_with_runtime + run_with_runtime(runtime, llm_ops._handle_llm_task, task) + finally: + llm_done.set() + llm_consumer = threading.Thread(target=consume_llm_task, name="upload-llm-probe") + llm_consumer.start() + async def dispatch_and_wait(): +"""), +(""" assert llm_queue.qsize() == 1 + llm_task = llm_queue.get_nowait() + assert llm_task["task_id"] == task_id + assert llm_task["media_id"] == upload["media_id"] + assert llm_task["processing_options"] == options + + from video_transcript_api.api.context import run_with_runtime + + run_with_runtime(runtime, llm_ops._handle_llm_task, llm_task) +""", +""" assert llm_done.wait(timeout=3) + llm_consumer.join(timeout=3) + assert not llm_consumer.is_alive() + assert llm_queue.empty() + llm_task = llm_observation["task"] + assert llm_task["task_id"] == task_id + assert llm_task["media_id"] == upload["media_id"] + assert llm_task["processing_options"] == options + assert llm_observation["media_exists_during_llm"] is True +"""), +] +for old, new in changes: + assert s.count(old) == 1, (old[:50], s.count(old)) + s = s.replace(old, new, 1) +p.write_text(s) +PY +uv sync --frozen +PYTHONPATH="$PWD/src" uv run --frozen pytest -vv tests/integration/test_local_upload_order_probe.py::test_dispatcher_uses_real_transcriber_then_cleans_owned_media_before_llm +``` + +实际结果:1 passed;输出断言 `media exists when actual LLM task consumer starts; removal follows transcription finally`。 diff --git a/docs/sessions/261008-local-upload/reviews/C-r2-verdict.md b/docs/sessions/261008-local-upload/reviews/C-r2-verdict.md new file mode 100644 index 00000000..83099cae --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/C-r2-verdict.md @@ -0,0 +1,218 @@ +# C 卡复审结论(冻结 H1) + +failure-visibility: p2-only + +## 范围与结论 + +- 增量:99780aff6b8dc567146b6a61e402007423d89db8..c216f6f07f90ab34322606ad604919a65e3d78f5。四问均通过:只补 ASR 产物归属及 LLM 前清理;无新抽象、无无据状态/回退、URL 与本地上传仍各走原分支。 +- 完整复验:c7b38563eb2f06f5ca36c06c6c7da7de9c5e9995..c216f6f07f90ab34322606ad604919a65e3d78f5,18 文件、1558 增/81 删。未读 progress/C-progress.md、实现报告或旧 review 正文。 +- 结论:4 项 P2,未确认 P1。交付阻断:是,因临时预算、URL 历史过滤及受理通知违反本卡明确合同;P2 严重度与阻断判断分开。新增异常捕获也违反明确边界。 +- OCR 前置扫描:skipped(primary 与 DeepSeek 均 leg_timeout),不算 clean。主干基线不可用:gh api request failed。 +- 配置缺少或关闭时上传保持关闭;生产 systemd、真实 ASR 容量、部署额度、hosted CI 和恢复域仍未验证。 + +## Findings + +1. P2 — 临时盘预算只在完整 ASR 之后核对。R7 要求 upload_temp_budget 覆盖源文件、音轨、中间产物及预留。uploads.py:274-284 先按声明大小两倍预留;transcription.py:2544-2564 才在音轨及 ASR 产物都写完后重新计数并拒绝超限。合成探针在预留 200 字节后写入 360 字节,最终检查才拒绝;所以处理期间实际占用可以超过已预留预算。影响是并行任务可先消耗超额临时盘。个人环境无真实容量数据,定 P2;不得据此宣称生产有硬上限。 +2. P2 — source=url 没有 URL 专属过滤。audit.py:253 声明来源过滤支持 upload/url,:266-268 只处理 upload;source=url 落入 :418-464 的通用 CTE,没排除 local_upload。真实 HTTP/SQLite 探针用 source=url 查询,结果仍含 platform=local_upload 的 root 及 URL 任务。自己的历史筛选会混入上传记录;不泄露正文或越权。 +3. P2 — 有 source_url 的上传受理通知不含“本地上传”。uploads.py:355-363 在 source_url 存在时只传该 URL;NotificationRouter.send_view_link (router.py:238-250) 只渲染该值、标题及 upload_ 分享链接。实际上传路由到企业微信 HTTP JSON 的探针确认标题、来源 URL、分享 URL 均在 payload,而“本地上传”不在。违反卡面通知字段合同,且永久源 URL 不代表来源类型。 +4. P2 — uploads.py:354-366 新增宽泛 except Exception,记录日志后仍返回 202。卡面明确禁止新增 catch;路由/通知异常会被吸收,调用方只看见已接受而看不到受理通知异常。日志可查,故不列 P1;仍属未经授权的异常处理边界。 + +## 不变式到实现/测试 + +1. raw bytes、服务端路径、持久 admission/dispatcher 与处理选项:uploads.py、CacheManager.accept_local_upload、transcription.process_task_queue;test_upload_intake.py、test_local_upload_lifecycle.py。CapsWriter token 契约由 test_capswriter_contract.py 锁;额外探针实际调用 Transcriber/CapsWriterClient 文件 producer,仅替换最终 ASR SDK 边界。 +2. LLM 前清理:transcription.py:841-845 与 2703-2719、TempFileManager.clean_up_task。并发真实队列消费者测试断言源、任务目录和 ASR 产物在处理入口前不存在;清理异常探针断言异常传播且 LLM 队列为空。H0 红验在同一用例上实际 AssertionError:source_exists 为 True。 +3. 首终态与期限:cache_manager.py:3002-3013;unit_local_upload_policy.py 与 test_local_upload_lifecycle.py。terminal_at 是本地上传首终态标记,completed_at 仍为 root 时钟真源;terminal_at IS NULL 与 write-once trigger 锁住首个 expires_at。它区分 never 的 expires_at=NULL 与尚未终态,服务迁移回填及重复终态写,不是第二套任务状态机。 +4. 空旧 token、Resolver、只读 history:view_token_resolver.py、views.py、audit.py;test_view_token_resolver.py、test_history_routes.py、test_local_upload_lifecycle.py。source=url 漏过滤见 Finding 2。 +5. 三个 reprocess owner/share 检查及 child 插入事务:tasks.py、cache_manager.py;test_local_upload_lifecycle.py 中三条实际 route 用例锁定,选定回归集通过。 +6. 通知:uploads.py、task_formatter.py、terminal_status.py、completion_share.py;test_notification_e2e_delivery.py 的完成通知真实 HTTP payload 通过;受理通知真实 HTTP payload 的缺标签由 Finding 3 捕获。 +7. URL 旧清理/状态路径:transcription.py、tempfile_manager.py;test_temp_cleanup_integration.py、test_tempfile_manager.py、test_task_status_cleanup.py、test_cache_cleanup.py 通过;代码核对 URL finally 分支仍按原捕获语义处理。 + +## 验证与限制 + +- H1 通过:test_upload_worker.py、test_local_upload_lifecycle.py、test_upload_intake.py、test_notification_e2e_delivery.py、test_capswriter_contract.py、test_cache_cleanup.py、test_task_status_cleanup.py、test_view_token_resolver.py、test_history_routes.py、test_temp_cleanup_integration.py、test_tempfile_manager.py、test_task_status_lifecycle.py、test_failure_status_persistence.py、test_upload_routes.py、test_task_status.py。 +- 裸子进程环境 + 合成 SQLite:test_local_upload_policy.py::test_resolver_uses_real_subprocess_environment_and_sqlite 通过;缺 VTA_UPLOADS_ENABLED 拒绝并输出 UPLOAD_DISABLED,无正文;true 返回成功正文。 +- H0 红验:scratch-worktree.sh 在 99780... 树复制 H1 改动后的生命周期测试并运行 test_dispatcher_uses_real_transcriber_then_cleans_owned_media_before_llm;实际断言 source_exists 为 False 时观察到 True,非导入/schema 错误;scratch 已自动删除。 +- 所有 pytest 的 tests/conftest.py 均报告 outbound network guard active。真实源媒体、生产配置、systemd、ASR/LLM 外部服务均未触碰。 +- git diff --check 通过;只新增本 verdict。scratch 初始无 data/logs/.venv;pytest 后有空 data/temp、4 个 .venv 链接、163 个 pytest-tmp 临时目录链接及 base-red.log.tmp 的 1 个链接。config/config.jsonc、logs 仍缺,普通硬链接为 0;这些本轮 scratch 产物留存,未读内容或清理。 + +## 附录 A:历史筛选与受理通知 HTTP 探针源码 + +以下测试临时放在 tests/integration/test_review_probes.py;命令为 PYTHONPATH=src:tests/integration python -m pytest -q tests/integration/test_review_probes.py。仅最终企业微信 HTTP POST 被替换;SQLite、上传路由、NotificationRouter 和消息 producer 均真实。 + +~~~python +import asyncio +import base64 +import datetime +import json +import threading +import uuid +from types import SimpleNamespace +from fastapi import FastAPI +from fastapi.testclient import TestClient +from test_local_upload_lifecycle import OWNER, _make_upload, lifecycle_client +from video_transcript_api.api.routes import audit as audit_routes +from video_transcript_api.api.routes import uploads as uploads_routes +from video_transcript_api.api.services import transcription +from video_transcript_api.api.services.transcription import verify_token +from video_transcript_api.cache.cache_manager import CacheManager +from video_transcript_api.utils.logging.audit_logger import AuditLogger +from video_transcript_api.utils.notifications import channel as channel_module +from video_transcript_api.utils.notifications import router as router_module +from video_transcript_api.utils.notifications import wechat as wechat_module +from video_transcript_api.utils.notifications.router import NotificationRouter +from video_transcript_api.utils.tempfile_manager import TempFileManager +from wecom_notifier import WeComNotifier +from wecom_notifier.platforms.wecom import sender as wecom_sender +def test_source_url_filter_includes_upload_snapshot(lifecycle_client, tmp_path, monkeypatch): + client, cache, _, _ = lifecycle_client + audit = AuditLogger(str(tmp_path / "audit.db")) + cache.audit_logger = audit + monkeypatch.setattr(audit_routes, "audit_logger", audit) + upload = _make_upload(cache, title="synthetic upload") + url_task = cache.create_task(url="https://url.example.test/item", platform="youtube", submitted_by="alice") + cache.update_task_status(url_task["task_id"], "success", platform="youtube", title="synthetic URL") + response = client.get("/api/audit/history?source=url&status=all&limit=100") + assert response.status_code == 200 + items = {item["task_id"]: item for item in response.json()["data"]["items"]} + assert items[upload["root_task_id"]]["platform"] == "local_upload" + assert url_task["task_id"] in items +def test_accepted_upload_final_http_payload_has_no_local_label(tmp_path, monkeypatch): + webhook = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=synthetic" + config = {"storage": {"temp_dir": str(tmp_path / "temp"), "upload_limits": { + "max_file_mib": 1, "max_media_hours": 1, "receive_concurrency": 1, + "upload_temp_budget_mib": 4}}, "wechat": {"webhook": webhook}} + records, condition = [], threading.Condition() + class Response: + def json(self): return {"errcode": 0, "errmsg": "ok"} + def post(url, *, json, **_kwargs): + with condition: + records.append(json) + condition.notify_all() + return Response() + monkeypatch.setattr(wecom_sender.requests, "post", post) + monkeypatch.setattr(router_module, "load_config", lambda: config) + monkeypatch.setattr(channel_module, "load_config", lambda: config) + monkeypatch.setattr(wechat_module, "load_config", lambda: config) + monkeypatch.setattr("video_transcript_api.utils.rendering.get_base_url", lambda: "https://share.example.test") + notifier = WeComNotifier(max_retries=0) + monkeypatch.setattr(wechat_module, "_get_global_notifier", lambda: notifier) + router = NotificationRouter() + cache = CacheManager(str(tmp_path / "cache")) + temp_manager = TempFileManager(config["storage"]["temp_dir"]) + q = asyncio.Queue(maxsize=2) + runtime = SimpleNamespace(started=True, upload_receiver_slots=threading.BoundedSemaphore(1), + reserve_upload_temp=lambda _task, size, budget, _dir: size <= budget, + release_upload_temp=lambda _task: None) + inflight = SimpleNamespace(try_register=lambda *_args: True, release=lambda *_args: None) + class ReadyProcessor: + done = lambda _self: False + get_coro = staticmethod(lambda: transcription.process_task_queue) + user = {**OWNER, "wechat_webhook": webhook} + async def current_user(): return dict(user) + monkeypatch.setenv("VTA_UPLOADS_ENABLED", "true") + for name, value in (("get_cache_manager", lambda: cache), ("get_config", lambda: config), + ("get_task_queue", lambda: q), ("get_temp_manager", lambda: temp_manager), + ("get_inflight_registry", lambda: inflight), ("get_notification_router", lambda: router)): + monkeypatch.setattr(uploads_routes, name, value) + app = FastAPI() + app.state.runtime, app.state.queue_processor = runtime, ReadyProcessor() + app.include_router(uploads_routes.router) + app.dependency_overrides[verify_token] = current_user + raw = b"synthetic upload" + metadata = {"filename": "synthetic.mp4", "byte_size": len(raw), "title": "Synthetic title", + "source_url": "https://source.example.test/item", "retention": "30d", + "processing_options": {"calibrate": False, "summarize": False}} + key = f"{int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)}-{uuid.uuid4()}" + try: + with TestClient(app) as client: + response = client.post("/api/uploads", content=raw, headers={ + "Authorization": "Bearer synthetic", "Content-Type": "application/octet-stream", + "Idempotency-Key": key, "X-Upload-Metadata": base64.urlsafe_b64encode( + json.dumps(metadata, separators=(",", ":")).encode()).decode().rstrip("=")}) + assert response.status_code == 202 + with condition: + assert condition.wait_for(lambda: len(records) == 1, timeout=5) + content = records[0]["markdown_v2"]["content"] + assert "/view/upload_" in content and "Synthetic title" in content + assert "https://source.example.test/item" in content + assert "本地上传" not in content + finally: + for manager in notifier.webhook_managers.values(): + manager._stop_flag.set() + manager.worker_thread.join(timeout=5) + cache.close() +~~~ + +## 附录 B:ASR 产物、预算、清理失败探针源码 + +以 scratch H1 的真实代码运行,mock 最后一个 CapsWriter SDK/网络边界;命令:PYTHONPATH=src python runtime-probe.py。断言通过,输出仅含合成值。 + +~~~python +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import queue +import shutil +import threading +from video_transcript_api.api.context import RuntimeContext +from video_transcript_api.api.services import transcription +from video_transcript_api.transcriber import capswriter_client as cw +from video_transcript_api.transcriber import transcriber as tm +from video_transcript_api.transcriber.transcriber import Transcriber +with TemporaryDirectory() as root: + root = Path(root) + taskdir = root / "owned-task" + taskdir.mkdir() + audio = taskdir / "input.wav" + audio.write_bytes(b"synthetic audio") + cw.Config.load_from_project_config = classmethod(lambda cls: None) + cw.load_config = lambda: {"storage": {"temp_dir": str(root / "workspace")}} + tm.load_config = lambda: {"capswriter": {"server_url": "ws://127.0.0.1:6006", "max_retries": 1}} + tm.get_workspace_dir = lambda: str(root / "workspace") + old_sdk = cw.transcribe_file_sync + cw.transcribe_file_sync = lambda *_a, **_k: SimpleNamespace( + raw={"text_accu": "producer text", "task_id": "synthetic", "time_start": 1., "time_complete": 2.}, + tokens=["producer text"], timestamps=[1.], duration=1.) + try: + tr = Transcriber({"capswriter": {"server_url": "ws://127.0.0.1:6006", "max_retries": 1}}) + tr.output_dir = tr.capswriter_client.output_dir = str(taskdir) + cw.Config.generate_txt = True + cw.Config.generate_json = cw.Config.generate_merge_txt = cw.Config.generate_srt = False + cw.Config.generate_lrc = cw.Config.generate_funasr_compat = False + out = Path(tr.transcribe(str(audio), "probe", media_duration=1.)["txt_path"]) + assert out == taskdir / "input.txt" and out.read_text() == "producer text" + shutil.rmtree(taskdir) + assert not out.exists() + finally: + cw.transcribe_file_sync = old_sdk +with TemporaryDirectory() as root: + rt = RuntimeContext.__new__(RuntimeContext) + rt._upload_budget_guard, rt._upload_reserved_bytes = threading.Lock(), {} + assert rt.reserve_upload_temp("t", 200, 200, root) + td = Path(root) / "t" + td.mkdir() + for name, size in (("source", 100), ("audio", 240), ("asr", 20)): + (td / name).write_bytes(b"x" * size) + actual = sum(p.stat().st_size for p in td.iterdir()) + assert actual == 360 and actual > 200 + assert not rt.reserve_upload_temp("t", actual, 200, root) +q = queue.Queue() +old_manager, old_queue = transcription.get_temp_manager, transcription.llm_task_queue +class BrokenTemp: + def clean_up_task(self, _task_id): raise OSError("synthetic cleanup failure") +transcription.get_temp_manager = lambda: BrokenTemp() +transcription.llm_task_queue = q +try: + try: + transcription._handoff_to_llm_stage( + "synthetic-task", {"platform": "local_upload"}, calibrating_status_kwargs={}, + task_notifier=None, log_context="probe", observability={}) + except OSError as exc: + assert str(exc) == "synthetic cleanup failure" + else: + raise AssertionError("cleanup did not propagate") + assert q.empty() +finally: + transcription.get_temp_manager, transcription.llm_task_queue = old_manager, old_queue +print("producer path/cleanup, over-budget post-check, and cleanup-failure queue assertions passed") +~~~ diff --git a/docs/sessions/261008-local-upload/reviews/C-r3-verdict.md b/docs/sessions/261008-local-upload/reviews/C-r3-verdict.md new file mode 100644 index 00000000..d9f8da54 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/C-r3-verdict.md @@ -0,0 +1,58 @@ +# PR207 C-r3 固定代码审查结论 + +## 结论 + +- 严重度:按 `personal` 使用边界(本人及 4–5 位可信用户、可信 Bearer)审查,未发现有实际触发路径支撑的 P1/P2 代码问题。没有把未经授权配置或假设中的不可信多租户风险升级为 finding。 +- 交付阻断:代码层未发现需要阻止交付的问题;审查证据层不能标成完整 clean:OCR 审查跳过,托管 CI 未知,且受输入隔离限制,无法把 H1→H2 差异逐条关联到既有 finding ID。详见限制。 +- 本文是独立代码与运行复验结论,不代表所有平台或真实 ASR 模型均已验证。 + +## 固定目标与审查范围 + +- 目标 HEAD:`ef425d77cdaecb693aba76ca6cb3f847ad5196e5`;分支:`card/vta-upload-C-review3-261009`。 +- H1→H2 增量:`c216f6f07f90ab34322606ad604919a65e3d78f5..ef425d77cdaecb693aba76ca6cb3f847ad5196e5`。原范围 12 个文件 +524/-44;排除未打开的两份进度自述后,审查代码与测试为 11 个文件 +521/-44。 +- PR203→H2 完整范围:`c7b38563eb2f06f5ca36c06c6c7da7de9c5e9995..ef425d77cdaecb693aba76ca6cb3f847ad5196e5`。原范围 25 个文件 +2048/-91;排除未打开的两份进度自述后,审查代码与测试为 23 个文件 +1997/-91。 +- 只读了冻结范围中的代码、测试和 `docs/sessions/261008-local-upload/design.md`。没有读取实现报告、先前 review、progress 自述或顾问报告。 +- 增量代码变动落在不变式 3、6、7、8 所涉修复路径,未识别到无关代码增量。因禁止读取旧 review,不能核验每一处修复与旧 finding ID 的精确映射;这是审计追溯限制,不是代码 finding。 + +## H1→H2 四问 + +1. **是否只改已登记事项:部分可核。** 增量 diff 范围和行为均符合本卡列出的 ASR/历史/通知修复边界;旧 finding ID 的逐条归属无法从许可输入中独立验证。 +2. **是否引入未经批准抽象:否。** `_local_upload_write_admission` 是不变式 3 明确允许的唯一局部写入 admission callback。通知字段 formatter 在完整范围内有接收通知和终态通知两个实际调用方。 +3. **是否新增无依据状态、事实源或 fallback:否。** 上传持久事实仍以 `local_uploads` 与任务状态映射为准;临时预算继续使用现有 `RuntimeContext.reserve_upload_temp`。未发现新增账本、配置、双写事实源或静默回退。 +4. **是否保留新旧并行 URL/本地上传路径:否。** 本地任务通过持久 `media_path` 进入共享转录处理;正常 URL 路径保持原 downloader/字幕逻辑。未见本地上传另起一套转录或 URL 抓取分支。 + +## 八项不变式核对 + +| 不变式 | 代码与测试证据 | 结论 | +| --- | --- | --- | +| 1. 接收、认证、默认关闭、有限正值限制;接收源有 hash;owner/key 同意图只回原 receipt;SQL 持久受理与 queue 交接一致;dispatcher 只消费持久 mapping;退休与接收并发有序;选项传到 consumer | `api/routes/uploads.py` 的 `_uploads_enabled`、边界检查、流式计数/hash 和 `accept_local_upload(... enqueue=put_nowait)`;`cache/cache_manager.py::accept_local_upload` 在同一无 await 事务中写 root、mapping 并交队,`get_admitted_local_upload_by_task` 验证 durable mapping;`api/services/transcription.py` 在 ASR 前校验路径与映射,stale 项失败清理。覆盖 `tests/integration/test_upload_intake.py` 的真实 HTTP/SQLite/queue、提交失败 stale item、lost-202 receipt、选项传递、接收/退休竞争测试;`tests/unit/test_upload_worker.py` dispatcher mapping 测试。 | 通过 | +| 2. 本地上传用服务器文件走共享 ASR/LLM;CapsWriter 结果由 token join 与时间坐标契约决定;URL 原语义不变 | `api/services/transcription.py` 的 `local_media_path` 分支跳过 URL 下载器及字幕抓取;`transcriber/capswriter_client.py` 与 `transcriber/transcriber.py` 的输出契约。覆盖 `tests/unit/test_capswriter_contract.py::test_body_is_token_join_not_transcript_text`、空/缺字段和错误契约用例,以及生命周期和 FunASR 分支测试。 | 通过;空文本等合法旧 URL 语义按原路径保留 | +| 3. 上传源、ASR 输出、兼容 JSON 和 atomic tmp 峰值都在实际写前用同一 runtime 预算准入 | intake 先预留源字节;`api/services/transcription.py::_local_upload_write_admission` 统计 task dir 当前文件的真实字节,加待写 UTF-8 tmp 大小并调用同一 `reserve_upload_temp`;CapsWriter atomic writer 在创建 tmp 前回调,文本和 JSON 输出都经过它。覆盖 `tests/unit/test_upload_temp_budget.py` 的旧 target/UTF-8 与拒绝前无 tmp 测试及 caps writer 写入测试。真实 producer 探针见下文。 | 通过 | +| 4. 真实队列消费者开始 LLM 前,原源与 taskdir 产物已删除;终态清理幂等;reprocess 从文字继续 | `api/services/transcription.py` 转录阶段 finally 清理本地 taskdir;`_handoff_to_llm_stage` 清理后才入 LLM queue;本地 reprocess 使用已保存文字。覆盖 `tests/integration/test_local_upload_lifecycle.py::test_dispatcher_uses_real_transcriber_then_cleans_owned_media_before_llm` 及三路 reprocess 覆盖。 | 通过 | +| 5. root 终态时间与保留期限固定,关闭/过期/空 token 不复活;公开读写与 reprocess 按真实 owner/share 授权 | `cache/cache_manager.py` 的 `local_uploads` schema、write-once terminal/revocation trigger、active share 查询及事务性 reprocess admission;`api/routes/tasks.py` 与本地内容路由。覆盖 lifecycle 中 owner/share/reprocess、关闭期间读写与事务竞争、公开 summary/export 和空 token 用例。 | 通过 | +| 6. source URL history 在 SQL 计数与分页前排除 local upload,owner/mixed 数据返回正确 items 与 total | `api/routes/audit.py` 的 source URL 查询将 local upload snapshot 从 URL history 排除;`tests/unit/test_history_routes.py::test_source_url_excludes_local_upload_snapshots_before_pagination` 与 `tests/integration/test_local_upload_lifecycle.py::test_upload_history_filters_owner_before_page_limit` 检查分页和 owner 过滤。 | 通过 | +| 7. 接受与终态通知由真实 producer 到既有 router/channel/FIFO/HTTP payload,包含安全标题/来源标签/分享链接且不泄露路径 | `api/routes/uploads.py` 组装 accepted payload;`utils/notifications/task_formatter.py` 被接受与终态两个调用点复用;终态 producer 通过现有 router 发送。覆盖 `tests/unit/test_notification_e2e_delivery.py` 的最终 HTTP payload/顺序,以及生命周期的独立公开能力通知测试。上传只展示 source URL,不据此拉取媒体。 | 通过 | +| 8. 接受通知维持同步;意外异常 fail-loud,但 commit 后 acceptance/root/queue 保持一次;重复 key 只查 receipt | `api/routes/uploads.py` 在提交与 queue handoff 后同步调用通知,无新增 catch 吞异常;明确返回 False 仍走原错误日志及 202,意外 throw 会成为 HTTP 500,已提交 receipt/queue 不撤销。覆盖 `tests/integration/test_local_upload_lifecycle.py::test_upload_notification_exception_keeps_acceptance_and_receipt`、`tests/integration/test_upload_intake.py::test_lost_202_receipt_retry_queries_original_without_requeue`。 | 通过 | + +## 实际 producer 与边界证据 + +- 在由 scratch wrapper 从固定 HEAD 创建的隔离 worktree 中,使用 `uv sync --frozen`、`UV_OFFLINE=true` 和 `strace -f -qq -e trace=execve -s 2048` 运行真实锁定版 CapsWriter SDK(rev `492fe191e3f9568ea178b61970c732c9d37c4e29`)、真实 `Transcriber`、原子 writer、runtime 预算,以及本机 fixture CapsWriter/FunASR 服务。没有替换 `transcribe_file` 或手工伪造 ffmpeg argv;完整命令、内联脚本和输出记录在外部执行报告。 +- `strace` 捕获实际子进程 argv:输入 `upload-source.bin` 交给 ffmpeg `-map 0:a:0 ... -f flac pipe:1`;第二个 ffmpeg 从 `pipe:0` 解码实际 FLAC 到 `s16le pipe:1`。SDK 实际发给本机 WebSocket 的 FLAC 为 10,010 bytes,具有 `fLaC` magic。SDK 没有额外 ffprobe 子进程。 +- 合成输入为 8,044 bytes。真实 `Transcriber` 写出 `.txt` 97 UTF-8 bytes(保留首尾空格)及 `_funasr.json` 439 bytes;sidecar join 与文本完全一致。真实 writer 对旧 target + source + tmp 峰值先做 admission;成功后 runtime reservation 等于实测峰值。另以 65,539-byte 合法结构化 ASR 输出 fixture 和已占用的其他任务 reservation 触发超预算:写入前拒绝,回调时峰值为 73,581 bytes,目标及 tmp 均未创建。该 fixture 证明字节/拒绝契约,不代表模型输出容量上界。 +- 实际 FunASR client 访问本机 HTTP capabilities 与 WebSocket,上传体逐字节等于源文件,发出 `terms`;client 运行期间源目录内容保持不变,没有额外 FunASR 文件写入。服务仅是最后网络边界 fixture,不声称真实模型转录质量。 +- 成功路径与拒绝路径的 ffmpeg argv、探针输出和完整复现命令写入外部报告。探针 scratch 在 wrapper 退出时回收。 + +## 运行验证 + +- 指定窄验证命令 `uv run --frozen pytest -q tests/unit/test_upload_temp_budget.py tests/unit/test_upload_worker.py tests/integration/test_local_upload_lifecycle.py tests/integration/test_upload_intake.py tests/unit/test_history_routes.py tests/unit/test_notification_e2e_delivery.py tests/unit/test_capswriter_contract.py`:退出码 0。 +- `make test` 按要求仅运行一次,工作目录为已知为空的 scratch worktree,不是实现树或审查树;初始无配置文件、无 `.venv`、无软链,与主仓 inode 不同;`UV_OFFLINE=true`,pytest 输出显示 outbound network guard 已安装。退出码 0,收集 3,812 项:3,809 passed、3 skipped。三个 skip 含缺少真实 CapsWriter preflight fixture(`tests/unit/test_capswriter_samples_total_contract.py:61`);因此不是完整真实 ASR 验证。scratch wrapper 退出后清理,代码树未被测试写入。 +- 两条 H1 对照在 H1 上均由目标行为 assertion 失败(history 行数为 2 而非 1;预算观测 372 而预期 37+30),不是导入、属性或 schema 错误。它们说明对照具有行为区分力。 +- OCR wrapper envelope:`/tmp/vta-pr207-ocr-envelope.json`;状态 `skipped`,primary 与 backup 均为 `leg_timeout`,coverage 为 null。空 findings 不代表 clean。 +- 托管 CI 基线在派发时 `gh api request failed`,没有 run/job 级结论;不能判断本轮是否有继承红或新红,亦未等待业务 CI。 + +## 限制与判定 + +- 代码范围未发现需要 P1/P2 处理的 finding,也未发现代码交付阻断。报告保留的审查证据限制是:H1 finding ID 追溯受输入隔离约束不可核实;OCR skipped;hosted CI 未知;真实模型/外部 ASR 服务未运行。它们不被写成通过。 +- 本次没有修改应用、测试、配置或 workflow;唯一交付为本 verdict 文档。 + +failure-visibility: skipped diff --git a/docs/sessions/261008-local-upload/reviews/D-r1-verdict.md b/docs/sessions/261008-local-upload/reviews/D-r1-verdict.md new file mode 100644 index 00000000..fba1a16a --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/D-r1-verdict.md @@ -0,0 +1,79 @@ +# D-r1 独立审查结论 + +failure-visibility: p1-found + +对象固定为 be2e4fd6e92f8cb83a034def7b3856f337cf7290..f82e7bf1fcdc401c1d72c3ad141c2321a457e5ab。风险档为 personal。本轮审完 9 个文件,改动规模为 2020 行新增、58 行删除。 + +**结论:阻断交付。** 1 项 P1 在支持的 Chromium 中可复现:上传期间换文件,会把原文件的回执记到新文件名下,并清掉尚未上传的新文件。这是静默错误,符合 personal 档 P1 红线。其余 5 项为 P2,单独不阻断。 + +## Findings + +### P1 — 在途换文件后回执串到新文件,且新选择被清除 + +- **违反:** 不变式 3。 +- **位置:** src/web/static/js/app.js:1563-1596。POST 捕获了原文件和元数据,但回执落历史时用全局 selectedUploadFile.name,随后无条件调用 clearUploadFile()。 +- **复现:** Chromium 中提交 first-file.wav,暂停 POST 后选择 replacement-file.wav,再返回原文件的 accepted 回执。请求元数据仍是 first-file.wav;本地历史却标成 replacement-file.wav,且新文件输入被清空。自有 scratch 复现见 fault-repro.spec.ts:53。 +- **影响与阻断:** 实际转录内容与历史名称不一致;用户随后选择的文件没有提交,也没有失败提示。用户可能据错误名称判断转录内容,不能接受。 +- **局部修法:** 在提交开始时保留该意图的文件与名称,回执只用该快照;只有当前选择仍是该文件时才清空。加浏览器回归测试覆盖上传期间换文件。 + +### P2 — receiving 回执被显示为受理成功并清除意图 + +- **违反:** 不变式 3、4。 +- **位置:** src/web/static/js/app.js:1563-1596 对任意 truthy 回执都写历史、显示“任务已受理”、清除文件与 key。 +- **证据:** 实际 FastAPI owner 查询可返回 state=receiving, task_id=null, view_token=null;自有 scratch 的真实 FastAPI/SQLite 查询通过。Chromium 注入该回执后观察到成功提示和文件输入清空,见 fault-repro.spec.ts:24。 +- **影响:** 当前状态细节虽显示 receiving,主提示却宣称正式受理;未受理的意图无法按原 key 继续核实。现有单测只覆盖 accepted 回执,没有锁住 receiving/failed 的 UI 语义。 +- **局部修法:** 仅 state=accepted 进入成功分支;其他状态显示对应状态,不清除文件或幂等 key。 + +### P2 — capabilities 未知或当前身份请求失败时仍允许提交 + +- **违反:** 不变式 2。 +- **位置:** src/web/static/js/app.js:845-855, 1395-1422, 1502-1505。uploadCapabilities 为空时按启用处理;查询失败只记日志并保留旧值;提交仅拒绝明确的 enabled=false。 +- **复现:** 支持的 Chromium 中保持 capabilities 请求未完成,按钮仍可用并发出 POST;先让身份 A 得到 enabled,再令身份 B 的请求返回 401,仍会用身份 B 的令牌发 POST。见 fault-repro.spec.ts:143,182。 +- **影响与阻断:** 未取得当前身份的功能开关和文件限额时,客户端仍传文件;服务端会再次校验,但这违背前端 fail-closed 契约,也会让关闭/超限状态无法在提交前提示。P2,单独不阻断。 +- **局部修法:** 每次身份变更/重新查询先清空旧 capabilities 并禁用提交;只有当前身份取得有效响应后再按开关和限额启用。 + +### P2 — HTTP 500 后不查询回执,用户再次点击会重发文件体 + +- **违反:** 不变式 3。 +- **位置:** src/web/static/js/app.js:1540-1545 收到任何带状态码的错误便直接显示失败返回,没有按 key 查询。 +- **真实失败路径:** src/video_transcript_api/api/routes/uploads.py:359-388 在 SQLite 接受并入队后发送通知;通知异常会让请求以 HTTP 500 结束。自有 scratch 的真实 FastAPI 测试确认 500 时记录已 accepted,owner 查询可读到 accepted;同 key 重放在服务端返回旧回执且 body 未被读取。 +- **浏览器复现:** 500 后页面显示失败;再次点击发出相同 key 和相同原始文件字节的第二个 POST,没有 GET 查询。见 fault-repro.spec.ts:99。 +- **影响与阻断:** 服务端幂等阻止重复建任务,但客户端违背未知受理只查询回执的约定,并再次传输文件体。P2,单独不阻断。 +- **局部修法:** 对可能发生在接受之后的服务端错误,保留原意图并按 key 查询;核实失败前不再次发送文件体。 + +### P2 — 功能开关文档变量名与服务端不一致 + +- **违反:** 不变式 7 的文档承诺须与实际行为一致。 +- **位置:** docs/features/local-upload.md:76 写 UPLOAD_ENABLED;src/video_transcript_api/api/routes/uploads.py:140-145 实际只检查 VTA_UPLOADS_ENABLED=="true",并要求 limits 与 worker 就绪。 +- **影响与阻断:** 按文档设置后上传仍保持关闭。P2,单独不阻断。 +- **局部修法:** 将文档改为实际变量,并说明 limits 与 worker 条件。 + +### P2 — 新增不受支持平台与响应体解析 fallback + +- **违反:** 不变式 7 明确要求现代浏览器路径,不增加 UUID、编码或未知响应 fallback;全局实现约定要求错误 fail fast。 +- **位置:** src/web/static/js/app.js:411,444 与 src/web/static/history.html:1171 将 JSON 解析失败转成空对象;app.js:1237-1238 使用 unescape/encodeURIComponent 编码 fallback;app.js:1254-1259 在 crypto.randomUUID 不可用时以 Math.random 造 key。 +- **影响与阻断:** 当前 Chromium 走主路径,未证明这些分支在目标浏览器中触发;fallback 让畸形响应或不支持环境继续运行,并降低幂等 key 熵。P2,单独不阻断。 +- **局部修法:** 删除无批准的 fallback,让不受支持环境或非法响应显式失败。 + +## 不变式、代码与已提交测试对照 + +| 不变式 | 生产代码 | 已提交测试与覆盖边界 | +|---|---|---| +| 1. 六字段元数据、raw bytes、鉴权和幂等 key | app.js:421-451,1195-1223;服务端 uploads.py | e2e/tests/local-upload.spec.ts:40-115 检查真实 Chromium 请求头、六字段元数据和 body SHA;tests/integration/test_upload_intake.py::test_real_http_stream_sqlite_dispatch_idempotency_and_owner_stop 用 FastAPI/SQLite 检查真实入库 bytes。浏览器到服务端仍由不同测试覆盖。 | +| 2. capabilities 来自当前身份,未知/失败不等于启用 | app.js:845-867,1395-1422,1502-1511 | 有 enabled happy path;没有已提交测试锁住未返回、401、身份切换后的 fail-closed。scratch 浏览器故障测试复现反例。 | +| 3. 意图与 body/key 绑定,未知只查回执 | app.js:1523-1557 | src/web/tests/local-upload.test.js:286-326 锁住网络异常时查询;e2e/tests/local-upload.spec.ts:117-146 锁住重复点击只 POST 一次。未覆盖在途换文件或 HTTP 500 后只查询。 | +| 4. 只有 accepted 算正式受理,状态展示准确 | app.js:1563-1596;真实回执由 uploads.py:148-175,410-420 生成 | test_upload_intake.py::test_real_http_stream_sqlite_dispatch_idempotency_and_owner_stop 锁住 accepted producer;没有已提交浏览器测试锁住 receiving/failed 消费。scratch 真实 FastAPI 查询与浏览器故障测试分别验证 producer 和 consumer。 | +| 5. owner 历史、独立上传 token、停止分享与撤销后拒读 | api/routes/audit.py:268-350;src/web/static/history.html:1185-1215,1325-1390 | test_upload_history_filters_owner_before_page_limit 锁住 owner 过滤、字段、revoke 状态;FastAPI 生命周期测试验证撤销后 view/export/read 拒绝;E2E local-upload.spec.ts:148-205 锁 UI。E2E server 是 Python/SQLite fixture,不是真实 FastAPI。 | +| 6. 浏览器 picker/drop、页面和实际结果路径 | src/web/static/js/app.js 上传事件;history.html 历史消费 | 三个 E2E 用真实 Chromium,但 API 来自 tests/browser_server.py fixture;上传仅覆盖 file-input setInputFiles,未覆盖 drop、键盘和小屏行为。FastAPI raw body、SQLite、queue/worker 在独立集成测试验证,未做一条浏览器直连 FastAPI 的全链测试。 | +| 7. 无额外 fallback/抽象,文档不高于已证行为 | app.js:411,444,1237-1259;history.html:1171;功能文档第 76 行 | 没有已提交测试约束这些 fallback 不存在;配置变量有代码对照,未有文档校验测试。 | + +## 验证与边界 + +- npm run test:web:14 个文件、181 个测试通过。 +- npm run test:browser:12 个 Chromium 测试通过。 +- make test:退出码 0。 +- 固定 H0 scratch:历史 FastAPI 测试通过;将 audit.py 的 upload_view_token producer 临时改为 None 后,该测试因 token 值业务断言失败。 +- 固定 base scratch:复制 H0 生命周期测试并用 .get("upload_view_token") 反验;缺字段导致 AssertionError,不是导入错误或 KeyError。 +- OCR 前置扫描为 skipped,原因 backup:deepseek=leg_timeout;本结论不把它记作 clean。 +- 浏览器 E2E fixture 与真实 FastAPI 集成测试分层,未验证生产配置、生产部署或浏览器直连真实 FastAPI 的全链。 +- 派发基线 CI 查询不可用(gh api request failed),继承红无法判定。 diff --git a/docs/sessions/261008-local-upload/reviews/D-r2-verdict.md b/docs/sessions/261008-local-upload/reviews/D-r2-verdict.md new file mode 100644 index 00000000..9a2e96c9 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/D-r2-verdict.md @@ -0,0 +1,73 @@ + +failure-visibility: p1-found + +# D-r2 独立审查结论 + +## 固定对象与结论 + +- 完整审查对象:`be2e4fd6e92f8cb83a034def7b3856f337cf7290..cabbf6b5b38922244372bffc8569bab98577edb4`。 +- 增量四问对象:`f82e7bf1fcdc401c1d72c3ad141c2321a457e5ab..cabbf6b5b38922244372bffc8569bab98577edb4`。 +- 结论:有一项 P1 会使未知受理意图丢失并允许重复提交,当前候选不能交付。另有 P2 的意图签名碰撞、未消费状态、无依据空数组兜底和自动化覆盖缺口。 + +## 增量四问 + +1. 增量是否仍是局部消费逻辑、同路径测试和文档?是。改动落在网页上传/历史消费及相应测试、说明。 +2. 是否新增未经批准的抽象?未发现通用重试层、额外 API 或新平台;`currentUploadIntent.metadata` 是未消费的单用途状态,见 P2-3。 +3. 状态、事实源或 fallback 是否无依据新增?有两处:意图状态的 404 处理会丢失唯一核实 key(P1);历史过滤选项把缺失字段兜成空数组(P2-4)。 +4. 是否留下双路径?没有发现并行编码/重试实现;未知提交仍走原 key 查询路径,但查询 404 会清意图,后续显式提交会另造 key(P1)。 + +## Findings + +### P1 — 核实回执 404 会清掉未知受理意图(阻断交付) + +位置:`src/web/static/js/app.js:1587-1601`,尤其 `1595-1598`。收到网络/5xx 未知结果后,代码保留 key 并设 `pendingVerification`;下一次点击通过 `getUploadReceipt` 查询。这个查询返回 404 时也进入同一个 4xx 分支,立即把 `currentUploadIntent` 置空。用户再次点击会生成新 key 并重新发送文件。 + +这违反不变式 3:未知受理必须保留原 key,只按 key 查询;核实不得重发 body 或换 key。实际 Chromium 临时探针复现了 POST 500、两次 owner receipt GET 404 后意图被清除,下一次点击发出第二个 POST 且 key 不同。可能造成同一文件重复入队/重复处理。 + +建议局部修复:区分 POST 的明确拒绝与 receipt 查询结果;receipt 404 应保留原意图和 key,并明确显示“当前未查到回执/状态未知”,不得在核实路径清意图。为这一分支添加持久的浏览器回归测试。 + +### P2 — `metaSignature` 的冒号拼接可碰撞(阻断交付) + +位置:`src/web/static/js/app.js:1567-1578`。签名把文件属性、标题、source URL 等用冒号串联,字段值本身可含冒号。临时 Chromium 探针用相同文件,分别设置 `(title="T", source="https://example.com/path:https://example.org")` 和 `(title="T:https://example.com/path", source="https://example.org")`,两组不同参数得到相同签名。未知意图下第二组参数复用了旧 key 并查询旧回执,未建立新意图。 + +这违反不变式 3 的“参数变更产生新 key”。建议对固定字段对象按固定键序列化后比较,或逐字段比较;提交的浏览器测试应断言参数变更产生新 key,且不把新参数误绑到旧回执。 + +### P2 — 未使用的 `currentUploadIntent.metadata` 状态(不单独阻断) + +位置:`src/web/static/js/app.js:1571-1576`。该字段被保存,但源码中没有读取方;实际 POST 使用局部 `metadataSnapshot`。它增加了一份看似权威但不起作用的状态,违反不变式 7 对新增状态须有消费者和测试锁的要求。删除该字段即可。 + +### P2 — 历史过滤选项对缺字段静默兜底(不单独阻断) + +位置:`src/web/static/history.html:986-991`。`webhooks`、`platforms`、`authors` 均以 `|| []` 处理。实际 `GET /api/audit/filter-options` 成功响应在 `src/video_transcript_api/api/routes/audit.py:687-695` 总是提供三个数组,异常走 HTTP 错误;缺字段是响应契约破坏,不应伪装成成功的空选项。建议移除这三个默认值,让不符合契约的响应走可见错误路径。 + +### P2 — 必需的浏览器回归场景未锁定(阻断交付) + +位置:`e2e/tests/local-upload.spec.ts:207-221`、`src/web/tests/local-upload.test.js:301-340`。名为“小屏与拖放”的 Playwright 用例实际通过 `setInputFiles` 选择文件,没有键盘文件选择或 DOM 拖放事件;已提交用例也没有锁定 receipt 404 保留 key、参数变化建立新 key、失败回执等关键路径。纯 helper/模拟 fetch 用例不能防止真实页面事件与请求意图脱节。 + +临时真实 Chromium 探针确认键盘文件选择与 DOM 拖放能产生 HTTP 请求,提交的字节长度和 SHA-256 与输入一致,metadata 的全部布尔选项保持 `false`;但该一次性探针不是仓库回归测试。建议补入正式浏览器测试并保留 producer 收到的原始字节/headers/metadata 断言。现有 Playwright 服务端是合成 fixture,不等同于真实 FastAPI;真实 FastAPI 生命周期测试单独覆盖其 receipt、owner history 和 revoke 行为,见下表。 + +## 关键不变式到代码/测试 + +| 不变式 | 当前实现/证据 | 结论 | +| --- | --- | --- | +| 1. 原始文件、Bearer、幂等键、六字段 metadata 和校验 | `src/web/static/js/app.js` 上传构造;`src/web/tests/local-upload.test.js` metadata 编码断言;`tests/integration/test_upload_intake.py` 实际 FastAPI intake | 独立检查通过;实际 browser producer 字节另有临时探针,非持久测试 | +| 2. capabilities 与认证状态 | `app.js` capabilities/认证消费;`src/web/tests/local-upload.test.js` | 本次定向及全套测试通过;未发现身份串用 | +| 3. 意图、一次提交、未知受理核实 | `app.js:1567-1613`;`local-upload.test.js` 有模拟请求路径 | P1 404 清 key;P2 签名碰撞;所需回归场景未全部提交 | +| 4. receipt、任务状态和公开 token | `tests/integration/test_upload_intake.py`;`test_local_upload_lifecycle.py` | 实际 FastAPI lifecycle 覆盖 accepted/owner receipt 与状态;浏览器 E2E 使用合成 fixture | +| 5. owner history、撤销及独立 token | `src/video_transcript_api/api/routes/audit.py`;`tests/integration/test_local_upload_lifecycle.py:536-578` | 实际路由断言 `view_token is None`、`upload_view_token` 有效、撤销后 token 为 None | +| 6. 真实浏览器生产行为 | `e2e/tests/local-upload.spec.ts`、`tests/browser_server.py` | Chromium 真实浏览器但服务端为 synthetic fixture;键盘/drop 与关键未知态目前只有临时探针,无持久回归锁 | +| 7. 无额外抽象/fallback/无消费者状态 | `app.js:1571-1576`;`history.html:986-991` | 见 P2-3、P2-4 | + +## 验证与边界 + +- `npm run test:web && npm run test:browser`:通过,14 个 Vitest 文件 / 185 个测试,Chromium 14 项通过。浏览器 runner 提示使用 Python 3.11 环境并设置 `UV_NO_SYNC`;其测试服务端为 synthetic fixture。 +- 临时真实 Chromium 探针:复现 P1 和 P2;另验证键盘 picker、DOM drop、提交字节 SHA-256/长度、全部 false 选项。探针只在一次性 scratch 中运行,未写入仓库。 +- `pytest -q tests/integration/test_local_upload_lifecycle.py`:8 项通过。全套 `make test` 在仓库 `.python-version` 指定的 Python 3.11 环境通过,记录 3821 pass、3 skip、0 fail、0 error、`[100%]`。第一次误用 Python 3.12 时依赖 `numpy==1.24.3` 因 `distutils` 缺失在 pytest 前构建失败;切换到项目 3.11 环境后通过。 +- base 反验:在官方 scratch 的 `be2e4fd` 上运行新增的 history token 断言,得到预期的业务断言差异 `None == upload token`,不是导入错误或 `KeyError`。 +- OCR 前置扫描按规定只运行一次,结果 `skipped`,原因 `backup:deepseek=leg_timeout`;因此 OCR 不能作为 clean 证据。私有证据:`/tmp/vta-upload-D-r2-261009-evidence.aCrvzE/ocr.stdout.json`、`ocr.stderr.txt`。 +- 派发时主干基线查询不可用(`gh api request failed`),继承红无法判定;没有声称 CI 主审通过。草稿 PR 中主审 job 的 skip 不能作为通过证据。 +- 所有验证使用隔离 scratch;review worktree 源码相对 H1 无改动。此次 review 不证明生产配置、完整 SDK 或部署链路行为。 + +## 交付建议 + +修复 P1 的 receipt 404 意图保留、P2 的 metadata 碰撞,并补齐阻断级浏览器回归场景后再复审。P2-3、P2-4 可同批做局部减法。除上述问题外,本次没有发现需扩大到后端或生产的变更。 diff --git a/docs/sessions/261008-local-upload/reviews/D-r3-verdict.md b/docs/sessions/261008-local-upload/reviews/D-r3-verdict.md new file mode 100644 index 00000000..3259b66a --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/D-r3-verdict.md @@ -0,0 +1,23 @@ + +# D-r3 独立审查记录 + +- 固定审查对象:`bd7510ac698dc6d7a9da7843460c79830de75fdf..a5e7836ce5547f448c92aac53a5af4d65041cdc7`。 +failure-visibility: skipped +- 软件严重度:未评估;没有形成软件 finding,也不能据此判为 clean。 +- 交付阻断:本轮独立审查未完成,D 的审查验收状态不可判定。 + +## 执行结果 + +现场确认位于指定 worktree,HEAD 为固定候选 SHA,分支为 `card/vta-upload-D-review3-261009`,起始状态干净。按现场接手流程运行 pickup 简报时,输出展开了先前会话的用户消息片段;这属于本卡禁止读取的会话日志输入。为保持审查独立性,立即停止,不再读取源码 diff、测试、设计文档、OCR 或任何候选作者材料;本文件不是代码审查结论。 + +触发污染的命令: + +```text +pickup-brief.sh --cwd <指定 worktree> +``` + +简报中的受限内容证据及完整命令、输出摘录见本派发外部报告。没有打开简报所指向的 auto-compact 文件,也没有读取实现报告、其他 review、候选外作者说明或 delegate 原始 JSON。 + +## 未执行项 + +增量四问、固定范围全审、OCR 前置扫描、真实浏览器/UI producer 与 consumer 验证、测试套件、变异反验均未执行。故没有源码/测试对应表、producer fixture 或验证通过声明。需由不暴露会话日志的干净独立执行重新审查同一固定对象;本记录不修改任何实现文件。 diff --git a/docs/sessions/261008-local-upload/reviews/E-r1-verdict.md b/docs/sessions/261008-local-upload/reviews/E-r1-verdict.md new file mode 100644 index 00000000..3d5fba37 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/E-r1-verdict.md @@ -0,0 +1,57 @@ + + +# E 卡独立审查结论(r1) + +failure-visibility: p2-only + +## 结论 + +发现 3 项 P2 测试证据缺口,当前不建议将 PR 标记为 ready;修正后再复审这些契约断言。固定审查范围为 `0af9901639f7b4940c6a9d051f467f2a13a971d1..56ad49b4127b10953f92c6fb72443561e1c6ba95`,结论针对该范围,不代表生产容量、真实模型质量、恢复域或部署安全已验证。 + +## Findings + +### P2:浏览器 producer metadata 未与 SQLite 持久化值比较 + +- **位置**:`e2e/tests/local-upload-full-chain.spec.ts:218-232`;`tests/local_upload_real_server.py:118-128`。 +- **证据**:E2E 解码并断言浏览器发出的 `X-Upload-Metadata` 等于 fixture,但真实状态查询没有返回 `local_uploads.request_metadata` 或任务的 `processing_options`,随后也没有把它们与浏览器实际 metadata 比较。独立 synthetic HTTP 测试覆盖了服务端 metadata 路径,但不能证明此浏览器 producer 的 wire payload 被实际持久化。 +- **影响**:浏览器功能或服务端解析若丢弃/改写处理选项,当前真实 producer 全链路仍可能通过。B3 的跨边界合同及合同索引所称“比较 producer bytes、SQLite metadata”没有被该 E2E 锁定。 +- **最小修正**:让 E2E 状态查询返回实际存储的 request metadata 与处理选项,在真实浏览器上传用例中断言它们与解码后的 producer metadata 一致;同步修正合同索引描述。 +- **交付阻断**:是。 + +### P2:真实全链路用例未观察意外 request failure 和 HTTP 错误响应 + +- **位置**:`e2e/tests/local-upload-full-chain.spec.ts:168-173, 371`。 +- **证据**:用例只收集 `pageerror` 和 `console` 的 error 类型。它逐项检查了被显式等待的几个请求状态,但没有注册 `requestfailed` 观察器,也没有收集页面请求中未预期的 HTTP 4xx/5xx。测试末尾的 `browserErrors` 因而不约束这两类失败。 +- **影响**:全链路仍可能在后台出现失败请求或意外 HTTP 错误而通过,不满足 E 卡对意外浏览器网络失败可见的验收不变量。 +- **最小修正**:在此真实用例中记录页面 `requestfailed` 和非预期 HTTP 错误响应,并在结束时断言为空;对有意预期的状态作局部显式声明。 +- **交付阻断**:是。 + +### P2:读取/到期测试没有控制在途读取跨越到期边界 + +- **位置**:`tests/integration/test_upload_races.py:65-102`;`docs/testing/local-upload-contracts.md:36`。 +- **证据**:测试先把首个权限检查的 `now` 固定为截止前一秒,暂停后立即释放请求;之后再对一个全新 GET 固定使用截止时刻并断言 404。它验证了截止前授权的读取和截止时刻的新读取结果,但没有在首个请求暂停期间推进时间至到期后再放行,因此没有证明“已授权传输跨过到期仍可完成”的受控竞态。索引称其为“两种/受控顺序”的表述超出了当前断言。 +- **影响**:若读取路径在授权之后、发送正文之前错误地重新按当前时刻拒绝,当前测试不能区分这种行为与合同允许的在途传输继续完成。设计合同明确允许已经授权的传输继续,并要求新读取在到期后拒绝。 +- **最小修正**:在首个 public read 的授权后暂停时,将测试时钟推进到到期边界,再放行并断言该响应仍成功;随后以相同到期时间发起新 GET 并断言 404/无正文。索引准确描述所覆盖的顺序。 +- **交付阻断**:是。 + +## B1–B16 契约索引核对 + +- **有具体测试映射且本地窄验证通过**:B1、B2、B4–B14、B16。B16 只验证本地开关关闭时的拒读与清理保护,不等于实际恢复域证明;合同文档对此限制有明确说明。 +- **部分证据**:B3 的 producer 字节与 metadata wire header 断言存在,但实际浏览器 metadata 到 SQLite 的绑定缺口见 P2-1。B15 有 Chromium 提交、历史、读取与撤销路径,网络失败观察缺口见 P2-2。 +- **B5**:文档映射指向真实 intake/dispatcher 测试,当前标准测试入口通过。 +- **部署和生产义务**:生产额度/容量、外部镜像恢复域、生产 ASR/模型质量和生产启用未被本次本地证据覆盖,也不是本地 E2E 可替代的证明。 + +## 验证与证据 + +- 固定 HEAD:`56ad49b4127b10953f92c6fb72443561e1c6ba95`;基线:`0af9901639f7b4940c6a9d051f467f2a13a971d1`。审查前 worktree clean,差异为 8 个文件、1290 行新增、31 行删除。 +- 官方 scratch 中执行 `TMPDIR=/tmp make test && npm run test:web && npm run test:browser`,退出码 0。浏览器测试 17/17,Web 测试 186 passed;pytest 入口完成且有既有 skip,静默输出没有打印最终通过计数。日志保存在本次审查 dispatch scratch 的 `narrow-verify.log`。 +- 实际临时 systemd unit 在目标环境执行 `tests/unit/test_local_upload_policy.py`,17 passed,`Result=success`、`ExecMainStatus=0`;记录保存在 dispatch scratch 的日志中。 +- 官方 scratch 重放了到期读取用例和浏览器真实全链路用例,分别 1/1 通过。真实 producer 契约产物与哈希、SQLite race 数据库及真实子进程环境记录保存在本次审查 dispatch scratch 的 `real-chain-evidence/`、`race-sqlite-evidence.db` 和 `real-server-runtime-evidence.txt`。本项为通过证据,不消除上列未锁定点。 +- 变异检查:将 `CacheManager.local_upload_share_is_active` 的到期判断临时反转,真实 HTTP route race 用例按预期以 `404 == 200` 断言失败;恢复后源码哈希与原值一致,临时变更不在差异中。 +- OCR 审查:一次执行返回 `status=skipped`,原因 `backup:deepseek=leg_timeout`;primary 返回 `content_error`。按纪律未重试,故 OCR 结论为 skipped,不视为软件 finding。 +- Hosted CI 与 PR ready 后的完整主审未运行;审查没有改变 PR ready 状态。基线 CI 结论也未能通过当时的 GitHub API 查询确定,保持未知。 +- 证据边界说明:审查中一次宽范围文本检索误触及 session progress 文档;其内容未作为判断依据。判断仅基于固定代码差异、批准设计合同、变更的契约索引及实际验证产物;未读取 PR 描述或其他审查记录。 + +## 详细执行报告 + +`report.md`(写入本次审查 dispatch 配置的独立报告目标) diff --git a/docs/sessions/261008-local-upload/reviews/E-r2-verdict.md b/docs/sessions/261008-local-upload/reviews/E-r2-verdict.md new file mode 100644 index 00000000..20f0a8b9 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/E-r2-verdict.md @@ -0,0 +1,15 @@ +# E-r2 独立审查结论 + +failure-visibility: clean + +**结论:通过;没有发现本轮 diff 引入的 P1、P2 或 P3 问题,不阻断交付。** + +审查范围固定为 `56ad49b..144411ee4e2e884e62d971444b263d7dc459d98c`。本轮改动限于上传契约文档、集成测试、真实服务测试夹具和 Chromium 全链路测试;没有改产品运行逻辑。 + +- 上传浏览器测试现在将 wire metadata 与真实 SQLite `local_uploads.request_metadata`、`task_status.processing_options` 对照。真实 Chromium 上传链路通过。 +- 页面网络断言收集 `requestfailed` 与 HTTP 4xx/5xx,只放行文档列出的两个历史页请求取消。临时注入 `/livez` 的可取消请求后,测试在新增的意外网络断言处按预期失败;测试文件与产品文件均已恢复原状。 +- 到期竞态测试让在途读取与后续读取使用同一个可推进的时钟,并在放行在途响应前确认读取仍在等待。对应集成测试通过。 + +验证结果:`make test` 通过(5 项跳过);`npm run test:web` 通过(14 个文件、186 项);`npm run test:browser` 通过(17 项)。另以临时 systemd 用户单元运行隔离网络下的 `npm run test`,17 项全部通过。CI 未运行,本结论不代表 CI 结果。 + +OCR 前置扫描返回 `status=skipped`、`reason=primary=leg_timeout`,外层退出码 124;因此没有把 OCR 记作干净扫描。人工审查和上述本地验证已完成。 diff --git a/docs/sessions/261008-local-upload/reviews/E-r3-verdict.md b/docs/sessions/261008-local-upload/reviews/E-r3-verdict.md new file mode 100644 index 00000000..16cd21b2 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/E-r3-verdict.md @@ -0,0 +1,115 @@ + +failure-visibility: p2-only + +# Local upload E 完整交付独立审查 verdict + +## 结论与机器判定 + +执行器已按固定范围完成完整审查与规定验证,outcome 为 succeeded。代码审查有一项 P2,因此 verdict 的机器判定为 failure-visibility: p2-only。该 P2 影响 E2E 子进程临时目录的隔离,不阻断产品交付,也没有观察到临时数据逃逸。 + +唯一 finding 位于 e2e/tests/local-upload-full-chain.spec.ts:54:startRealServer() 将子进程 TMPDIR 固定为 /tmp,没有沿用 private scratch TMPDIR。真实 full-chain 子进程采样验证了这一点。仓库 verdict 含有触发条件、影响、严重度与交付阻断判断、B1–B16 矩阵和完整竞态/运行记录。 + +## 审查基线、范围和方法 + +- 固定主审范围:0af9901639f7b4940c6a9d051f467f2a13a971d1..b50c861ef276248db087466992d549cc8edc90fe。不是只看 144411ee..b50c 的四个增量问题。 +- 逐个读完全范围 8 个路径的最终全文,并核对完整 git diff;另沿真实 source consumer 检查 upload router/cache、dispatcher/transcriber、view-token resolver、public views/reprocess/audit、浏览器页面和通知 consumer。只使用批准的 design 与 QA plan;未读取作者报告、PR 讨论、旧审查或会话材料。 +- 完整改动规模:8 paths,1,315 insertions / 31 deletions。包括 394 行真实 Chromium full-chain、受控竞态、SQLite 与 loopback fixture、契约索引和测试说明。审查按代码和 consumer 断言判断,没有以契约索引文字代替实现证据。 +- 批准 QA 计划 B1–B16 每项的 producer、consumer assertion、产物和状态见 verdict 矩阵。五组受控竞态逐项审查;没有将静态回显或单进程断言冒充跨边界 producer 输出。 + +## 全链路审查结论 + +新浏览器路径使用真实 Chromium 文件选择与 Blob/raw body,真正进入 FastAPI upload router;SQLite 写入 owned media 映射、接收元数据、任务和队列,然后由真实 dispatcher / transcriber 消费。实际 ffprobe 执行目标是 owned file;CapsWriter SDK 保持真实,通过 loopback WebSocket 产生与消费实际协议帧;LLM 和通知的替换只发生在最终本地 HTTP 服务边界。 + +终态正文由真实 server 生产、保存在 SQLite-backed cache,并由 public route 消费;页面继续通过 public URL、owner history 与 owner DELETE 完成 lifecycle。通知 JSON 来自真实 notification client,并在本地 HTTP sink 捕获。随后以同一 server 验证关闭分享后的 fresh read 拒绝。无预置终态 receipt/body/history,也没有将 ASR 方法整体替换为 mock。原有 D synthetic browser 回归仍保留,新 real-chain 独立运行。 + +B1–B16 的逐项 source/consumer/producer/runtime 记录、五组 race 顺序、OCR 状态和已验证边界均在仓库 verdict 文件中详列。范围和已运行的完整测试不支持声称已验证生产容量、真实模型效果、最低线上镜像或外部 restore 域;这些项目仍是明确的未知。 + +## 独立验证记录 + +官方 scratch-worktree 从 base b50c861ef276248db087466992d549cc8edc90fe 创建。创建后检查隔离目录缺席和 inode 分离;环境使用 scratch 自有 Python 3.11.16 / Node v24.14.0 / Chromium 151.0.7922.34,env -i,user/network namespace,离线且仅 loopback 可用。 + +- uv sync、root npm ci、npm --prefix e2e ci:通过。 +- make test:exit 0。输出有 5 个 skip 标记;未从流式输出取得最终逐项 summary,因此本报告不推测各 skip 原因。 +- npm run test:web:14 files、186 passed。 +- npm run test:browser:17 passed、1 worker、0 retries。 +- 关键反验:在 scratch 单行禁用正常 page response observer 后,活性断言精确失败(预期大于 0,实际为 0);恢复后字节与 git 版本相同,SHA-256 为 6cabefc26fc6bb6564c4a41c35cdb134ab79fe2123a3efb443071d561f1f81f4。 +- 实际 full-chain 进程由 strace 采集到 argv/cwd/env;其中 TMPDIR=/tmp 是 finding 的直接消费环境证据。 +- 实际 systemd transient unit 消费真实 scratch SQLite upload。unit 和 consumer 侧记录一致:success、退出码 0、功能开关/env、cwd、argv 和 transcript SYSTEMD_LOCAL_UPLOAD_RESOLVER_PROBE。 + +## OCR + +按要求运行一次,primary / chain / outer 限时 20s / 45s / 65s。工具结果 status=skipped、reason=primary=leg_timeout、attendance ledger 写入成功、外层退出码 124。此项是 skipped,不是 clean;未重跑或更换 provider。 + +## 交付状态与文件完整性 + +只新增 verdict 文件,不改产品 source、tests、goals、配置、PR 或其它报告路径。产品源码对 base b50c861ef276248db087466992d549cc8edc90fe 的差异为零。提交 SHA 和 push 远端结果在完成提交后回填于此。 + +## B1–B16 契约矩阵 + +| 项 | 实现与消费断言 | 实际 producer / 产物及验证状态 | +| --- | --- | --- | +| B1 鉴权、开关与 metadata 先于 body | routes/uploads.py 的 capabilities、raw intake、metadata decode;upload intake 集成测试断言拒绝请求 body-read 计数为 0。 Tests: test_real_http_stream_sqlite_dispatch_idempotency_and_owner_stop; test_disabled_and_missing_limits_reject_before_body; test_receiver_and_inflight_limits_reject_without_body_consumption | ASGI/FastAPI intake 请求走真实 Bearer 边界和真实 router;浏览器 full-chain 另走实际页面 POST。全量 make test 返回 0。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B2 有界接收与容量拒绝 | intake / upload reservation;声明与实际字节上限、并发/receiver、free-space、queue-full 路径断言拒绝时无 202、无 owned file/dispatch,并检查 reservation 释放。 Tests: test_actual_free_space_shortage_rejects_before_body_or_file; test_declared_and_observed_limits_check_bytes_not_content_length; test_queue_full_has_no_success_receipt_or_dispatch | 真实 HTTP intake 请求及实际 queue consumer;free-space 和队列故障使用受控本地 fixture。生产磁盘峰值和线上容量不在本次证据内。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B3 页面 producer 到 raw bytes | UploadAPI.submitUpload 到真实 raw intake;断言 producer 字节、owned file 字节、服务端 SHA-256 一致,并将 wire 六字段与 SQLite request metadata、task processing options 对照。 Tests: test_real_http_stream_sqlite_dispatch_idempotency_and_owner_stop; Chromium full-chain test at e2e/tests/local-upload-full-chain.spec.ts:168 | Chromium 文件选择和真实 fetch;observer 读取实际 Blob body 与 headers,但不修改请求。full-chain 运行通过。 Runtime status: make test aggregate exit 0; browser suite 17 passed, 1 worker, 0 retries. | +| B4 相同意图只受理一次 | register_local_upload / accept_local_upload 的真实 SQLite 唯一性、owner/key 与 metadata 冲突断言。 Tests: test_concurrent_same_owner_key_creates_one_sqlite_receipt; test_real_http_stream_sqlite_dispatch_idempotency_and_owner_stop | 两个独立 SQLite connection 控制 BEGIN IMMEDIATE 的先后;HTTP 层检查同 key、冲突零 body、不同 key 独立任务。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B5 持久受理与 stale queue | HTTP intake、SQL commit、真实 dispatcher durable-admission 查询;queue 满、commit 失败、丢失 202 回执、stale item 均有断言。 Tests: test_queue_full_has_no_success_receipt_or_dispatch; test_http_queue_handoff_precedes_durable_acceptance_commit; test_sql_commit_failure_leaves_stale_queue_item_for_dispatcher_to_drop; test_lost_202_receipt_retry_queries_original_without_requeue; test_dispatcher_fails_stale_local_mapping_before_processing_or_executor_submit | 真实 queue producer / dispatcher consumer;断言 stale payload 不进入 PROCESSING、executor 或 ASR。由全量 make test 执行,输出带有未逐项归因的 skip 标记。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B6 文件归属和媒体准入 | owned path 映射、_ensure_audio_track、时长与音轨限制,失败需在 ASR 前拒绝且不走 URL downloader。 Tests: test_local_media_admission_rejects_before_asr_and_never_uses_url_downloader; Chromium full-chain test at e2e/tests/local-upload-full-chain.spec.ts:168 | Chromium 上传真实合成 WAV;全链路真实 /usr/bin/ffprobe 探测,检查 shim 捕获的 argv 指向 SQLite owned file。 Runtime status: make test aggregate exit 0; browser suite 17 passed, 1 worker, 0 retries. | +| B7 共用 ASR 协议与源文件清理 | 真实 dispatcher、process_transcription、CapsWriter client;断言媒体在 LLM 前清理,失败路径不发布。 Tests: test_dispatcher_uses_real_transcriber_then_cleans_owned_media_before_llm; test_local_upload_funasr_branch_observes_source_only_before_llm | CapsWriter SDK 本体通过 loopback WebSocket 收发真实帧;模型服务仅在最终 HTTP/服务边界使用本地 fixture。没有替换整个 ASR 方法。 Runtime status: make test aggregate exit 0; browser suite 17 passed, 1 worker, 0 retries. | +| B8 root 固定保留期 | terminal success/failure、孤儿恢复与 never expiry 的固定 cutoff / write-once 断言。 Tests: test_root_terminal_sets_fixed_30_day_expiry_for_success_and_failure; test_startup_orphan_failure_anchors_upload_terminal_clock_once; test_never_expiry_and_revocation_are_write_once | 真实 SQLite terminal 写入由 lifecycle/policy 测试驱动;full-chain 完成后由真实 public reader 消费。通过全量测试入口,跳过标记原因未保留。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B9 单一 public read gate | ViewTokenResolver 与 view/raw/page/export/summary 入口;撤销后逐路断言 404 且不泄漏正文,legacy 空 token 不回退。 Tests: test_public_read_summary_exports_and_owner_history_close_without_content_bypass; test_upload_task_uses_blank_legacy_column_and_separate_upload_token | full-chain 走真实服务的正文读取、owner history 和 owner DELETE;独立生命周期测试逐路消费真实 FastAPI 路由。 Runtime status: make test aggregate exit 0; browser suite 17 passed, 1 worker, 0 retries. | +| B10 三个 owner reprocess callers | recalibrate、resummarize、generate_notes 经共享 SQLite 原子 admission gate;断言子任务 legacy token 空、child group 不串根。 Tests: test_reprocess_admission_holds_sqlite_order_against_concurrent_close; test_reprocess_snapshot_before_close_is_rejected_by_fresh_transaction | 两组 controlled-order 生命周期测试覆盖三 endpoint 的 admission-wins / revoke-wins,并检查实际 child queue 或零 child 拒绝。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B11 terminal publish 与撤销单调 | update_task_status、revoke_local_upload 与快照修复消费端;断言 revoked_at 不复活、正文保留而新读拒绝。 Tests: test_terminal_publish_and_revoke_are_both_write_once; test_success_snapshot_reaches_final_http_payloads_in_order | publish-before-revoke 与 revoke-before-publish 均由真实 SQLite/路由控制;fresh public reader 重查;通知 payload 顺序另有真实 HTTP sink 检查。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B12 清理与有效成果 | cache/task/receiving cleanup 与 publish 两顺序;开关关闭不清理有效 30d/never 成果,失败删除不得记成功。 Tests: test_active_upload_cleanup_and_cache_publish_preserve_body_in_both_orders; test_cleanup_protects_active_upload_cache_even_when_feature_is_disabled; test_task_cleanup_keeps_effective_share_root_for_audit_and_url_rows_still_expire; test_revoke_and_task_cleanup_are_ordered_at_the_sqlite_guard; test_receiving_cleanup_rechecks_after_accept_wins_and_preserves_owned_file; test_receiving_cleanup_retires_before_unlink_and_accept_loses | cache cleaner、真实 save_cache、SQLite 映射和文件字节由 Event/lock 控序消费。production restore / 旧镜像边界未验证。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B13 有限 key tombstone | receiving 清理退休 key 后的重新注册拒绝窗口,避免无限保留 upload body/tombstone。 Tests: test_real_sqlite_upload_identity_owner_key_and_intent_window; test_receiving_cleanup_retires_before_unlink_and_accept_loses | 真实 SQLite 记录经 cleanup producer 删除/退休,后续同旧 key 请求消费并断言可见拒绝。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. | +| B14 实际通知 payload | 终态通知 producer、通知 router/channel 与最终 payload;断言链接、完整摘要和回执顺序,无服务器路径/凭据。 Tests: test_accepted_upload_link_reaches_final_http_payload_with_public_capability; test_local_upload_terminal_producer_sends_public_share_url_and_source_label; Chromium full-chain test at e2e/tests/local-upload-full-chain.spec.ts:168 | full-chain 经真实通知 client 到本地 HTTP JSON sink;接收的真实 JSON 后续由真实 public reader 跟随 URL,并在 owner revoke 后验证拒绝。通知没有发往外部服务。 Runtime status: make test aggregate exit 0; browser suite 17 passed, 1 worker, 0 retries. | +| B15 页面交互和真实 producer | index/history/app.js 页面选择、metadata、错误收集、历史和 owner close。 Tests: e2e/tests/local-upload.spec.ts; Chromium full-chain test at e2e/tests/local-upload-full-chain.spec.ts:168 | Chromium 实际文件选择、页面请求与 fetch body。浏览器断言 page errors、requestfailed 和非预期 HTTP 错误;单行 observer 负控令正常响应计数不再增加时,toBeGreaterThan(0) 断言按预期变红。 Runtime status: make test aggregate exit 0; browser suite 17 passed, 1 worker, 0 retries. | +| B16 最低镜像与 restore | SQLite resolver/cleanup 测试锁住 feature disabled 拒绝正文且不误删有效成果。 Tests: test_resolver_reads_active_upload_and_denies_disabled_revoked_or_expired; test_cleanup_protects_active_upload_cache_even_when_feature_is_disabled | 本地有真实子进程环境与 SQLite 消费证据;没有真实最低 guard-only image 或 restore 域 producer,因此镜像/恢复安全仍未认证,不把本地证据外推为部署授权。 Runtime status: covered by make test, aggregate exit 0; 5 suite skip markers were not mapped to individual tests. External image/restore remains unverified. | + +## 五组受控竞态 + +1. **同 owner/key 双连接:** test_concurrent_same_owner_key_creates_one_sqlite_receipt 分别让两个真实 SQLite connection 先取得写事务;消费断言只有一个 receipt/task/queue entry。HTTP 集成另外消费冲突请求并断言不读取新 body。 +2. **publish / revoke:** test_terminal_publish_and_revoke_are_both_write_once 分别先 publish 和先 revoke,再由 fresh /view/{token} 读取;撤销保留,正文不被新请求读出。 +3. **cleanup / publish:** test_active_upload_cleanup_and_cache_publish_preserve_body_in_both_orders 在真实 cache lock / 候选快照处控序,并从最终 SQLite mapping 与文件 bytes 检查有效成果仍在。 +4. **三个 owner reprocess / revoke:** test_reprocess_admission_holds_sqlite_order_against_concurrent_close 与 test_reprocess_snapshot_before_close_is_rejected_by_fresh_transaction 覆盖三个 owner endpoint 的两种先后;检查已受理 child 可完成、撤销先赢则拒绝且零 child。 +5. **read / expiry:** test_public_read_and_expiry_both_controlled_orders 首次请求真实授权后暂停,用同一时钟推进到 cutoff,确认在途请求尚未完成再放行(200/正文);fresh request 读取同一 resolver 后为 404 且无正文。没有依赖 sleep 猜测顺序。 + +## 固定审查范围与具体 Finding 证据 + +完整全文审查和实际 consumer 核对的路径: + +- docs/testing/local-upload-contracts.md +- e2e/tests/local-upload-full-chain.spec.ts +- tests/README.md +- tests/fixtures/local_upload_loopback.py +- tests/integration/test_local_upload_lifecycle.py +- tests/integration/test_upload_races.py +- tests/local_upload_real_server.py +- tests/unit/test_local_upload_policy.py + +源范围严格为 0af9901639f7b4940c6a9d051f467f2a13a971d1..b50c861ef276248db087466992d549cc8edc90fe。读取了这 8 个文件完整最终正文,并运行完整范围 git diff;同时核对了 upload route、cache、transcription dispatcher、view-token resolver、public read/export、三个 reprocess handler、audit/history、浏览器 app 与通知消费者。实际差异为 1,315 insertions / 31 deletions,8 files changed。未用仅增量 review 替代全文审查。 + +### Finding:P2,非阻断 + +在 e2e/tests/local-upload-full-chain.spec.ts 第 54 行,startRealServer() 的 spawnEnv 显式写入 TMPDIR=/tmp。触发条件是在父 E2E 进程已获得私有 scratch TMPDIR 的隔离运行环境中启动 full-chain API 子进程;子进程不会继承父进程的该值,而会把 Python/依赖库可能产生的临时文件放到 scratch 外。实际 strace 捕获的子进程 argv 为 scratch .venv/bin/python tests/local_upload_real_server.py --run-dir ,cwd 是 scratch worktree,env 明确为 TMPDIR=/tmp。其 run-dir、SQLite、媒体和浏览器产物都在 scratch,本轮未观察到 /tmp 上的业务或临时写入。 + +严重度为 P2;交付影响为非阻断。建议使用本次 private scratch TMPDIR,并由测试验证实际 child env。修复不属于独立 reviewer 本卡授权范围,没有更改实现或测试 source。 + +### 实际环境边界的证据 + +独立 scratch 的依赖安装成功:uv sync、root npm ci、e2e npm ci。测试环境为 env -i、私有 Python 3.11.16 / Node v24.14.0 / Chromium 151.0.7922.34、user/network namespace、离线、仅 loopback 可达,IPv4/IPv6 主路由为空。make test exit 0,输出可见 5 个 skip 标记但没有可靠的逐项 summary;npm run test:web 为 14 files / 186 passed;npm run test:browser 为 17 passed、1 worker、0 retries。 + +负控只在 scratch 临时将正常 response observer 的计数从 += 1 改为 += 0;浏览器测试准确因活性断言期望大于 0、实际为 0 而失败,其他 16 个浏览器测试通过。恢复后的文件与 git 原文逐字节相同,SHA-256 为 6cabefc26fc6bb6564c4a41c35cdb134ab79fe2123a3efb443071d561f1f81f4。 + +systemd 证据来自真实 transient unit 对 scratch SQLite upload 的消费,而不是静态配置:Result=success、ExecMainStatus=0、VTA_UPLOADS_ENABLED=true;真实 WorkingDirectory、argv、HOME、TMPDIR、PYTHONPATH 均由 systemctl show 和消费者 JSON 记录,ViewTokenResolver 返回 SYSTEMD_LOCAL_UPLOAD_RESOLVER_PROBE。full-chain process 的 argv/cwd/env 同样由 strace 实际采样。 + +OCR 一次运行的完整结果为 status=skipped、profile=null、model=null、reason=primary=leg_timeout、findings=[]、reason_chain=[{leg: primary, reason: leg_timeout}]、attendance_ledger_write=ok;primary/chain/outer 限额 20s/45s/65s,外层 exit 124。记录为 skipped,不算 clean,也未重试。 + +未验证的边界:生产容量与最小镜像、真实 restore 域、真实外部通知/ASR、生产反向代理、真实模型质量与恢复后回滚。这些是明确的未知,不构成本地功能通过证据或部署授权。 + +## 完整 subprocess 环境与发布边界 + +full-chain 的实际 API child 由 strace 观察到 argv、cwd、env。spawnEnv 完整 payload 定义为:PATH=父 process.env.PATH(代码 fallback 为 /usr/bin:/bin),HOME=父 process.env.HOME(代码 fallback 为 /tmp),TMPDIR=/tmp,LANG=C.UTF-8,PYTHONUTF8=1,PYTHONUNBUFFERED=1。cwd=REPO_ROOT;argv 为 scratch .venv/bin/python、tests/local_upload_real_server.py、--run-dir、scratch run directory。实际运行确认 child 收到 TMPDIR=/tmp。完整解析值保存在约定的本机外部报告。 + +systemd 实际消费者环境由 systemctl show 和其自身写出的 JSON 一致采集:PATH=,HOME=/review-private/home,TMPDIR=/review-private/tmp,PYTHONPATH=/worktree/src,PYTHONUTF8=1,VTA_UPLOADS_ENABLED=true;cwd=/worktree。argv 为该 scratch 的 .venv/bin/python、review-private/consume.py、review-private/systemd-db、synthetic upload token、review-private/systemd-consumer.json。消费结果 status=success、transcript=SYSTEMD_LOCAL_UPLOAD_RESOLVER_PROBE。token 是临时合成值,未记录其内容。完整实际路径保存在本机外部报告。 + +跨文件/进程 payload 保持实际 consumer 来源:浏览器 observer 比较实际 Blob body 和 headers;服务端 SQLite request_metadata / processing_options 与 decoded 六字段比较;CapsWriter loopback 端接收真实 SDK frame;本地通知 HTTP sink 捕获实际 JSON;systemd consumer 读取自己的 env/cwd/argv 并实际调用 ViewTokenResolver。外部通知、生产凭据和生产服务均未使用。 diff --git a/docs/sessions/261008-local-upload/reviews/F-r1-verdict.md b/docs/sessions/261008-local-upload/reviews/F-r1-verdict.md new file mode 100644 index 00000000..d46b0dfc --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/F-r1-verdict.md @@ -0,0 +1,88 @@ +# F-r1 独立审查结论 + +failure-visibility: p2-only + +- 固定审查头:`de3ff7b6671418ae7746649ef2167d5778c790ef` +- 基线:`0ae4bffe911fa93bb858d24fd173aee602af9c0b` +- F 软件增量:`21329ec6ef8be569b2c4f29d5a3cfbddaa7ad571..de3ff7b6671418ae7746649ef2167d5778c790ef` +- 风险档:personal;Task-Id 与 Fixes-Issue 在任务卡中为空。 +- 软件工具验收:**未通过,一项 P2**。M6 工具的 ancestry 状态不满足“unknown 与已证不兼容分开”的要求。 +- 交付阻断:**阻断 F/M6 软件工具验收**,直到下述状态误报修正或由 lead 明确裁决接受。此结论不等于生产风险 P1。 +- 生产启用:**仍阻断且证据为 unknown**。CLI 对所有场景均输出 `UPLOAD_ENABLE_BLOCKED` 并返回 2;本轮没有访问生产服务、卷、unit、容量或恢复域。 + +## 范围与隔离 + +已枚举 `0ae4bffe..de3ff7b6` 的 18 个变更路径,检查了允许审查的工具、测试、操作文档、配置示例、compose 及规划差分。按任务卡禁止读取 `docs/sessions/261008-local-upload/progress/F-progress.md`;没有读取实现报告、旧 review、顾问报告、实现卡或 PR #208 正文。固定设计 `docs/sessions/261008-local-upload/design.md`、M6 与 GOALS/六份 goal 及 `roadmap-after-C.md` 用于核对原门槛。M4 仍阻塞、M5 未开始、M6 只允许软件准备;M4/M5、真实容量/恢复、环境证据和授权仍是完整验收与生产启用前置,没有被规划差分删减。 + +独立运行使用官方 scratch-worktree 入口,固定 `H0=de3ff7b6671418ae7746649ef2167d5778c790ef`。成功的完整验证树为 `/worktree`,开始时状态空,`config/config.jsonc`、`data`、`.venv` 均不存在。示例配置 inode 分别为 scratch `2049:3674756`、主树 `2049:2883693`、实现树 `2049:18877700`,没有共享 inode。新建 `.venv` 使用本地 CPython 3.11.15,`UV_OFFLINE=1`;配置、用户、媒体、SQLite、日志和 pytest 临时文件都在该 scratch 临时域。结束时 git status 为空,官方包装器已移除 scratch;最终 subprocess payload 另在 `/worktree` 捕获并自动清理。 + +所有运行检查置于 `bubblewrap --unshare-net` 网络命名空间:仅 `lo` 接口(`127.0.0.1/8`、`::1/128`),无外部路由;绑定写权限仅 scratch 域和本机 uv 包缓存。服务启动、URL fixture、ASR rejector 均通过 loopback。没有测试 systemd 或生产环境。 + +## Findings + +### F-R1 — P2:Git ancestry 查询错误被误报为已确认不兼容 + +- 违反不变式:spec §1 要求 unknown 与明确不兼容保持两态;spec §5 要求真实源码 ancestry 证据与运行环境未知分开。 +- 代码:`scripts/ops/local_upload_check.py:113-127`,`git merge-base --is-ancestor` 的任何非零退出都落到 `incompatible`。测试 `tests/unit/test_upload_ops.py:56-63` 只锁定真实祖先/非祖先,没有锁定 ancestry 查询本身失败时必须 unknown。 +- 真实条件与复现:在 scratch 内建只含一个普通 commit 的临时 Git 仓库,令其 HEAD 为 `aa40829b58a76f62cf840856ba667b79085275cd`。`git -C merge-base --is-ancestor 6af391d20edab8dfd8b320ce6b91d4d19e3260e2 ` 返回 128,stderr 为 `fatal: Not a valid commit name 6af391d20edab8dfd8b320ce6b91d4d19e3260e2`;同一输入调用 `compatibility_status` 却返回 `incompatible`。这是缺少最低安全 SHA 对象/不完整历史时的查询错误,不是“确认该源码早于最低版本”。 +- 对照:实际 H0 仓库返回 `safe-lineage`;已存在的 pre-A SHA `38b299468daaf309608f46ffe1c59886b0922c04` 返回 `incompatible`;全零、无法解析的 source SHA 返回 `unknown`。所以错误限定在 commit 能解析、但 ancestry 查询自身失败的路径。 +- P1 两问:①本轮在完整 H0 源仓实测到的 merge-base 查询成功;没有生产 checkout/部署证据证明不完整仓路径在真实服务环境触发,不能定 P1。②触发后会给出错误的保守状态,但 `run_check` 仍打印生产容量/恢复/ingress unknown、`UPLOAD_ENABLE_BLOCKED` 并返回 2;未发现它能启用上传或造成数据损害。定 P2。 +- 阻断理由:不会绕过安全开关,但会把“无法证明”呈现为“已证不兼容”,违反本卡明确的软件工具状态契约,因此阻断 M6 软件工具验收;不据此宣称存在生产 P1。 + +没有其他达到可执行 finding 门槛的意见。`docker-compose.deploy.yml` 默认 `.env` 形态的来源报告正确;`send_terms` 示例删项经真实加载器与 consumer 核对,不改变本轮已接受配置默认行为。`deployment_env_source` 对非默认 development compose 的 `- path:` 形式没有做完整 YAML 解析;任务文档的运维命令明确指定 tracked deploy compose,故不作为本轮交付 finding。 + +## 七项不变式的证据结论 + +1. **解析器、限额、磁盘、时长、bytes、ancestry、开关。** `scripts/ops/local_upload_check.py:26-127,143-211` 消费 `load_and_validate_config`、真实 data 路径和 `ffprobe`;`tests/unit/test_upload_ops.py:11-63` 锁正有限限额、size/duration 与 ancestry 正反例。真实 CLI 对 4 MiB、0.02 小时、并发 2、temp budget 12 MiB 与 20 秒/1,920,044 字节合成样本报告 `within_declared_limits`;source 状态漏洞见 F-R1。`process_upload_gate=exact_true` 仍得到 `UPLOAD_ENABLE_BLOCKED` 与退出码 2。无真实生产证明仍为 unknown。 +2. **受限 HTTP/SQLite/worker/ffprobe/URL 样本与 producer payload。** `scripts/perf/local_upload_capacity.py:96-135,138-207,238-426`;`tests/unit/test_upload_ops.py:66-111`、`tests/integration/test_upload_restore.py:150-180`。5 秒真实 API 探针:PCM s16le mono 48 kHz,480,044 字节;两份 raw HTTP receipt,持久 SHA-256 `27cb785df0cedf3e8136cf6893ae9a93a7220efc0420bd3e03dfc1c367f29638`;受控 URL 请求 1 次且和 upload 区间重叠;loopback ASR rejector 4 次连接,3 个 worker 均以预期失败终态结束。报告将 ASR/生产容量明确标 unknown。另一个 20 秒样本的 1,920,044 字节 SHA-256 为 `d85c8af3ff68fa7dec3d39d1b430293752de59f30da354b7ab30ea2be6c1c5f5`。 +3. **snapshot/旧 DB 恢复/fresh consumer/保护。** `tests/integration/test_upload_restore.py:40-148,183-249` 使用实际 CacheManager SQLite、转录正文 bytes、撤销前 snapshot、撤销后 DB 和独立 fresh 进程;旧 snapshot 的 `revoked_at=None` 由外部 false gate 保持不可读,进程实际输出 `UPLOAD_DISABLED`;当前 cleaner 保留有效 never 正文与 root。guard-only A SHA 的独立消费者对同类真实 producer 结果读出正文,并拒绝 upload/blank legacy alias。既有 `tests/unit/test_local_upload_policy.py` 还覆盖 active 30d/never 与清理消费者。完整 production 恢复域、审计卷及撤销权威源未实测,仍 unknown。 +4. **失败、清理、启动、进程退出。** 容量实验对运行时间强制 1–30 秒;真实探针启动 FastAPI、等待 worker 终态,并由上下文回收进程、HTTP fixture 与临时域;loopback ASR 失败作为预期显式记录,没有 fallback/retry/catch。未单独注入 systemd/生产启动失败或进程中断,不能据此宣称生产清理证明。 +5. **旧源码与配置/操作文档。** `MINIMUM_SAFE_SHA` 只断言 Git ancestry,不声明 OCI digest 与源 SHA 绑定;文档同时保留这一限制。模板保留四项 `null`,真实 parser 可消费合成正有限配置。`send_terms` 删除自 example 的默认 consumer 为 false,且严格 loader 不接受该未知字段;不改变本轮有效配置路径。CLI 只输出白名单限额、disk free、sample 和状态,不读取 dotenv,不打印配置秘密。 +6. **scratch/隔离。** 使用新 known-empty scratch、独立 `.venv`、固定 H0,loopback-only network namespace;未在主树、实现树或 review 树跑 pytest/make/服务。三个成功执行阶段结束均由 wrapper 清理,主树与实现树未写入。 +7. **软件交付与生产启用分离。** 当前工具有真实本地行为,`run_check` 仍 fail closed;生产容量、ASR 实际工作集、ingress、OCI/source 绑定、systemd 身份/mount、完整恢复域、CI/systemd 环境验证与部署授权全部保留 unknown,不能由本地样本或文档替代。 + +## 命令与实际结果 + +完整 scratch 命令入口: + +```text +/scripts/git/scratch-worktree.sh de3ff7b6671418ae7746649ef2167d5778c790ef -- /bin/bash -s +``` + +入口内先记录空树/inode manifest,再以 `bubblewrap --unshare-net` 运行;环境为 `UV_OFFLINE=1`、`UV_PYTHON=`、`UV_PYTHON_DOWNLOADS=never`,测试 `.venv` 位于该 scratch。实际执行命令与结果: + +```text +uv sync --frozen exit 0 +VTA_UPLOADS_ENABLED=true uv run --frozen python scripts/ops/local_upload_check.py --config /evidence/local-upload-config.json --data-dir /evidence/data --compose docker/docker-compose.deploy.yml --repo . --source-sha de3ff7b6671418ae7746649ef2167d5778c790ef --sample /evidence/sample.wav exit 2 (expected blocked) +uv run --frozen pytest -q tests/unit/test_upload_ops.py tests/integration/test_upload_restore.py exit 0 +make test exit 0 (one full run) +``` + +真实 CLI 关键输出: + +```text +upload_limits=valid +configured_limits=max_file_mib:4_max_media_hours:0.02_receive_concurrency:2_upload_temp_budget_mib:12 +sample_bytes=1920044 +sample_duration_seconds=20.000 +sample_limit_check=within_declared_limits +process_upload_gate=exact_true +source_compatibility=safe-lineage +production_capacity_evidence=unknown +restore_domain_evidence=unknown +production_ingress_evidence=unknown +UPLOAD_ENABLE_BLOCKED: production capacity has not been measured; independent restore domain has not been proved; production ingress and runtime image have not been verified +``` + +真实容量工具实际 child payload(只记录安全白名单字段及 config hash,不输出 token/config 原文):argv 为 scratch `.venv/bin/python3 /main.py --start --config /tmp/local-upload-capacity-vsz7iwpw/sandbox-config.json`;cwd 为 H0 scratch。env keys 为 `HOME,LANG,LC_ALL,PATH,PYTHONPATH,TZ,VTAPI_USERS_JSON,VTA_UPLOADS_ENABLED`,HOME、用户 JSON、配置和所有 storage/log 路径均在 scratch;`VTA_UPLOADS_ENABLED=true`。实际配置 5,585 bytes,SHA-256 `803696be2e74b266790175c8b4f655987848c4af22af52b44be9bc73276a3b00`;API `127.0.0.1`、ASR `ws://127.0.0.1:`、allowlist `127.0.0.1/32`。真实样本结果:`raw_http_receipts=2_of_2`、`controlled_url_upload_overlap=confirmed`、`worker_terminal_states=failed,failed,failed`、`asr_capacity=unknown_external_model_not_called`、`production_capacity=unknown_production_ingress_and_disk_quota_not_measured`。 + +最小 mutation 在 scratch 将实际 `sample["bytes"] > limit` 改成 `<`,执行 `uv run --frozen pytest -q tests/unit/test_upload_ops.py::test_sample_limit_check_compares_real_file_size_and_duration` 得退出码 1,失败为 `AssertionError: assert 'exceeds_file_limit' == 'within_declared_limits'`;`ImportError/ModuleNotFoundError=False`。源码恢复后 scratch git status 为空。 + +隔离 manifest(full verification scratch):`scratch_sha=de3ff7b6671418ae7746649ef2167d5778c790ef`、`status_before=`、`knownempty_config/config.jsonc=absent`、`knownempty_data=absent`、`knownempty_.venv=absent`、scratch/main/implementation example inode=`2049:3674756 / 2049:2883693 / 2049:18877700`、`link_count=1`、`links=lo`、`external_routes=none`、`network_guard_loopback_only=True`。结束时 `scratch_status=`;`config/config.jsonc` 不存在。测试在 scratch 内创建的数据与 venv 随 wrapper 清理。 + +## CI、OCR 与未证事项 + +- 只读查询 `gh pr view 208 --json headRefOid,state,statusCheckRollup`:head 等于 H0、state OPEN;`gate / quality` 为 SUCCESS,但 `gate / primary` 与 `gate / ocr` 为 SKIPPED;`gate (draft)` 聚合 SUCCESS 不代表主审通过。没有打开 PR 正文。当前 PR rollup 不能替代本地/生产环境证据。 +- 派发时主干基线查询为 `gh api request failed`;继承红结论未能判定,没有据此归因本轮。 +- 按卡面 OCR 在主脑前置扫描已尝试,外层 55 秒超时且 stdout envelope 零字节;本轮没有重启工具,OCR 结论仍 unknown,不是 clean 或 skipped。 +- 未运行 systemd/真实服务/真实卷/备份恢复、生产磁盘配额、真实 ASR/LLM、真实 ingress、裸 shell 的部署证据或用户授权流程。以上均不是本地代码 P1 证据,全部保持 unknown。 diff --git a/docs/sessions/261008-local-upload/reviews/F-r2-verdict.md b/docs/sessions/261008-local-upload/reviews/F-r2-verdict.md new file mode 100644 index 00000000..d78d5d32 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/F-r2-verdict.md @@ -0,0 +1,56 @@ +failure-visibility: p2-only + +# F-R2 独立审查结论 + +审查固定在 main `0ae4bffe911fa93bb858d24fd173aee602af9c0b`、Fbase `21329ec6ef8be569b2c4f29d5a3cfbddaa7ad571`、增量 H0 `de3ff7b6671418ae7746649ef2167d5778c790ef` 与 H1 `13e78042ac4ff97ae0fd42fa15031848259f985d`。审查对象是 main..H1 与 Fbase..H1;先专项核验 H0..H1。 + +**结论:P2-only;软件交付暂阻断。** 两个需处理的问题是恢复测试没有对同一恢复快照和 token 做开关开启的可读正对照;容量脚本的一次直接运行在固定等待后连接被拒,成功与失败结果并存,无法确认一次运行是否能稳定产出样本。没有确认 P1。生产启用仍因生产容量、独立恢复域、ingress/runtime image 和部署授权未知而阻断,这属于未证生产条件,不是代码 P1。 + +## H0..H1 四问 + +| 问题 | 结论 | +|---|---| +| 是否只修获准的 Git 查询错误分类并补真实 Git 测试? | 可审的软件改动仅将 `merge-base --is-ancestor` 的退出码 1 解释为不兼容、其他非零解释为 unknown,并新增真实 Git 缺失提交测试。增量还改动了禁止阅读的 F-progress 文件;该文件内容不纳入审查,因此对整个三文件增量不能作完整肯定结论。一次批量差分输出意外带出了该文件部分内容,属流程偏差,详见下文。 | +| 是否新增未经批准的抽象? | 否;已审代码没有新增 helper、状态层或配置层。 | +| 是否新增无据状态/事实源/fallback? | 否;沿用原有 `unknown`,没有第二事实源、fallback、重试或防御性 catch。 | +| 是否留下新旧双路径? | 未见;兼容判定仍走同一 Git ancestry 路径。 | + +H0 反向对照:在 H0 新 scratch 中只覆盖 H1 的 `tests/unit/test_upload_ops.py`,执行 `test_missing_minimum_commit_query_is_unknown_from_real_git`。pytest 退出 1,失败为业务断言 `assert 'incompatible' == 'unknown'`;不是导入、路径或夹具错误,说明新测试能区分旧实现。 + +## 发现 + +### P2:恢复拒读用例缺少同一快照的可读正对照 + +违反中性规格第 4、5 条的配对证据要求。`tests/integration/test_upload_restore.py:101-139` 生成真实上传和正文、复制撤销前的 SQLite/正文快照、恢复该快照,并让新进程以 `VTA_UPLOADS_ENABLED=false` 查询 token;断言拒读和 `UPLOAD_DISABLED`。但该测试没有再以 `true` 对同一个 `restored_cache` 与同一个 token 查询成功正文。`test_actual_guard_only_source_consumes_fixture_and_preserves_active_long_artifacts` 在 `:183-249` 的开启读取使用另一份新建缓存和真实 A 源码,不能证明刚恢复的那份快照及 token 本身有效。因此拒读断言仍可能在快照/token 映射错误时通过,无法排除“原本不可读”的恒真拒绝。 + +严重度为 P2:当前代码的 off gate 行为有直接断言,且另一个测试证明真实 A 消费者可以读真实 producer 产物;没有发现生产数据被错误公开的运行证据。交付仍阻断,因为任务规格明确要求恢复消费路径具有匹配的可读/不可读对照,而当前测试没有锁住这一不变式。P1 两问:真实使用中触发的是测试缺口而非已证实的错误读取路径,第一问不成立;没有已证实的不可接受生产后果,第二问不成立。 + +### P2:容量脚本固定等待后有一次真实连接拒绝 + +违反中性规格第 2、3 条对可执行容量样本和可解释结果的要求。`scripts/perf/local_upload_capacity.py:300-324` 启动 API 后固定 `sleep(2)`,仅检查进程仍存活就向 capabilities 端点发请求。H1 新 scratch 中直接执行文档命令 `python scripts/perf/local_upload_capacity.py --duration-seconds 20` 有一次退出 1,异常为 `ConnectionRefusedError`;进程当时仍存活。`tests/integration/test_upload_restore.py:150-180` 的单独 pytest 用例及一次 `make test` 均退出 0;strace 运行也在同一请求处失败,但 tracing 会影响时序。现有证据只能确认一次直接运行失败,不能确认根因或失败频率,也不能把它归咎于唯一的启动时序因素。 + +严重度为 P2:失败会中止本次本地样本并显式报错,不会启用上传、改数据卷或产生生产影响。交付阻断是因为所要求的直接容量运行未稳定给出样本,且成功/失败不一致尚未解释;测试套件退出 0 不能代替一次直接运行的有效测量。P1 两问:该次真实运行触发了,但发生在隔离 scratch 而非生产;没有不可接受后果,P1 不成立。 + +## 七项不变式核对 + +| 项 | 代码与测试锚点 | 结论 | +|---|---|---| +| 1. 配置、额度、磁盘/样本、源码兼容、unknown 与 blocked | `scripts/ops/local_upload_check.py:31-131,147-209`;`tests/unit/test_upload_ops.py:12-110,161-226` | 真实配置加载入口、正有限额度、ffprobe、实际磁盘剩余量及真实 Git ancestry 均有代码/测试。未提供生产证据时始终输出 `UPLOAD_ENABLE_BLOCKED` 并返回 2;无自动启用/部署/恢复。 | +| 2. 有界真实 HTTP、SQLite 回执和 loopback 对端 | `scripts/perf/local_upload_capacity.py:76-207,238-439`;`tests/integration/test_upload_restore.py:150-180` | 20 秒 PCM WAV、上传体 hash、真实 HTTP receiver、SQLite 回执、worker/ffprobe、受控 URL overlap、loopback ASR rejector 和临时目录采样都有实现及断言。直接样本运行结果有 P2 不确定性。 | +| 3. 观测阶段与跨进程 payload | `scripts/perf/local_upload_capacity.py:288-324`;`tests/unit/test_upload_ops.py:111-155` | 实际服务 argv/env 及实际写出配置字节已在被测进程运行时取证;网络命名空间无路由,实际连接目标仅 loopback。输出不宣称真实 ASR 容量或临时预算上界。 | +| 4. 快照、撤销、关闭 gate、长期正文保护 | `tests/integration/test_upload_restore.py:101-147`;实际 Resolver/cleaner | 实际 SQLite/正文复制、撤销后恢复旧 snapshot、fresh 关闭 gate 子进程拒读、never 行和正文由当前 cleaner 保留均有断言。缺同一 snapshot/token 的开启读取正对照,见 P2。 | +| 5. 最低安全旧源码与消费者 | `tests/integration/test_upload_restore.py:183-249`;`A_GUARD_ONLY_SHA=6af391d20edab8dfd8b320ce6b91d4d19e3260e2` | 用真实 A 源码 archive 消费当前 CacheManager 生成的 SQLite/文件,开启时读取正文,legacy/blank alias 拒绝,两个 cleaner 保留长期成果。仅证明源码行为,不证明 OCI image digest/生产镜像绑定。 | +| 6. 新 scratch 与隔离 | H1/H0 均使用精确 SHA 创建的官方 scratch worktree | 初始 `config/config.jsonc`、`data`、`.venv`、`.pytest_cache` 均不存在;无外部软链;与主仓及既有 F worktree 的 tracked inode 交集为零。运行使用新 venv、临时 HOME、scratch 外侧的运行临时域和无路由网络命名空间。 | +| 7. 软件证明与生产证明分开 | `docs/operations/local-upload.md:1-53`、compose 与示例配置 | 文档明确上传默认关闭、四额度为 null、真实容量/恢复/入口和部署授权未知;操作步骤不自动恢复、不触碰生产卷。生产证明仍未执行。 | + +## 实测和未知 + +- H1 实际 CLI 用 `config/config.example.jsonc`、合成 1 秒 WAV、临时数据目录和 H1 source SHA 运行,退出 2;实际输出中样本为 96044 bytes / 1.000 秒,limits 为 unknown,source 为 safe-lineage,生产 capacity/restore/ingress 均为 unknown,并打印 `UPLOAD_ENABLE_BLOCKED`。这是预期的拒绝启用结果,不是生产证明。 +- H1 容量子进程实际 argv 为 Python 的 `main.py --start --config <临时运行域>/sandbox-config.json`;观察到的环境含 scratch HOME、`PYTHONPATH=/src`、合成用户 JSON 与 `VTA_UPLOADS_ENABLED=true`。producer 实际生成的配置为 5640 bytes,SHA-256 `aeb4c404fc31496f77961bd4a122a2be8986b5aa48b9425b95fed345643a7a00`;API host 为 127.0.0.1,ASR URL 为 loopback,allowlist 为 127.0.0.1/32,缓存、workspace、temp、audit 与日志路径均位于临时运行域,token 与本次 synthetic fixture 相符。 +- H1 聚焦容量 pytest 用例退出 0;整次 `make test` 退出 0。报告到的 skip 共 5 项:3 项缺 CapsWriter preflight fixtures,2 项因执行环境 root 权限绕过 chmod 权限测试。另一次带 tracing 的定向运行退出 1,失败点为 capacity 请求连接拒绝。直接 CLI 的失败与上述通过结果不一致,因此不把 capacity 样本判为稳定通过,也不据此断言唯一根因。 +- 未运行生产服务、systemd、真实 ingress、生产磁盘/ASR/代理容量、完整恢复域或 CI;GitHub 基线查询在派发时不可用,继承红无法判断。主脑 OCR 前置扫描只得到超时且空 envelope,没有 reviewed/skipped 结论;本卡未重跑,也不把它当成 clean。 +- 卡面列出的 `GOALS.md` 与 `goals/local-upload/M6-recovery.md` 在固定 H1 工作树中不存在;评价锚点使用派发 spec、存在的 `docs/sessions/261008-local-upload/design.md` 和实际运维文档。没有读取实现报告、旧 review 或 PR 正文。 + +## 流程偏差 + +一次批量 `git diff` 意外把禁止读取的 `docs/sessions/261008-local-upload/progress/F-progress.md` 部分内容写入终端输出。该内容未作为证据或判断依据;但输入隔离已被破坏,不能把本报告称作完全无污染的独立审查。因此执行报告标记为 failed。对增量四问中可审的代码/测试结论不受该文件内容支持;该进度文件本身没有被审查。 diff --git a/docs/sessions/261008-local-upload/reviews/F-r3-verdict.md b/docs/sessions/261008-local-upload/reviews/F-r3-verdict.md new file mode 100644 index 00000000..b359b60f --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/F-r3-verdict.md @@ -0,0 +1,38 @@ +# F-r3 verdict + +failure-visibility: skipped + +## H1..H2 修复增量结论 + +固定范围 13e78042ac4ff97ae0fd42fa15031848259f985d..07bbd72192e7cce71f0498145d4fefb2c86a533d 只有 tests/integration/test_upload_restore.py 增加 15 行。新增内容在同一 pytest 临时恢复副本上补了 gate=true 正对照,以及 upload_ 无效令牌反例。未改产品/helper 源码,也没有增加抽象、状态源、fallback 或产品双路径;true 只传给本地子进程,不是生产重开入口。任务卡没有列出登记 finding ID,故无法独立核对外部登记表;改动范围与卡面声明的配对恢复测试一致。 + +四问:①本轮只在指定恢复测试函数内补锁;②无新抽象;③snapshot 的 SQLite 和正文 bytes 来自同一真实 producer,子进程环境显式覆盖开关,没有新增状态或降级路径;④false/true 是同一合成副本上的测试对照,不是两套产品实现。 + +## 固定 F 范围与软件不变式 + +完整范围为 21329ec6ef8be569b2c4f29d5a3cfbddaa7ad571..07bbd72192e7cce71f0498145d4fefb2c86a533d,排除 docs/sessions/261008-local-upload/progress/** 后涉及 9 个文件。H2 的设计与 M6 文件说明软件工具不构成生产授权;M6 仍为“进行中”,M4 阻塞、M5 未开始。 + +| 不变式 | 源码与锁定测试 | 本次证据/边界 | +|---|---|---| +| 1. 运维 CLI 使用真实 config loader、四项正有限额度/正整数并发、Git ancestry 三态与进程环境;不读 dotenv、不启用或恢复 | scripts/ops/local_upload_check.py:31-48,98-131,147-209;tests/unit/test_upload_ops.py:12-109,161-226 | 实际 CLI 使用 config/config.example.jsonc、scratch data、精确 H2 与 env -i 启动,输出 safe-lineage/unknown 生产证据及 UPLOAD_ENABLE_BLOCKED,exit 2。真实部署 .env 与 systemd 消费环境未测。 | +| 2. 容量样本由实际 HTTP/API producer 写入 SQLite,并核对 payload hash;仅限 loopback/合成介质,不代表生产容量 | scripts/perf/local_upload_capacity.py:96-135,138-207,238-427;tests/unit/test_upload_ops.py:137-159;tests/integration/test_upload_restore.py:165-195 | 目标测试通过;断言覆盖 2/2 raw HTTP receipts、实际接收字节 SHA-256、URL/上传重叠、ffprobe、worker 终态与 blocked 输出。测试捕获了脚本 stdout,具体 digest 未留在本次终端记录。ASR/LLM、生产磁盘配额、代理和容量上界均未知。 | +| 3. 恢复旧 SQLite/body snapshot 后,fresh resolver 的外部 gate=false 拒读;true 正对照只用于合成旧副本;cleaner 保留有效 never 与 root | tests/integration/test_upload_restore.py:101-162;配对增量同文件 141-154 | 同一实际 producer 生成 DB/body 后复制 snapshot;撤销源记录后只把该 snapshot 复制到临时 restore 目录,逐字节比较 DB/body;fresh 子进程 false 拒读且有 UPLOAD_DISABLED,true 读出预期正文,无效 upload_ token 不读且无 disabled 告警;真实 cache/task cleaner 保留正文/root。该旧 snapshot 本来就是 revoked_at=NULL,不能证明可信撤销来源或全恢复域。 | +| 4. A guard-only 或更新源码消费当前 producer 数据,合法分享可读,旧 token 别名与 blank 拒绝 | tests/integration/test_upload_restore.py:21,198-264;最低 SHA 6af391d20edab8dfd8b320ce6b91d4d19e3260e2 | H2 ancestry 查询为 true;目标测试通过并以 git archive 的 A 源码在独立解释器读取当前 producer 的 SQLite/body,断言合法正文可读、upload/blank 别名为空、cleaner 保留成果。ancestry 不证明 image digest 与源码绑定。 | +| 5. 本轮失败可见,不新增 retry/fallback/catch;真实 CLI 有终态 | scripts/ops/local_upload_check.py:27-28,193-209;scripts/perf/local_upload_capacity.py:210-214,257-310;上述 unit/integration tests | 运维 CLI 真实退出码 2,fail-closed;容量集成测试实际启动 API 并经 HTTP/SQLite 完成样本。其测试 teardown 等待进程退出,但本次没有独立记录 API shutdown exit code;不据单次启动声称稳定或 cleanup 已获生产证明。 | +| 6. 软件准备与上线分开 | docs/operations/local-upload.md:1-53;GOALS.md:7-12,24-26;goals/local-upload/M6-recovery.md:1-27;docs/sessions/261008-local-upload/design.md | 示例额度仍为空;诊断工具保持 blocked。未改生产配置,也未运行 CI、systemd、生产 ingress/image、真实恢复或部署授权流程。M4/M5 与完整 M6 验收不能勾完成。 | + +## 实测与未证 + +两次验证均通过官方 scripts/git/scratch-worktree.sh,以 VideoTranscriptAPI 主仓和固定 H2 创建临时树,并在 env -i、unshare --user --map-root-user --net 中运行。每棵树起始 HEAD 精确为 H2、干净;data、config/config.jsonc、liveconfig、.venv、.pytest_cache 均不存在;检查到的 symlink 均在树内;pyproject.toml inode 与 main 旧树不同。ip link set lo up 后网络接口仅 lo,IPv4/IPv6 route 均空。uv sync --offline --frozen 成功,CPython 3.11.15,依赖从本地缓存安装。 + +- 原样 uv sync --offline --frozen && .venv/bin/python -m pytest -q tests/unit/test_upload_ops.py tests/integration/test_upload_restore.py:12 passed。 +- 对配对用例的方向性红验:先断言 helper 环境赋值源码唯一命中,再只在 scratch 中把 VTA_UPLOADS_ENABLED 固定注入为 false;目标用例在 assert "RESULT=success" in enabled.stdout 处失败,exit 1。该 scratch 已自动移除。 +- 实际 local_upload_check.py 调用 exit 2,理由为四额度无效/未提供样本、生产容量/恢复域/ingress 未证;无配置正文或凭据输出。 +- 全量 make test:exit 0,pytest 进度到 100%;安静输出中可见 5 个 s 标记,但没有保留 skip 用例名/原因,也未保留全量分母,因此具体 skip 归因与数量标为 unknown,不重跑套件。输出含既有弃用警告。 +- 未运行正式 CI 或 OCR;卡面说明此前 OCR 超时,本轮遵守要求未再调用。派发时 CI 基线不可用,继承红/新红对照未能判定。 + +## 处置与审查状态 + +未观察到可据当前软件不变式成立的代码 finding;这不构成生产容量、恢复域、撤销来源、image/source 绑定、模型质量或稳定性证明。软件实现未发现已证代码缺陷;但本卡要求的独立审查因下述禁读违规不能清为通过,因此独立审查交付门仍未满足,需由干净输入的新审查确认。 + +程序偏差披露:一次 rg -n 'send_terms' src tests config docs 的搜索误包含禁读目录,输出带出 docs/sessions/261008-local-upload/progress/F-progress.md:14;我看到了该一行,随后停止读取任何 progress 文件。该内容未作为此 verdict 的证据,但这次输入污染无法撤销,故整体 failure-visibility 标为 skipped,不声称独立 review pass。