diff --git a/backend/Makefile b/backend/Makefile index 330a0e61..b10596ed 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,5 +1,5 @@ # `ci` includes `validate` (committed fixtures only). Large PLYs and tars stay in `.fixtures/`. -.PHONY: setup lint test schemas hygiene fixtures gt validate validate-full ci gates-offline gates-update e2e e2e-local lamar criteria-golden evals e2e-dev orchestrator-e2e ios-kit result-golden reads-gate reads-extra weights +.PHONY: setup lint test schemas hygiene fixtures gt validate validate-full ci gates-offline gates-update e2e e2e-local lamar criteria-golden evals e2e-dev orchestrator-e2e local-api local-api-smoke ios-kit result-golden reads-gate reads-extra weights ONLY ?= small @@ -86,6 +86,26 @@ e2e-dev: PYTHONPATH=. uv run --frozen python scripts/e2e_dev.py --self-check PYTHONPATH=. uv run --frozen python scripts/e2e_dev.py $(if $(PACKET),--packet $(PACKET),) +# Burst11: the one-command local capture API. Serves loopback only (127.0.0.1); +# the iOS simulator shares the Mac's loopback, a physical phone does not. +# Writes backend/runs/local-api/session.json (mode 0600, gitignored) with the +# base URL, app and reviewer tokens, pid and git sha. Stop with Ctrl-C. +# The documented default (docs/local-capture-api.md) is port 8000; override +# with PORT=8123 make local-api. The empty-value guard catches an exported +# PORT= so the launcher never sees a bare --port. +PORT ?= 8000 +ifeq ($(PORT),) +override PORT := 8000 +endif +local-api: + uv run --frozen --extra api python scripts/local_capture_api.py --host 127.0.0.1 --port $(PORT) + +# Burst11: drives the committed mini-arkit packet against the local capture API +# over a real TCP socket — the launcher script starts as a subprocess on an +# ephemeral port, no TestClient. +local-api-smoke: + uv run --frozen --extra api pytest tests/test_local_capture_api.py -q + orchestrator-e2e: STATE_BACKEND=memory STORAGE_BACKEND=local HSS_ORCH_STAGE_STUBS=1 VISION_EXTERNAL_CALLS=off SMP_ENVIRONMENT=dev \ uv run --frozen pytest server/orchestrator/tests -q diff --git a/backend/docs/evidence/local-capture-api/README.md b/backend/docs/evidence/local-capture-api/README.md new file mode 100644 index 00000000..2beca32f --- /dev/null +++ b/backend/docs/evidence/local-capture-api/README.md @@ -0,0 +1,19 @@ +# Local capture API — evidence + +Produced 2026-10-16 on a fresh clone of `alpha` at `4bcbedba`, branch +`obv/burst11-bs-local-api-serve`, head `993c3627`. Everything here was +captured against the real stack: the launcher script under uvicorn over a +TCP socket, no TestClient, no drain_finalize. + +| File | What it shows | +|---|---| +| `local-api-terminal.png` | Frame from the `make local-api` recording: the launcher banner — base URL, session-file path, the stand-in stage list with `placement` real, and the tokens-never-on-stdout note. | +| `local-api.cast` | Full asciinema recording of `make local-api` from a clean `runs/`: dependency-free startup, health wait, banner; stopped with Ctrl-C (Error 130 = SIGINT at the end of the recording is the operator stop). | +| `smoke-terminal.png` / `smoke.cast` | `make local-api-smoke`: the committed packet journey over real HTTP — `6 passed in 6.49s`. | +| `review-page.png` | Playwright screenshot of `GET /v1/captures/{id}/review` with the reviewer bearer from `session.json`, after the capture reached a terminal status. Renders the dev stub review page — locally the compose stage is a stand-in (`HSS_ORCH_STAGE_STUBS=1`), which is the documented dev configuration; the machine-readable outcome is the result envelope below. | +| `result-page.png` | Playwright screenshot of `GET /v1/captures/{id}/result` with the app bearer: the device-facing envelope — `status: manual_review`, `outcome.kind: manual_review`, "Review required before a placement is shown.", `reviewUrl`, `runId`. | +| `live-journey-receipt.json` | Sanitized receipt of the recorded live journey (create → signed uploads → commit → finalize 202 → inline orchestrator run → terminal `manual_review`). Tokens redacted; they are per-run local secrets. | + +Reproduction: `make local-api` in one terminal, `make local-api-smoke` in +another (both from `backend/`); the driver for the screenshots reused the +committed smoke-test helpers against the live port. diff --git a/backend/docs/evidence/local-capture-api/live-journey-receipt.json b/backend/docs/evidence/local-capture-api/live-journey-receipt.json new file mode 100644 index 00000000..a1f3950d --- /dev/null +++ b/backend/docs/evidence/local-capture-api/live-journey-receipt.json @@ -0,0 +1,12 @@ +{ + "what": "live journey against make local-api (recorded to local-api.cast)", + "driver": "tests/test_local_capture_api.py helpers over real TCP via httpx; no TestClient", + "baseUrl": "http://127.0.0.1:8000/v1", + "captureId": "cap_01M51TS13336P96H5B6KFPH9WW", + "runId": "run_4c05e7d782dfc5f5", + "outcomeKind": "manual_review", + "outcomeMessage": "Review required before a placement is shown.", + "gitSha": "993c362751879bd03f3a8b90dd74c04b0836b78b", + "tokens": "redacted \u2014 per-run local secrets, never committed", + "recordedAt": "2026-10-16T07:49:27+00:00" +} diff --git a/backend/docs/evidence/local-capture-api/local-api-terminal.png b/backend/docs/evidence/local-capture-api/local-api-terminal.png new file mode 100644 index 00000000..83c562fd Binary files /dev/null and b/backend/docs/evidence/local-capture-api/local-api-terminal.png differ diff --git a/backend/docs/evidence/local-capture-api/local-api.cast b/backend/docs/evidence/local-capture-api/local-api.cast new file mode 100644 index 00000000..a9ee56ff --- /dev/null +++ b/backend/docs/evidence/local-capture-api/local-api.cast @@ -0,0 +1,62 @@ +{"version": 2, "width": 80, "height": 24, "timestamp": 1792136634, "env": {"SHELL": "/bin/bash", "TERM": "tmux-256color"}} +[0.006449, "o", "uv run --frozen --extra api python scripts/local_capture_api.py --host 127.0.0.1 --port 8000\r\n"] +[0.887539, "o", "\u001b[32mINFO\u001b[0m: Started server process [\u001b[36m10967\u001b[0m]\r\n\u001b[32mINFO\u001b[0m: Waiting for application startup.\r\n"] +[0.8877, "o", "\u001b[32mINFO\u001b[0m: Application startup complete.\r\n"] +[0.888282, "o", "\u001b[32mINFO\u001b[0m: Uvicorn running on \u001b[1mhttp://127.0.0.1:8000\u001b[0m (Press CTRL+C to quit)\r\n"] +[0.912628, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 12, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/healthz\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TNRREN9VSJHXAHXSPFFA7\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:43:55.162473Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[0.913278, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 1, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/healthz\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TNRRS8XTC116G2XTQ4AJB\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:43:55.163157Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[0.913603, "o", "\r\nlocal capture API ready: http://127.0.0.1:8000"] +[0.913855, "o", "\r\nsession file: /home/user/work/burst11-bs-local-api-serve/backend/runs/local-api/session.json\r\nstand-in stages: validate, poses, objects, reads, scale, dense, coverage, scene, objects_3d, criteria, result\r\nreal stage: placement\r\nthe app and reviewer tokens are in the session file only, never on stdout\r\n"] +[45.414439, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 1, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": 422, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TQ47E7Y9F2YYXDZB62WGG\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:44:39.664289Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[51.70572, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": 422, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TQAC2S59PNA7ZNEXACEN2\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:44:45.955541Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[71.614961, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TQXT5ASMDRT0AFR4RRSKC\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"capture.created\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": \"mini-arkit-live\", \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TQXT5ASMDRT0AFR4RRSKB\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:05.864808Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[71.615046, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 3, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": 201, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TQXT5ASMDRT0AFR4RRSKB\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:05.864996Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[71.619994, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 1, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TQXT5ASMDRT0AFR4RRSKC/files\", \"http.status\": 422, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TQXTBY677VZZM30TXK8FP\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:05.869911Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[93.32869, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TRK0SZ0EWBCP57GDWA77W\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:27.578519Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[93.332034, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TQXT5ASMDRT0AFR4RRSKC/files\", \"http.status\": 422, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TRK0XBCW3YN07Q6GPZF4X\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:27.581960Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[100.563321, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TRT2W5EP2RMNS8WGG43ZA\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:34.813176Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.740491, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"capture.created\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": \"mini-arkit-live2\", \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13336P96H5B6KFPH9WV\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:41.990343Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.740716, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 3, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures\", \"http.status\": 201, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13336P96H5B6KFPH9WV\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:41.990536Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.74604, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"files.registered\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/files\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS138M1GNTMY57VZRR55E\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:41.995935Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.746239, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 3, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/files\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS138M1GNTMY57VZRR55E\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:41.996214Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.755413, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 1, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13KAP81X14EG5M08401\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.005321Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.757201, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13PGJ092TBCQEAEWDEK\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.007163Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}"] +[107.757461, "o", "\r\n"] +[107.758974, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13RYQAG6TN7HQF8WR0D\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.008911Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.760551, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13SYZ6500Q6JS3DFBW8\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.010521Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.762247, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13VFJPZE6MTNWRFGFKP\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.012221Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.76382, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13WNPVPP7JKXXX4H0J8\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.013782Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.765413, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS13Y8FCZK4ACA0HFCWJB\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.015326Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.766952, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS1402B45CWEWDQWQ9CPF\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.016935Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.768554, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS141DYJ9HKRZPGNSN3XW\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.018512Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.770115, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS143XXDVZ3KFRFSZP8P7\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.020075Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.771655, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 0, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"PUT\", \"http.route\": \"/v1/uploads/{token}\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS144QW2AKSFKZ1Z76V49\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.021606Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.783259, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"files.committed\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/files:commit\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS146MS9E6Z53P368CKDM\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.033194Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.783432, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 10, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/files:commit\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS146MS9E6Z53P368CKDM\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.033420Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.786605, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"finalize.accepted\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/finalize\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS14JHEJAJDGW44B7GE7V\", \"run_id\": \"run_4c05e7d782dfc5f5\", \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.036570Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.787314, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 2, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"POST\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/finalize\", \"http.status\": 202, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS14JHEJAJDGW44B7GE7V\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.037241Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.789833, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"events.poll\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/events\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS14PFV480P46JCF4ESCA\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.039764Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.789995, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 1, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/events\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS14PFV480P46JCF4ESCA\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.039960Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[107.817227, "o", "{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"synthetic_source\",\"code\":\"synthetic_source\",\"pointer\":\"/source/kind\",\"capture_id\":\"mini-arkit-live2\"}\r\n{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"keyframe_resolution_differs\",\"code\":\"keyframe_resolution_differs\",\"pointer\":\"/keyframes/0/w\",\"capture_id\":\"mini-arkit-live2\"}\r\n{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"keyframe_resolution_differs\",\"code\":\"keyframe_resolution_differs\",\"pointer\":\"/keyframes/1/w\",\"capture_id\":\"mini-arkit-live2\"}\r\n"] +[107.817427, "o", "{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"low_resolution\",\"code\":\"low_resolution\",\"pointer\":\"/keyframes/0/w\",\"capture_id\":\"mini-arkit-live2\"}\r\n{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"low_resolution\",\"code\":\"low_resolution\",\"pointer\":\"/keyframes/1/w\",\"capture_id\":\"mini-arkit-live2\"}\r\n{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"low_resolution\",\"code\":\"low_resolution\",\"pointer\":\"/keyframes/2/w\",\"capture_id\":\"mini-arkit-live2\"}\r\n{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"low_resolution\",\"code\":\"low_resolution\",\"pointer\":\"/keyframes/3/w\",\"capture_id\":\"mini-arkit-live2\"}\r\n{\"level\":\"warning\",\"event\":\"packet_issue\",\"msg\":\"few_images\",\"code\":\"few_images\",\"pointer\":\"/keyframes\",\"capture_id\":\"mini-arkit-live2\"}\r\n"] +[107.841175, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": null, \"disagreement\": null, \"duration_ms\": 23, \"error_code\": null, \"event\": \"promotion.done\", \"git_sha\": null, \"gpu\": null, \"http.method\": null, \"http.route\": null, \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": null, \"request_id\": null, \"run_id\": null, \"service.name\": null, \"stage\": null, \"state_backend\": null, \"storage_backend\": null, \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.091107Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[108.343446, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": null, \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"run.started\", \"git_sha\": null, \"gpu\": null, \"http.method\": null, \"http.route\": null, \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": null, \"product\": null, \"request_id\": null, \"run_id\": \"run_4c05e7d782dfc5f5\", \"service.name\": null, \"stage\": null, \"state_backend\": null, \"storage_backend\": null, \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:42.593380Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[112.798153, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"events.poll\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/events\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS14Y91SAC9E4BAH0DBWV\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:47.047893Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[112.798482, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 5002, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/events\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS14Y91SAC9E4BAH0DBWV\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:47.048330Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[112.801691, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"result.served\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/result\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS619Z552Z8BBTCJN1JTQ\", \"run_id\": \"run_4c05e7d782dfc5f5\", \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:47.051629Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[112.802038, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 2, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/result\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS619Z552Z8BBTCJN1JTQ\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:47.051896Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[113.887278, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 1, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/review\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"local-review\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS737KM7V9SXBEXQBT21P\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:48.137146Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[114.504603, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": \"cap_01M51TS13336P96H5B6KFPH9WW\", \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": null, \"error_code\": null, \"event\": \"result.served\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/result\", \"http.status\": null, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS7PHQYZ8A2CSKY31B87W\", \"run_id\": \"run_4c05e7d782dfc5f5\", \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:48.754511Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[114.504895, "o", "{\"amendment_id\": null, \"attempt\": null, \"capture_id\": null, \"deployment.environment\": \"dev\", \"disagreement\": null, \"duration_ms\": 1, \"error_code\": null, \"event\": \"http.request\", \"git_sha\": \"993c362751879bd03f3a8b90dd74c04b0836b78b\", \"gpu\": null, \"http.method\": \"GET\", \"http.route\": \"/v1/captures/cap_01M51TS13336P96H5B6KFPH9WW/result\", \"http.status\": 200, \"level\": \"info\", \"msg\": \"\", \"packet_id\": null, \"principal\": \"device\", \"product\": \"house-scanning\", \"request_id\": \"req_01M51TS7PHQYZ8A2CSKY31B87W\", \"run_id\": null, \"service.name\": \"capture-api\", \"stage\": null, \"state_backend\": \"memory\", \"storage_backend\": \"volume\", \"tokens_in\": null, \"tokens_out\": null, \"ts\": \"2026-10-16T07:45:48.754794Z\", \"vlm_cost_usd\": null, \"vlm_model\": null, \"vlm_provider\": null}\r\n"] +[124.093427, "o", "^C"] +[124.138068, "o", "\u001b[32mINFO\u001b[0m: Shutting down\r\n"] +[124.238432, "o", "\u001b[32mINFO\u001b[0m: Waiting for application shutdown.\r\n"] +[124.23854, "o", "\u001b[32mINFO\u001b[0m: Application shutdown complete.\r\n"] +[124.238901, "o", "\u001b[32mINFO\u001b[0m: Finished server process [\u001b[36m10967\u001b[0m]\r\n"] +[124.240332, "o", "Traceback (most recent call last):\r\n File \"/home/user/.local/share/uv/python/cpython-3.12.15-linux-x86_64-gnu/lib/python3.12/asyncio/runners.py\", line 118, in run\r\n"] +[124.240516, "o", " return self._loop.run_until_complete(task)\r\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\r\n File \"uvloop/loop.pyx\", line 1518, in uvloop.loop.Loop.run_until_complete\r\n"] +[124.240626, "o", "asyncio.exceptions.CancelledError\r\n\r\nDuring handling of the above exception, another exception occurred:\r\n\r\nTraceback (most recent call last):\r\n"] +[124.240805, "o", " File \"/home/user/work/burst11-bs-local-api-serve/backend/scripts/local_capture_api.py\", line 221, in \r\n"] +[124.241057, "o", " raise SystemExit(main())\r\n ^^^^^^\r\n File \"/home/user/work/burst11-bs-local-api-serve/backend/scripts/local_capture_api.py\", line 216, in main\r\n uvicorn.Server(config).run()\r\n File \"/home/user/work/burst11-bs-local-api-serve/backend/.venv/lib/python3.12/site-packages/uvicorn/server.py\", line 86, in run\r\n return asyncio_run(self.serve(sockets=sockets), loop_factory=self.config.get_loop_factory())\r\n"] +[124.241207, "o", " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\r\n File \"/home/user/.local/share/uv/python/cpython-3.12.15-linux-x86_64-gnu/lib/python3.12/asyncio/runners.py\", line 195, in run\r\n return runner.run(main)\r\n ^^^^^^^^^^^^^^^^\r\n File \"/home/user/.local/share/uv/python/cpython-3.12.15-linux-x86_64-gnu/lib/python3.12/asyncio/runners.py\", line 123, in run\r\n raise KeyboardInterrupt()\r\nKeyboardInterrupt\r\n"] +[124.49459, "o", "make: *** [Makefile:100: local-api] Error 130\r\n"] diff --git a/backend/docs/evidence/local-capture-api/result-page.png b/backend/docs/evidence/local-capture-api/result-page.png new file mode 100644 index 00000000..78ed3ec9 Binary files /dev/null and b/backend/docs/evidence/local-capture-api/result-page.png differ diff --git a/backend/docs/evidence/local-capture-api/review-page.png b/backend/docs/evidence/local-capture-api/review-page.png new file mode 100644 index 00000000..d7f0de49 Binary files /dev/null and b/backend/docs/evidence/local-capture-api/review-page.png differ diff --git a/backend/docs/evidence/local-capture-api/smoke-terminal.png b/backend/docs/evidence/local-capture-api/smoke-terminal.png new file mode 100644 index 00000000..10982eab Binary files /dev/null and b/backend/docs/evidence/local-capture-api/smoke-terminal.png differ diff --git a/backend/docs/evidence/local-capture-api/smoke.cast b/backend/docs/evidence/local-capture-api/smoke.cast new file mode 100644 index 00000000..d51aa864 --- /dev/null +++ b/backend/docs/evidence/local-capture-api/smoke.cast @@ -0,0 +1,11 @@ +{"version": 2, "width": 80, "height": 24, "timestamp": 1792136781, "env": {"SHELL": "/bin/bash", "TERM": "tmux-256color"}} +[0.005238, "o", "uv run --frozen --extra api pytest tests/test_local_capture_api.py -q\r\n"] +[6.779782, "o", "\u001b[32m.\u001b[0m"] +[6.802891, "o", "\u001b[32m.\u001b[0m"] +[6.818502, "o", "\u001b[32m.\u001b[0m"] +[6.827253, "o", "\u001b[32m.\u001b[0m"] +[6.828111, "o", "\u001b[32m.\u001b[0m"] +[6.893632, "o", "\u001b[32m.\u001b[0m"] +[7.058185, "o", "\u001b[32m [100%]\u001b[0m"] +[7.061215, "o", "\r\n"] +[7.061486, "o", "\u001b[32m\u001b[32m\u001b[1m6 passed\u001b[0m\u001b[32m in 6.49s\u001b[0m\u001b[0m\r\n"] diff --git a/backend/docs/local-capture-api.md b/backend/docs/local-capture-api.md new file mode 100644 index 00000000..006e69f3 --- /dev/null +++ b/backend/docs/local-capture-api.md @@ -0,0 +1,129 @@ +# The local capture API: one command, loopback only + +`make local-api` gives a reviewer, the iOS simulator and `tools/hunter-local-ingest` a working +capture API on `127.0.0.1` from a fresh checkout. It replaces the hand-assembled uvicorn command in +`server/api/README.md`, which ran with `HSS_INGEST_HOOKS=null` — so finalize never reached the +orchestrator and no run ever started. + +## Fresh checkout + +```bash +git clone https://github.com/SamGu-NRX/BaseScanning.git && cd BaseScanning +git checkout alpha +cd backend +uv sync --frozen --extra api +make local-api # PORT=8000 default; override with PORT=8123 make local-api +``` + +Wait for the banner: + +``` +local capture API ready: http://127.0.0.1:8000 +session file: .../backend/runs/local-api/session.json +stand-in stages: validate, poses, objects, reads, scale, dense, coverage, scene, objects_3d, criteria, result +real stage: placement +``` + +Stop with Ctrl-C. Every run generates fresh tokens; nothing persists across restarts except the +uploaded bytes under `runs/local-api/volume/`. + +`make local-api-smoke` proves the same launcher end to end: it starts the script as a subprocess on +an ephemeral port and drives the committed `data/fixtures/packets/mini-arkit` packet over real TCP +with httpx — create, register, PUT to the signed URLs, commit, finalize, long-poll events to +terminal `manual_review`, read the result and the reviewer page — plus the auth and error +boundaries. No TestClient, no `drain_finalize`: the orchestrator is driven inline by `start_run`. + +## Environment + +| Variable | Value set by `make local-api` | Why | +|---|---|---| +| `SMP_ENVIRONMENT` | `dev` | dev posture; no cloud credentials needed | +| `HSS_AUTH_MODE` | `static` | bearer tokens from `CAPTURE_API_TOKENS` (explicit, because dev otherwise defaults to `none`) | +| `CAPTURE_API_TOKENS` | `device:,reviewer:local-review:` | generated per run; tokens are ≥32 chars (`server/api/auth.py`) | +| `UPLOAD_URL_HMAC_SECRET` | fresh hex per run | signs the volume upload tokens | +| `STATE_BACKEND` | `memory` | no Modal, no database | +| `STORAGE_BACKEND` | `volume` | signed PUT URLs a client can actually fetch: `{CAPTURE_API_PUBLIC_URL}/v1/uploads/{token}` (`storage_volume.py`), unlike `storage_local.py`'s `file://` URLs | +| `VOLUME_ROOT` | `backend/runs/local-api/volume` | uploaded bytes live under gitignored `runs/` | +| `CAPTURE_API_PUBLIC_URL` | `http://127.0.0.1:` | derived from the bind address, so upload URLs are reachable on this machine | +| `HSS_INGEST_HOOKS` | `auto` | finalize reaches the orchestrator (this is the point) | +| `HSS_ORCH_STAGE_STUBS` | `1` | committed stage stubs instead of GPU/vision work | +| `HSS_ORCH_PREWARM` | `0` | no prewarm work on capture create | +| `HSS_ORCH_MODAL_REMOTE` | `0` | a 'local' run never dispatches to deployed Modal functions — remote workers cannot see this process's memory state or volume, and remote-on also flips state to a Modal Dict and storage to GCS (`server/common/deploy_switch.py`) | +| `VISION_EXTERNAL_CALLS` | `off` | no networked model calls | +| `BASE_WEBHOOK_URL` / `WEBHOOK_HMAC_SECRET` | unset | webhooks off | + +Pass through without interpretation: `HSS_LOCAL_OUTCOME` and `HSS_LOCAL_STAGE_DELAY_S`. The stage +stubs honor them when the burst11-bs-local-outcome-states packet lands; this launcher never reads +or changes them, so `HSS_LOCAL_OUTCOME=pass make local-api` just works. + +### What the launcher refuses from the shell + +Everything above pins **where work runs and where bytes go**. The same audit applied to the rest of +the environment turned up eleven keys a developer's shell could export that would do exactly that, +so the launcher drops them before uvicorn imports and prints one line naming what it refused +(key names only, never values): + +| Refused key | Why | +|---|---| +| `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` | Modal credentials any code path reaching for Modal would authenticate with | +| `MODAL_IDENTITY_TOKEN` | a Modal identity token that authenticates this process | +| `MODAL_APP_NAME` | names the deployed Modal app a Modal call would target | +| `MODAL_ENVIRONMENT` | selects which Modal environment the Modal client targets | +| `MODAL_TASK_ID` | marks the process as inside Modal and flips packet-cache behavior (`orchestrator/artifacts.py`) | +| `STATE_DICT_NAME` | names the shared Modal Dict state would land in | +| `HSS_PACKET_CACHE` | redirects packet-cache bytes away from the local volume | +| `HSS_ORCH_TEST_FAIL_STAGE` / `HSS_ORCH_TEST_HANG_STAGE` / `HSS_ORCH_TEST_KILL_COORDINATOR_AFTER` | fault-injection hooks a stray export would turn into a mysterious local failure or hang | + +Reviewed and left alone, with the reasons: `HSS_LOCAL_OUTCOME` / `HSS_LOCAL_STAGE_DELAY_S` are the +deliberate harness pass-throughs above; `HSS_OBJECTS_CACHE_DIR`, `HSS_READS_CACHE_DIR` and +`HSS_SCALE_METER_MODELS_PATH` are local-disk read paths for stages that run as committed stubs +locally (only `placement` is real, and it reads none of them); `HF_HOME` / `HUGGINGFACE_HUB_CACHE` +are weight-cache locations no local stage downloads into; `CAPTURE_API_URL` / `CAPTURE_API_TOKEN` +are client-side harness variables the server never reads; and `HSS_MODAL_SCHEDULE` and the Modal +runtime keys are only read inside Modal images, which a loopback-only memory-state run never +reaches. `BASE_WEBHOOK_URL` / `WEBHOOK_HMAC_SECRET` stay unset (webhooks off), as before. + +## The session file + +`backend/runs/local-api/session.json`, mode `0600` (the directory `runs/` is gitignored): + +```json +{ + "baseUrl": "http://127.0.0.1:8000/v1", + "appToken": "", + "reviewerToken": "", + "pid": 12345, + "gitSha": "", + "stageStubs": true, + "startedAt": "" +} +``` + +The shape is pinned verbatim by `tests/test_local_capture_api.py` because the iOS run and later +packets read it. Two choices to note: the tokens are written **inline** (`appToken`, not +`appTokenFile`) because a single self-contained file is one less path for every consumer to resolve +against an unknown working directory; and `gitSha` is the full head SHA so a captured session always +identifies the code that served it. Tokens appear nowhere else — not in stdout, not in the uvicorn +access log (which keeps upload URLs as `/v1/uploads/{token}` route shapes anyway). + +## What is a stand-in + +`HSS_ORCH_STAGE_STUBS=1` runs every one of the twelve orchestrator stages as its committed stub +**except `placement`**, which is `stubs_exempt` (deterministic CPU work, no GPU/network/credentials) +and solves for real. A terminal `manual_review` verdict therefore proves the whole HTTP + finalize + +orchestrator path, not the quality of the vision stages. + +## The iOS simulator + +The simulator shares the Mac's loopback, so it talks to `http://127.0.0.1:8000/v1` directly — set +the app's capture API base URL and paste the `appToken` from the session file. **A physical phone +does not share the Mac's loopback** and is out of scope: the server refuses any non-loopback bind, +by design. + +## tools/hunter-local-ingest + +Point it at the session: read `backend/runs/local-api/session.json`, use `baseUrl` as the API base, +`appToken` as the bearer on every request, and register uploads from the packet directory exactly as +the smoke test does. The review page needs the `reviewerToken` bearer: +`GET {baseUrl}/captures/{captureId}/review` with `Authorization: Bearer ` returns the +rendered HTML. diff --git a/backend/scripts/local_capture_api.py b/backend/scripts/local_capture_api.py new file mode 100644 index 00000000..8f321ac7 --- /dev/null +++ b/backend/scripts/local_capture_api.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""One-command local capture API for reviewers, the iOS simulator and harness scripts. + +`make local-api` (backend/) starts the real HTTP app `server.api.app:create_app` +under uvicorn, bound to loopback only, with generated tokens and a fresh HMAC +secret, and writes the session the iOS run and later packets read: + + backend/runs/local-api/session.json (mode 0600; runs/ is gitignored) + +The journey is the documented front door: create a capture, register files, +PUT bytes to the signed upload URLs, commit, finalize `packet.json`, long-poll +events, then read the result and the reviewer page — over a real TCP socket, +unlike `scripts/capture_flow_e2e.py`, which drives TestClient in process. + +Loopback only: the server refuses any `--host` other than 127.0.0.1 or ::1, and +`CAPTURE_API_PUBLIC_URL` (which signs the upload URLs) is derived from the same +host, so it can never point a client at a routable address. The iOS simulator +shares the Mac's loopback, so it reaches 127.0.0.1 directly; a physical phone +does not and is out of scope. + +Stand-in stages: `HSS_ORCH_STAGE_STUBS=1` runs every orchestrator stage as its +committed stub except `placement`, which is `stubs_exempt` and solves for real. +The launcher forces `HSS_ORCH_MODAL_REMOTE=0` — a developer's shell that exports +it as 1 must not send a 'local' run to deployed Modal functions, which cannot +see this process's memory state or volume — and refuses the other parent-shell +keys that would move work or bytes off this machine (REFUSED_PARENT_KEYS below). +Pass-through, without interpretation: `HSS_LOCAL_OUTCOME` and +`HSS_LOCAL_STAGE_DELAY_S` are honored by the stage stubs when the +burst11-bs-local-outcome-states packet lands; this script neither reads nor +changes them. +""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +import socket +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +LOOPBACK_HOSTS = {"127.0.0.1", "::1"} + +# Twelve-stage graph (server/orchestrator/graph.py). With HSS_ORCH_STAGE_STUBS=1 +# every one of these is a committed stub; `placement` alone is stubs_exempt. +STAND_IN_STAGES = ( + "validate", + "poses", + "objects", + "reads", + "scale", + "dense", + "coverage", + "scene", + "objects_3d", + "criteria", + "result", +) +REAL_STAGES = ("placement",) + +SESSION_DEFAULT = ROOT / "runs" / "local-api" + + +def _git_sha() -> str: + env = os.environ.get("GIT_SHA") + if env: + return env + try: + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT.parent, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except Exception: + return "unknown" + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _public_url(host: str, port: int) -> str: + host_part = f"[{host}]" if ":" in host else host + return f"http://{host_part}:{port}" + + +def _port(value: str) -> int: + """Parse --port; refuse an empty value loudly instead of argparse's bare + "invalid int value" (a rule that expands an unset or empty $(PORT) here).""" + text = value.strip() + if not text: + raise argparse.ArgumentTypeError( + "refusing an empty --port: pass a port number (0 picks a free one); " + "make local-api supplies the documented default (8000)" + ) + try: + return int(text) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid port {value!r}: pass a number (0 picks a free one)") from exc + + +def _write_session(path: Path, session: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as handle: + json.dump(session, handle, indent=2) + handle.write("\n") + os.chmod(path, 0o600) + + +def _wait_health(base: str, timeout_s: float) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"{base}/v1/healthz", timeout=2) as response: + if response.status == 200: + return True + except (urllib.error.URLError, TimeoutError, OSError): + time.sleep(0.25) + return False + + +def _announce(base: str, session_path: Path, timeout_s: float) -> None: + """Print the banner once /v1/healthz answers. Runs on a side thread while + uvicorn serves on the main thread, so the tokens stay off the terminal.""" + + def _ready() -> None: + if _wait_health(base, timeout_s): + standins = ", ".join(STAND_IN_STAGES) + print(f"\nlocal capture API ready: {base}", flush=True) + print(f"session file: {session_path}", flush=True) + print(f"stand-in stages: {standins}", flush=True) + print(f"real stage: {', '.join(REAL_STAGES)}", flush=True) + print("the app and reviewer tokens are in the session file only, never on stdout", flush=True) + else: + print(f"\nlocal capture API failed its health check within {timeout_s:.0f}s; stopping", flush=True) + os._exit(1) + + threading.Thread(target=_ready, daemon=True).start() + + +def _local_env_overrides( + *, + app_token: str, + reviewer_token: str, + hmac_secret: str, + git_sha: str, + base: str, + volume_root: Path, +) -> dict[str, str]: + """The env a local run forces, wherever the developer's shell disagrees. + + Every value pins where work runs and where bytes go: memory state, the + local volume, loopback-only URLs, and local dispatch — HSS_ORCH_MODAL_REMOTE=0 + (the Greptile P1 raised on #293: with the inherited value 1 a 'local' run + dispatched to deployed Modal functions that cannot see this process's memory + state or volume, and remote-on also flips state to a Modal Dict and storage + to GCS via server/common/deploy_switch.py shared_remote_backends). + """ + return { + "SMP_ENVIRONMENT": "dev", + "HSS_AUTH_MODE": "static", + "CAPTURE_API_TOKENS": f"device:{app_token},reviewer:local-review:{reviewer_token}", + "UPLOAD_URL_HMAC_SECRET": hmac_secret, + "STATE_BACKEND": "memory", + "STORAGE_BACKEND": "volume", + "VOLUME_ROOT": str(volume_root), + "CAPTURE_API_PUBLIC_URL": base, + "HSS_INGEST_HOOKS": "auto", + "HSS_ORCH_STAGE_STUBS": "1", + "HSS_ORCH_PREWARM": "0", + "HSS_ORCH_MODAL_REMOTE": "0", + "VISION_EXTERNAL_CALLS": "off", + "GIT_SHA": git_sha, + } + + +# Parent-shell keys a local run refuses, each because it would move work or +# bytes off this machine or sabotage the run: Modal credentials and app +# targeting (any code path reaching for Modal would authenticate to the +# deployed app), the shared Modal Dict and packet-cache names (state and bytes +# would leave the local volume), and the fault-injection hooks a stray export +# would turn into a mysterious local failure or hang. The value is the +# one-line reason a refusal names. +REFUSED_PARENT_KEYS: dict[str, str] = { + "MODAL_TOKEN_ID": "Modal credentials a Modal call would authenticate with", + "MODAL_TOKEN_SECRET": "Modal credentials a Modal call would authenticate with", + "MODAL_IDENTITY_TOKEN": "a Modal identity token that authenticates this process", + "MODAL_APP_NAME": "names the deployed Modal app a Modal call would target", + "MODAL_ENVIRONMENT": "selects which Modal environment the Modal client targets", + "MODAL_TASK_ID": "marks the process as inside Modal and flips packet-cache behavior", + "STATE_DICT_NAME": "names the shared Modal Dict state would land in", + "HSS_PACKET_CACHE": "redirects packet-cache bytes away from the local volume", + "HSS_ORCH_TEST_FAIL_STAGE": "injects a stage failure into the run", + "HSS_ORCH_TEST_HANG_STAGE": "injects a stage hang into the run", + "HSS_ORCH_TEST_KILL_COORDINATOR_AFTER": "kills the coordinator mid-run", +} + + +def _refused_parent_keys(parent: dict[str, str] | None = None) -> list[str]: + """Parent-shell keys this launcher refuses, in REFUSED_PARENT_KEYS order.""" + env = os.environ if parent is None else parent + return [key for key in REFUSED_PARENT_KEYS if key in env] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--host", default="127.0.0.1", help="bind address; loopback only (127.0.0.1 or ::1)") + parser.add_argument("--port", type=_port, default=8000, help="port (0 picks a free one)") + parser.add_argument( + "--session-dir", + type=Path, + default=SESSION_DEFAULT, + help="directory for session.json and the volume root (default: backend/runs/local-api)", + ) + parser.add_argument("--health-timeout", type=float, default=60.0, help="seconds to wait for /v1/healthz") + args = parser.parse_args(argv) + + if args.host not in LOOPBACK_HOSTS: + print( + f"refusing to bind {args.host!r}: the local capture API serves loopback only " + f"(127.0.0.1 or ::1). A phone that is not the simulator cannot reach this server anyway.", + file=sys.stderr, + ) + return 2 + + port = args.port if args.port > 0 else _free_port() + base = _public_url(args.host, port) + + # Fresh per-run credentials: a device bearer for the app and harness, an + # explicit reviewer bearer for the review page (server/api/auth.py reads + # `reviewer: