Skip to content

feat(api): GET /runs — list runs filtered by status + initial_input#78

Merged
viktor-shcherb merged 3 commits intomainfrom
dev/76-list-runs
Apr 30, 2026
Merged

feat(api): GET /runs — list runs filtered by status + initial_input#78
viktor-shcherb merged 3 commits intomainfrom
dev/76-list-runs

Conversation

@viktor-shcherb
Copy link
Copy Markdown
Member

Summary

Adds GET /runs to the publisher API: a list endpoint that filters by
status, pipeline_id, and arbitrary initial_input.<field>=<value>
predicates against the JSON blob, paginated by limit/offset. Mounts
inside createPublisherApp so it inherits the bearer-auth middleware
alongside the existing GET /runs/{id} route. Lets an agent resolve a
natural-language request (e.g. "add Stripe to jobseek") into a
concrete run_id without the user pasting it.

Related issue

Closes #76

Files changed

  • src/api/publisher/runs.ts — new mountRunListRoute + RunListItem/RunListView types + filter/pagination logic. The existing mountRunRoutes calls into it so index.ts is unchanged in spirit.
  • src/api/publisher/runs.test.ts — 11 vitest cases covering every "Verification" bullet from the issue plus the server-side limit clamp and field-name SQL-injection guard.
  • docs/contracts.md — new §8 GET /runs — list runs with request/response shapes and error tokens; §2.2 publisher route list updated.

Verification (from the issue)

  • ?status=running returns only running runs ordered by created_at DESC.
  • ?pipeline_id=jobseek-add-company&status=running AND-combines.
  • ?initial_input.company_name=Stripe matches by JSON_EXTRACT($.company_name).
  • ?limit=5&offset=10 paginates.
  • Bearer auth required (no Authorization → 401, exercised in test).
  • Schema validation errors → 400 (limit=abc, offset=-1, initial_input.bad-field=x).
  • Empty result set returns 200 {runs: []} (NOT 404).
  • limit clamped server-side at 100 even when the caller asks for more.

Manual checks run locally

  • pnpm typecheck clean
  • pnpm lint clean
  • pnpm test 299 root tests + 34 contracts tests pass; coverage on the new module 97.88%
  • pnpm grep:all all four gates green

SQL-injection note

initial_input.<field> interpolates the field name into a
JSON_EXTRACT(initial_input_json, '$.<field>') clause. The value side
stays bound. Field names are gated against ^[A-Za-z0-9_]+$ before
interpolation; anything else returns 400 initial_input_field_invalid:<field>.

Notes for reviewer

  • Out of scope on purpose: PATCH /runs/{id} (separate murmur#77), per-user
    authz (post-demo).
  • No DB schema change; existing runs.created_at is sufficient. A
    (created_at DESC) index could come later if list traffic grows.
  • The list query SQL is built per-request because the WHERE shape is
    data-dependent — at demo scale the prepare cost is negligible.

@viktor-shcherb
Copy link
Copy Markdown
Member Author

Review: APPROVE

(Posted as a comment because GitHub blocks self-approval via gh pr review --approve. Treat this as the reviewer verdict.)

All issue verification items met (each has a passing test in src/api/publisher/runs.test.ts):

  • ?status=running returns only running runs ordered by created_at DESC
  • ?pipeline_id=...&status=... AND-combines
  • ?initial_input.company_name=Stripe matches via JSON_EXTRACT($.company_name)
  • ?limit=5&offset=10 paginates
  • Bearer auth required (no Authorization → 401, exercised in test; route inherits app.use("*", bearerAuth) in server.ts — no carve-out)
  • Schema validation errors → 400 (limit=abc, offset=-1, initial_input.bad-field=x)
  • Empty result set → 200 {runs: []} (NOT 404)
  • limit clamped server-side at 100
  • Documented row shape (run_id, pipeline_id, status, initial_input, created_at, webhook_status)

CI: green (quality pass; build skipped). Local gates also green: pnpm typecheck, pnpm lint, pnpm test (299 root + 34 contracts), pnpm grep:all. Coverage on src/api/publisher/runs.ts is 97.88% line / 94.87% branch.

Process: claim ✓, sketch posted before first commit ✓ (sketch 11:52:53Z, first commit 11:54:42Z), interfaces → tests → impl ordering preserved across the three commits.

Security checks

  • SQL injection on initial_input.<field>: airtight. Field is gated by ^[A-Za-z0-9_]+$ before interpolating into JSON_EXTRACT(initial_input_json, '\$.<field>'). The class disallows ' (no string-literal escape), \ (no escape sequence), . and [] (no JSON-Path traversal). Value side stays bound. There is no SQLite JSON-Path operator that could reach SQL from inside the path expression.
  • ?status= whitelist: not enforced at the API layer — the issue scope says "one of running | completed" but doesn't list a 400-on-unknown-status verification item. The value is bound, so a bad status just returns an empty list. Non-blocking; flagged as a nit below.
  • limit/offset bounds: parseStrictInt rejects floats, scientific notation, leading +, whitespace, and overflow (Number.isFinite guard). limit < 1 and offset < 0 rejected. limit silently clamped at 100.
  • Auth: inherits the global bearerAuth middleware mounted in createServer — no carve-out, no re-auth in the handler.
  • No any, no @ts-expect-error, no eslint disables. Type assertions (as RunRow | undefined, as unknown for parsed JSON) are reasonable boundary casts.

Backward compat

  • GET /runs/{run_id} handler unchanged in behavior (only the file-level docstring was extended).
  • No DB schema change, no migration.
  • Other publisher routes untouched.
  • docs/contracts.md §2.2 publisher route list and new §8 properly describe the new route.

Nits (non-blocking, address if you'd like)

  • runs.ts:289-293?status= accepts any string. The issue's scope language ("one of running, completed") suggests a server-side allowlist would be more defensive than silently returning [] for typos. Not a blocker since the value is bound and there's no verification item that requires 400-on-unknown.
  • runs.ts:362-367parseStrictInt accepts numbers above Number.MAX_SAFE_INTEGER that round-trip through Number.isInteger. Harmless here (just returns an empty page) but a one-line >= 0 && <= 2**53 - 1 clamp would be tidier.
  • runs.test.ts:175,182[a1].sort() on a single-element array is a no-op; the .sort() calls can be removed for clarity.

LGTM. Orchestrator: ready to merge.

@viktor-shcherb viktor-shcherb merged commit 7d58b38 into main Apr 30, 2026
2 checks passed
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.

feat(api): GET /runs — list runs filtered by status + initial_input fields

1 participant