Documentation
¶
Overview ¶
Package sessionrecorder is an opt-in host utility that records typed agent-adaptor events under a host-owned session key and serves them back by a cursor that stays monotonic across runs. EventRecorder, NewMemoryEventBackend, and NewJSONLEventBackend form the typed Event API.
Placement ¶
This package lives under hosttools because:
- the core SDK (github.com/agent-dance/agent-adaptor) does not import it, preserving the boundary between execution and host persistence;
- it is one concrete answer to UI session-history recovery while leaving storage policy under host control;
- it keeps the SDK stateless by default while still saving every host from writing the same JSONL-plus-cursor plumbing.
HostSeq vs provider/run sequence numbers ¶
Event ordering is scoped to one run. Two runs that share the same host-side session key — for example a browser's stable thread id that survives a page refresh — restart their run-local sequence, so that sequence alone cannot serve as a cross-run recovery cursor. A naive filter over a provider or run-local sequence can fold old-run events into the new window and reorder the stream.
HostSeq is the cursor this package assigns. It is strictly monotonic within one session key regardless of how many runs contribute events to that session, so the standard increment-and-resume protocol
afterHostSeq := lastKnownHostSeq records, _ := recorder.Since(ctx, sessionKey, afterHostSeq) lastKnownHostSeq = records[len(records)-1].HostSeq
works across arbitrary run boundaries.
Choosing a sessionKey ¶
sessionKey is intentionally a neutral aggregation key. There are two canonical patterns; pick one and keep it stable per logical thread you want to read back:
Audit style (recommended starting point): sessionKey = RunID - one record stream per run, immutable after the run ends - downside: cannot accumulate cross-run history of the same logical conversation; the host correlates RunIDs externally
Conversation style: sessionKey = ThreadID (or any host-stable "logical thread" identifier) - history accumulates across resumed/forked runs under the same UI conversation
EventRecorder assigns HostSeq in process. All access for one sessionKey must therefore route to one process, regardless of which key style is chosen. Multi-process hosts need sticky routing or a coordinator-aware EventBackend that owns sequence allocation transactionally.
Scope ¶
The package handles "append an Event, read Events back by cursor".
The package does NOT own:
- persistent HITL pending state — derive it on demand from the existing history. The recorder remains the single source of truth; introducing a separate pending dimension creates double-write inconsistency risk.
- HTTP / SSE transport — that's bridges/* and the host's HTTP router's job.
- routing / sticky-by-thread dispatch across pods — the package is single-process by design. Multi-pod hosts should plug a shared EventBackend (e.g. Redis, Postgres) below the EventRecorder.
- fan-out (Stream → SSE + EventRecorder + metrics) — that's a few lines of host-owned for loop; see examples/web-chat/copilotkit for the canonical pattern.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var DefaultKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_\-]{0,127}$`)
DefaultKeyPattern matches portable single-component session keys: an alphanumeric leader followed by at most 127 alphanumerics, dashes, or underscores.
var ErrInvalidSessionKey = errors.New("sessionrecorder: invalid session key")
ErrInvalidSessionKey identifies a key rejected by the recorder or its backend. The validator's diagnostic is included in the returned error.
var ErrJSONLEventBackendClosed = errors.New("sessionrecorder: jsonl event backend closed")
ErrJSONLEventBackendClosed is returned when an operation is attempted after a JSONLEventBackend has been closed.
var ErrJSONLEventLogCorrupt = errors.New("sessionrecorder: corrupt jsonl event log")
ErrJSONLEventLogCorrupt identifies a malformed, truncated, or internally inconsistent event log. Load never silently skips these records: an audit history that cannot be replayed faithfully is an error, not a shorter successful history.
Functions ¶
This section is empty.
Types ¶
type EventBackend ¶
type EventBackend interface {
// Load returns all known records for sessionKey in HostSeq order.
Load(ctx context.Context, sessionKey string) ([]EventRecord, error)
// Append persists exactly one record for sessionKey. It must not return
// nil after an encoding failure, a short/partial write, or a storage
// error. Backend-specific documentation defines whether nil means the
// record reached the operating system or stable storage.
Append(ctx context.Context, sessionKey string, r EventRecord) error
// Sessions enumerates keys the backend has any records for.
Sessions(ctx context.Context) ([]SessionInfo, error)
// Close releases backend resources. Implementations must be safe for
// concurrent use and repeated calls; repeated callers observe the same
// close result.
io.Closer
}
EventBackend is the low-level storage interface behind an EventRecorder — Backend's contract verbatim, typed on EventRecord.
func NewMemoryEventBackend ¶
func NewMemoryEventBackend() EventBackend
NewMemoryEventBackend returns an EventBackend that keeps records in process memory. It is intended for tests and explicitly ephemeral hosts.
type EventOption ¶
type EventOption func(*eventRecorder)
EventOption configures an EventRecorder constructed via NewEventRecorder.
func WithEventClock ¶
func WithEventClock(fn func() time.Time) EventOption
WithEventClock overrides the time source used to stamp RecordedAt.
func WithEventKeyValidator ¶
func WithEventKeyValidator(v KeyValidator) EventOption
WithEventKeyValidator overrides the session-key validator. The default is DefaultKeyValidator.
type EventRecord ¶
EventRecord is one persisted unified event together with the HostSeq assigned to it. It marshals to a stable JSON envelope (kind + authoritative event metadata + event payload) and unmarshals back to the typed event.
func (EventRecord) MarshalJSON ¶
func (r EventRecord) MarshalJSON() ([]byte, error)
MarshalJSON encodes the record as {host_seq, recorded_at, kind, meta, event}.
func (*EventRecord) UnmarshalJSON ¶
func (r *EventRecord) UnmarshalJSON(data []byte) error
UnmarshalJSON restores the typed event from the envelope.
type EventRecorder ¶
type EventRecorder interface {
// Record appends an event under sessionKey and returns the record
// with the HostSeq it was assigned. HostSeq values strictly increase
// within one sessionKey; a rejected backend write rolls the number
// back so a retry gets the same one.
Record(ctx context.Context, sessionKey string, ev adaptor.Event) (EventRecord, error)
// Since returns records whose HostSeq is strictly greater than
// afterHostSeq, in ascending order. afterHostSeq == 0 fetches the
// whole known history.
Since(ctx context.Context, sessionKey string, afterHostSeq HostSeq) ([]EventRecord, error)
// Sessions enumerates known session keys, most recent first.
Sessions(ctx context.Context) ([]SessionInfo, error)
io.Closer
}
EventRecorder is the host-facing typed Event recording API. Implementations MUST be safe for concurrent use.
func NewEventRecorder ¶
func NewEventRecorder(backend EventBackend, opts ...EventOption) EventRecorder
NewEventRecorder wraps an EventBackend into an EventRecorder. Same single-process HostSeq contract as New: route all access for a given sessionKey through one process, or plug a coordinator-aware backend.
Panics only if backend is nil.
type HostSeq ¶
type HostSeq = uint64
HostSeq is the host-scoped cursor assigned to each recorded Event. It is strictly monotonic within one session key and remains stable across SDK run boundaries.
type JSONLEventBackend ¶
type JSONLEventBackend interface {
EventBackend
Flush() error
}
JSONLEventBackend is the durable, typed-Event implementation of EventBackend. Flush synchronizes every dirty session file with its storage device. Close is idempotent and flushes before closing files.
Append synchronizes its record before returning by default, so a successful EventRecorder.Record is durable without a separate Flush call. Hosts that deliberately choose buffered durability can opt out with WithoutJSONLEventSyncOnAppend and establish their own Flush boundaries.
func NewJSONLEventBackend ¶
func NewJSONLEventBackend(dir string, opts ...JSONLEventOption) (JSONLEventBackend, error)
NewJSONLEventBackend creates a typed Event JSONL backend rooted at dir. Each session is stored as <dir>/<sessionKey>.jsonl with one stable EventRecord envelope per line.
The constructor rejects an empty path, resolves dir to an absolute path, and creates dir plus missing parents. Creation errors are returned; it never substitutes an in-memory backend. Missing parents use the configured directory mode, while existing directory permissions are left untouched. Opening or writing an individual session file happens in Append and any resulting error is returned to the caller.
The backend coordinates concurrent callers within one process. Concurrent writers in different processes are intentionally unsupported; use a coordinator-aware EventBackend for that deployment model.
type JSONLEventOption ¶
type JSONLEventOption func(*jsonlEventBackend)
JSONLEventOption configures NewJSONLEventBackend.
func WithJSONLEventDirMode ¶
func WithJSONLEventDirMode(mode os.FileMode) JSONLEventOption
WithJSONLEventDirMode changes the creation mode for the storage directory and any missing parents. The default is 0o700. The process umask still applies; existing directories are not chmod'ed.
func WithJSONLEventFileMode ¶
func WithJSONLEventFileMode(mode os.FileMode) JSONLEventOption
WithJSONLEventFileMode changes the creation mode for new log files. The default is 0o600 because events may contain prompts, tool arguments, and process output. The process umask still applies; existing files are not chmod'ed.
func WithJSONLEventKeyValidator ¶
func WithJSONLEventKeyValidator(v KeyValidator) JSONLEventOption
WithJSONLEventKeyValidator replaces the business key validator. The backend's cross-platform single-file-component check remains mandatory, so a custom validator can tighten accepted keys but cannot enable path traversal or nested paths.
func WithoutJSONLEventSyncOnAppend ¶
func WithoutJSONLEventSyncOnAppend() JSONLEventOption
WithoutJSONLEventSyncOnAppend chooses buffered durability. Append still performs one complete JSONL write and reports encoding/write errors, but the caller must use Flush (or check Close's error) to observe storage sync failures. The default synchronizes every append.
type KeyValidator ¶
KeyValidator returns an error when a session key is not accepted. A validator must be deterministic and side-effect-free.
var DefaultKeyValidator KeyValidator = func(key string) error { if !DefaultKeyPattern.MatchString(key) { return fmt.Errorf("sessionrecorder: refused session key %q: must match %s", key, DefaultKeyPattern) } return nil }
DefaultKeyValidator applies DefaultKeyPattern. JSONLEventBackend performs an additional non-replaceable filesystem-containment check.