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:
7
.changeset/FN-8399-migration-status-dashboard.md
Normal file
7
.changeset/FN-8399-migration-status-dashboard.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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`/
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user