feat(dashboard,desktop): show live database-migration progress during boot

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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-17 20:09:56 -07:00
parent 48b0d04322
commit 0b6c4cd4ca
24 changed files with 711 additions and 20 deletions

View File

@@ -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.

View File

@@ -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<Awaited<ReturnType<typeof startMigrationHoldingServer>>>) => Promise<void>,
): Promise<void> {
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();
}
});
});

View File

@@ -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) => {

View File

@@ -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<void>;
}
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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fusion — starting</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: #0b0e14; color: #e6e9ef;
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.card { max-width: 30rem; padding: 2rem; text-align: center; }
.spinner {
width: 2.25rem; height: 2.25rem; margin: 0 auto 1.25rem;
border: 3px solid rgba(59, 130, 246, 0.25); border-top-color: #3b82f6;
border-radius: 50%; animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
h1 { font-size: 1.15rem; margin: 0 0 0.5rem; font-weight: 600; }
p { margin: 0 0 0.75rem; color: #9aa4b2; }
.progress { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; color: #7fb1ff; min-height: 1.5em; overflow-wrap: anywhere; }
.offline { color: #f0883e; }
</style>
</head>
<body>
<div class="card" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
<h1 id="title">Fusion is starting…</h1>
<p id="subtitle">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.</p>
<div class="progress" id="progress"></div>
</div>
<script>
(function () {
var failures = 0;
var sawMigration = false;
async function poll() {
try {
var res = await fetch("/api/health", { cache: "no-store" });
if (!res.ok) return;
failures = 0;
var data = await res.json();
if (data && (data.status === "starting" || data.status === "migrating")) {
if (data.status === "migrating") {
sawMigration = true;
document.getElementById("title").textContent = "Database migration in progress";
}
var label = data.migration && data.migration.label;
if (label) document.getElementById("progress").textContent = label;
return;
}
location.reload();
} catch (err) {
failures += 1;
if (failures >= 8) {
document.getElementById("progress").innerHTML =
'<span class="offline">Cannot reach Fusion — it may have stopped. Check the terminal, then reload.</span>';
}
}
}
poll();
setInterval(poll, 1500);
})();
</script>
</body>
</html>`;
}
export async function startMigrationHoldingServer(
options: StartOptions,
): Promise<MigrationHoldingServer | null> {
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<boolean>((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<void> | null = null;
return {
port,
setMigrationProgress(event: MigrationProgressEvent): void {
latest = event;
},
close(): Promise<void> {
if (closed) return closed;
closed = new Promise<void>((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;
},
};
}

View File

@@ -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 */ }
},
});
/*

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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<ReturnType<typeof getFusionShell>>,
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 (
<div className="desktop-launch-gate" role="status" aria-live="polite">
<div className="desktop-launch-gate__panel">
<p>{message}</p>
{detail ? <p className="desktop-launch-gate__detail">{detail}</p> : null}
</div>
</div>
);
@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="migration-in-progress-banner" role="status" aria-live="polite">
<DatabaseZap aria-hidden="true" />
<span>
{t("app.migrationInProgress", "Database migration in progress — the dashboard will reconnect when it completes.")}
{progressLabel ? (
<span className="migration-in-progress-banner-progress">{progressLabel}</span>
) : null}
</span>
</div>
);
}

View File

@@ -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(
<DesktopLaunchGate>
<div data-testid="app-loaded">app</div>
</DesktopLaunchGate>,
);
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.

View File

@@ -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(<MigrationInProgressBanner isActive={false} progressLabel="ignored" />);
expect(container).toBeEmptyDOMElement();
});
it("renders status copy when active", () => {
render(<MigrationInProgressBanner isActive />);
expect(screen.getByRole("status")).toHaveTextContent("Database migration in progress");
});
it("shows the structured progress label when provided", () => {
render(<MigrationInProgressBanner isActive progressLabel="[3/12] project.tasks — 500/2000 rows" />);
expect(screen.getByRole("status")).toHaveTextContent("[3/12] project.tasks — 500/2000 rows");
});
});

View File

@@ -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. */}
<MigrationInProgressBanner
isActive={dashboardHealth?.status === "migrating"}
progressLabel={dashboardHealth?.migration?.label}
/>
{viewMode === "project" && currentProject && (
<>
<TestModeBanner isActive={isTestMode} />

View File

@@ -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;
};
};
}

View File

@@ -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<void>((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({

View File

@@ -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,

View File

@@ -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<TaskStoreLike>;
createStore?: (rootDir: string, onMigrationProgress?: (progress: DesktopMigrationProgress) => void) => Promise<TaskStoreLike>;
createDashboardServer?: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
/**
* FNXC:DesktopRuntime 2026-07-05-00:00:
@@ -95,15 +112,23 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
async function createStoreDefault(
rootDir: string,
onMigrationProgress?: (progress: DesktopMigrationProgress) => void,
): Promise<TaskStoreLike> {
// 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<TaskStoreLike>;
private readonly createStore: (rootDir: string, onMigrationProgress?: (progress: DesktopMigrationProgress) => void) => Promise<TaskStoreLike>;
private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
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");

View File

@@ -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<void> }).__backendShutdown =

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 실행",

View File

@@ -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",

View File

@@ -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",