From 64a4803f327e2640572fdf899b39c33548406079 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:35:04 +0000 Subject: [PATCH 1/7] feat(backend): one-command local capture API on loopback (make local-api) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/local_capture_api.py starts the real app under uvicorn, loopback only, fresh tokens + HMAC secret per run, memory state and volume storage, ingest hooks auto so finalize drives the orchestrator inline; it writes runs/local-api/session.json (0600) with the base URL, tokens, pid, sha. make local-api-smoke drives the mini-arkit packet over real TCP via httpx against the script as a subprocess — no TestClient, no drain_finalize. Co-authored-by: Obvious --- backend/Makefile | 13 + backend/docs/local-capture-api.md | 101 +++++++ backend/scripts/local_capture_api.py | 221 +++++++++++++++ backend/tests/test_local_capture_api.py | 359 ++++++++++++++++++++++++ 4 files changed, 694 insertions(+) create mode 100644 backend/docs/local-capture-api.md create mode 100644 backend/scripts/local_capture_api.py create mode 100644 backend/tests/test_local_capture_api.py 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/local-capture-api.md b/backend/docs/local-capture-api.md new file mode 100644 index 000000000..24255aaeb --- /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 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. + +## 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: