sessionstore

package
v0.26.0 Latest Latest
Warning

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

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

README

pkg/sessionstore

pkg/sessionstore is the session-scoped facade over a storage.Composite backend. It is the in-tree SessionJournal implementation that a pkg/rig is configured with; it owns the durable event/command log, the replay-free session catalog, and the workspace ref → blob offload threshold.

The storage primitives themselves live in the sibling looprig/storage module; the concrete backend (filesystem, NATS, rclone) lives in one of looprig/fsstore, looprig/natsstore, looprig/rclonestore. This package is the session-shaped adapter between those generic primitives and the pkg/journal contract.

What is sessionstore?

  • Open(b *storage.Composite, opts...) (*Store, error) — the constructor. Validates the composite (rejects a nil composite or any nil primitive — Ledger, Leaser, KV, Blobs — with a typed *InvalidBackendError, fail-closed). Resolves the options from defaults (512 KiB offload threshold) plus overrides.
  • Store — the facade. Holds the assembled *storage.Composite plus resolved Options. Construct it only via Open.
  • SessionJournalStore satisfies pkg/journal.SessionJournal for the session's serialized writer: Append(ctx, rec) (seq, err) encodes the record, offloads large payloads to Blobs, and appends the envelope to the session's Ledger.
  • Catalog — the replay-free session index. Projected from the event stream as events are appended; keyed by session id. Records SessionMeta (id, title, status, loop count, model, hustle usage, timestamps) and per-session derived state. A picker reads it without replaying any journal.
  • Lease — the single-writer epoch lease a session acquires on open so two processes can't own the same session at once.
  • OptionsWithOffloadThreshold(n) is the only knob today: the payload size (bytes) above which a record is stored as an out-of-line blob instead of inline in the ledger. Default 512 KiB; non-positive values are ignored.

How to use

A consumer wires a *sessionstore.Store into a rig:

import (
    "github.com/looprig/harness/pkg/sessionstore"
    "github.com/looprig/storage"
    // import a backend, e.g.
    // fsstore "github.com/looprig/fsstore"
)

backend, err := fsstore.Composite(rootDir)  // *storage.Composite
if err != nil { return err }

store, err := sessionstore.Open(backend,
    sessionstore.WithOffloadThreshold(1<<20),  // 1 MiB
)
if err != nil { return err }

r, err := rig.Define(
    rig.WithSessionStore(store),
    /* ... */
)

pkg/rig and pkg/session reach the store through the rig; you don't call Append yourself. The Catalog is reachable through the same store for a session picker (a "recent sessions" list, a restore UI).

Sibling packages

  • pkg/journal — the SessionJournal contract this package implements.
  • pkg/event — the events the catalog projects.
  • pkg/hustle — hustle usage the catalog aggregates.
  • pkg/workspacestore — the workspace snapshot store wired alongside this one in a rig with a workspace placement.
  • pkg/rigrig.WithSessionStore takes a *sessionstore.Store.
  • github.com/looprig/storageLedger, Leaser, KV, Blobs.
  • github.com/looprig/fsstore / looprig/natsstore / looprig/rclonestore — the backend modules that produce a *storage.Composite.

How it is designed

       *storage.Composite (looprig/storage)
            │
            │  sessionstore.Open (validates + wraps)
            ▼
       *sessionstore.Store
            │
   ┌────────┼────────────────┐
   │        │                │
   ▼        ▼                ▼
 Ledger   Blobs              KV
 (append) (offload ≥512KiB)  (catalog)
   │        │                │
   │        │                │
   ▼        ▼                ▼
 session journal          Catalog
 (pkg/journal)         (replay-free index)
Layout

Every session's records share a leading name segment:

  • ledger name: sessions/<uuid>
  • blobs live under: sessions/<uuid>/blobs/...
  • catalog key: sessions/<uuid>/catalog

The layout is the contract between Open, Append, Replay, and the Catalog; it is enforced by the named constants in this package (sessionsPrefix), not by string surgery at the call sites.

Large-record offload

A record whose payload exceeds the offload threshold (default 512 KiB, under storage's 1 MiB per-record ceiling) is stored as an out-of-line blob; the ledger carries only the envelope framing the blob key. The threshold sits comfortably under the per-record ceiling so envelope framing never pushes a record over the limit.

Catalog is derivable

The Catalog is a replay-free projection. It is best-effort by construction: UpdateOnEvent never returns a non-nil error (the catalog is derivable, so a failed index is logged and swallowed inside it). A failed CAS — storage.KV has no unconditional Put, every Put is a revision compare-and-swap — is retried up to catalogMaxCASRetries; a pathologically contended key surfaces a typed *CatalogConflictError rather than spinning forever. RepairCatalog rebuilds a catalog from the journal under its own scan timeout.

Fail-closed validation

Open rejects a nil composite or any nil primitive field with a typed *InvalidBackendError that names the missing piece ("composite", "Ledger", "Leaser", "KV", "Blobs"), so the composition root knows exactly what was not wired and never dereferences a nil primitive later.

Documentation

Overview

Package sessionstore frames a session's ledger records for durable storage. The envelope defined here is the versioned wire frame that wraps one record's codec bytes with the small amount of metadata a writer needs to route and de-duplicate it (its kind and idempotency id) without re-decoding the payload.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type BlobIntegrityError

type BlobIntegrityError struct {
	Seq  uint64
	Key  string
	Want string // the sha256 the pointer named (expected)
	Got  string // sha256 of the bytes actually fetched
}

BlobIntegrityError reports an offloaded record whose fetched blob bytes do not hash to the sha256 its ledger pointer named: sha256(bytes) != pointer.SHA256, so the blob has been corrupted or substituted. It fails secure — replay surfaces it rather than decoding tampered bytes — and carries the record's ledger sequence, the blob key, and both the expected (pointer) and actual hashes.

func (*BlobIntegrityError) Error

func (e *BlobIntegrityError) Error() string

type BlobPointerIDMismatchError

type BlobPointerIDMismatchError struct {
	Seq     uint64
	Key     string
	OuterID string
	InnerID string
}

BlobPointerIDMismatchError reports an offloaded record whose OUTER blobptr envelope's idempotency id does not match the id embedded in the RESOLVED inner envelope. The writer always stamps the exact same id on both halves of an offload (see sessionJournal.offload/frame — both the inline pre-offload envelope and its blobptr stand-in carry rec.IdempotencyID()), so a mismatch means the pointer and the blob it names have drifted apart. Replay fails closed rather than trusting either id blindly — this is the id-integrity counterpart to BlobIntegrityError's content hash check, and matters because a durable idempotency index is keyed by this id.

func (*BlobPointerIDMismatchError) Error

type BlobUnavailableError

type BlobUnavailableError struct {
	Seq   uint64
	Key   string
	Cause error
}

BlobUnavailableError reports that an offloaded record's backing blob could not be fetched: a dangling pointer (the blob is absent — Cause is a *storage.BlobNotFoundError) or any other Blobs.Get / read failure. It fails closed — replay surfaces it rather than yielding a zero-valued record — so a missing blob can never be mistaken for a drained backlog. It carries the record's ledger sequence and the blob key, and unwraps to the underlying cause.

func (*BlobUnavailableError) Error

func (e *BlobUnavailableError) Error() string

func (*BlobUnavailableError) Unwrap

func (e *BlobUnavailableError) Unwrap() error

type Catalog

type Catalog struct {
	// contains filtered or unexported fields
}

Catalog maintains the derived session catalog in storage.KV: one SessionMeta per session, keyed by the session's ledger name. It has one reason to change: how the catalog is indexed. UpdateOnEvent folds a single event into the keyed entry (best-effort, post-append); ListSessions reads the KV only (no ledger cursor); RepairCatalog rebuilds an entry from the authoritative ledger.

func (*Catalog) ListSessions

func (c *Catalog) ListSessions(ctx context.Context) ([]SessionMeta, error)

ListSessions returns every catalog entry by reading the KV ONLY — keys then values — with ZERO ledger replay and NO cursor. It is the session picker's data source: a replay-free index. Entries come back sorted ascending by session id (the storage KV.Keys canonical order — a deterministic improvement over the NATS catalog's arbitrary order). An empty catalog returns an empty slice (not an error); a corrupt entry surfaces a typed *CatalogReadError so the caller can repair.

func (*Catalog) ReadMeta

func (c *Catalog) ReadMeta(ctx context.Context, id uuid.UUID) (SessionMeta, bool, error)

ReadMeta reads one session's projected catalog entry by a SINGLE KV load — NEVER a journal replay. It is the status-read contract: cheap and projection-only (the fold already ran on the append path; a reader just reads the derived record). It returns (meta, true, nil) for a present entry, (zero, false, nil) for an absent one, and a typed *CatalogReadError on a read/decode fault. Absence is distinguished by the load path's revision-0 sentinel (a stored entry always has a committed revision >= 1).

func (*Catalog) RepairCatalog

func (c *Catalog) RepairCatalog(ctx context.Context, sessionID uuid.UUID) (SessionMeta, error)

RepairCatalog rebuilds a session's catalog entry from the authoritative ledger — the repair path for a missing, stale, or corrupt entry. Since the catalog is derived, repair reconstructs it by folding the session's events (the same applyEvent mapping the inline update uses) over an ordered cold replay, then writing under revision-CAS. A lost CAS or a newer decodable catalog high-water forces a fresh scan so repair cannot overwrite an event appended after an earlier replay snapshot. It scans events ONLY (the event replayer never surfaces command/fence records). A session whose ledger carries no SessionStarted yields a typed *EmptySessionError (nothing to index). Unlike UpdateOnEvent, repair is NOT best-effort: a read/write failure is surfaced (the caller explicitly asked to repair). A Catalog with no opener fails with a typed *CatalogReadError unwrapping errNoReplayer.

func (*Catalog) UpdateOnEvent

func (c *Catalog) UpdateOnEvent(ctx context.Context, ev event.Event, seq uint64) error

UpdateOnEvent folds ev into the session's catalog entry via a bounded read-modify-write under KV revision-CAS — but ONLY for a catalog-relevant event (a no-op event short-circuits before any KV I/O). It is BEST-EFFORT: any KV read/write/decode error (or exhausted CAS retries) is reported to the injected logger and swallowed (returns nil). It MUST NEVER fail the underlying append — the catalog is derivable, so a lost update is repaired later, never propagated. The returned error is always nil; the signature keeps a nil-error contract for the appender seam.

seq is the event's durable journal sequence, folded into the projection: it advances the entry's LastJournalSeq (monotonic max) and stamps the LastTurn/LastStep summaries, so a status reader can resume from it.

type CatalogClock

type CatalogClock func() time.Time

CatalogClock is the time seam for the catalog: it stamps LastActiveAt at update time. Injecting it makes activity-bump assertions deterministic in tests.

type CatalogCompactionError

type CatalogCompactionError struct {
	Kind      CatalogCompactionErrorKind
	AttemptID event.CompactAttemptID
}

CatalogCompactionError reports contradictory canonical compaction history encountered while rebuilding the derived catalog from the durable journal.

func (*CatalogCompactionError) Error

func (e *CatalogCompactionError) Error() string

type CatalogCompactionErrorKind

type CatalogCompactionErrorKind string
const (
	CatalogCompactionDuplicateTerminal CatalogCompactionErrorKind = "duplicate_terminal"
)

type CatalogConflictError

type CatalogConflictError struct {
	SessionID uuid.UUID
	Attempts  int
}

CatalogConflictError reports that a catalog update could not win the KV revision-CAS within catalogMaxCASRetries attempts: a persistently contended key. It has no storage analog in the NATS catalog (JetStream KV Put was unconditional last-write-wins); it exists because storage.KV is CAS-only. UpdateOnEvent logs+swallows it (best-effort); RepairCatalog surfaces it.

func (*CatalogConflictError) Error

func (e *CatalogConflictError) Error() string

type CatalogDecodeError

type CatalogDecodeError struct{ Cause error }

CatalogDecodeError identifies a malformed or semantically invalid catalog value. CatalogReadError wraps it with the affected session identity.

func (*CatalogDecodeError) Error

func (e *CatalogDecodeError) Error() string

func (*CatalogDecodeError) Unwrap

func (e *CatalogDecodeError) Unwrap() error

type CatalogDuplicateFieldError

type CatalogDuplicateFieldError struct {
	Path  string
	Field string
}

CatalogDuplicateFieldError reports duplicate JSON object members, including case aliases that encoding/json would otherwise accept with last-value wins.

func (*CatalogDuplicateFieldError) Error

type CatalogEncodeError

type CatalogEncodeError struct{ Cause error }

CatalogEncodeError wraps a failure to marshal a SessionMeta to JSON. A SessionMeta is value-typed, so this is effectively unreachable, but the codec returns a typed error rather than dropping the json.Marshal error to satisfy errors-are-typed.

func (*CatalogEncodeError) Error

func (e *CatalogEncodeError) Error() string

func (*CatalogEncodeError) Unwrap

func (e *CatalogEncodeError) Unwrap() error

type CatalogHustleError

type CatalogHustleError struct {
	Kind  CatalogHustleErrorKind
	RunID hustle.RunID
	Cause error
}

CatalogHustleError reports a malformed or overflowing privileged lifecycle fold. RunID identifies the offending durable run without exposing its input or output.

func (*CatalogHustleError) Error

func (e *CatalogHustleError) Error() string

func (*CatalogHustleError) Unwrap

func (e *CatalogHustleError) Unwrap() error

type CatalogHustleErrorKind

type CatalogHustleErrorKind string
const (
	CatalogHustleDuplicateStart       CatalogHustleErrorKind = "duplicate_start"
	CatalogHustleTerminalWithoutStart CatalogHustleErrorKind = "terminal_without_start"
	CatalogHustleAttributionMismatch  CatalogHustleErrorKind = "attribution_mismatch"
	CatalogHustleInvalidLifecycle     CatalogHustleErrorKind = "invalid_lifecycle"
	CatalogHustleRuntimeMismatch      CatalogHustleErrorKind = "runtime_mismatch"
	CatalogHustleUsageOverflow        CatalogHustleErrorKind = "usage_overflow"
	CatalogHustleRunCountOverflow     CatalogHustleErrorKind = "run_count_overflow"
)

type CatalogHustleMetaValidationError

type CatalogHustleMetaValidationError struct {
	Index int
	Rule  CatalogMetaRule
	Cause error
}

func (*CatalogHustleMetaValidationError) Error

func (*CatalogHustleMetaValidationError) Unwrap

type CatalogLogger

type CatalogLogger interface {
	// CatalogUpdateFailed is called with the typed error when a best-effort catalog update
	// could not read or write its KV entry. The implementation must not panic and must not
	// re-raise — it is the end of the error's life.
	CatalogUpdateFailed(err error)
}

CatalogLogger is the narrow logging seam the best-effort catalog update writes to when a KV read/write fails: the catalog is derivable, so a failure is logged and swallowed, NEVER surfaced to the append path. It is a single-method interface (Interface Segregation); a nop default keeps existing wiring unchanged.

type CatalogMetaField

type CatalogMetaField string

CatalogMetaField identifies one semantic SessionMeta projection field.

const (
	CatalogMetaFieldLoopID          CatalogMetaField = "Loops.LoopID"
	CatalogMetaFieldLoopOrder       CatalogMetaField = "Loops"
	CatalogMetaFieldRuntime         CatalogMetaField = "Loops.Runtime"
	CatalogMetaFieldRuntimeSeq      CatalogMetaField = "Loops.RuntimeValueSeq"
	CatalogMetaFieldCumulativeUsage CatalogMetaField = "Loops.CumulativeUsage"
	CatalogMetaFieldCurrentContext  CatalogMetaField = "Loops.CurrentContext"
	CatalogMetaFieldContextSeq      CatalogMetaField = "Loops.ContextSeq"
	CatalogMetaFieldContextValueSeq CatalogMetaField = "Loops.ContextValueSeq"
)

type CatalogMetaRule

type CatalogMetaRule string

CatalogMetaRule identifies a semantic catalog invariant.

const (
	CatalogMetaRuleRequired        CatalogMetaRule = "must be set"
	CatalogMetaRuleSortedUnique    CatalogMetaRule = "must be sorted and unique"
	CatalogMetaRuleInvalid         CatalogMetaRule = "is invalid"
	CatalogMetaRuleExceedsRuntime  CatalogMetaRule = "must not exceed RuntimeSeq"
	CatalogMetaRuleLegacyValue     CatalogMetaRule = "must be zero when Runtime is absent"
	CatalogMetaRuleExceedsContext  CatalogMetaRule = "must not exceed ContextSeq"
	CatalogMetaRuleNotAfterRuntime CatalogMetaRule = "must be newer than RuntimeSeq"
	CatalogMetaRuleContextAbsent   CatalogMetaRule = "must be zero when CurrentContext is absent"
	CatalogMetaRuleContextCurrent  CatalogMetaRule = "must equal ContextSeq when CurrentContext is set"
)

type CatalogMetaValidationError

type CatalogMetaValidationError struct {
	LoopIndex int
	Field     CatalogMetaField
	Rule      CatalogMetaRule
	Cause     error
}

CatalogMetaValidationError reports an invalid bounded loop projection. The index makes corrupt records diagnosable without parsing an error string.

func (*CatalogMetaValidationError) Error

func (*CatalogMetaValidationError) Unwrap

func (e *CatalogMetaValidationError) Unwrap() error

type CatalogOption

type CatalogOption func(*catalogOptions)

CatalogOption configures a Catalog at OpenCatalog time. Applied in order over a defaults struct, so a later option overrides an earlier one.

func WithCatalogClock

func WithCatalogClock(now CatalogClock) CatalogOption

WithCatalogClock injects the clock LastActiveAt is stamped from. A nil clock is ignored (time.Now is kept).

func WithCatalogLogger

func WithCatalogLogger(log CatalogLogger) CatalogOption

WithCatalogLogger injects the logger best-effort update failures are reported to. A nil logger is ignored (the nop default is kept).

func WithCatalogReplayer

func WithCatalogReplayer(opener EventReplayerOpener) CatalogOption

WithCatalogReplayer overrides the EventReplayerOpener RepairCatalog folds a session's ledger through. A nil opener is ignored (OpenCatalog defaults it to the owning Store, so repair works out of the box). It exists so a test can inject a scripted opener.

type CatalogOrderingError

type CatalogOrderingError struct {
	EventType string
	Sequence  uint64
	Last      uint64
}

CatalogOrderingError marks an online delivery whose sequence is behind the catalog cursor and whose additive effect therefore cannot be classified as a duplicate or a delayed unique record from bounded metadata alone. The online updater repairs from the authoritative journal instead of guessing.

func (*CatalogOrderingError) Error

func (e *CatalogOrderingError) Error() string

type CatalogReadError

type CatalogReadError struct {
	SessionID uuid.UUID
	Cause     error
}

CatalogReadError wraps a failure to read or decode a catalog entry (a KV Get/Keys error that is not "not found", or a malformed stored SessionMeta). It carries (when known) the session and unwraps to the cause. ListSessions and RepairCatalog surface it; the best-effort UpdateOnEvent logs+swallows it (it must never fail the append).

func (*CatalogReadError) Error

func (e *CatalogReadError) Error() string

func (*CatalogReadError) Unwrap

func (e *CatalogReadError) Unwrap() error

type CatalogUsageError

type CatalogUsageError struct {
	LoopID uuid.UUID
	Cause  error
}

CatalogUsageError reports invalid or overflowing usage encountered while building the repairable catalog projection.

func (*CatalogUsageError) Error

func (e *CatalogUsageError) Error() string

func (*CatalogUsageError) Unwrap

func (e *CatalogUsageError) Unwrap() error

type CatalogWriteError

type CatalogWriteError struct {
	SessionID uuid.UUID
	Cause     error
}

CatalogWriteError wraps a failure to write a catalog entry (a KV Put/encode error). It carries the session and unwraps to the cause. The best-effort UpdateOnEvent logs+swallows it; RepairCatalog surfaces it (a repair the caller asked for that could not persist is a real failure).

func (*CatalogWriteError) Error

func (e *CatalogWriteError) Error() string

func (*CatalogWriteError) Unwrap

func (e *CatalogWriteError) Unwrap() error

type CheckpointSummary

type CheckpointSummary struct {
	Ref         workspacestore.Ref        `json:"ref"`
	EventID     uuid.UUID                 `json:"event_id"`
	Seq         uint64                    `json:"seq"`
	Consistency event.SnapshotConsistency `json:"consistency,omitempty"`
}

CheckpointSummary identifies the newest checkpoint independently from later rewinds.

type EmptySessionError

type EmptySessionError struct{ SessionID uuid.UUID }

EmptySessionError reports that RepairCatalog could not rebuild a session's entry because its ledger carries no SessionStarted (nothing to index). It carries the session and unwraps to errEmptyRepair.

func (*EmptySessionError) Error

func (e *EmptySessionError) Error() string

func (*EmptySessionError) Unwrap

func (e *EmptySessionError) Unwrap() error

type EnvelopeError

type EnvelopeError struct {
	Reason string
	Cause  error
}

EnvelopeError reports a failure to encode or decode a frame (or a blobPointer body): a malformed JSON payload, an unknown kind, or an unsupported version. Reason carries the human-readable context; Cause, when non-nil, is the underlying encoding/json error reachable via errors.As / errors.Unwrap. A semantic rejection (unknown kind or unsupported version) has no underlying cause and leaves Cause nil.

func (*EnvelopeError) Error

func (e *EnvelopeError) Error() string

func (*EnvelopeError) Unwrap

func (e *EnvelopeError) Unwrap() error

type EventReplayerOpener

type EventReplayerOpener interface {
	OpenInternalEventReplayer(id uuid.UUID, req ReplayRequest) (journal.EventReplayer, error)
}

EventReplayerOpener is the narrow seam RepairCatalog folds a session's ledger through: it opens a privileged read-side event replayer for one session. *Store satisfies it via OpenInternalEventReplayer (Dependency Inversion — the catalog depends on this method alone, not the whole Store). A nil opener disables repair (RepairCatalog fails with a typed error).

type GCDeleteError

type GCDeleteError struct {
	Key   string
	Cause error
}

GCDeleteError reports a failure to delete one orphaned blob. GC surfaces it rather than silently swallowing the failure, so a caller learns the session's blobs could not be fully reclaimed. It carries the blob key and unwraps to the underlying cause.

func (*GCDeleteError) Error

func (e *GCDeleteError) Error() string

func (*GCDeleteError) Unwrap

func (e *GCDeleteError) Unwrap() error

type GCLeaseNotHeldError

type GCLeaseNotHeldError struct {
	SessionID uuid.UUID
	Epoch     uint64
}

GCLeaseNotHeldError reports that GC was refused because the session's single-writer lease is not held (released, or overtaken by a higher epoch). GC deletes blobs, so it must run only as the single writer; running unguarded could reap a blob a live owner is still offloading (its pointer append is mid-flight). It fails closed with this typed error and deletes nothing. It carries the session and the (stale) epoch the refused lease held, and unwraps to a *journal.LeaseLostError for errors.As — mirroring pkg/journal's ObjectGC.

func (*GCLeaseNotHeldError) Error

func (e *GCLeaseNotHeldError) Error() string

func (*GCLeaseNotHeldError) Unwrap

func (e *GCLeaseNotHeldError) Unwrap() error

type GCListError

type GCListError struct {
	Prefix string
	Cause  error
}

GCListError reports a failure to list the session's blob prefix. GC fails closed: without the blob inventory it cannot decide what to reap, so it deletes nothing. It carries the prefix and unwraps to the underlying cause.

func (*GCListError) Error

func (e *GCListError) Error() string

func (*GCListError) Unwrap

func (e *GCListError) Unwrap() error

type GCResult

type GCResult struct {
	// Scanned is the number of blobs listed under the session's blob prefix.
	Scanned int
	// Referenced is the number of listed blobs still referenced by an in-ledger
	// pointer (kept).
	Referenced int
	// Deleted is the number of orphaned blobs reaped this pass; it always equals
	// len(DeletedKeys).
	Deleted int
	// DeletedKeys enumerates the reaped blob keys in lexicographic order (Blobs.List
	// returns sorted keys and the sweep preserves that order). It lets a caller log
	// exactly what was reclaimed without re-deriving it.
	DeletedKeys []string
}

GCResult summarizes one GC pass. It mirrors pkg/journal's GCResult shape, minus its WithinGrace term: storage's Blobs.List exposes no per-blob timestamp, so there is no grace window over the storage contract — GC's safety rests entirely on the single-writer lease/idle serialization the caller provides (see ObjectGC). On a fully successful pass Scanned == Referenced + Deleted.

type GCScanError

type GCScanError struct {
	Name  string
	Cause error
}

GCScanError reports a failure to scan the session's ledger for the set of blob keys referenced by a live pointer: a ledger read/cursor failure, or an undecodable envelope or blob pointer. GC fails closed — without a COMPLETE live set it cannot safely decide which blobs are orphans, so it deletes nothing rather than risk reaping a still-referenced blob. It carries the ledger name and unwraps to the underlying cause.

func (*GCScanError) Error

func (e *GCScanError) Error() string

func (*GCScanError) Unwrap

func (e *GCScanError) Unwrap() error

type HustleUsageAggregate

type HustleUsageAggregate struct {
	Name            hustle.Name           `json:"name"`
	ModelSource     hustle.ModelSource    `json:"model_source"`
	NamedModelKey   model.ModelKey        `json:"named_model_key,omitzero"`
	Runtime         event.ModelRuntime    `json:"runtime,omitzero"`
	Status          hustle.TerminalStatus `json:"status"`
	Runs            uint64                `json:"runs"`
	CumulativeUsage content.Usage         `json:"cumulative_usage,omitzero"`
}

HustleUsageAggregate is one canonical terminal bucket. Current-loop work has a zero NamedModelKey so arbitrarily many resolved runtime keys cannot grow the catalog; named work uses the immutable key from its definition descriptor.

type InvalidBackendError

type InvalidBackendError struct {
	Missing string
}

InvalidBackendError reports that Open was handed a nil composite, or a composite with a nil primitive field. Missing names the absent piece ("composite", or one of "Ledger"/"Leaser"/"KV"/"Blobs") so the composition root knows exactly what was not wired. Open fails closed on it rather than dereferencing a nil primitive later.

func (*InvalidBackendError) Error

func (e *InvalidBackendError) Error() string

type LoopUsageMeta

type LoopUsageMeta struct {
	LoopID  uuid.UUID          `json:"loop_id"`
	Runtime event.ModelRuntime `json:"runtime,omitzero"`
	// RuntimeSeq is the latest lifecycle sequence observed for runtime selection.
	// A legacy event without Runtime advances this watermark while preserving the
	// last known value. One bounded scalar per loop prevents delayed lifecycle
	// notifications from regressing selection without an unbounded event set.
	RuntimeSeq uint64 `json:"runtime_seq,omitempty"`
	// RuntimeValueSeq is the sequence that supplied Runtime. It can trail
	// RuntimeSeq when a newer legacy event carries no resolved runtime, allowing
	// delayed known values to converge to the highest known sequence boundedly.
	RuntimeValueSeq uint64        `json:"runtime_value_seq,omitempty"`
	CumulativeUsage content.Usage `json:"cumulative_usage,omitzero"`
	// ContextSeq is the highest context-relevant lifecycle, mutation, or
	// measurement sequence observed for this loop. ContextValueSeq identifies the
	// event supplying CurrentContext. Invalidation preserves the former watermark
	// while clearing only the value sequence and measurement.
	ContextSeq      uint64                   `json:"context_seq,omitempty"`
	ContextValueSeq uint64                   `json:"context_value_seq,omitempty"`
	CurrentContext  event.ContextMeasurement `json:"current_context,omitzero"`
}

LoopUsageMeta is the catalog's bounded projection for one durable loop. CumulativeUsage folds authoritative StepDone request usage only; TurnDone's convenience projection is deliberately excluded.

type NilLeaseError

type NilLeaseError struct {
	SessionID uuid.UUID
}

NilLeaseError reports that a Store constructor (OpenJournal or OpenObjectGC) was handed a nil lease. The lease is a required dependency (DIP): the composition root acquires it via AcquireLease and passes it in. The constructor fails closed with this typed error rather than deferring a nil dereference to first use (stamping the epoch into the opening fence, or the GC lease guard).

func (*NilLeaseError) Error

func (e *NilLeaseError) Error() string

type ObjectGC

type ObjectGC struct {
	// contains filtered or unexported fields
}

ObjectGC reaps orphaned offload blobs from one session's content-addressed blob prefix: blobs no in-ledger pointer references. An orphan arises from the writer's blob-durable-before-pointer discipline — the offload Put lands BEFORE the blobptr append, so a crash in that gap leaves a durable blob with no pointer.

It is lease-guarded: it deletes, so it runs only while holding a valid single-writer lease and is therefore the single deleter. That lease guard is also the whole of its concurrency safety. GC MUST NOT run concurrently with active appends/offloads to the same session: a blob whose pointer append is still in flight would be observed as unreferenced by the scan and wrongly reaped. The caller serializes GC with the writer — typically running it while holding the session lease (as the single writer) or when the session is idle. Unlike pkg/journal's ObjectGC there is no grace window backstop: storage's Blobs.List surfaces no ModTime, so an in-flight upload cannot be protected by age; the serialization is load-bearing, not advisory.

It is the GC analogue of the sessionstore journal (write) and replayers (read), wired at the composition root via Store.OpenObjectGC.

func (*ObjectGC) GC

func (g *ObjectGC) GC(ctx context.Context) (GCResult, error)

GC runs one live-set-sweep pass under the held lease. It (1) refuses unless the lease is held — GC deletes, so it must be the single writer; (2) scans the session's ledger and builds the LIVE set of blob keys referenced by a blobptr record; (3) lists the session's blob prefix and deletes every listed blob NOT in the live set. It returns a summary of the pass. Every failure is a typed fail-closed error; on a scan or list failure it deletes nothing (an incomplete live set must never drive a delete).

type Option

type Option func(*Options)

Option overrides a single field of Options at Open time. Options are applied in order over the defaults, so a later Option wins over an earlier one.

func WithOffloadThreshold

func WithOffloadThreshold(n int) Option

WithOffloadThreshold sets the large-record offload threshold in bytes. A non-positive value is ignored and the default is kept, so the option owns its invariant (a threshold must be positive) rather than trusting the caller.

type Options

type Options struct {
	// OffloadThreshold is the payload size (bytes) above which a record is
	// stored as an out-of-line blob instead of inline in the ledger.
	OffloadThreshold int
}

Options are the resolved knobs a Store operates under. It is populated by Open from the defaults plus any Option overrides; callers never construct it directly.

type PersistencePathError

type PersistencePathError struct {
	Path  string
	Cause error
}

PersistencePathError reports a local persistence path that could not be canonicalized without ambiguity.

func (*PersistencePathError) Error

func (e *PersistencePathError) Error() string

func (*PersistencePathError) Unwrap

func (e *PersistencePathError) Unwrap() error

type ReplayDecodeError

type ReplayDecodeError struct {
	Seq   uint64
	Cause error
}

ReplayDecodeError reports a failure to decode a replayed ledger record into its typed form: an undecodable envelope, an undecodable blob pointer, an unexpected (post-resolution) envelope kind, or a codec unmarshal failure on the record's body. It fails secure — replay surfaces it rather than skipping or zero-valuing the record — and carries the offending record's ledger sequence and the underlying cause (a *EnvelopeError, an event/command codec error, etc.).

func (*ReplayDecodeError) Error

func (e *ReplayDecodeError) Error() string

func (*ReplayDecodeError) Unwrap

func (e *ReplayDecodeError) Unwrap() error

type ReplayReadError

type ReplayReadError struct {
	Name  string
	Cause error
}

ReplayReadError reports a failure to read the next record from the ledger cursor (a backend Ledger.Read or Cursor.Next failure). It fails closed: replay surfaces it rather than guessing the backlog is drained. It carries the ledger name and unwraps to the underlying cause.

func (*ReplayReadError) Error

func (e *ReplayReadError) Error() string

func (*ReplayReadError) Unwrap

func (e *ReplayReadError) Unwrap() error

type ReplayRequest

type ReplayRequest struct {
	// FromSeq is the inclusive ledger sequence to begin at. Storekit sequences are
	// 1-based and Ledger.Read(from) yields the record at Seq==from first; 0 (and 1)
	// both begin at the first record.
	FromSeq uint64
}

ReplayRequest positions a sessionstore replay. It carries an exported inclusive start sequence because journal.ReplayRequest hides its start behind a package-private journal.StartPos that an out-of-package replayer cannot read: the storage replayer's positioning must therefore flow through this request, set when the replayer is opened. Subject/loop narrowing is not part of storage replay — a session is one ledger, walked whole and filtered by envelope kind — so this request needs only the start position.

type SessionMeta

type SessionMeta struct {
	// SessionID is the session this entry describes.
	SessionID uuid.UUID `json:"session_id"`
	// Title is a short, human-readable label derived from the MOST RECENT turn's user
	// message (its first line, truncated), so a picker shows what a session is doing now
	// rather than its stale opening line. It updates on every TurnStarted that carries
	// derivable text, retaining the prior value when a turn's message has none (e.g. a
	// tool-continuation with no user-authored text). Empty until a first TurnStarted with
	// derivable text is seen.
	Title string `json:"title,omitempty"`
	// CreatedAt is when the session started (SessionStarted's CreatedAt).
	CreatedAt time.Time `json:"created_at,omitzero"`
	// LastActiveAt is the most recent activity instant (bumped by TurnStarted, StepDone,
	// RestoreDone), stamped from the catalog's injected clock at update time.
	LastActiveAt time.Time `json:"last_active_at,omitzero"`
	// Status is the session's lifecycle phase (active until SessionStopped -> stopped).
	Status SessionStatus `json:"status,omitempty"`
	// AgentKind names the agent role (from SessionStarted's ConfigFingerprint). It is
	// passthrough: empty until the agent threads its kind through loop.Definition.
	AgentKind string `json:"agent_kind,omitempty"`
	// LoopCount is the number of loops registered in the session: the primary plus one
	// per LoopStarted.
	LoopCount int `json:"loop_count,omitempty"`
	// ConfigFingerprint is the config identity the session started under, for the picker
	// to surface a config change on restore.
	ConfigFingerprint event.ConfigFingerprint `json:"config_fingerprint,omitzero"`
	// State is the status-fold lifecycle state (running/waiting_on_gate/idle/failed/
	// interrupted/stopped). It supersedes Status for richer callers; Status is retained
	// for back-compat. Empty until the fold sees its first state-bearing event.
	State SessionState `json:"state,omitempty"`
	// LastJournalSeq is the highest journal sequence folded into this entry (a monotonic
	// max over the events the projection has consumed): a status reader's resume cursor.
	LastJournalSeq uint64 `json:"last_journal_seq,omitempty"`
	// ActiveTurnID is the turn currently running (set by TurnStarted, cleared by TurnDone).
	// Zero when no turn is active.
	ActiveTurnID uuid.UUID `json:"active_turn_id,omitzero"`
	// WaitingGateID is the open gate blocking progress (set by GateOpened, cleared by
	// GateResolved). Zero when no gate is open.
	WaitingGateID uuid.UUID `json:"waiting_gate_id,omitzero"`
	// LastTurn is the codec-safe summary of the most recent terminal turn event
	// (TurnDone/TurnFailed). Nil until a turn ends.
	LastTurn *eventSummary `json:"last_turn,omitempty"`
	// LastStep is the codec-safe summary of the most recent StepDone. Nil until a step
	// completes.
	LastStep         *eventSummary     `json:"last_step,omitempty"`
	LastCheckpoint   CheckpointSummary `json:"last_checkpoint,omitzero"`
	CurrentWorkspace WorkspacePointer  `json:"current_workspace,omitzero"`
	// Loops is the deterministic, per-loop usage/runtime projection. It is sorted
	// by LoopID bytes and rebuilt from lifecycle + StepDone events.
	Loops []LoopUsageMeta `json:"loops,omitempty"`
	// Hustles is a bounded terminal-only aggregate. Detailed runs and unmatched
	// starts remain exclusively in the privileged journal.
	Hustles []HustleUsageAggregate `json:"hustles,omitempty"`
}

SessionMeta is the derived per-session catalog entry: the small, replay-free record the session picker reads to list sessions without opening a single ledger cursor. It is JSON (snake_case) stored one-per-session in storage.KV, keyed by the session's ledger name ("sessions/<uuid>"). It is a cache rebuilt from the authoritative ledger when missing or stale (RepairCatalog) — never the source of truth.

type SessionState

type SessionState string

SessionState is the richer, status-fold lifecycle state the catalog projects from the event stream. It is a closed typed enum a status reader (the serve session API) switches on. It SUPERSEDES SessionStatus for callers that need the running/waiting/idle/terminal distinction, but Status is kept for back-compat (see SessionMeta.Status): State is additive, so an old entry decoded without it simply reads as the empty state and is rebuildable via RepairCatalog.

const (
	// StateRunning: a turn is actively executing (set by TurnStarted, restored after a
	// gate resolves while a turn is active).
	StateRunning SessionState = "running"
	// StateWaitingOnGate: a gate is open and blocking progress (set by GateOpened).
	StateWaitingOnGate SessionState = "waiting_on_gate"
	// StateIdle: the session is up but no turn is running (set by SessionStarted, TurnDone,
	// or a gate resolving with no active turn).
	StateIdle SessionState = "idle"
	// StateFailed: the last turn ended in a non-cancellation failure (set by TurnFailed).
	StateFailed SessionState = "failed"
	// StateInterrupted: the last turn was interrupted/cancelled (set by TurnInterrupted).
	StateInterrupted SessionState = "interrupted"
	// StateStopped: the session emitted SessionStopped — the terminal state that wins over
	// every other (set by SessionStopped).
	StateStopped SessionState = "stopped"
)

type SessionStatus

type SessionStatus string

SessionStatus is the lifecycle phase the catalog records for a session. It is a closed typed enum (not a free-form string) so a picker can switch on it and a typo cannot silently mislabel a session.

const (
	// StatusActive marks a session whose primary loop is running (the SessionStarted
	// default until a SessionStopped flips it).
	StatusActive SessionStatus = "active"
	// StatusStopped marks a session that emitted SessionStopped (a clean shutdown). It
	// survives on disk and is brought back by restore — Stopped is a phase, not a delete.
	StatusStopped SessionStatus = "stopped"
)

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the session-scoped facade over a storage backend. It holds the assembled *storage.Composite (whose four primitives it addresses by field — they have colliding method names, so there is no flattened backend interface) plus the resolved Options. Construct it only via Open.

func Open

func Open(b *storage.Composite, opts ...Option) (*Store, error)

Open validates the backend and returns a Store over it. A nil composite or any nil primitive field is rejected with a typed *InvalidBackendError (fail closed, never a panic). Options are resolved from the 512 KiB default plus any overrides.

func (*Store) AcquireLease

func (s *Store) AcquireLease(ctx context.Context, id uuid.UUID) (journal.Lease, error)

AcquireLease acquires single-writer ownership of a session's stream and returns it as a journal.Lease. It derives (and validates) the session's ledger name, acquires the storage lease over that name, and wraps the result so the journal sees a journal.Lease. A storage *LeaseHeldError — the expected "someone else owns this session" outcome — is translated to the journal's own *LeaseHeldError, keyed by session id and the live holder's epoch, so callers classify it at the journal level without depending on storage's error vocabulary. Any other backend error is surfaced unchanged (fail closed).

func (*Store) OpenCatalog

func (s *Store) OpenCatalog(opts ...CatalogOption) *Catalog

OpenCatalog returns a Catalog over the Store's KV. Repair is enabled by default (the opener defaults to the Store itself, which can open a per-session event replayer); a clock, logger, or a different opener may be injected. It does no I/O and cannot fail — the KV is already wired into the Composite Open validated.

func (*Store) OpenEventReplayer

func (s *Store) OpenEventReplayer(id uuid.UUID, req ReplayRequest) (journal.EventReplayer, error)

OpenEventReplayer returns a read-side replayer over session id's ledger that surfaces the session's events only — commands and internal fences are filtered out, matching pkg/journal's subject-filtered EventReplayer (which binds a consumer to the event subjects alone). Positioning comes from req.FromSeq (inclusive). The returned value satisfies the unchanged journal.EventReplayer interface; its Open binds the ledger cursor. Construction does no I/O — the ctx-bounded read happens in Open — so it takes no context. A zero id yields a concrete (empty) session ledger, not a wildcard, so it is allowed and simply replays as empty.

func (*Store) OpenInternalEventReplayer

func (s *Store) OpenInternalEventReplayer(id uuid.UUID, req ReplayRequest) (journal.EventReplayer, error)

OpenInternalEventReplayer returns the privileged event stream used by restore and catalog repair. Product-facing readers use OpenEventReplayer instead.

func (*Store) OpenInternalRecordReplayer

func (s *Store) OpenInternalRecordReplayer(id uuid.UUID, req ReplayRequest) (journal.RecordReplayer, error)

OpenInternalRecordReplayer returns the privileged full read side used by restore and storage maintenance. It surfaces EVERY record — public and internal events, commands, and fences — in ledger-sequence order. Product-facing readers must use OpenEventReplayer, which filters non-public event visibility. Positioning comes from req.FromSeq (inclusive). The returned value satisfies journal.RecordReplayer's full-stream contract; its Open binds the ledger cursor. Construction does no I/O, so it takes no context.

func (*Store) OpenJournal

func (s *Store) OpenJournal(ctx context.Context, id uuid.UUID, lease journal.Lease) (journal.SessionJournal, error)

OpenJournal binds a single-writer journal to session id's ledger and takes ownership of the tip by writing the opening fence — a fence-kind envelope carrying the lease epoch — as an append fenced on the ledger's current tip. That fence advances the tip, so any stale prior writer's next CAS append conflicts; only once it commits is the journal ready to accept Appends. The lease is a required dependency (DIP): a nil lease fails closed with *NilLeaseError.

func (*Store) OpenJournalWithOpeningAppend

func (s *Store) OpenJournalWithOpeningAppend(
	ctx context.Context,
	id uuid.UUID,
	lease journal.Lease,
	middleware journal.AppendMiddleware,
) (journal.SessionJournal, error)

OpenJournalWithOpeningAppend is OpenJournal with middleware around the ownership fence append. The middleware sees the fence while it is still part of journal construction; the journal is returned only after that append commits and ready is set. Later appends are not decorated by this seam.

func (*Store) OpenObjectGC

func (s *Store) OpenObjectGC(id uuid.UUID, lease journal.Lease) (*ObjectGC, error)

OpenObjectGC binds an offload-blob GC to session id under the given single-writer lease (DIP: the composition root acquires the lease and passes it in; GC never acquires or releases one, and depends only on the narrow journal.Lease view). A nil lease fails closed with *NilLeaseError. The ledger and blob store come from the validated Store backend, so they are guaranteed non-nil here.

func (*Store) PersistencePaths

func (s *Store) PersistencePaths() ([]string, error)

PersistencePaths returns the canonical local roots reported by the Store's configured primitives. Providers without the optional storage.PathReporter capability contribute no paths. It fails closed with *PersistencePathError when a reported path cannot be resolved without ambiguity.

func (*Store) WorkspaceCheckpointBySeq

func (s *Store) WorkspaceCheckpointBySeq(ctx context.Context, id uuid.UUID, seq uint64) (CheckpointSummary, bool, error)

WorkspaceCheckpointBySeq finds the checkpoint whose durable journal sequence is seq.

func (*Store) WorkspaceCheckpointByTurn

func (s *Store) WorkspaceCheckpointByTurn(ctx context.Context, id, turnID uuid.UUID) (CheckpointSummary, bool, error)

WorkspaceCheckpointByTurn finds the latest turn-triggered checkpoint caused by turnID.

func (*Store) WorkspaceLiveRefs

func (s *Store) WorkspaceLiveRefs(ctx context.Context, retainedSessionIDs []uuid.UUID) (map[workspacestore.Ref]struct{}, error)

WorkspaceLiveRefs computes the complete workspace-ref set from the retained session IDs supplied by the operator. It scans every journal and retains history from both checkpoint and restore transitions. This only discovers refs; collection remains an explicit operator action serialized against all snapshot writers.

type WorkspaceJournalScanError

type WorkspaceJournalScanError struct {
	SessionID uuid.UUID
	Cause     error
}

WorkspaceJournalScanError reports that a retained session journal could not be scanned completely for workspace references. Manual workspace GC must fail closed on this error: an incomplete live set is unsafe to collect against.

func (*WorkspaceJournalScanError) Error

func (e *WorkspaceJournalScanError) Error() string

func (*WorkspaceJournalScanError) Unwrap

func (e *WorkspaceJournalScanError) Unwrap() error

type WorkspacePointer

type WorkspacePointer struct {
	Ref     workspacestore.Ref     `json:"ref"`
	EventID uuid.UUID              `json:"event_id"`
	Seq     uint64                 `json:"seq"`
	Source  WorkspacePointerSource `json:"source,omitempty"`
}

WorkspacePointer identifies one durable workspace transition. Ref is content identity; Seq and EventID are the journal transition identity.

type WorkspacePointerSource

type WorkspacePointerSource string

WorkspacePointerSource identifies the transition that selected CurrentWorkspace. Unknown decodes catalog records written before this discriminator existed.

const (
	WorkspacePointerSourceUnknown    WorkspacePointerSource = ""
	WorkspacePointerSourceCheckpoint WorkspacePointerSource = "checkpoint"
	WorkspacePointerSourceRestore    WorkspacePointerSource = "restore"
)

Jump to

Keyboard shortcuts

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