diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 1fe004b6..d9bc6124 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -9,6 +9,12 @@ import { resolveSyntaxTheme } from "./syntax_theme_bridge"; import { ExternalMutationTransport } from "./external_mutation_transport"; import type { MutationTrackingContext } from "./external_mutation"; import { shouldReframe } from "./amicode_service_wiring"; +import { + fetchServedBuildId, + BuildChangeWatcher, + SERVED_BUILD_ID_FETCH_TIMEOUT_MS, + type BuildChangeClock, +} from "./dist_build_id"; // ============================================================================ // ChatPanel — a WebviewPanel that iframes opencode's SolidJS chat at @@ -88,6 +94,48 @@ export class ChatPanel { private frameOrigin!: URL; private frameAuthToken?: string; private frameHideProjectDir?: string; + /** #1556 (subsuming #1459): the served dist's build id this panel's iframe + * was stamped with (`?amicode_build=`) — the SW cache-bust + the prompt's + * comparison baseline. undefined = never derived (honest degradation: + * unstamped src, exactly today's behavior). */ + private stampedBuildId?: string; + /** #1556: once the framed app signals ready, the guarded pre-ready re-stamp + * closes — a later-arriving derivation must never re-navigate a live app. */ + private appReadySeen = false; + private disposed = false; + /** Which render path constructed this panel — the pre-ready re-stamp must + * re-render through the same shell (splash overlay or bare iframe). */ + private renderMode: "normal" | "transition" = "normal"; + /** #1556: last-known served build id per origin (window-wide, warmed by every + * fetch this window's panels/watchers make) — so every panel AFTER the first + * stamps its FIRST render, and the re-frame path stamps from the new origin. + * Test-scoped reset via clearServedBuildIdCache(). */ + private static readonly servedBuildIds = new Map(); + /** #1556: the version ids this WINDOW has already prompted for (shared by + * every live panel's watcher) — a ship prompts exactly once even with + * side-by-side tabs. Test-scoped reset via clearBuildIdLaneForTest(). */ + private static readonly promptedBuildVersions = new Set(); + /** #1556 test seams (production runs undefined → the host's global fetch): + * the injectable fetch for the served-build-id derivation, and the watcher's + * injectable clock so a 3-minute poll is one synchronous tick under test. */ + static buildIdFetchImpl?: typeof fetch; + static buildChangeClock?: BuildChangeClock; + /** #1556 observability: the reload lane's log sink, wired at activation to + * the opencode output channel ("[reload-lane] …"). The lane's fetches + * bypass the webview SW and leave no request log the developer can see, so + * the panel + watcher narrate their own lifecycle here. Test-scoped reset + * via clearBuildIdLaneForTest(). */ + private static laneLog: ((line: string) => void) | undefined; + /** Wire the lane's log sink (extension.ts activation); undefined = silent. */ + static setLaneLog(fn: ((line: string) => void) | undefined): void { + ChatPanel.laneLog = fn; + } + /** #1556: one log line, never thrown. */ + private static logLane(line: string): void { + try { + ChatPanel.laneLog?.(line); + } catch {} + } /** Subscribe to live-panel count changes. Used by the workspace tree to mute the chat button. */ static onLiveChange(cb: (count: number) => void): void { @@ -111,6 +159,17 @@ export class ChatPanel { hideProjectDir?: string, withSplash?: boolean, ) { + // #1556 (subsuming #1459): seed the stamp from the window-wide served-id + // cache (a prior panel/watcher fetch already observed this origin), then + // kick this panel's own derivation of the served build id from its origin + // — the host fetch bypasses the webview SW (#1556's premise), so it always + // sees the true current dist. The fetch is STARTED before the first + // renderHtml; its result re-stamps the frame pre-app-ready (see + // resolveStampedBuildId — the sync factory signatures pin the first render + // synchronous, so a cache-miss panel converges via one guarded re-render). + this.renderMode = withSplash ? "transition" : "normal"; + this.stampedBuildId = ChatPanel.servedBuildIds.get(opencodeUrl.origin); + void this.resolveStampedBuildId(opencodeUrl.origin); this.panel.webview.html = withSplash ? this.renderTransitionHtml(opencodeUrl, authToken, hideProjectDir) : this.renderHtml(opencodeUrl, authToken, hideProjectDir); @@ -165,11 +224,37 @@ export class ChatPanel { authorization: serverAuth, }); } + // #1556 (the prompt half): while this panel is alive, poll the served + // build id on the slow interval and prompt once per version when it + // drifts from the stamp. Origin + stamp are read LIVE so a re-frame + // (service-shelf switch) needs no watcher surgery; the interval clears + // with the panel's disposables. + this.disposables.push( + new BuildChangeWatcher({ + origin: () => this.frameOrigin.origin, + stampedBuildId: () => this.stampedBuildId, + fetchServed: (origin) => this.fetchServedBuildIdForOrigin(origin), + prompt: (message, ...items) => + vscode.window.showInformationMessage(message, ...items) as PromiseLike, + reload: () => void vscode.commands.executeCommand("workbench.action.reloadWindow"), + // one shared set across THIS window's panels — exactly one prompt per + // ship, however many side-by-side tabs are live. + prompted: ChatPanel.promptedBuildVersions, + ...(ChatPanel.buildChangeClock ? { clock: ChatPanel.buildChangeClock } : {}), + log: (line) => ChatPanel.logLane(`panel@${this.frameOrigin.origin} ${line}`), + }), + ); + ChatPanel.logLane( + `panel constructed: origin=${opencodeUrl.origin} renderMode=${this.renderMode} stamped=${this.stampedBuildId ?? "undef"}`, + ); this.panel.webview.onDidReceiveMessage( (msg) => { // app-ready: the SolidJS app has mounted and is rendering. Fire // any registered callbacks (one-shot) and clear the list. if (msg && msg.source === "amicode" && msg.kind === "app-ready") { + // #1556: the app is live — close the guarded pre-ready re-stamp + // window (a later-arriving derivation must never re-navigate it). + this.appReadySeen = true; // The syntax theme cannot be sent at panel construction: the iframe // has not registered its message listener yet. app-ready is the // first reliable point to deliver the initial VS Code token theme. @@ -402,12 +487,69 @@ export class ChatPanel { return this.frameOrigin.toString(); } + /** #1556 test seam: reset the window-wide reload-lane state (served-id + * cache + already-prompted versions) between tests — module state survives + * suites otherwise. Production never clears either. */ + static clearBuildIdLaneForTest(): void { + ChatPanel.servedBuildIds.clear(); + ChatPanel.promptedBuildVersions.clear(); + } + + /** #1556: the served-build-id fetch, shared by the construction path and the + * watcher's polls. Every observed id is recorded in the window-wide cache so + * panels created later stamp their FIRST render (and a re-framed panel + * stamps from the new origin). Never throws — a degraded origin is honest + * degradation, never a surfaced error. */ + private async fetchServedBuildIdForOrigin(origin: string): Promise { + try { + const id = await fetchServedBuildId( + origin, + AbortSignal.timeout(SERVED_BUILD_ID_FETCH_TIMEOUT_MS), + ChatPanel.buildIdFetchImpl, + ); + if (id !== undefined) ChatPanel.servedBuildIds.set(origin, id); + return id; + } catch { + return undefined; + } + } + + /** #1556: await this panel's served-build-id derivation and stamp it. The + * sync factory signatures (and their synchronous-html contract) pin the + * first render synchronous, so the derivation — STARTED before the first + * renderHtml — lands as one guarded re-render: only pre-app-ready (never + * re-navigate a live session), only when the id actually changed, never on + * a disposed panel. undefined (fetch/parse failure) → no stamp, no + * re-render — exactly today's behavior. */ + private async resolveStampedBuildId(origin: string): Promise { + try { + const id = await this.fetchServedBuildIdForOrigin(origin); + if (id === undefined || id === this.stampedBuildId) return; + this.stampedBuildId = id; + if (this.disposed || this.appReadySeen) return; + this.panel.webview.html = + this.renderMode === "transition" + ? this.renderTransitionHtml(this.frameOrigin, this.frameAuthToken, this.frameHideProjectDir) + : this.renderHtml(this.frameOrigin, this.frameAuthToken, this.frameHideProjectDir); + } catch { + /* the reload lane must never disturb the panel */ + } + } + /** #1188: re-point this panel's iframe at `newUrl` (the service shelf) when it * is currently on a different origin (the engine fallback). Re-renders the - * webview HTML; a no-op when the origin already matches. */ + * webview HTML; a no-op when the origin already matches. #1556: re-derives + * and re-stamps from the NEW origin — the stamp never carries the old + * origin's id across an origin switch (sync render stamps from the new + * origin's cache, then the kicked fetch converges via the guarded + * pre-ready re-render; the app is re-booting here, so that window is + * re-opened). */ reframe(newUrl: URL): void { if (!shouldReframe(this.frameHref(), newUrl.toString())) return; this.frameOrigin = newUrl; + this.appReadySeen = false; + this.stampedBuildId = ChatPanel.servedBuildIds.get(newUrl.origin); + void this.resolveStampedBuildId(newUrl.origin); this.panel.webview.html = this.renderHtml(newUrl, this.frameAuthToken, this.frameHideProjectDir); } @@ -558,6 +700,14 @@ export class ChatPanel { // on every reload because ephemeral ports rotate the localStorage origin. const devAssetRoot = (vscode.workspace.getConfiguration("amicode").get("devAssetRoot", "") ?? "").trim(); if (devAssetRoot) framed.searchParams.set("amicode_developer", "1"); + // #1556/#1459: the served dist's build id rides the frame URL as a + // cache key the webview's service worker has never seen. Every ship + // mints a fresh index-.js name, so the stamped URL (and with it + // the SW's navigation cache entry) can never serve the previous build + // across a ship — Reload Window lands the new dist with no cache + // surgery. Never disturbs auth_token or any existing param; undefined + // id (origin doc unparseable/unreachable) → no param, today's behavior. + if (this.stampedBuildId) framed.searchParams.set("amicode_build", this.stampedBuildId); return /* html */ ` @@ -648,6 +798,9 @@ export class ChatPanel { if (ChatPanel.bugReportAvailable) framed.searchParams.set("amicode_bug_report", "1"); const devAssetRootTransition = (vscode.workspace.getConfiguration("amicode").get("devAssetRoot", "") ?? "").trim(); if (devAssetRootTransition) framed.searchParams.set("amicode_developer", "1"); + // #1556/#1459: the amicode_build stamp — identical semantics to + // renderHtml's (the SW cache-bust must hold on BOTH shell paths). + if (this.stampedBuildId) framed.searchParams.set("amicode_build", this.stampedBuildId); return /* html */ ` @@ -789,6 +942,8 @@ export class ChatPanel { } dispose(): void { + this.disposed = true; // #1556: in-flight derivations must not re-render a dead panel + ChatPanel.logLane(`panel disposed: origin=${this.frameOrigin?.origin ?? "?"}`); for (const d of this.disposables) { try { d.dispose(); diff --git a/packages/extension/src/dist_build_id.ts b/packages/extension/src/dist_build_id.ts new file mode 100644 index 00000000..1c734b4e --- /dev/null +++ b/packages/extension/src/dist_build_id.ts @@ -0,0 +1,195 @@ +// ============================================================================ +// dist build id (#1556, subsuming #1459's stamping half) — the primitives of +// the live-test reload lane. The fleet's post-deploy ceremony is "the agent +// ships the dist, the developer reloads the window"; it broke twice because +// (a) VS Code's webview service worker caches the panel iframe's NAVIGATION, +// so the freshly shipped index never reaches the frame until the outer +// webview's cache is cleared by hand (#1459's diagnosis), and (b) nothing +// ever told the developer a new build was live — the agent's last message had +// to do the UI's job. Both halves are tractable from the extension HOST alone +// because the host's fetches bypass the webview SW entirely: a GET of the +// panel origin's document always sees the true current dist, even while the +// framed app is stuck on the previous ship. +// +// The version is already mechanically present: every vite build mints the +// entry asset as index-.js, so the origin doc's entry reference +// IS the served build id (a clean /amicode/app-version route is a named +// follow-up, not this slice). Fail-soft throughout: an unparseable doc, a +// failed fetch, a non-200 — all degrade to undefined, never a throw, never an +// error surface. vscode-free on purpose: every seam is injectable so the +// unit tests need neither the VS Code API nor the network. +// ============================================================================ + +/** The slow poll cadence for the new-build prompt (#1556): one small GET every + * 3 minutes while a chat panel is alive. Cheap enough to leave running, slow + * enough that it is never a nag loop; the once-per-version semantics below + * guarantee at most ONE prompt per shipped version regardless of cadence. */ +export const BUILD_CHANGE_POLL_INTERVAL_MS = 3 * 60 * 1000; + +/** The cap on the panel-construction served-id fetch: the origin doc is a + * local/tunnel hop away (milliseconds), but a hung tunnel must never wedge a + * derivation — it resolves undefined at 3s and the lane degrades honestly. */ +export const SERVED_BUILD_ID_FETCH_TIMEOUT_MS = 3_000; + +/** The prompt's action button — clicking it reloads the window. */ +export const RELOAD_WINDOW_BUTTON = "Reload Window"; + +/** Extract the build id from a served dist's index.html: the content hash in + * its `index-.js` entry-asset reference (e.g. `/assets/index-C8RDSfBx.js` + * → "C8RDSfBx"; the stylesheet's different hash is NOT the id). The hash + * charset is vite/rollup's base64url — letters, digits, `_`, `-`. The match + * requires `index-` to start a fresh filename token (a preceding path + * separator, quote, or whitespace) so a differently-named asset like + * `my-index-abc.js` is never mistaken for the entry. First reference wins. + * undefined on no-match or empty input — an unparseable origin doc never + * breaks anything upstream. */ +export function distBuildIdFromIndexHtml(html: string): string | undefined { + if (!html) return undefined; + const m = html.match(/(?:^|[^A-Za-z0-9_.-])index-([A-Za-z0-9_-]+)\.js\b/); + return m?.[1]; +} + +/** GET the origin document and derive the served build id from it. NEVER + * throws: fetch failure, abort, non-200, unparseable body, malformed origin — + * all resolve undefined (the honest "this origin's version is unknown"). + * `fetchImpl` is the injection seam for tests; production uses the host's + * global fetch, whose requests bypass the webview SW (#1556's whole premise). + * The target is the ORIGIN document ("/") — the same doc the panel's iframe + * navigates — regardless of any deeper path on the input. */ +export async function fetchServedBuildId( + origin: string, + signal?: AbortSignal, + fetchImpl?: typeof fetch, +): Promise { + try { + let url: URL; + try { + url = new URL("/", origin); + } catch { + return undefined; + } + const doFetch = fetchImpl ?? fetch; + const res = await doFetch(url.toString(), { signal, headers: { accept: "text/html" } }); + if (res.status !== 200) return undefined; + return distBuildIdFromIndexHtml(await res.text()); + } catch { + return undefined; + } +} + +/** setInterval/clearInterval seam — the production watcher uses the host's + * real timers; tests inject a manual clock so a 3-minute poll is one tick. */ +export interface BuildChangeClock { + setInterval(callback: () => void, ms: number): unknown; + clearInterval(handle: unknown): void; +} + +export interface BuildChangeWatcherDeps { + /** The origin the panel's iframe is currently framed at (a re-frame to the + * service shelf switches it mid-life; the poll follows whatever is live). */ + origin(): string; + /** The build id the loaded panel was STAMPED with; undefined when the + * construction-time derivation failed (unstamped — an unknown baseline). */ + stampedBuildId(): string | undefined; + /** The served-id fetch — the same derivation seam (fetchServedBuildId). */ + fetchServed(origin: string): Promise; + /** Show the information message; returns the chosen item label, or + * undefined on decline/dismiss. Injected so tests need no VS Code API; + * production wires vscode.window.showInformationMessage. */ + prompt(message: string, ...items: string[]): PromiseLike; + /** What runs when the user picks the Reload Window button (production: + * workbench.action.reloadWindow via vscode.commands.executeCommand). */ + reload(): void; + /** The already-prompted version ids. Share ONE set across a window's + * watchers so a ship prompts exactly once no matter how many chat tabs + * are live (#1556's "exactly one prompt" AC); default = a private set + * (unit tests get isolated semantics). */ + prompted?: Set; + /** Clock injection (tests); default = the host's real timers. */ + clock?: BuildChangeClock; + /** Poll cadence override (tests); default BUILD_CHANGE_POLL_INTERVAL_MS. */ + pollIntervalMs?: number; + /** Observability sink (the extension wires this to an output channel): + * one line per lifecycle event (constructed / poll / prompted / disposed). + * The lane's own fetches are invisible to every other surface — the host + * fetch bypasses the webview SW and leaves no request log the developer + * can see — so the watcher narrates its own state. Never throws (the + * watcher wraps calls); absent = silent, exactly the pre-observability + * behavior. */ + log?: (line: string) => void; +} + +/** The new-build prompt (#1556's ceremony half): while a panel is alive, poll + * the served build id on the slow interval and compare it to the id the + * panel was stamped with. When they differ, show ONE information message — + * "Amicode: a new app build is live (). Reload Window to pick it up." — + * with a Reload Window button. Once-per-version semantics: a version is + * marked prompted BEFORE the prompt resolves (accept, decline, and dismiss + * all count), so the same served id never re-prompts; a NEWER id later + * prompts again. Share one `prompted` set across a window's watchers and a + * ship prompts EXACTLY once no matter how many chat tabs are live. A served + * id of undefined (fetch failed) never prompts and never compares. + * Disposable: the owning panel clears the interval with its own lifecycle. */ +export class BuildChangeWatcher { + private readonly clock: BuildChangeClock; + private readonly handle: unknown; + private readonly prompted: Set; + private busy = false; + private disposed = false; + + constructor(private readonly deps: BuildChangeWatcherDeps) { + this.prompted = deps.prompted ?? new Set(); + this.clock = + deps.clock ?? { + setInterval: (cb, ms) => setInterval(cb, ms), + clearInterval: (h) => clearInterval(h as ReturnType), + }; + this.handle = this.clock.setInterval( + () => void this.poll(), + deps.pollIntervalMs ?? BUILD_CHANGE_POLL_INTERVAL_MS, + ); + try { + this.deps.log?.(`watcher constructed: interval=${deps.pollIntervalMs ?? BUILD_CHANGE_POLL_INTERVAL_MS}ms`); + } catch {} + } + + /** One poll tick — public so the interval callback and the tests share the + * exact same path. Re-entrant calls are ignored (a slow fetch can't stack + * a second prompt on top of a pending one). */ + async poll(): Promise { + if (this.busy || this.disposed) return; + this.busy = true; + try { + const origin = this.deps.origin(); + const served = await this.deps.fetchServed(origin); + const stamped = this.deps.stampedBuildId(); + try { + this.deps.log?.(`poll: origin=${origin} served=${served ?? "undef"} stamped=${stamped ?? "undef"}${this.disposed ? " DISPOSED-MID-POLL" : ""}`); + } catch {} + if (served === undefined) return; // fetch failed — never prompt, never compare + if (served === stamped) return; // panel is current + if (this.prompted.has(served)) return; // once per version — no nag loop + this.prompted.add(served); + try { + this.deps.log?.(`prompting: served=${served} stamped=${stamped ?? "undef"}`); + } catch {} + const choice = await this.deps.prompt( + `Amicode: a new app build is live (${served}). Reload Window to pick it up.`, + RELOAD_WINDOW_BUTTON, + ); + if (choice === RELOAD_WINDOW_BUTTON) this.deps.reload(); + } catch { + /* fail-soft: the prompt lane must never disturb the panel */ + } finally { + this.busy = false; + } + } + + dispose(): void { + this.disposed = true; + this.clock.clearInterval(this.handle); + try { + this.deps.log?.("watcher disposed"); + } catch {} + } +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 55721e6c..28f485ce 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -312,6 +312,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const runsChannel = vscode.window.createOutputChannel("Amicode — runs"); const devicesChannel = vscode.window.createOutputChannel("Amicode — devices"); ctx.subscriptions.push(opencodeChannel, runsChannel, devicesChannel); + // #1556 (the reload lane's observability): the lane's host-side fetches + // bypass the webview SW and leave no request log the developer can see, so + // the panel + watcher narrate their own lifecycle (construction, dispose, + // each poll's derivation, each prompt) into this channel. + ChatPanel.setLaneLog((line) => opencodeChannel.appendLine(`[reload-lane] ${line}`)); // Runs root (resolved early — the inspector needs it for its CSP resource roots). const runsRoot = resolveRunsRoot(vscode.workspace.getConfiguration("amicode").get("runsRoot", "")); diff --git a/packages/extension/test/chat_panel_build_id.test.ts b/packages/extension/test/chat_panel_build_id.test.ts new file mode 100644 index 00000000..17799f8f --- /dev/null +++ b/packages/extension/test/chat_panel_build_id.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as vscode from "vscode"; +import { ChatPanel } from "../src/chat_panel"; +import { mintServerPassword, serverAuthToken } from "../src/server_auth"; +import { BUILD_CHANGE_POLL_INTERVAL_MS, RELOAD_WINDOW_BUTTON, type BuildChangeClock } from "../src/dist_build_id"; + +// ============================================================================ +// #1556 (subsuming #1459) — the stamp half of the live-test reload lane at +// the ChatPanel. The panel's iframe src gains `amicode_build=` (the served +// dist's content hash, derived host-side from the origin doc) on BOTH HTML +// paths, so every ship mints a document URL the webview's service worker has +// never cached — Reload Window lands the fresh build with no cache surgery +// (#1459). The auth_token (#163) and every existing boot param must survive +// the stamp untouched; a failed/unparseable derivation leaves the src exactly +// as today (honest degradation). While the panel is alive, the watcher half +// polls the served id and prompts once per version when it drifts from the +// stamp, with a Reload Window button that runs the reload command. +// ============================================================================ + +type CapturedPanel = { webview: { html: string }; dispose(): void }; + +/** Wrap the mock's createWebviewPanel to capture the panel openOrReveal builds + * (chat_panel.test.ts's harness idiom — the html is the surface under test). */ +function capturePanel(): { created: CapturedPanel[]; restore: () => void } { + const created: CapturedPanel[] = []; + const w = vscode.window as unknown as { createWebviewPanel: (...a: unknown[]) => CapturedPanel }; + const orig = w.createWebviewPanel; + w.createWebviewPanel = (...a: unknown[]) => { + const p = orig(...a); + created.push(p); + return p; + }; + return { created, restore: () => (w.createWebviewPanel = orig) }; +} + +function fakeCtx(): vscode.ExtensionContext { + return { extensionUri: { fsPath: "/ext" } } as unknown as vscode.ExtensionContext; +} + +const iframeSrc = (html: string): URL => { + const m = html.match(/