import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { prepareGithubActionsWatch, preparedWatchTargetKey, watchPreparedGithubActions, } from "./github.ts"; import { BackgroundWatchWidget, hasRunningJob, SPINNER_INTERVAL_MS } from "./renderer.ts"; import type { BackgroundWatchFinalEntry, BackgroundWatchSettledEvent, BackgroundWatchSnapshot, GithubActionsWatchInput, PreparedGithubActionsWatch, RunWatchDetails, WatchResult, WatchRuntime, } from "./types.ts"; export const BACKGROUND_ENTRY_TYPE = "github-actions-watch-final"; export const BACKGROUND_SETTLED_EVENT = "github-actions-watch:settled"; export const BACKGROUND_WIDGET_KEY = "github-actions-watch-background"; export const MAX_BACKGROUND_WATCHES = 3; const SHUTDOWN_SETTLE_TIMEOUT_MS = 2_000; interface BackgroundWatchHost { exec: WatchRuntime["exec"]; appendEntry(customType: string, data?: T): void; emitSettled(event: BackgroundWatchSettledEvent): void; /** Deliver the final watch status to the agent conversation (skipped for cancellations). */ sendSettledMessage(entry: BackgroundWatchFinalEntry): void; } interface BackgroundWatchTask { id: string; targetKey: string; input: GithubActionsWatchInput; prepared: PreparedGithubActionsWatch; state: "watching" | "stopping"; startedAt: number; controller: AbortController; latestDetails?: RunWatchDetails; settled: Promise; } export interface BackgroundStartResult { watchId: string; targetKey: string; deduplicated: boolean; } export interface BackgroundStopResult { watchIds: string[]; found: boolean; } export interface BackgroundWatchManagerOptions { maxActive?: number; now?: () => number; makeId?: () => string; prepare?: typeof prepareGithubActionsWatch; watch?: typeof watchPreparedGithubActions; persistLogs?: WatchRuntime["persistLogs"]; shutdownSettleTimeoutMs?: number; /** Injectable spinner ticker; returns its own stop function. Defaults to an unref'd interval. */ spinnerScheduler?: (tick: () => void, intervalMs: number) => () => void; } const defaultSpinnerScheduler = (tick: () => void, intervalMs: number): (() => void) => { const timer = setInterval(tick, intervalMs); timer.unref?.(); return () => clearInterval(timer); }; function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function targetLabel(snapshot: BackgroundWatchSnapshot): string { const details = snapshot.latestDetails; if (details?.mode === "run" && details.runs[0]) return `${details.repo} run #${details.runs[0].id}`; if (details) return `${details.repo}@${details.headSha?.slice(0, 7) ?? "HEAD"}`; if (snapshot.input.run) return `run ${snapshot.input.run}`; return `${snapshot.input.repo ?? "current repo"}@${snapshot.input.branch ?? "HEAD"}`; } function plainWidgetLines(snapshots: BackgroundWatchSnapshot[]): string[] { return snapshots.map((snapshot) => `Actions ${snapshot.watchId}: ${targetLabel(snapshot)} (${snapshot.state})`); } async function waitBounded(promises: Promise[], milliseconds: number): Promise { if (promises.length === 0) return; let timer: ReturnType | undefined; try { await Promise.race([ Promise.allSettled(promises).then(() => undefined), new Promise((resolve) => { timer = setTimeout(resolve, milliseconds); }), ]); } finally { if (timer) clearTimeout(timer); } } export class BackgroundWatchManager { private readonly tasks = new Map(); private readonly maxActive: number; private readonly now: () => number; private readonly makeId: () => string; private readonly prepare: typeof prepareGithubActionsWatch; private readonly watch: typeof watchPreparedGithubActions; private readonly persistLogs?: WatchRuntime["persistLogs"]; private readonly shutdownSettleTimeoutMs: number; private readonly spinnerScheduler: (tick: () => void, intervalMs: number) => () => void; private sequence = 0; private shuttingDown = false; private uiContext?: ExtensionContext; private widgetInstalled = false; private requestWidgetRender?: () => void; private stopSpinnerTimer?: () => void; constructor( private readonly host: BackgroundWatchHost, options: BackgroundWatchManagerOptions = {}, ) { this.maxActive = options.maxActive ?? MAX_BACKGROUND_WATCHES; this.now = options.now ?? Date.now; this.makeId = options.makeId ?? (() => `actions-${this.now().toString(36)}-${(++this.sequence).toString(36)}`); this.prepare = options.prepare ?? prepareGithubActionsWatch; this.watch = options.watch ?? watchPreparedGithubActions; this.persistLogs = options.persistLogs; this.shutdownSettleTimeoutMs = options.shutdownSettleTimeoutMs ?? SHUTDOWN_SETTLE_TIMEOUT_MS; this.spinnerScheduler = options.spinnerScheduler ?? defaultSpinnerScheduler; } async start( input: GithubActionsWatchInput, ctx: ExtensionContext, parentSignal?: AbortSignal, ): Promise { if (this.shuttingDown) throw new Error("GitHub Actions background watch manager is shutting down"); const controller = new AbortController(); const abortPreparation = () => controller.abort(); parentSignal?.addEventListener("abort", abortPreparation, { once: true }); let prepared: PreparedGithubActionsWatch; try { prepared = await this.prepare(input, this.runtime(ctx, controller.signal)); } finally { parentSignal?.removeEventListener("abort", abortPreparation); } if (this.shuttingDown) { controller.abort(); throw new Error("GitHub Actions background watch manager is shutting down"); } const targetKey = preparedWatchTargetKey(prepared); const duplicate = [...this.tasks.values()].find((task) => task.targetKey === targetKey); if (duplicate) { return { watchId: duplicate.id, targetKey, deduplicated: true }; } if (this.tasks.size >= this.maxActive) { throw new Error(`At most ${this.maxActive} background GitHub Actions watches may run at once`); } const id = this.makeId(); const task: BackgroundWatchTask = { id, targetKey, input: { ...input, background: true }, prepared, state: "watching", startedAt: this.now(), controller, settled: Promise.resolve(), }; this.tasks.set(id, task); this.refreshUi(ctx); task.settled = this.supervise(task, ctx); return { watchId: id, targetKey, deduplicated: false }; } getSnapshots(watchId?: string): BackgroundWatchSnapshot[] { const tasks = watchId ? [this.tasks.get(watchId)].filter((task): task is BackgroundWatchTask => Boolean(task)) : [...this.tasks.values()]; return tasks.map((task) => ({ watchId: task.id, targetKey: task.targetKey, state: task.state, input: task.input, startedAt: task.startedAt, elapsedSeconds: Math.max(0, Math.floor((this.now() - task.startedAt) / 1_000)), latestDetails: task.latestDetails, })); } async stop(options: { watchId?: string; all?: boolean }, ctx: ExtensionContext): Promise { const selected = options.all ? [...this.tasks.values()] : options.watchId ? [this.tasks.get(options.watchId)].filter((task): task is BackgroundWatchTask => Boolean(task)) : []; if (selected.length === 0) return { watchIds: [], found: false }; for (const task of selected) { task.state = "stopping"; task.controller.abort(); } this.refreshUi(ctx); await waitBounded(selected.map((task) => task.settled), this.shutdownSettleTimeoutMs); return { watchIds: selected.map((task) => task.id), found: true }; } async shutdown(ctx?: ExtensionContext): Promise { if (this.shuttingDown) return; this.shuttingDown = true; const active = [...this.tasks.values()]; for (const task of active) task.controller.abort(); this.clearUi(ctx ?? this.uiContext); await waitBounded(active.map((task) => task.settled), this.shutdownSettleTimeoutMs); this.tasks.clear(); } private runtime(ctx: ExtensionContext, signal: AbortSignal): WatchRuntime { return { cwd: ctx.cwd, signal, exec: this.host.exec, persistLogs: this.persistLogs, }; } private async supervise(task: BackgroundWatchTask, ctx: ExtensionContext): Promise { let result: WatchResult | undefined; let caught: unknown; try { result = await this.watch(task.prepared, this.runtime(ctx, task.controller.signal), (details) => { task.latestDetails = details; this.refreshUi(ctx); }); } catch (error) { caught = error; } if (this.shuttingDown) { this.tasks.delete(task.id); return; } const settledAt = this.now(); let entry: BackgroundWatchFinalEntry; let notificationType: "info" | "warning" | "error"; if (result) { entry = { watchId: task.id, targetKey: task.targetKey, input: task.input, terminalState: "completed", startedAt: task.startedAt, settledAt, summary: result.summary, details: result.details, }; notificationType = result.details.outcome === "failure" ? "error" : "info"; } else if (isAbortError(caught)) { entry = { watchId: task.id, targetKey: task.targetKey, input: task.input, terminalState: "cancelled", startedAt: task.startedAt, settledAt, summary: `GitHub Actions background watch ${task.id} was cancelled.`, details: task.latestDetails, error: "GitHub Actions watch cancelled", }; notificationType = "warning"; } else { const message = errorMessage(caught ?? "Unknown GitHub Actions watch error"); entry = { watchId: task.id, targetKey: task.targetKey, input: task.input, terminalState: "error", startedAt: task.startedAt, settledAt, summary: `GitHub Actions background watch ${task.id} failed: ${message}`, details: task.latestDetails, error: message, }; notificationType = "error"; } this.tasks.delete(task.id); this.refreshUi(ctx); this.host.appendEntry(BACKGROUND_ENTRY_TYPE, entry); this.host.emitSettled({ ...entry, eventVersion: 1, artifactPath: entry.details?.artifactPath, }); if (entry.terminalState !== "cancelled") this.host.sendSettledMessage(entry); if (ctx.hasUI) ctx.ui.notify(entry.summary, notificationType); } private refreshUi(ctx: ExtensionContext): void { if (this.shuttingDown) return; this.uiContext = ctx; const snapshots = this.getSnapshots(); if (snapshots.length === 0) { this.clearUi(ctx); return; } if (ctx.mode === "tui") { if (!this.widgetInstalled) { ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, (tui, theme) => { const widget = new BackgroundWatchWidget(() => this.getSnapshots(), theme); this.requestWidgetRender = () => { widget.invalidate(); tui.requestRender(); }; return widget; }); this.widgetInstalled = true; } else { this.requestWidgetRender?.(); } } else if (ctx.mode === "rpc") { ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, plainWidgetLines(snapshots)); } if (ctx.hasUI) { ctx.ui.setStatus( BACKGROUND_WIDGET_KEY, ctx.ui.theme.fg("accent", `Actions: ${snapshots.length} running`), ); } this.syncSpinner(ctx, snapshots); } /** * Repaint the widget on the spinner cadence so in-progress jobs animate smoothly between * polls. The timer exists only while a job is actually running, only in the TUI, and is * unref'd so it never keeps the process alive. It repaints; it never polls GitHub. */ private syncSpinner(ctx: ExtensionContext, snapshots: BackgroundWatchSnapshot[]): void { const animate = ctx.mode === "tui" && !this.shuttingDown && snapshots.some((snapshot) => hasRunningJob(snapshot.latestDetails)); if (animate && !this.stopSpinnerTimer) { this.stopSpinnerTimer = this.spinnerScheduler(() => this.requestWidgetRender?.(), SPINNER_INTERVAL_MS); } else if (!animate) { this.stopSpinner(); } } private stopSpinner(): void { this.stopSpinnerTimer?.(); this.stopSpinnerTimer = undefined; } private clearUi(ctx?: ExtensionContext): void { this.stopSpinner(); if (!ctx?.hasUI) return; ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined); ctx.ui.setStatus(BACKGROUND_WIDGET_KEY, undefined); this.widgetInstalled = false; this.requestWidgetRender = undefined; } }