Documentation
¶
Overview ¶
Package sessions owns Harbor's session-lifecycle subsystem.
A Session is a longer-lived multi-turn conversation that contains many Runs. Identity for runtime concerns is the triple `(tenant, user, session)`; runs are scoped within sessions (RFC §6.9). The Session record itself is keyed in the StateStore at `Kind = "session.lifecycle"` with `RunID = ""` — sessions are session-scoped, not run-scoped.
Harbor ships a single concrete *Registry implementation that sits over the StateStore, codifying the typed-wrapper-over-generic StateStore contract. There is no driver pluralism at the session layer; driver pluralism lives at StateStore (in-mem / SQLite / Postgres already). Per AGENTS.md §4.4, optional-capability ceremony is forbidden when all V1 drivers (here: implementations) will implement everything.
Four lifetime invariants are load-bearing and pinned by tests:
- Identity captured immutably on Open — Touch / Close re-save the same identity from the existing record; mismatched ctx identity is rejected with ErrIdentityMismatch.
- Reopen-after-close forbidden — clients open a new SessionID.
- Cross-tenant SessionID reuse rejected — `SessionID=S` opened under Tenant A then attempted under Tenant B returns ErrSessionIDReuse, even though the StateStore key (which contains the full Quadruple) would not naturally collide.
- GC never reaps a session with a RUNNING task — enforced when the TaskRegistry-backed probe (TaskRunningProbe) is wired via WithGCPolicy, as both reference assemblies do. The no-op default (returns false, nil) exists only for registries constructed without task awareness.
Lifecycle events (`session.opened / .touched / .closed / .gc_reaped`) land on the EventBus as `SafePayload` types — they're Harbor-internal markers with no secret-shaped fields by construction (RFC §6.13). Subscribers can extract typed fields directly, no redactor walk in between.
Index ¶
- Constants
- Variables
- type CascadeEraser
- type CascadeEraserDeps
- type Clock
- type GCPolicy
- type Option
- type Registry
- func (r *Registry) Close(ctx context.Context, id string, reason string) error
- func (r *Registry) CloseRegistry(_ context.Context) error
- func (r *Registry) EnsureOpen(ctx context.Context, ident identity.Identity) (*Session, error)
- func (r *Registry) Erase(ctx context.Context, id string) error
- func (r *Registry) GC(ctx context.Context, policy GCPolicy) (int, error)
- func (r *Registry) Get(ctx context.Context, id string) (*Session, error)
- func (r *Registry) Inspect(ctx context.Context, id string) (*SessionSnapshot, error)
- func (r *Registry) ListSnapshots(ctx context.Context, f SessionListFilter) ([]SessionSnapshot, error)
- func (r *Registry) Open(ctx context.Context, id string, ident identity.Identity) (*Session, error)
- func (r *Registry) Touch(ctx context.Context, id string) error
- type RunningProbe
- type Session
- type SessionClosedPayload
- type SessionErasedPayload
- type SessionGCReapedPayload
- type SessionLimits
- type SessionListFilter
- type SessionLister
- type SessionOpenedPayload
- type SessionRegistry
- type SessionSnapshot
- type SessionTouchedPayload
Constants ¶
const ( EventTypeSessionOpened events.EventType = "session.opened" EventTypeSessionTouched events.EventType = "session.touched" EventTypeSessionClosed events.EventType = "session.closed" EventTypeSessionGCReaped events.EventType = "session.gc_reaped" // EventTypeSessionErased marks a completed data-lifecycle erasure // (`sessions.delete`). It is the ONLY durable trace of the erasure — // a redacted, content-free record-of-fact emitted under the actor's // observability scope, NEVER under the erased session's own identity // (re-persisting under the erased triple would leave the session's // `state.history` non-empty — RFC §6.13 / §7). EventTypeSessionErased events.EventType = "session.erased" )
Session lifecycle event types. Each is registered with the events package's exhaustive registry via init() so Publish accepts them without ErrUnknownEventType. Subscribers can filter on these via events.Filter.Types.
Variables ¶
var ( // ErrReopenAfterClose — Open called for a SessionID whose existing // record is Closed. Per RFC §6.9 ("Reopen-after-close is forbidden"). ErrReopenAfterClose = errors.New("sessions: reopen-after-close forbidden") // ErrSessionIDReuse — Open called with a SessionID already opened // under a different (tenant, user). Per RFC §6.9 ("reusing a session // ID across tenants/users is rejected"). ErrSessionIDReuse = errors.New("sessions: SessionID reused across tenants/users") // ErrIdentityMismatch — Touch / Close called with a ctx Identity // that disagrees with the stored session's Identity. The triple is // captured immutably on Open; mid-flight identity swaps are bugs. ErrIdentityMismatch = errors.New("sessions: ctx identity mismatches stored session identity") // ErrSessionNotFound — Get / Touch / Close / Inspect targeting a // SessionID that has no record (or the record was Deleted). ErrSessionNotFound = errors.New("sessions: session not found") // ErrSessionAlreadyOpen — Open called twice with the same triple // AND SessionID without an intervening Close. Distinct from // ErrReopenAfterClose (which fires when Closed is true). ErrSessionAlreadyOpen = errors.New("sessions: session already open") // ErrRegistryClosed — any operation called after CloseRegistry. ErrRegistryClosed = errors.New("sessions: registry is closed") // ErrSessionRunning — Erase was refused because the target session // has a RUNNING task. Erasure mirrors the GC never-reap-running // invariant (RFC §6.9): a session with in-flight work is durable // execution state, not a cache entry, so it is refused fail-loud and // NO store is touched. The caller retries after the task finishes. ErrSessionRunning = errors.New("sessions: cannot erase a session with a running task") )
Sentinel errors. Callers compare via errors.Is.
var ErrEraserMisconfigured = errors.New("sessions: CascadeEraser missing a mandatory dependency")
ErrEraserMisconfigured — NewCascadeEraser was called with a missing mandatory dependency. Fails closed (CLAUDE.md §5) rather than building an eraser that would nil-panic on the first erasure.
var ErrErasureRecordFailed = errors.New("sessions: erasure record-of-fact could not be durably completed")
ErrErasureRecordFailed — the cascade's destructive steps completed (in this attempt or an earlier converging one) but the durable record-of-fact could not be completed: a redactor refusal or a bus-publish failure at the final `session.erased` emit. Erase fails the WHOLE call loud rather than reporting success with a missing/incomplete audit trail. The session's scoped data IS gone; the durable erasure-ledger checkpoint (persisted before the irreversible StateStore.DeleteScope clear — see erasureLedgerRecord) survives, so a re-invoke converges: it skips straight to re-attempting the record + emit from the checkpointed cumulative counts, never re-running a destructive step.
Functions ¶
This section is empty.
Types ¶
type CascadeEraser ¶ added in v1.7.0
type CascadeEraser struct {
// contains filtered or unexported fields
}
CascadeEraser performs the ordered, fail-loud, idempotent session erasure cascade behind `sessions.delete`: it refuses fail-loud on a running task, then deletes the session's scoped Artifacts, Memory, and State (the kind-agnostic StateStore scope delete removes the durable session-lifecycle record + run-scoped trajectories + planner checkpoints + the durable event stream), clears the registry's in-memory catalogs, and completes with a redacted, content-free `session.erased` audit event under the actor's observability scope.
There is no ACID transaction across the three independent stores; the cascade is ordered and every per-store delete is idempotent, so a mid-cascade error surfaces loudly (never a partial silent success — CLAUDE.md §13) and the cascade is safe to re-invoke to convergence. The refuse-if-running pre-flight runs FIRST so a refusal touches nothing; the event-bus fence (see fenceSession) runs immediately after it and BEFORE any destructive step, and a present-but-failing fence capability fails the whole cascade loud right there — the same retry-safe posture as a mid-cascade store error, just earlier.
The audit record is part of the erasure's success criteria ¶
Two follow-ups (issues #409/#410) hardened this cascade's audit trail on top of the ordering + fencing shape above. Previously, the final `session.erased` emit was best-effort AFTER the destructive steps completed: a transient bus/redactor failure lost the ONLY audit record while Erase still reported success, and — because the destructive steps (specifically StateStore.DeleteScope) had already removed the session-lifecycle record — a re-invoke returned ErrSessionNotFound, leaving no second chance to emit. Separately, a mid-cascade retry re-ran idempotent deletes that found fewer records the second time, so the reported counts reflected only the final converging attempt.
The BINDING ordering invariant: at no point may (data irrevocably gone) ∧ (no durable audit record) ∧ (success returned) hold — nor the inverse (record says erased, data present, success returned). The chosen mechanism is to persist a durable compliance checkpoint (erasureLedgerRecord, via the StateStore the cascade already drives) BEFORE each destructive step's result would otherwise become unrecoverable, rather than bounded-retrying the emit in-call. Concretely:
- The ledger checkpoint is persisted under the actor's observability scope (ledgerScope — same reserved (tenant, user, <erasure-audit>) slot the final event publishes under), NOT the erased triple, so it survives StateStore.DeleteScope's clear of the erased session's own scope. It is tenant/user-scoped OPERATOR AUDIT DATA about a deleted session — never user content — addressing the phase plan's identity-scoping risk note head-on.
- The ledger is checkpointed immediately after EVERY destructive step (artifacts, memory, state), accumulating each step's own count onto whatever a PRIOR interrupted attempt already contributed (loaded at the top of Erase). This is what makes counts cumulative across converging attempts (#410): a step whose data is already gone on a retry contributes 0 locally, but the ledger already holds the earlier attempt's non-zero contribution. The ledger carries the session lifecycle's OpenedAt stamp so accumulation NEVER crosses a session-id reuse boundary: when the target session still exists (a fresh attempt, not a converging retry) and a leftover ledger's stamp mismatches the live session's OpenedAt, the leftover belongs to an ABANDONED prior lifecycle of the reused id — it is discarded (with a loud Warn) and the cascade starts from zero, so a reused session id's counts reflect only its own lifecycle. Discarding necessarily forfeits the abandoned lifecycle's never-emitted record-of-fact: the caller was told loudly (ErrErasureRecordFailed) to re-invoke and instead reopened the id — inflating the NEW lifecycle's compliance counts to preserve a stale checkpoint would be the worse corruption.
- Because the ledger holds the complete, accurate counts BEFORE StateStore.DeleteScope ever runs (steps 1-2 checkpoint before step 3), the compliance record is durably persisted strictly before the irreversible clear — satisfying the first half of the invariant. The final record-of-fact emit (completeErasure/emitErased) reads ONLY from the ledger, never recomputing counts, so it can't drift.
- A re-invoke after the destructive steps completed but the final emit failed finds the session already gone (ErrSessionNotFound from the registry pre-flight) AND a still-present ledger checkpoint — recognized as a CONVERGING retry: it skips every destructive step (nothing left to do) and goes straight to completeErasure, which re-attempts the record + emit using the ledger's cumulative counts. Only a successful emit clears the ledger, so an emit failure keeps the session "re-invokable" without ever touching a store again.
- A redactor refusal or a bus-publish failure at the final emit both fail Erase loud with the wrapped sentinel ErrErasureRecordFailed (no `Error`-log-and-continue) — the same loud path, because both mean the durable record-of-fact did not land.
- The final emit is IDEMPOTENT per (session, lifecycle): before publishing, completeErasure checks the observability scope's retained history (when the bus supports events.HistoryReplayer) for a `session.erased` record already carrying this session id + lifecycle stamp, and on a hit skips the publish — converging silently to success and still clearing the ledger. This closes the duplicate-emit window when the publish succeeded but the ledger cleanup failed and the caller re-invoked. The check is capability-gated and bounded (see recordAlreadyEmitted): when it cannot verify, it emits — a duplicate compliance record is the acceptable failure direction, a lost one never is.
- Concurrent Erase calls for the SAME session are serialized by a striped IN-PROCESS lock (lockSession) so exactly one goroutine in this process ever runs the cascade for a given session at a time: the loser blocks until the winner's cascade (through ledger cleanup) is fully done, then sees the genuine ErrSessionNotFound with no ledger — "one wins, one gets the not-found path, never a double event." HONESTY NOTE — the lock is per-process only: two runtime REPLICAS racing sessions.delete for the same session are NOT serialized (their ledger checkpoints are last-writer-wins StateStore saves, and both may run destructive steps — each of which is idempotent, so the data outcome converges). The cross-replica residuals are count skew on the racing attempts and a possible duplicate emit where the item-6 history check races the other replica's publish; never a lost record, never resurrected data. Cross-replica serialization would need a StateStore CAS/lease primitive — the same recorded limitation as the agent-config registry's per-process owner locks.
Documented residual gap (accepted, not silently dropped): there is no ACID transaction spanning a destructive store (artifacts/memory/state) and the StateStore the ledger checkpoint itself is persisted through — a generalized transactional-outbox subsystem is an explicit non-goal of this design. A failure in the narrow window between a destructive step succeeding and its OWN checkpoint save committing means that step's contribution is not durably recorded; a converging retry's local count for that step is by then 0 (already deleted), so the final total under-counts by exactly that step's amount. This is strictly narrower than the earlier whole-cascade gap it replaces (a failure ANYWHERE previously lost the whole attempt's counts) and is pinned by TestCascadeEraser_LedgerSaveFailure_LoudAndRetrySafe rather than left silently untested. It never affects the fail-loud / no-lost-record invariant (CLAUDE.md §13) — only count exactness on this specific interleaving.
A constructed *CascadeEraser is immutable after construction and safe to share across N concurrent goroutines: every method's per-call state lives in the call's arguments and locals, the shared stores + registry are each independently concurrency-safe, and the fixed-size striped lock array (eraseLocks) is the one internally-synchronized mutable field — it never grows with the number of distinct sessions ever erased.
func NewCascadeEraser ¶ added in v1.7.0
func NewCascadeEraser(deps CascadeEraserDeps) (*CascadeEraser, error)
NewCascadeEraser builds the session-erasure cascade orchestrator. Registry / State / Memory / Artifacts / Bus are mandatory — a nil fails loud with a wrapped ErrEraserMisconfigured. The returned *CascadeEraser is immutable after construction and safe for concurrent use by N goroutines.
func (*CascadeEraser) Erase ¶ added in v1.7.0
func (e *CascadeEraser) Erase(ctx context.Context, id identity.Identity) (prototypes.SessionsDeleteResponse, error)
Erase runs the full erasure cascade for the verified identity. The identity is the caller's own verified `(tenant, user, session)` — the own-session-only scope contract is enforced at the wire / service edge before Erase is reached. Order (RFC §6.9, hardened with the durable record-of-fact ordering + cumulative counts described above):
- lock: serialize concurrent Erase calls for this exact session (lockSession) so only one goroutine ever runs the steps below for it at a time; load any durable erasure-ledger checkpoint a PRIOR interrupted attempt left behind.
- refuse-if-running: load+verify the session record under id and probe the running-task seam. A refusal touches NO store — UNLESS a ledger checkpoint already exists, in which case ErrSessionNotFound here means the destructive steps already fully completed on a prior attempt and only the final record-of-fact remains: Erase converges via completeErasure without touching artifacts / memory / state again. When the session EXISTS, the ledger's lifecycle stamp (the session's OpenedAt) is verified: a leftover ledger from an abandoned prior lifecycle of a reused session id is discarded with a loud Warn so counts never inflate across a reuse boundary.
- fence: mark the triple erased on the event bus, BEFORE any destructive step. A present-but-failing Fence capability fails the WHOLE erasure loud here — nothing has been touched yet, so this is retry-safe. A bus that does not implement the capability at all is a logged downgrade, not an error (see fenceSession).
- artifacts: enumerate the session's artifacts and delete each, then durably checkpoint the cumulative count (issue #410).
- memory: flush the session's memory to a clean state, then checkpoint.
- state: kind-agnostic scope delete (the session-lifecycle record, run-scoped trajectories, planner checkpoints, and the durable event stream all live under the triple and all go) — THE irreversible clear — then checkpoint the final cumulative count. Because steps 3-5 each checkpoint before the NEXT step runs, the ledger is always durably complete strictly before this step's clear takes effect ("record before the irreversible clear" ordering).
- complete: clear the registry's in-memory catalogs, build the redacted content-free `session.erased` payload from the ledger's cumulative counts, publish it (skipping idempotently when a record for this session+lifecycle already exists — recordAlreadyEmitted), and — only once the record durably exists — remove the ledger checkpoint (completeErasure).
Returns ErrSessionRunning (refused — 409), ErrSessionNotFound (absent under the caller's identity AND no pending ledger — 404), ErrErasureRecordFailed (destructive steps done, record incomplete — re-invokable), or a wrapped store/fence error (loud, retry-safe: every step through state.DeleteScope is idempotent, and the fence step (2) runs before step 3 ever touches a store). The response carries non-sensitive deletion telemetry only.
type CascadeEraserDeps ¶ added in v1.7.0
type CascadeEraserDeps struct {
Registry *Registry
State state.StateStore
Memory memory.MemoryStore
Artifacts artifacts.ArtifactStore
Bus events.EventBus
Redactor audit.Redactor
Clock Clock
Logger *slog.Logger
}
CascadeEraserDeps bundles the seams the CascadeEraser drives. Registry, State, Memory, Artifacts, and Bus are mandatory; Redactor, Clock, and Logger are optional.
type Clock ¶
Clock abstracts time so GC tests are deterministic without time.Sleep. Production code uses realClock; tests pass a fakeClock.
The interface intentionally returns time.Time directly (not a monotonic count) so GC's wall-clock math is identical between production and test paths.
type GCPolicy ¶
type GCPolicy struct {
IdleTTL time.Duration
HardCap time.Duration
SweepInterval time.Duration
RunningProbe RunningProbe
}
GCPolicy bundles the GC sweeper's tunables. Defaults match RFC §6.9: IdleTTL 24h, HardCap 720h (30 days), SweepInterval 15m. The RunningProbe should be TaskRunningProbe wherever a TaskRegistry is in scope; default is the no-op.
type Option ¶
type Option func(*Registry)
Option configures a Registry at construction. Only WithClock is exported; production code does not touch it. Tests use a fake clock to drive GC deterministically.
func WithClock ¶
WithClock injects a custom Clock. Production code uses realClock; the test suite uses a controllable clock to exercise hard-cap GC without time.Sleep (per AGENTS.md §11).
func WithGCPolicy ¶
WithGCPolicy injects an explicit GCPolicy at construction. When omitted, the GCPolicy is built from the SessionsConfig defaults + the no-op RunningProbe. Production wiring (cmd/harbor and harbortest/devstack) passes a policy whose RunningProbe is TaskRunningProbe so GC honors RFC §6.9.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry is the StateStore-backed implementation of SessionRegistry. It is the single concrete impl in V1 — driver pluralism lives at the StateStore layer.
Concurrency model:
- StateStore writes go through `state.StateStore.Save` which is itself concurrent-safe per the concurrent-reuse contract.
- The cross-tenant SessionID-uniqueness map is guarded by mu.
- The sweeper goroutine's lifecycle is owned by `done` + `wg`.
func New ¶
func New(store state.StateStore, cfg config.SessionsConfig, bus events.EventBus, opts ...Option) (*Registry, error)
New constructs a Registry. The store and bus are required; cfg supplies the GC tunables (defaults applied if zero); opts can override the Clock or GCPolicy.
The sweeper goroutine starts immediately and runs at gcPolicy.SweepInterval until CloseRegistry is called. The probe defaults to no-op; assemblies that own a TaskRegistry MUST pass TaskRunningProbe via WithGCPolicy (both reference assemblies do) so GC never reaps a session with a RUNNING task.
func (*Registry) Close ¶
Close marks the session Closed and emits session.closed. Idempotent: closing an already-closed session is a no-op AND preserves the original ClosedReason.
func (*Registry) CloseRegistry ¶
CloseRegistry cancels the sweeper goroutine and joins it. Idempotent. Subsequent operations return ErrRegistryClosed.
func (*Registry) EnsureOpen ¶ added in v1.2.0
EnsureOpen is the create-on-first-use entry point. It returns the live session for `ident`, creating it if no record exists. The session id is `ident.SessionID` (the per-request session the client chose); the (tenant, user) come from the verified connection token.
Semantics:
- No record yet → Open it (create) and return the fresh session.
- Open record at this exact triple → no-op, return the existing session (a second turn in the same conversation is not an error).
- Closed record at this triple → ErrReopenAfterClose (RFC §6.9: a GC-reaped or operator-closed session is read-only; a NEW conversation must pick a NEW session id). The caller maps this to a clear client error; it is never a silent revive.
EnsureOpen is identity-mandatory: an incomplete triple fails closed. It is the seam the Protocol ControlSurface calls on `start` so the first turn of a brand-new conversation materialises the session row the Console's sessions.list reads.
func (*Registry) Erase ¶ added in v1.7.0
Erase is the registry-side pre-flight + in-memory clear for a session erasure (`sessions.delete`). It runs the fail-loud preconditions that MUST hold before any store is touched — load+verify the record under the caller's verified ctx identity, then probe the RunningProbe seam — and, when they pass, clears the in-memory open-sessions / id-index map entry and the per-(tenant, user) discovery catalog.
Erase performs NO durable record delete of its own: the durable `session.lifecycle` record is itself a StateStore record under the triple, removed by the kind-agnostic StateStore.DeleteScope step of the cascade. Calling Erase standalone leaves the durable record in place (the caller is expected to run DeleteScope); the erasure orchestrator composes the two via the unexported preflightErase / clearErased halves so the probe runs FIRST (a refusal touches nothing) and the in-memory clear runs LAST (after DeleteScope).
Identity-mandatory: the ctx identity scopes the read, and its SessionID must equal id. Returns ErrSessionNotFound when no record is visible to the caller's identity, ErrSessionRunning when the probe reports a RUNNING task (no store touched), ErrIdentityMismatch when the stored identity disagrees with the caller's.
func (*Registry) GC ¶
GC performs a single sweep pass. For each currently-open session:
- if RunningProbe returns true: skip (per RFC §6.9 "GC never reaps a session with a RUNNING task").
- else if LastSeen + IdleTTL < clock.Now(): close with reason "gc:idle".
- else if OpenedAt + HardCap < clock.Now(): close with reason "gc:hard_cap" (the hard cap wins over recent Touch).
Returns the number of sessions reaped and the first probe / close error encountered (the sweep continues past errors so a single bad session doesn't block the rest).
func (*Registry) Get ¶
Get loads the session with `id` for the identity in ctx. Returns ErrSessionNotFound when the record is absent. Identity-mandatory: the ctx Identity is used to scope the StateStore read; cross-tenant access is prevented because the StateStore key contains the full triple.
func (*Registry) Inspect ¶
Inspect returns a SessionSnapshot. Running is derived from the configured RunningProbe at inspection time.
func (*Registry) ListSnapshots ¶
func (r *Registry) ListSnapshots(ctx context.Context, f SessionListFilter) ([]SessionSnapshot, error)
ListSnapshots implements sessions.SessionLister — the `search.sessions` read-side projection. Returns snapshots for every session the registry has seen (open OR closed) matching the filter.
The registry's in-memory `idIndex` is the catalog of every SessionID that has been Opened during this registry's lifetime. The snapshot is built by Loading each matching session from the StateStore so the Closed / ClosedAt / LastSeen fields are current; Running is derived from the GCPolicy RunningProbe at inspection time (mirroring Inspect's contract).
The caller (the search subsystem) is responsible for the auth scope gate — ListSnapshots does NOT re-check scope. It DOES validate that every supplied `TenantIDs` / `UserIDs` / `SessionIDs` entry is non-empty (a no-op for empty filters).
Concurrent reuse: ListSnapshots only reads `idIndex` / `openSessions` under the registry's mutex; no per-call state lives on `*Registry`. One Registry serves N concurrent ListSnapshots safely.
func (*Registry) Open ¶
Open creates a new session record, captures the identity triple immutably, and emits session.opened. Cross-tenant SessionID reuse and reopen-after-close are rejected; same-triple double-open is rejected with ErrSessionAlreadyOpen.
type RunningProbe ¶
RunningProbe is the seam the GC sweeper consults so it can honor "never reap a session with a RUNNING task" (RFC §6.9). The TaskRegistry-backed implementation is TaskRunningProbe; both reference assemblies wire it via WithGCPolicy. A nil probe is treated as the no-op default (returns false, nil) — only for registries constructed without task awareness.
func TaskRunningProbe ¶ added in v1.3.0
func TaskRunningProbe(reg tasks.TaskRegistry) RunningProbe
TaskRunningProbe adapts a tasks.TaskRegistry into the RunningProbe seam so the GC sweeper can enforce RFC §6.9's "GC never reaps a session with a RUNNING task" invariant. The probe lists the session's tasks filtered to tasks.StatusRunning (the exact status the RFC names — PENDING / PAUSED tasks do not block GC) and reports whether any exist.
Wiring: both reference assemblies (cmd/harbor `bootDevStack` and harbortest/devstack `Assemble`) pass this probe via sessions.New(..., WithGCPolicy(GCPolicy{RunningProbe: ...})). A registry constructed without it falls back to the no-op default, which reports no running tasks — sessions with in-flight work become reapable, so assemblies that own a TaskRegistry MUST wire this adapter.
The import direction is sound by construction: internal/tasks does not import internal/sessions (verified at the time of authoring), so the adapter lives here, next to the RunningProbe type it satisfies.
type Session ¶
type Session struct {
ID string
Identity identity.Identity
OpenedAt time.Time
LastSeen time.Time
Closed bool
ClosedAt time.Time
ClosedReason string
Limits SessionLimits
Context map[string]any
}
Session is the persisted lifecycle record for one session. The Identity field carries the triple captured on Open and is immutable afterwards. Closed transitions to true on Close; ClosedAt stays zero while Closed is false.
Limits and Context are reserved slots — later phases will populate Limits with the cost / token ceilings and Context with the (version, hash, llm/tool ctx, memory, artifacts) quintuple sketched in RFC §6.9. round-trips both fields through marshal/unmarshal but applies no validation.
type SessionClosedPayload ¶
type SessionClosedPayload struct {
events.SafeSealed
SessionID string
ClosedAt int64 // unix nanoseconds
Reason string
}
SessionClosedPayload reports a Close. Carries the SessionID, the ClosedAt timestamp, and the operator-provided Reason. Reason is a short caller-controlled string — callers MUST NOT pass tool args, raw user input, or any secret-shaped material; the bus does not re-redact SafePayload types.
type SessionErasedPayload ¶ added in v1.7.0
type SessionErasedPayload struct {
events.SafeSealed
// SessionID is the session that was erased.
SessionID string
// StateRecordsDeleted is the number of StateStore records removed by
// the kind-agnostic scope delete. Cumulative across converging
// attempts.
StateRecordsDeleted int
// ArtifactsDeleted is the number of artifacts removed. Cumulative
// across converging attempts.
ArtifactsDeleted int
// MemoryPurged is true when the session's memory was flushed clean.
MemoryPurged bool
// ErasedAt is the erasure timestamp (unix nanoseconds).
ErasedAt int64
}
SessionErasedPayload reports a completed session erasure. It carries the erased SessionID plus the per-store deletion counts and the erasure timestamp — non-sensitive telemetry only, NO user content. The erased session id is a payload FIELD (not the event's Identity): the event is published under the actor's observability scope so it never re-persists durable state under the erased triple. SafePayload by construction — every field is a bounded id, count, or timestamp.
This is the durable record-of-fact for a right-to-erasure operation, and publishing it is part of `sessions.delete`'s SUCCESS CRITERIA rather than a best-effort afterthought: Erase only ever returns success once this payload has been redacted and durably published. The count fields are CUMULATIVE across any converging retries the cascade needed (see internal/sessions.CascadeEraser's package godoc) — the true total actually removed, never just the last attempt's own count.
type SessionGCReapedPayload ¶
type SessionGCReapedPayload struct {
events.SafeSealed
SessionID string
ReapedAt int64 // unix nanoseconds
Reason string
}
SessionGCReapedPayload reports a GC sweep reaping. Reason is one of "gc:idle" or "gc:hard_cap"; the same string is also stored in Session.ClosedReason. SafePayload by construction.
type SessionLimits ¶
type SessionLimits struct {
}
SessionLimits is reserved for later phases (cost ceilings, tool catalog). Empty at V1; round-trips through marshal.
type SessionListFilter ¶
type SessionListFilter struct {
TenantIDs []string
UserIDs []string
SessionIDs []string
SinceLastSeen time.Time
UntilLastSeen time.Time
IncludeClosed bool
}
SessionListFilter narrows ListSnapshots's result set. All fields are wildcards when empty. SinceLastSeen / UntilLastSeen filter by the LastSeen timestamp; zero means "no bound."
type SessionLister ¶
type SessionLister interface {
// ListSnapshots returns session snapshots that match the filter,
// scoped to the requested tenants. Empty `TenantIDs` matches every
// tenant the registry has seen (the search subsystem gates this
// on the caller's auth.ScopeAdmin claim — the registry does NOT
// re-check scope). Empty `UserIDs` / `SessionIDs` are wildcards.
ListSnapshots(ctx context.Context, f SessionListFilter) ([]SessionSnapshot, error)
}
SessionLister is the narrow read-side capability the `search.sessions` Searcher consumes. The triple `(tenant, user, session)` is the load-bearing isolation key (CLAUDE.md §6); the listing is server-enforced per the supplied SessionListFilter. The returned snapshots include both currently-open and previously-closed sessions — search wants the union.
Intentionally NOT on the SessionRegistry interface: the lister is a projection over the in-memory open-session index plus the StateStore. Concrete `*Registry` implements it; future drivers add it when their backing store gains a `list` capability (StateStore List is post-V1).
type SessionOpenedPayload ¶
type SessionOpenedPayload struct {
events.SafeSealed
SessionID string
OpenedAt int64 // unix nanoseconds; identity-of-record across drivers
}
SessionOpenedPayload reports a successful Open. Carries the SessionID and the OpenedAt timestamp; the identity triple lives on the Event itself, so it is intentionally NOT duplicated here.
SafePayload by construction — no secret-shaped fields.
type SessionRegistry ¶
type SessionRegistry interface {
Open(ctx context.Context, id string, ident identity.Identity) (*Session, error)
// EnsureOpen is the create-on-first-use entry point: it
// returns the live session for ident, creating it if absent and
// no-opping if already open. A closed session is NOT revived
// (ErrReopenAfterClose). See the concrete impl for full semantics.
EnsureOpen(ctx context.Context, ident identity.Identity) (*Session, error)
Get(ctx context.Context, id string) (*Session, error)
Touch(ctx context.Context, id string) error
Close(ctx context.Context, id string, reason string) error
Inspect(ctx context.Context, id string) (*SessionSnapshot, error)
GC(ctx context.Context, policy GCPolicy) (int, error)
// CloseRegistry cancels the sweeper goroutine and joins it.
// Idempotent. Distinct method name (rather than Close) so it
// doesn't collide with Close(id, reason).
CloseRegistry(ctx context.Context) error
}
SessionRegistry is the public surface every consumer ( envelope writer, Protocol surface, Console subscribers, etc.) talks to. One concrete impl ships in a later phase (`*Registry`); driver pluralism lives at the StateStore layer.
type SessionSnapshot ¶
SessionSnapshot is the read-side projection returned by Inspect. Carries the lifecycle fields plus a Running boolean derived from the GCPolicy.RunningProbe at inspection time. Running is intrinsically stale by the time the caller reads it; the same is true of any snapshot model.
type SessionTouchedPayload ¶
type SessionTouchedPayload struct {
events.SafeSealed
SessionID string
LastSeen int64 // unix nanoseconds
}
SessionTouchedPayload reports a Touch. Carries the SessionID and the new LastSeen timestamp. SafePayload by construction.