diff --git a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts index 72af015e0..dfeda0a99 100644 --- a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts +++ b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts @@ -64,6 +64,7 @@ function setup( ), cancelFor: vi.fn(async () => undefined), } + const send = vi.fn() const deps = { cli, state: { workspaceContext: () => "ctx", providerList: () => [] }, @@ -74,12 +75,12 @@ function setup( onDrain: async () => undefined, }, pty: { cancelFor: vi.fn(async () => undefined) }, - getMainWindow: () => null, + getMainWindow: () => ({ webContents: { send } }), providerJobs, } // biome-ignore lint/suspicious/noExplicitAny: partial test doubles registerIpcHandlers(deps as any) - return { providerJobs, cli } + return { providerJobs, cli, send } } function invoke(channel: string, args: Record) { @@ -154,7 +155,7 @@ describe("provider job lifecycle over IPC", () => { it("runs set-source then init on update, clearing the job once", async () => { const seen: string[][] = [] - const { providerJobs } = setup((cliArgs) => { + const { providerJobs, send } = setup((cliArgs) => { seen.push(cliArgs) return { lines: [], code: 0 } }) @@ -178,6 +179,53 @@ describe("provider job lifecycle over IPC", () => { expect(providerJobs.get("docker")?.error).toBeTruthy() }) + + it("streams update phases and completes the provider job", async () => { + const seen: string[][] = [] + const { providerJobs, send } = setup((cliArgs) => { + seen.push(cliArgs) + return { lines: [statusLine(cliArgs[1] === "init" ? "running_init" : "downloading")], code: 0 } + }) + + const commandId = await invoke("provider_update_streaming", { name: "docker" }) + expect(commandId).toEqual(expect.any(String)) + expect(providerJobs.get("docker")?.activity).toBe("updating") + + await vi.waitFor(() => expect(providerJobs.get("docker")).toBeUndefined()) + expect(seen.map((args) => args[1])).toEqual(["set-source", "init"]) + expect(send).toHaveBeenCalledWith( + "command-progress", + expect.objectContaining({ commandId, success: true, done: true }), + ) + }) + + it("retains update failure details after a streaming update", async () => { + const { providerJobs } = setup((cliArgs) => ({ + lines: [], + code: cliArgs[1] === "init" ? 1 : 0, + })) + + await invoke("provider_update_streaming", { name: "docker" }) + await vi.waitFor(() => expect(providerJobs.get("docker")?.error).toBeTruthy()) + }) + + + it("terminates a streaming update when provider refresh fails", async () => { + const { providerJobs, send } = setup(() => ({ lines: [], code: 0 })) + providerJobs.setRefresh(() => Promise.reject(new Error("refresh boom"))) + + const commandId = await invoke("provider_update_streaming", { name: "docker" }) + + await vi.waitFor(() => + expect(providerJobs.get("docker")?.errorCode).toBe("provider_refresh_failed"), + ) + expect(send).toHaveBeenCalledWith( + "command-progress", + expect.objectContaining({ commandId, success: false, done: true }), + ) + }) + + it("does not blame a successful init for a refresh failure afterward", async () => { const { providerJobs } = setup(() => ({ lines: [statusLine("running_init"), statusLine("ready")], @@ -189,4 +237,37 @@ describe("provider job lifecycle over IPC", () => { expect(providerJobs.get("docker")?.error).not.toBe("refresh boom") }) + + it("refreshes and clears a retained provider refresh failure", async () => { + const { providerJobs } = setup(() => ({ lines: [], code: 0 })) + const refresh = vi.fn().mockResolvedValue(undefined) + providerJobs.setRefresh(refresh) + providerJobs.start("docker", "updating") + await providerJobs.finish("docker", { + code: "provider_refresh_failed", + message: "status unavailable", + }) + + const result = await invoke("provider_refresh_state", { name: "docker" }) + + expect(result).toEqual({ ok: true }) + expect(refresh).toHaveBeenCalledOnce() + expect(providerJobs.get("docker")).toBeUndefined() + }) + + it("returns failure and retains recovery when provider refresh still fails", async () => { + const { providerJobs } = setup(() => ({ lines: [], code: 0 })) + providerJobs.setRefresh(() => Promise.reject(new Error("still unavailable"))) + providerJobs.start("docker", "updating") + await providerJobs.finish("docker", { + code: "provider_refresh_failed", + message: "status unavailable", + }) + + const result = await invoke("provider_refresh_state", { name: "docker" }) + + expect(result).toEqual({ ok: false, message: "still unavailable" }) + expect(providerJobs.get("docker")?.errorCode).toBe("provider_refresh_failed") + }) + }) diff --git a/desktop/src/main/__tests__/provider-jobs.test.ts b/desktop/src/main/__tests__/provider-jobs.test.ts index 0acf8444e..e427bfe59 100644 --- a/desktop/src/main/__tests__/provider-jobs.test.ts +++ b/desktop/src/main/__tests__/provider-jobs.test.ts @@ -194,4 +194,32 @@ describe("ProviderJobs", () => { expect(jobs.get("docker")).toBeUndefined() }) + + it("retries refresh for a retained refresh failure", async () => { + const refresh = vi.fn().mockResolvedValue(undefined) + jobs.setRefresh(refresh) + jobs.start("docker", "updating") + await jobs.finish("docker", { + code: "provider_refresh_failed", + message: "status unavailable", + }) + + await jobs.retryRefresh("docker") + + expect(refresh).toHaveBeenCalledOnce() + expect(jobs.get("docker")).toBeUndefined() + }) + + it("keeps a refresh recovery job when retry fails", async () => { + jobs.setRefresh(() => Promise.reject(new Error("still unavailable"))) + jobs.start("docker", "updating") + await jobs.finish("docker", { + code: "provider_refresh_failed", + message: "status unavailable", + }) + + await expect(jobs.retryRefresh("docker")).rejects.toThrow("still unavailable") + expect(jobs.get("docker")?.errorCode).toBe("provider_refresh_failed") + }) + }) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 16a6e2f56..9c92432fa 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -71,6 +71,7 @@ type UpdateInfo = { } let providerUpdateCache: Record = {} +let providerUpdateCacheCheckedAt: string | null = null const IMAGE_CATALOG_URL = process.env.DEVSY_IMAGE_CATALOG_URL ?? @@ -751,6 +752,113 @@ export function registerIpcHandlers(deps: IpcDependencies): { }) }) + ipcMain.handle( + "provider_update_streaming", + async (_event, args: { name: string }) => { + const cmdId = crypto.randomUUID() + const win = deps.getMainWindow() + providerJobs.start(args.name, "updating") + + const sendProgress = (message: string, level?: string) => { + const formatted = redactSensitiveText(formatLogLine(message)) + providerJobs.appendLog(args.name, formatted) + win?.webContents.send("command-progress", { + commandId: cmdId, + message: formatted, + level, + done: false, + }) + } + + const runStep = (cliArgs: string[]): Promise => + new Promise((resolve, reject) => { + cli.runStreaming( + cliArgs, + (line, stream, meta) => { + if (stream === "stdout") { + const envelope = parseCliEnvelope(line) + if (envelope?.kind === "status") { + providerJobs.reportStatus( + args.name, + redactOperationStatus(normalizeOperationStatus(envelope)), + ) + return + } + } + sendProgress(line, meta?.level) + }, + (code, cliError) => { + if (code === 0) { + resolve() + return + } + reject( + Object.assign( + new Error( + cliError?.message ?? `${cliArgs.join(" ")} exited with ${code}`, + ), + { cliError }, + ), + ) + }, + ).catch(reject) + }) + + void (async () => { + let failure: CLIError | undefined + try { + sendProgress("Downloading provider update", "info") + await runStep(["provider", "set-source", args.name, "--use=false"]) + sendProgress("Initializing updated provider", "info") + await runStep(["provider", "init", args.name]) + } catch (error) { + failure = (error as { cliError?: CLIError }).cliError ?? { + code: "provider_update_failed", + message: errorMessage(error), + } + } + try { + await providerJobs.finish( + args.name, + failure ? redactCLIError(failure) : undefined, + ) + } catch (error) { + failure = { + code: "provider_refresh_failed", + message: "The provider updated, but its current state could not be refreshed.", + hint: "Refresh provider status to try again.", + context: { cause: errorMessage(error) }, + } + await providerJobs.finish(args.name, redactCLIError(failure)) + } + win?.webContents.send("command-progress", { + commandId: cmdId, + message: redactSensitiveText( + formatLogLine( + failure ? "Provider update failed" : "Provider update complete", + failure ? "ERROR" : "INFO", + ), + ), + level: failure ? "error" : "info", + success: !failure, + cliError: failure ? redactCLIError(failure) : undefined, + done: true, + }) + })() + + return cmdId + }, + ) + + ipcMain.handle("provider_refresh_state", async (_event, args: { name: string }) => { + try { + await providerJobs.retryRefresh(args.name) + return { ok: true } as const + } catch (error) { + return { ok: false, message: errorMessage(error) } as const + } + }) + ipcMain.handle("provider_options", async (_event, args: { name: string }) => { return cli.run(["provider", "get", args.name]) }) @@ -825,12 +933,14 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle("provider_check_updates", async () => { const out = await computeUpdateChecks() providerUpdateCache = out + providerUpdateCacheCheckedAt = new Date().toISOString() return out }) - ipcMain.handle("provider_get_update_cache", async () => { - return providerUpdateCache - }) + ipcMain.handle("provider_get_update_cache", async () => ({ + updates: providerUpdateCache, + lastCheckedAt: providerUpdateCacheCheckedAt, + })) ipcMain.handle("image_catalog_get", async () => { const { cachePath, seedPath } = imageCatalogPaths() @@ -1856,6 +1966,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { void (async () => { try { providerUpdateCache = await computeUpdateChecks() + providerUpdateCacheCheckedAt = new Date().toISOString() } catch { // Silently swallow background errors. } diff --git a/desktop/src/main/provider-jobs.ts b/desktop/src/main/provider-jobs.ts index 72147d1b6..f0d051ebe 100644 --- a/desktop/src/main/provider-jobs.ts +++ b/desktop/src/main/provider-jobs.ts @@ -35,6 +35,7 @@ export interface ProviderJob { errorCode?: string errorHint?: string errorContext?: Record + logs?: string[] } export class ProviderJobs { @@ -64,6 +65,15 @@ export class ProviderJobs { this.emit() } + + /** Retain recent operation output so failures remain diagnosable after navigation. */ + appendLog(name: string, line: string): void { + const job = this.jobs.get(name) + if (!job) return + this.jobs.set(name, { ...job, logs: [...(job.logs ?? []), line].slice(-500) }) + this.emit() + } + /** Record the complete current-protocol status event without losing metadata. */ reportStatus(name: string, status: OperationStatus): void { const job = this.jobs.get(name) @@ -140,6 +150,19 @@ export class ProviderJobs { this.emit() } + + /** Retry only the authoritative provider-state refresh after a completed operation. */ + async retryRefresh(name: string): Promise { + const job = this.jobs.get(name) + if (!job || job.errorCode !== "provider_refresh_failed") return + const generation = this.generations.get(name) + await this.refresh?.() + if (this.generations.get(name) !== generation) return + this.jobs.delete(name) + this.generations.delete(name) + this.emit() + } + /** * Supplies a way to re-read provider state from disk, so a finished job * isn't cleared before the list reflects what the command just wrote. diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte index bc0e34c28..5db6f8274 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderSheet.svelte @@ -1,5 +1,5 @@ diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index d735341f1..24cd60ff4 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -166,8 +166,12 @@ export async function providerReleaseJob(name: string): Promise { return invoke("provider_release_job", { name }) } -export async function providerUpdate(name: string): Promise { - return invoke("provider_update", { name }) +export async function providerUpdateStreaming(name: string): Promise { + return invoke("provider_update_streaming", { name }) +} + +export async function providerRefreshState(name: string): Promise { + unwrapEnvelope(await invoke("provider_refresh_state", { name })) } export async function providerOptions( @@ -208,6 +212,13 @@ export async function providerCheckUpdates() { return invoke>("provider_check_updates") } +export async function providerGetUpdateCache() { + return invoke<{ + updates: Record + lastCheckedAt: string | null + }>("provider_get_update_cache") +} + // Image catalog commands export async function imageCatalogGet(): Promise { return invoke("image_catalog_get") diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index aebe97087..e2f6ab33b 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -196,6 +196,9 @@ const COMMANDS: Record = { provider_delete: () => undefined, provider_use: () => undefined, provider_update: () => undefined, + provider_update_streaming: () => `mock-provider-update-${Date.now()}`, + provider_refresh_state: () => ({ ok: true }), + provider_get_update_cache: () => ({ updates: {}, lastCheckedAt: hourAgo }), provider_options: () => PROVIDER_OPTIONS, provider_set_options: () => undefined, provider_release_job: () => undefined, diff --git a/desktop/src/renderer/src/lib/stores/providerVersions.test.ts b/desktop/src/renderer/src/lib/stores/providerVersions.test.ts index de6587dd3..b7e8818e9 100644 --- a/desktop/src/renderer/src/lib/stores/providerVersions.test.ts +++ b/desktop/src/renderer/src/lib/stores/providerVersions.test.ts @@ -3,14 +3,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest" vi.mock("$lib/ipc/commands.js", () => ({ providerListVersions: vi.fn(), + providerGetUpdateCache: vi.fn(), providerCheckUpdates: vi.fn(), })) import { providerCheckUpdates, + providerGetUpdateCache, providerListVersions, } from "$lib/ipc/commands.js" import { + loadCachedUpdates, loadVersionsFor, providerVersions, refreshUpdates, @@ -38,6 +41,59 @@ describe("providerVersions store", () => { expect(state.lastCheckedAt).not.toBeNull() }) + it("renders cached results before a live refresh", async () => { + vi.mocked(providerGetUpdateCache).mockResolvedValue({ + updates: { + docker: { + current: "v1.0", + latest: "v1.1", + updateAvailable: true, + unsupported: false, + }, + }, + lastCheckedAt: "2026-09-21T17:00:00.000Z", + }) + + await loadCachedUpdates() + + const state = get(providerVersions) + expect(state.updates.docker.latest).toBe("v1.1") + expect(state.lastCheckedAt?.toISOString()).toBe("2026-09-21T17:00:00.000Z") + }) + + it("keeps cached results and exposes partial check failures", async () => { + vi.mocked(providerGetUpdateCache).mockResolvedValue({ + updates: { + docker: { + current: "v1.0", + latest: "v1.1", + updateAvailable: true, + unsupported: false, + }, + }, + lastCheckedAt: "2026-09-21T17:00:00.000Z", + }) + await loadCachedUpdates() + vi.mocked(providerCheckUpdates).mockResolvedValue({ + docker: { + current: "v1.0", + latest: "", + updateAvailable: false, + unsupported: false, + error: "network unavailable", + }, + }) + + await refreshUpdates() + + const state = get(providerVersions) + expect(state.updates.docker.latest).toBe("v1.1") + expect(state.updates.docker.updateAvailable).toBe(true) + expect(state.updates.docker.error).toBe("network unavailable") + expect(state.refreshError).toBe("One provider update check failed.") + expect(state.refreshing).toBe(false) + }) + it("populates byProvider via loadVersionsFor", async () => { vi.mocked(providerListVersions).mockResolvedValue({ versions: [ diff --git a/desktop/src/renderer/src/lib/stores/providerVersions.ts b/desktop/src/renderer/src/lib/stores/providerVersions.ts index 4279f237a..b040163d0 100644 --- a/desktop/src/renderer/src/lib/stores/providerVersions.ts +++ b/desktop/src/renderer/src/lib/stores/providerVersions.ts @@ -1,6 +1,7 @@ import { writable } from "svelte/store" import { providerCheckUpdates, + providerGetUpdateCache, providerListVersions, } from "$lib/ipc/commands.js" import type { @@ -15,21 +16,64 @@ type State = { > updates: Record lastCheckedAt: Date | null + refreshing: boolean + refreshError: string | null } const initial: State = { byProvider: {}, updates: {}, lastCheckedAt: null, + refreshing: false, + refreshError: null, } const internal = writable(initial) export const providerVersions = { subscribe: internal.subscribe } +export async function loadCachedUpdates(): Promise { + const cached = await providerGetUpdateCache() + internal.update((s) => ({ + ...s, + updates: cached.updates, + lastCheckedAt: cached.lastCheckedAt ? new Date(cached.lastCheckedAt) : null, + })) +} + export async function refreshUpdates(): Promise { - const updates = await providerCheckUpdates() - internal.update((s) => ({ ...s, updates, lastCheckedAt: new Date() })) + internal.update((s) => ({ ...s, refreshing: true, refreshError: null })) + try { + const updates = await providerCheckUpdates() + const failed = Object.values(updates).filter((result) => result.error).length + internal.update((s) => { + const merged = { ...s.updates } + for (const [name, result] of Object.entries(updates)) { + merged[name] = result.error && s.updates[name] + ? { ...s.updates[name], error: result.error } + : result + } + return { + ...s, + updates: merged, + lastCheckedAt: new Date(), + refreshing: false, + refreshError: + failed === 0 + ? null + : failed === 1 + ? "One provider update check failed." + : `${failed} provider update checks failed.`, + } + }) + } catch (error) { + internal.update((s) => ({ + ...s, + refreshing: false, + refreshError: error instanceof Error ? error.message : String(error), + })) + throw error + } } export async function loadVersionsFor(name: string): Promise { diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 1e7efd3bf..5f1341a75 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -104,6 +104,7 @@ export interface ProviderJob { errorCode?: string errorHint?: string errorContext?: Record + logs?: string[] } export type { WorkspaceJob } from "$shared/workspace-operation.js" diff --git a/desktop/src/renderer/src/pages/ProvidersPage.svelte b/desktop/src/renderer/src/pages/ProvidersPage.svelte index b087234c7..30b195d24 100644 --- a/desktop/src/renderer/src/pages/ProvidersPage.svelte +++ b/desktop/src/renderer/src/pages/ProvidersPage.svelte @@ -1,9 +1,11 @@