Skip to content

feat: read session history over HTTP - #147

Merged
ananthanandanan merged 1 commit into
mainfrom
ananthanandanan/feat-http-server-read-session-history-over-http
Aug 5, 2026
Merged

feat: read session history over HTTP#147
ananthanandanan merged 1 commit into
mainfrom
ananthanandanan/feat-http-server-read-session-history-over-http

Conversation

@ananthanandanan

Copy link
Copy Markdown
Contributor

Closes #130.

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. The data was already on disk in the wire format, so this is plumbing over SessionStore, not new design.

What lands

GET /sessions — every session's metadata, wrapped as {"sessions": [...]}. SessionMeta goes out as plain dicts rather than a mirrored Pydantic model, so it can't drift from the dataclass; it's the same shape reigner session list --json emits.

GET /sessions/{id}/events — one session's transcript as {"session_id", "total", "truncated", "events"}. The events are the same envelopes /run streams, so a replay reconstructs exactly what a client would have seen live. ?limit=N returns the last N through a bounded deque, keeping the tail without materialising the file; total still counts the whole transcript so a client knows what it skipped.

Two bugs fixed along the way

  • POST /run could 500. store.exists() validates the id before touching disk, so {"session_id": "../foo"} raised InvalidSessionId — which _resolve_session didn'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.
  • The module docstring claimed browser clients use EventSource. They can't — it only issues GETs and /run is a POST. Corrected to fetch() + stream parsing.

The guard runs before the read on purpose: load_events is a generator, so its id validation only fires on the first next() — inside the read loop, where an InvalidSessionId would 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

serve now 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.

$ reigner serve --http --host 0.0.0.0
· reigner http server — indialaw (anthropic/claude-opus-4-8)
! bound to 0.0.0.0 with no auth, CORS, or rate limiting
  — put a gateway in front before exposing this.
· listening on http://0.0.0.0:8000
  (POST /run · GET /sessions · GET /sessions/{id}/events · GET /health)

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 --strict builds, /openapi.json generates with all four paths.

Also exercised end-to-end against a real project with five existing sessions:

  • Replayed a 72-event session intact — 2 rounds, 31 tool calls, 6 citations.
  • ?limit=5 returned seqs 67–71 with total: 72, truncated: true.
  • Live POST /run then replay: body["events"] == streamed_frames was True. That byte-identity is the property the issue rests on.
  • Resumed the session over HTTP, agent recalled the prior turn, re-read showed total: 17 with 2 user_query events.
  • Failure modes confirmed on the wire: 404 / 404 / 404 / 422 for unknown, traversal, dotted, and limit=0; POST /run with a traversal id went 500 → 404. A torn transcript (tested on a copy) returned 422 ... 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 ?limit window can cut mid-round: a client asking for the last N may get a tool_result whose tool_call fell outside it. Acceptable for restoring a scrollback, and every event carries seq and turn to detect it. If it turns out to matter, the fix is a ?rounds=N parameter rather than making limit cleverer.

No CHANGED: footer — the endpoints are additive and the /run change is a bugfix, so nothing anyone configures against moved. Shout if you'd rather it appear under Changed.

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
@ananthanandanan
ananthanandanan merged commit 97c630a into main Aug 5, 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: HTTP server — read session history over HTTP

1 participant