Skip to content

Code review · P3 Task 18, read API, logs, cancel and the live stream

review-code on autopilot/P3-t18, base 87aaa59, 2026-10-30. The step is P1 Task 18, built in the local-only order of inbox #80 (a), before Tasks 12 to 16, with the rules of inbox #87. It adds:

  • packages/spectio-server/src/spectio_server/api/runs_read.py: GET /runs, /runs:summary, /runs/{id}, /processes, /stages, /tasks and /version, with opaque cursors;
  • api/logs.py: GET /runs/{id}/logs?source=, the last 64 KiB, read through O_NOFOLLOW descriptors under the private launch directory;
  • api/cancel.py: POST /runs/{id}:cancel;
  • api/stream.py: GET /events/stream, one spectio.run.delta per changed run per second;
  • store/writer.py: note_change and on_commit, which hand each commit's changed runs and tasks to the stream; projector/runs.py and store/runs.py call note_change;
  • migration 0004_read_api.py: run_stages, tasks.stage_id, workflow_versions.version; ids.py: monotonic IDs;
  • openapi/0.0.1.json, the OpenAPI snapshot;
  • the FU-P3-17 input hardening (browser.py, workflows.py, runs_write.py, problems.py, serve.py), the FU-P3-14 runner fix (spectio/runner/loop.py), and FU-P3-19's httpx2 in place of httpx;
  • tests: tests/api/test_read.py, test_cancel.py, test_stream.py, test_openapi.py, test_hardening.py, and one test in packages/spectio/tests/test_local_launcher.py.

The reviewers were code-reviewer and security-reviewer, independent and in parallel. Format: README.md. The endpoints are owned by api §5 (notes 2026-10-28 and 2026-10-30, Task 18), the stream by api §8.3, the problem slugs by api §4.1.

Round Head code-reviewer security-reviewer
1 ec75d07 (the step, hardening, FU-P3-14, inbox #87, the cursor fix) approve (4 minors, 1 simplify) approve (7 minors)
2 562510b (stream frame for a new run, a held SSE cap, cancel actor, problem refusals) approve (2 minors, 1 simplify) approve (1 minor)
simplify 562510b (no change) code-simplifier: no change, −0 +0

IDs repeat across rounds. Each round numbered its findings again, so each ID below carries its round: CR-P3T18-03 (r1) is not CR-P3T18-03 (r2). The commit message of 562510b uses the bare IDs of round 1. The reports were relayed by the coordinator in summary.

Test-first. The worker reports that each round-1 fix was written as a test first and seen to fail before the change: test_new_run_sends_a_delta, test_client_cap_holds_under_concurrent_opens (before the fix, all 40 opens got 200), test_dropped_client_still_counts, the event columns in test_cancel_states, and test_deeply_nested_body_is_a_422_problem, test_wrong_method_is_a_problem, test_unknown_path_is_a_problem.

Gate at 562510b, from the worktree, re-run by doc-writer:

uv run --locked pytest -q             # 484 passed, 15 deselected
uv run --locked ruff check .          # All checks passed!
uv run --locked ruff format --check . # 78 files already formatted
uv run --locked mypy packages         # Success: no issues found in 77 source files
uv run --locked lint-imports          # Contracts: 4 kept, 0 broken.
uv lock --check                       # Resolved 55 packages

No warning: the StarletteDeprecationWarning of CR-P3T17-03 (r1) is gone with httpx2.

CODE  step=P3-t18  review=approve b/M/m=0/0/6  security=approve b/M/m=0/0/8  rounds=2  simplify=nochange -0/+0  tests=pass

The counts add up the two rounds per reviewer; the two simplify findings (CR-P3T18-05 (r1), CR-P3T18-05 (r2)) are not counted.

Findings

Id Severity Finding Status
CR-P3T18-01 (r1) minor A new run sent no stream frame: insert_run (POST /runs and the rebuild) never called note_change, so another tab's runs list saw a new run only once its head report committed. fixed in 562510b: insert_run calls note_change; test_new_run_sends_a_delta
CR-P3T18-02 (r1) minor The cap of 32 SSE clients could be passed: the handler checked len(stream.subscribers) and subscribed later, inside the generator; a dropped slow client left subscribers while its connection stayed open. fixed in 562510b: Stream.clients counts open connections; open() right after the check with no await; _Response frees the slot in a finally
CR-P3T18-03 (r1) minor The OpenAPI snapshot gives Task 19's typed client little: every route returns dict[str, Any], so every 200 schema is a free object; info.version is the package version, so a release bump fails test_openapi_snapshot with no API change. open, follow-up FU-P3-21
CR-P3T18-04 (r1) minor Two carried follow-ups had no owner: FU-P3-17's time bound on schema patterns, and FU-P3-19's question whether test_sigterm_stops_cleanly is flaky, and why. given owners by the docs commit of this step: the time bound goes to FU-P3-20; FU-P3-19 stays open for the flaky test (P3-M4)
CR-P3T18-05 (r1) simplify Task paging passes tasks.c.id as sort key and tie-breaker, and encodes the cursor [id, id]: the WHERE has a redundant clause. open: code-simplifier left it, because removing it changes the cursor format or the answer to a forged cursor; a cursor-format decision, follow-up FU-P3-21
SEC-P3T18-01 (r1) minor A person's Cancel was logged as a projector event with no actor (Repudiation): actor_principal_id and actor_kind were null. fixed in 562510b: source="api", actor_kind="person", the one principal of 0.0.1, also created_by of the command; asserted in test_cancel_states
SEC-P3T18-02 (r1) minor The 32-client SSE cap could be exceeded by concurrent opens (same bug as CR-P3T18-02 (r1)). fixed in 562510b; test_client_cap_holds_under_concurrent_opens: 40 opens give 32 × 200 and 8 × 429
SEC-P3T18-03 (r1) minor A delta frame has no size bound: changed.tasks lists every task that changed in a second; 64 frames × 32 clients can hold much memory in a large scatter before a slow client is dropped. open, follow-up FU-P3-20
SEC-P3T18-04 (r1) minor Some refusals were not problem+json: a body nested about 500 000 levels gave FastAPI's 400 {"detail": …}, a wrong method the default 405. fixed in 562510b: a handler for Starlette's HTTPException; body-parse 400 → 422 validation-failed on body, 404 → not-found, others (405) about:blank with Allow kept
SEC-P3T18-05 (r1) minor The log reader checks S_ISREG but not the owner; dev-env §15.3 req. 12 says engine logs are read only when the file is owned by the user. Hardening under a private launch directory; required when P1 Task 25 reuses the reader for cli-observed launch directories. open, follow-up FU-P3-20, due P1 Task 25
SEC-P3T18-06 (r1) minor FU-P3-17's time bound on schema patterns (ReDoS through nextflow_schema.json) was moved on with no owner. given an owner by the docs commit: follow-up FU-P3-20
SEC-P3T18-07 (r1) minor The new read routes and /events/stream have no principal and no can() check, and Stream._publish sends every run's delta to every subscriber. Accepted under inbox #80 and #86 (local-only, one Unix user). open: P1 Task 16 turns every route, SSE included (req. 2), into a 401 without a session; a multi-user version needs a filter per subscriber. The reserved question of #86 stays Thomas's
CR-P3T18-03 (r2) minor about:blank for framework refusals with no api §4.1 slug (405) was written only in a docstring, not in the inbox, against unattended-mode rule (12). fixed by the docs commit: inbox #87 rule 12 and the api §4.1 note
CR-P3T18-04 (r2) minor test_client_cap_holds_under_concurrent_opens waits a fixed 0.2 s for the closes. It works because uvicorn reports ASGI spec 2.3, so Starlette's listen_for_disconnect ends the response at once. Under spec 2.4 (starlette/responses.py:270-285) a close is seen only at the next send, up to 15 s with the keep-alive: the test would fail, and closed tabs would hold cap slots up to 15 s. open, follow-up FU-P3-20
CR-P3T18-05 (r2) simplify test_client_cap_holds_under_concurrent_opens takes a monkeypatch it never uses, and imports stream_mod in the function only for MAX_CLIENTS. open: a test of kept code, so code-simplifier left it; follow-up FU-P3-21
SEC-P3T18-05 (r2) minor The framework-refusal handler builds its title with HTTPStatus(exc.status_code).phrase, which raises ValueError for a status outside http.HTTPStatus: a bare 500. Nothing in spectio_server raises a Starlette HTTPException today. open, follow-up FU-P3-20

Closed from earlier reviews:

  • FU-P3-14 (CR-P3T09-04 (r1)): a Snakemake head that exits with a code ends its launch at the poll that sees it, after one more spool read, not 60 s later (df61c4e, test_snakemake_end_skips_the_spool_grace).
  • FU-P3-17, all but the pattern time bound (4bddad3): malformed Referer → 403; location over 4096 characters or refused by the file system → 422; $schema not a string or a recursive $ref → 422 on params; a read error after the open → 422, descriptor closed; writes need a Content-Length (411) of at most 1 MiB (413); params nest 32 levels at most; refuse_control refuses Cf, Zl, Zp; uvicorn's access log is off; NO_API no longer names the Host checks.
  • FU-P3-19, all but the flaky-test question: httpx2 in place of httpx (CR-P3T17-03 (r1) and repeats), monkeypatch.setattr(wf, "run_git", …) (CR-P3T17-06 (r1)), one status in the symlink test (CR-P3T17-05 (r4)).

Fixes by round

Commit Findings
562510b fix(api): stream frames for new runs, a held SSE cap, cancel actor, problem refusals CR-P3T18-01 (r1), CR-P3T18-02 (r1), SEC-P3T18-01 (r1), SEC-P3T18-02 (r1), SEC-P3T18-04 (r1)

code-simplifier changed nothing. It left three things alone: the task cursor (CR-P3T18-05 (r1): either the cursor format changes, or a forged cursor [a, b] with a > b answers differently); the unused monkeypatch in a test of kept code (CR-P3T18-05 (r2)); and subscribe / unsubscribe in stream.py:65-74, which only open / close call in production but test_stream.py:131, :138 and :153 call directly.

For the Progress page

The Task 18 section of P3-M4 is written: P3-M4 · Task 18, with its findings and open questions.