From 978d715a460f6e3ab7be3f9e82d4e0cc955d6cf6 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 09:13:11 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=87=8C=E7=A8=8B=E7=A2=91=E7=8A=B6=E6=80=81?= =?UTF-8?q?=EF=BC=88executor=20dlg-20261009-010337-a85c01=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-010337-a85c01 Task-Id: VideoTranscriptAPI-20261009-10 --- .../261008-local-upload/roadmap-after-C.md | 51 +++++++++++++++++++ goals/local-upload/M1-baseline.md | 10 ++-- goals/local-upload/M2-intake.md | 10 ++-- goals/local-upload/M3-worker.md | 6 +-- goals/local-upload/M4-web.md | 6 +-- goals/local-upload/M5-proof.md | 4 +- goals/local-upload/M6-recovery.md | 14 ++--- 7 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 docs/sessions/261008-local-upload/roadmap-after-C.md diff --git a/docs/sessions/261008-local-upload/roadmap-after-C.md b/docs/sessions/261008-local-upload/roadmap-after-C.md new file mode 100644 index 00000000..6e506205 --- /dev/null +++ b/docs/sessions/261008-local-upload/roadmap-after-C.md @@ -0,0 +1,51 @@ +# local-upload:C 合并后的路线审计 + +审计基线:主干 `0ae4bffe911fa93bb858d24fd173aee602af9c0b`。本文件只同步既定规划和调度,不评审新产品、不改应用、不触碰真实服务;目标和非目标保持不变。 + +## 层 0:原方案是否仍必要? + +**仍然必要,方案不变。** 本地上传仍沿用既有处理链,正文分享身份与任务进度分离;默认 30 天,长期分享必须显式选择 `never`;公开读取、owner 修改。部署、生产容量与恢复仍需另行授权。U 的浏览器底座已合并不等于网页 M4 完成,C 的代码与测试也不构成生产质量、容量或启用承诺。 + +## 五问 + +### 1. M3 真的完成了吗? + +**实现阶段已完成并合入。** M3 对应 PR #207,merge commit 为 `0ae4bffe911fa93bb858d24fd173aee602af9c0b`;C producer commit `ef425d77cdaecb693aba76ca6cb3f847ad5196e5` 的 run `37864605980`、attempt 1 真实 run conclusion 为 `success`,`gate / primary` job conclusion 也为 `success`。独立 review 记录 `96cd595097cf549fa058d2e3a9fefd48b99ca9ea` 只判代码,不承诺生产质量或容量。PR #201/#203/#207 的合并与里程碑编号分别记于 M1/M2/M3;#202 未合并,不用其 owner 字段推断实现归属。 + +### 2. 下一个目标是什么? + +**先做 F/M6 的非生产软件工具准备。** M1、M2、M3 已合并,原 F 卡允许从 A/C 后准备软件工具和临时 fixture,不需要等待网页 M4/M5 才开始规划或实现这部分。当前仅拆清了依赖;恢复工具、脚本与 fixture 尚未实现,不能记为通过。 + +D 仍等待 UI registry 登记:Gate Hub #1393 最新状态为 OPEN,尚无 `has_ui: true`。按 SOP 必须先登记再派 UI;不得跨仓代改。登记完成后才推进 D/M4 网页。 + +### 3. 还漏了什么? + +- M4 网页尚未开始,且受 UI registry 登记阻塞;浏览器底座 PR #200 不等于上传网页、真实 API producer 或历史行为验收。 +- M5 全链路行为证明尚未开始,依赖 M4;真实 HTTP producer 到正文、任务、Resolver、history 与 cleanup 的完整证据仍未取得。 +- M6 软件工具和临时 fixture 尚未实现;完整恢复演练、撤销不复活、有效正文保护、四项真实限额测量、实际消费环境证据与部署授权都未完成。 +- 不以已合代码、review、CI、mock/helper 测试或文档代替上述真实行为与生产证据;生产开关继续关闭。 + +### 4. 顺序是什么? + +1. 现在可先推进 F/M6 非生产软件工具准备(前置为 M1/M2/M3);不操作生产服务。 +2. D 等待 Gate Hub 完成 `has_ui: true` 登记后再派发;随后实现并验收 M4。 +3. M4 完成后推进 M5 的全链路行为与安全证明。 +4. M6 完整验收与生产启用评估必须等待 M4、M5,并补齐真实独立恢复域/控制、撤销状态不复活、有效正文保护、四项限额实测、消费环境证据及用户/部署授权。 + +M6 的 frontmatter `depends_on` 仅表示可先启动的软件准备前置 M1/M2/M3;它不删除或缩减第 4 步的完整验收和生产启用前置。 + +### 5. 完成定义是什么? + +分开记录四种完成,不能互相替代: + +- **软件准备**:非生产恢复工具/脚本与临时 fixture 真正实现,并由可复现的本地验证证明;目前尚未实现。 +- **行为测试**:M4 使用真实浏览器/API producer 锁定字节、headers、receipt、历史与状态;M5 再用真实 HTTP、SQLite、文件、dispatcher、Resolver 和消费者完成全链路证明。fixture 必须保留真实 producer 输出,跨边界断言真实 payload;目前 M4/M5 未完成。 +- **SOP / 环境证据**:先完成 UI registry 登记和派发 SOP;M6 还需在获授权的隔离真实 unit/身份/mount/数据格式下验证环境传递及完整恢复过程。文档或模拟介质不算已执行。 +- **生产启用**:仅当 M4/M5 与 M6 全部证据齐全、独立审查通过、四项限额在真实环境实测、有效正文保护与撤销不复活得到证明,并获得用户/部署授权后才可评估;任何一项缺失都保持默认关闭。软件准备完成不会自动启用。 + +## 实际状态及核实边界 + +- GitHub PR #201、#203、#207 均为 MERGED,merge commits 分别为 `6af391d20edab8dfd8b320ce6b91d4d19e3260e2`、`c7b38563eb2f06f5ca36c06c6c7da7de9c5e9995`、`0ae4bffe911fa93bb858d24fd173aee602af9c0b`;当前 GitHub `main` 指向最后一个 SHA。 +- M1/M2/M3 的 `merged_pr` 与状态已同步为 #201/#203/#207;owner 表示后续推进责任,不是实现者归因。M4 阻塞、M5 未开始,均未标完成。M6 仅软件准备进入规划/进行中,恢复脚本及 fixture 尚未落地。 +- 未核验或未获得的生产证据仍未完成:真实生产服务部署、容量、完整恢复域与授权。本次没有访问/更改真实配置、数据、服务或生产网络。 +- 仓内原不存在本路线文件;本次在任务卡指定的精确路径新建,没有另造 Goal,也未写入 `GOALS.md`。派生表留给 lead 后续生成。 diff --git a/goals/local-upload/M1-baseline.md b/goals/local-upload/M1-baseline.md index ffa5279c..e2b328f1 100644 --- a/goals/local-upload/M1-baseline.md +++ b/goals/local-upload/M1-baseline.md @@ -2,22 +2,22 @@ lane: local-upload id: M1 slug: baseline -status: 实现中 -owner: delegate implementer dlg-20261008-094534-0fd910 +status: 已完成 +owner: lead order: 1 priority: 高 depends_on: [] -merged_pr: null +merged_pr: 201 --- # 里程碑进度:local-upload/M1:上传身份、分享资格与清理基线 - **预期产出**:cache.db 持有独立上传身份/期限/撤销记录;旧 NOT NULL `view_token` 不再承载公开上传 token;实际 Resolver 与 cache/task/audit/temp 维护消费者按资格保护。 -- **当前范围**:A 卡 guard-only 基线。不包含 HTTP 接收、真实 ASR worker、网页、生产额度和恢复证明;未合并、未部署、未启用。 +- **当前范围**:A 卡 guard-only 基线,已随 PR #201 合并至主干(merge commit `6af391d20edab8dfd8b320ce6b91d4d19e3260e2`)。不包含 HTTP 接收、真实 ASR worker、网页、生产额度和恢复证明;未部署、未启用。 - **关键决策**:用现有 `CacheManager` 做 store;`view_token=''` 只作旧列占位,公开 `upload_` token 只从新表解析;`VTA_UPLOADS_ENABLED` 缺省/非 true 即关闭读取,不影响清理保护。 - **推进前必须拿到的证据**: - [x] 卡树 `uv sync --frozen` 后,窄测连续五次:`uv run --frozen pytest -q tests/unit/test_local_upload_policy.py tests/unit/test_view_token_resolver.py tests/cache/test_task_status_cleanup.py`,五轮 exit=0;环境:本地隔离 worktree 与临时 SQLite,日志 `/tmp/vta-upload-A-narrow-final-{1..5}.log`。 - [x] `make test` 完整 gate exit=0;URL Resolver、API、history、cache/task cleanup 与媒体锁竞态整文件回归共 196 tests,exit=0;环境:当前 card worktree。全量日志 `/tmp/vta-upload-A-make-test-final.log`。 - [ ] Resolver 开关测试同时经过当前进程和真实子进程,使用同一份临时 SQLite 验证变量缺省与显式 true;CI 和裸 shell 需运行。当前仓无 upload systemd consumer;不得把本地结果声称为 systemd/生产验证。 - [ ] 独立审查与 PR run/job 实际 conclusion;draft 的 SKIPPED 不算 primary 通过。 -- **完成条件**:上述证据全部来自真实 Resolver 与维护入口,代码位置/测试锁定成对;PR merge 之前由 lead 验收。生产启用仍依赖 M2–M6 的部署、容量及恢复门。 +- **完成条件**:A 实现已合并,status「已完成」仅表示 M1 代码已落主干,不代表生产证明已完成。仍未勾选的跨进程/CI证据不得冒充已验证;生产启用仍依赖 M2–M6 的完整证据、部署、容量及恢复门。 diff --git a/goals/local-upload/M2-intake.md b/goals/local-upload/M2-intake.md index fc07ff2b..ac62cea7 100644 --- a/goals/local-upload/M2-intake.md +++ b/goals/local-upload/M2-intake.md @@ -2,18 +2,18 @@ lane: local-upload id: M2 slug: intake -status: 已实现待独立审查与CI -owner: delegate implementer dlg-20261008-130923-6b2088 +status: 已完成 +owner: lead order: 2 priority: 高 depends_on: [local-upload/M1] -merged_pr: null +merged_pr: 203 --- # 里程碑进度:local-upload/M2:认证接收与幂等回执 - **预期产出**:Bearer 认证的能力查询、raw octet-stream 接收、有限 metadata header、真实额度准入、owner/key receipt 与诚实 HTTP 状态。 -- **当前范围**:B 卡;不把 A 的 SQLite store 方法误报为已存在 HTTP 接收端。 +- **当前范围**:B 卡真实 HTTP 接收已随 PR #203 合并至主干(merge commit `c7b38563eb2f06f5ca36c06c6c7da7de9c5e9995`);不把 A 的 SQLite store 方法误报为 HTTP 接收端。 - **关键决策**:沿用 `docs/sessions/261008-local-upload/design.md` 的 wire 合同;新正式受理截止为 key 创建时间+24h(到截止时拒绝),未来容差5分钟;`receiving` 不等于 accepted/202,同意图只回原receipt、不消费新 body、不重入队,变化 metadata 409。root/accepted与一次 `put_nowait` 位于同一个无await SQLite临界步骤,commit成功后才回202;SQL和queue并非共同事务,commit失败形成的stale项由dispatcher durable admission gate丢弃。 - **推进前必须拿到的证据**: - [x] 真实 FastAPI/ASGI 与临时 cache.db 上由 client 发 raw 文件字节及 headers;锁定实际落盘字节、服务端 SHA-256、Unicode metadata、root row 与 dispatcher 实际消费路径。producer fixture 保留在 `tests/integration/test_upload_intake.py`。 @@ -22,4 +22,4 @@ merged_pr: null - [x] receiving cleanup/accept 两个可控真实SQLite+文件顺序均锁定:accept先赢时源字节保留;cleanup先退休时accept失败且root不创建,之后才unlink已退休路径。 - [x] 未设置VTA开关的真实consumer测试在`env -i`裸shell与隔离的临时systemd user unit分别运行成功;未改真实systemd/service或生产环境。 - [ ] Hosted CI及实际生产unit环境传递仍由lead验收;本地隔离验证不等于生产部署/容量证明。 -- **完成条件**:本地代码与测试已覆盖 202/失败边界;正式合并、CI 与真实部署环境变量/容量证明由 lead 后续托管,生产仍关闭。 +- **完成条件**:B 实现已合并,status「已完成」只表示代码与实现阶段落地主干。PR #203 的真实 gate 主审结论为 SUCCESS;实际生产 unit 环境变量传递、容量证明与部署授权仍未完成,生产仍关闭。 diff --git a/goals/local-upload/M3-worker.md b/goals/local-upload/M3-worker.md index 23bca1b0..c6d3287e 100644 --- a/goals/local-upload/M3-worker.md +++ b/goals/local-upload/M3-worker.md @@ -3,11 +3,11 @@ lane: local-upload id: M3 slug: worker status: 已完成 -owner: delegate implementer dlg-20261008-181551-f0aaf6 +owner: lead order: 3 priority: 高 depends_on: [local-upload/M1, local-upload/M2] -merged_pr: null +merged_pr: 207 --- # 里程碑进度:local-upload/M3:本地输入进入既有转录 worker @@ -20,4 +20,4 @@ merged_pr: null - [x] `tests/unit/test_capswriter_contract.py` 锁 invalid producer contract;`test_upload_worker.py` 锁具名失败状态;终态成功/失败/启动孤儿、子任务迟到写、撤销/到期与受理竞态均由 SQLite/HTTP 消费者测试覆盖。 - [x] queue/数据库/ASR 失败以现有 worker 失败与 `UPLOAD_PROCESSING_FAILED` 显式可见;临时目录删除失败不再被 local-upload 路径吞掉;未新增重试、fallback 或第二状态机。 - [x] 环境测试在裸 shell(变量未设)及 dispatch 专属 transient systemd unit(显式 false,测试同时通过真子进程验证缺省拒读/显式启用);未触碰生产配置/服务。Hosted CI 结论由 lead 按任务卡取证,draft 状态不计作门禁通过。 -- **完成条件**:真实处理者消费的是接收器写出的媒体与选项,阶段与 URL 路径保持一致;正文资格和任务进度分离。 +- **完成条件**:真实处理者消费的是接收器写出的媒体与选项,阶段与 URL 路径保持一致;正文资格和任务进度分离。M3/C 实现已随 PR #207 合并(merge commit `0ae4bffe911fa93bb858d24fd173aee602af9c0b`);run `37864605980`/attempt 1 的 run conclusion 与 `gate / primary` job conclusion 均为 SUCCESS。该代码审查与 CI 不证明生产质量、容量、恢复或启用授权。 diff --git a/goals/local-upload/M4-web.md b/goals/local-upload/M4-web.md index f8b5c159..99d9f326 100644 --- a/goals/local-upload/M4-web.md +++ b/goals/local-upload/M4-web.md @@ -2,8 +2,8 @@ lane: local-upload id: M4 slug: web -status: 未开始 -owner: delegate implementer dlg-20261008-094534-0fd910 +status: 阻塞 +owner: lead order: 4 priority: 中 depends_on: [local-upload/M1, local-upload/M2, local-upload/M3] @@ -13,7 +13,7 @@ merged_pr: null # 里程碑进度:local-upload/M4:上传、回执与本人历史网页 - **预期产出**:原生单文件 picker/drop、metadata headers、期限选择、真实 receipt/处理中/结果状态与本人 history source filter。 -- **当前范围**:D 卡;不得从 mock-only fixture 发明 wire 形状,不改旧 URL 任务页面规则。 +- **当前范围**:D 卡;不得从 mock-only fixture 发明 wire 形状,不改旧 URL 任务页面规则。D 尚未开始:Gate Hub #1393 仍 OPEN,真实 UI registry 尚无 `has_ui: true`;SOP 要求先登记再派 UI,当前不得跨仓代改。U 浏览器底座 PR #200 已合并不等于 M4 网页完成。 - **关键决策**:沿用设计文件中的 HTTP 字段;默认30天、长期显式选择 never;进度继续走既有 task API;source URL 仅展示,不 fetch。 - **推进前必须拿到的证据**: - [ ] 浏览器/harness 调用真实 API,断言 producer 发出的 raw Blob 字节、headers 和完整服务端 receipt;保留实际请求 fixture。 diff --git a/goals/local-upload/M5-proof.md b/goals/local-upload/M5-proof.md index 2bf51f84..0b3e1789 100644 --- a/goals/local-upload/M5-proof.md +++ b/goals/local-upload/M5-proof.md @@ -3,7 +3,7 @@ lane: local-upload id: M5 slug: proof status: 未开始 -owner: delegate implementer dlg-20261008-094534-0fd910 +owner: lead order: 5 priority: 高 depends_on: [local-upload/M1, local-upload/M2, local-upload/M3, local-upload/M4] @@ -13,7 +13,7 @@ merged_pr: null # 里程碑进度:local-upload/M5:全链路安全与兼容性证明 - **预期产出**:从真实 HTTP producer 到 owned bytes、task/queue、既有处理链、Resolver、公正文、本人 history 与 cleanup 的端到端证据。 -- **当前范围**:E 卡;整理方案、helper 单测或邻侧 response 不算实际消费证明。 +- **当前范围**:E 卡,依赖 M4 完整网页行为与真实 API producer;M4 尚受 UI registry 登记阻塞,因此 M5 尚未开始。整理方案、helper 单测或邻侧 response 不算实际消费证明。 - **关键决策**:每项不变式必须指向真实生产代码与能被 mutation 打红的测试;跨文件/进程边界保存 producer 实际 payload。 - **推进前必须拿到的证据**: - [ ] 真 FastAPI、临时 SQLite/文件系统、真实 dispatcher、Resolver 与现有 consumer;断言 HTTP/body、subprocess argv/env、写入文件等实际 producer 输出。 diff --git a/goals/local-upload/M6-recovery.md b/goals/local-upload/M6-recovery.md index c0f5083e..d141febc 100644 --- a/goals/local-upload/M6-recovery.md +++ b/goals/local-upload/M6-recovery.md @@ -2,23 +2,25 @@ lane: local-upload id: M6 slug: recovery -status: 未开始 -owner: delegate implementer dlg-20261008-094534-0fd910 +status: 进行中 +owner: lead order: 6 priority: 高 -depends_on: [local-upload/M1, local-upload/M2, local-upload/M3, local-upload/M4, local-upload/M5] +depends_on: [local-upload/M1, local-upload/M2, local-upload/M3] merged_pr: null --- # 里程碑进度:local-upload/M6:恢复域、单向撤销与容量恢复 - **预期产出**:证明独立恢复开关、cache.db/正文/audit/share记录完整恢复与容量阈值;未证明前上传读写关闭。 -- **当前范围**:F 卡,生产前置证明;A 不做恢复演练、容量实测或部署授权。 +- **当前范围**:F 卡的非生产软件工具准备可先行;A/C 实现已合并,F 原计划允许此时做工具与临时 fixture,但不做生产服务操作。此 frontmatter 依赖仅约束软件准备,不替代完整验收/生产启用门。 - **关键决策**:`VTA_UPLOADS_ENABLED` 在数据卷外;可信撤销状态/完整恢复域不能证明时保持关闭;长期成果不能通过删除腾容量。 -- **推进前必须拿到的证据**: +- **当前阶段边界**:`进行中` 仅表示规划已拆分并允许软件准备先行;恢复脚本/工具与临时 fixture 尚未实现,不能写作 passed。 +- **完整验收 / 生产启用前置**:仍须先完成 M4 与 M5,并证明独立恢复控制、已撤销状态不复活、有效正文受保护、四项限额在真实环境实测、环境与部署授权齐备。调度拆分不改变任何产品目标、证据或完成定义。 +- **推进前必须拿到的证据(全部仍未验证,不得勾选)**: - [ ] 用与目标服务一致但隔离的真实 systemd unit、身份、mount、配置与数据格式演练完整备份/恢复;启动新服务前检查开关关闭。仅获授权后操作。 - [ ] fresh Resolver 证明有效30d/never正文和必要解析记录保留、已撤销/关闭状态不复活;缺一个恢复域时拒绝打开;回滚不能以旧卷重开分享。 - [ ] 在实际磁盘配额、ASR并发和URL负载下测 max_file_mib、max_media_hours、receive_concurrency、upload_temp_budget_mib;保留真实文件/调用证据,拒绝用示例数字冒充实测。 - [ ] 容量不足拒绝新受理且可见;中断/孤儿清理有界,原媒体仍按既有处理阶段清理;不删除有效成果释放空间。 - [ ] 单列CI、裸 shell、获准 systemd unit 环境结果;文档、配置值或模拟介质不能替代恢复演练。 -- **完成条件**:证明完整、通过独立审查并经用户/部署授权后才能评估启用;任何关键证明缺失都维持默认关闭。 +- **完成条件**:软件工具准备完成不等于 M6 验收完成。只有 M4/M5 完成、独立恢复控制与完整恢复域验证、撤销不复活、有效正文保护、四项限额真实实测、环境证据齐全、通过独立审查且获用户/部署授权后,才能评估生产启用;任何关键证明缺失都维持默认关闭。 From 21329ec6ef8be569b2c4f29d5a3cfbddaa7ad571 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 09:23:26 +0800 Subject: [PATCH 02/12] =?UTF-8?q?[pi-lead]=20=E7=94=9F=E6=88=90=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E8=B7=AF=E7=BA=BF=E7=B4=A2=E5=BC=95=E5=B9=B6=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E8=BD=AF=E4=BB=B6=E5=87=86=E5=A4=87=E4=B8=8E=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E5=85=B3=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: pi-lead Agent-Session: pi-lead-1791429303659-4145181 --- GOALS.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 GOALS.md diff --git a/GOALS.md b/GOALS.md new file mode 100644 index 00000000..493cf46d --- /dev/null +++ b/GOALS.md @@ -0,0 +1,39 @@ +# 本地音视频上传:项目路线图 + +## 项目目标 + +自己及四五位可信朋友在现有网页选择本地音视频,复用既有转录、校对、总结、阅读与通知。成果公开阅读,变更由 owner 执行;默认保留30天,每次可显式选择 never。采用既有用户、HTTP/SQLite/worker、浏览器与通知路径,不新增账户、网盘、批量续传、broker、重试或外部撤销平台。 + +## 完成定义 + +- **软件交付**:后台、上传网页、本人历史、公开分享与停止分享、真实全链路行为验证和容量/恢复工具均落盘,经各自本地漏斗、正式CI/模型门禁与正常PR合并。 +- **生产启用**:软件交付不自动启用;仍需真实容量/四正有限额度、代理、独立恢复控制及完整恢复域、撤销不复活/有效成果保护、实际消费环境证据和用户部署授权。未知保持 off。 +- 索引中的 M1–M3「已完成」指已验收并合并的软件里程碑,不冒称生产证明完整。M1 历史未勾项不追填绿:A 曾经 primary failure,经真实反证/正规签票和 READY 后合并,不能改称原 primary success;当前跨进程/门禁与消费者回归由已合 B/C 证据继续覆盖。完整生产证明仍由 M6 承接。 +- M6 当前只是非生产软件准备,工具及演练未实现;其软件前置 M1–M3 与最终验收/生产启用前置 M4–M5 及所有生产证明明确分开,不削减功能或提前标 done。 + + +| Lane | 激活里程碑 | 推进文件 | +| --- | --- | --- | +| local-upload | M6 | [goals/local-upload/M6-recovery.md](goals/local-upload/M6-recovery.md) | + +| Lane | ID | 名称 | 状态 | 排序 | 优先级 | 依赖 | 进度文件 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| local-upload | M1 | local-upload/M1:上传身份、分享资格与清理基线 | 已完成 | 1 | 高 | — | [goals/local-upload/M1-baseline.md](goals/local-upload/M1-baseline.md) | +| local-upload | M2 | local-upload/M2:认证接收与幂等回执 | 已完成 | 2 | 高 | local-upload/M1 | [goals/local-upload/M2-intake.md](goals/local-upload/M2-intake.md) | +| local-upload | M3 | local-upload/M3:本地输入进入既有转录 worker | 已完成 | 3 | 高 | local-upload/M1, local-upload/M2 | [goals/local-upload/M3-worker.md](goals/local-upload/M3-worker.md) | +| local-upload | M4 | local-upload/M4:上传、回执与本人历史网页 | 阻塞 | 4 | 中 | local-upload/M1, local-upload/M2, local-upload/M3 | [goals/local-upload/M4-web.md](goals/local-upload/M4-web.md) | +| local-upload | M5 | local-upload/M5:全链路安全与兼容性证明 | 未开始 | 5 | 高 | local-upload/M1, local-upload/M2, local-upload/M3, local-upload/M4 | [goals/local-upload/M5-proof.md](goals/local-upload/M5-proof.md) | +| local-upload | M6 | local-upload/M6:恢复域、单向撤销与容量恢复 | 进行中 | 6 | 高 | local-upload/M1, local-upload/M2, local-upload/M3 | [goals/local-upload/M6-recovery.md](goals/local-upload/M6-recovery.md) | + + +## 路线图审计 + +### local-upload — 2026-10-09 / PR207 + +- **层0:方案仍必要吗?** 是。已合后台和浏览器底座尚未提供完整上传网页,原产品目标与非目标未变。 +- **里程碑真完成了吗?** M1/#201、M2/#203、M3/#207 的软件实现已经正常合入;C 的正式 CI producer 在准确 H2 返回完整 SUCCESS。M4 网页、M5 全链路和 M6 工具/生产证明仍未完成。执行器运输未知与成果代码验收分别保留,不篡原报告。 +- **下一个目标还是对的吗?** 是。网页待属主 registry 的 has_ui 真登记;先准备不依赖网页的 F 软件工具,不能借它启用生产。 +- **有没有漏掉的里程碑?** 无新增产品里程碑;现有 M4/M5 和 M6 原检查清单继续约束未完成事项。不能拿浏览器底座 U 当 M4 已交付。 +- **新证据是否改变顺序?** 是。后台已合、网页测试登记仍阻塞;将 M6 软件准备依赖限定已合 M1–M3,完整验收/生产启用依旧等 M4/M5 与原真实容量恢复授权。不跨仓改登记、不跳浏览器测试 SOP。 +- **done 的定义还成立吗?** 是。软件、行为测试、测试规范/环境证据、生产启用分别判定。真实 producer payload 与有牙断言才是证据,fixture 不代表生产容量或模型质量。 +- **审计结论**:按独立路线审计 `docs/sessions/261008-local-upload/roadmap-after-C.md` 调整可逆调度依赖,不更改目标;M6 仅软件准备进行中,其余缺项与授权边界保留。 From 03828d3231003fc6ef3f91e52d772950e8b4f796 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 09:44:05 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E8=BF=90=E7=BB=B4=E9=98=BB=E6=96=AD=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-013605-110782 Task-Id: VideoTranscriptAPI-20261009-11 --- .../progress/F-progress.md | 8 + scripts/ops/local_upload_check.py | 197 ++++++++++++++++++ tests/unit/test_upload_ops.py | 129 ++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 docs/sessions/261008-local-upload/progress/F-progress.md create mode 100755 scripts/ops/local_upload_check.py create mode 100644 tests/unit/test_upload_ops.py diff --git a/docs/sessions/261008-local-upload/progress/F-progress.md b/docs/sessions/261008-local-upload/progress/F-progress.md new file mode 100644 index 00000000..8711bf9e --- /dev/null +++ b/docs/sessions/261008-local-upload/progress/F-progress.md @@ -0,0 +1,8 @@ +# F 进度 + +## 2026-10-09 · 首个运维门槛单元 + +- **当前阶段**:implementing;首个只读运维门槛单元已验证并待提交/推送。 +- **本段结论**:`uv run --frozen pytest -q tests/unit/test_upload_ops.py` 4 passed;CLI 只读取明确传入的消费配置/数据目录与当前进程开关,不加载 dotenv、不打印密钥、不启用/部署/恢复;即使四限额、开关和代码祖先检查满足,也会以 `UPLOAD_ENABLE_BLOCKED` 阻止生产启用,因为容量、独立恢复域和实际入口尚未知。 +- **关键决策与否决方案**:消费配置解析沿用真实 `load_and_validate_config`;env 文件仅从 tracked compose 声明识别来源,绝不 source;兼容检查以 Git commit ancestry 与已知 A guard-only 完整 SHA 为边界,不用版本字符串比较;本地检查结果不作为 enable 权限或伪造容量凭据。首轮测试因预期把四个 `null` 错判为 `missing` 有 AssertionError 红,修正断言后全绿;生产代码未变异。 +- **下一步唯一动作**:提交并推送该独立可绿单元,再继续恢复与容量运维证据。 diff --git a/scripts/ops/local_upload_check.py b/scripts/ops/local_upload_check.py new file mode 100755 index 00000000..ecb6dba8 --- /dev/null +++ b/scripts/ops/local_upload_check.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Read-only local upload preflight; it never enables, deploys, or restores data.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +MINIMUM_SAFE_SHA = "6af391d20edab8dfd8b320ce6b91d4d19e3260e2" +UPLOAD_LIMIT_FIELDS = ( + "max_file_mib", + "max_media_hours", + "receive_concurrency", + "upload_temp_budget_mib", +) + + +class CheckFailure(RuntimeError): + """A local check could not produce an evidence-backed result.""" + + +def valid_upload_limits(config: dict[str, Any]) -> tuple[bool, str]: + storage = config.get("storage") + limits = storage.get("upload_limits") if isinstance(storage, dict) else None + if not isinstance(limits, dict): + return False, "missing" + values = [limits.get(field) for field in UPLOAD_LIMIT_FIELDS] + for field, value in zip(UPLOAD_LIMIT_FIELDS, values, strict=True): + if field == "receive_concurrency": + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + return False, "invalid" + elif ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(value) + or value <= 0 + ): + return False, "invalid" + return True, "valid" + + +def inspect_sample(path: Path, ffprobe: str = "ffprobe") -> dict[str, float | int]: + if not path.is_file(): + raise CheckFailure("sample_not_file") + result = subprocess.run( + [ + ffprobe, + "-v", + "error", + "-show_entries", + "format=duration:stream=codec_type", + "-of", + "json", + str(path), + ], + check=False, + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise CheckFailure(f"ffprobe_failed_rc_{result.returncode}") + probe = json.loads(result.stdout) + streams = probe.get("streams") + duration = probe.get("format", {}).get("duration") + if not isinstance(streams, list) or not any( + isinstance(item, dict) and item.get("codec_type") in {"audio", "video"} + for item in streams + ): + raise CheckFailure("sample_has_no_media_stream") + try: + duration_seconds = float(duration) + except (TypeError, ValueError) as exc: + raise CheckFailure("sample_duration_unknown") from exc + if not math.isfinite(duration_seconds) or duration_seconds <= 0: + raise CheckFailure("sample_duration_invalid") + return {"bytes": path.stat().st_size, "duration_seconds": duration_seconds} + + +def compatibility_status(repo: Path, source_sha: str | None) -> str: + if source_sha is None: + return "unknown" + if not re.fullmatch(r"[0-9a-fA-F]{40}", source_sha): + return "invalid" + resolved = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"{source_sha}^{{commit}}"], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + if resolved.returncode != 0 or resolved.stdout.strip().lower() != source_sha.lower(): + return "unknown" + ancestor = subprocess.run( + [ + "git", + "-C", + str(repo), + "merge-base", + "--is-ancestor", + MINIMUM_SAFE_SHA, + source_sha, + ], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + return "safe-lineage" if ancestor.returncode == 0 else "incompatible" + + +def deployment_env_source(compose_path: Path) -> str: + """Report the tracked env_file declaration without reading any dotenv file.""" + if not compose_path.is_file(): + return "unknown" + lines = compose_path.read_text(encoding="utf-8").splitlines() + for index, line in enumerate(lines): + if line.strip() == "env_file:" and index + 1 < len(lines): + candidate = lines[index + 1].strip() + if candidate.startswith("- "): + return candidate[2:].split()[0] + return "unknown" + + +def run_check(args: argparse.Namespace) -> int: + from video_transcript_api.api.context import load_and_validate_config + + config = load_and_validate_config(args.config) + limits_ok, limits_state = valid_upload_limits(config) + print(f"upload_limits={limits_state}") + + data_path = Path(args.data_dir) + if not data_path.is_dir(): + raise CheckFailure("data_dir_not_directory") + disk = shutil.disk_usage(data_path) + print(f"data_disk_free_bytes={disk.free}") + + sample_state = "not_supplied" + if args.sample: + sample = inspect_sample(Path(args.sample), args.ffprobe) + print(f"sample_bytes={sample['bytes']}") + print(f"sample_duration_seconds={sample['duration_seconds']:.3f}") + sample_state = "measured" + + compose_path = Path(args.compose) + env_source = deployment_env_source(compose_path) + print(f"deployment_env_file={env_source}") + configured_gate = os.environ.get("VTA_UPLOADS_ENABLED") + print(f"process_upload_gate={'exact_true' if configured_gate == 'true' else 'off_or_unset'}") + print(f"source_compatibility={compatibility_status(Path(args.repo), args.source_sha)}") + print("production_capacity_evidence=unknown") + print("restore_domain_evidence=unknown") + print("production_ingress_evidence=unknown") + + # Local checks describe inputs only. They cannot certify a production domain, + # guarantee rollback-control independence, or authorize changing the gate. + blocked_reasons = [ + "production capacity has not been measured", + "independent restore domain has not been proved", + "production ingress and runtime image have not been verified", + ] + if not limits_ok: + blocked_reasons.append("four limits are not positive finite values") + if sample_state == "not_supplied": + blocked_reasons.append("no local media sample was measured") + print("UPLOAD_ENABLE_BLOCKED: " + "; ".join(blocked_reasons)) + return 2 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True, help="actual consumer config path") + parser.add_argument("--data-dir", required=True, help="actual data mount path") + parser.add_argument("--compose", default=str(PROJECT_ROOT / "docker/docker-compose.deploy.yml")) + parser.add_argument("--repo", default=str(PROJECT_ROOT), help="repository containing source commits") + parser.add_argument("--source-sha", help="source revision proven for the runtime image") + parser.add_argument("--sample", help="optional local media file inspected by ffprobe") + parser.add_argument("--ffprobe", default="ffprobe") + return parser + + +def main(argv: list[str] | None = None) -> int: + return run_check(build_parser().parse_args(argv)) + + +if __name__ == "__main__": + sys.path.insert(0, str(PROJECT_ROOT / "src")) + raise SystemExit(main()) diff --git a/tests/unit/test_upload_ops.py b/tests/unit/test_upload_ops.py new file mode 100644 index 00000000..e4caba34 --- /dev/null +++ b/tests/unit/test_upload_ops.py @@ -0,0 +1,129 @@ +"""Fail-closed contracts for local upload operations checks.""" + +import json +from pathlib import Path +import pytest + +from scripts.ops import local_upload_check + + +def test_upload_limits_must_all_be_positive_finite_and_concurrency_integral(): + limits = { + "max_file_mib": 1.5, + "max_media_hours": 2, + "receive_concurrency": 1, + "upload_temp_budget_mib": 4, + } + assert local_upload_check.valid_upload_limits({"storage": {"upload_limits": limits}}) == (True, "valid") + + for field, value in ( + ("max_file_mib", None), + ("max_media_hours", float("inf")), + ("upload_temp_budget_mib", float("nan")), + ("receive_concurrency", True), + ("receive_concurrency", 1.5), + ): + invalid = limits | {field: value} + assert local_upload_check.valid_upload_limits( + {"storage": {"upload_limits": invalid}} + ) == (False, "invalid") + + assert local_upload_check.valid_upload_limits({"storage": {}}) == (False, "missing") + + +def test_ffprobe_subprocess_receives_sample_path_and_json_contract(tmp_path): + capture = tmp_path / "argv.json" + output = tmp_path / "probe.json" + sample = tmp_path / "synthetic.wav" + sample.write_bytes(b"synthetic sample bytes") + output.write_text(json.dumps({"streams": [{"codec_type": "audio"}], "format": {"duration": "1.25"}})) + fake_ffprobe = tmp_path / "ffprobe" + fake_ffprobe.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "open(os.environ['FFPROBE_CAPTURE'], 'w').write(json.dumps(sys.argv[1:]))\n" + "print(open(os.environ['FFPROBE_OUTPUT']).read())\n" + ) + fake_ffprobe.chmod(0o755) + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setenv("FFPROBE_CAPTURE", str(capture)) + monkeypatch.setenv("FFPROBE_OUTPUT", str(output)) + try: + inspected = local_upload_check.inspect_sample(sample, str(fake_ffprobe)) + finally: + monkeypatch.undo() + + argv = json.loads(capture.read_text()) + assert argv == [ + "-v", "error", "-show_entries", "format=duration:stream=codec_type", + "-of", "json", str(sample), + ] + assert inspected == {"bytes": len(b"synthetic sample bytes"), "duration_seconds": 1.25} + + +def test_preflight_logs_upload_enable_blocked_without_reading_dotenv(tmp_path, monkeypatch, capsys): + secret = "do-not-print-this-dotenv-secret" + env_file = tmp_path / ".env" + env_file.write_text(f"VTA_UPLOADS_ENABLED=true\nTOKEN={secret}\n") + data_dir = tmp_path / "data" + data_dir.mkdir() + config = { + "storage": { + "upload_limits": { + "max_file_mib": None, + "max_media_hours": None, + "receive_concurrency": None, + "upload_temp_budget_mib": None, + } + } + } + monkeypatch.setenv("VTA_UPLOADS_ENABLED", "false") + monkeypatch.setattr( + "video_transcript_api.api.context.load_and_validate_config", + lambda _path: config, + raising=False, + ) + + args = local_upload_check.build_parser().parse_args( + ["--config", str(tmp_path / "config.jsonc"), "--data-dir", str(data_dir), + "--compose", str(Path(__file__).resolve().parents[2] / "docker/docker-compose.deploy.yml"), + "--repo", str(Path(__file__).resolve().parents[2])] + ) + assert local_upload_check.run_check(args) == 2 + output = capsys.readouterr().out + + assert "upload_limits=invalid" in output + assert "deployment_env_file=.env" in output + assert "process_upload_gate=off_or_unset" in output + assert "UPLOAD_ENABLE_BLOCKED:" in output + assert "restore domain has not been proved" in output + assert secret not in output + + +def test_process_gate_true_does_not_certify_enablement(tmp_path, monkeypatch, capsys): + data_dir = tmp_path / "data" + data_dir.mkdir() + config = {"storage": {"upload_limits": { + "max_file_mib": 1, + "max_media_hours": 1, + "receive_concurrency": 1, + "upload_temp_budget_mib": 2, + }}} + monkeypatch.setenv("VTA_UPLOADS_ENABLED", "true") + monkeypatch.setattr( + "video_transcript_api.api.context.load_and_validate_config", + lambda _path: config, + raising=False, + ) + args = local_upload_check.build_parser().parse_args( + ["--config", str(tmp_path / "config.jsonc"), "--data-dir", str(data_dir), + "--compose", str(Path(__file__).resolve().parents[2] / "docker/docker-compose.deploy.yml"), + "--repo", str(Path(__file__).resolve().parents[2]), "--source-sha", "not-a-sha"] + ) + + assert local_upload_check.run_check(args) == 2 + output = capsys.readouterr().out + assert "upload_limits=valid" in output + assert "process_upload_gate=exact_true" in output + assert "source_compatibility=invalid" in output + assert "UPLOAD_ENABLE_BLOCKED:" in output From 7c6e7cf083dce3a59b42189a64fe085394c36101 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 10:21:58 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=AE=B9=E9=87=8F=E4=B8=8E=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E8=BF=90=E7=BB=B4=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-013605-110782 Task-Id: VideoTranscriptAPI-20261009-11 --- config/config.example.jsonc | 3 +- docker/docker-compose.deploy.yml | 2 + docker/docker-compose.yml | 5 +- docker/ops.env.example | 8 +- docs/operations/local-upload.md | 53 +++ .../progress/F-progress.md | 13 +- scripts/ops/local_upload_check.py | 39 +- scripts/perf/local_upload_capacity.py | 439 ++++++++++++++++++ tests/integration/test_upload_restore.py | 249 ++++++++++ tests/unit/test_upload_ops.py | 64 ++- 10 files changed, 855 insertions(+), 20 deletions(-) create mode 100644 docs/operations/local-upload.md create mode 100755 scripts/perf/local_upload_capacity.py create mode 100644 tests/integration/test_upload_restore.py diff --git a/config/config.example.jsonc b/config/config.example.jsonc index 18ba6b30..5f7483e9 100644 --- a/config/config.example.jsonc +++ b/config/config.example.jsonc @@ -63,7 +63,7 @@ "task_status_retention_days": 180, // task_status 表终态(success/failed)任务记录保留天数(每日定时清理),0 表示永久保留。注意:/view 链接依赖 task_status 行解析,本值不应短于 cache_retention_days,短于时按缓存保留期执行(钳制并记 warning);cache_retention_days 为 0(缓存永久保留)时本清理会被跳过 "audit_log_retention_days": 180, // 审计日志保留天数(每日定时清理 audit.db 超期记录),0 表示永久保留 "max_download_size_mb": 4096, // 单文件下载大小上限(MB),0 表示不限制,防止超长视频填满磁盘 - "upload_limits": { // 未经容量实测的上传限制留 null;缺少任一正有限值时上传能力关闭 + "upload_limits": { // 四项真实容量证明齐备前保持 null;样本/示例值不能作为生产额度 "max_file_mib": null, "max_media_hours": null, "receive_concurrency": null, @@ -417,7 +417,6 @@ // ============================================================ "funasr_spk_server": { "server_url": "ws://localhost:8767", // WebSocket 服务地址 - "send_terms": false, // 发送术语热词;非空 terms 会绕过普通缓存,重复音频也会重新识别 "max_retries": 3, // 连接/未知异常的最大重试次数 "retry_delay": 5, // 重试间隔(秒),也是 queue_full 无 retry_after 时的兜底退避 "connection_timeout": 30, // 连接超时时间(秒) diff --git a/docker/docker-compose.deploy.yml b/docker/docker-compose.deploy.yml index 7090614b..c2c09bfc 100644 --- a/docker/docker-compose.deploy.yml +++ b/docker/docker-compose.deploy.yml @@ -7,6 +7,8 @@ services: container_name: video-transcript-api environment: - TZ=Asia/Shanghai + # .env 是部署域环境源,位于 data/config 卷外;恢复数据库不得覆盖此处的关闭开关。 + # 缺失 VTA_UPLOADS_ENABLED 或值不是精确小写 true 时保持关闭。 env_file: - .env ports: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a9c758b6..566e4994 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,8 +9,9 @@ services: container_name: video-transcript-api environment: - TZ=Asia/Shanghai - # 可观测 DSN 只经 env_file 注入 os.environ(不挂载、不进 git)。 - # 缺 ops.env 也能起(SDK fail-open)。DSN 绝不写进 config.jsonc。 + # 部署环境值只经 env_file 注入 os.environ,不随 config/data 卷恢复。 + # VTA_UPLOADS_ENABLED 缺失、空值或非精确小写 true 时保持关闭;不得为诊断而开启。 + # 可观测 DSN 也只经 env_file 注入;缺 ops.env 仍可启动,DSN 绝不写入 config.jsonc。 env_file: - path: ops.env required: false diff --git a/docker/ops.env.example b/docker/ops.env.example index 707c781c..80ce5262 100644 --- a/docker/ops.env.example +++ b/docker/ops.env.example @@ -1,7 +1,8 @@ # 可观测性注入文件(示例)。真实 ops.env 只放在部署目录、绝不进 git。 # -# 用途:docker-compose.yml 通过 env_file 把这里的变量注入容器 os.environ, -# 供 zlx-ops-sdk 上报错误到 GlitchTip。DSN 绝不写进 config/config.jsonc。 +# 用途:docker-compose.yml 通过 env_file 把这里的变量注入容器 os.environ。 +# SENTRY_DSN 供 zlx-ops-sdk 上报错误;DSN 绝不写进 config/config.jsonc。 +# VTA_UPLOADS_ENABLED 是部署域独立开关;保持空值/未设置即关闭,不能用此样例启用。 # # 部署步骤(n305): # 1. 在 GlitchTip 建 project 拿到 DSN @@ -11,3 +12,6 @@ # GlitchTip DSN(缺省 = 不上报,SDK fail-open,不报错) SENTRY_DSN= + +# 本地上传必须经容量/恢复/部署授权后才能设置为精确的小写 true。 +VTA_UPLOADS_ENABLED= diff --git a/docs/operations/local-upload.md b/docs/operations/local-upload.md new file mode 100644 index 00000000..58b082b7 --- /dev/null +++ b/docs/operations/local-upload.md @@ -0,0 +1,53 @@ +# 本地上传运维检查与恢复边界 + +本说明只覆盖软件侧工具与安全操作顺序,不构成生产容量、恢复或启用证明。当前 M6 软件工具可以在本地沙箱演练;生产入口、磁盘/代理容量、ASR 最大工作集、独立恢复域与部署授权仍未知,必须保持上传开关关闭。 + +## 真实消费路径 + +- API 通过 `load_and_validate_config` 读取 `/app/config/config.jsonc`;Docker consumer 将宿主配置目录挂到该路径。四项 `storage.upload_limits` 必须为正有限值(并发为正整数);示例配置保留 `null`,不会把实验数字写入生产额度。 +- `docker/docker-compose.yml` 的服务环境来自可选 `docker/ops.env`,数据与配置分别挂在 `../data`、`../config`。部署 compose 的环境源是部署域 `.env`,配置与数据挂载为 `./config`、`./data`;仓内 `docker/deploy_targets.json` 指向 `/opt/media/VideoTranscriptAPI`。 +- `VTA_UPLOADS_ENABLED` 是进程环境变量,不是 JSON 配置,也不在数据卷里。上传接收实际要求值精确等于小写 `true`;未设置、空值及其他值均关闭。恢复 `data/` 不得覆盖部署域 `.env`。Resolver 与接收入口都要检查该 gate。 +- 当前工具不读取 `.env`,只从 tracked compose 描述中报告 `env_file` 文件名。检查 `.env` 时仅可用单键 `grep`,不得 `source` 整个文件;不得打印完整环境或配置。 + +## 本地诊断 + +只对已知配置路径运行;不要把沙箱样例数写成生产额度: + +```bash +uv run --frozen python scripts/ops/local_upload_check.py \ + --config \ + --data-dir \ + --compose \ + --repo \ + --source-sha \ + --sample +``` + +工具使用真实配置解析器、只读取配置中的有限额度字段、目标文件系统剩余空间及可选样本的 `ffprobe` 时长/大小;不会打印配置秘密、读 dotenv、启用/部署/恢复数据。兼容判据是 Git 对象中已存在的 commit ancestry,最低 A guard-only SHA 为 `6af391d20edab8dfd8b320ce6b91d4d19e3260e2`;更早的 pre-A `38b299468daaf309608f46ffe1c59886b0922c04` 应拒绝。 ancestry 只核验源码祖先,不证明 OCI digest 与源码 SHA 的绑定,也不证明真实旧 image 行为。 + +无论本地检查多绿,CLI 都以 `UPLOAD_ENABLE_BLOCKED` 结束:当前尚未提供生产四限额实测、独立恢复域证据及生产 ingress/image 证明。不可将 gate 设置为 true,也不能用该诊断输出代替部署授权。 + +### 有界本地接收实验 + +```bash +uv run --frozen python scripts/perf/local_upload_capacity.py --duration-seconds 20 +``` + +命令会报告 source SHA 与工作树 clean/dirty 状态;只有 clean checkout 的观测才可归因到精确提交。它会自行新建并回收一个 OS `mkdtemp`,从 tracked 示例生成仅用于实验的配置,服务只绑定 loopback,数据、用户和日志都写该临时目录。脚本用原始 HTTP 向真实 FastAPI upload receiver 发送两份 20 秒、PCM 16-bit mono/48 kHz 合成 WAV,运行真实 `ffprobe` 与当前 worker;同时用受控 loopback HTTP fixture 做 URL 下载。ASR 对端被替换成 loopback WebSocket 拒绝器,worker 应显式失败,不调用外部 ASR/LLM/通知服务。脚本比对服务端 SQLite 中每份实际字节数和 SHA-256,并测量 10ms 采样间隔下临时目录观察峰值。 + +这是一个有明确大小/时长/并发上限的单机诊断,不测真实生产硬盘配额、反代缓冲、ASR/LLM 峰值与最大时长;观察峰值不是预留量,也不得回填四项限制。任何实验中的未知或异常都应中止并查因,不能扩限重跑直到变绿。 + +## 安全恢复顺序(仅获授权后) + +1. 在部署项目根 `/opt/media/VideoTranscriptAPI` 只查询开关一键,不读/打印整个 `.env`:`grep -n '^VTA_UPLOADS_ENABLED=' .env`;缺键、空值或非精确 `true` 都是关闭态。检查返回非零时应区分“键不存在”与“值不同”。如果值为 true,须先由获准操作者在部署域将该单键改成 false,并重新 grep 核实;不得 `source .env`。 +2. 服务保持停止:`docker compose -f docker-compose.yml --project-directory /opt/media/VideoTranscriptAPI --env-file /opt/media/VideoTranscriptAPI/.env stop video-transcript-api`。只用授权恢复工具把 cache DB 与对应正文文件恢复到已知空目标;严禁覆盖部署目录 `.env`、服务 unit 或其他卷。不得使用本卡工具自动恢复任何数据库。 +3. 核对恢复来源确实包含撤销后的权威状态,并检查 DB 与正文/必要映射完整。仅有撤销前 snapshot 时,恢复会带回旧 `revoked_at=NULL`;本地行无法识别完整卷回退。没有独立、可信且可核验的撤销来源时,停止在关闭态,不可声称撤销未复活。 +4. 仍保持环境 gate 关闭,启动隔离消费方做只读核对:真实 Resolver 拒绝被撤销记录,cache/task cleaner 保留有效 `never` 与有效 30d 正文及必要 root 映射,原媒体仍按既有临时清理规则处理。恢复后的服务只有在独立部署授权后才可启动;在证据齐全前不要把 gate 改回 true。本文测试只在 pytest `tmp_path` 沙箱验证,未启动 systemd 或生产服务。 +5. 只有 M4/M5 完成、部署 image 具有可验证 source SHA、A guard-only 或更新版本通过真实消费回归、独立恢复域/撤销状态可证明、四限额在实际磁盘/ASR并发/URL负载下测得、真实代理/ingress 条件已核对且获得单独部署授权后,才可由授权操作者评估设置开关。任一项 unknown 就继续 off。 + +## 当前证据状态 + +- 已实现并测试:独立 SQLite+正文生产者生成 snapshot;撤销后将 sandbox 恢复到撤销前副本;fresh Resolver 通过独立 `VTA_UPLOADS_ENABLED=false` 子进程环境拒读;current cache/task cleaner 对有效长期成果及 root 保持保护。 +- 已用真实 A guard-only SHA 的源树作为独立 subprocess consumer,消费当前 producer 生成的 SQLite+文件;启用 sandbox gate 时读取合法 active share,legacy upload-token/blank token 别名拒绝,两个 cleaner 均保留旧龄有效长期结果。pre-A 版本由 commit ancestry 判据拒绝。 +- 本机单次 synthetic 探针数据只代表当次硬件、依赖与临时文件系统;ASR 被 loopback rejector 拒绝,ASR 容量、代理缓冲与真实生产并发未知。脚本报告为 blocked,不将该样本作为任何生产额度。 +- 未执行:真实生产 ingress、配置/数据卷、systemd 身份与 mount、生产备份恢复、真实磁盘配额、真实 ASR/LLM 工作集或用户部署授权。不得把未执行改写成 passed。 diff --git a/docs/sessions/261008-local-upload/progress/F-progress.md b/docs/sessions/261008-local-upload/progress/F-progress.md index 8711bf9e..fb72db76 100644 --- a/docs/sessions/261008-local-upload/progress/F-progress.md +++ b/docs/sessions/261008-local-upload/progress/F-progress.md @@ -2,7 +2,14 @@ ## 2026-10-09 · 首个运维门槛单元 -- **当前阶段**:implementing;首个只读运维门槛单元已验证并待提交/推送。 -- **本段结论**:`uv run --frozen pytest -q tests/unit/test_upload_ops.py` 4 passed;CLI 只读取明确传入的消费配置/数据目录与当前进程开关,不加载 dotenv、不打印密钥、不启用/部署/恢复;即使四限额、开关和代码祖先检查满足,也会以 `UPLOAD_ENABLE_BLOCKED` 阻止生产启用,因为容量、独立恢复域和实际入口尚未知。 +- **当前阶段**:implementing;独立只读门槛单元已提交 `03828d3231003fc6ef3f91e52d772950e8b4f796`、推送并由 draft PR #208 承接。 +- **本段结论**:`uv run --frozen pytest -q tests/unit/test_upload_ops.py` 4 passed;`scripts/ops/local_upload_check.py` 只读取明确传入的消费配置/数据目录与当前进程开关,不加载 dotenv、不打印密钥、不启用/部署/恢复;即使四限额、开关和代码祖先检查满足,也会以 `UPLOAD_ENABLE_BLOCKED` 阻止生产启用,因为容量、独立恢复域和实际入口尚未知。 - **关键决策与否决方案**:消费配置解析沿用真实 `load_and_validate_config`;env 文件仅从 tracked compose 声明识别来源,绝不 source;兼容检查以 Git commit ancestry 与已知 A guard-only 完整 SHA 为边界,不用版本字符串比较;本地检查结果不作为 enable 权限或伪造容量凭据。首轮测试因预期把四个 `null` 错判为 `missing` 有 AssertionError 红,修正断言后全绿;生产代码未变异。 -- **下一步唯一动作**:提交并推送该独立可绿单元,再继续恢复与容量运维证据。 +- **下一步唯一动作**:完成恢复与容量工具的单元和本地沙箱验证,提交第二里程碑并在 clean tree 重跑容量探针。 + +## 2026-10-09 · 恢复消费者与容量探针 + +- **当前阶段**:implementing;真实 SQLite+正文 snapshot/restore 回归、A guard-only 真实源码消费者测试、受控容量探针和运维文档已落盘,当前窄测 11 passed,尚未提交。 +- **本段结论**:fresh Resolver 子进程接收恢复前 DB 且外部 gate 明确 false,实际拒绝读;current cleaner 保留有效 never 正文/root。独立 subprocess 从真实 A guard-only commit `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 运行 resolver/cleaner,active share 可读、upload/blank legacy alias 不可读、cache/task 两个 cleaner 不删有效长成果;pre-A commit 由 ancestry 负例拒绝。容量脚本实际成功完成一次 raw HTTP/两接收者/loopback URL 下载与 ASR rejector 实验,但当时 source tree dirty,HEAD 值不足以作最终版本证据,需 clean commit 后复跑。 +- **关键决策与否决方案**:恢复 fixture 只用 pytest `tmp_path`;还原撤销前快照会让 SQLite 内 `revoked_at` 回到 NULL,所以唯一安全结论是部署域 gate 关闭且没有独立撤销来源时不得再开;脚本仅对 loopback 服务/下载/ASR fixture 工作,失败终态是预期而不代表 ASR 容量。移除 config example 中消费者 validator 不接受的 `funasr_spk_server.send_terms` 示例键;消费者默认值仍为 false,不修改运行时消费者。 +- **下一步唯一动作**:提交本里程碑,然后 clean-tree 重跑 `scripts/perf/local_upload_capacity.py` 并保存真实观测。 diff --git a/scripts/ops/local_upload_check.py b/scripts/ops/local_upload_check.py index ecb6dba8..a785e0fd 100755 --- a/scripts/ops/local_upload_check.py +++ b/scripts/ops/local_upload_check.py @@ -77,15 +77,24 @@ def inspect_sample(path: Path, ffprobe: str = "ffprobe") -> dict[str, float | in for item in streams ): raise CheckFailure("sample_has_no_media_stream") - try: - duration_seconds = float(duration) - except (TypeError, ValueError) as exc: - raise CheckFailure("sample_duration_unknown") from exc + duration_seconds = float(duration) if not math.isfinite(duration_seconds) or duration_seconds <= 0: raise CheckFailure("sample_duration_invalid") return {"bytes": path.stat().st_size, "duration_seconds": duration_seconds} +def sample_limit_status(sample: dict[str, float | int], config: dict[str, Any]) -> str: + valid, _state = valid_upload_limits(config) + if not valid: + return "unknown_limits" + limits = config["storage"]["upload_limits"] + if sample["bytes"] > limits["max_file_mib"] * 1024 * 1024: + return "exceeds_file_limit" + if sample["duration_seconds"] > limits["max_media_hours"] * 3600: + return "exceeds_duration_limit" + return "within_declared_limits" + + def compatibility_status(repo: Path, source_sha: str | None) -> str: if source_sha is None: return "unknown" @@ -137,19 +146,35 @@ def run_check(args: argparse.Namespace) -> int: config = load_and_validate_config(args.config) limits_ok, limits_state = valid_upload_limits(config) print(f"upload_limits={limits_state}") + limits = config.get("storage", {}).get("upload_limits") + if limits_ok: + print( + "configured_limits=" + f"max_file_mib:{limits['max_file_mib']}_" + f"max_media_hours:{limits['max_media_hours']}_" + f"receive_concurrency:{limits['receive_concurrency']}_" + f"upload_temp_budget_mib:{limits['upload_temp_budget_mib']}" + ) data_path = Path(args.data_dir) if not data_path.is_dir(): raise CheckFailure("data_dir_not_directory") disk = shutil.disk_usage(data_path) print(f"data_disk_free_bytes={disk.free}") + reserve_state = "unknown_limits" + if limits_ok: + budget_bytes = int(limits["upload_temp_budget_mib"] * 1024 * 1024) + safety_margin = max(1024 * 1024, budget_bytes // 20) + reserve_state = "sufficient" if disk.free >= budget_bytes + safety_margin else "insufficient" + print(f"configured_temp_reserve={reserve_state}") sample_state = "not_supplied" if args.sample: sample = inspect_sample(Path(args.sample), args.ffprobe) print(f"sample_bytes={sample['bytes']}") print(f"sample_duration_seconds={sample['duration_seconds']:.3f}") - sample_state = "measured" + sample_state = sample_limit_status(sample, config) + print(f"sample_limit_check={sample_state}") compose_path = Path(args.compose) env_source = deployment_env_source(compose_path) @@ -170,6 +195,10 @@ def run_check(args: argparse.Namespace) -> int: ] if not limits_ok: blocked_reasons.append("four limits are not positive finite values") + if reserve_state == "insufficient": + blocked_reasons.append("configured upload temporary reserve exceeds available space") + if sample_state.startswith("exceeds_"): + blocked_reasons.append("sample exceeds its configured upload limit") if sample_state == "not_supplied": blocked_reasons.append("no local media sample was measured") print("UPLOAD_ENABLE_BLOCKED: " + "; ".join(blocked_reasons)) diff --git a/scripts/perf/local_upload_capacity.py b/scripts/perf/local_upload_capacity.py new file mode 100755 index 00000000..71dca250 --- /dev/null +++ b/scripts/perf/local_upload_capacity.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Run a bounded local-only upload/download overlap experiment in a fresh temp tree. + +This records a synthetic local observation, not a production limit or enablement +credential. The real API worker is allowed to reach only a loopback ASR rejector. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import http.client +import http.server +import json +import os +from pathlib import Path +import platform +import socket +import socketserver +import sqlite3 +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import uuid +import wave +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from datetime import datetime, timezone +from urllib.parse import urlparse + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + + +class ExperimentFailure(RuntimeError): + """A sandbox measurement did not produce the promised evidence.""" + + +class QuietMediaHandler(http.server.SimpleHTTPRequestHandler): + requests_seen = 0 + request_intervals: list[tuple[float, float]] = [] + requests_guard = threading.Lock() + + def do_GET(self): + started = time.monotonic() + time.sleep(0.5) + super().do_GET() + with self.requests_guard: + type(self).requests_seen += 1 + type(self).request_intervals.append((started, time.monotonic())) + + def log_message(self, _format, *args): + return + + +class LoopbackRejectHandler(socketserver.BaseRequestHandler): + connections_seen = 0 + requests_guard = threading.Lock() + + def handle(self): + with self.requests_guard: + type(self).connections_seen += 1 + self.request.recv(1024) + self.request.sendall(b"HTTP/1.1 503 Controlled local ASR fixture\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + + +class LoopbackThreadingServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +def _make_wav(path: Path, duration_seconds: int) -> bytes: + sample_rate = 48_000 + frames = duration_seconds * sample_rate + with wave.open(str(path), "wb") as writer: + writer.setnchannels(1) + writer.setsampwidth(2) + writer.setframerate(sample_rate) + writer.writeframes(b"\x00\x00" * frames) + return path.read_bytes() + + +def _tool_version(name: str) -> str: + result = subprocess.run( + [name, "-version"], check=False, capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + raise ExperimentFailure(f"{name}_version_probe_failed_rc_{result.returncode}") + return result.stdout.splitlines()[0].replace(" ", "_") + + +def _write_synthetic_config(root: Path, *, api_port: int, asr_port: int) -> tuple[Path, Path]: + sys.path.insert(0, str(PROJECT_ROOT / "src")) + from video_transcript_api.utils.logging import load_config + + config = load_config(PROJECT_ROOT / "config/config.example.jsonc", use_cache=False) + data = root / "data" + storage = config["storage"] + storage["cache_dir"] = str(data / "cache") + storage["workspace_dir"] = str(data / "workspace") + storage["temp_dir"] = str(data / "temp") + storage["audit_db"] = str(data / "audit.db") + storage["upload_limits"] = { + "max_file_mib": 4, + "max_media_hours": 0.02, + "receive_concurrency": 2, + "upload_temp_budget_mib": 12, + } + config["api"]["host"] = "127.0.0.1" + config["api"]["port"] = api_port + config["api"]["auth_token"] = "sandbox-token-never-use-outside-this-run" + config["web"]["base_url"] = f"http://127.0.0.1:{api_port}" + config["capswriter"]["server_url"] = f"ws://127.0.0.1:{asr_port}" + config["capswriter"]["max_retries"] = 1 + config["capswriter"]["retry_delay"] = 0 + config["capswriter"]["connection_timeout"] = 1 + config["funasr_spk_server"].pop("send_terms", None) + config["security"]["download_url_allowlist"] = ["127.0.0.1/32"] + config["wechat"]["webhook"] = "" + config["feishu"]["webhook"] = "" + config["log"]["file"] = str(data / "logs" / "sandbox-api.log") + config_path = root / "sandbox-config.json" + config_path.write_text(json.dumps(config, ensure_ascii=True), encoding="utf-8") + users_path = root / "sandbox-users.json" + users_path.write_text( + json.dumps({"users": { + config["api"]["auth_token"]: {"user_id": "sandbox-owner", "name": "Sandbox"} + }}), + encoding="utf-8", + ) + return config_path, users_path + + +def _raw_upload(base_url: str, token: str, payload: bytes, starts: threading.Barrier) -> dict: + key = f"{int(datetime.now(timezone.utc).timestamp() * 1000)}-{uuid.uuid4()}" + metadata = { + "filename": "bounded-synthetic.wav", + "byte_size": len(payload), + "title": "local capacity fixture", + "source_url": None, + "retention": "never", + "processing_options": {"calibrate": False, "summarize": False}, + } + metadata_header = base64.urlsafe_b64encode( + json.dumps(metadata, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ).decode("ascii").rstrip("=") + parsed = urlparse(base_url) + connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=20) + path = "/api/uploads" + connection.putrequest("POST", path) + connection.putheader("Authorization", f"Bearer {token}") + connection.putheader("Content-Type", "application/octet-stream") + connection.putheader("Content-Length", str(len(payload))) + connection.putheader("Idempotency-Key", key) + connection.putheader("X-Upload-Metadata", metadata_header) + connection.endheaders() + starts.wait(timeout=5) + started = time.monotonic() + chunk_bytes = 64 * 1024 + for offset in range(0, len(payload), chunk_bytes): + connection.send(payload[offset : offset + chunk_bytes]) + time.sleep(0.003) + response = connection.getresponse() + body = response.read() + elapsed = time.monotonic() - started + connection.close() + if response.status != 202: + detail = json.loads(body).get("detail", "no_detail") + raise ExperimentFailure(f"upload_http_status_{response.status}_detail={detail}") + receipt = json.loads(body) + if not receipt.get("upload_id") or not receipt.get("task_id"): + raise ExperimentFailure("upload_receipt_missing_identity") + return { + "upload_id": receipt["upload_id"], + "task_id": receipt["task_id"], + "elapsed_seconds": elapsed, + "started_at": started, + "ended_at": time.monotonic(), + } + + +def _submit_controlled_url(base_url: str, token: str, url: str, starts: threading.Barrier) -> str: + parsed = urlparse(base_url) + body = json.dumps({ + "url": url, + "use_speaker_recognition": False, + "processing_options": {"calibrate": False, "summarize": False}, + }).encode("utf-8") + connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=20) + starts.wait(timeout=5) + connection.request( + "POST", + "/api/transcribe", + body=body, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + response = connection.getresponse() + result = json.loads(response.read()) + connection.close() + task_id = result.get("data", {}).get("task_id") + if response.status != 200 or not task_id: + raise ExperimentFailure(f"controlled_url_http_status_{response.status}") + return task_id + + +def _stop_process(process: subprocess.Popen) -> None: + if process.poll() is None: + process.terminate() + process.wait(timeout=10) + + +def _file_bytes(root: Path) -> int: + return sum(path.stat().st_size for path in root.rglob("*") if path.is_file()) + + +def _wait_for_terminal(db_path: Path, task_ids: list[str], process: subprocess.Popen, timeout_seconds: int = 30) -> dict[str, str]: + deadline = time.monotonic() + timeout_seconds + states: dict[str, str] = {} + while time.monotonic() < deadline: + if process.poll() is not None: + raise ExperimentFailure("sandbox_api_exited_during_measurement") + with sqlite3.connect(db_path) as connection: + rows = connection.execute( + "SELECT task_id, status FROM task_status WHERE task_id IN (%s)" % ",".join("?" for _ in task_ids), + task_ids, + ).fetchall() + states = dict(rows) + if len(states) == len(task_ids) and all(status in {"success", "failed"} for status in states.values()): + return states + time.sleep(0.1) + raise ExperimentFailure("sandbox_tasks_did_not_reach_terminal_state") + + +def run_experiment(duration_seconds: int) -> int: + if duration_seconds < 1 or duration_seconds > 30: + raise ExperimentFailure("duration_seconds_must_be_between_1_and_30") + + with tempfile.TemporaryDirectory(prefix="local-upload-capacity-") as temporary: + root = Path(temporary).resolve() + if root == PROJECT_ROOT or PROJECT_ROOT in root.parents: + raise ExperimentFailure("sandbox_must_be_outside_project_tree") + wav_path = root / "bounded-synthetic.wav" + payload = _make_wav(wav_path, duration_seconds) + if len(payload) > 4 * 1024 * 1024: + raise ExperimentFailure("synthetic_sample_exceeded_4_mib") + + from scripts.ops.local_upload_check import inspect_sample + + sample_info = inspect_sample(wav_path) + if abs(sample_info["duration_seconds"] - duration_seconds) > 0.1: + raise ExperimentFailure("ffprobe_duration_did_not_match_synthetic_source") + + with ExitStack() as cleanup: + asr_fixture = LoopbackThreadingServer(("127.0.0.1", 0), LoopbackRejectHandler) + asr_thread = threading.Thread(target=asr_fixture.serve_forever, daemon=True) + cleanup.callback(asr_thread.join, 5) + cleanup.callback(asr_fixture.server_close) + cleanup.callback(asr_fixture.shutdown) + asr_thread.start() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as port_socket: + port_socket.bind(("127.0.0.1", 0)) + api_port = port_socket.getsockname()[1] + + config_path, users_path = _write_synthetic_config( + root, api_port=api_port, asr_port=asr_fixture.server_address[1] + ) + + class SandboxMediaHandler(QuietMediaHandler): + requests_seen = 0 + request_intervals: list[tuple[float, float]] = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(root), **kwargs) + + media_server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), SandboxMediaHandler) + media_thread = threading.Thread(target=media_server.serve_forever, daemon=True) + cleanup.callback(media_thread.join, 5) + cleanup.callback(media_server.server_close) + cleanup.callback(media_server.shutdown) + media_thread.start() + controlled_url = f"http://127.0.0.1:{media_server.server_address[1]}/{wav_path.name}" + + clean_env = { + "PATH": os.environ["PATH"], + "HOME": os.environ.get("HOME", str(root)), + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "TZ": "UTC", + "PYTHONPATH": str(PROJECT_ROOT / "src"), + "VTAPI_USERS_JSON": str(users_path), + "VTA_UPLOADS_ENABLED": "true", + } + service_log_path = root / "sandbox-api.log" + with service_log_path.open("wb") as service_log: + process = subprocess.Popen( + [sys.executable, str(PROJECT_ROOT / "main.py"), "--start", "--config", str(config_path)], + cwd=PROJECT_ROOT, + env=clean_env, + stdout=service_log, + stderr=subprocess.STDOUT, + ) + cleanup.callback(_stop_process, process) + time.sleep(2) + if process.poll() is not None: + raise ExperimentFailure("sandbox_api_failed_to_start") + + base_url = f"http://127.0.0.1:{api_port}" + parsed = urlparse(base_url) + connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=5) + connection.request( + "GET", + "/api/uploads/capabilities", + headers={"Authorization": "Bearer sandbox-token-never-use-outside-this-run"}, + ) + readiness = connection.getresponse() + capabilities = json.loads(readiness.read()) + connection.close() + if readiness.status != 200 or capabilities.get("enabled") is not True: + raise ExperimentFailure("sandbox_upload_receiver_not_ready") + + starts = threading.Barrier(3) + data_temp = root / "data" / "temp" + max_temp_bytes = 0 + max_data_bytes = 0 + minimum_free_bytes = shutil.disk_usage(root).free + with ThreadPoolExecutor(max_workers=3) as pool: + upload_futures = [ + pool.submit( + _raw_upload, + base_url, + "sandbox-token-never-use-outside-this-run", + payload, + starts, + ) + for _ in range(2) + ] + url_future = pool.submit( + _submit_controlled_url, + base_url, + "sandbox-token-never-use-outside-this-run", + controlled_url, + starts, + ) + futures = upload_futures + [url_future] + while any(not future.done() for future in futures): + max_temp_bytes = max(max_temp_bytes, _file_bytes(data_temp)) + max_data_bytes = max(max_data_bytes, _file_bytes(root / "data")) + minimum_free_bytes = min(minimum_free_bytes, shutil.disk_usage(root).free) + time.sleep(0.01) + uploads = [future.result(timeout=1) for future in upload_futures] + url_task_id = url_future.result(timeout=1) + max_temp_bytes = max(max_temp_bytes, _file_bytes(data_temp)) + max_data_bytes = max(max_data_bytes, _file_bytes(root / "data")) + + task_ids = [item["task_id"] for item in uploads] + [url_task_id] + terminal_states = _wait_for_terminal( + root / "data/cache/cache.db", task_ids, process + ) + if any(status != "failed" for status in terminal_states.values()): + raise ExperimentFailure("unexpected_non_failure_in_loopback_asr_experiment") + + with sqlite3.connect(root / "data/cache/cache.db") as connection: + received = connection.execute( + "SELECT upload_id, byte_size, sha256 FROM local_uploads ORDER BY upload_id" + ).fetchall() + expected_hash = hashlib.sha256(payload).hexdigest() + if len(received) != 2 or any( + size != len(payload) or digest != expected_hash + for _, size, digest in received + ): + raise ExperimentFailure("producer_bytes_do_not_match_persisted_receiver_record") + intervals = SandboxMediaHandler.request_intervals + if SandboxMediaHandler.requests_seen < 1: + raise ExperimentFailure("controlled_url_download_fixture_not_consumed") + if not any( + download_start < upload["ended_at"] + and download_end > upload["started_at"] + for download_start, download_end in intervals + for upload in uploads + ): + raise ExperimentFailure("controlled_url_download_did_not_overlap_upload_receiving") + if LoopbackRejectHandler.connections_seen < 1: + raise ExperimentFailure("loopback_asr_fixture_not_consumed") + + source_sha = subprocess.run( + ["git", "-C", str(PROJECT_ROOT), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + source_status = subprocess.run( + ["git", "-C", str(PROJECT_ROOT), "status", "--porcelain"], + check=True, + capture_output=True, + text=True, + ).stdout + source_tree_state = "clean" if not source_status else "dirty" + latency_values = ",".join(f"{item['elapsed_seconds']:.3f}" for item in uploads) + print(f"sandbox_root=mkdtemp:{root.name}") + print(f"source_sha={source_sha}") + print(f"source_worktree={source_tree_state}") + print(f"host={platform.machine()}_cpu_count={os.cpu_count()}_python={platform.python_version()}") + print(f"ffmpeg={_tool_version('ffmpeg')}") + print(f"ffprobe={_tool_version('ffprobe')}") + print(f"synthetic_codec=pcm_s16le_mono_48000Hz_duration_seconds={duration_seconds}") + print(f"sample_bytes={len(payload)}_ffprobe_duration_seconds={sample_info['duration_seconds']:.3f}") + print("raw_http_receipts=2_of_2") + print(f"received_payload_sha256={expected_hash}") + print("sandbox_limits=max_file_mib:4_max_media_hours:0.02_receive_concurrency:2_upload_temp_budget_mib:12") + print("receiver_concurrency_configured=2_upload_clients=2") + print(f"upload_latency_seconds={latency_values}") + print("controlled_url_upload_overlap=confirmed") + print(f"controlled_loopback_url_download_requests={SandboxMediaHandler.requests_seen}") + print(f"loopback_asr_rejector_connections={LoopbackRejectHandler.connections_seen}") + print(f"worker_terminal_states={','.join(sorted(terminal_states.values()))}") + print(f"temp_directory_peak_observed_bytes={max_temp_bytes}_sample_interval_ms=10") + print(f"data_tree_peak_observed_bytes={max_data_bytes}_sample_interval_ms=10") + print(f"sandbox_free_bytes_min={minimum_free_bytes}") + print("asr_capacity=unknown_external_model_not_called") + print("production_capacity=unknown_production_ingress_and_disk_quota_not_measured") + print("UPLOAD_ENABLE_BLOCKED: sandbox observations are not production capacity, restore-domain, or deployment authorization") + + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--duration-seconds", type=int, default=20) + args = parser.parse_args(argv) + return run_experiment(args.duration_seconds) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_upload_restore.py b/tests/integration/test_upload_restore.py new file mode 100644 index 00000000..b0b6ae6f --- /dev/null +++ b/tests/integration/test_upload_restore.py @@ -0,0 +1,249 @@ +"""Real SQLite/file restore and guard-only consumer regression contracts.""" + +from datetime import datetime, timedelta, timezone +import hashlib +import io +import os +from pathlib import Path +import shutil +import sqlite3 +import subprocess +import sys +import tarfile +import uuid +import wave + +import pytest + +from src.video_transcript_api.cache.cache_manager import CacheManager +from src.video_transcript_api.utils.logging.audit_logger import AuditLogger + +A_GUARD_ONLY_SHA = "6af391d20edab8dfd8b320ce6b91d4d19e3260e2" + + +def _new_upload(manager, *, retention="never"): + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + intent = manager.register_local_upload( + owner_user_id="restore-owner", + idempotency_key=f"{now_ms}-{uuid.uuid4()}", + retention=retention, + intent_metadata={"filename": "synthetic.wav", "retention": retention}, + ) + return manager.accept_local_upload( + intent["upload_id"], + media_id=f"upload_{uuid.uuid4().hex}", + title="sandbox restore fixture", + ) + + +def _write_real_result(manager, upload): + stored = manager.save_cache( + platform="local_upload", + url="", + media_id=upload["media_id"], + use_speaker_recognition=False, + transcript_data="Producer-created restore transcript.", + transcript_type="capswriter", + title="sandbox restore fixture", + author="", + description="", + ) + assert stored is not None + manager.update_task_status( + upload["root_task_id"], + "success", + platform="local_upload", + media_id=upload["media_id"], + title="sandbox restore fixture", + ) + transcript_file = Path(stored["transcript_file"]) + assert transcript_file.read_bytes() == b"Producer-created restore transcript." + return transcript_file + + +def _age_real_consumers(manager, upload): + old = (datetime.now(timezone.utc) - timedelta(days=400)).strftime("%Y-%m-%d %H:%M:%S") + with manager._get_cursor() as cursor: + cursor.execute( + "UPDATE video_cache SET updated_at = ? WHERE platform = 'local_upload' AND media_id = ?", + (old, upload["media_id"]), + ) + cursor.execute( + "UPDATE task_status SET completed_at = ?, created_at = ? WHERE task_id = ?", + (old, old, upload["root_task_id"]), + ) + + +def _resolver_process(cache_dir, token, *, enabled): + code = ( + "import sys; " + "from video_transcript_api.cache.cache_manager import CacheManager; " + "from video_transcript_api.api.services.view_token_resolver import ViewTokenResolver; " + "cm=CacheManager(cache_dir=sys.argv[1]); " + "value=ViewTokenResolver(cm).get_view_data_by_token(sys.argv[2]); " + "print('RESULT=' + str((value or {}).get('status', 'NONE'))); " + "print('BODY=' + str((value or {}).get('transcript', 'NONE'))); cm.close()" + ) + env = os.environ.copy() + env["VTA_UPLOADS_ENABLED"] = "true" if enabled else "false" + env["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src") + return subprocess.run( + [sys.executable, "-c", code, str(cache_dir), token], + cwd=Path(__file__).resolve().parents[2], + env=env, + capture_output=True, + text=True, + check=False, + timeout=20, + ) + + +def test_snapshot_restore_keeps_external_gate_off_and_protects_long_result(tmp_path): + source_cache = tmp_path / "producer-cache" + producer = CacheManager(cache_dir=str(source_cache)) + upload = _new_upload(producer) + transcript_file = _write_real_result(producer, upload) + _age_real_consumers(producer, upload) + + # Snapshot the actual consumer-created database and artifact bytes while the + # share is active; the snapshot does not include the deployment env gate. + producer.close() + snapshot_cache = tmp_path / "pre-revocation-snapshot" + shutil.copytree(source_cache, snapshot_cache) + snapshot_db_bytes = (snapshot_cache / "cache.db").read_bytes() + snapshot_transcript = transcript_file.relative_to(source_cache) + expected_transcript_bytes = (snapshot_cache / snapshot_transcript).read_bytes() + + after_snapshot = CacheManager(cache_dir=str(source_cache)) + revoked = after_snapshot.revoke_local_upload(upload["upload_id"]) + assert revoked["revoked_at"] is not None + after_snapshot.close() + + # Restore only this known-empty sandbox. The external gate stays false and + # is passed to a fresh consumer process, not restored from SQLite. + restored_cache = tmp_path / "restored-cache" + shutil.copytree(snapshot_cache, restored_cache) + assert (restored_cache / "cache.db").read_bytes() == snapshot_db_bytes + restored_transcript = restored_cache / snapshot_transcript + assert restored_transcript.read_bytes() == expected_transcript_bytes + restored = CacheManager(cache_dir=str(restored_cache)) + record = restored.get_local_upload_by_id(upload["upload_id"]) + assert record["revoked_at"] is None + assert record["retention"] == "never" + restored.audit_logger = AuditLogger(str(tmp_path / "restore-audit.db")) + + current = _resolver_process(restored_cache, upload["view_token"], enabled=False) + assert current.returncode == 0, current.stderr + assert "RESULT=NONE" in current.stdout + assert "BODY=NONE" in current.stdout + assert "UPLOAD_DISABLED" in current.stdout + + # Exercise both actual current cleaners against stale-age but effective + # never-expiring result and its required root resolver row. + assert restored.cleanup_old_cache(days=1) == 0 + assert restored_transcript.read_bytes() == expected_transcript_bytes + assert restored.cleanup_task_status(retention_days=1, cache_retention_days=1) == 0 + assert restored.get_task_by_id(upload["root_task_id"]) is not None + restored.close() + + +def test_local_capacity_probe_exercises_real_http_payload_and_loopback_overlap(tmp_path): + sample = tmp_path / "expected-capacity-payload.wav" + with wave.open(str(sample), "wb") as writer: + writer.setnchannels(1) + writer.setsampwidth(2) + writer.setframerate(48_000) + writer.writeframes(b"\x00\x00" * (20 * 48_000)) + expected = sample.read_bytes() + repo = Path(__file__).resolve().parents[2] + result = subprocess.run( + [sys.executable, str(repo / "scripts/perf/local_upload_capacity.py"), "--duration-seconds", "20"], + cwd=repo, + capture_output=True, + text=True, + check=False, + timeout=90, + ) + + assert result.returncode == 0, result.stderr + assert f"sample_bytes={len(expected)}_ffprobe_duration_seconds=20.000" in result.stdout + assert f"received_payload_sha256={hashlib.sha256(expected).hexdigest()}" in result.stdout + assert "raw_http_receipts=2_of_2" in result.stdout + assert "controlled_url_upload_overlap=confirmed" in result.stdout + temp_peak = next( + line for line in result.stdout.splitlines() + if line.startswith("temp_directory_peak_observed_bytes=") + ) + assert int(temp_peak.split("=", 1)[1].split("_", 1)[0]) > 0 + assert "worker_terminal_states=failed,failed,failed" in result.stdout + assert "asr_capacity=unknown_external_model_not_called" in result.stdout + assert "UPLOAD_ENABLE_BLOCKED:" in result.stdout + + +def test_actual_guard_only_source_consumes_fixture_and_preserves_active_long_artifacts(tmp_path): + repo = Path(__file__).resolve().parents[2] + archive = subprocess.run( + ["git", "-C", str(repo), "archive", A_GUARD_ONLY_SHA], + check=False, + capture_output=True, + timeout=20, + ) + assert archive.returncode == 0, archive.stderr.decode("utf-8", errors="replace") + legacy_root = tmp_path / "guard-only-source" + legacy_root.mkdir() + with tarfile.open(fileobj=io.BytesIO(archive.stdout), mode="r:") as bundle: + bundle.extractall(legacy_root, filter="data") + + producer_cache = tmp_path / "active-long-cache" + producer = CacheManager(cache_dir=str(producer_cache)) + upload = _new_upload(producer, retention="never") + transcript_file = _write_real_result(producer, upload) + _age_real_consumers(producer, upload) + producer.close() + + # Run the real A guard-only consumer source in a separate interpreter over + # the current SQLite+file producer artifact; do not mock the historical code. + consumer = ( + "import sys; " + "from video_transcript_api.cache.cache_manager import CacheManager; " + "from video_transcript_api.api.services.view_token_resolver import ViewTokenResolver; " + "from video_transcript_api.utils.logging.audit_logger import AuditLogger; " + "cm=CacheManager(cache_dir=sys.argv[1]); " + "cm.audit_logger=AuditLogger(sys.argv[3]); " + "resolver=ViewTokenResolver(cm); " + "active=resolver.get_view_data_by_token(sys.argv[2]); " + "print('ACTIVE_BODY=' + str((active or {}).get('transcript', 'NONE'))); " + "print('UPLOAD_LEGACY_ALIAS=' + str(cm.get_task_by_view_token(sys.argv[2]))); " + "print('BLANK_ALIAS=' + str(cm.get_task_by_view_token(''))); " + "print('CACHE_CLEANED=' + str(cm.cleanup_old_cache(days=1))); " + "print('TASK_CLEANED=' + str(cm.cleanup_task_status(retention_days=1, cache_retention_days=1))); " + "print('ROOT_PRESENT=' + str(cm.get_task_by_id(sys.argv[4]) is not None)); cm.close()" + ) + env = os.environ.copy() + env["VTA_UPLOADS_ENABLED"] = "true" + env["PYTHONPATH"] = str(legacy_root / "src") + result = subprocess.run( + [ + sys.executable, + "-c", + consumer, + str(producer_cache), + upload["view_token"], + str(tmp_path / "guard-only-audit.db"), + upload["root_task_id"], + ], + cwd=legacy_root, + env=env, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "ACTIVE_BODY=Producer-created restore transcript." in result.stdout + assert "UPLOAD_LEGACY_ALIAS=None" in result.stdout + assert "BLANK_ALIAS=None" in result.stdout + assert "CACHE_CLEANED=0" in result.stdout + assert "TASK_CLEANED=0" in result.stdout + assert "ROOT_PRESENT=True" in result.stdout + assert transcript_file.read_bytes() == b"Producer-created restore transcript." diff --git a/tests/unit/test_upload_ops.py b/tests/unit/test_upload_ops.py index e4caba34..753bb4b0 100644 --- a/tests/unit/test_upload_ops.py +++ b/tests/unit/test_upload_ops.py @@ -5,6 +5,7 @@ import pytest from scripts.ops import local_upload_check +from scripts.perf import local_upload_capacity def test_upload_limits_must_all_be_positive_finite_and_concurrency_integral(): @@ -31,7 +32,38 @@ def test_upload_limits_must_all_be_positive_finite_and_concurrency_integral(): assert local_upload_check.valid_upload_limits({"storage": {}}) == (False, "missing") -def test_ffprobe_subprocess_receives_sample_path_and_json_contract(tmp_path): +def test_sample_limit_check_compares_real_file_size_and_duration(): + config = {"storage": {"upload_limits": { + "max_file_mib": 1, + "max_media_hours": 1, + "receive_concurrency": 1, + "upload_temp_budget_mib": 2, + }}} + assert local_upload_check.sample_limit_status( + {"bytes": 1024, "duration_seconds": 3600}, config + ) == "within_declared_limits" + assert local_upload_check.sample_limit_status( + {"bytes": 1024 * 1024 + 1, "duration_seconds": 1}, config + ) == "exceeds_file_limit" + assert local_upload_check.sample_limit_status( + {"bytes": 1, "duration_seconds": 3600.1}, config + ) == "exceeds_duration_limit" + assert local_upload_check.sample_limit_status( + {"bytes": 1, "duration_seconds": 1}, {"storage": {}} + ) == "unknown_limits" + + +def test_compatibility_uses_real_commit_ancestry_not_sha_text(): + repo = Path(__file__).resolve().parents[2] + assert local_upload_check.compatibility_status( + repo, "6af391d20edab8dfd8b320ce6b91d4d19e3260e2" + ) == "safe-lineage" + assert local_upload_check.compatibility_status( + repo, "38b299468daaf309608f46ffe1c59886b0922c04" + ) == "incompatible" + + +def test_ffprobe_subprocess_receives_sample_path_and_json_contract(tmp_path, monkeypatch): capture = tmp_path / "argv.json" output = tmp_path / "probe.json" sample = tmp_path / "synthetic.wav" @@ -45,13 +77,9 @@ def test_ffprobe_subprocess_receives_sample_path_and_json_contract(tmp_path): "print(open(os.environ['FFPROBE_OUTPUT']).read())\n" ) fake_ffprobe.chmod(0o755) - monkeypatch = pytest.MonkeyPatch() monkeypatch.setenv("FFPROBE_CAPTURE", str(capture)) monkeypatch.setenv("FFPROBE_OUTPUT", str(output)) - try: - inspected = local_upload_check.inspect_sample(sample, str(fake_ffprobe)) - finally: - monkeypatch.undo() + inspected = local_upload_check.inspect_sample(sample, str(fake_ffprobe)) argv = json.loads(capture.read_text()) assert argv == [ @@ -61,6 +89,30 @@ def test_ffprobe_subprocess_receives_sample_path_and_json_contract(tmp_path): assert inspected == {"bytes": len(b"synthetic sample bytes"), "duration_seconds": 1.25} +def test_capacity_fixture_uses_only_sandbox_paths_and_loopback_peers(tmp_path): + config_path, users_path = local_upload_capacity._write_synthetic_config( + tmp_path, api_port=18080, asr_port=18081 + ) + config = json.loads(config_path.read_text()) + sandbox_data = tmp_path / "data" + + assert Path(config["storage"]["cache_dir"]).is_relative_to(sandbox_data) + assert Path(config["storage"]["workspace_dir"]).is_relative_to(sandbox_data) + assert Path(config["storage"]["temp_dir"]).is_relative_to(sandbox_data) + assert config["api"]["host"] == "127.0.0.1" + assert config["capswriter"]["server_url"] == "ws://127.0.0.1:18081" + assert config["security"]["download_url_allowlist"] == ["127.0.0.1/32"] + assert config["storage"]["upload_limits"]["receive_concurrency"] == 2 + assert "VTA_UPLOADS_ENABLED" not in config + assert "sandbox-token-never-use-outside-this-run" in users_path.read_text() + assert not (tmp_path / "data").exists() + + +def test_capacity_experiment_rejects_unbounded_sample_duration(): + with pytest.raises(local_upload_capacity.ExperimentFailure, match="1_and_30"): + local_upload_capacity.run_experiment(31) + + def test_preflight_logs_upload_enable_blocked_without_reading_dotenv(tmp_path, monkeypatch, capsys): secret = "do-not-print-this-dotenv-secret" env_file = tmp_path / ".env" From d33808abad7ebbc21981da659e556810c8e7216b Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 10:24:02 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=81=A2=E5=A4=8D=E4=B8=8E=E5=AE=B9=E9=87=8F?= =?UTF-8?q?=E6=B2=99=E7=AE=B1=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-013605-110782 Task-Id: VideoTranscriptAPI-20261009-11 --- .../261008-local-upload/progress/F-progress.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sessions/261008-local-upload/progress/F-progress.md b/docs/sessions/261008-local-upload/progress/F-progress.md index fb72db76..6d461587 100644 --- a/docs/sessions/261008-local-upload/progress/F-progress.md +++ b/docs/sessions/261008-local-upload/progress/F-progress.md @@ -5,11 +5,11 @@ - **当前阶段**:implementing;独立只读门槛单元已提交 `03828d3231003fc6ef3f91e52d772950e8b4f796`、推送并由 draft PR #208 承接。 - **本段结论**:`uv run --frozen pytest -q tests/unit/test_upload_ops.py` 4 passed;`scripts/ops/local_upload_check.py` 只读取明确传入的消费配置/数据目录与当前进程开关,不加载 dotenv、不打印密钥、不启用/部署/恢复;即使四限额、开关和代码祖先检查满足,也会以 `UPLOAD_ENABLE_BLOCKED` 阻止生产启用,因为容量、独立恢复域和实际入口尚未知。 - **关键决策与否决方案**:消费配置解析沿用真实 `load_and_validate_config`;env 文件仅从 tracked compose 声明识别来源,绝不 source;兼容检查以 Git commit ancestry 与已知 A guard-only 完整 SHA 为边界,不用版本字符串比较;本地检查结果不作为 enable 权限或伪造容量凭据。首轮测试因预期把四个 `null` 错判为 `missing` 有 AssertionError 红,修正断言后全绿;生产代码未变异。 -- **下一步唯一动作**:完成恢复与容量工具的单元和本地沙箱验证,提交第二里程碑并在 clean tree 重跑容量探针。 +- **下一步唯一动作**:运行本仓全量 `make test` 并记录最终提交/PR 状态。 ## 2026-10-09 · 恢复消费者与容量探针 -- **当前阶段**:implementing;真实 SQLite+正文 snapshot/restore 回归、A guard-only 真实源码消费者测试、受控容量探针和运维文档已落盘,当前窄测 11 passed,尚未提交。 -- **本段结论**:fresh Resolver 子进程接收恢复前 DB 且外部 gate 明确 false,实际拒绝读;current cleaner 保留有效 never 正文/root。独立 subprocess 从真实 A guard-only commit `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 运行 resolver/cleaner,active share 可读、upload/blank legacy alias 不可读、cache/task 两个 cleaner 不删有效长成果;pre-A commit 由 ancestry 负例拒绝。容量脚本实际成功完成一次 raw HTTP/两接收者/loopback URL 下载与 ASR rejector 实验,但当时 source tree dirty,HEAD 值不足以作最终版本证据,需 clean commit 后复跑。 -- **关键决策与否决方案**:恢复 fixture 只用 pytest `tmp_path`;还原撤销前快照会让 SQLite 内 `revoked_at` 回到 NULL,所以唯一安全结论是部署域 gate 关闭且没有独立撤销来源时不得再开;脚本仅对 loopback 服务/下载/ASR fixture 工作,失败终态是预期而不代表 ASR 容量。移除 config example 中消费者 validator 不接受的 `funasr_spk_server.send_terms` 示例键;消费者默认值仍为 false,不修改运行时消费者。 -- **下一步唯一动作**:提交本里程碑,然后 clean-tree 重跑 `scripts/perf/local_upload_capacity.py` 并保存真实观测。 +- **当前阶段**:implementing;真实 SQLite+正文 snapshot/restore 回归、A guard-only 真实源码消费者测试、受控容量探针和运维文档已提交 `7c6e7cf083dce3a59b42189a64fe085394c36101` 并推送 draft PR #208;窄测 11 passed。 +- **本段结论**:fresh Resolver 子进程接收恢复前 DB 且外部 gate 明确 false,实际拒绝读;current cleaner 保留有效 never 正文/root。独立 subprocess 从真实 A guard-only commit `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 运行 resolver/cleaner,active share 可读、upload/blank legacy alias 不可读、cache/task 两个 cleaner 不删有效长成果;pre-A commit 由 ancestry 负例拒绝。clean-tree 容量探针完整 SHA=`7c6e7cf083dce3a59b42189a64fe085394c36101`:20s PCM s16le mono/48kHz 样本 1,920,044 bytes;2/2 raw HTTP receipts,服务端接收摘要一致;两客户端、URL下载与上传时间重叠确认,临时目录采样峰值 3,840,088 bytes、data 树峰值 4,572,561 bytes,10ms 采样。机器 x86_64/20 CPU/Python 3.11.15,ffmpeg/ffprobe 6.1.1。ASR loopback rejector 收到 4 次连接,三个 worker 均 failed 是预期;真实 ASR、模型最大时长、生产 ingress/代理、实际容量与恢复域仍 unknown。真实 preflight 用 config example、temp users/data、精确 true 进程 env 跑出 `UPLOAD_ENABLE_BLOCKED` 与 pre-A 不兼容结论,退出码 2;dotenv 未加载。 +- **关键决策与否决方案**:恢复 fixture 只用 pytest `tmp_path`;还原撤销前 DB 会令 SQLite 内 `revoked_at` 回到 NULL,所以无独立撤销来源就不能再开;脚本仅触达 loopback 服务/下载/ASR fixture,worker 失败不作为容量凭据。移除 config example 中消费者 validator 不接受的 `funasr_spk_server.send_terms` 示例键;消费者默认值仍为 false,不修改运行时消费者。 +- **下一步唯一动作**:运行本仓全量 `make test` 并记录最终提交/PR 状态。 From de3ff7b6671418ae7746649ef2167d5778c790ef Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 10:37:15 +0800 Subject: [PATCH 06/12] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=85=A8=E9=87=8F=E5=9B=9E=E5=BD=92=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-013605-110782 Task-Id: VideoTranscriptAPI-20261009-11 --- .../261008-local-upload/progress/F-progress.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/sessions/261008-local-upload/progress/F-progress.md b/docs/sessions/261008-local-upload/progress/F-progress.md index 6d461587..8d18e6b9 100644 --- a/docs/sessions/261008-local-upload/progress/F-progress.md +++ b/docs/sessions/261008-local-upload/progress/F-progress.md @@ -10,6 +10,13 @@ ## 2026-10-09 · 恢复消费者与容量探针 - **当前阶段**:implementing;真实 SQLite+正文 snapshot/restore 回归、A guard-only 真实源码消费者测试、受控容量探针和运维文档已提交 `7c6e7cf083dce3a59b42189a64fe085394c36101` 并推送 draft PR #208;窄测 11 passed。 -- **本段结论**:fresh Resolver 子进程接收恢复前 DB 且外部 gate 明确 false,实际拒绝读;current cleaner 保留有效 never 正文/root。独立 subprocess 从真实 A guard-only commit `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 运行 resolver/cleaner,active share 可读、upload/blank legacy alias 不可读、cache/task 两个 cleaner 不删有效长成果;pre-A commit 由 ancestry 负例拒绝。clean-tree 容量探针完整 SHA=`7c6e7cf083dce3a59b42189a64fe085394c36101`:20s PCM s16le mono/48kHz 样本 1,920,044 bytes;2/2 raw HTTP receipts,服务端接收摘要一致;两客户端、URL下载与上传时间重叠确认,临时目录采样峰值 3,840,088 bytes、data 树峰值 4,572,561 bytes,10ms 采样。机器 x86_64/20 CPU/Python 3.11.15,ffmpeg/ffprobe 6.1.1。ASR loopback rejector 收到 4 次连接,三个 worker 均 failed 是预期;真实 ASR、模型最大时长、生产 ingress/代理、实际容量与恢复域仍 unknown。真实 preflight 用 config example、temp users/data、精确 true 进程 env 跑出 `UPLOAD_ENABLE_BLOCKED` 与 pre-A 不兼容结论,退出码 2;dotenv 未加载。 +- **本段结论**:fresh Resolver 子进程接收恢复前 DB 且外部 gate 明确 false,实际拒绝读;current cleaner 保留有效 never 正文/root。独立 subprocess 从真实 A guard-only commit `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 运行 resolver/cleaner,active share 可读、upload/blank legacy alias 不可读、cache/task 两个 cleaner 不删有效长成果;pre-A commit 由 ancestry 负例拒绝。clean-tree 容量探针完整 SHA=`d33808abad7ebbc21981da659e556810c8e7216b`:20s PCM s16le mono/48kHz 样本 1,920,044 bytes;2/2 raw HTTP receipts,服务端接收摘要一致;两客户端、URL下载与上传时间重叠确认,临时目录采样峰值 3,840,088 bytes、data 树峰值 4,572,561 bytes,10ms 采样。机器 x86_64/20 CPU/Python 3.11.15,ffmpeg/ffprobe 6.1.1。ASR loopback rejector 收到 4 次连接,三个 worker 均 failed 是预期;真实 ASR、模型最大时长、生产 ingress/代理、实际容量与恢复域仍 unknown。真实 preflight 用 config example、temp users/data、精确 true 进程 env 跑出 `UPLOAD_ENABLE_BLOCKED` 与 pre-A 不兼容结论,退出码 2;dotenv 未加载。 - **关键决策与否决方案**:恢复 fixture 只用 pytest `tmp_path`;还原撤销前 DB 会令 SQLite 内 `revoked_at` 回到 NULL,所以无独立撤销来源就不能再开;脚本仅触达 loopback 服务/下载/ASR fixture,worker 失败不作为容量凭据。移除 config example 中消费者 validator 不接受的 `funasr_spk_server.send_terms` 示例键;消费者默认值仍为 false,不修改运行时消费者。 -- **下一步唯一动作**:运行本仓全量 `make test` 并记录最终提交/PR 状态。 +- **下一步唯一动作**:完成全量回归和部署状态核验,随后交 lead 接 hosted gate / 独立审查。 + +## 2026-10-09 · 全量回归与本地边界确认 + +- **当前阶段**:implementing;实现代码在 `d33808abad7ebbc21981da659e556810c8e7216b` 已完成本地全量验证;生产启用仍 blocked,PR #208 保持 draft。 +- **本段结论**:两次 `make test` 均 exit 0,`PYTEST_ADDOPTS=-rs make test` 明确为 3,820 passed、3 skipped、0 failed(仅 `test_capswriter_samples_total_contract.py:61` 因真实 preflight fixture 不可用);新增窄测 11 passed。clean-tree容量探针与真实 preflight均在最终实现 SHA `d33808...` 上执行;容量记录为 synthetic observation,preflight在 gate=true但限额/恢复/ingress未知时退出2并打印 `UPLOAD_ENABLE_BLOCKED`。测试生成的 data/log/cache、`.pytest_cache`、175 MiB本地 `.venv` 与 Python caches 已按本树初始 absent 状态采样后清理,最终工作树 clean。 +- **关键决策与否决方案**:继承红因派发时 `gh api` 基线不可用,标记“未能判定”;本卡开发期红均为修正后的断言/fixture/脚本契约错误,最终全量无新红。未执行真实 systemd、生产服务/ingress/磁盘/ASR/备份恢复;未知项不改写为 passed,不改 GOALS/M6生产检查,不启用开关。 +- **下一步唯一动作**:把 draft PR #208 与验证/blocked条件交 Pi lead,等待其正式 CI、独立审查与后续授权。 From d193b4c56848169e5f3a0fedb2365d4c41b66640 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 11:47:35 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=A5=96=E5=85=88=E6=9F=A5=E8=AF=A2=E6=9C=AA?= =?UTF-8?q?=E7=9F=A5=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-033345-cf92e1 Task-Id: VideoTranscriptAPI-20261009-11 --- .../progress/F-progress.md | 7 +++ scripts/ops/local_upload_check.py | 6 ++- tests/unit/test_upload_ops.py | 45 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/sessions/261008-local-upload/progress/F-progress.md b/docs/sessions/261008-local-upload/progress/F-progress.md index 8d18e6b9..5296061c 100644 --- a/docs/sessions/261008-local-upload/progress/F-progress.md +++ b/docs/sessions/261008-local-upload/progress/F-progress.md @@ -20,3 +20,10 @@ - **本段结论**:两次 `make test` 均 exit 0,`PYTEST_ADDOPTS=-rs make test` 明确为 3,820 passed、3 skipped、0 failed(仅 `test_capswriter_samples_total_contract.py:61` 因真实 preflight fixture 不可用);新增窄测 11 passed。clean-tree容量探针与真实 preflight均在最终实现 SHA `d33808...` 上执行;容量记录为 synthetic observation,preflight在 gate=true但限额/恢复/ingress未知时退出2并打印 `UPLOAD_ENABLE_BLOCKED`。测试生成的 data/log/cache、`.pytest_cache`、175 MiB本地 `.venv` 与 Python caches 已按本树初始 absent 状态采样后清理,最终工作树 clean。 - **关键决策与否决方案**:继承红因派发时 `gh api` 基线不可用,标记“未能判定”;本卡开发期红均为修正后的断言/fixture/脚本契约错误,最终全量无新红。未执行真实 systemd、生产服务/ingress/磁盘/ASR/备份恢复;未知项不改写为 passed,不改 GOALS/M6生产检查,不启用开关。 - **下一步唯一动作**:把 draft PR #208 与验证/blocked条件交 Pi lead,等待其正式 CI、独立审查与后续授权。 + +## 2026-10-09 · F-R1 ancestry 错误态修复 + +- **当前阶段**:repairing;仅运维 CLI 的 Git ancestry 非零映射、实际 Git unit 回归及本进度记录有改动,未提交。 +- **本段结论**:新测试先在 H0 `de3ff7b6671418ae7746649ef2167d5778c790ef` 官方 scratch 以真实 AssertionError 红:临时仓 HEAD 由 `git rev-parse HEAD` 取得,minimum SHA 对象缺失,raw `git merge-base --is-ancestor` 实返 128,旧 helper 错报 incompatible。H1 修复后 narrow 12 passed;scratch 中 `make test` 与 `PYTEST_ADDOPTS=-rs make test` 均 exit 0,后者 3821 passed/3 skipped/0 failed,skip 为真实 CapsWriter preflight fixture unavailable。 +- **关键决策与否决方案**:严格映射 Git 返回码 `0→safe-lineage`、`1→incompatible`、其它查询错误→已有 `unknown`;保留未解析 source→unknown,既有真实 A safe-lineage 与 pre-A incompatible 对照不变。不增状态、catch、fallback、重试或包装层;测试用真实一次提交仓和真实 producer argv,不mock subprocess。 +- **下一步唯一动作**:提交并推送这三处修复到原 PR #208,核对远端/clean 后交 lead。 diff --git a/scripts/ops/local_upload_check.py b/scripts/ops/local_upload_check.py index a785e0fd..f516df5d 100755 --- a/scripts/ops/local_upload_check.py +++ b/scripts/ops/local_upload_check.py @@ -124,7 +124,11 @@ def compatibility_status(repo: Path, source_sha: str | None) -> str: text=True, timeout=10, ) - return "safe-lineage" if ancestor.returncode == 0 else "incompatible" + if ancestor.returncode == 0: + return "safe-lineage" + if ancestor.returncode == 1: + return "incompatible" + return "unknown" def deployment_env_source(compose_path: Path) -> str: diff --git a/tests/unit/test_upload_ops.py b/tests/unit/test_upload_ops.py index 753bb4b0..b1ec582c 100644 --- a/tests/unit/test_upload_ops.py +++ b/tests/unit/test_upload_ops.py @@ -2,6 +2,7 @@ import json from pathlib import Path +import subprocess import pytest from scripts.ops import local_upload_check @@ -63,6 +64,50 @@ def test_compatibility_uses_real_commit_ancestry_not_sha_text(): ) == "incompatible" +def test_missing_minimum_commit_query_is_unknown_from_real_git(tmp_path, monkeypatch): + repo = tmp_path / "source-repository" + repo.mkdir() + isolated_gitconfig = tmp_path / "gitconfig" + isolated_gitconfig.write_text("") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(isolated_gitconfig)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + minimum_sha = local_upload_check.MINIMUM_SAFE_SHA + + subprocess.run(["git", "init", "--quiet", str(repo)], check=True, capture_output=True, text=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Upload Ops Fixture"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "fixture@example.invalid"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "commit.gpgsign", "false"], check=True) + (repo / "producer.txt").write_bytes(b"real source commit fixture\\n") + subprocess.run(["git", "-C", str(repo), "add", "--", "producer.txt"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "--quiet", "-m", "fixture source"], check=True) + + producer = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + source_sha = producer.stdout.strip() + assert len(source_sha) == 40 + int(source_sha, 16) + + absent_minimum = subprocess.run( + ["git", "-C", str(repo), "cat-file", "-e", f"{minimum_sha}^{{commit}}"], + check=False, + capture_output=True, + text=True, + ) + assert absent_minimum.returncode == 128 + actual_query = subprocess.run( + ["git", "-C", str(repo), "merge-base", "--is-ancestor", minimum_sha, source_sha], + check=False, + capture_output=True, + text=True, + ) + assert actual_query.returncode == 128 + assert local_upload_check.compatibility_status(repo, source_sha) == "unknown" + + def test_ffprobe_subprocess_receives_sample_path_and_json_contract(tmp_path, monkeypatch): capture = tmp_path / "argv.json" output = tmp_path / "probe.json" From 13e78042ac4ff97ae0fd42fa15031848259f985d Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 11:56:22 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E8=AE=B0=E5=BD=95F-R1=20scratch=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E4=B8=8E=E5=81=8F=E5=B7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-033345-cf92e1 Task-Id: VideoTranscriptAPI-20261009-11 --- docs/sessions/261008-local-upload/progress/F-progress.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/sessions/261008-local-upload/progress/F-progress.md b/docs/sessions/261008-local-upload/progress/F-progress.md index 5296061c..1293bb34 100644 --- a/docs/sessions/261008-local-upload/progress/F-progress.md +++ b/docs/sessions/261008-local-upload/progress/F-progress.md @@ -27,3 +27,10 @@ - **本段结论**:新测试先在 H0 `de3ff7b6671418ae7746649ef2167d5778c790ef` 官方 scratch 以真实 AssertionError 红:临时仓 HEAD 由 `git rev-parse HEAD` 取得,minimum SHA 对象缺失,raw `git merge-base --is-ancestor` 实返 128,旧 helper 错报 incompatible。H1 修复后 narrow 12 passed;scratch 中 `make test` 与 `PYTEST_ADDOPTS=-rs make test` 均 exit 0,后者 3821 passed/3 skipped/0 failed,skip 为真实 CapsWriter preflight fixture unavailable。 - **关键决策与否决方案**:严格映射 Git 返回码 `0→safe-lineage`、`1→incompatible`、其它查询错误→已有 `unknown`;保留未解析 source→unknown,既有真实 A safe-lineage 与 pre-A incompatible 对照不变。不增状态、catch、fallback、重试或包装层;测试用真实一次提交仓和真实 producer argv,不mock subprocess。 - **下一步唯一动作**:提交并推送这三处修复到原 PR #208,核对远端/clean 后交 lead。 + +## 2026-10-09 · F-R1 验收与交接 + +- **当前阶段**:修复已提交并推送原分支,commit `d193b4c56848169e5f3a0fedb2365d4c41b66640`;PR #208 仍 OPEN/DRAFT、remote head 匹配。 +- **本段结论**:H0 AssertionError 红、H1 窄测 12 passed、全量 exit 0(3821 passed/3 skipped/0 failed)均在新 scratch;唯一 skip 为 `tests/unit/test_capswriter_samples_total_contract.py:61` 缺真实 preflight fixture。隔离 Git producer SHA `0b21ae0e9cc5b5c89a9aa7ebd768e0dd733d7b87`,raw query argv 使用 minimum `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 返回 128 `fatal: Not a valid commit name`,H1 consumer 返回 `unknown`。 +- **关键决策与否决方案**:实现目录曾被一次未显式 `cd` 的独立 Python/Git fixture 诊断命令误用;仅创建/删除 `/tmp` Git fixture,之后同一 query 已在新 H1 scratch 重复。实现树中 mtime早于本续派的 `.venv`/`data`/`.pytest_cache` 已只采样元数据、未读内容且因来源未证保留;所有 pytest/Narrow/make 均只在 scratch执行。修复未读写生产/他树,PR保持draft。 +- **下一步唯一动作**:交 Pi lead 做正式 hosted CI 与独立 review,维持 PR draft、不ready/merge。 From ddf45f6d5db3720dc09fbd56732217022276c1a0 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 14:16:44 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E9=97=A8=E6=A7=9B=E6=AD=A3=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-061455-08de64 Task-Id: VideoTranscriptAPI-20261009-15 --- tests/integration/test_upload_restore.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration/test_upload_restore.py b/tests/integration/test_upload_restore.py index b0b6ae6f..ecf3e938 100644 --- a/tests/integration/test_upload_restore.py +++ b/tests/integration/test_upload_restore.py @@ -138,6 +138,21 @@ def test_snapshot_restore_keeps_external_gate_off_and_protects_long_result(tmp_p assert "BODY=NONE" in current.stdout assert "UPLOAD_DISABLED" in current.stdout + # This dangerous positive control applies only to the pytest restore copy: + # its old snapshot still has revoked_at=NULL, not a production reopen permit. + enabled = _resolver_process(restored_cache, upload["view_token"], enabled=True) + assert enabled.returncode == 0, enabled.stderr + assert "RESULT=success" in enabled.stdout + assert f"BODY={expected_transcript_bytes.decode('utf-8')}" in enabled.stdout + + invalid_token = "invalid-restored-view-token" + assert invalid_token != upload["view_token"] + invalid = _resolver_process(restored_cache, invalid_token, enabled=True) + assert invalid.returncode == 0, invalid.stderr + assert "RESULT=NONE" in invalid.stdout + assert "BODY=NONE" in invalid.stdout + assert "UPLOAD_DISABLED" not in invalid.stdout + # Exercise both actual current cleaners against stale-age but effective # never-expiring result and its required root resolver row. assert restored.cleanup_old_cache(days=1) == 0 From df05e2e2001593baa79d5d111deb6bbfc5b7345d Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 14:29:13 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=AD=A3=E6=96=87=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-061455-08de64 Task-Id: VideoTranscriptAPI-20261009-15 --- tests/integration/test_upload_restore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_upload_restore.py b/tests/integration/test_upload_restore.py index ecf3e938..30c7fd54 100644 --- a/tests/integration/test_upload_restore.py +++ b/tests/integration/test_upload_restore.py @@ -143,7 +143,7 @@ def test_snapshot_restore_keeps_external_gate_off_and_protects_long_result(tmp_p enabled = _resolver_process(restored_cache, upload["view_token"], enabled=True) assert enabled.returncode == 0, enabled.stderr assert "RESULT=success" in enabled.stdout - assert f"BODY={expected_transcript_bytes.decode('utf-8')}" in enabled.stdout + assert f"BODY={expected_transcript_bytes.decode('utf-8')}" in enabled.stdout.splitlines() invalid_token = "invalid-restored-view-token" assert invalid_token != upload["view_token"] From 07bbd72192e7cce71f0498145d4fefb2c86a533d Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 14:39:04 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=97=A0=E6=95=88?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=BB=A4=E7=89=8C=E6=B5=8B=E8=AF=95=E5=A4=B9?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-063745-77abc3 Task-Id: VideoTranscriptAPI-20261009-15 --- tests/integration/test_upload_restore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_upload_restore.py b/tests/integration/test_upload_restore.py index 30c7fd54..8888cb72 100644 --- a/tests/integration/test_upload_restore.py +++ b/tests/integration/test_upload_restore.py @@ -145,7 +145,7 @@ def test_snapshot_restore_keeps_external_gate_off_and_protects_long_result(tmp_p assert "RESULT=success" in enabled.stdout assert f"BODY={expected_transcript_bytes.decode('utf-8')}" in enabled.stdout.splitlines() - invalid_token = "invalid-restored-view-token" + invalid_token = "upload_invalid-restored-view-token" assert invalid_token != upload["view_token"] invalid = _resolver_process(restored_cache, invalid_token, enabled=True) assert invalid.returncode == 0, invalid.stderr From 358a9698b0292b41ebc20fabfda278af1ef65fce Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Fri, 9 Oct 2026 15:26:00 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=87=AA=E8=BF=B0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261009-072403-537a94 Task-Id: VideoTranscriptAPI-20261009-17 --- .../progress/F-progress.md | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 docs/sessions/261008-local-upload/progress/F-progress.md diff --git a/docs/sessions/261008-local-upload/progress/F-progress.md b/docs/sessions/261008-local-upload/progress/F-progress.md deleted file mode 100644 index 1293bb34..00000000 --- a/docs/sessions/261008-local-upload/progress/F-progress.md +++ /dev/null @@ -1,36 +0,0 @@ -# F 进度 - -## 2026-10-09 · 首个运维门槛单元 - -- **当前阶段**:implementing;独立只读门槛单元已提交 `03828d3231003fc6ef3f91e52d772950e8b4f796`、推送并由 draft PR #208 承接。 -- **本段结论**:`uv run --frozen pytest -q tests/unit/test_upload_ops.py` 4 passed;`scripts/ops/local_upload_check.py` 只读取明确传入的消费配置/数据目录与当前进程开关,不加载 dotenv、不打印密钥、不启用/部署/恢复;即使四限额、开关和代码祖先检查满足,也会以 `UPLOAD_ENABLE_BLOCKED` 阻止生产启用,因为容量、独立恢复域和实际入口尚未知。 -- **关键决策与否决方案**:消费配置解析沿用真实 `load_and_validate_config`;env 文件仅从 tracked compose 声明识别来源,绝不 source;兼容检查以 Git commit ancestry 与已知 A guard-only 完整 SHA 为边界,不用版本字符串比较;本地检查结果不作为 enable 权限或伪造容量凭据。首轮测试因预期把四个 `null` 错判为 `missing` 有 AssertionError 红,修正断言后全绿;生产代码未变异。 -- **下一步唯一动作**:运行本仓全量 `make test` 并记录最终提交/PR 状态。 - -## 2026-10-09 · 恢复消费者与容量探针 - -- **当前阶段**:implementing;真实 SQLite+正文 snapshot/restore 回归、A guard-only 真实源码消费者测试、受控容量探针和运维文档已提交 `7c6e7cf083dce3a59b42189a64fe085394c36101` 并推送 draft PR #208;窄测 11 passed。 -- **本段结论**:fresh Resolver 子进程接收恢复前 DB 且外部 gate 明确 false,实际拒绝读;current cleaner 保留有效 never 正文/root。独立 subprocess 从真实 A guard-only commit `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 运行 resolver/cleaner,active share 可读、upload/blank legacy alias 不可读、cache/task 两个 cleaner 不删有效长成果;pre-A commit 由 ancestry 负例拒绝。clean-tree 容量探针完整 SHA=`d33808abad7ebbc21981da659e556810c8e7216b`:20s PCM s16le mono/48kHz 样本 1,920,044 bytes;2/2 raw HTTP receipts,服务端接收摘要一致;两客户端、URL下载与上传时间重叠确认,临时目录采样峰值 3,840,088 bytes、data 树峰值 4,572,561 bytes,10ms 采样。机器 x86_64/20 CPU/Python 3.11.15,ffmpeg/ffprobe 6.1.1。ASR loopback rejector 收到 4 次连接,三个 worker 均 failed 是预期;真实 ASR、模型最大时长、生产 ingress/代理、实际容量与恢复域仍 unknown。真实 preflight 用 config example、temp users/data、精确 true 进程 env 跑出 `UPLOAD_ENABLE_BLOCKED` 与 pre-A 不兼容结论,退出码 2;dotenv 未加载。 -- **关键决策与否决方案**:恢复 fixture 只用 pytest `tmp_path`;还原撤销前 DB 会令 SQLite 内 `revoked_at` 回到 NULL,所以无独立撤销来源就不能再开;脚本仅触达 loopback 服务/下载/ASR fixture,worker 失败不作为容量凭据。移除 config example 中消费者 validator 不接受的 `funasr_spk_server.send_terms` 示例键;消费者默认值仍为 false,不修改运行时消费者。 -- **下一步唯一动作**:完成全量回归和部署状态核验,随后交 lead 接 hosted gate / 独立审查。 - -## 2026-10-09 · 全量回归与本地边界确认 - -- **当前阶段**:implementing;实现代码在 `d33808abad7ebbc21981da659e556810c8e7216b` 已完成本地全量验证;生产启用仍 blocked,PR #208 保持 draft。 -- **本段结论**:两次 `make test` 均 exit 0,`PYTEST_ADDOPTS=-rs make test` 明确为 3,820 passed、3 skipped、0 failed(仅 `test_capswriter_samples_total_contract.py:61` 因真实 preflight fixture 不可用);新增窄测 11 passed。clean-tree容量探针与真实 preflight均在最终实现 SHA `d33808...` 上执行;容量记录为 synthetic observation,preflight在 gate=true但限额/恢复/ingress未知时退出2并打印 `UPLOAD_ENABLE_BLOCKED`。测试生成的 data/log/cache、`.pytest_cache`、175 MiB本地 `.venv` 与 Python caches 已按本树初始 absent 状态采样后清理,最终工作树 clean。 -- **关键决策与否决方案**:继承红因派发时 `gh api` 基线不可用,标记“未能判定”;本卡开发期红均为修正后的断言/fixture/脚本契约错误,最终全量无新红。未执行真实 systemd、生产服务/ingress/磁盘/ASR/备份恢复;未知项不改写为 passed,不改 GOALS/M6生产检查,不启用开关。 -- **下一步唯一动作**:把 draft PR #208 与验证/blocked条件交 Pi lead,等待其正式 CI、独立审查与后续授权。 - -## 2026-10-09 · F-R1 ancestry 错误态修复 - -- **当前阶段**:repairing;仅运维 CLI 的 Git ancestry 非零映射、实际 Git unit 回归及本进度记录有改动,未提交。 -- **本段结论**:新测试先在 H0 `de3ff7b6671418ae7746649ef2167d5778c790ef` 官方 scratch 以真实 AssertionError 红:临时仓 HEAD 由 `git rev-parse HEAD` 取得,minimum SHA 对象缺失,raw `git merge-base --is-ancestor` 实返 128,旧 helper 错报 incompatible。H1 修复后 narrow 12 passed;scratch 中 `make test` 与 `PYTEST_ADDOPTS=-rs make test` 均 exit 0,后者 3821 passed/3 skipped/0 failed,skip 为真实 CapsWriter preflight fixture unavailable。 -- **关键决策与否决方案**:严格映射 Git 返回码 `0→safe-lineage`、`1→incompatible`、其它查询错误→已有 `unknown`;保留未解析 source→unknown,既有真实 A safe-lineage 与 pre-A incompatible 对照不变。不增状态、catch、fallback、重试或包装层;测试用真实一次提交仓和真实 producer argv,不mock subprocess。 -- **下一步唯一动作**:提交并推送这三处修复到原 PR #208,核对远端/clean 后交 lead。 - -## 2026-10-09 · F-R1 验收与交接 - -- **当前阶段**:修复已提交并推送原分支,commit `d193b4c56848169e5f3a0fedb2365d4c41b66640`;PR #208 仍 OPEN/DRAFT、remote head 匹配。 -- **本段结论**:H0 AssertionError 红、H1 窄测 12 passed、全量 exit 0(3821 passed/3 skipped/0 failed)均在新 scratch;唯一 skip 为 `tests/unit/test_capswriter_samples_total_contract.py:61` 缺真实 preflight fixture。隔离 Git producer SHA `0b21ae0e9cc5b5c89a9aa7ebd768e0dd733d7b87`,raw query argv 使用 minimum `6af391d20edab8dfd8b320ce6b91d4d19e3260e2` 返回 128 `fatal: Not a valid commit name`,H1 consumer 返回 `unknown`。 -- **关键决策与否决方案**:实现目录曾被一次未显式 `cd` 的独立 Python/Git fixture 诊断命令误用;仅创建/删除 `/tmp` Git fixture,之后同一 query 已在新 H1 scratch 重复。实现树中 mtime早于本续派的 `.venv`/`data`/`.pytest_cache` 已只采样元数据、未读内容且因来源未证保留;所有 pytest/Narrow/make 均只在 scratch执行。修复未读写生产/他树,PR保持draft。 -- **下一步唯一动作**:交 Pi lead 做正式 hosted CI 与独立 review,维持 PR draft、不ready/merge。