diff --git a/backend/Makefile b/backend/Makefile index 330a0e613..55995389d 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -86,6 +86,19 @@ 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. +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 000000000..2beca32fb --- /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 000000000..a1f3950d2 --- /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 000000000..83c562fd8 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 000000000..a9ee56ffa --- /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 000000000..78ed3ec9f 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 000000000..d7f0de497 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 000000000..10982eab7 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 000000000..d51aa8644 --- /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-app-states.md b/backend/docs/local-app-states.md new file mode 100644 index 000000000..8f84de608 --- /dev/null +++ b/backend/docs/local-app-states.md @@ -0,0 +1,153 @@ +# Every state the app renders, over a real socket + +The iOS processing UI renders eight states while a scan runs. This page names +each one, the exact command that produces it on `make local-api`, and what the +API serves; the e2e suite `backend/tests/test_local_app_states_e2e.py` drives +all of them over TCP in CI, and `evidence/burst11-bs-local-app-states-run/` +carries a recorded terminal walk per state (`walk-.gif`) plus +reviewer-page screenshots. Screen needs come from +`ios/HouseScanKit/Sources/HouseScanKit/Processing/PhotoProcessingStatus.swift` +and the copy in `ios/HouseScan/UI/Copy/ProcessingCopy.swift`. + +## The journey every state goes through + +One server, one capture, the documented front door — with the app token from +`backend/runs/local-api/session.json` (`TOKEN`, below) and a retagged copy of +the committed packet (`data/fixtures/packets/mini-arkit`): + +```bash +PID=local-app-states-eligible +CAPTURE_ID=$(curl -s -X POST http://127.0.0.1:$PORT/v1/captures \ + -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $PID" \ + -H "Content-Type: application/json" -d @create.json | jq -r .captureId) + +curl -s -X POST http://127.0.0.1:$PORT/v1/captures/$CAPTURE_ID/files \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d @files.json # register: sealed hashes per file +# ... one signed PUT per file, then: +curl -s -X POST http://127.0.0.1:$PORT/v1/captures/$CAPTURE_ID/files:commit \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @commit.json + +curl -s -X POST http://127.0.0.1:$PORT/v1/captures/$CAPTURE_ID/finalize \ + -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $PID" \ + -H "Content-Type: application/json" -H "X-Packet-Sha256: $SHA" \ + --data-binary @packet/packet.json # 202 {runId, status: processing} + +curl -s "http://127.0.0.1:$PORT/v1/captures/$CAPTURE_ID/events?after=0&wait=5" \ + -H "Authorization: Bearer $TOKEN" # long-poll to a terminal status +curl -s http://127.0.0.1:$PORT/v1/captures/$CAPTURE_ID/result \ + -H "Authorization: Bearer $TOKEN" # what the result screen reads +curl -s http://127.0.0.1:$PORT/v1/captures/$CAPTURE_ID/review \ + -H "Authorization: Bearer $REVIEWER_TOKEN" # the reviewer page +``` + +`create.json`, `files.json`, `commit.json` and the PUT loop are exactly what +the e2e test and `evidence/burst11-bs-local-app-states-run/walk-*.gif` run; +the recorded walks are the source of truth for both command and output. + +## The eight states + +| App screen | Command (on `make local-api`) | Capture status when settled | Served outcome | +|---|---|---|---| +| Processing your scan | `HSS_LOCAL_STAGE_DELAY_S=2 make local-api` | `processing` → `manual_review` | stage events stream while the twelve stubs work | +| A possible battery spot | `HSS_LOCAL_OUTCOME=eligible make local-api` | `complete` | `eligible` with a proposed `recommendedPlacement` | +| One more look needed | `HSS_LOCAL_OUTCOME=needs_more_photos make local-api` | `needs_views` | `needs_more_photos` with `viewsNeeded` | +| Needs an installer's review | `HSS_LOCAL_OUTCOME=manual_review make local-api` | `manual_review` | `manual_review` | +| No possible spot found | `HSS_LOCAL_OUTCOME=not_eligible SHOW_REJECT_TO_HOMEOWNER=1 make local-api` | `complete` | `not_eligible`, no placement | +| (reject hidden from the homeowner) | `HSS_LOCAL_OUTCOME=not_eligible make local-api` | `manual_review` | `manual_review` | +| Processing didn't finish | `HSS_LOCAL_OUTCOME=failed make local-api` | `failed` | failed event naming the stage, plain message | +| The processing service refused this scan | any; send a bad packet or token | — (never runs) | 4xx with an error code | +| (answer not ready yet) | any; read before any run | pre-run | not-ready envelope / 404 `result_not_ready` | + +## What each screen needs from the API + +### Processing your scan + +While the run works the events stream carries `stage` events +(`{runId, stage, status, ...}`); the capture status stays `processing` until a +terminal event lands. `HSS_LOCAL_STAGE_DELAY_S=2` paces the twelve stubs at two +seconds each so the working state is observable — and pollable: a long-poll +mid-run returns `status: "processing"` with the stage events so far. + +### A possible battery spot (`eligible`) + +`complete` capture; `result` answers: + +```json +{ + "status": "complete", + "outcome": { + "kind": "eligible", + "message": "Local test stand-in: the proposed placement is scripted, not measured. This is a local test outcome, not a check of a wall.", + "recommendedPlacement": { + "wallId": "w1", + "profile": "C", + "footprintM": {"w": 0.7874, "h": 1.0033, "d": 0.5588}, + "boxSceneM": { + "centerM": [0.6937, 0.50165, 0.2994], + "sizeM": [0.7874, 1.0033, 0.5588], + "yawDeg": 0.0 + }, + "confidence": 0.5 + } + }, + "reviewUrl": "/v1/captures//review" +} +``` + +Worded as proposed: the service's own message says the placement is scripted, +not measured — the answer stays provisional, the AR box and the card render it +as a candidate. The committed pin is +`backend/tests/fixtures/app_states/eligible/result.json`. + +### One more look needed (`needs_more_photos`) + +`needs_views` capture; `result` carries `viewsNeeded`, each with `id`, +`kind` (`"band"`), a `prompt` (`title`, `body`) and a `why` — the screen shows +the prompt verbatim. Pinned at +`backend/tests/fixtures/app_states/needs_more_photos/result.json`. + +### Needs an installer's review (`manual_review`) + +`manual_review` capture; `outcome.kind` `manual_review`. This is also where a +`not_eligible` run lands when `SHOW_REJECT_TO_HOMEOWNER` is unset — the reject +screen never shows to the homeowner without the flag. + +### No possible spot found (`not_eligible`, flag on) + +`SHOW_REJECT_TO_HOMEOWNER=1` lets the capture complete with `outcome.kind` +`not_eligible`, a service message, and no placement. + +### Processing didn't finish (`failed`) + +The events stream ends with a `failed` event carrying `code`, a plain +`message` (the screen shows it verbatim) and `next: "none"`, after a `stage` +event with `status: "failed"` naming the stage that failed. `result` answers +`status: "failed"` with `outcome.kind: "manual_review"` — a failed run still +routes the homeowner to the installer. The reviewer page answers +404 `result_not_ready` for a failed run: the route serves the run's +`result/review.html`, and a failed run publishes none. Pinned at +`backend/tests/fixtures/app_states/failed/result.json`; the failed evidence +screenshot is that 404 envelope. + +### The processing service refused this scan (`refused`) + +Two refusals, both before any run exists: + +- a malformed finalize body → 4xx with + `errors: [{code, pointer, message}]` — retrying the same bytes won't change it; +- a missing or wrong bearer → 401 with the same envelope. + +### Answer not ready (`not ready`) + +Before any run: `result` answers 200 with the not-ready envelope +(`status` still pre-run, `outcome: null`, `viewsNeeded: []`), and the reviewer +page answers 404 `result_not_ready`. + +## Regenerating the evidence + +`evidence/burst11-bs-local-app-states-run/` holds one recorded walk per state +plus the three reviewer-page screenshots. Each walk is a real asciinema +recording of the journey above against a real `make local-api` server; session +tokens are masked in the recordings. The e2e suite re-proves the same states in +CI without recordings. diff --git a/backend/docs/local-capture-api.md b/backend/docs/local-capture-api.md new file mode 100644 index 000000000..855057be8 --- /dev/null +++ b/backend/docs/local-capture-api.md @@ -0,0 +1,101 @@ +# 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 | +| `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 to produce every state the app renders — `docs/local-app-states.md` names each +state, its command and what gets served; this launcher never reads or changes the variables. + +## 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 000000000..baa3e1e10 --- /dev/null +++ b/backend/scripts/local_capture_api.py @@ -0,0 +1,221 @@ +#!/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. +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 _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 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=int, 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: