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,/tasksand/version, with opaque cursors;api/logs.py:GET /runs/{id}/logs?source=, the last 64 KiB, read throughO_NOFOLLOWdescriptors under the private launch directory;api/cancel.py:POST /runs/{id}:cancel;api/stream.py:GET /events/stream, onespectio.run.deltaper changed run per second;store/writer.py:note_changeandon_commit, which hand each commit's changed runs and tasks to the stream;projector/runs.pyandstore/runs.pycallnote_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'shttpx2in place ofhttpx; - tests:
tests/api/test_read.py,test_cancel.py,test_stream.py,test_openapi.py,test_hardening.py, and one test inpackages/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): malformedReferer→ 403; location over 4096 characters or refused by the file system → 422;$schemanot a string or a recursive$ref→ 422 onparams; a read error after the open → 422, descriptor closed; writes need aContent-Length(411) of at most 1 MiB (413); params nest 32 levels at most;refuse_controlrefuses Cf, Zl, Zp; uvicorn's access log is off;NO_APIno longer names the Host checks. - FU-P3-19, all but the flaky-test question:
httpx2in place ofhttpx(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.