diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index 8ab276b6..9dfb4446 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -1,10 +1,14 @@ -// Inspector view — pure composition of atoms/components + layout selectors. -// Owns the message protocol (runlabel / iteration / warming / completed / -// pulsemeta / pulse / ping) shared with run_inspector.ts. +// Inspector view — per-run panes (1.3) over the post-#67 native pulse protocol. +// Owns the runId-keyed message protocol shared with run_inspector.ts: +// runlabel · iteration · warming · completed · pulsemeta · pulse (+ activate/ping) +// every message carries `runId`; the view keeps ONE `panel` per runId and shows +// the ACTIVE one (host sends `activate`). A late/throttled message for a +// background run updates ITS pane only — never the visible pane's badge/plot +// (no cross-talk; #67's plot-only-pulse property preserved per pane). // -// The live pulse renders NATIVELY from pulse data (#66) — the per-iter PNGs -// remain run-dir/archival artifacts but are no longer displayed. A run whose -// log carries no pulse lines shows a hint instead of a plot. +// The live pulse renders NATIVELY from pulse data (#66); per-iter PNGs remain +// archival. Pane MARKUP is the design lane (UX4 #49) — this is the plumbing +// reshape (freeze 2: the runId-keyed protocol, not the DOM). import { defineStyle } from "../style"; import { mark } from "../atoms/icon"; @@ -17,6 +21,10 @@ defineStyle("inspector-view", ` body { margin: 0; height: 100vh; font-family: var(--text-font); font-size: var(--text-body); color: var(--vscode-foreground); } .brand { font-weight: 600; } + /* Panes carry .stack (display:flex from layout.css). Use two-class selectors so + these win over .stack on specificity — not on stylesheet order. */ + .pane:not(.active) { display: none; } + .pane.active { display: flex; } `); const IDLE_HINT = "No solve in progress — fire one from the Amicode chat, or run “Replay demo run”."; @@ -28,7 +36,15 @@ export interface InspectorView { onMessage(msg: unknown): void; } -export function createInspectorView(post: (msg: unknown) => void): InspectorView { +/** One run's pane — the β single-run view, now instanced per runId. No + * single-run globals: everything (status, plot, metrics, gotPulse) is closed + * over here, so N panes never share state. */ +interface Panel { + el: HTMLElement; + apply(msg: Record): void; +} + +function createPanel(): Panel { const status = pill("idle"); const runLabel = text("mono small dim"); const pulse = pulseplot(IDLE_HINT); @@ -37,10 +53,7 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView const feasibility = metric("feasibility"); const optimality = metric("optimality"); const metrics = [hero, iteration, feasibility, optimality]; - - /** Whether the current run has delivered pulse data — decides the - * completed-without-data hint. Reset on warming (a NEW run started). */ - let gotPulse = false; + let gotPulse = false; // per-pane: decides the completed-without-data hint const brand = document.createElement("div"); brand.className = "row gap-sm brand"; @@ -56,24 +69,18 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView grid.append(...metrics.map((m) => m.el)); const el = document.createElement("div"); - el.className = "stack pad-lg scroll-y"; + el.className = "pane stack pad-lg scroll-y"; el.style.height = "100vh"; el.append(topbar, pulse.el, grid); return { el, - onMessage(msg: any): void { - if (!msg || typeof msg !== "object") return; + apply(msg: Record): void { switch (msg.type) { - case "ping": { - post({ type: "pong", seq: msg.seq, t0: msg.t0 }); - break; - } - case "runlabel": { + case "runlabel": runLabel.set(String(msg.text ?? "")); break; - } - case "iteration": { + case "iteration": hero.label("objective"); hero.value((msg.f_val as number).toExponential(4)); iteration.value(String(msg.iter)); @@ -81,42 +88,77 @@ export function createInspectorView(post: (msg: unknown) => void): InspectorView optimality.value((msg.kkt_error as number).toExponential(2)); status.set("running", "running"); break; - } - case "warming": { - // A NEW run started but has no data yet — clear the previous run's - // plot + stats so the old pulse doesn't linger while the new solve - // compiles/warms up. + case "warming": gotPulse = false; pulse.waiting(WARMING_HINT); for (const m of metrics) m.clear(); hero.label("objective"); status.set("running", "warming up"); break; - } case "completed": { - // Authoritative terminal state from the watcher (FINISHED on disk). const ok = msg.status === "completed"; status.set(ok ? "done" : "failed", ok ? "converged" : String(msg.status)); - // Promote the hero card to the final fidelity — the number that matters. if (ok && typeof msg.fidelity === "number") { hero.label("fidelity"); hero.value((msg.fidelity as number).toFixed(5)); } - if (!gotPulse) pulse.waiting(NO_DATA_HINT); // old runs / non-emitting scripts + if (!gotPulse) pulse.waiting(NO_DATA_HINT); break; } - case "pulsemeta": { - pulse.meta({ drives: msg.drives, knots: msg.knots, labels: msg.labels, bounds: msg.bounds }); + case "pulsemeta": + pulse.meta({ drives: msg.drives as number, knots: msg.knots as number, labels: msg.labels as string[], bounds: msg.bounds as [number, number][] }); break; - } - case "pulse": { - // Plot-only: never touches the status pill (a throttled record can - // legally land after "completed"; the badge must not regress). + case "pulse": + // Plot-only (never the badge): a throttled record can land after + // "completed", and for a background run must not touch the visible pane. gotPulse = true; - pulse.update({ iter: msg.iter, dt: msg.dt, values: msg.values }); + pulse.update({ iter: msg.iter as number, dt: msg.dt as number, values: msg.values as number[][] }); break; - } } }, }; } + +export function createInspectorView(post: (msg: unknown) => void): InspectorView { + const panels = new Map(); + let active: string | undefined; + + // Shell holds the panes; an empty-state hint shows until the first run. + const empty = text("dim", IDLE_HINT); + empty.el.className = "pad-lg dim"; + + const el = document.createElement("div"); + el.style.height = "100vh"; + el.append(empty.el); + + const panelFor = (runId: string): Panel => { + let p = panels.get(runId); + if (!p) { + p = createPanel(); + panels.set(runId, p); + el.append(p.el); + } + return p; + }; + + const activate = (runId: string): void => { + active = runId; + empty.el.style.display = "none"; + for (const [id, p] of panels) p.el.classList.toggle("active", id === runId); + if (!panels.has(runId)) panelFor(runId).el.classList.add("active"); // pane may arrive before data + }; + + return { + el, + onMessage(msg: any): void { + if (!msg || typeof msg !== "object") return; + if (msg.type === "ping") { post({ type: "pong", seq: msg.seq, t0: msg.t0 }); return; } + if (msg.type === "activate") { if (typeof msg.runId === "string") activate(msg.runId); return; } + // Every other message is runId-keyed → route to that run's pane. A message + // with no runId (legacy/none) falls back to the active pane. + const runId = typeof msg.runId === "string" ? msg.runId : active; + if (!runId) return; + panelFor(runId).apply(msg as Record); + }, + }; +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 70e3c4ff..3c52b323 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { "name": "amicode-v2", "displayName": "Amicode v2", - "description": "Amico research IDE \u2014 opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", + "description": "Amico research IDE — opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", "version": "0.0.1", "publisher": "harmoniqs", "license": "Apache-2.0", @@ -149,6 +149,7 @@ "@types/vscode": "^1.95.0", "@vscode/vsce": "^3.2.0", "esbuild": "^0.24.0", + "happy-dom": "^20.10.6", "smol-toml": "^1.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index bfd81ae7..9055a445 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -105,6 +105,10 @@ export class PulseStream { } export interface IterRecord { iter: number; f_val: number; inf_pr: number; inf_du: number } +/** Terminal completion, built by readTerminalState and flowed WHOLE to every + * consumer (never exploded into positional args mid-pipe) — the #84 funnel. + * Additive contract fields join HERE + readTerminalState and reach all paths + * by construction: #81's `formulation?` next, then #64 hashing / #41 usage. */ export interface RunCompletion { runId: string; runDir: string; status: RunStatus; fidelity?: number } export interface PromoteInfo { runId: string; runDir: string; fidelity: number } diff --git a/packages/extension/src/run_inspector.ts b/packages/extension/src/run_inspector.ts index 846f0527..bd85a02a 100644 --- a/packages/extension/src/run_inspector.ts +++ b/packages/extension/src/run_inspector.ts @@ -4,44 +4,59 @@ import { inspectorResourceRootDirs } from "./opencode_paths"; import type { PulseEvent, PulseMeta, PulseRecord } from "./run_dir_reader"; // ============================================================================ -// Run Inspector — bottom-panel webview that shows the live pulse-plot stream -// from spike_solve.jl plus a stats row driven by AMICODE_ITER parsing. +// Run Inspector — bottom-panel webview showing the live pulse-plot stream + +// an AMICODE_ITER stats row. // -// Ported from amicode/src/spikes/spike_b_inspector.ts with the simulation -// experiments stripped (we don't need ping/sim now that we have a real run -// path). Keeps the throttled image swap + setImageSource / postIteration API. +// 1.3 (#58): multi-run. The host↔webview message protocol is now runId-keyed +// (freeze 2 = the protocol, not the DOM): every message carries `runId`, the +// host keeps ONE buffer per runId, and the webview keeps ONE pane per runId. +// RunsManager fans ALL runs in here runId-tagged and calls activate(runId) to +// pick the visible pane. Per-run buffers make reopen (S36 buffer+replay) rebuild +// EVERY pane, not just the active one; the 5 Hz pulse throttle is per-run so a +// fast background run can't starve the foreground. +// +// Ported from the β single-run view (throttled record swap + native pulse #66). // ============================================================================ -const REFRESH_INTERVAL_MS = 200; // 5 Hz cap on pulse-record refresh +const REFRESH_INTERVAL_MS = 200; // 5 Hz cap on pulse-record refresh (per run) let INSPECTOR: InspectorView | undefined; +/** Everything replayable about one run's pane. Kept current whether or not the + * webview exists, so resolveWebviewView can rebuild the pane on reopen (S36). + * `pulseTimer`/`pendingPulse` are live-only throttle state (per run). */ +interface PaneBuffer { + runId: string; + runLabel?: string; + warming: boolean; + completion?: { status: string; fidelity?: number }; + pulseMeta?: PulseMeta; + pulseRecord?: PulseRecord; // newest record (throttle coalesces to this) + iterRecord?: { iter: number; f_val: number; kkt_error: number; eq_viol: number; ineq_viol: number; rho: number }; + pulseTimer?: NodeJS.Timeout; + pendingPulse?: PulseRecord; +} + class InspectorView implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; - /** Terminal state that arrived before the webview existed (e.g. on launch the - * watcher follows `latest` → a finished run completes before the panel is - * opened). Replayed after buffered pulse data so the badge isn't stuck "running". */ - private bufferedCompletion?: { status: string; fidelity?: number }; - /** A run started but hasn't emitted its first frame yet (Julia warming up). - * Buffered so the warming state shows even if the panel opens late. */ - private bufferedWarming = false; - /** Run label (runId) for the topbar — buffered so it shows even if the panel - * opens after the run was selected. */ - private bufferedRunLabel?: string; - /** Pulse events that arrived before the webview existed (#66 AC7). Unlike - * PNGs (re-offered by the poll forever), the log line is the canonical - * signal — dropped means gone. Meta + NEWEST record only. */ - private bufferedPulseMeta?: PulseMeta; - private bufferedPulseRecord?: PulseRecord; - /** 5 Hz throttle for live pulse records — same REFRESH_INTERVAL_MS policy as - * PNG frames: leading edge posts, the window coalesces (newest wins), the - * trailing edge flushes. Meta is never throttled (once per run). */ - private pulseTimer?: NodeJS.Timeout; - private pendingPulse?: PulseRecord; + /** One buffer per runId — the multi-run state. */ + private readonly panes = new Map(); + /** The run whose pane is visible. Buffered until the webview materializes so + * a late-opened panel still lands on the right pane. */ + private activeRunId?: string; constructor(private readonly ctx: vscode.ExtensionContext) {} + private paneFor(runId: string): PaneBuffer { + let p = this.panes.get(runId); + if (!p) { + p = { runId, warming: false }; + this.panes.set(runId, p); + } + return p; + } + resolveWebviewView(view: vscode.WebviewView): void { this.view = view; view.webview.options = { @@ -51,106 +66,106 @@ class InspectorView implements vscode.WebviewViewProvider { localResourceRoots: inspectorResourceRootDirs(this.ctx.extensionUri.fsPath).map((d) => vscode.Uri.file(d)), }; view.webview.html = this.renderHtml(view.webview); - view.onDidDispose(() => { this.view = undefined; this.clearPulseTimer(); }); + view.onDidDispose(() => { this.view = undefined; this.clearAllTimers(); }); + + // S36 replay: rebuild EVERY pane from its buffer (not just the active one), + // so switching to a background run after reopen shows its state too. Per + // pane the order mirrors the live stream: runlabel → warming → pulsemeta → + // pulse → iteration → completed (terminal state stays the last word). + for (const p of this.panes.values()) this.replayPane(view, p); + // Then pick the visible pane. activate is idempotent and last, so it wins + // regardless of pane-replay order above. + if (this.activeRunId) view.webview.postMessage({ type: "activate", runId: this.activeRunId }); + } - // Topbar run label — replay first so it's set regardless of run state. - if (this.bufferedRunLabel) { - view.webview.postMessage({ type: "runlabel", text: this.bufferedRunLabel }); - this.bufferedRunLabel = undefined; - } - // A run is warming up (no data yet) — show that until the first record. - if (this.bufferedWarming) { - this.bufferedWarming = false; - view.webview.postMessage({ type: "warming" }); - } - // Replay buffered pulse events (#66): meta first, then the newest record — - // and BEFORE the buffered completion below, so terminal state stays the - // last word the webview hears. - if (this.bufferedPulseMeta) { - view.webview.postMessage({ type: "pulsemeta", ...this.bufferedPulseMeta }); - this.bufferedPulseMeta = undefined; - } - if (this.bufferedPulseRecord) { - view.webview.postMessage({ type: "pulse", ...this.bufferedPulseRecord }); - this.bufferedPulseRecord = undefined; - } - // Then replay a terminal state if the run already finished — after the - // pulse data so "converged"/"failed" is the last word the webview hears. - if (this.bufferedCompletion) { - const c = this.bufferedCompletion; - this.bufferedCompletion = undefined; - view.webview.postMessage({ type: "completed", status: c.status, fidelity: c.fidelity }); - } + private replayPane(view: vscode.WebviewView, p: PaneBuffer): void { + const rid = p.runId; + if (p.runLabel !== undefined) view.webview.postMessage({ type: "runlabel", runId: rid, text: p.runLabel }); + if (p.warming) view.webview.postMessage({ type: "warming", runId: rid }); + if (p.pulseMeta) view.webview.postMessage({ type: "pulsemeta", runId: rid, ...p.pulseMeta }); + if (p.pulseRecord) view.webview.postMessage({ type: "pulse", runId: rid, ...p.pulseRecord }); + if (p.iterRecord) view.webview.postMessage({ type: "iteration", runId: rid, ...p.iterRecord, t_post: Date.now() }); + if (p.completion) view.webview.postMessage({ type: "completed", runId: rid, status: p.completion.status, fidelity: p.completion.fidelity }); } - // -------- public surface used by RunsManager -------- + // -------- public surface used by RunsManager (all runId-keyed) -------- - postIterationRecord(rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { - // Ok to drop iter records pre-materialization: the stats row refreshes on - // the next record (seconds away), and switchToRun's log replay re-ingests - // history whenever the run is re-selected. (Pulse events, by contrast, are - // buffered above — the log line is their only delivery.) - if (!this.view) return; - this.view.webview.postMessage({ - type: "iteration", - iter: rec.iter, - f_val: rec.f_val, - kkt_error: rec.inf_du, - eq_viol: rec.inf_pr, - ineq_viol: 0, - rho: 1.0, - t_post: Date.now(), - }); + postIterationRecord(runId: string, rec: { iter: number; f_val: number; inf_pr: number; inf_du: number }): void { + const p = this.paneFor(runId); + p.warming = false; + p.iterRecord = { iter: rec.iter, f_val: rec.f_val, kkt_error: rec.inf_du, eq_viol: rec.inf_pr, ineq_viol: 0, rho: 1.0 }; + if (!this.view) return; // buffered above; reopen replays it + this.view.webview.postMessage({ type: "iteration", runId, ...p.iterRecord, t_post: Date.now() }); } - /** Terminal-state signal so the badge stops saying "running" — on live - * finish AND when switching to an already-finished run. */ - postCompletion(status: string, fidelity?: number): void { - if (!this.view) { - // Panel not open yet — stash; resolveWebviewView replays it after pulse data. - this.bufferedCompletion = { status, fidelity }; - return; - } - this.view.webview.postMessage({ type: "completed", status, fidelity }); + /** Terminal-state signal (badge stops saying "running") — on live finish AND + * when a run is selected already-finished. Buffered per run for reopen. */ + postCompletion(runId: string, status: string, fidelity?: number): void { + const p = this.paneFor(runId); + p.warming = false; + p.completion = { status, fidelity }; + if (!this.view) return; + this.view.webview.postMessage({ type: "completed", runId, status, fidelity }); } - /** A run started but has no data yet (Julia warming up) — show that instead - * of an idle panel, so a ~minute of cold start doesn't read as frozen. */ - setWarmingUp(): void { + /** A run started but has no data yet (Julia warming up) — show that instead of + * an idle pane, so a ~minute of cold start doesn't read as frozen. */ + setWarmingUp(runId: string): void { + const p = this.paneFor(runId); + // Warming means "no data yet". Never clobber a pane that already has terminal + // state or streamed data (a run selected after its pipeline fanned events in, + // or the stale-warming-after-completion race). + if (p.completion || p.iterRecord || p.pulseRecord) return; + p.warming = true; if (!this.view) { - this.bufferedWarming = true; vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); return; } - this.view.webview.postMessage({ type: "warming" }); + this.view.webview.postMessage({ type: "warming", runId }); } - /** Pulse-stream event (#66): forwarded to the view as pulsemeta/pulse - * messages. Buffering for late materialization lands with AC7. */ - postPulse(e: PulseEvent): void { - if (!this.view) { - // Buffer (newest record wins) — replayed in resolveWebviewView. - if (e.type === "meta") this.bufferedPulseMeta = e.meta; - else this.bufferedPulseRecord = e.record; + /** Pulse-stream event (#66) → pulsemeta/pulse messages, runId-tagged. Meta is + * posted once; records are throttled to 5 Hz PER RUN (leading edge posts, the + * window coalesces newest-wins, trailing edge flushes). The newest record is + * always kept in the buffer for reopen even while the window is open. */ + postPulse(runId: string, e: PulseEvent): void { + const p = this.paneFor(runId); + // NB: unlike postIterationRecord/postCompletion this deliberately does NOT + // clear p.warming — pulse is plot-only (#67), and the warming badge is + // iter-driven. setWarmingUp already treats a present pulseRecord as "has + // data", so a pulse still blocks a re-warm; the flag just isn't flipped here. + if (e.type === "meta") { + p.pulseMeta = e.meta; + if (this.view) this.view.webview.postMessage({ type: "pulsemeta", runId, ...e.meta }); return; } - if (e.type === "meta") { this.view.webview.postMessage({ type: "pulsemeta", ...e.meta }); return; } - if (this.pulseTimer) { this.pendingPulse = e.record; return; } // window open — coalesce, newest wins - this.view.webview.postMessage({ type: "pulse", ...e.record }); - this.pulseTimer = setTimeout(() => { - this.pulseTimer = undefined; - if (this.pendingPulse && this.view) { - const rec = this.pendingPulse; - this.pendingPulse = undefined; - this.postPulse({ type: "record", record: rec }); + // record + p.pulseRecord = e.record; // newest wins for reopen replay + if (!this.view) return; + if (p.pulseTimer) { p.pendingPulse = e.record; return; } // window open — coalesce + this.view.webview.postMessage({ type: "pulse", runId, ...e.record }); + p.pulseTimer = setTimeout(() => { + p.pulseTimer = undefined; + if (p.pendingPulse && this.view) { + const rec = p.pendingPulse; + p.pendingPulse = undefined; + this.postPulse(runId, { type: "record", record: rec }); } }, REFRESH_INTERVAL_MS); } - /** Set the topbar run label (runId). Buffered until the webview materializes. */ - setRunLabel(label: string): void { - if (!this.view) { this.bufferedRunLabel = label; return; } - this.view.webview.postMessage({ type: "runlabel", text: label }); + /** Set a run's topbar label (runId). Buffered per run until materialize. */ + setRunLabel(runId: string, label: string): void { + this.paneFor(runId).runLabel = label; + if (this.view) this.view.webview.postMessage({ type: "runlabel", runId, text: label }); + } + + /** Make `runId` the visible pane (1.3 selection seam). Buffered until the + * webview materializes; resolveWebviewView replays it last. */ + activate(runId: string): void { + this.paneFor(runId); // ensure a pane exists even before any data + this.activeRunId = runId; + if (this.view) this.view.webview.postMessage({ type: "activate", runId }); } reveal(): void { @@ -162,12 +177,11 @@ class InspectorView implements vscode.WebviewViewProvider { // -------- internal -------- - private clearPulseTimer(): void { - if (this.pulseTimer) { - clearTimeout(this.pulseTimer); - this.pulseTimer = undefined; + private clearAllTimers(): void { + for (const p of this.panes.values()) { + if (p.pulseTimer) { clearTimeout(p.pulseTimer); p.pulseTimer = undefined; } + p.pendingPulse = undefined; } - this.pendingPulse = undefined; } private renderHtml(webview: vscode.Webview): string { diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index 75eb6df6..0a5b2e42 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -173,30 +173,52 @@ export class RunsManager implements vscode.Disposable { }); } - /** EXPLICIT selection (demo replay command; 1.3's user clicks): routes the - * single-run Inspector/StatusBar at a run AND PINS the selection — after - * this, auto-follow never steals the view (see `pinned`). Replays the run - * dir for display, then live events flow. */ + /** EXPLICIT selection (demo replay command; 1.3's user clicks): makes + * `runId` the inspector's visible pane (`activate`) AND PINS the selection — + * after this, auto-follow never steals the view (see `pinned`). + * + * Display: a run WITH a live pipeline was already fanned in runId-tagged + * (registration replay + live tail), so its pane is current — no re-ingest + * (review #70 #4). A run with NO pipeline (finished at discovery, or + * completed + torn down) was never fanned — replay it from disk into its + * pane. If FINISHED landed inside the ≤700ms poll window, the same-tick + * checkFinished below turns it into a real completion (badge + status bar), + * never a stale "running"/"warming". */ selectRun(runId: string): void { const rec = this.registry.get(runId); if (!rec) return; this.pinned = true; if (this.selected === runId) return; this.selected = runId; - getInspector()?.reveal(); - getInspector()?.setRunLabel(runId); - // Display replay (late-join safe): full history from disk → inspector. - // Promote inside the replay stays guarded by promotedRuns, so re-selecting - // a finished run never re-pops the prompt. - try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } - catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } - // Fresh/live run → Julia warming up (the view swaps the hint when the first - // pulse record arrives). Same post-replay order as β's switchToRun — and, - // like β, re-check DISK (not the registry phase): FINISHED may have landed - // inside the ≤700ms poll window, and warming-after-completion would invert - // the terminal badge until the next tick. - if (rec.phase !== "finished" && !fs.existsSync(path.join(rec.runDir, "FINISHED"))) { - getInspector()?.setWarmingUp(); + const ins = getInspector(); + ins?.reveal(); + ins?.setRunLabel(runId, runId); + ins?.activate(runId); // 1.3: switch the visible pane + const p = this.pipelines.get(runId); + if (p) { + // FINISHED may have landed inside the poll window — complete it NOW, + // through the one completion mechanism, so the badge can't sit stale. + this.checkFinished(p); + // Point the single status bar at the selected run from registry state + // (routeIter/completeRun keep it current from here). + const r = this.registry.get(runId)!; + this.opts.statusBar?.setRun({ + runId, outputDir: r.runDir, startedAt: 0, + status: r.phase === "finished" ? (r.status ?? "completed") : "running", + latestIter: r.latestIter, fidelity: r.fidelity, + }); + } else { + // Never fanned (no pipeline) — display replay from disk (late-join safe). + // Promote inside the replay stays guarded by promotedRuns, so + // re-selecting a finished run never re-pops the prompt. + try { ingestRunDir(rec.runDir, this.displaySink(rec), this.opts.promoteThreshold ?? 0.99); } + catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } + } + // Fresh/live run → Julia warming up. Disk-checked (FINISHED may exist while + // the registry still says live); the host's setWarmingUp also no-ops if the + // pane already carries data/terminal state (host guard). + if (this.registry.get(runId)?.phase !== "finished" && !fs.existsSync(path.join(rec.runDir, "FINISHED"))) { + ins?.setWarmingUp(runId); } } @@ -253,20 +275,22 @@ export class RunsManager implements vscode.Disposable { // Auto-follow BEFORE the replay (β latest-follow parity: a newly REGISTERED // live run is by definition the newest start) — unless an explicit selection - // is pinned. Deciding first lets the ONE ingest below both seed pipeline - // state and feed the display through routeIter/routePulse's selection gate - // (review #70: the old shape parsed the whole run.log twice per discovery — - // a state pass, then selectRun's display pass). + // is pinned. The single ingest below fans the run's history into ITS pane + // regardless (1.3 fan-out); following just decides which pane is visible + // (review #70 #4: the old shape parsed the whole run.log twice per + // discovery — a state pass, then selectRun's display pass). const follow = !this.pinned; if (follow && this.selected !== runId) { this.selected = runId; - getInspector()?.reveal(); - getInspector()?.setRunLabel(runId); + const ins = getInspector(); + ins?.reveal(); + ins?.setRunLabel(runId, runId); + ins?.activate(runId); } // Single replay: arms the pipeline's pulse stream (meta), seeds iter - // high-water, routes to the inspector iff selected above, and yields the - // byte offset the live tail starts from. + // high-water, fans history runId-tagged, and yields the byte offset the + // live tail starts from. let logBytes = 0; try { logBytes = ingestRunDir(runDir, this.pipelineSink(p), this.opts.promoteThreshold ?? 0.99); } catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); } @@ -298,45 +322,45 @@ export class RunsManager implements vscode.Disposable { // Fresh/live run with no data yet → Julia warming up (post-replay, β order). // Disk-checked: a torn FINISHED (fall-through above) must not read "warming". if (follow && !fs.existsSync(path.join(runDir, "FINISHED"))) { - getInspector()?.setWarmingUp(); + getInspector()?.setWarmingUp(runId); } } /** Sink for a pipeline's SINGLE registration replay: seeds registry/pulse - * state and — because auto-follow assigns selection BEFORE the replay — - * feeds the display through routeIter/routePulse's selection gate in the - * same pass (review #70: no second display ingest). */ + * state AND fans the history into the inspector runId-tagged through + * routeIter/routePulse (1.3 fan-out — the run's pane buffers it even while + * hidden), so no second display ingest is needed (review #70 #4). */ private pipelineSink(p: RunPipeline): RunSink { return { iter: (rec: IterRecord) => this.routeIter(p, rec), // A FINISHED that landed between the existsSync check and this replay — - // rare race; treat exactly like a live completion. - run: (c: RunCompletion) => this.completeRun(p.runId, c.status, c.fidelity), - pulse: (e: PulseEvent) => { - if (e.type === "meta") p.pulses.arm(e.meta); - this.routePulse(p.runId, e); - }, + // rare race; treat exactly like a live completion (fans out + promotes). + run: (c: RunCompletion) => this.completeRun(c), // whole object — see completeRun (#84 seam) + pulse: (e: PulseEvent) => { if (e.type === "meta") p.pulses.arm(e.meta); this.routePulse(p.runId, e); }, promote: (info: PromoteInfo) => this.promptPromote(info), }; } - /** Display sink for selection replays: inspector + status bar; promote stays - * guarded. For a still-live run, meta also re-arms the pipeline stream. */ + /** Display sink for a selection replay: posts the run's history into ITS pane + * (runId-tagged) + points the status bar at it; promote stays guarded. For a + * still-live run, meta also re-arms the pipeline stream (redundant with + * registration but harmless). */ private displaySink(rec: RunRecord): RunSink { - const p = this.pipelines.get(rec.runId); + const rid = rec.runId; + const p = this.pipelines.get(rid); return { iter: (r: IterRecord) => { - this.registry.noteIter(rec.runId, r.iter); - getInspector()?.postIterationRecord(r); - this.opts.statusBar?.setRun({ runId: rec.runId, outputDir: rec.runDir, startedAt: 0, status: "running", latestIter: r.iter }); + this.registry.noteIter(rid, r.iter); + getInspector()?.postIterationRecord(rid, r); + this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: "running", latestIter: r.iter }); }, run: (c: RunCompletion) => { - getInspector()?.postCompletion(c.status, c.fidelity); - this.opts.statusBar?.setRun({ runId: c.runId, outputDir: c.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rec.runId)?.latestIter, fidelity: c.fidelity }); + getInspector()?.postCompletion(rid, c.status, c.fidelity); + this.opts.statusBar?.setRun({ runId: rid, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: this.registry.get(rid)?.latestIter, fidelity: c.fidelity }); }, pulse: (e: PulseEvent) => { if (e.type === "meta") p?.pulses.arm(e.meta); - getInspector()?.postPulse(e); + getInspector()?.postPulse(rid, e); }, promote: (info: PromoteInfo) => this.promptPromote(info), }; @@ -345,15 +369,20 @@ export class RunsManager implements vscode.Disposable { private routeIter(p: RunPipeline, rec: IterRecord): void { p.dedup.noteIter(rec.iter); this.registry.noteIter(p.runId, rec.iter); - if (this.selected !== p.runId) return; - getInspector()?.postIterationRecord(rec); - // Live status-bar update — "running · iter N" as it solves (#5 AC3). - this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: "running", latestIter: rec.iter }); + // 1.3 fan-out: every run's iters go to the inspector runId-tagged (the + // webview updates that run's pane; only the active pane is visible — no + // cross-talk). The single status bar tracks the SELECTED run only. + getInspector()?.postIterationRecord(p.runId, rec); + if (this.selected === p.runId) { + // Live status-bar update — "running · iter N" as it solves (#5 AC3). + this.opts.statusBar?.setRun({ runId: p.runId, outputDir: p.runDir, startedAt: 0, status: "running", latestIter: rec.iter }); + } } private routePulse(runId: string, e: PulseEvent): void { - if (this.selected !== runId) return; - getInspector()?.postPulse(e); + // Fan out to every run's pane (runId-tagged); the webview shows only the + // active pane. A background run's pulse never touches the visible plot. + getInspector()?.postPulse(runId, e); } private checkFinished(p: RunPipeline): void { @@ -362,26 +391,35 @@ export class RunsManager implements vscode.Disposable { const t = this.readTerminal(p.runDir); if (!t) return; // torn/invalid FINISHED — next tick retries p.finishedSeen = true; - this.completeRun(p.runId, t.status, t.fidelity); + this.completeRun({ runId: p.runId, runDir: p.runDir, ...t }); } /** Terminal handling for ANY run, selected or not: registry, teardown, - * channel, inspector/status-bar (selected only), promote (any run, once). */ - private completeRun(runId: string, status: RunStatus, fidelity?: number): void { - const rec = this.registry.get(runId); + * channel, inspector/status-bar (selected only), promote (any run, once). + * + * Takes the WHOLE RunCompletion (never exploded into positional fields) — + * this is the #84 seam: every completion path (ingestRunDir replay, live + * checkFinished) funnels the object built by ONE shared read, so an + * additive contract field (#81's `formulation` next, then #64 hashing / + * #41 usage) reaches every consumer by construction instead of being + * re-plumbed per path. Consumers cherry-pick at the leaf, not mid-pipe. */ + private completeRun(c: RunCompletion): void { + const rec = this.registry.get(c.runId); if (!rec || rec.phase === "finished") return; // idempotent (watch + poll can both fire) - this.registry.markFinished(runId, status, fidelity); - const p = this.pipelines.get(runId); + this.registry.markFinished(c.runId, c.status, c.fidelity); + const p = this.pipelines.get(c.runId); p?.dispose(); - this.pipelines.delete(runId); - this.opts.channel.appendLine(`[runs] ${runId} ${status}${fidelity !== undefined ? ` F=${fidelity.toFixed(6)}` : ""}`); - if (status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); - if (this.selected === runId) { - getInspector()?.postCompletion(status, fidelity); - this.opts.statusBar?.setRun({ runId, outputDir: rec.runDir, startedAt: 0, status, latestIter: rec.latestIter, fidelity }); + this.pipelines.delete(c.runId); + this.opts.channel.appendLine(`[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`); + if (c.status !== "completed") this.opts.channel.appendLine(`[runs] see ${path.join(rec.runDir, "run.log")}`); + // Terminal state to the inspector for EVERY run (its pane's badge stops + // saying "running" even in the background); status bar for the selected run. + getInspector()?.postCompletion(c.runId, c.status, c.fidelity); + if (this.selected === c.runId) { + this.opts.statusBar?.setRun({ runId: c.runId, outputDir: rec.runDir, startedAt: 0, status: c.status, latestIter: rec.latestIter, fidelity: c.fidelity }); } - if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { - this.promptPromote({ runId, runDir: rec.runDir, fidelity }); + if (c.status === "completed" && c.fidelity !== undefined && c.fidelity >= (this.opts.promoteThreshold ?? 0.99)) { + this.promptPromote({ runId: c.runId, runDir: rec.runDir, fidelity: c.fidelity }); } } diff --git a/packages/extension/test/inspector_view_contract.test.ts b/packages/extension/test/inspector_view_contract.test.ts index c996a796..0fba455d 100644 --- a/packages/extension/test/inspector_view_contract.test.ts +++ b/packages/extension/test/inspector_view_contract.test.ts @@ -10,9 +10,9 @@ import { registerRunInspector } from "../src/run_inspector"; // 1. the shell links brand.css + layout.css and the dist view bundle; // 2. the CSP authorizes every grant the view depends on; // 3. the design-owned stylesheets exist and brand.css carries the brand token. -// The message protocol (runlabel/iteration/warming/completed/refresh/ping) is -// exercised end-to-end by the watcher tests; ids/classes are now internal to -// the view and free to change. +// The message protocol (runId-keyed: runlabel/iteration/warming/completed/ +// pulsemeta/pulse/activate/ping) is exercised end-to-end by the watcher tests; +// ids/classes are now internal to the view and free to change. const PKG_ROOT = join(__dirname, ".."); @@ -64,40 +64,53 @@ describe("Run Inspector shell contract (plumbing ⇄ TS-composed view)", () => { // #66 AC7 — pulse events posted before the webview materializes must not be // lost: the log line is the canonical signal (nothing re-delivers it, unlike // PNGs which the poll re-offers). The host buffers meta + the NEWEST record -// and replays them on resolve, BEFORE any buffered terminal state. -describe("Run Inspector host buffering (#66 pulse events)", () => { +// per run and replays them on resolve, BEFORE any buffered terminal state. +// +// 1.3 (#58): every message is runId-keyed. Each run gets its own buffer + +// throttle; resolve replays EVERY pane (S36) and activate names the visible one. +describe("Run Inspector host buffering (#66 pulse events, runId-keyed)", () => { const META = { drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] as [number, number][] }; + const rec = (iter: number): { iter: number; dt: number; values: number[][] } => ({ iter, dt: 0.2, values: [[iter / 10, iter / 5]] }); function harness() { const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] }; const inspector = registerRunInspector(ctx as never); - const posted: Array> = []; - const view = { - webview: { - options: {}, - cspSource: "vscode-webview://unit", - asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), - postMessage: (m: Record) => { posted.push(m); }, - set html(_v: string) { /* ignore */ }, - get html() { return ""; }, - }, - onDidDispose: () => ({ dispose() {} }), + /** Build an independent webview target (its own capture buffer + dispose + * hook) — lets a single inspector be resolved twice for the reopen path. */ + const makeView = () => { + const posted: Array> = []; + let disposeCb: () => void = () => undefined; + const view = { + webview: { + options: {}, + cspSource: "vscode-webview://unit", + asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), + postMessage: (m: Record) => { posted.push(m); }, + set html(_v: string) { /* ignore */ }, + get html() { return ""; }, + }, + onDidDispose: (cb: () => void) => { disposeCb = cb; return { dispose() {} }; }, + }; + return { view, posted, dispose: () => disposeCb() }; }; - return { inspector, view, posted }; + const first = makeView(); + return { inspector, makeView, view: first.view, posted: first.posted, dispose: first.dispose }; } - it("buffers meta + NEWEST record pre-materialization, replays them before a buffered completion", () => { + it("buffers meta + NEWEST record pre-materialization, replays them (runId-tagged) before a buffered completion", () => { const { inspector, view, posted } = harness(); - inspector.postPulse({ type: "meta", meta: META }); - inspector.postPulse({ type: "record", record: { iter: 1, dt: 0.2, values: [[0.1, 0.2]] } }); - inspector.postPulse({ type: "record", record: { iter: 2, dt: 0.2, values: [[0.3, 0.4]] } }); - inspector.postCompletion("completed", 0.9999); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(1) }); + inspector.postPulse("r1", { type: "record", record: rec(2) }); + inspector.postCompletion("r1", "completed", 0.9999); inspector.resolveWebviewView(view as never); - const types = posted.map((m) => m.type); + const r1 = posted.filter((m) => m.runId === "r1"); + expect(r1.every((m) => m.runId === "r1")).toBe(true); // every message carries the runId + const types = r1.map((m) => m.type); expect(types).toContain("pulsemeta"); expect(types.filter((t) => t === "pulse")).toHaveLength(1); // newest-wins: iter 1 dropped - expect(posted.find((m) => m.type === "pulse")).toMatchObject({ iter: 2 }); + expect(r1.find((m) => m.type === "pulse")).toMatchObject({ iter: 2 }); expect(types.indexOf("pulsemeta")).toBeLessThan(types.indexOf("pulse")); expect(types.indexOf("pulse")).toBeLessThan(types.indexOf("completed")); // terminal state stays the last word }); @@ -106,25 +119,96 @@ describe("Run Inspector host buffering (#66 pulse events)", () => { vi.useFakeTimers(); const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); - inspector.postPulse({ type: "meta", meta: META }); + inspector.postPulse("r1", { type: "meta", meta: META }); posted.length = 0; - inspector.postPulse({ type: "record", record: { iter: 1, dt: 0.2, values: [[0.1, 0.2]] } }); + inspector.postPulse("r1", { type: "record", record: rec(1) }); expect(posted.map((m) => m.type)).toEqual(["pulse"]); // leading edge posts immediately - inspector.postPulse({ type: "record", record: { iter: 2, dt: 0.2, values: [[0.3, 0.4]] } }); - inspector.postPulse({ type: "record", record: { iter: 3, dt: 0.2, values: [[0.5, 0.6]] } }); + inspector.postPulse("r1", { type: "record", record: rec(2) }); + inspector.postPulse("r1", { type: "record", record: rec(3) }); expect(posted).toHaveLength(1); // inside the window: coalesced vi.advanceTimersByTime(200); expect(posted).toHaveLength(2); // trailing edge: exactly one flush - expect(posted[1]).toMatchObject({ type: "pulse", iter: 3 }); // …carrying the newest + expect(posted[1]).toMatchObject({ type: "pulse", iter: 3, runId: "r1" }); // …carrying the newest }); it("posts straight through once the webview is live", () => { const { inspector, view, posted } = harness(); inspector.resolveWebviewView(view as never); posted.length = 0; - inspector.postPulse({ type: "meta", meta: META }); - inspector.postPulse({ type: "record", record: { iter: 3, dt: 0.2, values: [[0.5, 0.6]] } }); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(3) }); expect(posted.map((m) => m.type)).toEqual(["pulsemeta", "pulse"]); + expect(posted.every((m) => m.runId === "r1")).toBe(true); + }); + + it("keeps a separate pane buffer per run — a background run's record never lands under another runId", () => { + const { inspector, view, posted } = harness(); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(1) }); + inspector.postPulse("r2", { type: "meta", meta: META }); + inspector.postPulse("r2", { type: "record", record: rec(7) }); + inspector.resolveWebviewView(view as never); + + // r1's pulse is iter 1 under r1; r2's is iter 7 under r2. No cross-key leak. + expect(posted.filter((m) => m.type === "pulse" && m.runId === "r1")).toMatchObject([{ iter: 1 }]); + expect(posted.filter((m) => m.type === "pulse" && m.runId === "r2")).toMatchObject([{ iter: 7 }]); + }); + + it("per-run throttle windows are independent — r2's leading edge is not coalesced by r1's open window", () => { + vi.useFakeTimers(); + const { inspector, view, posted } = harness(); + inspector.resolveWebviewView(view as never); + posted.length = 0; + inspector.postPulse("r1", { type: "record", record: rec(1) }); // opens r1's window (posts) + inspector.postPulse("r2", { type: "record", record: rec(1) }); // r2 has its OWN window (posts) + expect(posted.filter((m) => m.type === "pulse")).toHaveLength(2); + expect(posted.map((m) => m.runId).sort()).toEqual(["r1", "r2"]); + }); + + it("activate is replayed LAST on materialize and names the visible pane", () => { + const { inspector, view, posted } = harness(); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r2", { type: "meta", meta: META }); + inspector.activate("r2"); + inspector.resolveWebviewView(view as never); + + const activate = posted.filter((m) => m.type === "activate"); + expect(activate).toHaveLength(1); + expect(activate[0]).toMatchObject({ runId: "r2" }); + expect(posted.indexOf(activate[0])).toBe(posted.length - 1); // last word = the visible pane + }); + + it("rebuilds EVERY pane on reopen (S36) — dispose then re-resolve replays all runs", () => { + const { inspector, makeView } = harness(); + const a = makeView(); + inspector.resolveWebviewView(a.view as never); + inspector.postPulse("r1", { type: "meta", meta: META }); + inspector.postPulse("r1", { type: "record", record: rec(4) }); + inspector.postCompletion("r1", "completed", 0.99); + inspector.postPulse("r2", { type: "meta", meta: META }); + inspector.postPulse("r2", { type: "record", record: rec(9) }); + inspector.activate("r2"); + a.dispose(); // user closes the panel + + const b = makeView(); + inspector.resolveWebviewView(b.view as never); // reopen — fresh DOM + // Both panes rebuilt from buffers, each with its newest record, r1 terminal. + expect(b.posted.filter((m) => m.type === "pulse" && m.runId === "r1")).toMatchObject([{ iter: 4 }]); + expect(b.posted.filter((m) => m.type === "completed" && m.runId === "r1")).toHaveLength(1); + expect(b.posted.filter((m) => m.type === "pulse" && m.runId === "r2")).toMatchObject([{ iter: 9 }]); + expect(b.posted[b.posted.length - 1]).toMatchObject({ type: "activate", runId: "r2" }); + }); + + it("setWarmingUp no-ops once the pane has data or terminal state (no clobber of a fanned-in run)", () => { + const { inspector, view, posted } = harness(); + inspector.resolveWebviewView(view as never); + inspector.postPulse("r1", { type: "record", record: rec(1) }); // r1 has data + inspector.postCompletion("r2", "completed", 0.99); // r2 is terminal + posted.length = 0; + inspector.setWarmingUp("r1"); + inspector.setWarmingUp("r2"); + inspector.setWarmingUp("r3"); // fresh run → warming IS shown + expect(posted.filter((m) => m.type === "warming")).toMatchObject([{ runId: "r3" }]); }); }); diff --git a/packages/extension/test/inspector_webview_view.test.ts b/packages/extension/test/inspector_webview_view.test.ts new file mode 100644 index 00000000..757fa8a4 --- /dev/null +++ b/packages/extension/test/inspector_webview_view.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { createInspectorView } from "../media/ui/views/inspector"; + +// Coverage for the 1.3 webview ROUTER (freeze-2, runId-keyed protocol): per-run +// pane isolation, `activate` pane-toggling, the empty-state hint, and #67's +// plot-only pulse (a pulse must never touch the badge). Closes the gap the +// adversarial review flagged — half the slice's guarantee lives here and was +// asserted only by comments. +// +// The pane MARKUP is the design lane (UX4 #49): these assertions target the +// router CONTRACT + observable badge/visibility, not class names beyond +// .pane/.active/.pill. Runs under happy-dom because the atoms inject styles via +// constructable stylesheets (`new CSSStyleSheet()`), which jsdom can't model. + +const iter = (runId: string, n: number) => ({ type: "iteration", runId, iter: n, f_val: 1e-2, eq_viol: 1e-8, kkt_error: 1e-6 }); +const panes = (v: { el: HTMLElement }) => [...v.el.querySelectorAll(".pane")]; +const activePane = (v: { el: HTMLElement }) => v.el.querySelector(".pane.active"); +const pillText = (pane: Element | null | undefined) => pane?.querySelector(".pill")?.textContent; + +describe("Inspector webview router (1.3 per-run panes)", () => { + it("activate shows exactly one pane and hides the empty-state hint", () => { + const v = createInspectorView(() => {}); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); + const emptyHint = v.el.firstElementChild as HTMLElement; // the idle hint, appended first + expect(emptyHint.style.display).not.toBe("none"); + + v.onMessage(iter("r1", 3)); + v.onMessage(iter("r2", 4)); + expect(panes(v)).toHaveLength(2); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(0); // panes exist but none shown yet + + v.onMessage({ type: "activate", runId: "r2" }); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); + expect(panes(v)[1].classList.contains("active")).toBe(true); // r2 = 2nd-created pane + expect(panes(v)[0].classList.contains("active")).toBe(false); + expect(emptyHint.style.display).toBe("none"); + }); + + it("activate before any data still creates and shows the pane", () => { + const v = createInspectorView(() => {}); + v.onMessage({ type: "activate", runId: "rX" }); + expect(panes(v)).toHaveLength(1); + expect(panes(v)[0].classList.contains("active")).toBe(true); + expect((v.el.firstElementChild as HTMLElement).style.display).toBe("none"); + }); + + it("a background run's iteration never mutates the active pane (no cross-talk)", () => { + const v = createInspectorView(() => {}); + v.onMessage({ type: "activate", runId: "r1" }); + v.onMessage(iter("r1", 3)); + const r1 = activePane(v)!; + expect(pillText(r1)).toBe("running"); + + // r2 is a background run — its iteration must land in ITS pane, not r1's. + v.onMessage(iter("r2", 99)); + expect(pillText(activePane(v))).toBe("running"); // r1 badge unchanged + const r2 = panes(v).find((p) => !p.classList.contains("active"))!; + expect(pillText(r2)).toBe("running"); // r2 has its OWN running badge + expect(r1.textContent).toContain("3"); // r1 still reads iter 3… + expect(r1.textContent).not.toContain("99"); // …not r2's 99 (no value bleed) + }); + + it("pulse is plot-only — it never touches the active pane's badge (#67)", () => { + const v = createInspectorView(() => {}); + v.onMessage({ type: "activate", runId: "r1" }); + const r1 = activePane(v)!; + expect(pillText(r1)).toBe("idle"); + v.onMessage({ type: "pulsemeta", runId: "r1", drives: 1, knots: 2, labels: ["a_1"], bounds: [[-0.2, 0.2]] }); + v.onMessage({ type: "pulse", runId: "r1", iter: 1, dt: 0.2, values: [[0.1, 0.2]] }); + expect(pillText(r1)).toBe("idle"); // pulse did NOT flip the badge + }); + + it("switching activate moves the visible pane, each pane keeps its own state", () => { + const v = createInspectorView(() => {}); + v.onMessage(iter("r1", 1)); + v.onMessage({ type: "completed", runId: "r1", status: "completed", fidelity: 0.999 }); // hidden pane still updates + v.onMessage(iter("r2", 2)); + v.onMessage({ type: "activate", runId: "r1" }); + expect(pillText(activePane(v))).toBe("converged"); // r1 terminal badge shows on activate + v.onMessage({ type: "activate", runId: "r2" }); + expect(v.el.querySelectorAll(".pane.active")).toHaveLength(1); + expect(pillText(activePane(v))).toBe("running"); // now r2 is visible + const r1 = panes(v).find((p) => !p.classList.contains("active"))!; + expect(pillText(r1)).toBe("converged"); // r1 untouched by the switch + }); +}); diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts index 3e1fa499..91dd8eab 100644 --- a/packages/extension/test/runs_manager.test.ts +++ b/packages/extension/test/runs_manager.test.ts @@ -4,12 +4,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import * as vscodeMock from "vscode"; -// Drive the live RunsManager (1.2, #57) over a temp runs root and assert the -// inspector calls. Ports the RunsRootWatcher state-machine coverage (idle-on- -// finished baseline, warming→completion, #66 pulse routing) onto index-driven -// discovery, and adds the multi-run behaviors: concurrent runs all tracked, -// selection routing, background completion/promote, the Scheduler seam, and -// the explicit-selection demo-replay path. +// Drive the live RunsManager (1.2 #57 / 1.3 #58) over a temp runs root and +// assert the inspector calls. Ports the RunsRootWatcher state-machine coverage +// (idle-on-finished baseline, warming→completion, #66 pulse routing) onto +// index-driven discovery, and adds the multi-run behaviors: concurrent runs all +// tracked, selection routing, background completion/promote, the Scheduler seam, +// and the explicit-selection demo-replay path. +// +// 1.3: the inspector protocol is runId-keyed and the manager FANS every run's +// events into it runId-tagged (the webview shows only the active pane). The +// single status bar stays selection-gated — so background-vs-foreground is now +// asserted on the status bar, not on whether the inspector was called. // // The inspector is mocked (getInspector() returns spies); `vscode` is the // aliased stub. tick() is called directly so the poll path is deterministic. @@ -21,6 +26,7 @@ const { inspector } = vi.hoisted(() => ({ postIterationRecord: vi.fn(), postPulse: vi.fn(), setRunLabel: vi.fn(), + activate: vi.fn(), reveal: vi.fn(), }, })); @@ -31,6 +37,9 @@ import { RunsManager, type SchedulerLifecycleEvent, type SchedulerLike } from ". const channel = { appendLine() {}, append() {} } as never; const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; +/** Minimal StatusBarManager spy — only setRun is exercised. */ +function statusBarSpy() { return { setRun: vi.fn(), clear: vi.fn(), dispose: vi.fn() }; } + function writeManifest(dir: string, runId: string): void { writeFileSync(join(dir, "run.toml"), `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + @@ -71,8 +80,9 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { const run = stageRun(root, "r2"); // manifest only, no data yet const m = new RunsManager({ runsRoot: root, channel }); m.start(); - expect(inspector.setWarmingUp).toHaveBeenCalledTimes(1); - expect(inspector.setRunLabel).toHaveBeenCalledWith("r2"); + expect(inspector.setWarmingUp).toHaveBeenCalledWith("r2"); + expect(inspector.setRunLabel).toHaveBeenCalledWith("r2", "r2"); + expect(inspector.activate).toHaveBeenCalledWith("r2"); // 1.3: selection = activate the pane expect(m.selectedRun).toBe("r2"); // result.toml alone must NOT complete the run (FINISHED is authoritative). @@ -82,11 +92,11 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); tick(m); - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.postCompletion).toHaveBeenCalledWith("r2", "completed", 0.9999); m.dispose(); }); - it("live tail forwards meta and each record in order as they land (#66)", () => { + it("live tail forwards meta and each record in order as they land (#66), runId-tagged", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const run = stageRun(root, "p1"); const m = new RunsManager({ runsRoot: root, channel }); @@ -96,13 +106,13 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { writeFileSync(join(run, "run.log"), META_LINE + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n"); tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(2); - expect(inspector.postPulse).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "meta" })); - expect(inspector.postPulse).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); + expect(inspector.postPulse).toHaveBeenNthCalledWith(1, "p1", expect.objectContaining({ type: "meta" })); + expect(inspector.postPulse).toHaveBeenNthCalledWith(2, "p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 1 }) })); appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n"); tick(m); expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith("p1", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 2 }) })); m.dispose(); }); @@ -117,18 +127,19 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { appendFileSync(join(run, "run.log"), "AMICODE_PULSE iter=4 dt=0.2 a=0.3,0.4\n"); tick(m); // record parses against the armed meta expect(inspector.postPulse).toHaveBeenCalledTimes(3); - expect(inspector.postPulse).toHaveBeenLastCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); + expect(inspector.postPulse).toHaveBeenLastCalledWith("p2", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 4 }) })); m.dispose(); }); }); -describe("RunsManager multi-run (#57)", () => { +describe("RunsManager multi-run (#57 / #58 fan-out)", () => { beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); }); - it("two concurrent live runs: newest auto-selected, BOTH tracked to completion", () => { + it("two concurrent live runs: newest auto-selected; both fanned to the inspector, status bar tracks the selected only", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const a = stageRun(root, "rA"); - const m = new RunsManager({ runsRoot: root, channel }); + const statusBar = statusBarSpy(); + const m = new RunsManager({ runsRoot: root, channel, statusBar: statusBar as never }); m.start(); expect(m.selectedRun).toBe("rA"); @@ -136,27 +147,33 @@ describe("RunsManager multi-run (#57)", () => { tick(m); // index tail discovers it expect(m.selectedRun).toBe("rB"); // auto-follow the newest start inspector.postIterationRecord.mockClear(); + statusBar.setRun.mockClear(); - // Background run A keeps streaming — tracked (registry) but NOT displayed. + // Background run A keeps streaming — FANNED to the inspector runId-tagged (the + // webview keeps it in rA's hidden pane) but the single status bar is untouched. appendFileSync(join(a, "run.log"), "AMICODE_ITER iter=7 f=0.1 inf_pr=1e-8 inf_du=1e-6\n"); tick(m); - expect(inspector.postIterationRecord).not.toHaveBeenCalled(); + expect(inspector.postIterationRecord).toHaveBeenCalledWith("rA", expect.objectContaining({ iter: 7 })); + expect(statusBar.setRun).not.toHaveBeenCalled(); // selection-gated: rB is selected expect(m.runs().find(r => r.runId === "rA")?.latestIter).toBe(7); - // A finishes in the background: registry terminal, inspector untouched… + // A finishes in the background: registry terminal, completion fanned to the + // inspector (rA's pane badge), status bar still untouched… writeFileSync(join(a, "result.toml"), 'schema_version = "1"\nfidelity = 0.9995\niterations = 7\n'); writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); tick(m); - expect(inspector.postCompletion).not.toHaveBeenCalled(); // rB is selected + expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9995); + expect(statusBar.setRun).not.toHaveBeenCalled(); // still rB selected expect(m.runs().find(r => r.runId === "rA")).toMatchObject({ phase: "finished", fidelity: 0.9995 }); - // …but the promote prompt STILL fires (fan-out is per-run, not per-selection). + // …and the promote prompt STILL fires (fan-out is per-run, not per-selection). expect(promote).toHaveBeenCalledTimes(1); - // B completes while selected → completion reaches the inspector. + // B completes while selected → completion + status bar both fire. writeFileSync(join(b, "FINISHED"), 'status = "failed"\nexit_code = 3\n'); tick(m); - expect(inspector.postCompletion).toHaveBeenCalledWith("failed", undefined); + expect(inspector.postCompletion).toHaveBeenCalledWith("rB", "failed", undefined); + expect(statusBar.setRun).toHaveBeenCalledWith(expect.objectContaining({ runId: "rB", status: "failed" })); promote.mockRestore(); m.dispose(); }); @@ -176,13 +193,14 @@ describe("RunsManager multi-run (#57)", () => { const promote = vi.spyOn(vscodeMock.window, "showInformationMessage"); inspector.postCompletion.mockClear(); m.selectRun("rA"); // user switches back (1.3 seam) - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.activate).toHaveBeenCalledWith("rA"); + expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); expect(promote).not.toHaveBeenCalled(); // promote-once held promote.mockRestore(); m.dispose(); }); - it("PULSE events are gated on selection too (not just iter) — background run's plot never reaches the inspector", () => { + it("PULSE events are fanned to the inspector runId-tagged even for a background run (webview shows only the active pane)", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const a = stageRun(root, "rA", { log: META_LINE }); // rA armed with meta const m = new RunsManager({ runsRoot: root, channel }); @@ -192,10 +210,11 @@ describe("RunsManager multi-run (#57)", () => { expect(m.selectedRun).toBe("rB"); // rA now background inspector.postPulse.mockClear(); - // A background pulse RECORD on rA must not reach the inspector (rB selected). + // A background pulse RECORD on rA reaches the inspector TAGGED "rA" — the + // webview routes it to rA's hidden pane, never the visible rB plot. appendFileSync(join(a, "run.log"), "AMICODE_PULSE iter=5 dt=0.2 a=0.1,0.2\n"); tick(m); - expect(inspector.postPulse).not.toHaveBeenCalled(); + expect(inspector.postPulse).toHaveBeenCalledWith("rA", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 5 }) })); m.dispose(); }); @@ -214,7 +233,7 @@ describe("RunsManager multi-run (#57)", () => { writeFileSync(join(a, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); m.selectRun("rA"); // user switches back BEFORE the tick // selectRun re-checks disk → completion, NOT warming (no terminal-badge inversion). - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); + expect(inspector.postCompletion).toHaveBeenCalledWith("rA", "completed", 0.9999); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); m.dispose(); }); @@ -245,8 +264,9 @@ describe("RunsManager multi-run (#57)", () => { emit({ kind: "queued", queueId: "q1", position: 0 }); // logged, no throw emit({ kind: "started", queueId: "q1", runId: "rSched", runDir: dir }); expect(m.selectedRun).toBe("rSched"); - expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched"); - expect(inspector.setWarmingUp).toHaveBeenCalled(); + expect(inspector.setRunLabel).toHaveBeenCalledWith("rSched", "rSched"); + expect(inspector.activate).toHaveBeenCalledWith("rSched"); + expect(inspector.setWarmingUp).toHaveBeenCalledWith("rSched"); // The index line landing later is a no-op (registration is idempotent). appendFileSync(join(root, "index"), "rSched\t2026-07-03T00:00:00Z\t/s.jl\n"); @@ -267,9 +287,10 @@ describe("RunsManager multi-run (#57)", () => { m.pokeDiscovery(); // same-tick registration… expect(inspector.postCompletion).not.toHaveBeenCalled(); // …but no auto-display m.selectRun("rDemo"); // the replayDemo command's path - expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo"); - expect(inspector.postPulse).toHaveBeenCalledWith(expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); - expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9998); + expect(inspector.setRunLabel).toHaveBeenCalledWith("rDemo", "rDemo"); + expect(inspector.activate).toHaveBeenCalledWith("rDemo"); + expect(inspector.postPulse).toHaveBeenCalledWith("rDemo", expect.objectContaining({ type: "record", record: expect.objectContaining({ iter: 60 }) })); + expect(inspector.postCompletion).toHaveBeenCalledWith("rDemo", "completed", 0.9998); expect(inspector.setWarmingUp).not.toHaveBeenCalled(); // finished — never "warming" expect(promote).not.toHaveBeenCalled(); // finished-at-discovery: no prompt promote.mockRestore(); @@ -293,7 +314,8 @@ describe("RunsManager review-#70 fixes", () => { stageRun(root, "rB"); // background solve starts tick(m); expect(m.selectedRun).toBe("rA"); // auto-follow deferred to the pin - expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB"); + expect(inspector.setRunLabel).not.toHaveBeenCalledWith("rB", "rB"); + expect(inspector.activate).not.toHaveBeenCalledWith("rB"); // visible pane untouched expect(m.runs().find(r => r.runId === "rB")?.phase).toBe("live"); // …but rB IS tracked m.selectRun("rB"); // explicit switch still works diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c76a57..34e9be59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19) + version: 2.1.9(@types/node@22.19.19)(happy-dom@20.10.6) packages/extension: devDependencies: @@ -50,6 +50,9 @@ importers: esbuild: specifier: ^0.24.0 version: 0.24.2 + happy-dom: + specifier: ^20.10.6 + version: 20.10.6 smol-toml: specifier: ^1.3.0 version: 1.6.1 @@ -58,7 +61,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19) + version: 2.1.9(@types/node@22.19.19)(happy-dom@20.10.6) packages/schema: dependencies: @@ -83,7 +86,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19) + version: 2.1.9(@types/node@22.19.19)(happy-dom@20.10.6) packages: @@ -652,6 +655,12 @@ packages: '@types/vscode@1.120.0': resolution: {integrity: sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typespec/ts-http-runtime@0.3.6': resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==} engines: {node: '>=20.0.0'} @@ -820,6 +829,10 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1087,6 +1100,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1757,6 +1774,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -1769,6 +1790,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -2239,6 +2272,12 @@ snapshots: '@types/vscode@1.120.0': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.19 + '@typespec/ts-http-runtime@0.3.6': dependencies: http-proxy-agent: 7.0.2 @@ -2432,6 +2471,10 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 22.19.19 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -2767,6 +2810,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.10.6: + dependencies: + '@types/node': 22.19.19 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3436,7 +3492,7 @@ snapshots: '@types/node': 22.19.19 fsevents: 2.3.3 - vitest@2.1.9(@types/node@22.19.19): + vitest@2.1.9(@types/node@22.19.19)(happy-dom@20.10.6): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) @@ -3460,6 +3516,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 + happy-dom: 20.10.6 transitivePeerDependencies: - less - lightningcss @@ -3475,6 +3532,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} why-is-node-running@2.3.0: @@ -3485,6 +3544,8 @@ snapshots: wrappy@1.0.2: optional: true + ws@8.21.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1