Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6c4322a
fix(backend): address Greptile review findings on the orchestrator
ObviousApp Oct 15, 2026
223b32a
fix(backend): retryable finalize handoff and lease-guarded file-index…
SamGu-NRX Oct 15, 2026
9f07c90
fix(backend): serialize MemoryState event seq assignment
SamGu-NRX Oct 15, 2026
e805a26
test(backend): add runtime-join journeys over the capture front door
ObviousApp Oct 16, 2026
54b439c
test(backend): read the fake start_run failure as a local contract
ObviousApp Oct 16, 2026
0d19b3c
fix(backend): close amendment, confirmation, and retry defects on the…
ObviousApp Oct 16, 2026
043e8db
merge(backend): bring the moved burst7 base into the runtime-join branch
ObviousApp Oct 16, 2026
926a1a8
test(backend): pin finalize failure-publication ordering, not the pub…
ObviousApp Oct 16, 2026
64a4803
feat(backend): one-command local capture API on loopback (make local-…
SamGu-NRX Oct 16, 2026
993c362
test: pin the local capture API journey over a real socket
SamGu-NRX Oct 16, 2026
ac5b185
Merge origin/alpha into obv/burst9-bs-runtime-join
SamGu-NRX Oct 16, 2026
9d91363
test(backend): failing regressions for the #278 review findings
SamGu-NRX Oct 16, 2026
430369f
fix(backend): publish an escaped finalize failure once, under the slot
SamGu-NRX Oct 16, 2026
b6d1580
fix(backend): a late correction re-runs on a fresh deadline
SamGu-NRX Oct 16, 2026
4bef9fd
fix(backend): a corrected result publishes its new final event
SamGu-NRX Oct 16, 2026
cb7f394
fix(backend): a busy index lease no longer strands committed files
SamGu-NRX Oct 16, 2026
d0774fd
docs: local capture API evidence — recorded live journey, smoke run, …
SamGu-NRX Oct 16, 2026
64805a0
Merge pull request #278 from SamGu-NRX/obv/burst9-bs-runtime-join
SamGu-NRX Oct 16, 2026
2ba1551
feat(orchestrator): dev-only scripted local outcomes for stage stubs
ObviousApp Oct 16, 2026
4f90637
test(backend): pin local-outcome contracts end to end and commit app-…
ObviousApp Oct 16, 2026
96eec0c
merge: bring local outcome states (burst11 #291) onto the local captu…
ObviousApp Oct 16, 2026
5d8e62c
test(backend): drive every app state over a real socket from the loca…
SamGu-NRX Oct 16, 2026
db86ad4
docs(backend): name every local app state and the command that serves it
SamGu-NRX Oct 16, 2026
c41b148
docs(backend): note the failed run's reviewer answer; ruff-format the…
SamGu-NRX Oct 16, 2026
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
13 changes: 13 additions & 0 deletions backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Default launch command fails

When PORT is unset, this recipe ends with a bare --port. Neither Makefile nor its included infra/deploy.mk defines a default, so argument parsing exits before the server starts. Define PORT ?= 8000 or omit the option when no override is supplied.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/Makefile
Line: 94

Comment:
**Default launch command fails**

When `PORT` is unset, this recipe ends with a bare `--port`. Neither `Makefile` nor its included `infra/deploy.mk` defines a default, so argument parsing exits before the server starts. Define `PORT ?= 8000` or omit the option when no override is supplied.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by 8c92df1 on #281 (the Makefile this packet owns): PORT ?= 8000 plus an $(PORT) empty-value guard, so the documented bare make local-api hands the launcher --port 8000 and never a bare --port. Pinned by three make -n regressions in backend/tests/test_local_capture_api.py (documented default, PORT=8123 override, exported-but-empty PORT=). Left unresolved here since this branch does not yet carry the fix.


# 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"]
153 changes: 153 additions & 0 deletions backend/docs/local-app-states.md
Original file line number Diff line number Diff line change
@@ -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-<state>.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/<captureId>/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.
101 changes: 101 additions & 0 deletions backend/docs/local-capture-api.md
Original file line number Diff line number Diff line change
@@ -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:<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 |
| `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": "<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