Skip to content

Add a read-only status command for the event queue file - #88

Merged
anth-volk merged 4 commits into
mainfrom
claude/read-only-status-command
Nov 5, 2026
Merged

anth-volk merged 4 commits into
mainfrom
claude/read-only-status-command

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #87

Summary

This adds microcosm-emitter status, a read-only command that prints each run in an emitter's event queue file. It brings back only the read-only status part of the closed #36. Microcosm's Modal runner is to run it in the container after a build (Microcosm task 1a · 5, not yet written). It will record in the stage receipt how many events each run left undelivered, without reading the file itself.

  • Which file: --event-queue-file PATH, optional. Without it, status reads the default path for the environment it runs in. That is the interface's default_spool_path(): $XDG_CACHE_HOME/microcosm/telemetry/events.sqlite3, or ~/.cache/microcosm/telemetry/events.sqlite3 when XDG_CACHE_HOME is unset or blank.
    • The emitter host opens whatever file the build that starts it names.
    • A build given no path computes this same default from its own environment.
    • So the default is the build's file only when the build passed no path and status runs with the same environment.
  • Output: one JSON object on standard output, {"runs": [{"run_id", "producer_id", "upload_state", "reason", "undelivered_events"}]}.
    • Runs are sorted by run ID, then producer ID.
    • Every run is listed, including runs with no undelivered events.
    • reason is the run's local_only_reason column, or null.
    • undelivered_events counts the run's events the file still holds. The emitter deletes an event once the collector acknowledges it, but its pruning also deletes undelivered events older than 7 days or beyond 100 MiB. So 0 means none are held, not that every event was delivered.
  • Read-only:
    • The file is opened in SQLite's read-only mode (mode=ro) through the new core.database.create_read_only_engine. It is never opened through the class the emitter's own modules use, which would create a missing file, make the file and its directory private to this user, migrate it, and prune events older than 7 days.
    • Only the five printed columns are selected, so no run's registration JSON is decoded.
    • So status makes no network connection, and doesn't deliver, prune, migrate, create, or change any file, directory or permission.
    • The one exception is a file another program switched to WAL mode, which no emitter does: SQLite then creates -wal and -shm files beside it when the directory is writable.

Exit codes

Each failure prints one line on standard error naming the path, and nothing on standard output. telemetry-token's codes 3 to 7 are unchanged; #36's renumbering into one ExitCode table is left out.

Exit code Meaning
0 The runs are printed
2 A usage error (argparse)
8 No file exists at the path; nothing was read
9 The file can't be read as an event queue file. One of: this user may not read it; it isn't an event queue file (not a regular file, not a SQLite database, or a database without a migration revision); it records a migration revision this emitter doesn't know (a file a newer release wrote, or a hand-over database); the journal beside it holds a write that didn't finish, which only a connection that may write can roll back; any other database or operating-system error, named by SQLite's error code or the error's type

How errors are handled

  • telemetry/service/run_status.py catches only SQLAlchemyError and OSError, and turns them into UnusableEventQueueFileError with a Problem.
  • cli.status catches only that error and MissingEventQueueFileError. There is no catch-all, so any other error is a defect and ends with Python's traceback. status holds no credential, so a traceback can't print one.
  • Each Problem member's value is its line for standard error, so no problem can lack a message.
  • SQLite's error is recognised by the driver exception's sqlite_errorname (SQLITE_READONLY_ROLLBACK, SQLITE_NOTADB), so no module imports the driver (tests/test_boundaries.py).
  • The revision check doesn't catch Alembic's errors either. core.migrations gains two helpers:
    • recorded_revisions, which reads the version table;
    • known_revisions, which lists the packaged event queue history.

No new suppression

This PR adds no inline suppression, so rules/baselines/noqa-comments.txt is unchanged at 140 entries (146 lines, including its 6-line header).

  • cli.status imports microcosm_emitter.telemetry.service.run_status inside the function, so that host, version and telemetry-token still load no database code.
  • The new module is added to banned-module-level-imports in pyproject.toml, as Fix Ruff's typing-only import, annotation and in-function import findings (TC, ANN, PLC) #65 added microcosm_emitter.host.__main__ for the same kind of deferred import in cli. Ruff then forbids importing it at module level (TID253), and its PLC0415 accepts the function-level import without a # noqa.
  • No application module imports it at module level.
  • Decided: Anthony approved this entry on 2026-11-04.

Environment variables

There are no new variables. Without --event-queue-file, status reads XDG_CACHE_HOME, and HOME through Path.home(), from the process that runs it, through the interface's existing default_spool_path(). A relative XDG_CACHE_HOME resolves against that process's working directory.

The emitter host reads neither variable: it opens the path the build sends it. Microcosm's runner (1a · 5) will run status with the build's environment. Nothing else is read: no credential, collector token or collector origin.

Tests

tests/telemetry/test_status_command.py is new. Each test was run locally on macOS with Python 3.14.6, selected by node ID from an explicit list. Node IDs below are under tests/telemetry/test_status_command.py::.

Test Checks Red Green
test_status_prints_each_run_and_changes_nothing The JSON for four runs registered out of order, two producers of one run, a pending reason, a local-only run and a run with no events. Events aged 8 days are still counted. The directory listing, modes 0755 and 0644, sizes and SHA-256 are unchanged. Every socket connection fails the test 5aab484: failed, assert 2 == 0 59f1c08: passed
test_status_reads_the_default_path_for_its_environment (3 cases; renamed in round 1) Without the option: XDG_CACHE_HOME set, blank, or unset with HOME 5aab484: 3 failed, assert 2 == 0 59f1c08: 3 passed
test_status_exits_8_without_an_event_queue_file (2 cases) A named path and the default path: exit 8, one line naming the path, nothing on standard output, no directory created 5aab484: 2 failed, assert 2 == 8 59f1c08: 2 passed
test_status_exits_9_for_a_file_it_cannot_use (8 cases) Other content, zero bytes, a directory, a hand-over database, a newer revision, no read permission (skipped as root), a journal holding an unfinished write, and (round 1) a known revision without the telemetry tables, which reaches the fallback message cannot be read (SQLITE_ERROR). Each: exit 9, the matching message with the path, and the directory unchanged 5aab484: 7 failed, assert 2 == 9. The round-1 case passed at 9debc93 already; with the fallback message's wording changed temporarily, it failed, and the change was reverted 59f1c08: 7 passed; e6b7d31: 8 passed
test_status_prints_a_run_whose_registration_is_not_an_object (round 1) A run whose registration JSON is a list is still printed, with the command run as a program 9debc93: failed, assert 1 == 0 (a ValueError traceback from decoding the registration) e6b7d31: passed
test_status_help_names_the_event_queue_file status --help names --event-queue-file and the event queue file 5aab484: failed, assert 2 == 0 59f1c08: passed
  • Red runs: 14 failed at 5aab484. At 9debc93, 1 failed and 15 passed.
  • Green run: 16 passed at e6b7d31.
  • Lint: ruff format --check and ruff check passed on the changed Python files only.
  • CI runs the rest: only these tests and only the changed files were run locally. CI runs the rest of the suite, the quality guards (including the inline-suppression count), the rules registry, the package builds and the collector job.

Review round 1

The review found no bugs. These changes come from its code-quality points:

  • the pruning wording;
  • the default-path wording;
  • the five-column query;
  • the fallback-message test;
  • messages carried by Problem;
  • the README no longer says the runner already runs status;
  • the fragment's full exit-9 list;
  • the WAL note in create_read_only_engine's docstring;
  • the baseline count in this description.

Not verified

  • The command has not run inside a Modal container. That happens in Microcosm's runner change (1a · 5), which also waits for the emitter host to finish before running status.
  • The case of a journal holding an unfinished write is built by turning off SQLite's syncing for one connection, as in Report on and deliver a spool database copied from a build on another machine #36, not taken from a real interrupted commit.
  • The permission case assumes a user other than root, as on GitHub's hosted runners; as root it is skipped.

Release

The fragment is added, so merging this releases 0.13.0 of both distributions to PyPI through push.yaml.

🤖 Generated with Claude Code

anth-volk and others added 4 commits November 4, 2026 15:08
Each run's upload state, reason and undelivered events as JSON, read
without changing the file; exit 8 without a file and 9 for one it cannot
use (microcosm-emitter#87).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`microcosm-emitter status` prints each run's upload state, reason and
undelivered events as JSON, reading `--event-queue-file` or the file the
emitter itself uses in SQLite's read-only mode. It exits 8 without a file
and 9 for one it cannot use, catching only database and operating-system
errors (microcosm-emitter#87).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run whose registration JSON is not an object is still printed, and a
known revision without the telemetry tables gets the message that names
SQLite's error code. The default-path test is named for what it checks:
the default path for the environment status runs in (review round 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
status selects the five columns it prints, so it no longer decodes a
run's registration JSON. Each Problem's value is its message, so none can
lack one. The README, the fragment and the docstrings now say that pruning
also deletes undelivered events, that the default path is the one for the
environment status runs in, and that a WAL-mode file gets -wal and -shm
files; the README no longer says Microcosm's runner already runs status
(review round 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review November 5, 2026 12:05
@anth-volk
anth-volk merged commit e2f627f into main Nov 5, 2026
14 checks passed
@anth-volk
anth-volk deleted the claude/read-only-status-command branch November 5, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a read-only status command that counts each run's undelivered events

1 participant