Skip to content

feat(api): Admin work-item list, detail and live event stream (#279) - #294

Merged
bitbiter-dev merged 3 commits into
masterfrom
feat/279-admin-work-items
Oct 20, 2026
Merged

bitbiter-dev merged 3 commits into
masterfrom
feat/279-admin-work-items

Conversation

@bitbiter-dev

Copy link
Copy Markdown
Owner

Closes #279. Part of #276 (design: ADR 0008, #276 "Admin API").

Summary

Three admin-only routes over the work queue, read through a new IWorkItemReader (Core):

GET /api/v1/admin/work-items?status=&kind=&page=&pageSize=   newest request first (RequestedAt desc, Id desc); page 1, size 50, max 200
                                                               → { items, page, pageSize, total, cursor }
GET /api/v1/admin/work-items/{id}                            → item | 404
GET /api/v1/admin/work-items/events                          SSE, event "work-item", id = cursor; honours Last-Event-ID

UpdatedAt is the change cursor. A client lists, then subscribes with Last-Event-ID: <cursor>, so nothing that changes in between is missed:

stream(lastEventId)
  cursor = parse(lastEventId) ?? latest UpdatedAt ?? epoch
  every WorkItemEvents:IntervalSeconds (2)
    changed = items with UpdatedAt > cursor − 10 s, by UpdatedAt, Id
    send each one not already sent by the last poll as (Id, UpdatedAt)
    cursor = max(cursor, item.UpdatedAt); event id = cursor

The 10 s lookback picks up a change that commits behind the cursor, and the dedupe set stops it being sent twice.

To make UpdatedAt mean "changed", the Worker heartbeat now moves it only when the progress snapshot changes. Without that, every running item would be re-sent every 2 s. LeaseUntil still shows that the Worker is alive.

 HeartbeatAsync
   .SetProperty(LeaseUntil, now + lease)
   .SetProperty(Progress*, progress)
-  .SetProperty(UpdatedAt, now)
+  .SetProperty(UpdatedAt, same progress ? UpdatedAt : now)

A comment-only migration updates the UpdatedAt column comment to match.

The DTO shows Result (as JSON) only for Succeeded, and Error only for Failed/Cancelled. Times are ISO-8601 strings and enums are names.

Evidence

  • Integration (PostgreSQL): a heartbeat with the same progress keeps UpdatedAt and extends the lease. A change to any of done, total or note moves it.
  • Unit:
    • Reader: ordering, filters, paging and total; the ToQueryString shape is WHERE "UpdatedAt" > @since ORDER BY "UpdatedAt", "Id".
    • Event stream (Timeline-driven): lookback, resume from Last-Event-ID, an unreadable id, dedupe, an item changed again, a change committed behind the cursor, and the interval.
    • HTTP:
      • query binding;
      • an unknown status → 400;
      • a non-admin → 403 on all three routes;
      • SSE framing (event: work-item, id: 2026-10-18T09:00:09Z).
  • Mutation (scripts/mutation-changed.sh):
    • Before: 99.42%. 2 survivors, the validation messages.
    • After: 100.00%, 342 killed and 0 survived.

Merge Danger

Door: two-way

Read-only endpoints, plus a heartbeat that writes less. The migration only changes a column comment.

Blast Radius: small

  • Anything that reads UpdatedAt as "last heartbeat" would now see the last change instead. Nothing in the repo does; liveness is LeaseUntil.
  • Each open event stream holds one request-scoped DbContext and polls every 2 s.
  • If a Worker's clock runs more than 10 s behind the API's, its changes can fall behind the cursor and be missed. The list endpoint is still correct.
  • Cancelled items show their Error (e.g. "superseded"). That decides the shape feat: Cancel queued and running work items #280 builds on.
  • A null RequestedBy means the item was scheduled.
  • Error holds an exception type and message, which can contain a NAS file path. Only admins can see it.
  • The tests use the test auth handler; none sends a real bearer token.

Triage: #279's Design section now links ADR 0008 and #276's Admin API decisions, and the issue is relabelled ready-for-agent.

🤖 Generated with Claude Code

bitbiter-dev and others added 3 commits October 20, 2026 20:32
…s progress changed (#279)

UpdatedAt is the change cursor the admin event stream reads. A heartbeat
that only extends the lease changed nothing an admin can see, so it no
longer moves UpdatedAt; LeaseUntil already records that the Worker is alive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/v1/admin/work-items lists items newest request first, filtered by
status and kind and paged, with the stream cursor read before the page.
GET .../{id} returns one item or 404. GET .../events streams every changed
item as Server-Sent Events, re-querying UpdatedAt at WorkItemEvents:
IntervalSeconds (2 s); each event id is the cursor, so Last-Event-ID resumes
from it. A 10 s lookback, deduplicated against the previous poll, keeps a
change whose commit lands behind the cursor from being skipped.

The API reads work items only through IWorkItemReader, as WorkItemView.
A result is shown once an item succeeded; an error once it failed or was
cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne place (#279)

WorkItemView holds Core's WorkProgress rather than three loose fields, and
the event id and the list cursor share the mapper's ISO formatter, so the
two cursors cannot drift apart. Asserts the paging validation messages
(two surviving mutants) and makes the HTTP event-stream test independent
of when the server reaches its next poll. Documents the admin view and the
heartbeat rule in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bitbiter-dev
bitbiter-dev merged commit 67fabd2 into master Oct 20, 2026
12 checks passed
@bitbiter-dev
bitbiter-dev deleted the feat/279-admin-work-items branch October 20, 2026 08:29
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): Admin work-item list, detail and live SSE stream

1 participant