P1 · Walking skeleton (0.0.1 on a cluster) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: use
subagent-driven-development(step P3 ofnext-phase.md) orexecuting-plansto implement this plan task by task. Steps use checkbox (- [ ]) syntax for tracking.
Draft, 2026-10-28 (step P1); revised the same day for the head in tmux, the EMBL target and the laptop access (log), then after the doc review of PR #47 for live refresh over SSE and SQLite as a rebuildable index (log), and after the security review of that change for §15.3 requirements 8–11 (record); then for runs started from a terminal, Tasks 25 and 26 (log); then for stages declared or detected, Assumption 6 and Task 27 (log); then for each job's files, Resume, the failure summary, results, the copy actions and resources, Assumptions 1, 3 and 20 (notes) and Tasks 28 to 32 (log); then on 2026-10-29 for the laptop command spectio open, now in the spectio package (Task 24, log). Waiting for Thomas at the GO gate (R10 §5). No product code exists yet; this page writes none.

Note 2026-10-28: the figure predates Tasks 25 and 26 (M6, runs started from a terminal), Task 27 (M7, stages) and Tasks 28 to 32 (M8, job files and run tools).
Step page: Progress · P1.
Goal: the smallest Spectio a test user can run on the EMBL cluster: spectio serve in a tmux session on a login node, opened through an SSH tunnel with a one-time token link (by spectio open or an SSH config entry), that launches the synthetic Nextflow or Snakemake pipeline (its head in its own tmux session on the login node, its tasks on SLURM), lists the runs, shows one run's state, task counts, stages (declared or detected, Task 27) and processes or rules, and whether its head is alive, as text and tables, and cancels it; it also monitors the runs that users start by hand from a terminal with the bridge enabled (Tasks 25 and 26).
Architecture: one process per user on the login node: a FastAPI app, an in-process runner thread and SQLite as an index in a node-local temporary directory, rebuilt from the run directories when it is missing (dev-env §15.3; Assumption 21). The runner writes a head script, starts it in its own tmux session on the login node, in the pane's foreground (or detached with setsid on the local executor in tests), watches its PID and exit files, and tails the event spool that the engine plugins write in the run directory. One writer thread validates spool events and projects them into runs, tasks and attempts in the same transaction. A three-screen React UI reads the API and refreshes over one SSE stream.
Tech stack: Python 3.13 (uv workspace), Pydantic 2, Typer, FastAPI + uvicorn, SQLAlchemy Core + Alembic on SQLite, jsonschema; Groovy (nf-spectio, built as in spike T2); React + Vite + Mantine 7 + TanStack Query; pytest, Vitest, Playwright (T6 skeleton); sind slurm-mini.
Spec: dev-env §15.1 (what 0.0.1 contains) and §15.3 (architecture and the seven security requirements); summary in mvp-0.0.1.md; decision log 2026-10-27, revised, 2026-10-28, narrowed and 2026-10-28, head in tmux; what P1 must handle: R10 §3a. Executors read the spec section named in each task.
Process (assumed — pending Thomas, inbox #52, D0 (b)): the proposal's light process. Product code gets review-code (code-reviewer and security-reviewer, then code-simplifier); tooling and test-only changes get tests and one review. When code contradicts a spec, the owning spec changes in the same PR. One Progress page per milestone, not per task.
Assumptions¶
Decisions taken in place of Thomas, who was not available for brainstorming. Each is (assumed — pending Thomas); the inbox entry is named where one exists.
- D1, no relaunch, no parameter history (inbox #61). The run detail shows its own parameters only. (assumed — pending Thomas) Note 2026-10-28 (Thomas, "les 5 dans 0.0.1", log): Resume of a failed or cancelled run and Copy to a new launch are in 0.0.1 (Task 30); rerun from a stage, the relaunch dialog and the parameter history stay out.
- D2, how a workflow reaches the form (inbox #34, SR-056). The user types an absolute path to a pipeline directory that their own account can read. The engine is detected from the files:
main.nfmeans Nextflow;workflow/SnakefileorSnakefilemeans Snakemake. The schema comes fromnextflow_schema.json, or fromconfig.schema.yaml/workflow/schemas/config.schema.yaml. Without a schema, the types are inferred fromconfig/config.yaml, or the form falls back to key / value rows. There is no catalogue, noPOST /workflows, no Git URL and noPOST /runs:validatein P1. Aworkflowsrow (sourcelocal) and aworkflow_versionsrow are created at launch. (assumed — pending Thomas) - D3, what engine events may carry (inbox #55, option (a)).
nf-spectiosends noscriptorenv. The SDK event models are allow-lists (extra="ignore"), soscript,env,homeDir,commandLine,configTextand Snakemake'scmdare never stored, even if a plugin sends them. (assumed — pending Thomas) Note 2026-10-28: unchanged by Task 30. The command a run shows is the one the head script runs, built by Spectio, and Snakemake'sshellcmdis not stored, so a Snakemake job has no script to show (inbox #70). The trace's resource fields,workdirandnative_idare kept (Task 28, 29). - D4, Cancel (inbox #62). One Cancel action: a cancel marker
.spectio/cancelin the run directory, then an interrupt in the head's tmux pane (tmux send-keys -t <pane id> C-c), then SIGTERM to the head's process group if it is still alive after a 60 s grace period, then SIGKILL after another 60 s (schedulers §3.1.1, tmux launcher). On the local executor: SIGTERM to the process group, then SIGKILL. (assumed — pending Thomas) - Attempt state
aborted(inbox #56, option (a)). (assumed — pending Thomas) - Stages and the processes or rules list, both (decided — Thomas, 2026-10-28; log). The processes (Nextflow) or rules (Snakemake) list of Tasks 18 and 20 stays, in first-seen order, with task counts by state. Task 27 adds named stages: declared in a
spectio.yml(the workflow's own, or a lab overlay file, checking-scenarios §12.7), else detected (ui-spec §5.1.1), else none; display only, no gates; names in no stage havetasks.stage_idnull. State words, the header line, badges, the "Not in a stage" group, process names and the empty-state line: the ui-spec §4.4 note of 2026-10-28. The synthetic pipelines have no subworkflows, so they show no stages; the stage tests use the real-pipeline fixtures oftests/pipelines/real/stages/. Note 2026-10-28: this replaces "stages are the processes or rules; detection andrun_stagescome later". The naming of detected stages is (assumed — pending Thomas, inbox #68). - Live refresh over SSE (decided — Thomas, 2026-10-28; log). The UI opens one stream per browser,
GET /api/v1/events/stream(api §8.3), behind the session cookie; each frame names a run, and the UI refetches that run, or the first page of the list. The P1 subset sends one frame type,spectio.run.delta, and a keep-alive comment every 15 s. TheEventSourcecookie behaviour of §15.3 req. 3 is in scope and checked by the Playwright smoke (Task 20). Note 2026-10-28: this replaces the assumption "No SSE in 0.0.1, the UI polls every 5 s". - A session-free bootstrap page. §15.3 req. 2 asks for a session on every request, but something must serve the page that reads the ticket from the URL fragment. Four routes are served without a session cookie:
GET /auth/link(static HTML) andGET /auth/link.js, which carry no data and need no credential;POST /api/v1/auth/magic-links:exchange, which needs a valid one-time ticket; andPOST /api/v1/auth/links, which needs the server key (Assumption 9). (assumed — pending Thomas; a spec gap to record in §15.3) - The state file and
spectio login --print-link. The server creates~/.spectio/server.jsonin the shared home withO_EXCL(login node, port, PID, database directory, start time; mode 0600; no secret) and removes it at a clean stop;spectio serverefuses to start on another login node while it names a server there, unless given--force, which replaces it (dev-env §15.3).spectio login --print-linkreads it and the server key file in the node-local directory, checks that the listener on the recorded port belongs to the caller's uid (§15.3 req. 9), and callsPOST /api/v1/auth/linkswithAuthorization: Bearer <server key>and the loopbackOrigin. With--jsonit prints one line, a fixed sentinel then{"ticket": …}, forspectio open(§15.3 req. 8). Run on another login node, where the server's loopback cannot be reached, it checks the node name from the state file, then prints "The server runs on: run ssh <node> spectio login --print-link." and exits 2, with no link. (assumed — pending Thomas) - Port. The default is 8080 (architecture §7.3). When 8080 is taken (likely on a shared login node),
spectio initpicks a free port once and records it inspectio.toml, so an SSH config entry stays valid;spectio init --portchanges it. If that port is taken at start,spectio serverefuses and names the port and the uid that holds it; it never picks another (§15.3 req. 9). (assumed — pending Thomas) - Ingest and projection share one transaction on the writer thread, and the spool cursor (file, byte offset) is a SQLite row written in that same transaction, not the
.spectio/events/.cursorfile of architecture §13.1, which stays for a remote forwarder.runs.projected_seqtherefore always equalslast_seq. When the index is rebuilt (Assumption 21), the cursor starts again at 0 and event IDs make the replay idempotent. (assumed — pending Thomas) - Spool numbers (written into api §14 by Task 1): one JSON object per line; a line of at most 64 KiB; the writer rotates at 32 MiB; the reader ignores anything past 64 MiB in one file and counts it. (assumed — pending Thomas, inbox #28)
- One head script for both launchers.
<launch>/.spectio/head.shruns undersetsid bashon the local executor, and as the command of the tmux session's pane on the login node, in the pane's foreground and withoutsetsid(Task 10). On both, it starts with a cleared environment that keeps only an allow-list (HOME,USER,LOGNAME,SHELL,LANG,TMPDIR), then runs through the user's login shell (bash -l) so that their modules andPATHapply, as the--export=NONEhead job did before: nothing fromspectio serveor the tmux server (theSPECTIO_*settings, the server key path) reaches the engine. The security posture is unchanged. A trailer writes.spectio/head.exit(exit code, epoch), and the engine's output is copied to.spectio/head.outwhile the pane shows it. The script setsSPECTIO_RUN_ID,SPECTIO_LAUNCH_NandSPECTIO_SPOOL_DIR, and runs the engine command recorded atspectio init(Assumption 14). Parameters reach the engine as a file (-params-file,--configfile), never as shell words. (assumed — pending Thomas) - Engines and their settings.
spectio initrecords how to run each engine: thenextflowandsnakemakefound on the login shell'sPATH, or an explicit command (a full path, ormodule load … && nextflow), inspectio.toml[engines]. The task partition and account for the engines' SLURM executors are unset by default and configurable in[slurm]; the Nextflow head's heap isNXF_OPTS=-Xmx2gby default, configurable. The head watch (PID and exit file) runs every 5 s. (assumed — pending Thomas) - Head end state. While the PID check of schedulers §3.1.1 matches, the head is alive; after that,
head.exitgives its exit code, and a head gone with nohead.exit(killed, or a login-node reboot) exited unknown. A head that ended with no terminal engine event within 60 s makes the runfailed, classhead_lostorengine_fault(api §7.1). (assumed — pending Thomas, inbox #40) - Snakemake mapping (api §7.2 says [to be validated]).
job_infocreates the task (rule + wildcards) and an attempt;job_startedgivesrunning(Snakemake reports no separate submit);job_finishedgivessucceeded;job_errorgivesfailed. Ajob_startedon the samejobidafter a failure opens attempt n + 1 (T2). A Snakemake launch succeeds when the head exits 0 and noerrorrecord arrived. A Nextflow launch ends ononFlowComplete.success. (assumed — pending Thomas) - Bridge delivery.
nf-spectiois unpacked into$NXF_HOME/plugins/nf-spectio-<ver>/, as architecture §7.3 says, and enabled byNXF_PLUGINS_DEFAULT=nf-spectio@<ver>in the head script, not-plugins(inbox #69, decided 2026-10-29). The Snakemake logger is copied to<launch>/.spectio/pylib/and put onPYTHONPATH, so compute nodes read it from the run directory. (assumed — pending Thomas) - Minimal tenancy.
spectio initcreates one principal (the Unix user), one projectdefault, one environment (kindslurm, orlocalwith--local) and its runner row. There are no groups. Every tenant row keepsproject_id(mvp-0.0.1.md§4). (assumed — pending Thomas) - Where the tests run. The Playwright smoke runs against
spectio serveon the local executor on the CI runner. The sind end-to-end tests drive the HTTP API from inside the submitter node, as a browser behind a tunnel would. (assumed — pending Thomas) - Ultra-simple screens. The launch form has no stepper, no review step and no file checks. Logs are the head stdout and stderr and the engine run log, last 64 KiB; per-task logs come later. Sessions last until the server restarts. (assumed — pending Thomas) Reversed in part 2026-10-28 (Thomas: "Je veux également que ça soit possible de voir les logs globaux et les lots et stout err log sh et autres de chaque job si possible via workdir", log): each job's own files and its work directory are in 0.0.1 (Task 28), with the failure summary, Resume, results, the copy actions and resources (Tasks 29 to 32); the run logs keep the last 64 KiB.
- The database is an index in a node-local temporary directory (inbox #64, option (d)). At EMBL everything is shared between the login nodes except some temporary directories (Thomas, 2026-10-28). The data directory (
spectio.toml, the state file; no secret) stays in~/.spectio/; the database,serve.lockand the server key go in<node-local tmp>/spectio-<uid>/, 0700, by default$TMPDIRor/tmp, with themkdtempfallback of §15.3 when that path is squatted (which path is node-local on the EMBL login nodes is [to be validated]). Each run directory holds the truth: the manifest.spectio/run.json, the spool,head.pid,head.exit, the cancel marker and.spectio/launch-failed.json. At boot the server reconciles the run directories missing from the index, which is all of them when the database is new, under the checks of §15.3 req. 10 (dev-env §15.3 Where data lives). (assumed — pending Thomas, inbox #64) - Runs started from a terminal (Thomas, 2026-10-28, log). A bridge started without
SPECTIO_RUN_IDmakes its own run ID, writes the spool in<launch dir>/.spectio/events/, and writes a pointer~/.spectio/observed/<run ID>.jsononly if~/.spectio/exists (architecture §13.8). The runner scans the pointers under §15.3 req. 12; the run gets origincli-observedand is monitor only, with no Cancel (inbox #65). The bridges are released as dev-env §12 says, once Thomas has claimed the names (inbox #66); P1 builds and dry-runs the release packages only. (assumed — pending Thomas)
Global Constraints¶
- Python 3.11–3.14 supported, tested on 3.13 (
PYTHON_VERSIONindev/versions.env) (architecture §7.3). - SQLite loaded by Python ≥ 3.51.3, or ≥ 3.50.7 / ≥ 3.44.6 in those series; checked at start (api §7).
- Nextflow ≥ 25.04 (tested 26.10.4, 25.10.9, 25.04.8); Snakemake ≥ 9.0 with
snakemake-interface-logger-plugins>=1.2,<3(tested 9.27.1) (architecture §13.4). - Package layout:
packages/spectio-sdk,packages/spectio,packages/spectio-server,engine-bridges/nf-spectio,engine-bridges/snakemake-logger-plugin-spectio,web/(architecture §6). The runner never imports server code (import-linter);spectiohas no FastAPI and no SQLAlchemy. - IDs are ULID text with a type prefix (
run_,tsk_,att_,evt_…); times are UTC; JSON is stored inTEXT(api §7). - Run states: the full enum in the
CHECKconstraint; 0.0.1 usesqueued,running,cancelling,succeeded,failed,cancelled(api §7.1). Attempt states:pending,submitted,running,succeeded,failed,cached,stored,aborted; states only move forward (api §7.2). - Head: a tmux session
spectio-<run_id>on the server's login node, neversbatchin 0.0.1; the server's own session isspectio-server; session names carry the run ID only (schedulers §3.1.1, decision log, 2026-10-28, head in tmux). - Server: bound to
127.0.0.1only; API under/api/v1; errors are RFC 9457 problem details (api §4.1). - Secrets: ≥ 256 bits from
secrets, compared withhmac.compare_digest; ticket valid 15 min, single use, in the URL fragment (dev-env §15.3). - UI copy and labels: English; state words from ui-spec §3.1; text and tables only, no chart, DAG or timeline (log 2026-10-28).
- Every
featmerged intomainduring 0.0.x carries the footerRelease-As: 0.0.1(dev-env §15.2). - Every external fact added to a spec has a source, or is marked [to be validated] (
CLAUDE.md).
Review Focus¶
The five inputs most likely to hurt a test user that no other test covers. Each has its test in the task named.
- Parameter values or a pipeline path with spaces, quotes,
$(…)or newlines. They must never be interpreted by a shell: the values go into a params file, and paths are quoted withshlex.quote. Test: Task 9,test_head_script_shell_metacharacters. - A spool line that is only partly written (the engine is mid-append, or NFS shows a short file). It is not consumed and is read whole on the next tick. Test: Task 3,
test_partial_last_line_waits. - The head cannot start (tmux missing, a session of that name already exists, the engine exits at once). The run ends
failed, classlaunch_error, with the start of tmux's or the engine's stderr as its message, and nothing staysqueued. Test: Task 10,test_tmux_start_failure_fails_run. - The engine is missing, or older than the floor, at launch. The run ends
failed, classengine_missingorengine_version, with a message naming the binary and the version found. Test: Task 9,test_engine_missing_and_too_old. - A link opened twice, after 15 minutes, or after a server restart. The bootstrap page says "This link was already used or has expired. Run
spectio login --print-linkon the cluster for a new one.", not a blank page or raw JSON. Tests: Task 12,test_reused_or_expired_link_message, and the browser check in Task 20.
Milestones¶
| Milestone | Tasks | Ends with |
|---|---|---|
| M1 · Events in | 1–7 | T2 fixtures and real local runs of both engines replay into SQLite with the right states |
| M2 · Heads | 8–10 | heads launched, watched, adopted and cancelled, locally and in tmux |
| M3 · Served safely | 11–16 | spectio serve in its tmux session, with the state file, the token link and §15.3 requirements 1–5 and 7 (6 is Task 3) |
| M4 · API and screens | 17–21 | the API of the four screens, the UI, and a wheel that carries the UI and both bridges |
| M5 · On the cluster | 22–24 | both engines end to end on slurm-mini in CI, with cancel and restart; the SSH config entry and spectio open |
| M6 · Runs from a terminal | 25–26 | a run started by hand with the bridge enabled shows in the runs list, monitor only; both bridges build as release packages, dry-run only |
| M7 · Stages | 27 | the run detail shows named stages, declared or detected, above the processes or rules list, on the four example pipelines |
| M8 · Job files and run tools | 28–32 | each job's files and work directory, the failure summary with the SLURM state, resources per job, Resume and the copy actions, results and reports in a sandbox |
Commands assume the repository root, and that the uv workspace exists (after Task 1).
M1 · Events in¶
Task 1: Workspace and the spool event contract¶
Spec: api §14 (new: the driven-run part, inbox #36 (a)), dev-env §15.3 req. 6, architecture §13.1 · Depends on: none · R10 §3a: rows 1, 4 (C2: first task)
Files:
- Create:
pyproject.toml(uv workspace, memberspackages/*; ruff, mypy, pytest, import-linter config),packages/spectio-sdk/pyproject.toml,packages/spectio-sdk/src/spectio_sdk/events.py,packages/spectio/pyproject.toml,packages/spectio-server/pyproject.toml(empty packages with__init__.py) - Create:
tests/fixtures/engine-events/convert_t2.py, and its outputtests/fixtures/engine-events/*.spool.jsonl(one per T2 fixture inspikes/engine-events/fixtures/) - Modify:
docs/specs/api-and-data-model.md(new §14, driven-run part only),docs/decisions/log.md(dated entry),.github/workflows/ci.yml(jobproduct:uv sync, ruff, mypy,lint-imports, pytest; added toci-okneeds) - Test:
packages/spectio-sdk/tests/test_spool_events.py
Interfaces:
-
Produces:
SpoolEvent(Pydantic,extra="forbid"on the envelope):v: Literal[1],id: str(≤ 64 chars),run: str(run_+ ULID),launch: int ≥ 1,producer: str(nf-spectio/<ver>orsnakemake-logger-spectio/<ver>),seq: int ≥ 1,time: datetime,type: str(nextflow.<hook>orsnakemake.<event>),data: dict.parse_data(event) -> BaseModelreturns the allow-listed model of the type (extra="ignore"), or raisesUnknownEventType.MAX_LINE_BYTES = 65536,MAX_FILE_BYTES = 67108864,ROTATE_BYTES = 33554432. -
Step 1: Write the failing tests
def test_t2_fixtures_validate(): # every converted line of all 15 fixtures parses
for line in fixture_lines("*.spool.jsonl"): parse_data(SpoolEvent.model_validate_json(line))
def test_denylisted_fields_never_survive(): # D3: allow-list models drop them
ev = make_event("nextflow.onTaskComplete", script="rm -rf", env={"K": "v"}, commandLine="x")
assert not {"script", "env", "commandLine"} & parse_data(ev).model_dump().keys()
def test_envelope_rejects_unknown_keys_and_bad_run_id(): ... # extra key, "run": "../x" → ValidationError
- Step 2:
uv run pytest packages/spectio-sdk/tests/test_spool_events.py -v: FAIL (module missing). - Step 3: Write the spec, then the code. First write api §14, "Driven runs (0.0.1)": envelope, file naming
.spectio/events/<producer>-<launch>-<nnnn>.jsonl, the caps above, the per-type allow-lists, and "dropped and counted, never executed". Then writeevents.pyfrom it, andconvert_t2.py, which maps capture lines (body.hook,body.seq) and SnakemakeLogRecordlines into envelopes, keeping the<launch>placeholders. Add the log entry "api §14 driven-run part written (inbox #36 a)". - Step 4: the same command PASSES;
uvx --with mkdocs-material mkdocs build --strictis clean. - Step 5: Commit
feat(sdk): spool event contract (api §14, driven runs).
Task 2: SQLite store and its discipline¶
Spec: api §7 (tables, SQLite version rule), architecture §7.2 (one writer, network file system refusal, the dated note on the rebuildable index) · Depends on: 1 · R10 §3a: row 11
Files:
- Create:
packages/spectio-server/src/spectio_server/store/{db.py,schema.py,writer.py},packages/spectio-server/src/spectio_server/migrations/(Alembic, one initial revision) - Test:
packages/spectio-server/tests/test_store.py
Interfaces:
-
Produces: tables
principals,projects,environments,runners,runner_commands,workflows,workflow_versions,runs,run_inputs,launches,tasks,attempts,events,spool_cursors(launch_id, file, offset),spool_drops(launch_id, reason, count).default_db_dir() -> Path($TMPDIRor/tmp, thenspectio-<uid>;os.mkdir0700; an existing path is accepted only if, opened withO_DIRECTORY|O_NOFOLLOW, it is the same directory, owned by the user, with no group or other bits; otherwisetempfile.mkdtemp(prefix=f"spectio-{uid}-")with a warning naming the squatter's uid; never tightened).open_db(db_dir: Path) -> tuple[Engine, bool](the flag is true when the database was created, so boot rebuilds it, Task 11; WAL,busy_timeout=5000,auto_vacuum=INCREMENTALat creation; raisesUnsafeDatabaseon a network file system or an old SQLite).Writer.submit(fn: Callable[[Connection], T]) -> Future[T]: one thread, one connection,BEGIN IMMEDIATE.fs_type(path, mounts=Path("/proc/self/mounts")) -> str. -
Step 1: Write the failing tests
def test_refuses_network_fs(tmp_path): # fake mounts table: tmp_path on "nfs4", then "lustre", "gpfs"
with pytest.raises(UnsafeDatabase, match="nfs4"): open_db(tmp_path, mounts=fake_mounts(tmp_path, "nfs4"))
def test_refuses_old_sqlite(monkeypatch): ... # sqlite_version "3.45.0" → UnsafeDatabase naming 3.51.3
def test_pragmas(tmp_path): # journal_mode == "wal", auto_vacuum == 2
def test_writer_serialises(tmp_path): # 50 concurrent submits → 50 rows, no SQLITE_BUSY
def test_run_state_check_constraint(tmp_path): # inserting state "paused_forever" raises IntegrityError
def test_default_db_dir_private(tmp_path, monkeypatch): # TMPDIR=tmp_path → tmp_path/spectio-<uid>, mode 0700
def test_db_dir_squatted_falls_back(tmp_path, monkeypatch): # a symlink there, a dir of another uid, or mode 0755 →
# mkdtemp fallback, warning names the uid, the path untouched
def test_open_db_reports_created(tmp_path): # first open → created True; second open → False
- Step 2:
uv run pytest packages/spectio-server/tests/test_store.py -v: FAIL. - Step 3: implement as in the interfaces. The file system type comes from the longest mount prefix in
/proc/self/mounts; the refused types arenfs,nfs4,lustre,gpfs,beegfs,cifs,smb3,ceph,fuse.sshfs. The refusal message names the type and the alternatives of architecture §7.2. - Step 4: the same command PASSES.
- Step 5: Commit
feat(server): SQLite store with one writer.
Task 3: Spool reader (§15.3 req. 6)¶
Spec: dev-env §15.3 req. 6, api §14 (Task 1), architecture §13.1 · Depends on: 1 · R10 §3a: rows 1, 2 (req. 6), 10
Files:
- Create:
packages/spectio/src/spectio/runner/{spool.py,types.py}(SpoolBatchlives intypes.py) - Test:
packages/spectio/tests/test_spool_reader.py
Interfaces:
- Consumes:
SpoolEvent,parse_data, the caps (Task 1). -
Produces:
read_spool(launch_dir: Path, run_id: str, launch_n: int, cursor: dict[str, int], max_lines: int = 5000) -> SpoolBatch, whereSpoolBatch(events: list[SpoolEvent], cursor: dict[str, int], drops: Counter[str]). Pure: no database access; the caller storescursoranddropsin the same transaction as the events. -
Step 1: Write the failing tests (all named
test_req6_*or after the Review Focus line)
def test_req6_invalid_line_dropped_and_counted(tmp_path): # "not json", wrong schema → drops["invalid"] == 2
def test_req6_oversized_line_dropped(tmp_path): # 65537-byte line → drops["line_too_large"] == 1, next line read
def test_req6_file_cap(tmp_path): # bytes past MAX_FILE_BYTES ignored, drops["file_too_large"] == 1
def test_req6_other_run_id_dropped(tmp_path): # line with run != run_id → drops["wrong_run"]
def test_partial_last_line_waits(tmp_path): # no trailing "\n": not returned, cursor before it;
# after the append completes it is returned once
def test_symlinked_spool_file_refused(tmp_path): # events/x.jsonl -> /etc/passwd: skipped, drops["symlink"]
def test_cursor_resumes_without_duplicates(tmp_path): ... # two reads across a rotation → each id once
- Step 2:
uv run pytest packages/spectio/tests/test_spool_reader.py -v: FAIL. - Step 3: implement
read_spool. Open each file withos.open(..., O_RDONLY | O_NOFOLLOW)after checking that its real path is insidelaunch_dir/.spectio/events/. Read from the cursor offset; split on\nonly; neverevalor execute anything; iterate files in name order. - Step 4: the same command PASSES.
- Step 5: Commit
feat(runner): spool reader with caps and confinement.
Task 4: Projector, run state and Nextflow mapping¶
Spec: api §7.1 (run transitions, the 0.0.1 subset), §7.2 (Nextflow mapping, aborted), §8.2 (lattice, double onFlowComplete), synthesis §2ter (logical task key: process + tag) · Depends on: 2, 3 · R10 §3a: rows 1, 4, 7
Files:
- Create:
packages/spectio-server/src/spectio_server/projector/{ingest.py,runs.py,nextflow.py} - Modify:
packages/spectio/src/spectio/runner/types.py(Task 3; addHeadReportandFailurehere, on the runner side, so the runner never imports the server) - Test:
packages/spectio-server/tests/test_projector_nextflow.py
Interfaces:
- Consumes:
SpoolBatch(Task 3),Writer(Task 2). -
Produces:
ingest(conn, launch_id: str, batch: SpoolBatch) -> None. It insertsevents(seqfromruns.last_seq,unique(run_id, event_id), duplicates ignored), projects them, and stores the cursor and the drop counts, in one transaction. Alsoapply_head_report(conn, launch_id: str, report: HeadReport) -> None(used by Tasks 9 and 10),Failure(class_: str, message: str, task_id: str | None)andHeadReport(state: str, native_id: str | None, node: str | None, exit_code: int | None, ended: bool, failure: Failure | None). Run summary columns:tasks_total,tasks_expected(nullable),counts(JSON state → n). -
Step 1: Write the failing tests (replay the converted T2 fixtures)
def test_flaky_26_10_4(): # 65 tasks all succeeded; 67 attempts, 2 failed; 2 tasks with attempts > 1; run succeeded
def test_toolerr_26_10_4(): # run failed; failure.message == "synthetic failure: tool exited with code 1";
# COMPARE treatA attempt failed; FINAL_REPORT attempt aborted, not counted as failed
def test_toolerr_25_10_9_double_flow_complete(): # one transition to failed, one run.state_changed event
def test_resume_26_10_4(): # 63 tasks cached: tests the `cached` state mapping only (resume is not in 0.0.1)
def test_forbidden_fields_never_stored(): # a line carrying script, env, commandLine, homeDir, configText, cmd →
# none of them in the stored events row (D3, inbox #55)
def test_replay_is_idempotent(): # ingest the same batch twice → same counts, same last_seq
def test_terminal_state_never_moves_back(): # succeeded, then a late onTaskStart → still succeeded, the event is logged
- Step 2:
uv run pytest packages/spectio-server/tests/test_projector_nextflow.py -v: FAIL. - Step 3: implement the transitions of api §7.1 that 0.0.1 uses:
queued → running(head RUNNING or first event);→ succeeded / failed(NextflowonFlowComplete.success);queued → failed(launch report);running → failed(head_lostorengine_fault, Assumption 15);→ cancelling → cancelled; a run incancellingendscancelledwhatever the engine reports. When a launch ends, every open attempt becomesaborted.failure.classistool_errorwhen an attempt haserrorActionTERMINATE,engine_faultotherwise. - Step 4: the same command PASSES.
- Step 5: Commit
feat(server): projector with run states and the Nextflow mapping.
Task 5: Snakemake mapping¶
Spec: api §7.2 (Snakemake row, [to be validated]), synthesis §2ter (rule + wildcards), T2 results · Depends on: 4
Files:
- Create:
packages/spectio-server/src/spectio_server/projector/snakemake.py - Test:
packages/spectio-server/tests/test_projector_snakemake.py
Interfaces:
- Consumes:
ingest,apply_head_report(Task 4). -
Produces: the Snakemake branch of
ingest;tasks_expectedfrom the lastprogress.total. -
Step 1: Write the failing tests
def test_checkpoint_flaky_retry(): # no attempt left open; 2 failed attempts, each followed by a succeeded one
# on the same task; tasks_expected == 67 (last progress.total)
def test_tool_error(): # head exit 1 → run failed, class tool_error, message from the last error record
def test_success_needs_head_exit_zero(): # all jobs finished, no head report yet → still running; exit 0 → succeeded
def test_untyped_records_ignored(): ... # the 122 records without "event" change no state
- Step 2:
uv run pytest packages/spectio-server/tests/test_projector_snakemake.py -v: FAIL. - Step 3: implement Assumption 16. Wildcards are sorted
k=vpairs joined with,, so the tag matches the golden files. - Step 4: the same command PASSES; Task 4's tests still pass.
- Step 5: Commit
feat(server): Snakemake event mapping.
Task 6: nf-spectio writes the spool¶
Spec: architecture §13.1, §13.3, §13.4, §7.3 (spool, never throw, version floor, jar delivery), api §14 · Depends on: 1 · R10 §3a: rows 4, 5
Files:
- Create:
engine-bridges/nf-spectio/(fromspikes/engine-events/nextflow/nf-spectio/on branchspike/engine-events:build.sh,src/spectio/SpectioPlugin.groovy; the observer appends envelopes instead of posting),engine-bridges/nf-spectio/tests/run_local.sh - Test:
packages/spectio/tests/test_bridge_nextflow.py(markedengines; runs inpipelines-synthetic.yml)
Interfaces:
- Consumes:
SPECTIO_RUN_ID,SPECTIO_LAUNCH_N,SPECTIO_SPOOL_DIR; envelope of Task 1. -
Produces:
build.sh <out-dir>writes the unpacked plugin layoutnf-spectio-0.0.1/. Hooks only enqueue, and one writer thread appends lines and rotates atROTATE_BYTES. Noscriptorenvis sent (Assumption 3). -
Step 1: Write the failing tests
def test_happy_local_spool_validates(nf_run): # synthetic happy, local executor, Nextflow 26.10.4 → every line
# passes SpoolEvent; replayed through ingest: run succeeded, 65 tasks
def test_no_script_or_env_keys(nf_run): ... # raw lines contain neither '"script"' nor '"env"'
def test_unwritable_spool_never_fails_run(tmp_path): # spool dir mode 0500 → Nextflow exits 0
- Step 2:
uv run pytest packages/spectio/tests/test_bridge_nextflow.py -v -m engines: FAIL. - Step 3: port the observer and catch every
Throwablein the writer, logging to.spectio/bridge.err. Put the 25.04 floor in the plugin'srequires. - Step 4: the same command PASSES on 26.10.4 and 25.10.9 (
NXF_VERmatrix inpipelines-synthetic.yml). - Step 5: Commit
feat(bridges): nf-spectio writes the event spool.
Task 7: Snakemake logger plugin writes the spool¶
Spec: architecture §13.1, §13.4, §7.3 (PYTHONPATH delivery), dev-env §6 (the interface's own suite) · Depends on: 1 · R10 §3a: row 5
Files:
- Create:
engine-bridges/snakemake-logger-plugin-spectio/snakemake_logger_plugin_spectio/__init__.py(from the T2 spike; stdlib and the interface only),engine-bridges/snakemake-logger-plugin-spectio/pyproject.toml - Test:
engine-bridges/snakemake-logger-plugin-spectio/tests/test_plugin.py
Interfaces:
-
Produces: a
LogHandlersubclass ofLogHandlerBase, with a settings class namedLogHandlerSettingsandwrites_to_file = False(T2:baseFilenamecrash). It convertsNamedlistresources to dicts and writes envelopessnakemake.<event>to$SPECTIO_SPOOL_DIR. -
Step 1: Write the failing tests
def test_interface_suite(): # snakemake_interface_logger_plugins.tests against this plugin,
# on interface 2.1.0 and the lowest 1.x of the range
def test_happy_local_spool_validates(smk_run): # Snakemake 9.27.1 synthetic happy via PYTHONPATH, no install
def test_logger_added_not_replacing(smk_run): # Snakemake's own log file still written
def test_unwritable_spool_never_fails_run(tmp_path): ...
- Step 2:
uv run --directory engine-bridges/snakemake-logger-plugin-spectio pytest -v: FAIL. - Step 3: port and harden as above.
- Step 4: the same command PASSES.
- Step 5: Commit
feat(bridges): Snakemake logger plugin writes the event spool.
M2 · Heads¶
Task 8: spectio init and spectio doctor¶
Spec: architecture §7.3 (data directory, configuration layers, spectio doctor, 0.0.1 prerequisites), §7.2 (runs root), api §6 (the one environment record) · Depends on: 2 · R10 §3a: rows 5, 8
Files:
- Create:
packages/spectio/src/spectio/cli/{main.py,init.py,doctor.py},packages/spectio/src/spectio/config.py(pydantic-settings:spectio.toml, thenSPECTIO_*, then flags) - Test:
packages/spectio/tests/test_init.py,packages/spectio/tests/test_doctor.py
Interfaces:
-
Produces:
spectio init [--data-dir PATH] [--db-dir PATH] [--runs-root PATH] [--port N] [--local] [--partition P] [--account A] [--nextflow CMD] [--snakemake CMD], every option with a default so thatspectio opencan run it unattended on first use (data directory~/.spectio/, database indefault_db_dir(), Assumption 21; runs root~/spectio-runs/in the shared home); it resolves the engines through the login shell (Assumption 14), picks the port (Assumption 10), writesspectio.tomland creates the rows of Assumption 18 through the server store (the CLI importsspectio_serverlazily;spectioalone says "install spectio[server]");Settingswithdata_dir,db_dir,runs_root,port=8080,slurm: SlurmOpts(partition: str | None, account: str | None)(for the engines' task jobs),engines.nextflow_cmd,engines.snakemake_cmd,engines.nxf_opts="-Xmx2g",engines.extra_args,runner.head_watch_s=5,runner.spool_s=2;spectio doctor, which printsok|warn|fail <check> <detail>lines and exits 1 on anyfail.
-
Step 1: Write the failing tests
def test_init_creates_one_environment(tmp_path): # one project "default", one environment kind slurm with
# config.runs_root, one runner row; second init → no duplicate
def test_runs_root_must_exist_and_be_absolute(tmp_path): ...
def test_init_records_engine_commands(fakebin): # nextflow on the login shell's PATH → its full path recorded;
# --nextflow "module load X && nextflow" → kept as given
def test_doctor_reports_engines(fakebin): # "ok nextflow 26.10.4 /…/nextflow", "fail snakemake not found on PATH"
def test_doctor_reports_fs_and_sqlite(tmp_path): ... # db dir on nfs4 (fake mounts) → fail; SQLite version line
def test_doctor_nxf_plugins_default(monkeypatch): ... # unset → "ok" with the one-shot line for terminal runs; "false",
# a list without nf-spectio, nf-spectio@<other ver>, or a plugins block naming
# nf-spectio in $NXF_HOME/config → warn; nf-spectio@0.0.1 with its plugin
# directory missing → fail (the check of architecture §13.8, inbox #69)
def test_init_defaults_unattended(tmp_path, monkeypatch): # no flags, stdin closed → no prompt; data dir, db dir
# and runs root take their defaults; spectio.toml records them
- Step 2:
uv run pytest packages/spectio/tests/test_init.py packages/spectio/tests/test_doctor.py -v: FAIL. - Step 3: implement;
doctoralso checkstmuxandsbatchon PATH (the engines submit with it), Java's major version, and the hostname pin recorded at init.doctorruns the network-file-system check and the 0700 check on the database directory, not on the data directory, which is in the shared home (Assumption 21); it also warns when the database directory is missing, since the next start rebuilds it. - Step 4: the same command PASSES.
- Step 5: Commit
feat(cli): spectio init and spectio doctor.
Task 9: Head script, local launcher and the runner loop¶
Spec: schedulers §3.1.1 (detach, PID and exit files, adopt), architecture §7.1 (in-process runner consumes runner_commands), api §7 (launches, runner_commands) · Depends on: 4, 6, 7, 8 · R10 §3a: row 5 (engine checks)
Files:
- Create:
packages/spectio/src/spectio/runner/{headscript.py,engines.py,local.py,loop.py} - Modify:
packages/spectio/src/spectio/cli/init.py(call the unpack) - Test:
packages/spectio/tests/test_headscript.py,packages/spectio/tests/test_local_launcher.py,packages/spectio/tests/test_init.py::test_init_unpacks_nf_spectio
Interfaces:
- Consumes:
HeadReport,Failure(Task 4);Settings,SlurmOpts(Task 8). -
Produces:
render_head_script(spec: LaunchSpec) -> str, whereLaunchSpec(run_id, launch_n, engine, engine_cmd: str, location: Path, launch_dir: Path, work_dir: Path, profiles: list[str], params: dict, slurm: SlurmOpts | None, env: dict[str, str]);check_engine(engine) -> EngineInfo(path, version), raisingEngineMissingorEngineTooOld;- the
Launcherprotocol:submit(spec) -> str(native id),poll(ids) -> dict[str, HeadReport],cancel(native_id, grace_s: int = 60) -> None,adopt(launch) -> HeadReport; write_run_manifest(run_dir: Path, manifest: RunManifest) -> None:.spectio/run.json(run ID, name, workflow, parameters, profiles, creation time), written atomically when the run is created (called by Task 17), the file the index is rebuilt from (Assumption 21); the runner writes.spectio/cancelbefore a cancel interrupt and.spectio/launch-failed.jsonwhen a launch fails;LocalLauncher(setsid bash head.sh;.spectio/head.pidholds PID, process group, leader start time, boot ID and host name, checked again before every signal);RunnerSink(a protocol inspectio/runner/types.py, implemented by the server through itsWriter, so the runner never imports the server):lease_commands() -> list[Command],ack(command_id: str, ok: bool, detail: str | None),active_launches() -> list[LaunchRef],cursor(launch_id) -> dict[str, int],ingest(launch_id, batch: SpoolBatch),head_report(launch_id, report: HeadReport);RunnerLoop(sink: RunnerSink, launcher: Launcher, tick_s: float = 2), which leasesrunner_commands(launch,cancel), reads spools (Task 3) and polls heads.
-
Step 1: Write the failing tests
def test_head_script_shell_metacharacters(tmp_path): # location "/p/a b'$(x)", param "x\n$(rm -rf ~)" →
# `bash -n` OK; the value only in params.json; no "$(rm" in head.sh
def test_head_script_nextflow_args(): # "-params-file", "-c .spectio/spectio.config", export NXF_PLUGINS_DEFAULT=nf-spectio@0.0.1,
# no "-plugins"; .spectio/spectio.config contains no "plugins" (inbox #69)
def test_nfcore_style_pins_kept(nf_run): ... # engines: a pipeline declaring nf-schema@2.7.2 → the run resolves both
# nf-schema@2.7.2 and nf-spectio@0.0.1 (FC-318; no registry release of nf-spectio)
def test_init_unpacks_nf_spectio(tmp_path, monkeypatch): ... # in test_init.py: NXF_PLUGINS_DIR unset, NXF_HOME=tmp → spectio init
# leaves $NXF_HOME/plugins/nf-spectio-0.0.1/; second init → same directory, unchanged
def test_head_script_snakemake_args(): # "--logger spectio", "--configfile", PYTHONPATH=<launch>/.spectio/pylib
def test_engine_missing_and_too_old(fakebin): # no nextflow → class engine_missing; "24.10.0" → engine_version,
# message names the path and the version
def test_local_launch_exit_file(tmp_path): # trivial engine stub exits 3 → head.exit "3 …", HeadReport(exit_code=3)
def test_adopt_after_restart(tmp_path): ... # new LocalLauncher sees the live PID (same start time and boot id) → adopted
def test_run_manifest_written(tmp_path): ... # run.json round-trips to RunManifest; mode 0600
def test_cancel_and_launch_failure_markers(tmp_path): ... # cancel → .spectio/cancel before the signal; missing
# engine → launch-failed.json with class engine_missing
def test_cancel_never_signals_other_boot_id(tmp_path): ... # head.pid with another boot ID or start time → no signal
def test_head_env_cleared(tmp_path, monkeypatch): ... # SPECTIO_SERVER_X=1 in the runner's env → absent from the
# stub engine's env dump; HOME and the login PATH present
- Step 2:
uv run pytest packages/spectio/tests/test_headscript.py packages/spectio/tests/test_local_launcher.py packages/spectio/tests/test_init.py::test_init_unpacks_nf_spectio -v: FAIL. - Step 3: implement Assumptions 13 and 17. Bridges come from
bridge_dir() -> Path(importlib.resourcesin an installed wheel, Task 21;$SPECTIO_BRIDGES_DIRpointing at the Task 6 and 7 build output in a checkout). Nextflow: write.spectio/spectio.config(process.executor = 'slurm'andqueueon a SLURM environment) and unpacknf-spectiointo$NXF_HOME/plugins; the head script exportsNXF_PLUGINS_DEFAULT=nf-spectio@<ver>, never passes-plugins(0.0.1 has nok8sexecutor, so thenf-k8saddition of §13.8 is not built here);spectio init(Task 8) calls the same idempotent unpack, so a terminal run finds the copy (architecture §13.8, The plugin copy) (architecture §13.8, inbox #69 decided 2026-10-29). Snakemake: pass--executor slurmon SLURM, plus--workflow-profile <location>/profiles/<p>when a profile is given, plus the environment'sextra_args. Run directories are<runs_root>/<run_id>/{launch,work}. - Step 4: the same command PASSES.
- Step 5: Commit
feat(runner): head script, local launcher and runner loop.
Task 10: tmux launcher on the login node¶
Spec: schedulers §3.1.1 (detach, PID and exit files, adopt; tmux launcher), dev-env §15.3 (Launch, a person can take the head over), decision log, 2026-10-28 · Depends on: 9 · R10 §3a: rows 6, 12 (adoption)
Files:
- Create:
packages/spectio/src/spectio/runner/tmux.py,packages/spectio/tests/fakebin/tmux(a fake that keeps its sessions in$FAKE_TMUX_DIRand runs the command detached, for unit tests) - Test:
packages/spectio/tests/test_tmux_launcher.py(unit, with the fake),packages/spectio/tests/test_tmux_real.py(markedtmux: runs only where a realtmuxis on PATH, as on the CI runner and the sind submitter)
Interfaces:
- Consumes: the
Launcherprotocol, the head script and the PID file ofLocalLauncher(Task 9),HeadReport(Task 4). -
Produces:
TmuxLauncher(tmux: str = "tmux"), which implementsLauncher:submitrunstmux new-session -d -P -F '#{pane_id}' -s spectio-<run_id> -c <launch dir> bash <absolute head.sh>(separate arguments) and returns the session name as the native id, recording the pane ID; every later call targets=spectio-<run_id>andsend-keystargets the pane ID (§15.3 req. 11); only the tmux server is detached: the head runs in the pane's foreground process group, withoutsetsid, so a Ctrl-C typed in the pane, or sent bysend-keys, reaches the engine (schedulers §3.1.1);polluses the PID check andhead.exit;adoptmatches the session name and the PID check;cancelsendsC-cto the pane, then SIGTERM and SIGKILL to the head's process group (Assumption 4). -
Step 1: Write the failing tests
def test_submit_creates_named_session(fake_tmux): # argv "new-session -d -s spectio-<run_id>"; no sbatch, no setsid
def test_tmux_start_failure_fails_run(fake_tmux): # tmux missing, or session exists → class launch_error,
# message starts with tmux's stderr; run not queued
def test_adopt_by_session_and_pid(fake_tmux): # new launcher, session and PID check match → adopted, no second start
def test_head_stopped_by_hand(fake_tmux): # the head exits 130 (Ctrl-C by a person) → HeadReport(exit_code=130)
def test_cancel_interrupt_then_term(fake_tmux): ... # send-keys C-c; still alive after grace → SIGTERM, then SIGKILL
def test_targets_exact_and_pane_id(fake_tmux): ... # every -t is "=spectio-<run_id>" or the recorded pane ID
def test_argv_never_single_string(fake_tmux): ... # every tmux call is an argument list; no shell=True
def test_real_tmux_roundtrip(tmp_path): ... # @pytest.mark.tmux: a stub engine in a real session; attach-able
# session listed by `tmux ls`; the head's process group is the pane's
# foreground group; send-keys C-c stops it; exit code from head.exit
- Step 2:
uv run pytest packages/spectio/tests/test_tmux_launcher.py -v: FAIL. - Step 3: implement Assumptions 4, 13 and 15.
- Step 4: the same command PASSES, and
uv run pytest -m tmux packages/spectio/tests/test_tmux_real.py -vPASSES where tmux exists. - Step 5: Commit
feat(runner): tmux launcher on the login node.
M3 · Served safely¶
Each task below carries one §15.3 security requirement; its tests are named test_req<N>_* in packages/spectio-server/tests/security/. Requirement 6 is Task 3. conftest.py (Task 12) provides app, client (no session) and authed_client (session cookie, Host: 127.0.0.1:8080, matching Origin).
Task 11: spectio serve, bind (§15.3 req. 1) and boot¶
Spec: dev-env §15.3 req. 1 and "Server restart", architecture §7.1, §7.2 (boot order, one process) · Depends on: 3, 8, 9, 10 · R10 §3a: rows 2, 6, 12
Files:
- Create:
packages/spectio-server/src/spectio_server/{app.py,serve.py,rebuild.py},packages/spectio/src/spectio/cli/serve.py - Test:
packages/spectio-server/tests/security/test_req1_bind.py,packages/spectio-server/tests/test_serve.py
Interfaces:
- Consumes:
open_db,Writer(Task 2),RunnerLoop,RunnerSinkand the launchers (Tasks 9 and 10). - Produces:
create_app(settings) -> FastAPIandserve(settings) -> None. Boot takes<db-dir>/serve.lock(flock, on the node-local directory), opens the database and, when it was just created, rebuilds it from the run directories under the runs root (rebuild_index(writer, runs_root) -> int, inspectio_server/rebuild.py; Assumption 21), then adopts the heads, replays the spools, starts the runner loop, and starts uvicorn on127.0.0.1on the recorded port only (Assumption 10). At every boot it also reconciles run directories missing from the index, and marks the rebuild complete in its last transaction. It creates the state file~/.spectio/server.jsonwithO_EXCL(Assumption 9) and the server key in the node-local directory and prints the start text below.spectio serve --detachstarts the same process in the tmux sessionspectio-serverand prints the same text in the calling terminal, never inside the pane.
Spectio 0.0.1 is running on <hostname>, port <port>.
1. On your laptop, open a tunnel: ssh -N -L 127.0.0.1:<port>:127.0.0.1:<port> <user>@<hostname>
2. Then open this link (single use, valid 15 minutes):
http://127.0.0.1:<port>/auth/link#<ticket>
New link at any time: spectio login --print-link
- Step 1: Write the failing tests
def test_req1_binds_loopback_only(settings): # the listening socket's address is 127.0.0.1
def test_req1_refuses_other_bind(settings): # host="0.0.0.0" in spectio.toml or SPECTIO_SERVER_HOST → exit 2,
# message "0.0.1 binds to 127.0.0.1 only"
def test_second_serve_refused(settings): # serve.lock held → exit 2 naming the pid
def test_state_file_names_other_node(settings): ... # server.json names a server on login2 → exit 2 naming login2;
# --force → starts and rewrites server.json
def test_detach_starts_tmux_session(fake_tmux): ... # --detach → session spectio-server, link printed
def test_port_taken_refuses(settings): ... # recorded port busy → exit 2 naming the port and the owning uid
def test_rebuild_from_run_dirs(settings, replayed_runs): ... # two finished runs, one live head, one cancelled run and
# one failed launch; delete the database; boot → five runs
# from run.json, counts equal the golden, the live head adopted,
# cancelled and failed (launch_error) kept, no duplicate event_id
def test_boot_reconciles_missing_runs(settings): ... # a run dir absent from an existing index → added at boot;
# a rebuild interrupted before its completion mark → redone
def test_rebuild_skips_symlinked_foreign_or_loose_dirs(settings): ... # symlink, other uid, 0755, bad name → skipped
def test_rebuild_run_id_must_match_dir(settings): ... # run.json run_id ≠ directory name → skipped, counted
def test_rebuild_ignores_manifest_session_and_paths(settings): ... # extra keys → refused (extra=forbid); session
# name and paths derived from the run ID
def test_rebuild_caps(settings): ... # more dirs or bytes than the per-boot caps → the rest next boot
def test_runs_root_loose_refused(settings): ... # runs root group-writable or another owner → exit 2
- Step 2:
uv run pytest packages/spectio-server/tests/security/test_req1_bind.py packages/spectio-server/tests/test_serve.py -v: FAIL. - Step 3: implement. There is no
--hostflag; any other configured value aborts the start. - Step 4: the same command PASSES.
- Step 5: Commit
feat(server): spectio serve bound to loopback.
Task 12: Token link, secrets and sessions (§15.3 req. 2)¶
Spec: dev-env §15.3 "Access" and req. 2, auth §2, §2.1 (ticket rules) · Depends on: 11 · R10 §3a: row 2
Files:
- Create:
packages/spectio-server/src/spectio_server/auth/{secrets.py,routes.py,middleware.py,static/link.html,static/link.js},packages/spectio/src/spectio/cli/login.py,packages/spectio-server/tests/conftest.py - Modify:
docs/specs/api-and-data-model.md(§5:POST /auth/links,POST /auth/magic-links:exchangeand the bootstrap pageGET /auth/link, so the OpenAPI snapshot of Task 18 matches a spec) - Test:
packages/spectio-server/tests/security/test_req2_secrets.py
Interfaces:
-
Produces:
ServerKey.generate(data_dir) -> ServerKey, written toserver.key, 0600, at each start;TicketStore.mint() -> str(43 base64url characters; only its SHA-256 is kept, with an expiry) andTicketStore.burn(ticket) -> bool;SessionStore.create() -> strandSessionStore.valid(secret) -> bool(in memory,compare_digeston digests);- routes:
POST /api/v1/auth/magic-links:exchange({"ticket"}→ 204 and the cookie) andPOST /api/v1/auth/links(server key →{"url"}); - middleware: 401 problem with no data on every other route, except the four of Assumption 8:
GET /auth/linkandGET /auth/link.js(no credential),POST /api/v1/auth/magic-links:exchange(a valid ticket) andPOST /api/v1/auth/links(the server key); spectio login --print-link.
-
Step 1: Write the failing tests
def test_req2_every_route_needs_session(client, app): # every route of app.routes except the four exemptions → 401,
# body has no run data
def test_req2_secrets_256_bits(): # ticket, session and key decode to ≥ 32 bytes; secrets used
def test_req2_ticket_single_use_and_15_min(client, clock): # second exchange → 401; at +15 min 1 s → 401
def test_req2_ticket_stored_hashed(app): ... # the store holds no plaintext ticket
def test_req2_server_key_only_for_links(client): # server key as bearer on GET /api/v1/runs → 401
def test_reused_or_expired_link_message(client): # 2nd exchange → 401 problem type ".../link-expired";
# GET /auth/link HTML holds the exact Review Focus 5 sentence
def test_login_print_link(cli, running_server): ... # prints a working link; ticket removed from history
def test_print_link_on_other_node(cli, tmp_path): ... # server.json names login3, hostname is login1 → exit 2,
# prints "ssh login3 spectio login --print-link", no request sent;
# a node "x;y" in server.json → refused, nothing printed
def test_print_link_refuses_foreign_listener(cli, tmp_path): ... # recorded port held by another uid → exit 2, key not sent
def test_print_link_json(cli, running_server): ... # --json → sentinel line, then {"ticket": <43 chars>}
- Step 2:
uv run pytest packages/spectio-server/tests/security/test_req2_secrets.py -v: FAIL. - Step 3: implement.
link.jsreadslocation.hash, callshistory.replaceStateto drop it, posts the exchange as JSON, thenlocation.replace("/"). The bootstrap routes sendCache-Control: no-store,Referrer-Policy: no-referrerandContent-Security-Policy: default-src 'self'. - Step 4: the same command PASSES.
- Step 5: Commit
feat(server): one-time token link and sessions.
Task 13: Session cookie (§15.3 req. 3)¶
Spec: dev-env §15.3 req. 3 and the Jupyter cookie-name rule · Depends on: 12
Files:
- Modify:
packages/spectio-server/src/spectio_server/auth/routes.py,middleware.py - Test:
packages/spectio-server/tests/security/test_req3_cookie.py
Interfaces:
-
Produces:
cookie_name(host_header: str) -> str, for example"spectio_session_41234"for127.0.0.1:41234, and"spectio_session_80"whenHosthas no port. -
Step 1: Write the failing tests
def test_req3_attributes(client): # Set-Cookie has HttpOnly, SameSite=Strict, Path=/; no Domain
def test_req3_name_has_host_port(client): # Host 127.0.0.1:9001 → spectio_session_9001
def test_req3_other_port_cookie_ignored(client): ... # a session cookie of port 9001 sent to Host :9002 → 401
- Step 2:
uv run pytest packages/spectio-server/tests/security/test_req3_cookie.py -v: FAIL. - Step 3: implement. The browser behaviours marked [to be validated] stay with spike sec-single-mode (R10 C6).
- Step 4: the same command PASSES.
- Step 5: Commit
feat(server): session cookie per host port.
Task 14: Host allow-list (§15.3 req. 4)¶
Spec: dev-env §15.3 req. 4 · Depends on: 11
Files:
- Create:
packages/spectio-server/src/spectio_server/auth/host.py - Test:
packages/spectio-server/tests/security/test_req4_host.py
Interfaces:
-
Produces:
host_allowed(host_header: str | None) -> bool. It accepts127.0.0.1,localhostand[::1], on any port or none, case-insensitively. A request whose host is refused gets 421 or 400 before authentication, with no data. -
Step 1: Write the failing tests
@pytest.mark.parametrize("h", ["127.0.0.1:8080", "localhost:41234", "[::1]:5", "LOCALHOST"])
def test_req4_loopback_accepted(h): assert host_allowed(h)
@pytest.mark.parametrize("h", ["evil.example:8080", "127.0.0.1.evil.example", "0.0.0.0:8080",
"localhost.", "[::ffff:127.0.0.1]", "", None])
def test_req4_others_refused(h): assert not host_allowed(h)
def test_req4_refused_before_auth(authed_client): ... # valid cookie + Host evil.example → refused, no data
- Step 2:
uv run pytest packages/spectio-server/tests/security/test_req4_host.py -v: FAIL. - Step 3: implement; this middleware runs first, also for the bootstrap routes.
- Step 4: the same command PASSES.
- Step 5: Commit
feat(server): loopback Host allow-list.
Task 15: File modes (§15.3 req. 5)¶
Spec: dev-env §15.3 req. 5 · Depends on: 8, 9, 12 · R10 §4: Q10 (group ACLs)
Files:
- Create:
packages/spectio/src/spectio/fsmodes.py(secure_dir(path) -> Nonecreates the data directory 0700 or tightens it;write_secret(path, data) -> Nonecreates a temporary name withO_CREAT|O_EXCL0600, writes, then renames) - Modify:
init.py(Task 8),store/db.py(Task 2),auth/secrets.py(Task 12),runner/headscript.py(Task 9) -
Test:
packages/spectio-server/tests/security/test_req5_modes.py -
Step 1: Write the failing tests
def test_req5_data_dir_0700(initialised): ... # stat mode & 0o777 == 0o700
def test_req5_key_and_db_files_0600(served): ... # server.key, spectio.db, -wal, -shm, server.json
def test_req5_run_dirs_0700(launched): ... # <runs_root>/<run_id> and launch/, work/
def test_req5_umask_does_not_loosen(monkeypatch): ... # with umask 000 the modes above still hold, and no file
# is wider than 0600 at any moment of its creation
def test_req5_key_not_in_shared_home(served): ... # server.key in the node-local dir; nothing secret in ~/.spectio
def test_req5_existing_loose_dir_tightened_or_refused(tmp_path): ... # data dir 0755 → 0700, with a warning (the node-local
# directory is never tightened: Task 2)
- Step 2:
uv run pytest packages/spectio-server/tests/security/test_req5_modes.py -v: FAIL. - Step 3: implement; files are created with
os.open(..., 0o600), never chmod-ed after the write. - Step 4: the same command PASSES.
- Step 5: Commit
feat: 0700 directories and 0600 secrets.
Task 16: Writes need Origin and JSON (§15.3 req. 7)¶
Spec: dev-env §15.3 req. 7 (decided, inbox #37) · Depends on: 12, 14
Files:
- Create:
packages/spectio-server/src/spectio_server/auth/writes.py -
Test:
packages/spectio-server/tests/security/test_req7_writes.py -
Step 1: Write the failing tests
def test_req7_missing_origin_403(authed_client): ... # POST /api/v1/runs/x:cancel without Origin → 403
def test_req7_foreign_origin_403(authed_client): ... # Origin https://evil.example → 403
def test_req7_origin_port_must_match_host(authed_client): ... # Host :8080, Origin http://127.0.0.1:9000 → 403
def test_req7_body_needs_json_415(authed_client): ... # text/plain or form body on POST /api/v1/runs → 415
def test_req7_bodyless_cancel_needs_origin_only(authed_client): ... # → not 403 and not 415
def test_req7_get_head_unaffected(authed_client): ...
- Step 2:
uv run pytest packages/spectio-server/tests/security/test_req7_writes.py -v: FAIL. - Step 3: implement; this check runs after the host and session checks and also covers the exchange and
auth/links. There are no CORS headers on any route. - Step 4: the same command PASSES;
uv run pytest packages/spectio-server/tests/security -vis all green. - Step 5: Commit
feat(server): Origin and JSON checks on writes.
M4 · API and screens¶
Task 17: Launch API¶
Spec: api §5, §5.2 (POST /runs, the 0.0.1 subset of its body: Assumption 2), ui-spec §4.6 (where the form comes from) · Depends on: 9, 16 · R10 §3a: rows 3, 9
Files:
- Create:
packages/spectio-server/src/spectio_server/api/{workflows.py,runs_write.py},packages/spectio-server/tests/data/{nf_schema_defs.json,nf_schema_definitions.json,smk_config.schema.yaml,smk_config.yaml} - Modify:
docs/specs/api-and-data-model.md(§5:GET /workflows:inspectand the 0.0.1 body ofPOST /runs) - Test:
packages/spectio-server/tests/api/test_launch.py
Interfaces:
- Consumes:
authed_client;runner_commands(Task 9). -
Produces:
GET /api/v1/workflows:inspect?location=→{location, engine, name, version, engine_version, schema, schema_source: "nextflow_schema.json" | "config.schema.yaml" | "inferred" | "none", profiles: [str]};versionis the pipeline's declared version, Nextflowmanifest.version("Project version number", Nextflow config reference,manifest; sourcenextflow-io/nextflowdocs/reference/config/manifest.mdx:130-132, checked 2026-10-28),nullfor Snakemake, which has no version field in the workflow [to be validated];engine_versionis the version of the engine the server would launch with (UX-296);POST /api/v1/runswith body{workflow: {location}, name?, profiles?: [str], params: {}}→201with the run (state: queued), aLocationheader and alaunchrow inrunner_commandswritten in the same transaction, after the run directory and its manifest are written (write_run_manifest, Task 9);422 validation-failedwitherrors: [{field, message}]otherwise.
-
Step 1: Write the failing tests
def test_inspect_detects_engines(synthetic): ... # tests/pipelines/synthetic/nextflow → nextflow, schema_source "none";
# …/snakemake → snakemake, profiles ["local", "slurm"]
def test_inspect_reads_schemas(): ... # $defs and definitions (nf-core shapes) → one group each; config.yaml → inferred
def test_inspect_refuses_relative_or_missing(authed_client): ... # 422 with field "location"
def test_launch_creates_run_and_command(authed_client, synthetic): # 201; GET Location → queued; one launch command
def test_launch_validates_params(authed_client): ... # schema says integer, value "x" → 422 errors[0].field == "params.chunks"
def test_launch_ambiguous_engine_422(tmp_path): ... # both main.nf and Snakefile → 422
- Step 2:
uv run pytest packages/spectio-server/tests/api/test_launch.py -v: FAIL. - Step 3: implement. Validate with
jsonschema(draft 7 and 2020-12); the run's name defaults to<workflow name>-<last 6 of id>; params are stored asrun_inputs.kind = params. - Step 4: the same command PASSES.
- Step 5: Commit
feat(api): inspect a pipeline and launch a run.
Task 18: Read API, logs and Cancel¶
Spec: api §5 (GET /runs, GET /runs:summary, GET /runs/{id}, /processes, /tasks, /logs, GET /version; the 0.0.1 subset in the dated note of 2026-10-28), api §5.1 (:cancel source states), api §8.3 (the stream, dated note 2026-10-28), ui-spec §4.4 (stages and process names, note 2026-10-28), architecture §13.7 (log sources), checking-scenarios §11.1 (confinement rule) · Depends on: 4, 5, 17 · R10 §3a: rows 9, 10; D4
Files:
- Create:
packages/spectio-server/src/spectio_server/api/{runs_read.py,logs.py,cancel.py,stream.py},packages/spectio-server/openapi/0.0.1.json - Test:
packages/spectio-server/tests/api/test_read.py,packages/spectio-server/tests/api/test_cancel.py,packages/spectio-server/tests/api/test_stream.py,packages/spectio-server/tests/api/test_openapi.py
Interfaces:
-
Produces:
GET /api/v1/runs?state=&q=&sort=&limit=&cursor=→{items, next}, newest first bycreated_at, or byended_atwithsort=-ended_at(home's runs that ended recently);statetakes a comma-separated list of run states,qis a prefix of the run name, as in api §5; both feed the filters of the runs list (UX-296);GET /api/v1/runs:summary?group_by=state→{groups: [{key: <state>, count}]}over all the user's runs, with no time window (since=andproject=come later); it feeds the count cards of the home screen;GET /api/v1/version→{version, node, runs_root, account, executor: "slurm" | "local"}: the Spectio version, the login node the server runs on, the runs root, the Unix account and the executor runs use; behind the session cookie in 0.0.1 (not public, Assumption 8); it feeds the shell's "server on" line; GET /api/v1/runs/{id}→{id, name, state, origin: "ui" | "cli-observed", created_at, started_at, ended_at, workflow: {engine, engine_version, location, name, version}, profiles, params, failure, head: {session, node, alive, exit_code} | null, summary: {tasks_total, tasks_expected, counts}};engine_versionis the versioncheck_engine(Task 9) found at launch, or the pointer's for acli-observedrun (Task 25);workflow.versionis the version the pipeline declares, from the bridge's start event (Nextflowworkflow.manifest.version[to be validated]),nullwhen it declares none (Snakemake declares none [to be validated]);headisnullwhile the head has not started (queued); for acli-observedrun,sessionisnullandaliveisnullwhen the head is on another node ("not checked", Task 25). No other fact (partition, queue, cluster name) is returned in 0.0.1; the run items ofGET /runscarry the same fields butparams;GET /api/v1/runs/{id}/processes→{items: [{process, stage_id, counts, retries}]}, in first-seen order;processis the fully qualified Nextflow name, asnf-spectioreports it (ui-spec §5.1.1), or the Snakemake rule name, the key Task 27'sassign()matches, andstage_idisnullfor a process in no stage (ui-spec §4.4 note);GET /api/v1/runs/{id}/stages→{items: [{id, position, name, source: "declared" | "detected", state, task_counts}]}inpositionorder, a subset of therun_stagesrow of api §7;idisrun_stages.stage_id(detected:4, or aspectio.ymlstage id), the value the/processesitems carry instage_id;stateis the storedrun_stages.stateof ui-spec §5.1.1, shown with the words of the ui-spec §4.4 note;{items: []}when the run has no stage; Task 27 fills the rows (Assumption 6);GET /api/v1/runs/{id}/tasks?state=&process=&limit=&cursor=;GET /api/v1/runs/{id}/logs?source=head-out|head-err|engine→text/plain; charset=utf-8, the last 64 KiB;POST /api/v1/runs/{id}:cancel→202with the run (cancelling) and acancelcommand;200when it is alreadycancellingor terminal;GET /api/v1/events/stream→text/event-stream(Assumption 7):event: spectio.run.delta,data: {run_id, seq_through, counts, changed: {tasks}}, a strict subset of api §8.5, one frame per committed change to a run (coalesced to at most one per run per second), and: keep-aliveevery 15 s; the writer thread publishes to an in-process broadcaster after each commit.
-
Step 1: Write the failing tests
def test_run_detail_after_replay(authed_client, replayed_flaky): ... # counts, processes in first-seen order, head null
def test_runs_paging(authed_client): ... # 3 runs, limit=2 → 2 items + next; next page → 1 item, next null
def test_runs_filter_state_and_q(authed_client): ... # state=running,queued → only those; q=rnaseq → only names starting with rnaseq
def test_runs_summary_by_state(authed_client): ... # 2 running, 1 failed → groups running 2, failed 1; no since needed
def test_version_behind_session(client, authed_client): ... # no cookie → 401; with it → version, node, runs_root, account, executor
def test_runs_sort_ended(authed_client): ... # sort=-ended_at → ended runs, last ended first
def test_stages_ordered(authed_client, seeded_stages): ... # run_stages rows inserted directly → items in position order, each with state and task_counts
def test_stages_empty(authed_client, replayed_flaky): ... # no stage → {items: []}; processes carry stage_id null
def test_stages_source(authed_client, seeded_stages): ... # source "declared" or "detected"; processes keyed by the fully qualified name
def test_run_versions_and_origin(authed_client, replayed_flaky): ... # origin "ui", engine_version from check_engine,
# workflow.version from the start event, null for Snakemake
def test_queued_run_head_null(authed_client): ... # launched, head not started → state queued, head null
def test_logs_tail_and_sources(authed_client): ... # 100 KiB head.out → last 65536 bytes; engine log per engine
def test_logs_confined(authed_client, tmp_path): ... # .nextflow.log -> /etc/passwd symlink → 404 log-not-found
def test_cancel_states(authed_client): ... # queued → 202 cancelling; cancelled → 200 unchanged; unknown → 404
def test_stream_needs_session(client): ... # no cookie → 401 problem, no stream opened
def test_stream_delta_after_ingest(authed_client, writer): ... # a spool batch committed → one spectio.run.delta
# frame naming the run, its counts and the changed task IDs
def test_openapi_snapshot(app): ... # app.openapi() == openapi/0.0.1.json (regenerate with --update)
- Step 2:
uv run pytest packages/spectio-server/tests/api -v: FAIL. - Step 3: implement; log files are opened with
O_NOFOLLOWafter a real-path check underlaunch_dir. - Step 4: the same command PASSES.
- Step 5: Commit
feat(api): runs, tasks, logs and cancel.
Task 19: UI shell and launch form¶
Spec: ui-spec §4.6 (launch form, without stepper or review: Assumption 20), §3.1 (state words), mvp-0.0.1 §1 (four screens), architecture §6 (React, Vite, Mantine 7, TanStack) · Depends on: 17 · Boards: Launch, LaunchSnakemake (reference only; boards show more than 0.0.1, UX-227)
Files:
- Create:
web/package.json,web/vite.config.ts,web/src/{main.tsx,api.ts,routes.tsx},web/src/launch/{LaunchPage.tsx,schemaFields.ts},web/src/launch/schemaFields.test.ts - Modify:
packages/spectio-server/src/spectio_server/app.py(serveweb/distat/, behind the session)
Interfaces:
- Consumes:
workflows:inspect,POST /runs(Task 17). -
Produces:
schemaFields(schema: JSONSchema) -> Section[](Section {title, fields: Field[]};Field {key, label, type: "string" | "integer" | "number" | "boolean" | "enum" | "json", required, default, help}). Routes/(home),/runs,/launchand/runs/:id; the shell shows the server's node fromGET /version(Task 18). The page title is "Launch a workflow". The fields are Pipeline directory (absolute path) and Load, the detected engine, Profile, Run name (optional) and the parameters, with the Launch button disabled while a required field is empty, the reason shown beside it. -
Step 1: Write the failing tests
test("groups from $defs and definitions", () => expect(schemaFields(nfDefs).map(s => s.title)).toEqual(["Input/output", "Options"]));
test("hidden fields skipped, enums kept", () => /* hidden: true absent; enum → type "enum" */);
test("no schema → empty sections (key/value rows)", () => expect(schemaFields(null)).toEqual([]));
- Step 2:
npm --prefix web test: FAIL. - Step 3: implement. Use native
<input>and<button>only and a label on every field (CLAUDE.mdkeyboard rule); key / value rows whenschema_sourceisnone. A 422 maps its errors onto the fields. - Step 4:
npm --prefix web testPASSES;npm --prefix web run buildwritesweb/dist. - Step 5: Commit
feat(web): UI shell and launch form.
Task 20: Runs list, run detail, Cancel, and the Playwright smoke¶
Spec: api §8.3 (live refresh over SSE, decided 2026-10-28), ui-spec §4.3 (columns in 0.0.1: Run, State, Workflow, Tasks, Started, Duration; no gauge, no Rows mode, no compare), §4.4 (header and Cancel; Summary and Tasks as tables; Logs; the stages note of 2026-10-28), §3.1, ui-spec §11 (empty state) · Depends on: 18 (including GET /runs:summary for home), 19, step T6 (tests/e2e/, PR #45) · R10 §3a: row 13 (UX-211) · Boards: the 0.0.1 lite boards (LiteHome, LiteRuns, LiteRunDetail, LiteRunObserved, LiteStatesRun and their phone twins, design/README); RunsTable, RunDetail for reference only
Files:
- Create:
web/src/home/HomePage.tsx,web/src/runs/{RunsPage.tsx,RunDetailPage.tsx,stateWord.ts},tests/e2e/skeleton.spec.ts(in the T6 Playwright setup),tests/e2e/serve-local.sh(spectio init --localin a temp directory,spectio serve, prints the link) - Modify:
.github/workflows/ci.yml(jobe2e-local: Nextflow and Snakemake fromdev/bootstrap.sh, then Playwright)
Interfaces:
- Consumes: the read API and
:cancel(Task 18). -
Produces:
- the home screen at
/: count cards by state fromGET /runs:summary?group_by=state, the active runs, the runs that ended recently, and the pipeline directories of the recent runs (derived fromGET /runs, no new endpoint), refreshed from the stream; the shell and every list say "live, updated N s ago" from the age of the last frame, and "Reconnecting…" while theEventSourcereconnects; - the runs list, refreshed from the SSE stream of Task 18 (one
EventSourceper browser), with state filters and a name filter (state=,q=), "Load more", and a neutral "From a terminal" chip oncli-observedruns; its empty state reads "No runs yet." with a "Launch a workflow" button; - the run detail, refetched on each stream frame for its run until it is terminal:
- a header: name, state word, short id, "n of m stages done · k running" for a run that has stages, and Cancel while
queuedorrunning, after a confirm "Cancel this run? Spectio interrupts its head in the tmux session, and the engine stops its tasks."; - a facts table: pipeline and its declared version, engine and its version, profile, started, ended, duration, the head (tmux session name, alive or exited, login node, exit code; "Head not started yet" while
headisnull), failure class and message; - a counts row, then the stages, or the flat processes or rules list when there is none, as the ui-spec §4.4 note says (Assumption 6);
stateWord.tsimplements its state rule; - a
cli-observedrun: no Cancel, "head on, not checked" and the engine log only (Task 25); - the tasks table (filter by state, and by process from
/processes; on a failed run it defaults tofailed), the parameters table, and the logs (head out, head err, engine log) loaded on click in<pre>.
- a header: name, state word, short id, "n of m stages done · k running" for a run that has stages, and Cancel while
- the home screen at
-
Step 1: Write the failing test
test("link → empty list → launch happy (Nextflow, local) → detail shows Succeeded and 65 tasks", async ({ page }) => { /* … */ });
test("home at / shows the count cards, the active runs and the runs that ended", async ({ page }) => { /* … */ });
test("the detail shows the flat list with no stage, and the stages when /stages has items", async ({ page }) => { /* route-mocked /stages */ });
test("cancel a running Snakemake run → Cancelled", async ({ page }) => { /* happy with a larger scale, so it still runs */ });
test("a reused link shows the expired message", async ({ page }) => { /* Review Focus 5 */ });
test("the detail updates without a reload as tasks finish", async ({ page }) => { /* SSE with the session cookie, §15.3 req. 3 */ });
test("a request without the cookie shows the 401 page, not data", async ({ page }) => { /* … */ });
- Step 2:
bash tests/e2e/serve-local.sh & npx --prefix dev/npm playwright test tests/e2e/skeleton.spec.ts(T6's config): FAIL. - Step 3: implement. Use the state words of ui-spec §3.1 and plain tables, with no chart.
- Step 4: the same command PASSES on the desktop and phone projects of T6; render the matching boards with
node tools/canvas/render.jsand compare by eye (no gate). - Step 5: Commit
feat(web): runs list, run detail and cancel.
Task 21: One wheel with the UI and both bridges¶
Spec: architecture §7.3 (UI in the wheel through a hatch hook; nf-spectio jar in the wheel; base install), architecture §6 (< 80 MB spectio environment) · Depends on: 6, 7, 20 · R10 §3a: row 5
Files:
- Create:
packages/spectio-server/hatch_build.py(copiesweb/dist),packages/spectio/hatch_build.py(runsengine-bridges/nf-spectio/build.shand copies the logger module intospectio/_bridges/),tests/packaging/test_wheel.sh -
Modify: the
.github/workflows/ci.ymlproductjob (build the wheels, run the test) -
Step 1: Write the failing test
tests/packaging/test_wheel.sh. It builds withuv build --all-packages; installs withuv tool installfrom the built wheels into a tempUV_TOOL_DIR; assertsspectio --versionprints0.0.1; that the wheel listsspectio/_bridges/nf-spectio-0.0.1/andspectio/_bridges/snakemake_logger_plugin_spectio/__init__.py; thatspectio_server/web/index.htmlexists; and that the environment ofspectioalone (without[server]) is under 80 MB. - Step 2:
bash tests/packaging/test_wheel.sh: FAIL. - Step 3: implement the two hooks; the runner reads the bridges with
importlib.resources. - Step 4: the same command PASSES and prints
RESULT wheel pass. - Step 5: Commit
build: one wheel with the UI and both bridges.
M5 · On the cluster¶
Task 22: End to end on slurm-mini: launch and monitor both engines¶
Spec: dev-env §15.3 (architecture), dev-env §4.1 (slurm-mini), T3 note (Snakemake status-check delay) · Depends on: 21
Files:
- Create:
tests/slurm/e2e.sh(subcommandslaunch nextflow|snakemake,cancel <engine>,restart; printsRESULT e2e-<name> pass|fail),tests/slurm/e2e_client.py(stdlib HTTP client: reads the link fromspectio login --print-link, does the exchange withHostandOrigin, launches, polls, compares counts) - Modify:
.github/workflows/pipelines-slurm.yml(ane2ematrix entry onslurm-mini; paths alsopackages/**,engine-bridges/**,web/**),tests/scenarios/happy-slurm-mini.yaml(expectlines become checked),dev/slurm/cluster.sh(installstmuxon the submitter node if its image lacks it; whether it does is [to be validated])
Interfaces:
- Consumes: the wheel (Task 21), installed into
$SLURM_DATA/toolsasdev/slurm/cluster.shexpects;incfromtests/slurm/scenarios.sh(run as userspxon the submitter). -
Produces:
spectio init --runs-root $SLURM_DATA/runs --partition htc, thenspectio serve --detach, withengines.extra_args.snakemake = ["--slurm-init-seconds-before-status-checks", "5"]andrunner.head_watch_s = 5. -
Step 1: Write the failing test (the harness). For each engine: launch the synthetic
happy(params fromtests/pipelines/synthetic/scenarios.yaml, profileslurm); expectqueued→runningwith the head alive in the tmux sessionspectio-<run_id>on the submitter node (nosbatchfor the head: the submit log of the engines' task jobs holds nospectio-head-name) →succeeded. The per-process counts must equaltests/pipelines/synthetic/golden/{nextflow-26.10.4,snakemake-9.27.1}/happy.tsv, the head end state comes fromhead.exit,spool_dropsstays empty, and at the endsqueue --melists no job of the run andtmux lsno sessionspectio-<run_id>. - Step 2:
bash dev/slurm/cluster.sh up mini && CLUSTER=mini bash tests/slurm/e2e.sh launch nextflow: FAIL. - Step 3: fix what the cluster reveals, each fix with a unit test in the task that owns the code.
- Step 4:
CLUSTER=mini bash tests/slurm/e2e.sh launch nextflowand… launch snakemakeprintRESULT e2e-launch-<engine> pass; thee2ejob is green inpipelines-slurm.yml. - Step 5: Commit
test(slurm): end to end launch and monitor on slurm-mini.
Task 23: End to end Cancel and server restart¶
Spec: schedulers §3.1.1 (tmux launcher: adopt, cancel), dev-env §4.1 (its note 2026-10-28: one cancel test per engine and a server restart while a head runs in tmux), dev-env §15.3 "Server restart" · Depends on: 22 · R10 §3a: row 6; D4
Files:
-
Modify:
tests/slurm/e2e.sh,tests/slurm/e2e_client.py -
Step 1: Write the failing tests
cancel <engine>: launchhappywith a largerscale, so it still runs; oncerunningwith tasks running,POST :cancel. Expectcancelling→cancelledwithin grace + 120 s, no job of the run insqueue --me(whether Nextflow's abort cancels its SLURM jobs is [to be validated] here), no sessionspectio-<run_id>left, and no attempt leftrunning(they endabortedorfailed).restart: launch Nextflowhappy; at the firstsucceededtask, killspectio serve(SIGKILL), wait 20 s and start it again withspectio serve --detach. The head is adopted by its tmux session name and PID check (no second start: the launcher log shows onenew-sessionfor the run); the run endssucceeded; the counts equal the golden;eventshas no duplicateevent_id; a new link is needed (the old cookie gets 401).
-
Step 2:
CLUSTER=mini bash tests/slurm/e2e.sh cancel nextflow: FAIL. - Step 3: fix what the runs reveal (unit tests in the owning tasks).
- Step 4:
cancel nextflow,cancel snakemakeandrestartprintRESULT … pass, locally and in thee2ejob. - Step 5: Commit
test(slurm): cancel and restart on slurm-mini.
Task 24: Laptop access: the SSH config entry and spectio open¶
Spec: dev-env §15.3 (Access; One login node per server, and a state file), cli §2 (the open row), decision log, 2026-10-28 · Depends on: 11, 12 · Added 2026-10-28 (Thomas) · Revised 2026-10-29 (Thomas): a command of the spectio package, no longer the separate package spectio-open (log)
Files:
- Create:
packages/spectio/src/spectio/cli/open.py(theopencommand) andpackages/spectio/src/spectio/open_remote.py(ssh argv, remote quoting, value checks),packages/spectio/tests/test_open.py,packages/spectio/tests/fakebin/ssh(records argv, answers from fixtures),docs/guides/open-from-your-laptop.md(the SSH config entry andspectio open, both paths side by side) - Modify:
packages/spectio/src/spectio/cli/main.py(registeropen),mkdocs.yml(the guide in the nav)
Interfaces:
-
Produces:
spectio open <host> [--node NODE] [--remote-cmd CMD] [--local-port N] [--no-browser], under §15.3 req. 8; a per-cluster laptop configuration,~/.config/spectio/clusters.toml([<host>] node,remote_cmd) read withtomllib; exit codes 0 (browser opened, tunnel up until Ctrl-C), 2 (remotespectiomissing: prints the one-time install command), 3 (ssh failed). -
Step 1: Write the failing tests
def test_reads_state_file_and_reaches_its_node(fake_ssh): # server.json names login3 → tunnel argv targets login3
def test_node_flag_for_first_start(fake_ssh): # no server.json, --node login2 → init if needed, then
# "spectio serve --detach" run on login2
def test_login_shell_and_remote_cmd(fake_ssh): # remote commands run through "$SHELL -lc"; clusters.toml
# remote_cmd "module load spectio && spectio" used as given
def test_missing_remote_spectio_prints_install(fake_ssh): # "spectio: command not found" → exit 2, prints the
# uv tool install line, runs no install
def test_rejects_bad_node_from_state_file(fake_ssh): ... # node "-oProxyCommand=x", "a b", "x;y", "\x1b]0;" or
# non-ASCII → exit 3, ssh never called
def test_ssh_argv_has_double_dash(fake_ssh): ... # "--" before the destination; no cluster value inside -o
def test_tunnel_argv_hardened(fake_ssh): ... # -N, -o ExitOnForwardFailure=yes,
# -L 127.0.0.1:<lport>:127.0.0.1:<rport>, no host-key option
def test_remote_quoting_two_layers(fake_ssh): ... # a remote_cmd with spaces and quotes reaches the remote shell
# intact (shlex.quote(shlex.join(argv)))
def test_url_built_locally(fake_ssh, monkeypatch): ... # remote prints a URL to evil.example → ignored; the opened URL
# is http://127.0.0.1:<lport>/auth/link#<ticket>
def test_free_local_port_and_link(fake_ssh, monkeypatch): ... # a free port; the ticket from --print-link --json
# opened with webbrowser.open, with the local port in it
- Step 2:
uv run pytest packages/spectio/tests/test_open.py -v: FAIL. - Step 3: implement. Remote commands are argument lists quoted with
shlex.quote(shlex.join(argv))for the two shell layers, never strings built from user input. The guide gives theHostblock (HostName <login node>,LocalForward 127.0.0.1:<port> 127.0.0.1:<port>) with the port thatspectio initrecorded (Assumption 10). - Step 4: the same command PASSES; by hand, once, on an EMBL login node:
spectio open <host>opens home, and the SSH config entry does the same (recorded on the Progress page; nothing in CI reaches EMBL). - Step 5: Commit
feat(open): spectio open and the SSH config guide.
M6 · Runs from a terminal¶
Task 25: Pointer file in both bridges; the server finds and shows the run¶
Spec: architecture §13.8, dev-env §15.3 req. 12 · Depends on: 6, 7, 9, 11, 18, 20 · Added 2026-10-28 (Thomas)
Files:
- Modify:
engine-bridges/nf-spectio/src/spectio/SpectioPlugin.groovyandengine-bridges/snakemake-logger-plugin-spectio/snakemake_logger_plugin_spectio/__init__.py(run ID, spool file names, pointer, the fd-relative writes of req. 12),packages/spectio/src/spectio/runner/loop.py(scan whenobserved/changes),packages/spectio-server/src/spectio_server/rebuild.py(read the pointers),packages/spectio-server/src/spectio_server/api/{cancel.py,logs.py}(422 on acli-observedrun, api §5.1 note; the log checks of req. 12),web/src/runs/{RunsPage.tsx,RunDetailPage.tsx}(the "started from a terminal" mark; no Cancel),tools/docs/check.py(install lines pin versions),mkdocs.yml - Create:
packages/spectio/src/spectio/runner/observed.py,docs/guides/runs-from-a-terminal.md(the setup of architecture §13.8: for Nextflow the one-shotNXF_PLUGINS_DEFAULT=nf-spectio@<ver> nextflow run …, the shell-profileexportas opt-in, and the Seqera Platform Head job variable; merged only once the names are claimed, inbox #66; inbox #69 decided 2026-10-29) - Test:
packages/spectio/tests/test_observed.py; the bridge tests of Tasks 6 and 7
Interfaces:
-
Produces:
scan_observed(home: Path, runs_root: Path, known: set[str], cap: int) -> list[ObservedRun]; the writer thread creates each new run withorigin = 'cli-observed'. The pointer's PID fields feed the head-alive check of Task 9 only when its boot ID is the server's; nothing sends them a signal. -
Step 1: Write the failing tests
# bridges (Tasks 6 and 7 test files, marked engines; each also as *_snakemake with smk_run)
def test_terminal_run_writes_pointer(tmp_home, nf_run): ... # no SPECTIO_RUN_ID → ~/.spectio/observed/run_<ULID>.json,
# 0600, run ID = file name, spool <run ID>-<producer>-0001.jsonl
def test_no_spectio_home_run_unaffected(tmp_home, nf_run): ... # no ~/.spectio → no pointer, spool written, exit 0
def test_unwritable_home_run_unaffected(tmp_home, nf_run): ... # ~/.spectio/observed mode 0500 → exit 0
def test_driven_run_writes_no_pointer(tmp_home, nf_run): ... # SPECTIO_RUN_ID set → no pointer
def test_bridge_refuses_foreign_spectio_dir(tmp_home, nf_run): ... # .spectio a symlink, other owner or 0777, bridge.err a
# symlink → nothing written outside, targets unchanged, exit 0
def test_loose_spectio_home_no_pointer(tmp_home, nf_run): ... # ~/.spectio 0777 or a symlink → no pointer
def test_pointer_keys_exact(tmp_home, nf_run): ... # keys are exactly those of architecture §13.8
# packages/spectio/tests/test_observed.py
def test_scan_finds_pointer(tmp_home): ... # valid pointer → one ObservedRun; its spool replays to succeeded
def test_req12_pointer_checks(tmp_home): ... # symlink, other owner, group/other bits, > size cap, bad JSON,
# extra keys, run ID ≠ file name → skipped and counted
def test_loose_observed_dir_not_scanned(tmp_home): ... # observed/ 0777 → warning, server starts, nothing read
def test_pointer_cannot_alias_driven_run(tmp_home): ... # run ID of a run directory, relative or unnormalised launch dir,
# launch dir under the runs root → skipped and counted
def test_group_writable_launch_dir_accepted(tmp_home): ... # launch dir 0770, its .spectio 0700 → read
def test_spectio_dir_swapped_after_check_not_read(tmp_home): ... # .spectio replaced by a symlink after the check → no read
def test_terminal_log_symlink_fifo_foreign_refused(tmp_home): ... # .nextflow.log a symlink, a FIFO or foreign → "log not readable"
def test_two_terminal_runs_same_launch_dir_kept_apart(tmp_home): ... # two runs, one launch dir → each its own events only;
# an envelope for the other run is dropped and counted
def test_terminal_run_other_boot_id_never_head_lost(tmp_home): ... # boot ID differs → "not checked", stays running
def test_runner_never_signals_cli_observed(tmp_home): ... # boot reconciliation included: no launcher or tmux call
def test_scan_skips_unchanged_dir(tmp_home): ... # observed/ mtime unchanged → not listed again
def test_ended_pointer_moved(tmp_home): ... # terminal event → pointer moved to observed/ended/
def test_scan_cap(tmp_home): ... # 10 000 pointers → at most the cap read per scan
def test_rebuild_keeps_terminal_run_id(tmp_home): ... # index deleted → same run ID after rebuild, ended/ included
def test_server_down_events_wait(tmp_home): ... # run ends while no server → read whole at next start
def test_terminal_run_has_no_cancel(client): ... # POST :cancel on a cli-observed run → 422 not-in-this-version
# docs check
def test_guide_pins_bridge_versions(): ... # every install line in the guide pins ==<ver> or nf-spectio@<ver>
- Step 2:
uv run pytest packages/spectio/tests/test_observed.py -v: FAIL. - Step 3: implement. The pointer is written under a temporary name with
O_CREAT|O_EXCL, then renamed; any error is logged once and swallowed (architecture §13.3). - Step 4: the same command PASSES, and the bridge tests PASS with
-m engines; the Playwright smoke of Task 20 gains one terminal run that shows the mark and no Cancel. - Step 5: Commit
feat(runner): show runs started from a terminal.
Task 26: Release packages of both bridges, dry-run only¶
Spec: dev-env §12 (Publishing), dev-env §15.3 req. 12 (Release), architecture §13.4 (version ranges) · Depends on: 6, 7, 21 · Added 2026-10-28 (Thomas)
Files:
- Modify:
engine-bridges/nf-spectio/(Gradle build with a pinnedio.nextflow.nextflow-pluginand a Gradle wrapper withdistributionSha256Sum; plugin IDnf-spectio, version0.0.1,requiresfrom §13.4),engine-bridges/snakemake-logger-plugin-spectio/pyproject.toml(name, version,snakemake-interface-logger-pluginsrange,docs/intro.mdfor the catalogue page) -
Create:
tests/packaging/test_bridges_release.sh -
Step 1: Write the failing test
tests/packaging/test_bridges_release.sh. With a fresh, emptyGRADLE_USER_HOME, it builds the Nextflow plugin zip and checks its manifest (ID, version,requires); it builds the Snakemake sdist and wheel withuv build, runstwine check --strict, installs the wheel into a fresh Snakemake 9 environment and runs a local synthetichappywith--logger spectio. It checks that no artefact holds a secret (npr.apiKey, a PyPI token, agradle.properties), never runsmake releaseortwine upload, and fails ifnpr.apiKeyor a PyPI token is set in its environment. - Step 2:
bash tests/packaging/test_bridges_release.sh: FAIL. - Step 3: implement the two builds.
- Step 4: the same command PASSES and prints
RESULT bridges-release dry-run pass. The real release runs from CI or a laptop, never the cluster, once Thomas has claimed the names (inbox #66). - Step 5: Commit
build(bridges): release packages for the Nextflow registry and PyPI.
M7 · Stages¶
Task 27: Stages, declared or detected, above the processes or rules list¶
Spec: ui-spec §5.1 and §5.1.1 (sources, detection rules, order and state), checking-scenarios §12.1 (stages[], what match sees) and §12.3 (overlaps), api §7 (run_stages, tasks.stage_id, summary.stage_mode; when detected stages freeze), api §5 (GET /runs/{id}/stages); evidence: stage-detection spike · Depends on: 4, 5, 18, 20, 25 · Added 2026-10-28 (Thomas, log)
Files:
- Create:
packages/spectio-sdk/src/spectio_sdk/stages.py(thestages[]model ofspectio.yml, read from the workflow's own file or from a lab overlay file in~/.spectio/overlays/, matched to the run as checking-scenarios §12.7 (note 2026-10-28) says; no editor),packages/spectio-server/src/spectio_server/stages/{detect_nf.py,detect_smk.py,assign.py,state.py},packages/spectio-server/tests/test_stages.py,tests/pipelines/real/stages/expected/*.json(from the spike'sout/*_detected.json; the freeze tests replaytests/pipelines/real/stages/events/rnaseq-3.27.0_star_salmon_preview_events.jsonl, and build events from the otherprocesses/*.txtlists) - Modify: the projector of Tasks 4 and 5 (freeze
run_stagesat the firstonFlowBeginorworkflow_started; settasks.stage_id; stage state on each event), the store schema of Task 2 (run_stages,tasks.stage_id), the head script of Task 9 (Snakemake only:snakemake --list-ruleswith the run's configuration into.spectio/rules.txtbefore the engine starts; a failure there is logged and ignored; it printsname (docstring), so the reader strips a trailing(…)), the read API of Task 18 (fills Task 18's/stagessubset fromrun_stages, plussummary.stage_mode; no new route),web/src/runs/RunDetailPage.tsx(Task 20: stages and the processes or rules, per ui-spec §4.4)
Interfaces:
- Produces:
detect_nextflow(process_names: list[str], manifest_name: str | None, meta: Mapping[str, str]) -> list[StageDef]anddetect_snakemake(main_snakefile: Path, defined: set[str] | None) -> list[StageDef], pure functions with a cap of 8;assign(stages: list[StageDef], name: str) -> str | None(the one stage whosematchmatches, withfnmatchcaseon the fully qualified name, since §12.3 refuses overlaps;Noneleavestasks.stage_idnull).StageDefhasid,name,source(declaredordetected),description,match. -
The Snakemake detector reads source files as the server's user, on the run's pipeline directory only: regular files, no symlink leaving the directory of the main Snakefile, at most 200 files of 1 MiB each; for a run started from a terminal,
snakefile_mainis untrusted input under dev-env §15.3 req. 12, and a path that fails the checks gives no detected stages. The server never runssnakemakeitself. (assumed — pending Thomas) -
Step 1: Write the failing tests
# packages/spectio-server/tests/test_stages.py — golden tests on tests/pipelines/real/stages/
def test_detect_nf_golden(fixture): ... # rnaseq star_salmon (67 → 8), rnaseq hisat2 (62 → 8), sarek (48 → 8),
# methylseq bismark (13 → 4), bwameth (15 → 5): == expected/*.json
def test_detect_nf_without_manifest(fixture): ... # manifest name None → the same stages on all five lists
def test_detect_nf_flat_none(): ... # the synthetic happy process list → []
def test_detect_nf_report_kept(fixture): ... # MULTIQC is its own last stage "Report" after the cap
def test_detect_smk_golden(fixture): ... # mosaicatcher v2.5.1 source + list-rules (101 rules → 8) == expected
def test_detect_smk_source_only(fixture): ... # no rules.txt → 204 rules, 8 stages, Final targets last
def test_rules_txt_docstrings_stripped(tmp_path): ... # "count_reads (Count reads per bin)" → "count_reads"
def test_detect_smk_unsafe_path(tmp_path): ... # symlink out of the pipeline dir, > 200 files, FIFO → [] and a warning
def test_overlay_assign_golden(fixture): ... # the four overlays: every name assigned, no overlap
def test_overlay_overlap_refused(fixture): ... # two stages match one name → the overlay is refused with both stage ids
def test_overlay_matching(tmp_home): ... # ~/.spectio/overlays/: name and engine match, version out of range,
# two files match → none applied (checking-scenarios §12.7 note)
def test_declared_beats_detected(fixture): ... # an overlay for the pipeline → source declared; none → detected
def test_stages_frozen_at_flow_begin(replayed): ... # onProcessCreate after onFlowBegin → no new stage, stage_id null
def test_stage_states(replayed): ... # running on first task, completed, skipped with no task, failed
def test_stages_route(authed_client, replayed): ... # Task 18's GET /api/v1/runs/{id}/stages: items {id, position, name,
# source, state, task_counts} in position order; stage_id on /processes items; stage_mode
- Step 2:
uv run pytest packages/spectio-server/tests/test_stages.py -v: FAIL. - Step 3: implement, starting from the spike's
detect_nf.pyanddetect_smk.py(branchspike/stage-detection), as typed pure functions. - Step 4: the same command PASSES; the Playwright smoke of Task 20 gains a check that a run of the synthetic
happyshows no stage and its processes, and a run directory seeded with the rnaseq fixture's events (read at boot, Task 11) shows 8 detected stages above its processes. - Step 5: Commit
feat(server): stages, declared or detected, above the processes list.
M8 · Job files and run tools¶
Added 2026-10-28 (Thomas: "Je veux également que ça soit possible de voir les logs globaux et les lots et stout err log sh et autres de chaque job si possible via workdir", then "les 5 dans 0.0.1"; log). Each job's files and the work directory (Task 28), the failure summary and resources per job (Task 29), Resume and the copy actions (Task 30), results and reports (Task 31), and their screens (Task 32). Specs: architecture §13.7 (files, failure reason, resources), api §5 and §5.1 (notes 2026-10-28), ui-spec §4.4 (note 2026-10-28, run tools), dev-env §15.3 req. 13 to 16. (assumed — pending Thomas, inbox #70)
Task 28: Each job's files and the work directory¶
Spec: architecture §13.7, api §5 (note 2026-10-28, Job files), checking-scenarios §11.1 (confinement), dev-env §15.3 req. 13 · Depends on: 4, 5, 6, 7, 9, 18
Files:
- Create:
packages/spectio-server/src/spectio_server/files/{confine.py,taskfiles.py,redact.py},packages/spectio-server/src/spectio_server/api/taskfiles.py - Modify:
nf-spectio(Task 6: sendworkdirandnative_idwith each task event, and returntruefromenableMetrics()), the Snakemake logger plugin (Task 7: keeplogfromjob_info,logandaux.external_jobidfromjob_error, and the SLURM ID and log path from the submission info text, with a regular expression versioned persnakemake-executor-plugin-slurmrelease), the projector of Tasks 4 and 5 (storeattempts.work_dir,attempts.native_idand the Snakemake log paths, each kept only when it resolves under a root of the run, req. 13), the head script of Task 9 (Nextflow:NXF_LOG_FILE=<run dir>/.spectio/nextflow.logandunset NXF_DEBUG; Snakemake on SLURM:--slurm-keep-successful-logs), the logs route of Task 18 (the engine log path from the run, not a fixed name) - Test:
packages/spectio-server/tests/test_taskfiles.py,packages/spectio-server/tests/api/test_taskfiles_api.py
Interfaces:
-
Produces:
open_confined(root_fd: int, rel: PurePosixPath, *, work_dir_fd: int) -> BinaryIO(the walk of checking-scenarios §11.1, revised 2026-10-28; raisesNotAllowed,NotFound),list_confined(root_fd, rel, cap: int) -> Listing,tail(f, max_bytes: int = 65536) -> bytes;list_task_files(attempt) -> list[TaskFile];redact_command_run(text: str) -> str; the routesGET /api/v1/tasks/{id}/attempts/{n}/filesand…/files/{name}of api §5 (note 2026-10-28). -
Step 1: Write the failing tests
def test_nf_allow_list(nf_workdir): ... # files lists .command.log/err/out/sh/run, .exitcode, .command.begin/trace;
# never .command.env/.in/.stage or a staged input
def test_nf_file_tail(authed_client, nf_workdir): ... # 100 KiB .command.err → last 65536 bytes, text/plain, nosniff
def test_req13_command_run_redacted(nf_workdir): ... # export TOKEN="abc" → export TOKEN=«hidden»; #SBATCH lines kept
def test_req13_redact_lexer(nf_workdir): ... # multi-line "…" value, $'a\nb', beforeScript FOO=bar → hidden
def test_req13_redact_container_env(nf_workdir): ... # -e NAME=value, SINGULARITYENV_X=… → hidden [to be validated]
def test_req13_redact_fails_closed(nf_workdir): ... # unterminated quote → every line from there on hidden
def test_array_task_has_no_log(nf_workdir): ... # native_id 8812345_3 and no .command.log → state absent, note
def test_cleaned_workdir_410(authed_client): ... # work directory gone → 410 log-cleaned
def test_not_written_yet(authed_client): ... # running attempt, no .exitcode → state not_yet, no error
def test_smk_rule_and_slurm_logs(smk_run): ... # job_info log + submission text → rule log and
# .snakemake/slurm_logs/rule_bwa_mem/WGS-07/48731209.log
def test_smk_job_error_paths(smk_run): ... # job_error log and aux.external_jobid recorded on the attempt
def test_smk_deleted_after_success(smk_run): ... # SLURM log missing on a succeeded job → deleted_after_success
def test_req13_forged_paths_dropped(replayed): ... # workdir /etc, log ../../.ssh/id_rsa, SLURM log outside the
# launch dir → not stored, counted
def test_req13_symlink_fifo_refused(tmp_path): ... # .command.err symlink to ~/.bashrc, FIFO, device → refused, no hang
def test_req13_dir_swapped_mid_request(tmp_path): ... # a parent directory replaced by a symlink between two openat → refused
def test_req13_published_symlink_served(tmp_path): ... # results/x.bam → symlink into the run's work dir → served
def test_req13_symlink_to_home_refused(tmp_path): ... # results/x → symlink to $HOME → refused, listed "link outside the run"
def test_req13_foreign_uid(tmp_path, other_uid): ... # a file owned by another uid → refused by fstat; hidden from listings
def test_req13_spectio_dir(authed_client): ... # .spectio/head.out, head.err, nextflow.log served; .spectio/manifest.json,
# .spectio/sacct.json → 422
def test_req13_name_outside_allow_list_422(authed_client): ... # name ".command.env", "../x", "/etc/passwd" → 422
def test_req13_smk_name_exact(authed_client, smk_run): ... # a recorded log path with one byte changed → 422
def test_req13_escape_sequences_removed(authed_client): ... # ESC [ 31m, OSC 8 links, BEL → stripped from the text served
def test_req13_headers(authed_client): ... # no-store, no-referrer, filename* on Download; a name with \x07 → 422
def test_req13_range_capped(authed_client): ... # Range of 5 MiB → 1 MiB served
def test_req13_file_pool_separate(slow_fs): ... # a file read stalled 30 s → spool events still projected
def test_binary_415(authed_client, nf_workdir): ... # NUL in the first 8 KiB → 415 binary-file
def test_engine_log_per_run(tmp_path): ... # head.sh exports NXF_LOG_FILE under the run's .spectio/ and runs
# unset NXF_DEBUG; Snakemake
# args carry --slurm-keep-successful-logs on SLURM only
def test_metrics_enabled(nf_run): ... # the trace of a finished task has realtime, %cpu and peak_rss
- Step 2:
uv run pytest packages/spectio-server/tests/test_taskfiles.py packages/spectio-server/tests/api/test_taskfiles_api.py -v: FAIL. - Step 3: implement; reads go through the bounded thread pool of the logs route.
- Step 4: the same command PASSES; on
slurm-mini(Task 22) a finished Nextflow task shows a non-empty.command.logand a Snakemake job its SLURM log. - Step 5: Commit
feat(server): each job's files and the work directory.
Task 29: Failure summary and resources per job¶
Spec: architecture §13.7 (failure reason, resources), api §5 (note 2026-10-28, Run and Resources), dev-env §15.3 req. 15 · Depends on: 4, 5, 18, 28
Files:
- Create:
packages/spectio/src/spectio/runner/sacct.py,packages/spectio-server/src/spectio_server/failure.py - Modify: the projector (resources from the Nextflow trace and the Snakemake
job_info;benchmark:files read at job end), the read API of Task 18 (failures,failures_total,resourceson task items),spectio init(resolve and store thesacctpath) - Test:
packages/spectio/tests/test_sacct.py,packages/spectio-server/tests/test_failure.py
Interfaces:
-
Produces:
SacctLimiter(one call in flight, one call per 60 s, 100 IDs per call),sacct_jobs(ids: list[str], timeout_s: float = 10) -> dict[str, SacctRow](SacctRow(state, exit_code, reason, max_rss_bytes, elapsed_s, req_mem_bytes, time_limit_s)),parse_smk_slurm_message(text: str) -> SchedulerInfo | None,build_failures(run, limit: int = 10) -> tuple[list[Failure], int]. -
Step 1: Write the failing tests
def test_req15_argv_and_id_check(fakebin): ... # ids "8812477", "8812345_3" → one argv list, no shell; "1;rm",
# 13 digits, "1_123456789" → refused before any call
def test_req15_env_explicit(fakebin): ... # the stored sacct path, SLURM_TIME_FORMAT=standard, nothing inherited
def test_sacct_parse_and_timeout(fakebin): ... # parsable2 output → SacctRow; slow fake → None after 10 s, no crash
def test_req15_limiter(fakebin): ... # 5,000 failed attempts opened → at most 50 calls, one in flight, 60 s apart
def test_req15_rebuild_no_calls(fakebin): ... # index rebuild and spool replay → 0 calls; values read from .spectio/sacct.json
def test_req15_cache_file(fakebin): ... # .spectio/sacct.json written with mode 0600
def test_req15_off(fakebin, settings): ... # [slurm] accounting = false → no call; usage_source null, "unavailable"
def test_smk_message_parsed(): ... # "… SLURM status is: 'OUT_OF_MEMORY'. Reason: Step 0: OutOfMemory."
# → state OUT_OF_MEMORY, reason, state_source engine_message;
# "… 'FAILED'. Reasons: Step 0: NonZeroExitCode, Step 1: …" → both reasons;
# "… failed due to a node failure, SLURM status is: 'NODE_FAIL'. Failed
# nodes will be excluded …" → state NODE_FAIL, reason node failure
def test_smk_message_no_sacct(): ... # "… Detailed failure reason unavailable …" → state, reason None
def test_nf_failure_summary(replayed_flaky, fakebin): ... # exit 137 on its last attempt → process, tag, exit_code,
# native_id, scheduler from sacct on first view, log_tail = last 20
# lines of .command.log
def test_failures_capped(replayed_many_failures): ... # 25 failed jobs → failures has 10, failures_total 25
def test_resources_nf(replayed_flaky): ... # task item resources: cpus, memory_bytes, time_s, realtime_s, cpu_percent,
# peak_rss_bytes, source trace
def test_resources_smk(smk_run): ... # threads and mem_mb requested; benchmark file → source benchmark;
# no benchmark → sacct batch → source sacct
- Step 2:
uv run pytest packages/spectio/tests/test_sacct.py packages/spectio-server/tests/test_failure.py -v: FAIL. - Step 3: implement;
sacctis called with--parsable2 --noheader -j <ids> --format=JobID,State,ExitCode,Reason,MaxRSS,Elapsed,ReqMem,Timelimit(fields [to be validated] onslurm-miniand at EMBL). - Step 4: the same command PASSES; on
slurm-minitheflakypipeline's injected out-of-memory task shows its SLURM state. - Step 5: Commit
feat(server): failure summary and resources per job.
Task 30: Resume, Copy to a new launch and the copy actions¶
Spec: api §5.1 (:relaunch mode resume, note 2026-10-28), api §5 (note 2026-10-28, Run, Copy to a new launch, Resume), dev-env §15.3 req. 7 and 16 · Depends on: 9, 10, 17, 18, 28
Files:
- Create:
packages/spectio-server/src/spectio_server/api/relaunch.py - Modify:
render_head_script(Task 9:LaunchSpec.resume_of: ResumeSource | None, with the parent's launch and work directories andengine_session_id),write_run_manifest(parent_run_id), the read API of Task 18 (command,parent_run_id,resumed_as,actions.resume), the launch API of Task 17 (GET /runs/{id}:launch-templatewithcode_loading), the head script of Task 9 (launch-directory lock file,stat .device and inode check,umask 022) -
Test:
packages/spectio-server/tests/api/test_relaunch.py,packages/spectio/tests/test_headscript.py(new cases) -
Step 1: Write the failing tests
def test_resume_failed_creates_child(authed_client, failed_run): ... # 201, child queued, parent_run_id set, parent stays failed
def test_resume_states(authed_client): ... # succeeded or running → 409; cli-observed → 422 not-in-this-version
def test_resume_actions_field(authed_client): ... # actions.resume {allowed, reason} for each state; resumed_as [{id, name, state}]
def test_resume_other_fields_422(authed_client): ... # mode rerun_from, params_patch → 422 not-in-this-version
def test_req16_relaunch_origin_csrf(authed_client): ... # no Origin, foreign Origin, or no CSRF token → 403, no child
def test_req16_parent_head_unverified_409(authed_client): ... # parent head on another node, or another boot ID → 409
def test_req16_one_child_parallel(authed_client): ... # two parallel POSTs → one 201, one 409 (BEGIN IMMEDIATE)
def test_req16_no_unlock_when_live(tmp_path): ... # a live Snakemake started by hand in the directory → no --unlock, 409
def test_req16_dir_swapped(tmp_path): ... # launch dir replaced after the check → head refuses (stat . dev/inode)
def test_req16_planted_config(authed_client): ... # nextflow.config or .nextflow/ owned by another uid or group-writable → refused
def test_req16_session_id_uuid(authed_client): ... # engine_session_id "x; rm -rf ~" → refused before the head script
def test_resume_head_script_nf(tmp_path): ... # cwd = parent's launch dir, "-resume <session id>", "-w <parent work>",
# spool and NXF_LOG_FILE in the child's .spectio/, umask 022, lock file
def test_resume_head_script_smk(tmp_path): ... # "--unlock" only when the head is proven gone, then "--rerun-incomplete"
def test_resume_rechecks_dirs(authed_client): ... # parent launch dir made group-writable → refused (req. 10)
def test_command_quoted(authed_client): ... # location "/p/a b'$(x)" → argv list; text is POSIX-quoted, names the
# params file, never a value; cli-observed → command null
def test_launch_template(authed_client): ... # params, profiles, location; non_default lists the changed keys
def test_req16_code_loading_flagged(authed_client): ... # custom_config_base, -c, --configfile, a local location → in
# code_loading; writable_by_others true for a group-writable path
- Step 2:
uv run pytest packages/spectio-server/tests/api/test_relaunch.py packages/spectio/tests/test_headscript.py -v: FAIL. - Step 3: implement; the child's run directory holds its own
.spectio/, and its head runs in the parent's launch directory. - Step 4: the same command PASSES; on
slurm-mini(Task 22) aflakyNextflow run, fixed by a parameter file edit, resumes and reports its earlier tasks as cached. - Step 5: Commit
feat(api): resume a run and copy it to a new launch.
Task 31: Results and reports¶
Spec: api §5 (note 2026-10-28, Results), dev-env §15.3 req. 13 and 14 · Depends on: 17, 18, 28
Files:
- Create:
packages/spectio-server/src/spectio_server/api/results.py - Modify: the launch API of Task 17 (
runs.results_dir: Nextflowparams.outdir, resolved against the launch directory; Snakemake the launch directory, without.snakemake/and.spectio/); the web app's report viewer page and its CSP (frame-src 'self'); the token link page and the CLI ("Spectio never asks you to paste a link") -
Test:
packages/spectio-server/tests/api/test_results.py,tests/e2e/report.spec.ts -
Step 1: Write the failing tests
def test_results_listing(authed_client, results): ... # one level, dirs first, folder size null, no recursion;
# exists false before the directory is created
def test_req14_scandir_cap(authed_client, results): ... # 5,000 entries → 1000 read, truncated, scandir stopped there
def test_results_reports(authed_client, results): ... # multiqc_report.html first, FastQC grouped, .html/.htm only, depth ≤ 4
def test_req14_report_search_caps(authed_client, big_results): ... # stops at 10,000 entries or 5 s; cached after the run ends
def test_req13_results_confined(authed_client, results): ... # ../, absolute, symlink to $HOME → 422 or "link outside the run"
def test_req14_headers(authed_client, results): ... # .html: CSP sandbox allow-scripts allow-downloads, no
# allow-same-origin, frame-ancestors 'self'; .svg, .xhtml, .xml →
# attachment with CSP sandbox; all: no-store, no-referrer, nosniff
def test_req14_stream_cap(authed_client): ... # a fifth follow stream in one session → 429
def test_observed_run_no_results(authed_client): ... # cli-observed → 404
test("req14: a report in the viewer cannot open windows, navigate the top page or leave", async ({ page }) => {
/* fixture report calls window.open, sets top.location, clicks an external link: all blocked; banner shown */ });
test("a MultiQC report renders in the viewer and cannot call the API", async ({ page }) => { /* real report fixture */ });
- Step 2:
uv run pytest packages/spectio-server/tests/api/test_results.py -v: FAIL. - Step 3: implement.
- Step 4: the same command PASSES, and the Playwright test passes with a MultiQC report from the nf-core/rnaseq megatest (whether MultiQC renders under the policy is [to be validated] here).
- Step 5: Commit
feat(api): results and reports, read only.
Task 32: The screens: job files, failure summary, results, Resume, copy and resources¶
Spec: ui-spec §4.4 (note 2026-10-28, run tools) · Depends on: 20, 28–31 · Boards: LiteRunDetail, MLiteRunDetail, LiteRunTasks, MLiteRunTasks, LiteTaskFiles, MLiteTaskFilesSheet, LiteTaskFilesStates, LiteRunLogs, MLiteRunLogs, LiteRunFailed, MLiteRunFailed, LiteRunActions, MLiteResumeSheet, LiteResults, LiteReportViewer, LiteLaunch
Files:
- Create:
web/src/runs/{RunHeader.tsx,TaskFilesDrawer.tsx,FailureSummary.tsx,ResultsSection.tsx,ResultsBrowser.tsx,ReportViewer.tsx,ResumeDialog.tsx},web/src/runs/TaskFilesDrawer.test.tsx -
Modify:
web/src/runs/RunDetailPage.tsx,web/src/launch/LaunchPage.tsx(prefill from:launch-template),tests/e2e/skeleton.spec.ts -
Step 1: Write the failing tests
test("Files opens the drawer on .command.log for a failed attempt; Escape returns focus to the row's button", …);
test("a missing file says why, in words, and its tab label carries the state", …); // every LiteTaskFilesStates panel
test("the size line says 'whole file' or 'last 64 KiB of …'; Download says 'Download last 1 MiB'", …);
test("tabs are buttons with role tab; the drawer is aria-modal", …);
test("failed run: Why it failed shows SLURM state, requested and used memory, last lines", async ({ page }) => { /* … */ });
test("Resume asks, creates a child, and both runs link to each other", async ({ page }) => { /* … */ });
test("a refused Resume stays visible, aria-disabled, with its reason as text", async ({ page }) => { /* … */ });
test("the Copy menu is in the shared header on every tab and state, and announces 'Copied'", async ({ page }) => { /* … */ });
test("a report opens in the viewer with its 'Untrusted report' banner", async ({ page }) => { /* … */ });
test("Copy to a new launch opens the form prefilled with the non-default values listed", async ({ page }) => { /* … */ });
test("resources columns show 'near limit' as text", async ({ page }) => { /* … */ });
- Step 2:
npm --prefix web testand the Playwright smoke: FAIL. - Step 3: implement; native
<button>and<input>only, per-row accessible names, nothing hover-only (CLAUDE.md). - Step 4: both PASS on the desktop and phone projects; render the boards and compare by eye.
- Step 5: Commit
feat(web): job files, failure summary, results, resume and copy.
Out of scope¶
Everything that dev-env §15.1 marks "no" for 0.0.1 or "later (open)":
- QC checks and gates in every form;
- Kubernetes, a shared server and remote runners;
- users, sign-in, accounts and roles;
- relaunch with changed parameters, rerun from a stage, and the parameter history (#61); Resume and Copy to a new launch are in (Task 30);
- visualisation (DAG, timelines, charts, comparison);
- full scheduler monitoring (task jobs, pending reasons, accounting, capacity);
- Postgres, Compose and Helm;
- the depictio hand-off, WES, MCP, embedded elements and LabID.
Also not in P1:
- pause, and resume of a paused run (Resume after a failure is Task 30);
- a workflow catalogue and Git URLs (D2);
POST /runs:validateand file checks;- publishing the bridges (Thomas, inbox #66), Cancel and the other actions on runs started from a terminal (#65), and a
spectio run --wrapper; - the head as an
sbatchhead job, and sites other than EMBL (log 2026-10-28); - slurmrestd;
- the spike sec-single-mode (R10 C6);
- everything before the 0.0.1 tag (R10 C5–C8).
Done when¶
- Tasks 1–32 are merged, each with its tests first and its
review-codepass (Assumptions, Process). - On
slurm-miniin CI (pipelines-slurm.yml, jobe2e), through a token-link session, the synthetic Nextflow and Snakemakehappyruns launch with their heads in tmux on the submitter node, endsucceeded, and match the golden counts per process (Task 22). - A running run of each engine cancels to
cancelledwith no job left, and a server killed while a Nextflow head runs adopts it on restart, with no duplicate event (Task 23). - Each of §15.3 requirements 1–7 has passing
test_req<N>_*tests (Tasks 3, 11–16), requirements 8–12 their named tests (Tasks 2, 9–12, 24–26), and requirements 13–16 theirs (Tasks 28–31). - The Playwright smoke passes on the local executor: link, home, launch, list, detail (stages and the flat list) with live refresh, cancel (Task 20).
- A server started with no database rebuilds it from the run directories (Task 11).
-
spectio openand the SSH config entry both open home on an EMBL login node (Task 24). - A run started by hand with the bridge enabled shows in the runs list as started from a terminal, monitor only, and a missing
~/.spectioleaves the run unaffected (Task 25). - Both bridges build as release packages, dry-run only; nothing is published (Task 26).
- The run detail shows declared or detected stages above the processes or rules list, and the detectors match their golden output on nf-core/rnaseq, sarek, methylseq and mosaicatcher-pipeline (Task 27).
- A failed run shows each job's files, the failure summary with its SLURM state, and resources per job; it resumes as a child run; a MultiQC report opens in its sandbox (Tasks 28–32).
-
tests/packaging/test_wheel.shpasses: one installable wheel with the UI and both bridges, and aspectioenvironment under 80 MB (Task 21). - Tier 0 is green on every package: ruff, mypy (
--strictonspectio-sdk),lint-imports. - api §14 (driven runs) is written, and every spec the code contradicted is updated with a log entry;
python3 tools/docs/check.pypasses. - Thomas has confirmed or corrected the Assumptions above (the inbox entries named there), before the 0.0.1 tag.
Open questions¶
- D2 and D3 (inbox #34 for SR-056, inbox #55) are reserved for Thomas; the plan follows R10's recommended options as Assumptions 2 and 3.
- The bootstrap-page gap (Assumption 8): §15.3 req. 2 should name the session-free page; it is to be recorded in the next apply pass.
- Where the Snakemake mapping lives: Assumption 16 replaces the [to be validated] of api §7.2 for 0.0.1. Thomas, or spike T1's resume check (#32), may change it.