/** * Realtime scope sync — a WebSocket subscription, with polling as the floor. * * The agent holds the whole assigned project and environment in RAM. Keeping * that current by polling a cursor means a rotation is live somewhere between * zero and one interval after it happens, and the interval cannot be shortened * far because every agent on the fleet is asking. A subscription inverts it: the * platform pushes, so propagation is a network hop and an idle agent costs one * open socket instead of a request every few seconds. * * ## Polling is not deleted, and that is the whole design * * A WebSocket is the fast path, never the only one. It fails in ways an HTTP * request does not — a proxy that idles it out, a load balancer that drops it, a * network that half-closes so the socket looks open and delivers nothing. Every * one of those is silent, and a silent sync failure on a vault replica means an * application confidently serving a credential that was revoked an hour ago. * * So the subscription is a latency optimisation over a poll that keeps running: * * · the socket delivers changes as they happen * · the poll continues at a LONG interval and is what actually establishes * freshness — `lastSyncOkMs` moves on a poll, not on a frame * · a poll that finds changes the socket should have delivered is proof the * socket is lying, and it is torn down and reopened * * That last point is the one worth the code. A half-open socket is * indistinguishable from a quiet one, and the only way to tell them apart is to * ask over a channel that is known to work. * * ## Freshness is never asserted by a frame * * A frame proves the platform sent something. It does not prove this agent is * seeing everything, because an attacker who can drop frames silently can hold a * replica stale for as long as they like. The staleness clock therefore only * advances on a successful poll — an authenticated round trip the agent * initiated — and `get` still refuses when that clock runs out. A subscription * can make the replica fresher; it can never make it *vouchable*. */ export type ChangeFrame = { type: 'changed'; names: readonly string[]; cursor: number; } | { type: 'resync'; reason?: string; } | { type: 'revoked'; reason?: string; }; export interface Subscriber { /** Close and stop reconnecting. */ stop(): void; /** For status output: is the socket currently up? */ readonly live: boolean; /** How many times it has reconnected. A climbing number is a bad network. */ readonly reconnects: number; } export interface SubscribeOptions { url: string; /** * Opens a socket. Injected so this is testable without a server, and so a * runtime with its own WebSocket can supply it. */ open(url: string): SocketLike; /** Applied when a frame arrives. */ onFrame(frame: ChangeFrame): void | Promise; /** The poll that establishes freshness. Runs regardless of the socket. */ poll(): Promise<{ invalidated: string[]; resync: boolean; }>; /** Long, because the socket is doing the fast work. */ pollIntervalMs?: number; /** Backoff ceiling. */ maxBackoffMs?: number; onEvent?: (event: string, detail?: unknown) => void; now?: () => number; setTimer?: (fn: () => void, ms: number) => unknown; clearTimer?: (handle: unknown) => void; } export interface SocketLike { close(): void; onmessage: ((event: { data: string; }) => void) | null; onopen: (() => void) | null; onclose: (() => void) | null; onerror: ((error: unknown) => void) | null; } /** * Reconnect with exponential backoff and full jitter. * * Jitter is not politeness. Every agent on a fleet loses its socket at the same * instant when the platform restarts, and without jitter every one of them * reconnects at the same instant too — turning a deploy into a self-inflicted * denial of service at exactly the moment the platform is least able to absorb * it. Full jitter across the whole window spreads them out. */ export declare function backoffMs(attempt: number, ceiling: number, random: () => number): number; export declare function subscribe(options: SubscribeOptions): Subscriber;