diff --git a/.changeset/FN-8399-migration-status-dashboard.md b/.changeset/FN-8399-migration-status-dashboard.md new file mode 100644 index 0000000000..1f591480dd --- /dev/null +++ b/.changeset/FN-8399-migration-status-dashboard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show SQLite→PostgreSQL migration status on the dashboard while cutover is not done. +category: fix +dev: Wire migration holding plus progress on fn serve and fixed-port daemon; expose durable running or failed migration state through health and dashboard banners. diff --git a/docs/storage.md b/docs/storage.md index 5082848174..1207783d6d 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -4,6 +4,11 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-review-2026-07-14.md) for the audited authority inventory, exact authorized legacy readers, and deployment/rollback checklist. +## SQLite→PostgreSQL cutover status + +- During a first-boot cutover, `fn dashboard`, `fn serve`, and `fn daemon --port ` keep their known HTTP port available with a migration holding page. Open dashboard tabs poll `/api/health` and show the migration banner with live progress. +- After a successful cutover, the usual dismissible data-migrated notice may appear. If the durable cutover marker remains `running` or `failed`, real-server `/api/health` reports `status: "degraded"` with migration detail and the dashboard keeps the migration banner visible. Do not delete retained legacy `.fusion/fusion.db` backups; check logs and run `fn db migrate` after fixing a failure. + ## Embedded PostgreSQL startup resources - The zero-config embedded PostgreSQL lifecycle uses mmap-backed primary shared memory to avoid exhausted SysV shared-memory IDs on constrained hosts. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 19774011a1..59338bd4cf 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -78,6 +78,7 @@ import { resolveSelfExtension } from "./self-extension.js"; import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; +import { startMigrationHoldingServer } from "./migration-holding-server.js"; import { ensureBundledDependencyGraphPluginInstalled, ensureBundledGrokRuntimePluginInstalled } from "../plugins/bundled-plugin-install.js"; import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; @@ -262,6 +263,21 @@ export async function runDaemon(opts: DaemonOptions = {}) { const selectedHost = opts.host ?? "127.0.0.1"; const cwd = await resolveRuntimeProjectPath(); + /* + FNXC:MigrationHoldingPage 2026-07-19-12:10: + A daemon with an explicit port has an operator-reachable HTTP address while + engine startup may run the SQLite→PostgreSQL cutover, so bind the holding + server before starting engines and forward runtime progress. Port 0 is + deliberately excluded: no browser can know the ephemeral URL before listen. + */ + const migrationHoldingServer = selectedPort > 0 + ? await startMigrationHoldingServer({ + port: selectedPort, + host: selectedHost, + log: (message) => console.log(`[daemon] ${message}`), + }) + : null; + // ── CentralCore: global coordination + ntfy project ID lookup ───────── let ntfyProjectId: string | undefined; let sharedCentralCore: CentralCore | null = null; @@ -345,6 +361,7 @@ export async function runDaemon(opts: DaemonOptions = {}) { const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion; const engineManager = new ProjectEngineManager(sharedCentralCore, { + onMigrationProgress: (event) => migrationHoldingServer?.setMigrationProgress(event), cliPackageVersion, getMergeStrategy, processPullRequestMerge: (s, wd, taskId, pool) => @@ -910,6 +927,8 @@ export async function runDaemon(opts: DaemonOptions = {}) { https: loadTlsCredentialsFromEnv(), }); + // The holding server owns a fixed daemon port only through engine/store boot. + await migrationHoldingServer?.close(); const server = app.listen(selectedPort, selectedHost); await new Promise((resolve, reject) => { diff --git a/packages/cli/src/commands/desktop.ts b/packages/cli/src/commands/desktop.ts index fd26e42cdc..26d821a226 100644 --- a/packages/cli/src/commands/desktop.ts +++ b/packages/cli/src/commands/desktop.ts @@ -36,6 +36,12 @@ async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: b // factory (embedded by default, external via DATABASE_URL), mirroring dashboard.ts. // FNXC:PostgresFinalCutover 2026-07-14-17:20: Desktop startup has no SQLite // fallback; an obsolete backend opt-out fails explicitly in the factory. + /* + FNXC:MigrationHoldingPage 2026-07-19-12:15: + CLI desktop intentionally does not use the HTTP holding server: it listens on + port 0 only after factory boot, so no fixed browser URL exists during cutover. + The Electron local-runtime/DesktopLaunchGate path owns launch progress instead. + */ const boot = await createTaskStoreForBackend({ rootDir }); const backendShutdown = boot.shutdown; const store: TaskStore = boot.taskStore; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 11d9ffc6e2..06d6d9000d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -58,6 +58,7 @@ import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; +import { startMigrationHoldingServer } from "./migration-holding-server.js"; import { ensureClaudeSkillsForAllProjectsOnStartup, maybeInstallClaudeSkillForNewProject, @@ -271,6 +272,18 @@ export async function runServe( const selectedHost = opts.host ?? "127.0.0.1"; const cwd = await resolveRuntimeProjectPath(); + /* + FNXC:MigrationHoldingPage 2026-07-19-12:05: + `fn serve` owns a known operator-facing port before its direct backend factory + boot. Hold that port through SQLite→PostgreSQL cutover and forward progress, + then release it immediately before the real listener binds. Bind failure is soft. + */ + const migrationHoldingServer = await startMigrationHoldingServer({ + port: selectedPort, + host: selectedHost, + log: (message) => console.log(`[serve] ${message}`), + }); + // ── CentralCore: global coordination + ntfy project ID lookup ───────── // // Created once and reused for: @@ -301,7 +314,10 @@ export async function runServe( const logPhase = (message: string, scope = "serve") => console.log(`[${scope}] ${message}`); const centralBootResult = await phaseTime( "backend.factory", - () => createTaskStoreForBackend({ rootDir: cwd }), + () => createTaskStoreForBackend({ + rootDir: cwd, + onMigrationProgress: (event) => migrationHoldingServer?.setMigrationProgress(event), + }), logPhase, ); /* @@ -1048,6 +1064,8 @@ export async function runServe( https: loadTlsCredentialsFromEnv(), }); + // Release the temporary owner before the production listener claims this port. + await migrationHoldingServer?.close(); const server = app.listen(selectedPort, selectedHost); await new Promise((resolve, reject) => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 92b4c8d39f..60c6d80490 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2424,6 +2424,7 @@ export { CENTRAL_BACKUP_SCHEMAS, migrateSqliteToPostgres, isSqliteMigrationComplete, + getSqliteMigrationState, completeSqliteMigration, defaultMigrationSources, formatMigrationProgress, @@ -2475,6 +2476,7 @@ export type { PgBackupPair, PgDumpResult, SqliteMigrationSource, + SqliteMigrationState, SchemaName, MigrationReport, MigrationProgressEvent, diff --git a/packages/core/src/postgres/index.ts b/packages/core/src/postgres/index.ts index 0c0e9de497..0220c95257 100644 --- a/packages/core/src/postgres/index.ts +++ b/packages/core/src/postgres/index.ts @@ -176,6 +176,7 @@ export { export { migrateSqliteToPostgres, isSqliteMigrationComplete, + getSqliteMigrationState, completeSqliteMigration, defaultMigrationSources, formatMigrationProgress, @@ -183,6 +184,7 @@ export { type SqliteMigrationSource, type SchemaName, type MigrationOptions, + type SqliteMigrationState, type MigrationReport, type MigrationProgressEvent, type MigrationProgressPhase, diff --git a/packages/core/src/postgres/sqlite-migrator.ts b/packages/core/src/postgres/sqlite-migrator.ts index bfe341397c..571c94efa5 100644 --- a/packages/core/src/postgres/sqlite-migrator.ts +++ b/packages/core/src/postgres/sqlite-migrator.ts @@ -459,6 +459,49 @@ async function ensureMigrationStateTable(db: PostgresJsDatabase>, + migrationKey: string, +): Promise { + await ensureMigrationStateTable(db); + const rows = (await db.execute(sql` + SELECT migration_key, project_id, status, last_error, updated_at + FROM public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + WHERE migration_key = ${migrationKey} + LIMIT 1 + `)) as unknown as Array<{ + migration_key: string; + project_id: string | null; + status: "running" | "complete" | "failed"; + last_error: string | null; + updated_at: Date | string; + }>; + const row = rows[0]; + return row + ? { + migrationKey: row.migration_key, + projectId: row.project_id, + status: row.status, + lastError: row.last_error, + updatedAt: row.updated_at, + } + : null; +} + /** Return true only after a fully verified cutover records its durable marker. */ export async function isSqliteMigrationComplete( db: PostgresJsDatabase>, diff --git a/packages/dashboard/app/api/health.ts b/packages/dashboard/app/api/health.ts index 179daa0b14..1ab251dcb4 100644 --- a/packages/dashboard/app/api/health.ts +++ b/packages/dashboard/app/api/health.ts @@ -33,7 +33,8 @@ export interface DashboardHealthResponse { "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`. + engine/db-corruption banners. After real listen, durable incomplete migration + status is attached so the cutover banner remains visible while degraded. */ holding?: boolean; migration?: { @@ -45,6 +46,10 @@ export interface DashboardHealthResponse { tableCount?: number; processedRows?: number; sourceRows?: number; + durableStatus?: "running" | "failed"; + lastError?: string | null; + migrationKey?: string; + updatedAt?: string; }; engine?: { available: boolean; diff --git a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx index 4c54993aee..c738566af4 100644 --- a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx +++ b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx @@ -3,6 +3,7 @@ FNXC:DashboardBanners 2026-06-24-00:00: DashboardBanners is the conditional banner cluster rendered above the dashboard-project-shell, extracted verbatim from AppInner's main return JSX. It is a pure render of the same gated banners (every condition, prop, FNXC comment, and the TaskIdIntegrityBanner setDashboardHealth updater preserved byte-for-byte); the banner components are imported directly from their siblings. */ import type { DashboardBannersProps } from "./types"; +import type { DashboardHealthResponse } from "../../api/health"; import type { SectionId } from "../SettingsModal"; import { TestModeBanner } from "../TestModeBanner"; import { MigrationInProgressBanner } from "../MigrationInProgressBanner"; @@ -22,6 +23,19 @@ import { SetupWarningBanner } from "../SetupWarningBanner"; import { ApprovalNotificationBanner } from "../ApprovalNotificationBanner"; import { GitHubStarPrompt } from "../GitHubStarPrompt"; +/* +FNXC:MigrationStatusDashboard 2026-07-19-12:35: +Boot-window progress reports migrating, while a real listener reports durable +running/failed migration as degraded. Keep this pure predicate as the single +banner authority so neither incomplete cutover state becomes invisible. +*/ +export function isMigrationStatusBannerActive(health: DashboardHealthResponse | null | undefined): boolean { + if (!health) return false; + if (health.status === "migrating") return true; + const durable = health.migration?.durableStatus; + return durable === "failed" || durable === "running"; +} + function isMailboxApprovalCandidate(candidate: DashboardBannersProps["approvalBannerCandidate"]): boolean { return candidate?.dedupeKey.startsWith("approval:") === true; } @@ -83,7 +97,7 @@ export function DashboardBanners({ must still explain the outage. Clears on the next health poll of the real server. */} {viewMode === "project" && currentProject && ( @@ -123,7 +137,7 @@ export function DashboardBanners({ )} {viewMode === "project" && currentProject && ( <> - {/* FNXC:PostgresMigrationNotice 2026-07-14-18:36: The PostgreSQL cutover is complete, so the dashboard must not advertise it as a future release. Active migration failures remain visible through SqliteMigrationBanner above. */} + {/* FNXC:PostgresMigrationNotice 2026-07-19-12:35: SqliteMigrationBanner is success-only. Incomplete cutover state is owned by MigrationInProgressBanner above, including durable degraded health. */} openSettingsWithNav("general" as SectionId)} /> diff --git a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx index 692ccf3e68..954def2740 100644 --- a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx +++ b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx @@ -64,7 +64,17 @@ vi.mock("../../ApprovalNotificationBanner", () => ({ })); vi.mock("../../GitHubStarPrompt", () => ({ GitHubStarPrompt: () => null })); -import { DashboardBanners } from "../DashboardBanners"; +import { DashboardBanners, isMigrationStatusBannerActive } from "../DashboardBanners"; + +describe("isMigrationStatusBannerActive", () => { + it("covers boot progress and durable failed/running health without false positives", () => { + expect(isMigrationStatusBannerActive({ status: "migrating" } as any)).toBe(true); + expect(isMigrationStatusBannerActive({ status: "degraded", migration: { active: false, durableStatus: "failed" } } as any)).toBe(true); + expect(isMigrationStatusBannerActive({ status: "degraded", migration: { active: false, durableStatus: "running" } } as any)).toBe(true); + expect(isMigrationStatusBannerActive({ status: "ok", migration: { active: false } } as any)).toBe(false); + expect(isMigrationStatusBannerActive(undefined)).toBe(false); + }); +}); function buildSession(overrides: Partial = {}): AiSessionSummary { return { diff --git a/packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts b/packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts index b0c88b904a..984265a9a5 100644 --- a/packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts +++ b/packages/dashboard/src/__tests__/dashboard-postgres-health.test.ts @@ -4,12 +4,14 @@ import type { AsyncDataLayer, TaskStore } from "@fusion/core"; const healthMocks = vi.hoisted(() => ({ checkPostgresHealth: vi.fn(), detectTaskIdIntegrityAnomaliesAsync: vi.fn(), + getSqliteMigrationState: vi.fn(), })); vi.mock("@fusion/core", async (importOriginal) => ({ ...(await importOriginal()), checkPostgresHealth: healthMocks.checkPostgresHealth, detectTaskIdIntegrityAnomaliesAsync: healthMocks.detectTaskIdIntegrityAnomaliesAsync, + getSqliteMigrationState: healthMocks.getSqliteMigrationState, })); import { @@ -32,6 +34,7 @@ describe("evaluateDashboardPostgresHealth", () => { checkedAt: "2026-07-14T23:45:00.000Z", anomalies: [], }); + healthMocks.getSqliteMigrationState.mockResolvedValue(null); }); it("derives and probes the PostgreSQL layer owned by TaskStore", async () => { @@ -45,6 +48,48 @@ describe("evaluateDashboardPostgresHealth", () => { expect(result.taskIdIntegrity.status).toBe("ok"); }); + it("surfaces durable failed and running cutovers without an age threshold", async () => { + const store = { getAsyncLayer: () => layer, getRootDir: () => "/repo" } as TaskStore; + for (const status of ["failed", "running"] as const) { + healthMocks.getSqliteMigrationState.mockResolvedValueOnce({ + migrationKey: "project:/repo", + projectId: null, + status, + lastError: status === "failed" ? "copy failed" : null, + updatedAt: "2000-01-01T00:00:00.000Z", + }); + const result = await evaluateDashboardPostgresHealth(store); + expect(result.migration).toMatchObject({ active: false, durableStatus: status, phase: status }); + expect(result.migration?.label).toBeTruthy(); + } + }); + + it("uses the typed bound project id before the root-directory fallback", async () => { + const store = { getAsyncLayer: () => layer, getRootDir: () => "/repo" } as TaskStore; + healthMocks.getSqliteMigrationState.mockResolvedValue({ + migrationKey: "project:daemon-project", + projectId: "daemon-project", + status: "failed", + lastError: "copy failed", + updatedAt: "2000-01-01T00:00:00.000Z", + }); + + const result = await evaluateDashboardPostgresHealth(store, undefined, { + projectId: "daemon-project", + }); + + expect(healthMocks.getSqliteMigrationState).toHaveBeenCalledWith(layer.db, "project:daemon-project"); + expect(result.migration).toMatchObject({ migrationKey: "project:daemon-project", durableStatus: "failed" }); + }); + + it("omits migration chrome for a complete marker", async () => { + const store = { getAsyncLayer: () => layer, getRootDir: () => "/repo" } as TaskStore; + healthMocks.getSqliteMigrationState.mockResolvedValue({ + migrationKey: "project:/repo", projectId: null, status: "complete", lastError: null, updatedAt: new Date(), + }); + expect((await evaluateDashboardPostgresHealth(store)).migration).toBeUndefined(); + }); + it("uses an explicit integration layer for health and compaction without consulting TaskStore", () => { const getAsyncLayer = vi.fn(() => null); const store = { getAsyncLayer } as unknown as TaskStore; diff --git a/packages/dashboard/src/dashboard-postgres-health.ts b/packages/dashboard/src/dashboard-postgres-health.ts index 0914035293..b0f5ba5bcc 100644 --- a/packages/dashboard/src/dashboard-postgres-health.ts +++ b/packages/dashboard/src/dashboard-postgres-health.ts @@ -1,5 +1,7 @@ +import { resolve as resolvePath } from "node:path"; import { checkPostgresHealth, + getSqliteMigrationState, detectTaskIdIntegrityAnomaliesAsync, type AsyncDataLayer, type TaskIdIntegrityReport, @@ -15,9 +17,25 @@ export type DashboardTaskIdIntegrityHealth = error: string; }; +export interface DashboardMigrationHealth { + active: false; + durableStatus: "running" | "failed"; + phase: "running" | "failed"; + label: string; + lastError: string | null; + migrationKey: string; + updatedAt: string; +} + export interface DashboardPostgresHealthResult { database: ReturnType; taskIdIntegrity: DashboardTaskIdIntegrityHealth; + migration?: DashboardMigrationHealth; +} + +/** Typed server-owned context for health probes that need project partitioning. */ +export interface DashboardPostgresHealthContext { + projectId?: string; } /** Resolve the production TaskStore layer while retaining an explicit integration override. */ @@ -39,6 +57,7 @@ The dashboard health surface is a PostgreSQL readiness signal, not a legacy SQLi export async function evaluateDashboardPostgresHealth( store: TaskStore, overrideLayer?: AsyncDataLayer, + context?: DashboardPostgresHealthContext, ): Promise { const checkedAt = new Date(); let layer: AsyncDataLayer | null = null; @@ -58,9 +77,11 @@ export async function evaluateDashboardPostgresHealth( try { const taskIdIntegrity = await detectTaskIdIntegrityAnomaliesAsync(layer.db); + const migration = await resolveDashboardMigrationHealth(store, layer, context); return { database: healthyDatabase(checkedAt), taskIdIntegrity, + ...(migration ? { migration } : {}), }; } catch (error) { return failedHealth( @@ -70,6 +91,43 @@ export async function evaluateDashboardPostgresHealth( } } +/* +FNXC:MigrationStatusDashboard 2026-07-19-14:30: +After the real server is listening, durable running and failed markers are +incomplete cutovers, not live progress. The typed server context carries the +engine's bound project id, because TaskStore does not promise an ad-hoc getter; +otherwise preserve startup-factory's absolute-root migration-key shape. No +updated_at age threshold is valid because progress does not refresh it. +*/ +async function resolveDashboardMigrationHealth( + store: TaskStore, + layer: AsyncDataLayer, + context?: DashboardPostgresHealthContext, +): Promise { + const boundProjectId = context?.projectId?.trim() || undefined; + const rootDir = typeof store.getRootDir === "function" ? store.getRootDir() : undefined; + // Compatibility-only test/integration stores without a root cannot identify a project marker. + if (!boundProjectId && !rootDir) return undefined; + const migrationKey = boundProjectId + ? `project:${boundProjectId}` + : `project:${resolvePath(rootDir!)}`; + const state = await getSqliteMigrationState(layer.db, migrationKey); + if (state?.status !== "running" && state?.status !== "failed") return undefined; + const status = state.status; + const lastError = state.lastError; + return { + active: false, + durableStatus: status, + phase: status, + label: status === "running" + ? "SQLite → PostgreSQL migration incomplete (status: running). Do not delete legacy .fusion/fusion.db backups. Re-run migration or check logs." + : `SQLite → PostgreSQL migration failed: ${lastError ?? "unknown error"}. Legacy SQLite files were retained as backups; see docs/storage.md and run 'fn db migrate' after fixing the error.`, + lastError, + migrationKey: state.migrationKey, + updatedAt: new Date(state.updatedAt).toISOString(), + }; +} + function healthyDatabase(checkedAt: Date): DashboardPostgresHealthResult["database"] { return { healthy: true, diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index af55acb604..7834d7ee3c 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -127,13 +127,15 @@ function buildTaskIdIntegrityHealth(report: DashboardTaskIdIntegrityHealth) { function buildHealthPayload(args: { database: ReturnType; taskIdIntegrityReport: DashboardTaskIdIntegrityHealth; + migration?: import("./dashboard-postgres-health.js").DashboardMigrationHealth; cliPackageVersion: string; engineAvailable: boolean; }) { - const { database, cliPackageVersion, engineAvailable } = args; + const { database, cliPackageVersion, engineAvailable, migration } = args; const taskIdIntegrity = buildTaskIdIntegrityHealth(args.taskIdIntegrityReport); return { - status: !database.healthy || database.corruptionDetected || taskIdIntegrity.status !== "ok" ? "degraded" : "ok", + // Durable running/failed migration markers must never be hidden behind ok health. + status: migration || !database.healthy || database.corruptionDetected || taskIdIntegrity.status !== "ok" ? "degraded" : "ok", version: cliPackageVersion, uptime: Math.floor(process.uptime()), /* @@ -145,6 +147,7 @@ function buildHealthPayload(args: { }, database, taskIdIntegrity, + ...(migration ? { migration } : {}), }; } @@ -1727,10 +1730,19 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT * unreachable backend surfaces degraded status + errors. */ app.get("/api/health", async (_req, res) => { - const health = await evaluateDashboardPostgresHealth(store, options?.postgresHealthLayer); + /* + FNXC:MigrationStatusDashboard 2026-07-19-14:30: + The daemon's TaskStore is bound to the engine project id, but TaskStore has + no project-id accessor. Pass that typed server identity to health so durable + migration markers use project: instead of a root-path fallback. + */ + const health = await evaluateDashboardPostgresHealth(store, options?.postgresHealthLayer, { + projectId: options?.engine?.getProjectId?.(), + }); res.json(buildHealthPayload({ database: health.database, taskIdIntegrityReport: health.taskIdIntegrity, + migration: health.migration, cliPackageVersion, engineAvailable: hasDashboardEngine(options), })); @@ -1931,11 +1943,18 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT * Force-recompute PostgreSQL connectivity and task-ID integrity through * the live TaskStore layer (or an explicit integration override). Query * failures remain visible as degraded health instead of healthy fallback. + * + * FNXC:MigrationStatusDashboard 2026-07-19-14:30: + * Preserve the engine's typed bound project identity on refresh so this + * endpoint queries the same durable project migration marker as GET health. */ - const health = await evaluateDashboardPostgresHealth(store, options?.postgresHealthLayer); + const health = await evaluateDashboardPostgresHealth(store, options?.postgresHealthLayer, { + projectId: options?.engine?.getProjectId?.(), + }); res.json(buildHealthPayload({ database: health.database, taskIdIntegrityReport: health.taskIdIntegrity, + migration: health.migration, cliPackageVersion, engineAvailable: hasDashboardEngine(options), })); diff --git a/packages/engine/src/project-engine-manager.ts b/packages/engine/src/project-engine-manager.ts index a008b7460c..6d62134b21 100644 --- a/packages/engine/src/project-engine-manager.ts +++ b/packages/engine/src/project-engine-manager.ts @@ -19,6 +19,7 @@ import type { CentralCore, TaskStore, RegisteredProject, + MigrationProgressEvent, } from "@fusion/core"; import { ProjectEngine } from "./project-engine.js"; import type { ProjectEngineOptions } from "./project-engine.js"; @@ -60,6 +61,8 @@ export interface EngineManagerOptions { * project root. Callers may still pass per-call overrides via ensureEngine. */ externalTaskStore?: ProjectEngineOptions["externalTaskStore"]; + /** Forward first-boot SQLite migration progress to a fixed-port holding server. */ + onMigrationProgress?: (event: MigrationProgressEvent) => void; } /** Default interval for background reconciliation (30 seconds). */ @@ -553,6 +556,7 @@ export class ProjectEngineManager { maxWorktrees: (settings?.maxWorktrees as number) ?? 10, // Shared global semaphore — all engines share one concurrency pool globalSemaphore: this.globalSemaphore, + onMigrationProgress: this.options.onMigrationProgress, }; } diff --git a/packages/engine/src/project-runtime.ts b/packages/engine/src/project-runtime.ts index 2b2b54b9cd..ea91b5c140 100644 --- a/packages/engine/src/project-runtime.ts +++ b/packages/engine/src/project-runtime.ts @@ -1,5 +1,5 @@ import type { EventEmitter } from "node:events"; -import type { TaskStore, Task, IsolationMode, ProjectSettings, GithubIssueAction } from "@fusion/core"; +import type { TaskStore, Task, IsolationMode, ProjectSettings, GithubIssueAction, MigrationProgressEvent } from "@fusion/core"; import type { Scheduler } from "./scheduler.js"; /** @@ -55,6 +55,13 @@ export interface ProjectRuntimeConfig { * Useful when the caller (e.g. dashboard.ts) owns and watches the store. */ externalTaskStore?: TaskStore; + /** + * FNXC:MigrationHoldingPage 2026-07-19-12:00: + * A fixed-port daemon binds a temporary holding server before engine startup. + * Forward factory migration progress through the runtime so that server can + * report live cutover status until the real dashboard listener takes over. + */ + onMigrationProgress?: (event: MigrationProgressEvent) => void; /** * PR-entity node GitHub ops (U3): the injected `createPr`/`mergePr`/`respond` * callbacks (+ source resolver + audit) for the `pr-create`/`pr-respond`/ diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 5a00763865..6a1602785b 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -314,6 +314,7 @@ export class InProcessRuntime const backendBoot = await createTaskStoreForBackend({ rootDir: this.config.workingDirectory, projectId: this.config.projectId, + onMigrationProgress: this.config.onMigrationProgress, }); // FNXC:PostgresFinalCutover 2026-07-14-17:20: Engine runtimes must fail // startup when PostgreSQL cannot boot; constructing a SQLite TaskStore is no longer valid.