Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion backend/Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Comment thread
obvious-autobuild[bot] marked this conversation as resolved.
uv run --frozen --extra api python scripts/local_capture_api.py --host 127.0.0.1 --port $(PORT)
Comment thread
obvious-autobuild[bot] marked this conversation as resolved.

# 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
Expand Down
19 changes: 19 additions & 0 deletions backend/docs/evidence/local-capture-api/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions backend/docs/evidence/local-capture-api/live-journey-receipt.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 62 additions & 0 deletions backend/docs/evidence/local-capture-api/local-api.cast

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions backend/docs/evidence/local-capture-api/smoke.cast
Original file line number Diff line number Diff line change
@@ -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"]
129 changes: 129 additions & 0 deletions backend/docs/local-capture-api.md
Original file line number Diff line number Diff line change
@@ -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:<app-token>,reviewer:local-review:<reviewer-token>` | 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:<port>` | 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": "<bearer for the app and harness>",
"reviewerToken": "<bearer for the review page>",
"pid": 12345,
"gitSha": "<git rev-parse HEAD at start>",
"stageStubs": true,
"startedAt": "<UTC ISO-8601>"
}
```

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 <reviewerToken>` returns the
rendered HTML.
Loading