FN-8399: expose incomplete migration status on dashboard

Expose durable SQLite-to-PostgreSQL migration state through dashboard health and banners.

- Read per-project running and failed migration markers from PostgreSQL
- Surface degraded migration state in health endpoints and dashboard banners
- Preserve migration context across CLI and runtime startup paths
- Document the recovery workflow and add a patch changeset

Files changed:
 .changeset/FN-8399-migration-status-dashboard.md   |  7 +++
 docs/storage.md                                    |  5 ++
 packages/cli/src/commands/daemon.ts                | 19 +++++++
 packages/cli/src/commands/desktop.ts               |  6 +++
 packages/cli/src/commands/serve.ts                 | 20 +++++++-
 packages/core/src/index.ts                         |  2 +
 packages/core/src/postgres/index.ts                |  2 +
 packages/core/src/postgres/sqlite-migrator.ts      | 43 ++++++++++++++++
 packages/dashboard/app/api/health.ts               |  7 ++-
 .../app/components/dashboard/DashboardBanners.tsx  | 18 ++++++-
 .../dashboard/__tests__/DashboardBanners.test.tsx  | 12 ++++-
 .../__tests__/dashboard-postgres-health.test.ts    | 45 +++++++++++++++++
 .../dashboard/src/dashboard-postgres-health.ts     | 58 ++++++++++++++++++++++
 packages/dashboard/src/server.ts                   | 27 ++++++++--
 packages/engine/src/project-engine-manager.ts      |  4 ++
 packages/engine/src/project-runtime.ts             |  9 +++-
 packages/engine/src/runtimes/in-process-runtime.ts |  1 +
 17 files changed, 275 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8399

Fusion-Task-Lineage: e196d6c4-ea9a-48ba-bedc-9e6fa44c33d3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 16:52:02 -07:00
parent a9c7a6bcc0
commit e4a032d9d9
17 changed files with 275 additions and 10 deletions

View File

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

View File

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

View File

@@ -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<void>((resolve, reject) => {

View File

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

View File

@@ -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<void>((resolve, reject) => {

View File

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

View File

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

View File

@@ -459,6 +459,49 @@ async function ensureMigrationStateTable(db: PostgresJsDatabase<Record<string, n
)`));
}
export interface SqliteMigrationState {
migrationKey: string;
projectId: string | null;
status: "running" | "complete" | "failed";
lastError: string | null;
updatedAt: Date | string;
}
/**
* FNXC:MigrationStatusDashboard 2026-07-19-12:25:
* Dashboard health reads the authoritative per-project cutover marker after
* listen. A running or failed marker is never aged out here: progress ticks do
* not update updated_at, and post-listen running means completion was missed.
*/
export async function getSqliteMigrationState(
db: PostgresJsDatabase<Record<string, never>>,
migrationKey: string,
): Promise<SqliteMigrationState | null> {
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<Record<string, never>>,

View File

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

View File

@@ -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. */}
<MigrationInProgressBanner
isActive={dashboardHealth?.status === "migrating"}
isActive={isMigrationStatusBannerActive(dashboardHealth)}
progressLabel={dashboardHealth?.migration?.label}
/>
{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. */}
<CliBinaryInstallBanner
onOpenSettings={() => openSettingsWithNav("general" as SectionId)}
/>

View File

@@ -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> = {}): AiSessionSummary {
return {

View File

@@ -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<typeof import("@fusion/core")>()),
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;

View File

@@ -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<TaskStore["getDatabaseHealth"]>;
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<DashboardPostgresHealthResult> {
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<DashboardMigrationHealth | undefined> {
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,

View File

@@ -127,13 +127,15 @@ function buildTaskIdIntegrityHealth(report: DashboardTaskIdIntegrityHealth) {
function buildHealthPayload(args: {
database: ReturnType<TaskStore["getDatabaseHealth"]>;
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:<projectId> 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),
}));

View File

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

View File

@@ -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`/

View File

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