diff --git a/docs/sessions/261008-local-upload/reviews/A-r1-verdict.md b/docs/sessions/261008-local-upload/reviews/A-r1-verdict.md new file mode 100644 index 00000000..df13e8e9 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/A-r1-verdict.md @@ -0,0 +1,23 @@ + +## 结论 + +failure-visibility: skipped + +人工独立审查已完成;本次固定 diff 未发现可复现的事实性 finding。OCR 前置扫描为 `skipped`,不能把空 findings 当作 clean;因此机器行保留 `skipped`。无人工确认的 P1/P2 缺陷,代码层面不构成独立阻断;OCR 跳过本身按审查纪律记录,不伪报通过。 + +## 不变式与消费者证据 + +- 身份/幂等:`cache_manager.py:346-363,640-692` 建窄表并以 owner/key 唯一、metadata 指纹拒绝冲突;`test_local_upload_policy.py:77,146` 用真实 SQLite 覆盖跨 owner、重放、冲突及并发唯一。 +- token 隔离:接收根写旧 `view_token=''`、独立 `upload_` token(`cache_manager.py:694-738`);Resolver 的 view 与 cache 两个公共入口均按前缀只查上传记录(`view_token_resolver.py:161-195,263-297`),旧查询拒绝 blank/`upload_`(`cache_manager.py:3383-3411`),LLM 配置与 owner fallback 也拒绝上传 token(`view_token_resolver.py:128-130,302-307`; `audit.py:660-665`)。测试:`test_local_upload_policy.py:175,259,312`、原 URL Resolver 测试。 +- 资格/时间:资格读取由 `local_upload_share_is_active` 检查状态、撤销和 expiry(`cache_manager.py:779-793`);唯一终态入口 `update_task_status` 用 SQLite `CURRENT_TIMESTAMP` 写 `completed_at`,同事务以该值设 30d expiry,never 不设截止(`cache_manager.py:2734-2780`)。恢复/关闭收敛也调用该入口(`:2958-3125`)。测试:`test_local_upload_policy.py:221,240,304`。所有持久字符串比较以 UTC 秒级格式;部署/systemd 实际时钟未探测,不从造时测试推断部署结论。 +- 读取与清理:`cleanup_old_cache` 在媒体锁内复查活动上传,且不看功能开关(`cache_manager.py:2101-2116`);`cleanup_task_status` 用 keyset 前进,并在 `BEGIN IMMEDIATE` 内复查活动 root 后才归档、撤销快照、删 task(`:2284-2337,2345-2369`)。实际维护入口将 `task_exists` 传给审计清理(`api/app.py:253-257`);`AuditLogger.cleanup_old_logs` 保留仍有 task root 的旧快照(`audit_logger.py:835-861`)。测试:`test_local_upload_policy.py:352,366,388,424,485,508`。 +- 实际消费者核对:独立临时 cache.db/audit.db 实测活动 never 上传正文可由 Resolver 读到;人为回置旧时间后,cache/task 清理均删 0 行,审计快照保留。撤销后 Resolver 拒绝,cache/task 各清 1 行;root 删除后快照在自身审计期限内继续保留,回置其归档时间超过期限才由真实 `task_exists` 清理删除。所有文件和数据库均在 `/tmp`。 +- URL 与媒体兼容:旧 token 生成 `view_`(`cache_manager.py:2520`),Resolver 对非 `upload_` 仍走原 task 查询;原 URL/history/清理套件包含在 H0 `make test` 中。临时媒体仍由 `TempFileManager` 清理,测试 `test_local_upload_policy.py:508`。 +- 文档/目标:设计、进度和六个 goal 仅记录 A 现状及后续门槛;M1 仍为实现中,后续未开始;没有改写 `GOALS.md`,没有把生产/恢复标成已验证。 + +## 验证、限制与阻断 + +- 本地冻结依赖(仅 `/tmp/vta-upload-A-r1-venv`):`python -m pytest -q tests/unit/test_local_upload_policy.py tests/unit/test_view_token_resolver.py tests/cache/test_task_status_cleanup.py` 全部通过。 +- H0 CI:run `37765345309`,`gate / quality` job `113271519803` 与 `Tests` 步骤成功;`make test`。PR #201 仍为 draft,`gate / primary` 为 skipped,不视为通过。派发主干基线不可用,继承红/新红无法判定。 +- OCR:wrapper `skipped`,reason=`primary=primary_marker_active(primary_failed_unclassified); backup:deepseek=leg_timeout`。诊断为 MiniMax 429 rate-limited,DeepSeek 180s timeout;直调也未产生 findings。stdout JSON 在仓外 `/tmp/vta-upload-A-r1-ocr-stdout.json`;不是 clean。 +- 未访问生产、systemd、备份恢复域或真实容量;这些不是 A 的本地 SQLite 证据,也未获本卡授权。独立 review 本身完成;没有发现需 lead 阻断本次代码的已证实问题。 diff --git a/docs/sessions/261008-local-upload/reviews/A-runtime-probes.md b/docs/sessions/261008-local-upload/reviews/A-runtime-probes.md new file mode 100644 index 00000000..6b1d691a --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/A-runtime-probes.md @@ -0,0 +1,149 @@ + +# PR201 三项运行探针 + +固定 head:`3a25dccc2cb92e22b4031715c37a584c041e582d`。只改本证据文件;所有 SQLite、缓存目录和 audit.db 均在 `TemporaryDirectory`,未读写 repo 配置。先以 mkdtemp 写入确认权限,再 `uv sync --frozen`(仅本 worktree `.venv`),`PYTHONPATH=src` 导入真实 `CacheManager`。无 worker/HTTP 端点;不外推到生产。 + +## 判定 + +1. **preterminal cache deletion:默认完整清理路径已证伪。** register/accept + `save_cache` 真写入正文;`local_upload_share_is_active=True`,而 `_has_active_local_upload_for_media=False`(`expires_at=NULL` 的 SQL 条件缺失)。但 `cleanup_old_cache` 先命中 queued/processing/calibrating task guard,三份文件与行均保留。撤销且 failed 上传、URL 对照由完整入口删除(2 行),不是 0 候选。仅用临时 SQL 把 `updated_at` 设为 31 天前以越过年龄筛选,不代表自然发生。覆盖 `update_task_status` 文档列出的全部非终态。后续:lead 决定是否统一两个 predicate 的语义;本次未证明实际清理丢失。 +2. **task cleanup FK crash:默认路径已证伪;FK ON 条件反例复现。** 默认 `_get_connection()` 实测 `foreign_keys=0`,真实 `cleanup_task_status` 无异常并删 root;receipt 保留、root_task_id 成孤儿(1)。同流程显式 `PRAGMA foreign_keys=ON` 后实际清理抛 `IntegrityError`,事务回滚,root/receipt 保留。`rg -n "PRAGMA foreign_keys" src` 无命中;CacheManager、AuditLogger 与只读 SQL 构造均未开启它。条件反例不代表默认路径或 P1。后续:lead 决定是否约束外部启用 FK 的契约,以及默认路径孤儿 receipt 是否符合历史查询需要。 +3. **accept after 24h:默认路径可复现。** 窗内 register,跨窗 24h+1s 调真实 accept 得 `ValueError`;DB 仍是 `receiving`、无 root。过窗重复 register 仍读出原 receipt。契约写明 server 验证 24h acceptance window、正式接受后才返回 202(design.md:35,42);按此字面拒绝符合约定。design 同时写“首次登记”窗口,若意为只校验 register,则与 accept 二次校验有歧义,需 lead 定义时点;本证据不改逻辑。 + +## 运行输出 + +探针1(旧年龄为受控条件;producer 文件及完整 consumer 路径): + +```text +queued: share=True sql_guard=False root=queued expiry=none file_before=True file_after=True +processing: share=True sql_guard=False root=processing expiry=none file_before=True file_after=True +calibrating: share=True sql_guard=False root=calibrating expiry=none file_before=True file_after=True +inactive: share=False sql_guard=False root=failed expiry=set file_before=True file_after=False +url: file_before=True file_after=False +cleanup_deleted=2 cache_rows=5->3 +``` + +探针2(临时 AuditLogger/DB;终态 root;now 控制为完成后 61 天以满足过期条件): + +```text +foreign_keys=0 outcome=ok deleted=1 root=1->0 upload=1->1 orphan=1 +foreign_keys=1 outcome=IntegrityError deleted=na root=1->1 upload=1->1 orphan=0 +``` + +探针3: + +```text +accept_exception=ValueError receipt_state=receiving root_task_id=null root_rows=0 +``` + +## 可复制探针 + +从仓库根目录运行;仅输出状态、计数、异常类型与文件存在性,不打印路径。 + +### 1. cache 清理 + +```bash +PYTHONPATH=src .venv/bin/python - <<'PY' +from datetime import datetime, timedelta, timezone; from pathlib import Path; import tempfile, uuid +from loguru import logger; from video_transcript_api.cache.cache_manager import CacheManager +logger.remove(); now = datetime.now(timezone.utc) +old = (now - timedelta(days=31)).strftime('%Y-%m-%d %H:%M:%S') +with tempfile.TemporaryDirectory() as td: + base = Path(td); m = CacheManager(str(base/'cache'), str(base/'cache/cache.db')); cases = {} + def make_upload(label, status=None, revoked=False): + mid = 'probe-' + label; key = f"{int(now.timestamp()*1000)}-{uuid.uuid4()}" + u = m.register_local_upload(owner_user_id='owner', idempotency_key=key, retention='30d', now=now) + a = m.accept_local_upload(u['upload_id'], media_id=mid, task_id='task-'+label, now=now) + if status in ('processing','calibrating'): m.update_task_status(a['root_task_id'], status, suppress_terminal_notification=True) + saved = m.save_cache('local_upload', '', mid, False, 'producer-body', 'capswriter') + if not saved: raise RuntimeError('save_cache failed') + if status == 'failed': m.update_task_status(a['root_task_id'], status, suppress_terminal_notification=True) + if revoked: m.revoke_local_upload(u['upload_id'], now=now) + with m._get_cursor() as c: c.execute('UPDATE video_cache SET updated_at=? WHERE media_id=?', (old, mid)) + with m._get_cursor() as c: + c.execute('SELECT u.*, t.status root_status FROM local_uploads u JOIN task_status t ON t.task_id=u.root_task_id WHERE u.media_id=?', (mid,)) + row = dict(c.fetchone()); c.execute('SELECT 1 FROM video_cache WHERE media_id=?', (mid,)); before = c.fetchone() is not None + with m._get_cursor() as c: sql_active = m._has_active_local_upload_for_media(c, 'local_upload', mid, m._utc_sql_timestamp(now)) + cases[label] = (row, CacheManager.local_upload_share_is_active(row, now), sql_active, base/'cache'/saved['files_loc'], before) + for s in ('queued','processing','calibrating'): make_upload(s, s) + make_upload('inactive', 'failed', True) + saved = m.save_cache('youtube', 'https://example.invalid/v', 'url-control', False, 'url-body', 'capswriter') + if not saved: raise RuntimeError('URL save_cache failed') + with m._get_cursor() as c: c.execute('UPDATE video_cache SET updated_at=? WHERE media_id=?', (old, 'url-control')) + url_path = base/'cache'/saved['files_loc'] + with m._get_cursor() as c: before_rows = c.execute('SELECT COUNT(*) FROM video_cache').fetchone()[0] + deleted = m.cleanup_old_cache(days=30, now=now) + with m._get_cursor() as c: after_rows = c.execute('SELECT COUNT(*) FROM video_cache').fetchone()[0] + for label, (row, active, sql_active, path, before) in cases.items(): + print(f"{label}: share={active} sql_guard={sql_active} root={row['root_status']} expiry={'none' if row['expires_at'] is None else 'set'} file_before={before} file_after={path.exists()}") + print(f"url: file_before=True file_after={url_path.exists()}"); print(f"cleanup_deleted={deleted} cache_rows={before_rows}->{after_rows}") + m._get_connection().close() +PY +``` + +### 2. task cleanup / FK + +```bash +PYTHONPATH=src .venv/bin/python - <<'PY' +from datetime import datetime, timedelta, timezone; from pathlib import Path; import tempfile, uuid +from loguru import logger; from video_transcript_api.cache.cache_manager import CacheManager +from video_transcript_api.utils.logging.audit_logger import AuditLogger +logger.remove() +def run(force_fk): + with tempfile.TemporaryDirectory() as td: + base = Path(td); m = CacheManager(str(base/'cache'), str(base/'cache/cache.db')) + audit = AuditLogger(str(base/'audit.db')); m.audit_logger = audit; conn = m._get_connection() + if force_fk: conn.execute('PRAGMA foreign_keys=ON') + fk = conn.execute('PRAGMA foreign_keys').fetchone()[0] + now = datetime.now(timezone.utc); late = now + timedelta(days=61); key = f"{int(now.timestamp()*1000)}-{uuid.uuid4()}" + u = m.register_local_upload(owner_user_id='owner', idempotency_key=key, retention='30d', now=now) + a = m.accept_local_upload(u['upload_id'], media_id='fk-probe', task_id='task-fk', now=now) + m.update_task_status(a['root_task_id'], 'failed', suppress_terminal_notification=True) + with m._get_cursor() as c: + c.execute('SELECT COUNT(*) FROM task_status WHERE task_id=?', (a['root_task_id'],)); rb = c.fetchone()[0] + c.execute('SELECT COUNT(*) FROM local_uploads WHERE upload_id=?', (u['upload_id'],)); ub = c.fetchone()[0] + try: deleted = str(m.cleanup_task_status(0, now=late)); outcome = 'ok' + except Exception as e: deleted = 'na'; outcome = type(e).__name__ + with m._get_cursor() as c: + c.execute('SELECT COUNT(*) FROM task_status WHERE task_id=?', (a['root_task_id'],)); ra = c.fetchone()[0] + c.execute('SELECT COUNT(*) FROM local_uploads WHERE upload_id=?', (u['upload_id'],)); ua = c.fetchone()[0] + c.execute('SELECT COUNT(*) FROM local_uploads u LEFT JOIN task_status t ON t.task_id=u.root_task_id WHERE u.upload_id=? AND t.task_id IS NULL', (u['upload_id'],)); orphan = c.fetchone()[0] + print(f"foreign_keys={fk} outcome={outcome} deleted={deleted} root={rb}->{ra} upload={ub}->{ua} orphan={orphan}") + conn.close(); audit.close() +run(False); run(True) +PY +``` + +### 3. 窗口过期后正式 accept + +```bash +PYTHONPATH=src .venv/bin/python - <<'PY' +from datetime import datetime, timedelta, timezone; from pathlib import Path; import tempfile, uuid +from loguru import logger; from video_transcript_api.cache.cache_manager import CacheManager +logger.remove() +with tempfile.TemporaryDirectory() as td: + base = Path(td); m = CacheManager(str(base/'cache'), str(base/'cache/cache.db')) + start = datetime.now(timezone.utc); late = start + timedelta(hours=24, seconds=1) + key = f"{int(start.timestamp()*1000)}-{uuid.uuid4()}" + u = m.register_local_upload(owner_user_id='owner', idempotency_key=key, retention='30d', now=start) + try: m.accept_local_upload(u['upload_id'], media_id='late-probe', task_id='task-late', now=late); exc = 'none' + except Exception as e: exc = type(e).__name__ + receipt = m.register_local_upload(owner_user_id='owner', idempotency_key=key, retention='30d', now=late) + with m._get_cursor() as c: c.execute('SELECT COUNT(*) FROM task_status WHERE task_id=?', ('task-late',)); roots = c.fetchone()[0] + print(f"accept_exception={exc} receipt_state={receipt['state']} root_task_id={'null' if receipt['root_task_id'] is None else 'set'} root_rows={roots}") + m._get_connection().close() +PY +``` + +## 约束与验收 + +固定 H0:`docs/sessions/261008-local-upload/design.md`。相关锁定测试名:`test_cleanup_protects_active_upload_cache_even_when_feature_is_disabled`、`test_cleanup_reclaims_revoked_or_expired_upload_cache`、`test_task_cleanup_keeps_effective_share_root_for_audit_and_url_rows_still_expire`、`test_expired_or_revoked_upload_task_rows_follow_existing_cleanup`、`test_real_sqlite_upload_identity_owner_key_and_intent_window`。只执行运行探针及卡面指定文件存在性判据,未跑 pytest/CI。 + +主干基线派发时不可用(`gh api request failed`),继承红未能判定;本卡没有新 CI 红。未改应用/测试/workflow。 + +## pickup 巡检 + +open issue 总数:3 +summary: orphan 0 owned 0 unattributable 0 too-new 0 recent-7d 0 stale-over-7d 0 missing_ledger_repos 0 +memory 巡检报告不可用:memory_dir_mismatch;未复用其他会话报告。 + +接手锚点:无交接单。当前分支 `card/vta-upload-A-probes-261008`,派发简报中 dispatch 即本任务本身;未将其视作他人占用。 diff --git a/docs/sessions/261008-local-upload/reviews/B-r1-verdict.md b/docs/sessions/261008-local-upload/reviews/B-r1-verdict.md new file mode 100644 index 00000000..458fab90 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/B-r1-verdict.md @@ -0,0 +1,31 @@ +failure-visibility: p2-only +# B 卡独立审查 verdict:固定 H0 + +审查对象:`6af391d20edab8dfd8b320ce6b91d4d19e3260e2..840facda8e0cb251a1197cf3647c5accd5c496ff`,risk-tier `personal`。只读该固定差分;未改产品或测试代码。 + +结论:发现 2 项 P2。**阻断本轮交付**,原因是 F1 违反批准 R3 的受理/队列交接不变式;P2 是个人使用风险定级,不代表契约合格。F2 记为 P2、不单独阻断。没有 P1。 + +## Findings + +### F1 — SQLite accepted 提交先于队列 handoff(P2;阻断:是) + +- 违反批准 R3:root、accepted 状态与一次 `queue.put_nowait` 必须处于同个无 await 的数据库临界步骤,只有该步骤成功提交才正式受理;不能依赖事后撤回补偿。源码:[uploads.py](../../../../src/video_transcript_api/api/routes/uploads.py#L321) 调用会自行提交的 `accept_local_upload`,返回后才在 [uploads.py](../../../../src/video_transcript_api/api/routes/uploads.py#L339) 入队;[cache_manager.py](../../../../src/video_transcript_api/cache/cache_manager.py#L783) 在 SQL 事务内创建 root/accepted,但不执行队列 handoff。QueueFull 另走 [uploads.py](../../../../src/video_transcript_api/api/routes/uploads.py#L342) → [cache_manager.py](../../../../src/video_transcript_api/cache/cache_manager.py#L868) 的事后撤回。 +- 真实默认 HTTP 路径探针:`PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=tests/integration:src uv run --frozen pytest -q -p no:cacheprovider --tb=short /tmp/vta_upload_queue_order_probe.py`。探针由真实 ASGI 请求发送 raw producer bytes,经真实 SQLite/cache 和 runtime 队列;在实际 `put_nowait` 调用前由独立 SQLite 连接读行。预期「仍未提交」的断言故意为负对照并失败:实际读到 `state='accepted'`、root `status='queued'`,payload 为 `platform='local_upload'`、`url='https://display.example/recording?id=42'`。证明 accepted 已对外持久可见后才开始队列 handoff。 +- 若进程在提交与入队间退出,receipt 已是 accepted,但内存队列没有任务;相同 key 只回原 receipt,不重入队。另有失败路径确证:`tests/integration/test_upload_intake.py::test_queue_put_failure_rolls_back_formal_receipt` 只验证 QueueFull 后的补偿;`test_sql_commit_failure_rolls_back_root_and_never_queues` 只覆盖入队前 SQL 失败,均未锁住 R3 顺序。 +- 外部工具严重度:OCR `skipped`、无级别;本仓判定:P2。P1 两问:真实代码路径每次请求均先提交再调用队列,已由本地真实 HTTP/SQLite 探针测得;但实际生产进程是否在这段窄窗口退出未测。后果是单任务可能被恢复为失败且同 key 不重提,朋友可检查后用新 key 重传;频率和用户接受度未在生产验证,故不升 P1。 +- 最小建议:把一次 `put_nowait` 移入现有 accepted/root SQL 临界步骤,成功提交后才返回 202;队列满由事务回滚自然留在未受理阶段,删除事后撤回路径。保留 dispatcher 的持久状态校验,用于丢弃「队列已写、SQL 提交失败」的孤儿项。 + +### F2 — receiving 清理在状态复核前删除源文件(P2;阻断:否) + +- 违反接收文件所有权与清理交接约束:仅可清未移交文件。`cleanup_expired_local_upload_receiving` 在 [cache_manager.py](../../../../src/video_transcript_api/cache/cache_manager.py#L737) 读出候选后,先于 `BEGIN IMMEDIATE` 和状态复核在 [cache_manager.py](../../../../src/video_transcript_api/cache/cache_manager.py#L749) 删除文件,之后才在 [cache_manager.py](../../../../src/video_transcript_api/cache/cache_manager.py#L762) 检查它仍为 receiving。 +- 临时 SQLite/文件并发探针用 barrier 固定交叉顺序:清理先选中已到期 receiving 行;accept 使用截止前时间成功提交;清理继续删文件并复核到 accepted。负对照要求源文件仍存在,命令以预期断言失败:`PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src uv run --frozen pytest -q -p no:cacheprovider --tb=short /tmp/vta_upload_cleanup_accept_probe.py`。实测结果:清理返回 0(行保留),accepted 映射仍可查,但源文件不存在。默认 dispatcher 随后会因路径缺失而在 ASR 前将任务置 failed。 +- 外部工具严重度:OCR `skipped`、无级别;本仓判定:P2。P1 两问:定时清理和接收可以并行,截止边界路径已由本地 SQLite/文件探针复现;真实部署的触发频率未测。后果是单个 accepted 上传文件丢失,用户可用新 key 重传;个人规模下不单独阻断。 +- 最小建议:先在 `BEGIN IMMEDIATE` 中重读并原子退休仍为过期 receiving 的行,再只删除该退休行拥有的路径;已变为 accepted 的行不得授权清理器删文件。现有顺序测试 [test_upload_routes.py](../../../../tests/unit/test_upload_routes.py#L82) 未覆盖该交叉。 + +## 核验状态 + +- `uv run --frozen pytest -q tests/unit/test_upload_routes.py tests/unit/test_upload_dispatch.py tests/integration/test_upload_intake.py`:exit 0。通过不能推翻上述缺失的跨提交窗口断言。 +- 本地 `env -i` 执行 `test_disabled_and_missing_limits_reject_before_body`:exit 0;示例四项额度仍为 `null`。worker readiness 由真实 `process_task_queue` 协程 fixture 覆盖。未测 CI、生产 systemd/ASR 容量或生产配置。 +- 实际 `ffprobe` 探针:1.0 秒 WAV 检出 1 条音轨;ffmpeg 生成的无音轨 MP4 拒绝为 `no_audio_track`;非媒体文件拒绝为 `media_probe_failed`。无外部 ASR/LLM/通知调用。测试中的媒体消费者替代 ASR;生产容量、压缩媒体真实尺寸及中间文件上界保持 unknown。 +- HTTP integration fixture 断言服务端落盘 bytes/SHA、SQLite root/media 行、真实队列/dispatcher 消费和临时文件移交;local media 单测确认 `create_downloader` 不得被调用。生产源路径与容量未部署验证。 +- OCR 命令及 envelope、输入隔离偏差、源码/13 文件映射、基线和已知坏态证据见执行器报告。 diff --git a/docs/sessions/261008-local-upload/reviews/B-r2-verdict.md b/docs/sessions/261008-local-upload/reviews/B-r2-verdict.md new file mode 100644 index 00000000..2f092a83 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/B-r2-verdict.md @@ -0,0 +1,63 @@ +# B-r2 独立复审结论 + +failure-visibility: p2-only + +## 结论 + +- H0..H1 四问通过;R3 入队顺序和 receiving 清理时文件所有权的增量修复符合登记范围,没有新增无依据抽象、状态或双路径。 +- 全量 `6af391d..H1` 复审发现 2 项 P2 和 1 项 P3。工具没有给出 finding 严重度;OCR 两腿均超时,状态为 `skipped`,不能记作 clean。 +- 三项 finding 都已存在于 H0;不归因于本轮 H0..H1 修复增量。 +- **B 功能交付阻断:是,待处理 P2-1。** 上传协议接受 `processing_options`,而真实消费者忽略它,导致用户选择的处理流程不生效。该结论是功能契约阻断,不是 P1 定级。P2-2 与 P3-3 可独立记入后续修复,不改变此阻断理由。 +- 风险档为 personal。三个问题均未满足 P1 两问:实际链路可触发,但当前默认关闭且生产使用未验证;后果是额外处理/错误回执或错误诊断,没有已证实的数据丢失、静默错误结果或崩溃。 + +## 审查基线 + +- 增量:`840facda8e0cb251a1197cf3647c5accd5c496ff..37907b9872ed9f59721afb53760965d32c204227`(7 files,+246/-44)。 +- 全量:`6af391d20edab8dfd8b320ce6b91d4d19e3260e2..37907b9872ed9f59721afb53760965d32c204227`(13 files,+1948/-33)。 +- 全量审查覆盖实现、测试、示例配置及设计/任务文档;未读取进度报告或既有 review 正文。 + +## 增量四问 + +1. **仅修登记的 R3 与文件所有权合同:通过。** 事务中的一次同步 `put_nowait`、stale dispatcher gate、cleanup 先退休再 unlink,以及 accept/cleanup 两种真实顺序的测试,与批准 R3 一致。 +2. **未经批准抽象:无。** 新增的可选 `enqueue` callback 只有 HTTP producer 使用;它让实际 queue put 落在 `BEGIN IMMEDIATE` 的同一无 await 临界步骤,避免 CacheManager 导入队列。属于此交接边界的必要调用形态。 +3. **新增状态/事实源/fallback:无。** 没有增加持久状态、重试或事后补偿;仅有临时文件退休路径。原 queue-full compensation 已删除。 +4. **新旧双路径:无。** HTTP route 删除 commit 后入队路径,改为唯一 callback 路径。 + +## Findings + +### P2-1:上传请求的 processing_options 未到达消费者(阻断 B 功能交付) + +- **违反契约:** `design.md` 的 metadata 合同(`processing_options` 沿用现有处理选项,约第 36 行)。 +- **代码:** `uploads.py:321-334` 将规范化 options 存入 acceptance;`cache_manager.py:818,831-858` 写入任务记录,但队列 payload 只含 `id/url/platform`;`transcription.py:576-578,616-618` 按队列字段缺失回退到全部默认开启,虽读了 request metadata 却只取 title。 +- **真实触发与结果:** H1 临时 HTTP→SQLite→真实 asyncio queue→`process_task_queue` 探针请求四项均为 false;队列实际 payload 不含 options,消费者传给 `process_transcription` 的四项均为 true。`test_http_queue_handoff_precedes_durable_acceptance_commit`(`test_upload_intake.py:464-498`)锁定的 producer payload 也只检查这三个字段,未断言 options。 +- **工具严重度 / 本仓判定:** 无外部工具标注;P2。P1 问一:合法请求在开关启用且用户关闭某选项时可触发,生产开关当前默认关闭、真实生产使用未验证。P1 问二:额外校对/总结/章节/说话人处理可能增加资源消耗,但未见数据丢失、隐私越权或不可接受后果证据,故不升 P1。 +- **最小修正:** 让实际 producer payload 带上已规范化 options,或让 dispatcher 从持久的 root/upload 记录读取;增加 producer 到 `process_transcription` 的断言。因这是公开请求字段的功能语义,lead 接受 B 前应处理或明确改写契约。 + +### P2-2:正文开始前的 setup 异常留下无错误的 receiving 回执 + +- **违反契约:** R3/receipt 合同要求失败不伪装成仍在接收;同 key 只返回原回执,不能假装已受理。 +- **代码:** `uploads.py:257-262,284-293,346-349`。`body_started` 只在 `create_task_dir`、写 receiving path、queue 检查之后设为 true;finally 仅在其为 true 时写错误码。 +- **真实触发与结果:** 临时 HTTP 探针令真实 `temp_manager.create_task_dir` 抛 `OSError`:POST 返回 500,SQLite receipt 保持 `state=receiving,error_code=NULL`;同 key 重放返回 200 且读取 0 个 body chunk。预留预算和 inflight 名额已释放,但该 key 在过期清理前不能重传。 +- **工具严重度 / 本仓判定:** 无外部工具标注;P2。P1 问一:真实文件系统故障路径已在请求 handler 复现,是否在生产发生未知;当前开关默认关闭。P1 问二:可用新 key 重新提交且无已证实数据丢失,主要后果为回执误显接收中及旧 key 暂不可复用,故不升 P1。 +- **最小修正:** 在已知的正文前 setup 失败点写明确错误码后继续原异常传播;不要用宽泛 catch、重试或补偿状态。 + +### P3-3:已读完正文的 SQLite commit 故障被标为 receive_incomplete + +- **契约关联:** R3 要求失败的正式接受不伪装成成功;此处 HTTP 500 已显式失败,但 receipt 的 `error_code` 是诊断字段。文档没有枚举错误码词表,因此按 P3 可观测性意见记录,不把它当功能契约阻断。 +- **代码:** `uploads.py:321-339,346-349`。commit 异常未设 `failure_code`,finally 将它统一写成 `receive_incomplete`。 +- **真实触发与结果:** 隔离 SQLite 故障探针已读取一个完整 body chunk,注入 acceptance commit 异常后响应 500、stale queue item 被 dispatcher 丢弃;receipt 为 `receiving`,`error_code=receive_incomplete`。现有 `test_sql_commit_failure_leaves_stale_queue_item_for_dispatcher_to_drop` 验证了 commit 回滚与 stale item 拒绝,但未断言该错误码。 +- **工具严重度 / 本仓判定:** 无外部工具标注;P3,非阻断。P1 两问:故障路径已在隔离环境复现,实际生产发生未知;响应和 receipt 显示失败且未正式受理,错误仅在诊断阶段名称,后果可接受。 +- **最小修正:** 对 acceptance commit 失败使用准确阶段码,并保留当前回滚、stale item 丢弃行为。 + +## 验证证据与限制 + +- H0 红对照:在隔离 scratch worktree 的 H0 上临时复制 H1 producer 测试 `test_http_queue_handoff_precedes_durable_acceptance_commit`,以预期 `AssertionError` 观察到 queue put 时独立 SQLite 视图已是 `accepted/root queued`;H1 同测试观察到 `receiving/root absent`。 +- H1 四项真实 producer/cleanup 顺序测试通过:`test_http_queue_handoff_precedes_durable_acceptance_commit`、`test_sql_commit_failure_leaves_stale_queue_item_for_dispatcher_to_drop`、`test_receiving_cleanup_rechecks_after_accept_wins_and_preserves_owned_file`、`test_receiving_cleanup_retires_before_unlink_and_accept_loses`。实际队列 payload、独立 SQLite 行、dispatcher `executor.submit` 和媒体文件 bytes 均有断言。 +- 窄测通过:在已装依赖的共享 venv 中运行 `uv run --frozen --no-sync pytest -q tests/unit/test_upload_routes.py tests/unit/test_upload_dispatch.py tests/integration/test_upload_intake.py`(35 passed);`--no-sync` 避免改动本树依赖环境。 +- A/URL/runtime/maintenance 回归通过:`uv run --frozen --no-sync pytest -q tests/unit/test_local_upload_policy.py tests/unit/test_view_token_resolver.py tests/unit/test_api_routes.py tests/unit/test_history_routes.py tests/unit/test_runtime_lifecycle.py tests/cache/test_task_status_cleanup.py tests/cache/test_cache_cleanup.py`;输出全绿,无失败。 +- 缺省关闭消费环境负对照通过:`env -i PATH=../../VideoTranscriptAPI/.venv/bin:/usr/bin:/bin PYTHONPATH=src python -m pytest -q tests/integration/test_upload_intake.py::test_disabled_and_missing_limits_reject_before_body`,1 passed。 +- 合成媒体本地预检通过:真实 ffmpeg 生成 1 秒音频和无音轨视频,`_ensure_audio_track` 读出 1.000 秒并以 `no_audio_track` 拒绝视频;未调用外部 ASR。 +- H1 实际 worker hold 探针确认 source bytes 与 `2x` reservation 在处理期间保留,receiver slot 在接收后释放;worker 完成后 reservation/inflight 清零。 +- OCR wrapper:`OCR_LOCAL_SINGLE_LEG_TIMEOUT_S=120 OCR_LOCAL_CHAIN_BUDGET_S=300 timeout 360s ocr-review --repo "$(git rev-parse --show-toplevel)" --from 840facda8e0cb251a1197cf3647c5accd5c496ff --to 37907b9872ed9f59721afb53760965d32c204227 --audience agent --concurrency 4 --background-file /tmp/vta-upload-B-r2-ocr-bg.md`;envelope=`skipped`,原因 `primary=leg_timeout; backup:deepseek=leg_timeout`,两腿各 120 秒。该项不代表 clean。 +- Verdict 机读抽取输出 `p2-only`;`git diff --cached --check` 无输出(通过);verdict 63 行。 +- 未验证真实生产配置、生产/systemd 环境、真实 ASR/LLM/通知、部署恢复域和容量;示例四项额度为 null,不能据临时测试额度或合成文件推生产容量。 diff --git a/docs/sessions/261008-local-upload/reviews/B-r3-verdict.md b/docs/sessions/261008-local-upload/reviews/B-r3-verdict.md new file mode 100644 index 00000000..e7fe5175 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/B-r3-verdict.md @@ -0,0 +1,48 @@ +# B 卡 R3 增量审查与完整复验 + +failure-visibility: p2-only + +## 固定对象与结论 + +- 增量:`37907b9872ed9f59721afb53760965d32c204227..6a497263e67789b4ce8fef39d407e6e8f77faff3`(3 文件,115 增、0 删)。 +- 全量:`6af391d20edab8dfd8b320ce6b91d4d19e3260e2..6a497263e67789b4ce8fef39d407e6e8f77faff3`(13 文件,2063 增、33 删)。 +- 增量四问:通过。新增唯一生产行把规范化 `processing_options` 加入已有 `accept_local_upload` 队列 payload;未增加抽象、状态/事实源、fallback 或第二处理路径。队列 callback 对应 SQL/queue 无共同事务的发布边界,事务内 `put_nowait` 和 stale-item gate 有现存消费者及边界测试。 +- 全量结论:1 项 P2,非阻断;未发现 P1。默认关闭、上传配额仍未知,不据此推断生产启用或漏洞。 + +## 增量四问及选项透传 + +| 问题 | 结论与证据 | +|---|---| +| 只修本轮登记的 R3/所有权合同? | 是。`cache_manager.py:853` 从同一 `options_json` 生成队列字段;SQL/queue 顺序与临时文件所有权逻辑未变。 | +| 是否新增未经批准抽象? | 否。仅现有方法的 payload 增加一字段。 | +| 是否新增状态、事实源或 fallback? | 否。读取路径仍由现存 request metadata、root SQL 行、queue payload 顺序传递;未新增状态或降级。 | +| 是否留下新旧双路径? | 否。HTTP 上传仍经同一 queue/dispatcher/consumer 路径。 | + +`test_processing_options_cross_real_upload_boundaries` 三种 producer 输入(全 false、混合、默认)实际通过 FastAPI raw body、SQLite metadata/root 行、捕获的真实 `put_nowait` payload 和 dispatcher 捕获的 consumer kwargs,逐层与期望值比较。默认规范由既有 URL 契约测试 `tests/unit/test_processing_options.py::test_none_normalizes_to_all_true_including_chapters` 锁定;不是只测 normalize helper。commit-fault 用例还断言实际 queue payload 带选项,stale item 不进 executor/ASR。 + +## 全量不变式检查 + +- 认证、Content-Type、metadata 和声明大小检查均在 body 读取前;上传只 `request.stream()`,保存实际字节、SHA-256 和服务器生成路径;basename 仅展示,source URL 限 http(s)/无 userinfo,不走 URL downloader。 +- 同 owner/key 重放不读 body、不重复入队;metadata 变化 409;不同 key 创建独立任务。Raw HTTP/SQLite/queue/consumer 由 `tests/integration/test_upload_intake.py::test_real_http_stream_sqlite_dispatch_idempotency_and_owner_stop` 覆盖。 +- R3:root/accepted SQL 写入与一次 `put_nowait` 在同一无 await 临界步骤;queue-full 回滚;commit 失败后的真实 stale payload 被 dispatcher 持久 admission gate 拒绝。测试同时检查 queue 操作时 SQLite 的 receiving/root-none 行及 ASR/executor 未调用。 +- 文件所有权:接收失败由接收端清理;正式接受后由 worker 清理。cleanup/accept 两个真实 SQLite 与文件顺序均有测试,分别锁定接受获胜保留源字节、cleanup 获胜后拒绝接受再 unlink。 +- 容量:四项限制要求正有限值;真实磁盘 free space 加安全余量;实际 body bytes 与声明/上限对照;receiver/inflight/budget 保留与释放有边界测试。额度数值和生产容量仍未知。 +- 音轨/时长:临时 WAV 经真实 HTTP 上传、真实 ffprobe、真实 dispatcher 到 `process_transcription`;2 秒输入在测试限额 0.0001 小时下以 `media_duration_limit` 失败,Transcriber 调用数为 0,worker 清掉源文件。ASR/LLM/通知外部服务未调用。 +- 默认关闭:本执行环境 `VTA_UPLOADS_ENABLED` 长度为 0;真实 TestClient 请求在 body 前返回 503,能力为 disabled。既有 `test_local_upload_policy.py::test_resolver_uses_real_subprocess_environment_and_sqlite` 用真实子进程与 SQLite 验证缺失开关拒读/显式开关读取。Hosted CI、实际 systemd unit、生产容量及外部 ASR/LLM readiness 本轮未测,保持未知。 +- 全量审阅包含 config、设计/里程碑契约、app/context/routes/transcription/cache 与新增单元/集成测试。按输入隔离要求未读取 `docs/sessions/261008-local-upload/progress/B-progress.md`、任何实施报告或旧 review 报告/正文。 + +## Finding:P2(非阻断)— 错误标记写入异常会跳过接收资源清理 + +- 违反合同:失败必须显式失败,同时仅接收方清理未移交文件,并释放接收并发、inflight 与预算;失败标记写库失败不应让临时资源永久占用。 +- 位置:`src/video_transcript_api/api/routes/uploads.py:346` 开始的 `finally`;`uploads.py:348` 先调用 `set_local_upload_error`,文件清理在 `uploads.py:352`,预算/inflight/接收槽释放分别在 `uploads.py:356`、`uploads.py:358`、`uploads.py:360`。 +- 触发与实测:真实 TestClient + 临时 SQLite/cache/temp,上传实际字节少于 metadata `byte_size`,进入 `declared_size_mismatch`;将 `set_local_upload_error` 故障注入为 `sqlite3.OperationalError`。结果 HTTP 500、body_reads=1、receiving row `error_code=null`、inflight_count=1、reserved_bytes_count=1、未清上传文件数=1。错误向上抛出,但它发生在清理之前,导致后续释放语句未执行。测试使用故障注入,不声称发生过生产 SQLite 故障。 +- 工具严重度:OCR 未报该项;无外部工具定级。本仓 P1 两问:真实上传消费链已在本地 TestClient 运行,持久 SQLite 写故障频率/生产环境未量,默认开关关闭;若触发,会耗尽接收槽/inflight/预算直到进程重启。按 personal 单机小规模和异常故障条件定 P2,不定 P1。 +- 最小建议:让现有文件/槽/inflight/预算清理在错误标记写库抛错时仍执行,同时保留原 SQLite 异常与 HTTP 500;无需 retry/fallback 或新状态。 +- 交付判定:不阻断本轮 review 交付;这是失败路径资源回收缺口,建议后续修复。本卡不改应用代码。 + +## OCR 与窄验证 + +- OCR:`reviewed`,`coverage=complete`,`findings=[]`,`cli_status=complete`;`verify_status=skipped` 因无 finding。前置运行 `OCR_LOCAL_SINGLE_LEG_TIMEOUT_S=120`、`OCR_LOCAL_CHAIN_BUDGET_S=300`,外层 timeout 390 秒;主腿耗时约 103 秒。OCR 只扫本轮增量,不替代完整人工复验。 +- `uv run --frozen pytest -q tests/unit/test_upload_routes.py tests/unit/test_upload_dispatch.py tests/integration/test_upload_intake.py`:exit 0,全绿点阵;输出 CPython 3.11.15、安装 71 packages、本地 outbound network guard。warning 仅 lark/pydantic deprecation。 +- 已知坏态对照:窄测里的 `test_sql_commit_failure_leaves_stale_queue_item_for_dispatcher_to_drop` 注入 SQL commit failure,确认一条真实 queue item 被丢弃、executor/worker 未调用、资源清理;上文 P2 故障注入则把“失败标记写坏”喂给接收 finally,实际断言到资源泄漏。 +- 生产 ASR、LLM、通知、CI/systemd 和容量未验证;没有触碰真实 config/data/.env、部署或服务。 diff --git a/docs/sessions/261008-local-upload/reviews/U-r1-verdict.md b/docs/sessions/261008-local-upload/reviews/U-r1-verdict.md new file mode 100644 index 00000000..f1f145d7 --- /dev/null +++ b/docs/sessions/261008-local-upload/reviews/U-r1-verdict.md @@ -0,0 +1,32 @@ +# U 浏览器测试与 CI 增量审查 verdict + +审查范围:`2e6f71a7f2ab06e7ed48d9270bbcc38aaa00b570..43f4db771dd850062ccedc2d05ccf394b25ca9d3`(H0,PR #200),14 个文件,688 additions / 2 deletions。仅审此差分。 + +## 裁决 + +**不阻断交付。** H0 的真实 Chromium 回归和 Vitest 步骤均已在 PR CI 执行并成功。未发现 P1 或会阻断合并的问题;有一项 P2 结构性意见,影响很小,可接受不修。 + +failure-visibility: clean + +## 规格核对 + +| 规格 | 判定与证据 | +|---|---| +| 真浏览器、保留 Vitest、阻断全 spec | 满足。根 `test:web` 原命令仍是 `vitest run`;`test:browser` 调用独立 Playwright Chromium。`playwright.config.ts` 使用 `testMatch: **/*.spec.ts`、`forbidOnly`、`retries: 0`、`workers: 1`、`fullyParallel: false`。H0 CI 实际运行 Vitest 13 files / 170 tests 与 Chromium 9 tests,均通过。 | +| 真实页面及用户操作 | 满足。`tests/browser_server.py` 读取生产首页 HTML 常量、静态页面文件和真实 Jinja 模板;`pages.spec.ts:39-93` 在 Chromium 实际提交并断言 POST JSON/成功状态,查询历史并打开阅读页,阅读正文并点击 TXT/HTML 导出。其余首页、提交、历史及处理中/失败/清理页各有 smoke。后台响应是隔离 fixture,不代表真实上传或 ASR 后台通过。 | +| 异常与请求失败可见 | 满足。`pages.spec.ts:9-23, 33-36` 收集 console error、page error、request failed、HTTP 4xx/5xx,并在每个 case 后断言为空;reset 非 2xx 会抛错;Playwright webServer 启动超时/失败使测试命令非零。 | +| SQLite、端口、服务隔离 | 满足。Node 入口从回环端口 0 分配端口并将 `VTA_BROWSER_PORT` 传给 Playwright;`reuseExistingServer: false`。Python 服务绑定 `127.0.0.1`,每次启动新建 `TemporaryDirectory` 与 SQLite;`/__e2e__/reset` 只存在于 `tests/browser_server.py`。CI 为单 worker,case 前重种。未发现固定时间参与断言或访问生产配置/ASR/通知的路径。 | +| CI 接线与模型 gate | 满足。PR #200 的真实 run [37762745649](https://github.com/zlxlabs/VideoTranscriptAPI/actions/runs/37762745649) 在精确 H0 SHA 上执行 `npm run test:web` 与 `npm run test:browser`;`chromium-regression=SUCCESS`。PR 仍为 draft,`gate / primary=SKIPPED`,不能算模型主审通过;`gate / quality=SUCCESS`。原 gate 保留且 `has_ui: true`。浏览器 workflow 的真实事件为 `pull_request`,无 push 触发。 | +| 录屏与上游登记 | 满足豁免边界。GitHub secret 名称查询确认 `PWRS_REPORTER_TOKEN` 不存在;本仓 issue #199 为 OPEN,AGENTS 豁免日期是 2026-10-08;上游 gate-hub #1393 为 OPEN。没有假录屏 workflow/链接;豁免不覆盖浏览器测试。 | + +## 动态证据与限制 + +- 权威 H0 CI 日志显示 Chromium 9/9(1 worker),Vitest 13/13 files、170/170 tests;PR 状态检查中的 `gate / primary` 为 `SKIPPED`。主干基线在派发时不可用,故继承红无法判定;H0 本次相关步骤均成功。 +- 审查树没有 `node_modules`、`e2e/node_modules` 或 `.venv`,未安装依赖;按卡面使用上述同 SHA GitHub CI 作为测试证据。 +- 对根 `npm run test:browser` 入口做了独立子进程负对照:`/tmp/vta-u-cli-probe.9jiVHa/child-payload.txt` 保存实际子进程 payload,断言 cwd 为 `e2e/`、动态端口在有效范围、argv 为 `playwright test`;已知子进程退出码 23 经入口原样传出,判据通过。浏览器请求 payload 则由真实 Chromium spec 对实际 POST 的 `postDataJSON()` 断言。 +- OCR 前置已调用 `ocr-review`,background 1243 bytes,单腿 180s/总预算 480s。完整 JSON envelope 解析成功但 `status=skipped`,reason=`primary=leg_timeout; backup:deepseek=leg_timeout`,findings 为空不代表扫过或 clean。证据:`/tmp/vta-u-review1-envelope.pMjEJj.json`;本 verdict 的 clean 仅表示本轮独立审查未发现 failure-visibility 缺陷。 + +## 非阻断意见与未覆盖范围 + +- **P2,非阻断** — `e2e/reset-backend.ts:3`:该包装函数只有一个调用点(`e2e/tests/pages.spec.ts:30`),仅转发 reset POST 并检查状态码,没有第二个消费者;内联也能保持相同 fail-fast 行为。后果是多一个文件/跳转点,没有运行正确性风险;按单消费者抽象约束记为可接受的非阻断清理项。 +- 受控 HTTP fixture 仅证明既有页面和交互;本轮不证明上传后端、ASR、通知或完整 16 合同。上游 `has_ui` registry 仍待 #1393;录屏发布链路仍待 #199。不得将 draft primary 的 skipped 描述为主审通过。