serve

package
v0.30.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

README

pkg/serve

pkg/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 (LiveSession, Rig) 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.

What is serve?

  • LiveSession — the narrow, HTTP-facing view of a running session: SessionID, Submit, SubscribeEvents, RespondGate, Interrupt. session.SessionController satisfies it structurally at the composition root; serve never imports or names that contract in production.
  • Rig[S, O] — the narrow session-factory view: NewSession and RestoreSession. 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.
  • Handler — builds the complete session HTTP surface: assembles the config from Options, installs the middleware (auth, body cap, request id, recovery, idempotency), wraps a ServeMux, and returns an http.Handler. The route table is fixed and disjoint (see below).
  • ReadHandler — builds the stateless, read-only session HTTP surface: capabilities plus /v1/sessions (list), /v1/sessions/{sid}/status, and /v1/sessions/{sid}/journal — nothing else. It exists for a process that serves durable history but hosts no agent, so it has no Rig to hand to Handler — a browse-only BFF or a read-plane pod. It shares Handler's middleware chain (Options apply the same way) and the identical read handlers, so there is exactly one implementation of each read route. Its /v1/capabilities document advertises journal only: a read-only server must not claim live_sse/ephemeral_sse/ gate_response planes it cannot serve.
  • Server — builds a hardened *http.Server bound to an address, refusing — fail secure — to bind a public address without authentication installed (PublicBindWithoutAuthError). It does not Listen or Serve; the caller runs the returned server so the listen lifecycle stays the caller's.
  • OptionsWithAuthenticator(a), WithBodyCap(n), WithVisibility(v), etc. The authenticator is required for a public bind; WithInsecurePublicBind is the explicit opt-in for deployments that terminate authentication in front (a mesh sidecar, an authenticating proxy).

How to use

import (
    "github.com/looprig/harness/pkg/serve"
    "github.com/looprig/harness/pkg/rig"
    "github.com/looprig/harness/pkg/session"
)

r, _ := rig.Define(/* ... */)

handler, err := serve.Handler(
    serve.WithRig(serve.Rig[session.SessionController, rig.SessionOption](r)),
    serve.WithAuthenticator(myAuth),
    serve.WithBodyCap(1 << 20),  // 1 MiB request body cap
    serve.WithVisibility(serve.VisibilitySessionScoped),
)
if err != nil { return err }

srv, err := serve.Server(":8080", handler)
if err != nil { return err }  // PublicBindWithoutAuthError if public + no auth
_ = srv.ListenAndServe()

A client drives a session over HTTP:

  • POST /v1/sessions — bring up a new session; returns the session id.
  • POST /v1/sessions/{sid}/restore — restore a prior session by id.
  • POST /v1/sessions/{sid}/input — submit a user turn (fire-and-forget; the outcome arrives on the event stream, correlated by the returned input id).
  • POST /v1/sessions/{sid}/interrupt — interrupt every in-flight turn.
  • POST /v1/sessions/{sid}/gates/{gid} — answer an open permission gate.
  • GET /v1/sessions/{sid}/events — Server-Sent Events stream filtered to the caller's visibility.
  • GET /v1/sessions/{sid}/status — read-only session status.
  • GET /v1/sessions/{sid}/journal — read-only durable journal view.
  • GET /v1/sessions — list sessions (read plane).
  • GET /v1/capabilities — server capabilities advertisement.

Sibling packages

  • pkg/rig — the composition root serve.Rig wraps.
  • pkg/session — the SessionController that satisfies LiveSession structurally at the composition root.
  • pkg/eventEventFilter, Subscription, Delivery for the events SSE stream.
  • pkg/gateGateResponse for the gate-answer endpoint.
  • pkg/hub — the fan-in SubscribeEvents reads.

How it is designed

   HTTP client
       │
       │  POST /v1/sessions/{sid}/input  ·  GET /v1/sessions/{sid}/events  ·  ...
       ▼
   ┌────────────────────────────────────────────────┐
   │ serve.Handler (this package)                    │
   │  middleware:  auth → body-cap → request-id →     │
   │               recovery → idempotency             │
   │  mux:  method+path patterns (Go 1.22 syntax)     │
   └────────────────────────────────────────────────┘
       │
       │  LiveSession / Rig (narrow interfaces; the
       │  concrete session.SessionController satisfies
       │  LiveSession structurally at the composition root)
       ▼
   pkg/session (SessionController) ─► pkg/rig (Rig)
       │
       ▼
   the live session machinery (internal/sessionruntime)
Strict Dependency Inversion

serve production code imports only pkg/event, pkg/gate, core/content, core/uuid, and stdlib. It never names pkg/session, pkg/rig, an LLM package, or a store package. The composition root instantiates serve.Rig[session.SessionController, rig.SessionOption] and hands it to Handler; serve depends on the behavior without depending on the implementation. The package's dependency-guard test proves it.

Disjoint route table

The route patterns are disjoint by design (Go 1.22 method+path syntax):

  • the live plane owns /events (a running in-process session);
  • the read plane owns /status, /journal, and the bare /v1/sessions listing (pure reads over durable history).

{sid} and {gid} are wildcard path segments the handlers recover via r.PathValue. The method is part of the pattern, so a matching path with the wrong method yields 405 (not 404) and an unmatched path yields 404 — both from the mux, before any handler runs.

Hardened server

Server sets the slowloris and resource-exhaustion guards the CLAUDE.md HTTP-server rule mandates: ReadTimeout 5 s, ReadHeaderTimeout 5 s, IdleTimeout 60 s, MaxHeaderBytes 1 MiB. An SSE GET is read to completion well within ReadTimeout (the long-lived part is the write); it is safe for the events stream while still bounding a stalled reader. Request bodies are capped separately by the body-cap middleware.

Fail-secure public bind

A public bind (empty or non-loopback host) with no authenticator installed and no WithInsecurePublicBind opt-in returns a PublicBindWithoutAuthError and no server. The has-auth bit is recovered from the handler without re-parsing options: if it 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.

Idempotency

A POST /v1/sessions/{sid}/input is idempotent by the input id: a redelivered submit with the same id de-duplicates rather than double-submitting. The idempotency layer is the wire-side counterpart of the journal's IdempotencyID de-dup on the durable side.

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func Handler

func Handler[S LiveSession, O any](rig Rig[S, O], reads Reader, opts ...Option) http.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

func ReadHandler(reads Reader, opts ...Option) http.Handler

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

func Server(addr string, h http.Handler, opts ...ServerOption) (*http.Server, error)

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

type InvalidAddrError struct {
	Addr  string
	Cause error
}

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

type InvalidParamError struct {
	Param  string
	Reason string
}

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

type JournalPage struct {
	From  uint64
	Limit int
}

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

type LoopNotFoundError struct {
	LoopID uuid.UUID
}

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

func WithAuth(authn func(*http.Request) error) Option

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

func WithMaxBodyBytes(n int64) Option

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

type Page struct {
	Skip  int
	Limit int
}

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

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.

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.

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

type SessionNotFoundError struct {
	SessionID uuid.UUID
}

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

type StatusEvent struct {
	JournalSeq uint64
	Event      event.Event
}

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

type StoreReadError struct {
	Op    string
	Cause error
}

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

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL