Skip to content

feat: sp — an AI-friendly CLI for investigating CI failures - #1

Open
gaurav02081 wants to merge 10 commits into
CCExtractor:masterfrom
gaurav02081:feat/initial-cli
Open

feat: sp — an AI-friendly CLI for investigating CI failures#1
gaurav02081 wants to merge 10 commits into
CCExtractor:masterfrom
gaurav02081:feat/initial-cli

Conversation

@gaurav02081

@gaurav02081 gaurav02081 commented Jun 23, 2026

Copy link
Copy Markdown

sp is a command-line client for the Sample Platform REST API. It exists so a developer — or an AI agent — can investigate a CI failure end to end from the terminal, without the web frontend. Output is JSON by default so it can be piped into jq or driven by a script, with -o table for humans.

This is a new repository, so this PR is the whole tool. It is large, but the 10 commits are each self-contained and green, and GitHub will show them individually — reviewing commit by commit is much easier than reading the combined diff, and the order below is the order they were built in.

If you would rather have this as a series of smaller PRs, say so and I will split it — the branches are already prepared. It is not stacked here only because chaining PRs needs the base branches to exist on CCExtractor/sp_cli, which I cannot push to.

Reading guide

# Commit What it adds
1 feat: sp, a command-line client… The client, rendering, classifier, and the first commands
2 feat: mirror the API's validators… constants.py — every enum the API validates, as Click choices
3 feat: sp investigate --with-history Cross-run verdicts: is this failure new?
4 feat: run errors, logs, artifacts… The endpoints from sample-platform#1135 and #1141
5 feat: run create, regression/category CRUD… Writes and administration (#1160)
6 feat: saved login session, colorized codes… The human-facing conveniences
7 docs: document the full command surface… README: commands, exit codes
8 fix: retry transient GET failures… Found by running against production
9 fix: degrade gracefully when a sample's history… Same
10 fix: ten defects found by code review A review pass over the whole branch

What to look at first

classifier.py / triage.py — the reason this tool exists. Every failure gets a stable code (SEGFAULT, ABORT, TIMEOUT, EXIT_CODE_MISMATCH, MISSING_OUTPUT, OUTPUT_DIFF), so you get a straight answer about why a test failed without reading logs.

constants.py — every enumeration the API validates, mirrored so bad input fails instantly as a usage error instead of costing a round trip. Each value cites the validator it mirrors. The ones easy to get wrong: /runs?status= accepts only queued|running|canceled (pass/fail are per-sample outcomes, not run states); /regression-tests?active is a two-way switch with no "list everything"; and there are seven token scopes, not six — system:write is separate from system:read.

history.pysplit_history does filtering the endpoint does not. /samples/{id}/history returns entries for every regression test on the sample and includes the run being investigated, so both must be stripped before any verdict means anything.

client.py — GET is retried with backoff; POST and DELETE never are. POST /runs is not idempotent, and a repeated DELETE turns a success into a confusing 404.

Testing

182 tests; isort, pycodestyle, pydocstyle, and mypy clean at every commit, not just at the tip.

Beyond unit tests, this was exercised two ways that changed the code:

  • Against a live mod_api from an upstream/master worktree on a copy of the dev database. That found a real bug: system:write was missing from the scope list, which silently made every sp admin write impossible.
  • Against production, read-only, 9286 runs. That found the missing retry (one timeout aborted a 45-sample investigation) and run output having no way to get the actual file. It also confirmed the classifier on real SEGFAULT / OUTPUT_DIFF / MISSING_OUTPUT failures — the dev database only ever produced one of those.

Commit 10 is a code-review pass; the subtle fixes there were revert-checked, meaning the old code was restored to confirm the new tests actually fail against it.

Known limitation

investigate --with-history returns UNKNOWN verdicts against production, because GET /samples/{id}/history times out there — CCExtractor/sample-platform#1161, filed with a diagnosis and a suggested fix. The CLI degrades gracefully rather than failing, and NEVER_PASSED drops to low confidence when the window was truncated. Everything else works against production today.

sp lets a developer or an AI agent investigate CI runs end-to-end from the
terminal (no web frontend): list/show runs, summaries, classified failures,
expected-vs-actual diffs, logs, errors, artifacts, samples, regression
tests, auth, and a one-shot `investigate` triage command.

Output defaults to JSON for agents, with a -o table human view. The API
address is configurable via --base-url / SP_BASE_URL. Includes a test suite
(pytest), lint/type configs (pycodestyle, pydocstyle, isort, mypy), and a
GitHub Actions CI workflow.
Every enumeration the REST API validates is now restated in sp_cli/constants.py
and wired into the commands as click.Choice, so a bad --status or --platform
fails instantly as a usage error instead of costing a round trip and coming
back as an HTTP 400.

The values are not invented; each one mirrors a specific validator in the
merged mod_api blueprint, and the module says which. The ones that are easy to
guess wrong:

- /runs?status= only accepts queued|running|canceled. It is derived from the
  latest TestProgress row, so pass/fail -- which are per-sample outcomes, not
  run states -- are deliberately absent. Use `sp run summary` for those.
- _VALID_SAMPLE_STATUSES is pass|fail|missing_output|not_started. No 'skipped',
  no 'running'.
- /regression-tests?active is a two-way switch, not tri-state: omitting it
  lists active tests only, so --active/--all was wrong and is now
  --active/--inactive.
- There are seven token scopes, not six: system:write is separate from
  system:read so a monitoring token cannot reconfigure the platform.

Also fills in filters the API supports but the CLI was not exposing (sample
--sha256/--status, queue --limit/--offset, run --repository/--sort and the
date window).

ApiContractTests pins all of this, so a validator changing upstream fails here
loudly rather than in production.
The classification codes say what broke, but not whether it is new. This adds a
cross-run verdict to every failure so the first question after a red build --
"did I break this?" -- is answered without opening the web UI.

Verdicts: NEW_REGRESSION (passed in the previous run, start reading here),
STILL_FAILING, NEVER_PASSED, FLAKY (two or more pass/fail flips), NO_HISTORY,
UNKNOWN. --history-depth N implies the flag.

The filtering in history.split_history is the part worth reviewing: the
/samples/{id}/history endpoint returns every regression test for a sample and
includes the run being investigated, so both have to be stripped before any
verdict is inferred. Leaving the current run in makes everything look
STILL_FAILING; leaving sibling tests in makes unrelated failures look like
history for this one.

Costs one lookup per distinct media sample, filtered to the run's platform and
cached across regression tests that share a sample -- so a 200-test run with 30
distinct failing samples is 30 calls, not 200.
sample-platform#1135 (errors, logs) and #1141 (test artifacts) are merged, so
the three commands that shipped as PENDING stubs are now real, and the
endpoints they left uncovered get commands of their own.

  sp run error-summary <id>   grouped counts -- the cheapest first look
  sp run errors <id>          per-test errors, --type/--severity/--sample
  sp run infra-errors <id>    VM / checkout / build / worker / storage
  sp run logs <id>            build log, --level/--source/--contains
  sp run artifacts <id>       binary, coredump, outputs, build log

Three contract details that shaped this:

- /runs/{id}/logs is the only cursor-paginated endpoint; sending it an offset
  is a 400 ("Cannot mix cursor and offset pagination"). Hence
  client.get_cursor_paginated alongside get_paginated, and --cursor/--all
  rather than --offset. The table footer reported next_offset and silently
  dropped next_cursor, so a partial log looked complete.
- A missing log answers 404 with code log_not_found, distinct from a missing
  run's not_found, and points at the artifacts endpoint. Both map to exit
  code 4, so callers must branch on error.code, not the status.
- The old --type help advertised 'test_failure', which the API cannot emit.
  Test errors are derived per request and are only ever exit_code_mismatch,
  missing_output, or diff_mismatch.

/runs/{id}/samples/{sid}/logs deliberately gets no command: it is a permanent
404 by design, because the CI worker does not store per-sample logs.
Covers every remaining merged endpoint, so the CLI is no longer read-mostly:

  sp run create                    POST /runs -- queue a run
  sp regression show/create/edit/rm
  sp category ls/create/edit/rm
  sp sample details                upload record, extra files, media info
  sp auth whoami/users/set-role
  sp admin maintenance/pause/resume
  sp admin blocked-users ls/add/rm
  sp admin forbidden-extensions ls/add/rm

409 conflict now maps to its own exit code (8). A refused delete is not a
validation failure -- the body was fine, the world disagreed -- and callers
need to tell "this test still has 23 results" apart from "your request was
malformed". Both delete paths return it, and both suggest the alternative:
retire with `edit --inactive` rather than deleting.

Request-shape details that are easy to get wrong, all pinned by tests:

- Regression-test categories are given by name, not id, must already exist,
  and on PATCH the list is replaced rather than merged.
- A new regression test is created inactive unless --active, matching the
  API's default: a maintainer should see what it produces on a verification
  run before it joins the suite. So `active` is sent only when asked for,
  otherwise "off" is indistinguishable from "unset".
- POST /runs needs a full 40-char commit_sha and owner/repo -- both rejected
  locally -- and schedules a test of an existing CI artifact rather than
  triggering a compile.
- Forbidden extensions are stored lower-cased without a leading dot, so .MKV
  and mkv normalize to the same request.
- Blocked users key on the numeric GitHub id, not the login: logins can be
  changed and reused, which would silently unblock somebody.

PATCH bodies are sparse -- only the flags you pass are sent -- and an empty
edit is a usage error rather than a pointless round trip.
Three human-facing conveniences, all bound by the same rule: machine output
stays clean. Every one of them is suppressed when the output is not a live
terminal, and none of them can reach stdout in JSON mode.

Saved session (sp_cli/config.py)
  `sp auth login` writes the token to ~/.config/sp/config.json so it does not
  have to be pasted into SP_API_TOKEN for every shell. Precedence is --token >
  SP_API_TOKEN > the file, so an explicit credential always wins. --no-save
  opts out for shared machines.

  The file is created through os.open at mode 0600 rather than chmod-ed
  afterwards, so the token is never briefly world-readable, and a warning is
  printed if an existing file has looser permissions. A corrupt file degrades
  to "logged out" instead of breaking every command, and `sp auth logout`
  clears it even when the server call fails -- an already-expired token must
  not be left behind on disk.

Colour (sp_cli/output.py)
  The code and verdict columns are colorized by severity. Padding is applied
  before styling, because escape codes have no display width and colorizing
  first pushes every later column out of line. Gated on table mode, a TTY,
  NO_COLOR, and --no-color.

Spinner (sp_cli/progress.py)
  Shown during the multi-page calls -- run failures, run logs --all,
  investigate -- and drawn on stderr, self-erasing, so even when it does run it
  cannot contaminate a payload being parsed on stdout.

`sp shell`, the fourth item on the backlog, is dropped rather than built: a
REPL is a second interface every future command must work in, it loses the
pipes that make the CLI useful, and the primary consumer is an agent driving
one-shot commands.
The README still showed only the handful of commands the first draft had, and
described `sp run logs` as "raw run logs" from when it was a stub.

Groups the commands by what you are actually doing -- investigating a failure,
browsing, maintaining tests, administering -- rather than listing them flat,
and documents `sp auth login` as the way to authenticate now that the token is
saved.

Adds the exit-code table. Scripts and agents branch on these, so they belong in
the README rather than only in the client docstring.
Both of these are things a live run against production surfaced that the
mocked tests could not.

Retry
  `investigate --with-history` makes one call per failing sample. On a real
  run -- 45 failures, ~1.5s per request -- that is a 60-90 second window, and a
  single 30s read timeout partway through aborted the whole command with exit
  3, discarding every lookup already done. That is exactly what happened.

  Failed GETs are now retried with exponential backoff plus jitter, for
  connection errors, read timeouts, 429 and 5xx. Retry-After is honoured when
  the server sends it, but clamped to 30s so a header of 3600 cannot hang the
  CLI. Jitter is there so a fleet of agents riding out the same blip does not
  resynchronise into a second thundering herd.

  Only GET is retried. POST /runs is not idempotent -- a retry racing a
  slow-but-successful first attempt would queue the run twice -- and a repeated
  DELETE turns a success into a confusing 404. 4xx other than 429 is
  deterministic, so asking again just wastes a round trip.

  Notices go to stderr, never stdout, so JSON stays parseable. --retries 0
  restores the previous fail-fast behaviour.

--decode
  The output endpoint returns the file base64-encoded inside a JSON envelope,
  so `sp run output` printed a multi-kilobyte blob that no one can read and no
  diff tool can consume. --decode writes the decoded bytes to stdout instead:

      sp run output 9388 11 --decode > actual.srt

  Written to sys.stdout.buffer rather than echoed, because these are subtitle
  files carrying CRLF and sometimes non-UTF-8 bytes -- re-encoding them would
  corrupt the very diff you are trying to read. Verified against production:
  5562 bytes out, CRLF intact.

Two existing client tests now pass retries=0. They assert the exit-code
mapping, not the backoff, and 429 is retryable -- leaving the default on made
them sleep for seconds.
Verified against production: /samples/{id}/history returns 504 there, and one
failed lookup aborted the entire investigation with exit 3 -- discarding the
run summary and all 45 classified failures to report a single missing verdict.
The classification is the bulk of the answer and it was already in hand.

A failed lookup now marks that row UNKNOWN and carries on, and the report gains
a `history_incomplete` block naming the samples that could not be fetched, so
the gap is visible rather than silently absent.

After three consecutive failures the endpoint is treated as down and the
remaining rows are marked without calling it. Without that circuit breaker a
broken endpoint costs one full timeout per failing sample -- 45 samples at 30s
is a 22-minute hang for an answer that is not coming.

Run 9388 on production now completes in ~1.5 minutes with all 45 failures
classified (24 MISSING_OUTPUT, 20 OUTPUT_DIFF, 1 SEGFAULT), where before it
returned nothing at all.

Note this is damage control, not a fix for the underlying problem: the verdicts
are all UNKNOWN on production because the endpoint paginates in Python after
loading every result for the sample across every run, so ?limit=5 does not
reduce the work. That needs fixing in sample-platform.
@gaurav02081 gaurav02081 changed the title feat: sp — command-line client for the Sample Platform REST API feat: sp — core CLI, HTTP client, and failure classifier (1/6) Aug 5, 2026
@gaurav02081 gaurav02081 changed the title feat: sp — core CLI, HTTP client, and failure classifier (1/6) feat: sp — an AI-friendly CLI for investigating CI failures Aug 5, 2026
@gaurav02081

gaurav02081 commented Aug 6, 2026

Copy link
Copy Markdown
Author

@cfsmp3 @canihavesomecoffee ready for review whenever you have time.

sp is a CLI for the Sample Platform API. The goal is to investigate a CI failure end to end from the terminal without the web UI, with JSON output by default so an agent or a script can drive it.

I tested it against the live deployment on a real PR run (9388, linux, commit f7e1d378b6). What it reported:

  • 237 samples, 192 passing, 45 failing — classified as 24 MISSING_OUTPUT, 20 OUTPUT_DIFF, 1 SEGFAULT. Those reconcile with /runs/9388/summary (21 fail + 24 missing_output = 45).
  • run diff and run output --decode gave the actual expected-vs-actual subtitle text for the failing tests.
  • run errors, run logs, run artifacts all worked against real data.

Two findings worth your attention:

  1. GET /samples/{id}/history returns 504 on production data. Filed as GET /samples/{id}/history times out (504) on production data sample-platform#1161 with a diagnosis — it paginates in Python after loading every result for the sample, so ?limit=5 does not reduce the work. It means investigate --with-history (the "is this failure new?" part) cannot work against production yet. Everything else can.
  2. The client had no retry, so a single 30s timeout aborted a 45-sample investigation partway through. Fixed in this PR.

The PR is large because it is the whole tool, but the 10 commits are each self-contained and green, so reviewing commit by commit is much easier than the combined diff. I can split it into ~6 smaller PRs if you would prefer — the branches are already prepared.

A high-effort review of the branch confirmed ten defects. They cluster in three
areas, and several were in code written an hour earlier -- completing a
production run was not evidence it was correct.

Credential handling
  Running `pytest` overwrote the developer's real ~/.config/sp/config.json: one
  login test omitted --no-save and CliCommandTests has no XDG isolation. It had
  already fired on this machine, leaving the literal `spci_x` behind. Adding
  --no-save to that one test is not the fix -- relying on every future test
  remembering it is what failed. tests/conftest.py now redirects
  XDG_CONFIG_HOME and HOME for every test automatically.

  `auth logout` cleared the saved session unconditionally, so revoking a
  scratch token from SP_API_TOKEN deleted an unrelated 30-day credential that
  cannot be recovered. It now compares the effective token against the saved
  one by value. The error path splits too: 401/403 still clears, because the
  token is dead either way, but a dropped connection leaves it alone rather
  than discarding a token that is probably fine.

  save_token relied on os.open's mode, which is ignored for a file that already
  exists -- so re-login wrote a fresh token into a still-0644 file and chmod-ed
  it afterwards. It now fchmods the descriptor before any byte is written.

investigate --with-history
  Three bugs that only appear with more than one failing regression test per
  sample, which is why the original tests missed them: a failed lookup was
  never memoised, so it was re-called and re-counted for every row sharing that
  sample and one bad sample tripped the breaker; the given_up check preceded
  the cache check, discarding history already fetched successfully; and
  --history-depth was unbounded although the API rejects limit > 100, so
  --history-depth 100 silently disabled all history while still exiting 0.

  The window itself was also wrong, and only half of that is fixable here. The
  endpoint pages over *all* of a sample's regression tests and slices before
  the CLI can filter, so a page of N gave roughly N/(tests on the sample) runs
  of the test under investigation -- reporting NEVER_PASSED for a test that
  passed nine runs ago. The CLI now requests a full page and applies the depth
  after filtering, and when a saturated page still leaves a short window it
  sets window_truncated and drops NEVER_PASSED to low confidence: "never
  passed" and "did not pass in the few runs I could see" are different claims.
  A real fix needs sample-platform#1161, and the docs now say so.

Output contracts
  --decode ignored the envelope's truncated flag, writing a file that ends
  mid-stream and looks complete -- every diff against it reports spurious
  missing lines. It is now refused, naming download_url, with --allow-truncated
  as an explicit opt-in.

  `auth revoke` and `auth logout` printed English sentences to stdout, so
  piping either into jq failed while every other command worked. Both now
  render JSON.

  LOG_MAX_LIMIT mirrored a 1-500 clamp that sits behind a validator rejecting
  anything over 100, so it was unreachable and the help advertised a page size
  the API refuses. Deleted rather than corrected: it duplicated a rule that is
  uniform across both paginators. That audit found 33 other unvalidated
  --limit/--offset options, now all bounded by IntRange.

The subtle fixes were revert-checked rather than trusted: restoring the old
code makes the new tests fail, including "token written while mode was 0o644".
182 tests, up from 166.
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.

2 participants