/** * union-cache.ts, the SDK session read facade (moved from goodvibes-tui). * * The cache-backed read layer that lets panel/read consumers keep their * SYNCHRONOUS listSessions()/getSession() shape while telling the truth about * cross-surface sessions. * * The problem it solves: in adopted-daemon mode a surface's OWN * SharedSessionBroker only holds the sessions THIS process created, so a panel * reading it misses every session hosted on the adopted daemon from other surfaces * (companion, webui, other TUIs). The daemon's own reads are ASYNC * (HttpTransport.operator.sessions.list returns a Promise) while the local broker's * are SYNC, a signature mismatch that blocks a drop-in swap. * * This facade bridges that: in adopted mode it refreshes the wire union on a modest * interval (and on demand), caches the last-known rows, and serves them * synchronously alongside the local rows. It NEVER lies: * * - EMBEDDED mode (this process's own broker IS the daemon's broker): pure * passthrough to the local broker, it already IS the truth, no wire, no * 'offline' segment. * - ADOPTED mode, wire reachable: serve union(local, wire), deduped by id (local * wins for its own session), with lastSyncAt + a `stale` flag. * - ADOPTED mode, wire UNREACHABLE (daemon died mid-session): degrade to 'offline' * , serve ONLY the local rows plus an honest 'cross-surface view offline' note, * rather than presenting the stale last-known union as if it were live. * - LOCAL/dormant (never adopted, or non-external mode): passthrough local, no * cross-surface claim. * * The wire refresh runs on its OWN interval timer (injectable) and is never invoked * from the render/keystroke hot path, reads are served from the cache * synchronously, so the facade adds zero awaits to any interactive path. * * MOVE NOTE (One-Platform): this generalizes cleanly (SDK-clean deps, * already parameterized via injected local + wireReader, generation-guarded probes) * and serves the union goal, so it now lives in the SDK alongside the spine client. * That "generalizes cleanly" is ARCHITECTURAL until a second real consumer imports * it, today only the TUI does. */ import { logger } from '../../utils/logger.js'; import type { SharedSessionBroker, SharedSessionRecord } from '../../control-plane/index.js'; /** The synchronous local read source, the in-process SharedSessionBroker. */ export type LocalSessionReader = Pick; /** The async wire reader, HttpTransport.operator.sessions.list against an adopted daemon. */ export interface WireSessionReader { list(limit?: number): Promise; } export type SessionUnionMode = 'local' | 'embedded' | 'adopted'; /** * Honest cross-surface posture for the panels to render. `offlineNote` is non-null * ONLY in adopted mode when the wire is unreachable, that is the exact string a * panel should show next to its (local-only) session rows. */ export interface CrossSurfaceView { readonly mode: SessionUnionMode; /** True only after a successful wire refresh in adopted mode. */ readonly online: boolean; /** True when the served rows are not a confirmed-live union (offline, or aged past the freshness window). */ readonly stale: boolean; /** Wall-clock ms of the last successful wire refresh, or null if never. */ readonly lastSyncAt: number | null; /** Honest operator note when the cross-surface view is offline, else null. */ readonly offlineNote: string | null; } /** * The read surface panels/openers consume in place of the raw broker. Declares its * own signatures (readonly returns) rather than inheriting the broker's mutable * ones, so both the broker-backed cache and the cache itself satisfy it. */ export interface SessionReadFacade { listSessions(limit?: number): readonly SharedSessionRecord[]; getSession(sessionId: string): SharedSessionRecord | null; readonly crossSurfaceView: CrossSurfaceView; } export interface SessionUnionCacheOptions { readonly local: LocalSessionReader; readonly now?: () => number; /** Wire refresh cadence in adopted mode (default 5s). */ readonly refreshIntervalMs?: number; /** A served union older than this reads as `stale` even while nominally online (default 20s). */ readonly staleAfterMs?: number; /** Upper bound on rows pulled from the wire per refresh (default 200). */ readonly wireLimit?: number; /** * Bound how long a single refresh() will wait on the wire before treating it as a * failed probe (default 4s, under the 5s refresh cadence). A dead daemon usually * rejects the fetch promptly (ECONNREFUSED), but a process that dies mid-connection * can leave a stale keep-alive socket that the runtime/OS doesn't notice for a long * time (well past any acceptable UI latency), this timeout caps the wait so the * probe can never hang past ~1 refresh interval. */ readonly probeTimeoutMs?: number; /** * Optional live accessor for the TRUE shared identity of "which wire rows * are mine", typically `() => sessionSpineClient.mirroredSessionIds` * (see {@link SessionSpineClient.mirroredSessionIds}). When supplied, every * wire row whose id appears in this set is dropped from the wire side of * the union BEFORE merging with `local`: the module doc's own invariant is * that `local` holds exactly this surface's own sessions, so `local` is * always the authoritative view for them regardless of what id the wire * mirror happens to carry for the same conceptual session. * * Why this matters: the plain merge below dedups by raw `record.id` * equality between `wireCache` and `local.listSessions()`. That is only * correct if whatever mirrored a local session onto the wire used the EXACT * same id the local reader reports for it, an assumption this facade * cannot verify and a caller can violate (e.g. a local record created * without an explicit id, auto-assigned one scheme, mirrored to the wire * under a separately-chosen id). When that happens, id-only dedup counts * the surface's own session TWICE: once from `wireCache` under the * wire-registered id, once from `local` under its own id, a constant +1 * that can spuriously trip a caller's overflow cap. Filtering wireCache by * the CANONICAL registered-id set fixes this for any number of self * sessions, with no special-casing, and is a no-op (byte-identical result) * whenever the ids already agree. */ readonly selfSessionIds?: (() => ReadonlySet) | undefined; /** Injectable timer seam for deterministic tests. */ readonly scheduler?: { setInterval?: (fn: () => void, ms: number) => ReturnType; clearInterval?: (handle: ReturnType) => void; setTimeout?: (fn: () => void, ms: number) => ReturnType; clearTimeout?: (handle: ReturnType) => void; }; readonly log?: Pick; } /** * Derive the footer's spine online/offline segment from the FRESHEST liveness * signal. The spine client's own status() is ACTIVITY-gated, it only flips on a * register/heartbeat/close wire call, so after the daemon dies mid-idle the footer * keeps reading 'online' until the next activity (seconds to minutes). The union * cache, by contrast, probes the wire every refreshIntervalMs (5s) in adopted mode, * so ITS `online` flag is a genuine liveness heartbeat with a bounded staleness. * * Rule (one signal, no new timer): once the wire has been confirmed reachable at * least once (`lastSyncAt !== null`), the union probe is authoritative for the * footer, a failed 5s probe reads 'offline' within one interval of the daemon * dying, and recovers on the next success. Before any confirmation (or when not * adopted), fall back to the spine client's own status. */ export declare function deriveSpineFooterStatus(spineStatus: 'unknown' | 'online' | 'offline', view: Pick): 'unknown' | 'online' | 'offline'; export declare class SessionUnionCache implements SessionReadFacade { private readonly local; private readonly now; private readonly refreshIntervalMs; private readonly staleAfterMs; private readonly wireLimit; private readonly probeTimeoutMs; private readonly selfSessionIds; private readonly scheduler; private readonly log; private mode; private wireReader; private wireCache; private online; private lastSyncAt; private timer; /** Guards against overlapping refresh() calls racing the same wire. */ private refreshInFlight; /** * Bumped on every activate()/markEmbedded()/deactivate(), stamps which adoption is * CURRENT. A performRefresh() call captures this at start and checks it again once * its wire promise settles; if it has moved on, the whole write-back (cache, online, * lastSyncAt, onTransition) is dropped, so a probe started under a superseded reader * can never overwrite a newer reader's state or paint a phantom liveness flip for a * UI that has already moved on. */ private generation; /** * Fired whenever a refresh() flips `online` (either direction). A consumer wires * this to requestRender() so a liveness change is never just correct DATA sitting * uncomposited, without it the flip is only PAINTED whenever some unrelated * activity happens to trigger the next render, which during an idle stretch can be * minutes away. */ private onTransition; constructor(options: SessionUnionCacheOptions); /** Current facade mode, for diagnostics/tests. */ getMode(): SessionUnionMode; /** * Register a callback fired whenever a refresh() flips the online/offline liveness * state, so a consumer can repaint the footer's spine segment promptly on a real * transition instead of waiting for incidental render activity. Pass null to clear. */ setOnTransition(callback: ((online: boolean) => void) | null): void; /** * Enter ADOPTED mode against a reachable daemon's wire reader. Kicks an immediate * refresh and starts the interval poll. Idempotent per reader. */ activate(wireReader: WireSessionReader): void; /** * Enter EMBEDDED mode, this process's broker IS the daemon's broker, so the local * reads are already the whole truth. Pure passthrough, no wire. */ markEmbedded(): void; /** Return to LOCAL/dormant mode (non-external daemon, or adoption lost). */ deactivate(reason: string): void; /** * Pull the wire union once and update the cache. Awaitable so tests drive it * deterministically. A rejecting wire degrades to offline WITHOUT dropping to a lie: * `online` flips false so listSessions() serves local-only rows. * * The wire call is raced against probeTimeoutMs so a stale/hung connection can't * hold `online` at a stale `true` indefinitely. An in-flight guard collapses * overlapping calls into the SAME pending probe. Fires onTransition exactly when * `online` actually flips (not on every tick). */ refresh(): Promise; private performRefresh; /** * Bound how long refresh() will wait on the wire promise. The underlying promise is * NOT cancelled (no AbortSignal reaches this layer today), it may still settle later * in the background and its result is simply ignored, but refresh() itself never * waits past probeTimeoutMs, which is what keeps the liveness probe honest under a * hung connection. */ private raceWithProbeTimeout; listSessions(limit?: number): readonly SharedSessionRecord[]; getSession(sessionId: string): SharedSessionRecord | null; get crossSurfaceView(): CrossSurfaceView; dispose(): void; private resetWireState; private stopTimer; } //# sourceMappingURL=union-cache.d.ts.map