From 0b6c4cd4cad7f11bb154508e6d15394d181e9d74 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 20:09:56 -0700 Subject: [PATCH] feat(dashboard,desktop): show live database-migration progress during boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-time SQLite→PostgreSQL migration runs inside createTaskStoreForBackend before any HTTP server listens, so browsers saw "connection refused" and open tabs failed silently for minutes. Now: - CLI: a temporary holding server binds the dashboard port for the boot window, serving an auto-reloading "Database migration in progress" page and an /api/health payload with status "migrating" + structured progress; the port is handed off (awaited) to the real app.listen(). - Dashboard SPA: already-open tabs render the new MigrationInProgressBanner from the 15s health poll when status is "migrating". - Desktop: LocalRuntimeManager publishes migration progress on DesktopRuntimeStatus via the new core onMigrationProgress option; DesktopLaunchGate shows the live label and extends its 30s startup timeout while progress advances (2min stall cap), in both boot and first-run flows. Co-Authored-By: Claude Fable 5 --- .changeset/migration-holding-banner.md | 7 + .../migration-holding-server.test.ts | 103 ++++++++++ packages/cli/src/commands/dashboard.ts | 30 ++- .../src/commands/migration-holding-server.ts | 189 ++++++++++++++++++ packages/core/src/postgres/startup-factory.ts | 16 ++ packages/dashboard/app/api/health.ts | 20 ++ .../app/components/DesktopLaunchGate.css | 13 ++ .../app/components/DesktopLaunchGate.tsx | 52 ++++- .../components/MigrationInProgressBanner.css | 32 +++ .../components/MigrationInProgressBanner.tsx | 38 ++++ .../__tests__/DesktopLaunchGate.test.tsx | 41 ++++ .../MigrationInProgressBanner.test.tsx | 25 +++ .../components/dashboard/DashboardBanners.tsx | 10 + .../dashboard/app/types/native-shell.d.ts | 12 ++ .../src/__tests__/local-runtime.test.ts | 51 +++++ .../src/__tests__/local-server.test.ts | 2 + packages/desktop/src/local-runtime.ts | 48 ++++- packages/desktop/src/local-server.ts | 18 +- packages/i18n/locales/en/app.json | 4 +- packages/i18n/locales/es/app.json | 4 +- packages/i18n/locales/fr/app.json | 4 +- packages/i18n/locales/ko/app.json | 4 +- packages/i18n/locales/zh-CN/app.json | 4 +- packages/i18n/locales/zh-TW/app.json | 4 +- 24 files changed, 711 insertions(+), 20 deletions(-) create mode 100644 .changeset/migration-holding-banner.md create mode 100644 packages/cli/src/commands/__tests__/migration-holding-server.test.ts create mode 100644 packages/cli/src/commands/migration-holding-server.ts create mode 100644 packages/dashboard/app/components/MigrationInProgressBanner.css create mode 100644 packages/dashboard/app/components/MigrationInProgressBanner.tsx create mode 100644 packages/dashboard/app/components/__tests__/MigrationInProgressBanner.test.tsx diff --git a/.changeset/migration-holding-banner.md b/.changeset/migration-holding-banner.md new file mode 100644 index 0000000000..4f777282f9 --- /dev/null +++ b/.changeset/migration-holding-banner.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show live database-migration progress during boot — dashboard holding page/banner and desktop launch screen. +category: feature +dev: CLI binds a temporary holding server on the dashboard port during `createTaskStoreForBackend` (new `onMigrationProgress` option) serving an auto-reloading page + `/api/health` `status:"migrating"`; open tabs render `MigrationInProgressBanner` from the health poll. Desktop publishes progress via `DesktopRuntimeStatus.migration` → IPC → `DesktopLaunchGate`, which shows the label and suspends its 30s timeout while progress advances. diff --git a/packages/cli/src/commands/__tests__/migration-holding-server.test.ts b/packages/cli/src/commands/__tests__/migration-holding-server.test.ts new file mode 100644 index 0000000000..68437c8dd4 --- /dev/null +++ b/packages/cli/src/commands/__tests__/migration-holding-server.test.ts @@ -0,0 +1,103 @@ +/* +FNXC:MigrationHoldingPage 2026-07-17-12:50: +The boot-window holding server is the only surface a browser can reach while +the SQLite→PostgreSQL auto-migration blocks dashboard boot. These tests pin its +contract: health JSON (starting → migrating with a progress label), HTML +holding page with 503 for navigations, JSON 503 for other API calls, and a +close() that fully releases the port for the real app.listen(). +*/ +import { describe, expect, it } from "vitest"; +import { startMigrationHoldingServer } from "../migration-holding-server.js"; +import type { MigrationProgressEvent } from "@fusion/core"; + +const HOST = "127.0.0.1"; + +async function withServer( + run: (server: NonNullable>>) => Promise, +): Promise { + const server = await startMigrationHoldingServer({ port: 0, host: HOST }); + expect(server).not.toBeNull(); + try { + await run(server!); + } finally { + await server!.close(); + } +} + +describe("startMigrationHoldingServer", () => { + it("reports starting, then migrating with a progress label on /api/health", async () => { + await withServer(async (server) => { + const before = await fetch(`http://${HOST}:${server.port}/api/health`); + expect(before.status).toBe(200); + const beforeBody = await before.json(); + expect(beforeBody.status).toBe("starting"); + expect(beforeBody.migration).toEqual({ active: false }); + + const event: MigrationProgressEvent = { + phase: "table-progress", + sourceSchema: "project", + table: "tasks", + tableIndex: 3, + tableCount: 12, + processedRows: 500, + sourceRows: 2000, + }; + server.setMigrationProgress(event); + + const after = await fetch(`http://${HOST}:${server.port}/api/health`); + const afterBody = await after.json(); + expect(afterBody.status).toBe("migrating"); + expect(afterBody.holding).toBe(true); + expect(afterBody.migration.active).toBe(true); + expect(afterBody.migration.table).toBe("tasks"); + expect(afterBody.migration.processedRows).toBe(500); + expect(afterBody.migration.sourceRows).toBe(2000); + expect(typeof afterBody.migration.label).toBe("string"); + expect(afterBody.migration.label).toContain("tasks"); + }); + }); + + it("serves a 503 HTML holding page for navigations and 503 JSON for other API calls", async () => { + await withServer(async (server) => { + const page = await fetch(`http://${HOST}:${server.port}/`); + expect(page.status).toBe(503); + expect(page.headers.get("content-type")).toContain("text/html"); + const html = await page.text(); + expect(html).toContain("migration"); + expect(html).toContain("/api/health"); + + const api = await fetch(`http://${HOST}:${server.port}/api/tasks`); + expect(api.status).toBe(503); + expect(api.headers.get("content-type")).toContain("application/json"); + const body = await api.json(); + expect(typeof body.error).toBe("string"); + }); + }); + + it("releases the port on close so the real server can bind", async () => { + const server = await startMigrationHoldingServer({ port: 0, host: HOST }); + expect(server).not.toBeNull(); + const port = server!.port; + await server!.close(); + // close() is idempotent. + await server!.close(); + await expect(fetch(`http://${HOST}:${port}/api/health`)).rejects.toThrow(); + }); + + it("fails soft (returns null) when the port is already taken", async () => { + const first = await startMigrationHoldingServer({ port: 0, host: HOST }); + expect(first).not.toBeNull(); + try { + const messages: string[] = []; + const second = await startMigrationHoldingServer({ + port: first!.port, + host: HOST, + log: (message) => messages.push(message), + }); + expect(second).toBeNull(); + expect(messages.some((m) => m.includes("holding page not available"))).toBe(true); + } finally { + await first!.close(); + } + }); +}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index f51d16c816..a4477809ee 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -78,6 +78,7 @@ import { createPrReconcileGithubOps, } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; +import { startMigrationHoldingServer } from "./migration-holding-server.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; @@ -893,9 +894,27 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: optional SQLite migrate) is the first large bucket after the PostgreSQL cutover. */ const logPhase = (message: string, scope = "dashboard") => logSink.log(message, scope); + /* + FNXC:MigrationHoldingPage 2026-07-17-12:30: + The backend factory below performs the one-time SQLite→PostgreSQL migration + BEFORE the real HTTP server binds, so browsers hitting the dashboard during + that window previously saw "connection refused". Bind a temporary holding + server on the selected port for the boot window: fresh navigations get a + live "database migration in progress" page and already-open dashboard tabs + see a "migrating" /api/health status they render as a banner. The port is + released (awaited) right before the real app.listen(). Bind failure is soft. + */ + const migrationHoldingServer = await startMigrationHoldingServer({ + port: selectedPort, + host: selectedHost, + log: (message) => logSink.log(message, "dashboard"), + }); const dashboardBackendBoot = await phaseTime( "backend.factory", - () => createTaskStoreForBackend({ rootDir: cwd }), + () => createTaskStoreForBackend({ + rootDir: cwd, + onMigrationProgress: (event) => migrationHoldingServer?.setMigrationProgress(event), + }), logPhase, ); // FNXC:PostgresFinalCutover 2026-07-14-17:20: Dashboard runtime storage is @@ -2704,6 +2723,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: }, POLL_MS).unref(); } + /* + FNXC:MigrationHoldingPage 2026-07-17-12:30: + Release the boot-window holding server before binding the real server on the + same port. The close is awaited so the ports never race; the holding page + keeps polling through the swap and reloads into the real dashboard. + */ + if (migrationHoldingServer) { + await migrationHoldingServer.close(); + } const server = app.listen(selectedPort, selectedHost); server.on("error", (err: NodeJS.ErrnoException) => { diff --git a/packages/cli/src/commands/migration-holding-server.ts b/packages/cli/src/commands/migration-holding-server.ts new file mode 100644 index 0000000000..423f7e3670 --- /dev/null +++ b/packages/cli/src/commands/migration-holding-server.ts @@ -0,0 +1,189 @@ +/* +FNXC:MigrationHoldingPage 2026-07-17-12:25: +The dashboard HTTP server only starts listening AFTER createTaskStoreForBackend +resolves, and that call performs the one-time SQLite→PostgreSQL auto-migration, +which can copy hundreds of thousands of rows and take minutes. During that window +the dashboard port was simply closed: a browser navigating to the dashboard saw +"connection refused" and an already-open dashboard tab silently failed its +fetches with no explanation. + +This module binds a tiny temporary HTTP server on the dashboard port for the +boot window and releases it just before the real server's app.listen(): +- Any page request gets a self-contained 503 holding page ("Database migration + in progress") that polls /api/health and reloads itself into the real + dashboard once boot completes. +- GET /api/health returns 200 JSON with status "starting" (no migration seen + yet) or "migrating" plus a structured progress snapshot. Already-open + dashboard tabs poll /api/health every 15s (useDashboardHealth) and use the + "migrating" status to show the MigrationInProgressBanner instead of failing + silently. The payload intentionally omits engine/database/taskIdIntegrity so + the frontend's optional-chained banner gates stay quiet during boot. +- All other /api/* requests get 503 JSON so in-flight app fetches fail cleanly + instead of parsing HTML. + +Bind failures (e.g. port already in use) are soft: boot proceeds without the +holding page and the existing app.listen EADDRINUSE fallback still applies. +*/ +import { createServer, type Server } from "node:http"; +import { formatMigrationProgress, type MigrationProgressEvent } from "@fusion/core"; + +export interface MigrationHoldingServer { + /** The actual bound port (useful when 0 was requested in tests). */ + readonly port: number; + /** Record the latest structured migration event for the health payload/page. */ + setMigrationProgress(event: MigrationProgressEvent): void; + /** Release the port. Resolves once every connection is torn down. */ + close(): Promise; +} + +interface StartOptions { + readonly port: number; + readonly host: string; + readonly log?: (message: string) => void; +} + +/* +FNXC:MigrationHoldingPage 2026-07-17-12:25: +The holding page must be fully self-contained (no dashboard bundle is served +yet), dark, and on-brand (all-blue). It polls /api/health every 1.5s: while the +status is "starting"/"migrating" it updates the progress line; once any other +status (the real server) answers it reloads into the real dashboard. Repeated +fetch failures flip the copy to a "cannot reach Fusion" hint so a crashed boot +does not masquerade as a forever-running migration. +*/ +function renderHoldingPage(): string { + return ` + + + + +Fusion — starting + + + +
+ +

Fusion is starting…

+

If a database migration is needed it runs now and can take a few minutes for large projects. This page reloads automatically when the dashboard is ready.

+
+
+ + +`; +} + +export async function startMigrationHoldingServer( + options: StartOptions, +): Promise { + let latest: MigrationProgressEvent | null = null; + + const server: Server = createServer((req, res) => { + const url = req.url ?? "/"; + if (url === "/api/health" || url.startsWith("/api/health?")) { + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify({ + status: latest ? "migrating" : "starting", + holding: true, + migration: latest + ? { + active: true, + phase: latest.phase, + label: formatMigrationProgress(latest), + ...("table" in latest ? { table: latest.table, tableIndex: latest.tableIndex, tableCount: latest.tableCount } : {}), + ...(latest.phase === "table-progress" ? { processedRows: latest.processedRows, sourceRows: latest.sourceRows } : {}), + } + : { active: false }, + })); + return; + } + if (url.startsWith("/api/")) { + res.writeHead(503, { "content-type": "application/json", "retry-after": "2" }); + res.end(JSON.stringify({ error: "Fusion is starting (database migration may be in progress)" })); + return; + } + res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2", "cache-control": "no-store" }); + res.end(renderHoldingPage()); + }); + + const bound = await new Promise((resolve) => { + server.once("error", (error: NodeJS.ErrnoException) => { + options.log?.(`migration holding page not available (${error.code ?? error.message}); continuing boot without it`); + resolve(false); + }); + server.listen(options.port, options.host, () => resolve(true)); + }); + if (!bound) return null; + + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : options.port; + + let closed: Promise | null = null; + return { + port, + setMigrationProgress(event: MigrationProgressEvent): void { + latest = event; + }, + close(): Promise { + if (closed) return closed; + closed = new Promise((resolve) => { + server.close(() => resolve()); + /* + FNXC:MigrationHoldingPage 2026-07-17-12:25: + Keep-alive sockets from the polling page would otherwise hold the port + open past close() and race the real app.listen() on the same port. + */ + server.closeAllConnections(); + }); + return closed; + }, + }; +} diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 006e02bb2e..9688271fba 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -427,6 +427,15 @@ export interface CreateTaskStoreForBackendOptions { * constructor (matching `new TaskStore(rootDir)`). */ readonly projectId?: string; + /* + FNXC:MigrationHoldingPage 2026-07-17-12:20: + During the one-time SQLite→PostgreSQL auto-migration the caller's HTTP server is + not yet listening, so the CLI binds a temporary holding server on the dashboard + port and needs the structured migration progress stream to surface it in the + browser. This observer receives the same MigrationProgressEvent stream that is + logged to the terminal; it must never throw (fire-and-forget UI plumbing). + */ + readonly onMigrationProgress?: (event: import("./sqlite-migrator.js").MigrationProgressEvent) => void; } /** @@ -651,6 +660,13 @@ export async function createTaskStoreForBackend( */ onProgress: (event) => { log.log(`startup-factory: SQLite migration — ${formatMigrationProgress(event)}`); + /* + FNXC:MigrationHoldingPage 2026-07-17-12:20: + Forward the same structured event to the caller (CLI holding server) + so the browser can show live migration progress; observer failures + must never abort the migration itself. + */ + try { options.onMigrationProgress?.(event); } catch { /* ignore observer errors */ } }, }); /* diff --git a/packages/dashboard/app/api/health.ts b/packages/dashboard/app/api/health.ts index 56087d19c2..179daa0b14 100644 --- a/packages/dashboard/app/api/health.ts +++ b/packages/dashboard/app/api/health.ts @@ -26,6 +26,26 @@ export interface DashboardHealthResponse { status: string; version: string; uptime: number; + /* + FNXC:MigrationHoldingPage 2026-07-17-12:35: + While the CLI's boot-window holding server owns the dashboard port (real + server not yet listening), /api/health answers with status "migrating" (or + "starting") and this progress snapshot — and OMITS engine/database/ + taskIdIntegrity. Consumers of those fields must optional-chain (the + DashboardBanners gates already do) so boot-window polls don't fire the + engine/db-corruption banners. The real server never sets `migration`. + */ + holding?: boolean; + migration?: { + active: boolean; + phase?: string; + label?: string; + table?: string; + tableIndex?: number; + tableCount?: number; + processedRows?: number; + sourceRows?: number; + }; engine?: { available: boolean; }; diff --git a/packages/dashboard/app/components/DesktopLaunchGate.css b/packages/dashboard/app/components/DesktopLaunchGate.css index 6fcb6850c3..0f9801ef19 100644 --- a/packages/dashboard/app/components/DesktopLaunchGate.css +++ b/packages/dashboard/app/components/DesktopLaunchGate.css @@ -40,3 +40,16 @@ flex-direction: column; gap: 0.5rem; } + +/* +FNXC:MigrationHoldingPage 2026-07-17-13:35: +Monospace progress line under the "Database migration in progress" gate copy — +carries the migrator's structured "[3/12] project.tasks — 500/2000 rows" label. +*/ +.desktop-launch-gate__detail { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-muted, #9ca3af); + overflow-wrap: anywhere; + margin: -0.75rem 0 1.25rem; +} diff --git a/packages/dashboard/app/components/DesktopLaunchGate.tsx b/packages/dashboard/app/components/DesktopLaunchGate.tsx index fc937a8b5d..b997674a20 100644 --- a/packages/dashboard/app/components/DesktopLaunchGate.tsx +++ b/packages/dashboard/app/components/DesktopLaunchGate.tsx @@ -6,7 +6,7 @@ import "./DesktopLaunchGate.css"; type Phase = | { kind: "loading" } | { kind: "chooser"; state: ShellConnectionState } - | { kind: "starting-local"; message: string } + | { kind: "starting-local"; message: string; detail?: string } | { kind: "local-error"; message: string } | { kind: "ready"; serverBaseUrl?: string } | { kind: "bypass" }; @@ -28,11 +28,27 @@ function needsChooser(state: ShellConnectionState): boolean { return !state.desktopMode; } +/* +FNXC:MigrationHoldingPage 2026-07-17-13:30: +While the first-boot SQLite→PostgreSQL migration runs inside the embedded +runtime start, `localRuntime.migration` carries live progress. Two duties here: +(1) report each new progress label so the gate can replace the static +"Starting…" copy with real migration status, and (2) push the startup deadline +out while progress advances — large databases legitimately exceed the 30s +default and must not surface the "did not become ready in time" error screen +mid-migration. The extension keys on the LABEL CHANGING (not merely +migration.active) so a genuinely hung migration still times out, after +MIGRATION_STALL_TIMEOUT_MS of no new progress. +*/ +const MIGRATION_STALL_TIMEOUT_MS = 120_000; + async function waitForLocalRuntime( shell: NonNullable>, - timeoutMs = 30_000, + options: { timeoutMs?: number; onMigrationProgress?: (label: string) => void } = {}, ): Promise<{ baseUrl: string }> { - const deadline = Date.now() + timeoutMs; + const timeoutMs = options.timeoutMs ?? 30_000; + let deadline = Date.now() + timeoutMs; + let lastMigrationLabel: string | null = null; // The runtime is started by main when setDesktopMode("local") fires; just // poll shell:getState until localRuntime reports running. while (Date.now() < deadline) { @@ -45,6 +61,12 @@ async function waitForLocalRuntime( if (rt?.state === "error") { throw new Error(rt.error ?? "Local runtime failed to start"); } + const migrationLabel = rt?.migration?.active ? rt.migration.label ?? null : null; + if (migrationLabel && migrationLabel !== lastMigrationLabel) { + lastMigrationLabel = migrationLabel; + deadline = Math.max(deadline, Date.now() + MIGRATION_STALL_TIMEOUT_MS); + options.onMigrationProgress?.(migrationLabel); + } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error("Local runtime did not become ready in time"); @@ -144,7 +166,17 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) { } if (cancelled) return; } - const { baseUrl } = await waitForLocalRuntime(shell); + const { baseUrl } = await waitForLocalRuntime(shell, { + /* FNXC:MigrationHoldingPage 2026-07-17-13:30: swap the static "Starting…" copy for live migration progress while the first-boot database migration runs. */ + onMigrationProgress: (label) => { + if (cancelled) return; + setPhase({ + kind: "starting-local", + message: t("desktop.migrationInProgress", "Database migration in progress — this can take a few minutes."), + detail: label, + }); + }, + }); if (cancelled) return; navigateToLocalRuntimeOrigin(baseUrl); return; @@ -188,10 +220,12 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) { if (phase.kind === "loading" || phase.kind === "starting-local") { const message = phase.kind === "loading" ? t("desktop.loading", "Loading Fusion…") : phase.message; + const detail = phase.kind === "starting-local" ? phase.detail : undefined; return (

{message}

+ {detail ?

{detail}

: null}
); @@ -230,7 +264,15 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) { try { await shell.setDesktopMode(mode); if (mode === "local") { - const { baseUrl } = await waitForLocalRuntime(shell); + const { baseUrl } = await waitForLocalRuntime(shell, { + /* FNXC:MigrationHoldingPage 2026-07-17-13:30: first "Run Fusion Locally" pick is exactly when the one-time database migration runs — show its progress. */ + onMigrationProgress: (label) => + setPhase({ + kind: "starting-local", + message: t("desktop.migrationInProgress", "Database migration in progress — this can take a few minutes."), + detail: label, + }), + }); navigateToLocalRuntimeOrigin(baseUrl); return; } diff --git a/packages/dashboard/app/components/MigrationInProgressBanner.css b/packages/dashboard/app/components/MigrationInProgressBanner.css new file mode 100644 index 0000000000..63e459c159 --- /dev/null +++ b/packages/dashboard/app/components/MigrationInProgressBanner.css @@ -0,0 +1,32 @@ +/* +FNXC:MigrationHoldingPage 2026-07-17-12:40: +Mirrors the TestModeBanner shape (left accent bar + tinted background) but uses +the info color: a running migration is an informational transient state, not a +warning. The monospace progress line carries the migrator's structured label. +*/ +.migration-in-progress-banner { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-md); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-md); + border-inline-start: var(--space-xs) solid var(--color-info); + background: color-mix(in srgb, var(--color-info) 18%, transparent); + color: var(--text); +} + +.migration-in-progress-banner-progress { + display: block; + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +@media (max-width: 768px), (max-height: 480px) { + .migration-in-progress-banner { + padding: var(--space-sm); + align-items: flex-start; + } +} diff --git a/packages/dashboard/app/components/MigrationInProgressBanner.tsx b/packages/dashboard/app/components/MigrationInProgressBanner.tsx new file mode 100644 index 0000000000..6bb8b384fa --- /dev/null +++ b/packages/dashboard/app/components/MigrationInProgressBanner.tsx @@ -0,0 +1,38 @@ +/* +FNXC:MigrationHoldingPage 2026-07-17-12:40: +When the Fusion server restarts and performs the one-time SQLite→PostgreSQL +migration, an already-open dashboard tab keeps polling /api/health (every 15s +via useDashboardHealth) and reaches the CLI's boot-window holding server, which +answers with status "migrating" plus a progress label. This banner surfaces +that state so the open tab explains the outage instead of failing silently. +It renders from health status alone (no project gate — during the boot window +no project data is fetchable) and disappears on the next poll of the real +server. Fresh navigations during migration get the holding page instead. +*/ +import { useTranslation } from "react-i18next"; +import { DatabaseZap } from "lucide-react"; +import "./MigrationInProgressBanner.css"; + +interface MigrationInProgressBannerProps { + isActive: boolean; + progressLabel?: string; +} + +export function MigrationInProgressBanner({ isActive, progressLabel }: MigrationInProgressBannerProps) { + const { t } = useTranslation("app"); + if (!isActive) { + return null; + } + + return ( +
+
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx b/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx index 03acf040dc..b46d366857 100644 --- a/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx +++ b/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx @@ -95,6 +95,47 @@ describe("DesktopLaunchGate — local handoff", () => { expect(shell.setDesktopMode).not.toHaveBeenCalled(); }); + /* + * FNXC:MigrationHoldingPage 2026-07-17-13:45: + * While localRuntime reports state "starting" with migration progress, the gate + * must show the migration copy + the structured progress label instead of the + * static "Starting local Fusion runtime…", then still hand off once running. + */ + it("shows live migration progress while starting, then navigates when running", async () => { + const location = stubLocation("file:///C:/app/index.html"); + let migrationDone = false; + const migrating = { + ...localReadyState, + localRuntime: { + source: "embedded-local", + state: "starting", + migration: { active: true, phase: "table-progress", label: "[3/12] project.tasks — 500/2000 rows" }, + }, + }; + const shell = { + getState: vi.fn(async () => (migrationDone ? localReadyState : migrating)), + setDesktopMode: vi.fn(async () => migrating), + onResetDesktopModeRequest: vi.fn(() => () => undefined), + resetDesktopMode: vi.fn(async () => undefined), + }; + (window as unknown as { fusionShell: unknown }).fusionShell = shell; + + render( + +
app
+
, + ); + + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Database migration in progress"), + ); + expect(screen.getByRole("status")).toHaveTextContent("[3/12] project.tasks — 500/2000 rows"); + + migrationDone = true; + await waitFor(() => expect(location.replace).toHaveBeenCalledTimes(1)); + expect(location.replace.mock.calls[0][0]).toMatch(/^http:\/\/127\.0\.0\.1:50123\//); + }); + it("starts the runtime when it is not running, then navigates to its origin", async () => { const location = stubLocation("file:///C:/app/index.html"); // First getState: stopped. setDesktopMode starts it; subsequent polls: running. diff --git a/packages/dashboard/app/components/__tests__/MigrationInProgressBanner.test.tsx b/packages/dashboard/app/components/__tests__/MigrationInProgressBanner.test.tsx new file mode 100644 index 0000000000..dc7cc2d207 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MigrationInProgressBanner.test.tsx @@ -0,0 +1,25 @@ +/* +FNXC:MigrationHoldingPage 2026-07-17-12:50: +Pins the open-tab migration banner contract: hidden unless health reports +status "migrating", and shows the structured progress label when present. +*/ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MigrationInProgressBanner } from "../MigrationInProgressBanner"; + +describe("MigrationInProgressBanner", () => { + it("renders nothing when inactive", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders status copy when active", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("Database migration in progress"); + }); + + it("shows the structured progress label when provided", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("[3/12] project.tasks — 500/2000 rows"); + }); +}); diff --git a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx index 953ccfd921..4c54993aee 100644 --- a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx +++ b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx @@ -5,6 +5,7 @@ DashboardBanners is the conditional banner cluster rendered above the dashboard- import type { DashboardBannersProps } from "./types"; import type { SectionId } from "../SettingsModal"; import { TestModeBanner } from "../TestModeBanner"; +import { MigrationInProgressBanner } from "../MigrationInProgressBanner"; import { SqliteMigrationBanner } from "../SqliteMigrationBanner"; import { EngineUnavailableBanner } from "../EngineUnavailableBanner"; import { EngineStatusBanner } from "../EngineStatusBanner"; @@ -76,6 +77,15 @@ export function DashboardBanners({ return ( <> + {/* FNXC:MigrationHoldingPage 2026-07-17-12:45: Rendered OUTSIDE the + project gate — while the boot-window holding server reports + status "migrating", project data is not fetchable, yet the open tab + must still explain the outage. Clears on the next health poll of the + real server. */} + {viewMode === "project" && currentProject && ( <> diff --git a/packages/dashboard/app/types/native-shell.d.ts b/packages/dashboard/app/types/native-shell.d.ts index afc6f1b317..c892d90e81 100644 --- a/packages/dashboard/app/types/native-shell.d.ts +++ b/packages/dashboard/app/types/native-shell.d.ts @@ -38,6 +38,18 @@ export interface ShellConnectionState { port?: number; baseUrl?: string; error?: string; + /* + FNXC:MigrationHoldingPage 2026-07-17-13:30: + Live SQLite→PostgreSQL migration progress published by the desktop + LocalRuntimeManager while state is "starting" (packages/desktop/src/ + local-runtime.ts). DesktopLaunchGate renders it and suspends its startup + timeout while progress advances. + */ + migration?: { + active: boolean; + phase?: string; + label?: string; + }; }; } diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index 8f9623a14f..ab122567ea 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -165,6 +165,57 @@ describe("LocalRuntimeManager", () => { expect(manager.getServerPort()).toBe(4545); }); + /* + FNXC:MigrationHoldingPage 2026-07-17-13:40: + Pins the desktop migration-progress contract: while createStore (which runs the + one-time SQLite→PG migration) is in flight, progress published through the + createStore callback must be visible on getStatus() as + state:"starting" + migration, and a terminal "running" status must not carry it. + */ + it("publishes migration progress while starting and clears it when running", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const server = new FakeServer(4550); + let releaseStore: () => void = () => {}; + const storeGate = new Promise((resolve) => { + releaseStore = resolve; + }); + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async (_rootDir, onMigrationProgress) => { + onMigrationProgress?.({ + active: true, + phase: "table-progress", + label: "[3/12] project.tasks — 500/2000 rows", + }); + await storeGate; + return store; + }, + createDashboardServer: async () => { + setTimeout(() => server.emit("listening"), 0); + return server as unknown as Server; + }, + }); + + const startPromise = manager.startLocal(); + await vi.waitFor(() => { + expect(manager.getStatus()).toMatchObject({ + source: "embedded-local", + state: "starting", + migration: { + active: true, + phase: "table-progress", + label: "[3/12] project.tasks — 500/2000 rows", + }, + }); + }); + + releaseStore(); + const status = await startPromise; + expect(status.state).toBe("running"); + expect(status.migration).toBeUndefined(); + expect(manager.getStatus().migration).toBeUndefined(); + }); + it("returns external-cli status without starting embedded runtime", async () => { const { LocalRuntimeManager } = await import("../local-runtime.ts"); const manager = new LocalRuntimeManager({ diff --git a/packages/desktop/src/__tests__/local-server.test.ts b/packages/desktop/src/__tests__/local-server.test.ts index f2df3a07e4..f7472031ad 100644 --- a/packages/desktop/src/__tests__/local-server.test.ts +++ b/packages/desktop/src/__tests__/local-server.test.ts @@ -147,6 +147,8 @@ const mocks = vi.hoisted(() => { vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore, createTaskStoreForBackend: mocks.createTaskStoreForBackend, + /* FNXC:MigrationHoldingPage 2026-07-17-13:50: local-server.ts formats migration progress for the launch gate. */ + formatMigrationProgress: (event: { phase: string }) => `migration ${event.phase}`, CentralCore: mocks.CentralCore, PluginLoader: mocks.PluginLoader, ensureBundledPluginInstalled: mocks.ensureBundledPluginInstalled, diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index 61b2bd1c2f..26ac5854b8 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -30,12 +30,29 @@ function strace(msg: string): void { export type RuntimeSource = "embedded-local" | "external-cli" | "none"; export type RuntimeState = "stopped" | "starting" | "running" | "error"; +/* +FNXC:MigrationHoldingPage 2026-07-17-13:20: +The one-time SQLite→PostgreSQL migration runs inside createTaskStoreForBackend +BEFORE the embedded server listens, so during it the desktop window only shows +the static "Starting local Fusion runtime…" gate. Surface structured migration +progress through the runtime status → IPC getRuntimeStatus → +fusionShell.getState().localRuntime → DesktopLaunchGate poll, which renders it +and suspends its 30s startup timeout while progress advances. `label` is the +same formatMigrationProgress() string the CLI logs. +*/ +export interface DesktopMigrationProgress { + active: boolean; + phase: string; + label: string; +} + export interface DesktopRuntimeStatus { source: RuntimeSource; state: RuntimeState; port?: number; baseUrl?: string; error?: string; + migration?: DesktopMigrationProgress; } /* @@ -69,7 +86,7 @@ type RuntimeInstance = { export interface LocalRuntimeManagerOptions { rootDir: string; getExternalPort?: () => number | undefined; - createStore?: (rootDir: string) => Promise; + createStore?: (rootDir: string, onMigrationProgress?: (progress: DesktopMigrationProgress) => void) => Promise; createDashboardServer?: (store: TaskStoreLike, rootDir: string) => Promise; /** * FNXC:DesktopRuntime 2026-07-05-00:00: @@ -95,15 +112,23 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function createStoreDefault(rootDir: string): Promise { +async function createStoreDefault( + rootDir: string, + onMigrationProgress?: (progress: DesktopMigrationProgress) => void, +): Promise { // FNXC:BackendFlip 2026-06-26-14:40: // Consult the startup factory to boot a PostgreSQL-backed TaskStore. Post // default-flip: the factory boots embedded PG by default when DATABASE_URL // is unset and external PG when DATABASE_URL is set. The backend shutdown handle is stashed on the returned object so // the runtime manager's stop path can release the pool / stop an embedded // cluster. - const { createTaskStoreForBackend } = await import("@fusion/core"); - const backendBoot = await createTaskStoreForBackend({ rootDir }); + const { createTaskStoreForBackend, formatMigrationProgress } = await import("@fusion/core"); + const backendBoot = await createTaskStoreForBackend({ + rootDir, + /* FNXC:MigrationHoldingPage 2026-07-17-13:20: forward the SQLite→PG migration stream so the launch gate can show live progress instead of a silent multi-minute "Starting…". */ + onMigrationProgress: (event) => + onMigrationProgress?.({ active: true, phase: event.phase, label: formatMigrationProgress(event) }), + }); /* FNXC:PostgresDesktopRuntime 2026-07-14-18:34: Desktop startup must fail visibly if PostgreSQL cannot boot; the removed opt-out must never construct an unbacked SQLite TaskStore. */ const store = backendBoot.taskStore as unknown as TaskStoreLike; // Attach the backend shutdown so LocalRuntimeManager can invoke it on stop. @@ -365,7 +390,7 @@ export class LocalRuntimeManager { private status: DesktopRuntimeStatus = { source: "none", state: "stopped" }; private readonly getExternalPort: () => number | undefined; - private readonly createStore: (rootDir: string) => Promise; + private readonly createStore: (rootDir: string, onMigrationProgress?: (progress: DesktopMigrationProgress) => void) => Promise; private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise; private readonly startupRetries: number; private readonly startupRetryDelayMs: number; @@ -489,7 +514,18 @@ export class LocalRuntimeManager { try { strace(`startEmbedded: BEGIN rootDir=${this.options.rootDir}`); - store = await this.createStore(this.options.rootDir); + /* + FNXC:MigrationHoldingPage 2026-07-17-13:20: + Publish live migration progress into the "starting" status so the renderer's + DesktopLaunchGate (polling getRuntimeStatus every 250ms) can show it. Only + overwrite while still starting — a late/stale event must never clobber a + terminal running/error status. + */ + store = await this.createStore(this.options.rootDir, (progress) => { + if (this.status.state === "starting") { + this.status = { source: "embedded-local", state: "starting", migration: progress }; + } + }); strace("startEmbedded: store.init"); await store.init(); strace("startEmbedded: store.watch"); diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index 8be19b8635..aeaf1ad915 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -38,6 +38,8 @@ export interface DesktopLocalServerState { status: "idle" | "starting" | "ready" | "error"; port?: number; error?: string | null; + /* FNXC:MigrationHoldingPage 2026-07-17-13:25: mirrors local-runtime.ts — live SQLite→PG migration progress while status is "starting". */ + migration?: { active: boolean; phase: string; label: string }; } export class DesktopLocalServerManager { @@ -67,7 +69,7 @@ export class DesktopLocalServerManager { let cleanup: RuntimeCleanup | undefined; try { - const { createTaskStoreForBackend } = await import("@fusion/core"); + const { createTaskStoreForBackend, formatMigrationProgress } = await import("@fusion/core"); const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core"); const { createServer } = await import("@fusion/dashboard"); const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine"); @@ -75,7 +77,19 @@ export class DesktopLocalServerManager { // Consult the startup factory to boot a PostgreSQL-backed TaskStore. // Post default-flip: the factory boots embedded PG by default when // DATABASE_URL is unset and external PG when DATABASE_URL is set. - const backendBoot = await createTaskStoreForBackend({ rootDir: this.rootDir }); + const backendBoot = await createTaskStoreForBackend({ + rootDir: this.rootDir, + /* FNXC:MigrationHoldingPage 2026-07-17-13:25: keep this legacy path in sync with local-runtime.ts — publish live SQLite→PG migration progress while starting. */ + onMigrationProgress: (event) => { + if (this.state.status === "starting") { + this.state = { + status: "starting", + error: null, + migration: { active: true, phase: event.phase, label: formatMigrationProgress(event) }, + }; + } + }, + }); /* FNXC:PostgresDesktopRuntime 2026-07-14-18:34: The legacy local-server entrypoint shares the same mandatory PostgreSQL startup contract as the primary desktop runtime. */ store = backendBoot.taskStore as unknown as TaskStoreLike; (store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown = diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 691bdcae93..f8add89be8 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1144,7 +1144,8 @@ "backendError": { "failedFetch": "Failed to fetch projects" }, - "testMode": "Test mode — no real AI calls" + "testMode": "Test mode — no real AI calls", + "migrationInProgress": "Database migration in progress — the dashboard will reconnect when it completes." }, "appName": "Fusion", "approval": { @@ -1994,6 +1995,7 @@ "connectRemoteButton": "Connect to Remote Fusion", "couldNotStart": "Couldn't start local Fusion", "loading": "Loading Fusion…", + "migrationInProgress": "Database migration in progress — this can take a few minutes.", "opening": "Opening…", "retry": "Retry", "runLocalButton": "Run Fusion Locally", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index a33cbeda77..d427fb85e1 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1134,7 +1134,8 @@ "backendError": { "failedFetch": "Error al obtener proyectos" }, - "testMode": "Modo de prueba — sin llamadas reales de IA" + "testMode": "Modo de prueba — sin llamadas reales de IA", + "migrationInProgress": "Migración de base de datos en curso — el panel se reconectará cuando termine." }, "appName": "", "approval": { @@ -1984,6 +1985,7 @@ "connectRemoteButton": "Conectarse a Fusion remoto", "couldNotStart": "No se puede iniciar Fusion local", "loading": "Cargando Fusion…", + "migrationInProgress": "Migración de base de datos en curso — puede tardar unos minutos.", "opening": "Abriendo…", "retry": "Reintentar", "runLocalButton": "Ejecutar Fusion localmente", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 0db0d04367..c25bd90316 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1134,7 +1134,8 @@ "backendError": { "failedFetch": "Impossible de récupérer les projets" }, - "testMode": "Mode test — pas d'appels IA réels" + "testMode": "Mode test — pas d'appels IA réels", + "migrationInProgress": "Migration de la base de données en cours — le tableau de bord se reconnectera une fois terminée." }, "appName": "", "approval": { @@ -1984,6 +1985,7 @@ "connectRemoteButton": "Se connecter à Fusion distant", "couldNotStart": "Impossible de démarrer Fusion local", "loading": "Chargement de Fusion…", + "migrationInProgress": "Migration de la base de données en cours — cela peut prendre quelques minutes.", "opening": "Ouverture…", "retry": "Réessayer", "runLocalButton": "Exécuter Fusion localement", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index eabcc3c075..476441c169 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1134,7 +1134,8 @@ "backendError": { "failedFetch": "프로젝트를 불러오지 못했습니다" }, - "testMode": "테스트 모드 — 실제 AI 호출 없음" + "testMode": "테스트 모드 — 실제 AI 호출 없음", + "migrationInProgress": "데이터베이스 마이그레이션 진행 중 — 완료되면 대시보드가 다시 연결됩니다." }, "appName": "", "approval": { @@ -1984,6 +1985,7 @@ "connectRemoteButton": "원격 Fusion에 연결", "couldNotStart": "로컬 Fusion을 시작할 수 없습니다", "loading": "Fusion 로드 중…", + "migrationInProgress": "데이터베이스 마이그레이션 진행 중 — 몇 분 정도 걸릴 수 있습니다.", "opening": "열고 있는 중…", "retry": "다시 시도", "runLocalButton": "로컬에서 Fusion 실행", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index cf46bd59d7..e4e4de4104 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1134,7 +1134,8 @@ "backendError": { "failedFetch": "获取项目失败" }, - "testMode": "测试模式 — 无实际 AI 调用" + "testMode": "测试模式 — 无实际 AI 调用", + "migrationInProgress": "数据库迁移进行中 — 完成后仪表盘将自动重新连接。" }, "appName": "", "approval": { @@ -1984,6 +1985,7 @@ "connectRemoteButton": "连接到远程 Fusion", "couldNotStart": "无法启动本地 Fusion", "loading": "正在加载 Fusion…", + "migrationInProgress": "数据库迁移进行中 — 可能需要几分钟。", "opening": "正在打开…", "retry": "重试", "runLocalButton": "本地运行 Fusion", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 46674c0e85..173a2b2e7b 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1134,7 +1134,8 @@ "backendError": { "failedFetch": "取得項目失敗" }, - "testMode": "測試模式 — 無實際 AI 呼叫" + "testMode": "測試模式 — 無實際 AI 呼叫", + "migrationInProgress": "資料庫遷移進行中 — 完成後儀表板將自動重新連線。" }, "appName": "", "approval": { @@ -1984,6 +1985,7 @@ "connectRemoteButton": "連接到遠端 Fusion", "couldNotStart": "無法啟動本機 Fusion", "loading": "正在載入 Fusion…", + "migrationInProgress": "資料庫遷移進行中 — 可能需要幾分鐘。", "opening": "正在開啟…", "retry": "重試", "runLocalButton": "本機執行 Fusion",