Documentation
¶
Overview ¶
Package serve hosts the HTTP surface over a live session.
It is the composition seam between the outside world (HTTP clients) and the in-process session machinery, and it obeys strict Dependency Inversion: the production package couples ONLY to the narrow interfaces declared here plus the leaf value types those interfaces mention (pkg/event, pkg/gate, core/content, core/uuid) and the standard library. It NEVER imports pkg/session, any LLM package, or any store package — those concrete types are wired in at the composition root and reach serve exclusively through LiveSession and Rig.
LiveSession is the per-session control surface an HTTP handler drives (submit input, subscribe to the event stream, answer a gate, interrupt). Rig is the session factory the handler calls to bring a new session up (NewSession) or resume a prior one (RestoreSession). Both are satisfied structurally by the real session contracts (proven in the package's dependency-guard test), so serve depends on the behavior without depending on the implementation.
Index ¶
- func Handler[S LiveSession, O any](rig Rig[S, O], reads Reader, opts ...Option) http.Handler
- func ReadHandler(reads Reader, opts ...Option) http.Handler
- func Server(addr string, h http.Handler, opts ...ServerOption) (*http.Server, error)
- type EventJournalPage
- type InvalidAddrError
- type InvalidParamError
- type JournalPage
- type LiveSession
- type LoopNotFoundError
- type NonPublicEventError
- type Option
- type Page
- type PublicBindWithoutAuthError
- type Reader
- type Rig
- type ServerOption
- type SessionDone
- type SessionList
- type SessionNotFoundError
- type SessionStatus
- type SessionSummary
- type StatusEvent
- type StoreReadError
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Handler ¶
Handler builds the complete session HTTP surface: it assembles the config from opts, mints a server over rig and reads, registers every route (SPEC §6) on a single ServeMux using Go 1.22 method+path patterns, and wraps the mux with the request-path middleware (authentication then body-cap). It returns a concrete *boundHandler that carries the has-auth bit so a downstream Server bind can fail secure without re-parsing the options.
rig and reads are wired at the composition root: rig drives the live plane (create/restore/input/interrupt/gate/events) and reads backs the stateless read plane (list/status/journal). All routes are disjoint, so registration order is irrelevant and no pattern conflicts.
func ReadHandler ¶
ReadHandler builds the stateless READ-ONLY session HTTP surface: capabilities plus the list/status/journal routes, and nothing else. It exists for a process that serves history but hosts no agent — a browse-only BFF or a read-plane pod — which therefore has no Rig to hand to Handler.
It reuses the identical handlers Handler registers (they hang off the shared rig-free readServer), so there is exactly ONE implementation of each read route and no second wire contract to drift. The live and control routes are NOT registered: a control request 404s from the mux before any handler runs, so browse-only mode is a property of the type system rather than a runtime authorization check that could fall through (fail secure).
The capability document it serves advertises `journal` only — a read-only server must not claim planes it cannot serve.
Like Handler it returns a *boundHandler carrying the has-auth bit, so a downstream Server bind stays fail-secure for a public address.
func Server ¶
Server builds a hardened *http.Server bound to addr and serving h, refusing — fail secure — to bind a public address without authentication (SPEC §10, Decision #18). It does NOT listen or serve; the caller runs the returned server (ListenAndServe / Serve), so binding policy is decided here and the listen lifecycle stays the caller's.
The has-auth bit is recovered from h WITHOUT re-parsing options: if h was built by Handler it satisfies authAware and reports whether an authenticator is installed; any other handler cannot prove auth and is treated as unauthenticated (fail secure). A public bind (empty or non-loopback host) with no auth and no WithInsecurePublicBind opt-in returns a PublicBindWithoutAuthError and no server. A malformed addr returns an InvalidAddrError and no server.
Types ¶
type EventJournalPage ¶
type EventJournalPage struct {
Events []StatusEvent `json:"events"`
NextJournalSeq uint64 `json:"next_journal_seq"`
Done bool `json:"done"`
}
EventJournalPage is the GET /v1/sessions/{sid}/journal response: a page of a session's Enduring events in journal-sequence order plus the resume cursor. NextJournalSeq is the sequence to pass as from_journal_seq to fetch the next page (set only when more may remain); Done reports the journal was exhausted (fewer than Limit events remained). GatePrepared never appears — the event replayer filters it.
type InvalidAddrError ¶
InvalidAddrError reports that the bind address handed to Server is malformed (e.g. missing port) and could not be parsed by net.SplitHostPort. The bind fails secure and returns nothing. Cause is the underlying parse error, exposed via Unwrap; the message carries ONLY the offending Addr — never a secret.
func (InvalidAddrError) Error ¶
func (e InvalidAddrError) Error() string
func (InvalidAddrError) Unwrap ¶
func (e InvalidAddrError) Unwrap() error
type InvalidParamError ¶
InvalidParamError reports that an HTTP path- or query-supplied parameter failed validation at the request boundary. It carries the parameter name and a client-safe Reason; handlers map it to HTTP 400 with a generic message. Typed (per CLAUDE.md) so callers errors.As it to distinguish bad input from other failures. Reason is a fixed, non-sensitive string (never echoes the raw value).
func (InvalidParamError) Error ¶
func (e InvalidParamError) Error() string
type JournalPage ¶
JournalPage is the cursor/limit window a journal read requests. From is the inclusive journal sequence to begin at (0 = from the beginning); Limit is the maximum number of Enduring events to return. Both are validated at the HTTP boundary before a JournalPage is built.
type LiveSession ¶
type LiveSession interface {
SessionID() uuid.UUID
Submit(ctx context.Context, blocks []content.Block) (uuid.UUID, error)
SubscribeEvents(filter event.EventFilter) (event.Subscription, error)
RespondGate(ctx context.Context, response gate.GateResponse) error
Interrupt(ctx context.Context) (bool, error)
}
LiveSession is the narrow, HTTP-facing view of a running session: the exact method set an HTTP handler needs to drive one session and nothing more (Interface Segregation). session.SessionController satisfies it structurally at the composition root; serve never imports or names that contract in production.
- Submit queues human-authored input to the session's primary loop and returns the minted input id (fire-and-forget; the outcome is observed on the event stream, correlated by that id).
- SubscribeEvents attaches a filtered consumer to the session fan-in; the caller Closes the returned Subscription when done.
- RespondGate delivers a human's answer to an open approval gate.
- Interrupt cancels every in-flight turn in the session, reporting whether any running turn was actually cancelled.
type LoopNotFoundError ¶
LoopNotFoundError reports that no loop exists for the requested loop id within a session. It maps to HTTP 404. The LoopID is for the audit log only.
func (LoopNotFoundError) Error ¶
func (e LoopNotFoundError) Error() string
type NonPublicEventError ¶
type NonPublicEventError struct {
Visibility event.EventVisibility
}
NonPublicEventError reports that an event reached an outward serialization boundary without public visibility. Visibility is retained for errors.As-based audit handling; Error deliberately omits event type and payload details.
func (*NonPublicEventError) Error ¶
func (e *NonPublicEventError) Error() string
type Option ¶
type Option func(*config)
Option mutates a config during construction (the functional-options pattern). Every Option is fail-safe: an invalid argument leaves the corresponding secure default in place rather than weakening the configuration.
func WithAuth ¶
WithAuth installs a caller-supplied authenticator applied to every request: a non-nil return from authn rejects the request with 401 before the wrapped handler runs (fail secure — authenticate before act). serve never bakes in a scheme; the default is no auth, which a caller opts out of by supplying an authenticator (least privilege — the auth policy is the caller's, wired at the composition root). A nil authn is ignored (stays no-auth), per the fail-safe option convention.
func WithMaxBodyBytes ¶
WithMaxBodyBytes sets the per-request body cap, in bytes. A non-positive n is ignored (the default cap stays in place), per the fail-safe option convention: an option may tighten or reset the bound but never disable it.
type Page ¶
Page is the offset/limit window a session-list read requests. Skip is the number of entries to drop from the front of the stable-sorted list; Limit is the maximum number to return. Both are validated (non-negative, Limit hard-capped) at the HTTP boundary before a Page is built, so a Reader implementation may trust them.
type PublicBindWithoutAuthError ¶
type PublicBindWithoutAuthError struct {
Addr string
}
PublicBindWithoutAuthError reports a fail-secure refusal to bind a non-loopback address without authentication configured (used by P1-10's bind path; defined here so the typed error exists). Its message carries ONLY the offending Addr — never any credential or secret.
func (PublicBindWithoutAuthError) Error ¶
func (e PublicBindWithoutAuthError) Error() string
type Reader ¶
type Reader interface {
ListSessions(ctx context.Context, page Page) (SessionList, error)
ReadStatus(ctx context.Context, id uuid.UUID) (SessionStatus, error)
ReadJournal(ctx context.Context, id uuid.UUID, page JournalPage) (EventJournalPage, error)
}
Reader is the narrow, stateless read plane over persisted session history. It is the seam serve's read handlers depend on (Dependency Inversion): serve declares the interface and the DTOs, and a concrete adapter (pkg/serve/catalogreader) wires it over the session store WITHOUT serve importing any store package. Every method is a pure read — it consults durable projection/history only, never a live in-process session — so any pod can serve a read and no live session need exist.
- ListSessions returns a stable-sorted, offset-paged slice of session summaries.
- ReadStatus returns one session's public projected status (no replay). It returns a SessionNotFoundError when the session has no catalog entry.
- ReadJournal returns a cursor-paged slice of the session's public Enduring events. Serve validates both event-bearing results again before writing.
type Rig ¶
type Rig[S LiveSession, O any] interface { NewSession(ctx context.Context, opts ...O) (S, error) RestoreSession(ctx context.Context, id uuid.UUID) (S, error) }
Rig is the narrow session-factory view serve depends on. It is generic over the concrete live-session type S (constrained to LiveSession) so a caller keeps the real type through NewSession/RestoreSession without serve importing it: the composition root instantiates Rig[session.SessionController, rig.SessionOption], while serve remains independent of both concrete packages.
- NewSession brings up a brand-new live session that exposes its minted ID.
- RestoreSession rebuilds a prior session from its durable history by id.
LIFETIME: the context serve passes to either method carries the request's VALUES but is never cancelled (see detachSessionLifetime). A rig that derives a session's lifetime from this context — as the harness runtime does — therefore gets a session that outlives the request that asked for it, which is the only thing that makes an HTTP-created session drivable by a later request. An implementation is free to derive a lifetime from it, and must not rely on it to learn that the client went away.
type ServerOption ¶
type ServerOption func(*serverConfig)
ServerOption mutates a serverConfig during Server construction (the functional- options pattern). Every option is fail-safe: it can only relax the bind guard via an explicit opt-in, never silently.
func WithInsecurePublicBind ¶
func WithInsecurePublicBind() ServerOption
WithInsecurePublicBind opts INTO binding a public (non-loopback) address with no authentication installed. Without it, such a bind is refused with a PublicBindWithoutAuthError (fail secure — deny by default). It exists for deployments that terminate authentication in front of this server (a mesh sidecar, an authenticating proxy); naming it "insecure" makes the trade-off explicit at the call site.
type SessionDone ¶ added in v0.30.0
type SessionDone interface {
// Done returns a channel closed once the session has begun shutting down. It never
// reopens, and a receive means "admits no new work", not "cleanup finished".
Done() <-chan struct{}
}
SessionDone is the OPTIONAL liveness extension of LiveSession: a session that can report its own death exposes a channel closed once its shutdown has begun.
It is deliberately separate from LiveSession rather than folded into it. A session is drivable whether or not it can report death, so requiring the method would force every implementor (tui, acp, consumer fakes) to grow one for a capability most of them do not have — an Interface Segregation violation, and a compile break across three repositories for one optional bit. Being structural, it also means session.SessionController need not widen: serve type-asserts the DYNAMIC type, and *sessionruntime.Session satisfies it today.
Absence is not death. A session that does not satisfy SessionDone is treated as live forever, which is exactly today's behaviour; only a session that opts in can be evicted. A wrapper around a live session MUST forward Done, or it silently opts its wrapped session out and reintroduces the corpse-pinning leak this exists to fix.
DUPLICATE, KNOWINGLY: session.Liveness is this interface — same method, same semantics, same segregation argument — declared for the Host boundary. serve does not import pkg/session in production, so the two cannot be unified without giving that up. Change one and change the other.
type SessionList ¶
type SessionList struct {
Sessions []SessionSummary `json:"sessions"`
Skip int `json:"skip"`
Limit int `json:"limit"`
NextSkip int `json:"next_skip"`
Done bool `json:"done"`
}
SessionList is the GET /v1/sessions response: a page of summaries plus the paging cursor a client resumes from. Skip/Limit echo the request window; NextSkip is the Skip to pass for the next page (set only when more may remain); Done reports the end of the list was reached (the page returned fewer than Limit entries).
type SessionNotFoundError ¶
SessionNotFoundError reports that no session exists for the requested id. It maps to HTTP 404. The SessionID is carried for the audit log; the client sees only the generic envelope message.
func (SessionNotFoundError) Error ¶
func (e SessionNotFoundError) Error() string
type SessionStatus ¶
type SessionStatus struct {
SessionID uuid.UUID `json:"session_id"`
State string `json:"state,omitempty"`
LastJournalSeq uint64 `json:"last_journal_seq"`
ActiveTurnID uuid.UUID `json:"active_turn_id,omitzero"`
WaitingGateID uuid.UUID `json:"waiting_gate_id,omitzero"`
LastTurn *StatusEvent `json:"last_turn,omitempty"`
LastStep *StatusEvent `json:"last_step,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitzero"`
}
SessionStatus is the GET /v1/sessions/{sid}/status response: one session's projected lifecycle status, read from the catalog projection with NO journal replay. State is the lifecycle fold (running/waiting_on_gate/idle/failed/ interrupted/stopped); ActiveTurnID and WaitingGateID are zero (omitted) unless a turn is running or a gate is open; LastTurn/LastStep are the codec-safe summaries of the most recent terminal turn and completed step; UpdatedAt is the projection's most-recent-activity instant.
type SessionSummary ¶
type SessionSummary struct {
SessionID uuid.UUID `json:"session_id"`
State string `json:"state,omitempty"`
Title string `json:"title,omitempty"`
CreatedAt time.Time `json:"created_at,omitzero"`
LastActiveAt time.Time `json:"last_active_at,omitzero"`
}
SessionSummary is one row of a session list: the small, picker-facing projection of a session's catalog entry. It is deliberately narrow (Interface Segregation) — a list caller needs an identity, a lifecycle state, and recency, not the full status projection (LastTurn/LastStep replay-safe summaries live on SessionStatus).
type StatusEvent ¶
StatusEvent pairs a durable journal sequence with the concrete event recorded at that sequence. Event is the event.Event INTERFACE, which encoding/json cannot serialize directly (an interface has no stable wire shape), so StatusEvent defines a custom MarshalJSON that emits the codec-safe {journal_seq, event} shape with the event serialized by event.MarshalEvent (the single durable-envelope authority). It is a write-only DTO — the read plane serializes it outward and never decodes it.
func (StatusEvent) MarshalJSON ¶
func (s StatusEvent) MarshalJSON() ([]byte, error)
MarshalJSON emits the codec-safe {journal_seq, event} shape: the event is encoded via event.MarshalEvent so the nested "event" value is the durable wire envelope (type-tagged, versioned) a decoder can round-trip, NOT a Go-struct dump of the interface. A nil Event is omitted (see statusEventWire). A MarshalEvent failure (an Ephemeral or unknown event handed to a status projection) surfaces as a marshal error rather than emitting a lossy record.
type StoreReadError ¶
StoreReadError reports that a read-plane backend operation failed. It maps to HTTP 500. Op names the failed operation (e.g. "list", "get") for the log; the Cause is wrapped for errors.As/Is and is NEVER written to the response body.
func (StoreReadError) Error ¶
func (e StoreReadError) Error() string
func (StoreReadError) Unwrap ¶
func (e StoreReadError) Unwrap() error
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package catalogreader is the concrete read-plane adapter behind serve.Reader.
|
Package catalogreader is the concrete read-plane adapter behind serve.Reader. |