import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; /** * Tick hourly and let pi decide when a fetch is actually due: pi treats stored * catalogs as fresh for REMOTE_CATALOG_REFRESH_INTERVAL_MS (4h) and skips the * network entirely on an unforced refresh inside that window. Ticking at * exactly 4h would land a few seconds short of the window every time, because * pi stamps `checkedAt` only after a fetch completes, so every other tick would * be skipped and the real cadence would be ~8h. A shorter interval removes that * off-by-one-latency skip, tolerates clock skew, and costs nothing when the * cache is fresh, because scheduled runs never set `force`. */ const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; /** Give the daemon a moment to finish startup before the first network refresh. */ const DEFAULT_INITIAL_DELAY_MS = 15_000; /** * Bound every catalog refresh so a stalled provider fetch cannot run forever. * One run covers every refreshable provider under a single signal, so this is a * whole-cycle budget for a background job, not pi's 15s startup budget. */ const DEFAULT_TIMEOUT_MS = 60_000; /** * Wait this long before the single follow-up attempt that a timed-out or failed * run earns, so a transient network problem does not cost a whole interval. */ const DEFAULT_RETRY_DELAY_MS = 5 * 60 * 1000; /** * Scheduled runs defer to pi's freshness gate; forced runs bypass it because * the caller knows the cached catalog is wrong (for example after a login). */ type RefreshMode = "scheduled" | "forced"; /** A queued follow-up must keep the strongest mode requested while a run was in flight. */ const strongestMode = (left: RefreshMode | undefined, right: RefreshMode): RefreshMode => left === "forced" || right === "forced" ? "forced" : "scheduled"; /** Whether a run finished with a complete picture, or earned a retry. */ type RunOutcome = "complete" | "incomplete"; /** Minimal structured-logging seam for refresh lifecycle and non-fatal failures. */ export interface ModelCatalogRefresherLogger { info(details: Record, message: string): void; warn(details: Record, message: string): void; error(details: Record, message: string): void; } export interface ModelCatalogRefresherOptions { runtime: Pick; logger?: ModelCatalogRefresherLogger; /** * When true the operator asked for offline behavior, so no network refresh is * ever attempted. Injected from the daemon environment instead of read from * `process.env` here, so the decision stays explicit and testable. */ offline?: boolean; intervalMs?: number; initialDelayMs?: number; timeoutMs?: number; retryDelayMs?: number; } const noopLogger: ModelCatalogRefresherLogger = { info() { /* no-op */ }, warn() { /* no-op */ }, error() { /* no-op */ }, }; /** * Refreshes provider model catalogs over the network on a background schedule. * * The shared ModelRuntime is constructed offline (see authService.ts), so its * own refreshes never touch the network and stay fast on request paths. This * refresher is the single place that deliberately performs network refreshes — * bounded by an abort timeout, serialized through one in-flight run, stopped by * `dispose()` even mid-flight, and off any request path. `requestRefresh()` * additionally asks for a prompt forced refresh after events that change what * should be listed, such as provider logins, where the cached catalog is known * to be wrong. * * When the operator asked for offline behavior (`PI_OFFLINE` / `PI_WEB_OFFLINE`), * the refresher performs no network I/O at all and the cached catalogs in * models-store.json are used as they are. */ export class ModelCatalogRefresher { private readonly runtime: Pick; private readonly logger: ModelCatalogRefresherLogger; private readonly offline: boolean; private readonly intervalMs: number; private readonly initialDelayMs: number; private readonly timeoutMs: number; private readonly retryDelayMs: number; private initialTimer?: NodeJS.Timeout; private intervalTimer?: NodeJS.Timeout; private retryTimer: NodeJS.Timeout | undefined; private inflight: Promise | undefined; private queuedMode: RefreshMode | undefined; private started = false; private disposed = false; /** * Aborted by `dispose()` so a refresh already in flight stops with the * refresher instead of holding its fetch open for the rest of the timeout * budget, which would delay daemon shutdown. */ private readonly lifetime = new AbortController(); constructor(options: ModelCatalogRefresherOptions) { this.runtime = options.runtime; this.logger = options.logger ?? noopLogger; this.offline = options.offline ?? false; this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; this.initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; } /** Idempotent: a second call keeps the timers the first call installed. */ start(): void { if (this.disposed || this.started) return; this.started = true; if (this.offline) { this.logger.info({}, "offline mode is enabled; skipping background model catalog refreshes"); return; } this.initialTimer = setTimeout(() => { this.queueRefresh("scheduled"); }, this.initialDelayMs); this.initialTimer.unref(); this.intervalTimer = setInterval(() => { this.queueRefresh("scheduled"); }, this.intervalMs); this.intervalTimer.unref(); } /** * Ask for an immediate refresh that bypasses pi's freshness gate, for callers * that know the cached catalog is stale (auth changes). Coalesces with * concurrent and overlapping requests, and is a no-op in offline mode so auth * changes never trigger network I/O either. */ requestRefresh(): void { this.queueRefresh("forced"); } /** * Whether a network catalog refresh is running right now. Read-only: callers * that report what a slow operation is concurrent with need this fact, and * must not be able to change the refresh schedule by asking for it. */ isRefreshInFlight(): boolean { return this.inflight !== undefined; } /** Terminal: stops the schedule, drops any queued follow-up, and aborts an in-flight run. */ dispose(): void { this.disposed = true; if (this.initialTimer !== undefined) clearTimeout(this.initialTimer); if (this.intervalTimer !== undefined) clearInterval(this.intervalTimer); this.clearRetryTimer(); this.lifetime.abort(); } /** * Single entry point for every refresh trigger. Only one run is ever in * flight; overlapping requests collapse into one follow-up that keeps the * strongest mode asked for, so a forced request is never downgraded. */ private queueRefresh(mode: RefreshMode, isRetry = false): void { if (this.disposed || this.offline) return; // Any fresh trigger supersedes a pending retry, which keeps retries from // stacking up behind normal activity. if (!isRetry) this.clearRetryTimer(); if (this.inflight !== undefined) { this.queuedMode = strongestMode(this.queuedMode, mode); return; } // runCycle() reports every failure through the logger and never rejects. this.inflight = this.runCycle(mode, isRetry); } /** One run plus the follow-up it leads to: a queued request, a single retry, or nothing. */ private async runCycle(mode: RefreshMode, isRetry: boolean): Promise { const outcome = await this.run(mode); this.inflight = undefined; const queued = this.queuedMode; this.queuedMode = undefined; if (queued !== undefined) { // A request that arrived during the run replaces any retry this run would // have earned; it runs now instead. this.queueRefresh(queued); return; } // Only a first attempt earns a retry, so a failing provider can never turn // into a retry loop. if (outcome === "incomplete" && !isRetry) this.scheduleRetry(mode); } private scheduleRetry(mode: RefreshMode): void { if (this.disposed) return; this.logger.info({ retryDelayMs: this.retryDelayMs, mode }, "scheduling one model catalog refresh retry"); this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.queueRefresh(mode, true); }, this.retryDelayMs); this.retryTimer.unref(); } private clearRetryTimer(): void { if (this.retryTimer === undefined) return; clearTimeout(this.retryTimer); this.retryTimer = undefined; } private async run(mode: RefreshMode): Promise { try { const result = await this.runtime.refresh({ allowNetwork: true, force: mode === "forced", signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(this.timeoutMs)]), }); if (result.aborted && this.lifetime.signal.aborted) { this.logStoppedByDispose(); return "incomplete"; } if (result.aborted) { this.logger.warn({ timeoutMs: this.timeoutMs }, "model catalog refresh timed out; keeping cached catalogs"); } if (result.errors.size > 0) { const providers = [...result.errors.entries()].map(([providerId, error]) => `${providerId}: ${error.message}`); this.logger.warn({ providers }, "model catalog refresh failed for some providers; keeping cached catalogs"); } return result.aborted || result.errors.size > 0 ? "incomplete" : "complete"; } catch (error: unknown) { if (this.lifetime.signal.aborted) this.logStoppedByDispose(); else this.logger.error({ err: error }, "model catalog refresh failed; keeping cached catalogs"); return "incomplete"; } } /** A shutdown abort is expected, so it must not be reported as a timeout or a fault. */ private logStoppedByDispose(): void { this.logger.info({}, "model catalog refresh aborted by dispose; keeping cached catalogs"); } }