feat: read session history over HTTP - #147
Merged
ananthanandanan merged 1 commit intoAug 5, 2026
Merged
Conversation
POST /run lets a client continue a thread, but nothing let it read one
back — sessions JSONL was only reachable from the CLI, so a browser
client that reloaded lost the conversation unless it mirrored every
event into a store of its own.
Adds two endpoints over the store the harness already owns:
- GET /sessions — every session's metadata, the same shape
`reigner session list --json` emits.
- GET /sessions/{id}/events — one session's transcript as the same
event envelopes /run streams, so a replay is byte-identical to what
the client would have seen live. ?limit=N returns the tail via a
bounded deque; total still counts the whole transcript.
Also fixes a live 500: store.exists() validates the id before touching
disk, so a session_id with path separators raised InvalidSessionId out
of POST /run. Unknown and malformed ids now both answer 404 through one
shared guard, which runs before the read — load_events is a generator,
so its validation would otherwise fire inside the loop and read as a
torn line.
An unreadable stored transcript answers 422 rather than silently
returning a short one.
`serve` lists all four routes on startup and warns on stderr when bound
to anything other than loopback: these endpoints serve every transcript
on disk, so an accidental exposure costs more than it used to.
Closes #130
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #130.
POST /runlets a client continue a thread, but nothing let it read one back — sessions JSONL was only reachable from the CLI, so a browser client that reloaded lost the conversation unless it mirrored every event into a store of its own. The data was already on disk in the wire format, so this is plumbing overSessionStore, not new design.What lands
GET /sessions— every session's metadata, wrapped as{"sessions": [...]}.SessionMetagoes out as plain dicts rather than a mirrored Pydantic model, so it can't drift from the dataclass; it's the same shapereigner session list --jsonemits.GET /sessions/{id}/events— one session's transcript as{"session_id", "total", "truncated", "events"}. The events are the same envelopes/runstreams, so a replay reconstructs exactly what a client would have seen live.?limit=Nreturns the last N through a boundeddeque, keeping the tail without materialising the file;totalstill counts the whole transcript so a client knows what it skipped.Two bugs fixed along the way
POST /runcould 500.store.exists()validates the id before touching disk, so{"session_id": "../foo"}raisedInvalidSessionId— which_resolve_sessiondidn't catch. Unknown and malformed ids now both answer 404 through one shared guard. They're deliberately indistinguishable: an id that can't name a file isn't on disk either, and a uniform answer tells a prober nothing about the store's layout.EventSource. They can't — it only issues GETs and/runis a POST. Corrected tofetch()+ stream parsing.The guard runs before the read on purpose:
load_eventsis a generator, so its id validation only fires on the firstnext()— inside the read loop, where anInvalidSessionIdwould be mistaken for a torn line and surface as a 422.An unreadable stored transcript answers 422 rather than silently returning a short one. A transcript someone restores a conversation from is complete, or it's declared broken — never quietly missing rows.
Operator surface
servenow lists all four routes on startup, and warns on stderr when bound to anything other than loopback. These endpoints change what an accidental exposure costs — from "can ask questions" to "serves every transcript on disk" — which is why the warning is here and not just in the docs. Unresolvable hostnames are treated as exposed: a spurious caution is cheap, silence on a public bind is not.Usage guide section 3.6 documents both endpoints and carries a no-auth admonition; the capability table gained a row.
Verification
Gate green: 1011 passed, 1 skipped (21 new — 12 server, 9 serve), ruff + format + mypy clean,
mkdocs build --strictbuilds,/openapi.jsongenerates with all four paths.Also exercised end-to-end against a real project with five existing sessions:
?limit=5returned seqs 67–71 withtotal: 72, truncated: true.POST /runthen replay:body["events"] == streamed_frameswasTrue. That byte-identity is the property the issue rests on.total: 17with 2user_queryevents.limit=0;POST /runwith a traversal id went 500 → 404. A torn transcript (tested on a copy) returned422 ... unreadable at event 13: unknown event type: 'not_an_event', and was skipped from the listing rather than poisoning it.Out of scope
Auth/multi-tenancy (deliberate server scope), offset/cursor pagination, and any new event types — all per the issue.
A
?limitwindow can cut mid-round: a client asking for the last N may get atool_resultwhosetool_callfell outside it. Acceptable for restoring a scrollback, and every event carriesseqandturnto detect it. If it turns out to matter, the fix is a?rounds=Nparameter rather than makinglimitcleverer.No
CHANGED:footer — the endpoints are additive and the/runchange is a bugfix, so nothing anyone configures against moved. Shout if you'd rather it appear under Changed.