import { randomUUID } from "node:crypto"; /** * Pure presence helpers shared by the Pi extension runtime and its tests. * * Presence is deliberately memory-only on the server: an active runtime keeps * refreshing a heartbeat while it is online, and the server expires stale * records on TTL. Nothing here is persisted. */ export type SessionPresenceState = "idle" | "running"; export const COMMAND_PROTOCOL_VERSION = 2; export interface PresenceSnapshot { sessionId: string; runtimeId: string; state: SessionPresenceState; /** Basename only; never a full cwd or transcript path. */ project: string; /** Normalized `provider/model` identifier. */ model: string; lastActivityAt: string; } export interface PresenceEligibility { configLoaded: boolean; globallyEnabled: boolean; tokenConfigured: boolean; commandEnabled: boolean; sessionSuppressed: boolean; sessionActive: boolean; } export interface PresenceHeartbeatBody { session_id: string; runtime_id: string; state: SessionPresenceState; project: string; model: string; command_protocol: typeof COMMAND_PROTOCOL_VERSION; last_activity_at: string; } export interface PresenceOfflineBody { session_id: string; runtime_id: string; } export function createPresenceRuntimeId(): string { return `runtime-${randomUUID()}`; } /** * A heartbeat is sent only for an active, non-subagent session with a token * and command delivery enabled. Activation is implied by a valid heartbeat, * so an inactive session must never register itself as online. */ export function presenceEnabled(input: PresenceEligibility): boolean { return input.configLoaded && input.globallyEnabled && input.tokenConfigured && input.commandEnabled && input.sessionActive && !input.sessionSuppressed; } export function buildPresenceHeartbeat(snapshot: PresenceSnapshot): PresenceHeartbeatBody { return { session_id: snapshot.sessionId, runtime_id: snapshot.runtimeId, state: snapshot.state, project: snapshot.project, model: snapshot.model, command_protocol: COMMAND_PROTOCOL_VERSION, last_activity_at: snapshot.lastActivityAt, }; } export function buildPresenceOffline(snapshot: PresenceSnapshot): PresenceOfflineBody { return { session_id: snapshot.sessionId, runtime_id: snapshot.runtimeId, }; } export interface PresenceReporterDeps { buildSnapshot: () => PresenceSnapshot | undefined; sendHeartbeat: (body: PresenceHeartbeatBody) => Promise; sendOffline: (body: PresenceOfflineBody) => Promise; } /** * Owns the heartbeat timer for one runtime. * * - `start()` sends an immediate heartbeat and then keeps refreshing on the * configured interval. * - A generation counter invalidates stale timers and in-flight refreshes after * a stop/restart or runtime replacement. * - An in-flight guard prevents overlapping heartbeat requests. * - `goOffline()` stops the timer and best-effort removes the record using the * last known snapshot. Heartbeat/offline failures stay silent: the next cycle * retries, and the server TTL naturally expires a runtime that is gone. */ export class PresenceReporter { private timer: NodeJS.Timeout | undefined; private generation = 0; private dirty = false; private inFlight: Promise | undefined; private offlineInFlight: Promise | undefined; private inFlightSnapshot: PresenceSnapshot | undefined; private lastSnapshot: PresenceSnapshot | undefined; private readonly deps: PresenceReporterDeps; constructor(deps: PresenceReporterDeps) { this.deps = deps; } isRunning(): boolean { return this.timer !== undefined; } getLastSnapshot(): PresenceSnapshot | undefined { return this.lastSnapshot; } start(intervalMs: number): void { this.stop(); const generation = this.generation; const period = Math.max(1, Math.trunc(intervalMs)); this.timer = setInterval(() => { if (generation === this.generation) this.refresh(); }, period); this.timer.unref?.(); this.refresh(); } refresh(): void { if (this.timer === undefined) return; if (this.inFlight || this.offlineInFlight) { this.dirty = true; return; } void this.beat(this.generation); } stop(): void { this.generation += 1; this.dirty = false; if (this.timer) clearInterval(this.timer); this.timer = undefined; } async goOffline(): Promise { // When disable removes eligibility, a new session's in-flight beat is more // recent than the previous session's last successful snapshot. const snapshot = this.deps.buildSnapshot() || this.inFlightSnapshot || this.lastSnapshot; this.stop(); const generation = this.generation; const pending = this.inFlight || this.offlineInFlight; if (pending) { try { await pending; } catch { // The heartbeat failure is already best-effort; continue to offline cleanup. } } // A concurrent restart owns the runtime again. Do not let this older // offline request remove a record created by the restarted reporter. if (generation !== this.generation || this.timer !== undefined) return; if (!snapshot) return; const request = (async () => { await this.deps.sendOffline(buildPresenceOffline(snapshot)); })(); this.offlineInFlight = request; try { await request; } catch { // Stay silent; TTL expiry removes the record if this best-effort call fails. } finally { if (this.offlineInFlight === request) { this.offlineInFlight = undefined; if (this.dirty && this.timer !== undefined) { this.dirty = false; this.refresh(); } } } } private async beat(generation: number): Promise { if (this.timer === undefined || generation !== this.generation || this.inFlight || this.offlineInFlight) return; const snapshot = this.deps.buildSnapshot(); if (!snapshot) return; const request = (async () => { await this.deps.sendHeartbeat(buildPresenceHeartbeat(snapshot)); })(); this.inFlight = request; this.inFlightSnapshot = snapshot; try { await request; if (generation === this.generation && this.timer !== undefined) { this.lastSnapshot = snapshot; } } catch { // Stay silent; the next interval retries and the server expires stale records. } finally { if (this.inFlight === request) { this.inFlight = undefined; this.inFlightSnapshot = undefined; // A newer start/refresh may have arrived during this request. Submit // its latest snapshot, never the old generation's captured state. if (this.dirty && this.timer !== undefined) { this.dirty = false; this.refresh(); } } } } }