sessions

package
v1.11.0 Latest Latest
Warning

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

Go to latest
Published: Jul 5, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

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:

  1. Identity captured immutably on Open — Touch / Close re-save the same identity from the existing record; mismatched ctx identity is rejected with ErrIdentityMismatch.
  2. Reopen-after-close forbidden — clients open a new SessionID.
  3. 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.
  4. 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

View Source
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

View Source
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.

View Source
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.

View Source
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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

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):

  1. 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.
  2. 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.
  3. 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).
  4. artifacts: enumerate the session's artifacts and delete each, then durably checkpoint the cumulative count (issue #410).
  5. memory: flush the session's memory to a clean state, then checkpoint.
  6. 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).
  7. 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

type Clock interface {
	Now() time.Time
}

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

func WithClock(c Clock) Option

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

func WithGCPolicy(p GCPolicy) Option

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

func (r *Registry) Close(ctx context.Context, id string, reason string) error

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

func (r *Registry) CloseRegistry(_ context.Context) error

CloseRegistry cancels the sweeper goroutine and joins it. Idempotent. Subsequent operations return ErrRegistryClosed.

func (*Registry) EnsureOpen added in v1.2.0

func (r *Registry) EnsureOpen(ctx context.Context, ident identity.Identity) (*Session, error)

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

func (r *Registry) Erase(ctx context.Context, id string) error

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

func (r *Registry) GC(ctx context.Context, policy GCPolicy) (int, error)

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

func (r *Registry) Get(ctx context.Context, id string) (*Session, error)

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

func (r *Registry) Inspect(ctx context.Context, id string) (*SessionSnapshot, error)

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

func (r *Registry) Open(ctx context.Context, id string, ident identity.Identity) (*Session, error)

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.

func (*Registry) Touch

func (r *Registry) Touch(ctx context.Context, id string) error

Touch updates LastSeen and re-saves. Identity-mandatory; ctx Identity is compared against the stored Identity, mismatch returns ErrIdentityMismatch. Touch on a Closed session returns ErrReopenAfterClose (Closed records are read-only).

type RunningProbe

type RunningProbe func(ctx context.Context, q identity.Quadruple) (bool, error)

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

type SessionSnapshot struct {
	Session
	Running bool
}

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.

Directories

Path Synopsis
Package protocol implements the two `sessions.*` Protocol methods the Console Sessions page consumes:
Package protocol implements the two `sessions.*` Protocol methods the Console Sessions page consumes:

Jump to

Keyboard shortcuts

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