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.
|
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
|
## 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.
|
- 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 { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
import { getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getPackageManagerAgentDir } from "./auth-paths.js";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
|
import { startMigrationHoldingServer } from "./migration-holding-server.js";
|
||||||
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledGrokRuntimePluginInstalled } from "../plugins/bundled-plugin-install.js";
|
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledGrokRuntimePluginInstalled } from "../plugins/bundled-plugin-install.js";
|
||||||
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
|
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
|
||||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.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 selectedHost = opts.host ?? "127.0.0.1";
|
||||||
const cwd = await resolveRuntimeProjectPath();
|
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 ─────────
|
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
|
||||||
let ntfyProjectId: string | undefined;
|
let ntfyProjectId: string | undefined;
|
||||||
let sharedCentralCore: CentralCore | null = null;
|
let sharedCentralCore: CentralCore | null = null;
|
||||||
@@ -345,6 +361,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion;
|
const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion;
|
||||||
|
|
||||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||||
|
onMigrationProgress: (event) => migrationHoldingServer?.setMigrationProgress(event),
|
||||||
cliPackageVersion,
|
cliPackageVersion,
|
||||||
getMergeStrategy,
|
getMergeStrategy,
|
||||||
processPullRequestMerge: (s, wd, taskId, pool) =>
|
processPullRequestMerge: (s, wd, taskId, pool) =>
|
||||||
@@ -910,6 +927,8 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
https: loadTlsCredentialsFromEnv(),
|
https: loadTlsCredentialsFromEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The holding server owns a fixed daemon port only through engine/store boot.
|
||||||
|
await migrationHoldingServer?.close();
|
||||||
const server = app.listen(selectedPort, selectedHost);
|
const server = app.listen(selectedPort, selectedHost);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
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.
|
// factory (embedded by default, external via DATABASE_URL), mirroring dashboard.ts.
|
||||||
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Desktop startup has no SQLite
|
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Desktop startup has no SQLite
|
||||||
// fallback; an obsolete backend opt-out fails explicitly in the factory.
|
// 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 boot = await createTaskStoreForBackend({ rootDir });
|
||||||
const backendShutdown = boot.shutdown;
|
const backendShutdown = boot.shutdown;
|
||||||
const store: TaskStore = boot.taskStore;
|
const store: TaskStore = boot.taskStore;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
|||||||
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
import { getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getPackageManagerAgentDir } from "./auth-paths.js";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
|
import { startMigrationHoldingServer } from "./migration-holding-server.js";
|
||||||
import {
|
import {
|
||||||
ensureClaudeSkillsForAllProjectsOnStartup,
|
ensureClaudeSkillsForAllProjectsOnStartup,
|
||||||
maybeInstallClaudeSkillForNewProject,
|
maybeInstallClaudeSkillForNewProject,
|
||||||
@@ -271,6 +272,18 @@ export async function runServe(
|
|||||||
const selectedHost = opts.host ?? "127.0.0.1";
|
const selectedHost = opts.host ?? "127.0.0.1";
|
||||||
const cwd = await resolveRuntimeProjectPath();
|
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 ─────────
|
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
|
||||||
//
|
//
|
||||||
// Created once and reused for:
|
// Created once and reused for:
|
||||||
@@ -301,7 +314,10 @@ export async function runServe(
|
|||||||
const logPhase = (message: string, scope = "serve") => console.log(`[${scope}] ${message}`);
|
const logPhase = (message: string, scope = "serve") => console.log(`[${scope}] ${message}`);
|
||||||
const centralBootResult = await phaseTime(
|
const centralBootResult = await phaseTime(
|
||||||
"backend.factory",
|
"backend.factory",
|
||||||
() => createTaskStoreForBackend({ rootDir: cwd }),
|
() => createTaskStoreForBackend({
|
||||||
|
rootDir: cwd,
|
||||||
|
onMigrationProgress: (event) => migrationHoldingServer?.setMigrationProgress(event),
|
||||||
|
}),
|
||||||
logPhase,
|
logPhase,
|
||||||
);
|
);
|
||||||
/*
|
/*
|
||||||
@@ -1048,6 +1064,8 @@ export async function runServe(
|
|||||||
https: loadTlsCredentialsFromEnv(),
|
https: loadTlsCredentialsFromEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Release the temporary owner before the production listener claims this port.
|
||||||
|
await migrationHoldingServer?.close();
|
||||||
const server = app.listen(selectedPort, selectedHost);
|
const server = app.listen(selectedPort, selectedHost);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@@ -2424,6 +2424,7 @@ export {
|
|||||||
CENTRAL_BACKUP_SCHEMAS,
|
CENTRAL_BACKUP_SCHEMAS,
|
||||||
migrateSqliteToPostgres,
|
migrateSqliteToPostgres,
|
||||||
isSqliteMigrationComplete,
|
isSqliteMigrationComplete,
|
||||||
|
getSqliteMigrationState,
|
||||||
completeSqliteMigration,
|
completeSqliteMigration,
|
||||||
defaultMigrationSources,
|
defaultMigrationSources,
|
||||||
formatMigrationProgress,
|
formatMigrationProgress,
|
||||||
@@ -2475,6 +2476,7 @@ export type {
|
|||||||
PgBackupPair,
|
PgBackupPair,
|
||||||
PgDumpResult,
|
PgDumpResult,
|
||||||
SqliteMigrationSource,
|
SqliteMigrationSource,
|
||||||
|
SqliteMigrationState,
|
||||||
SchemaName,
|
SchemaName,
|
||||||
MigrationReport,
|
MigrationReport,
|
||||||
MigrationProgressEvent,
|
MigrationProgressEvent,
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
migrateSqliteToPostgres,
|
migrateSqliteToPostgres,
|
||||||
isSqliteMigrationComplete,
|
isSqliteMigrationComplete,
|
||||||
|
getSqliteMigrationState,
|
||||||
completeSqliteMigration,
|
completeSqliteMigration,
|
||||||
defaultMigrationSources,
|
defaultMigrationSources,
|
||||||
formatMigrationProgress,
|
formatMigrationProgress,
|
||||||
@@ -183,6 +184,7 @@ export {
|
|||||||
type SqliteMigrationSource,
|
type SqliteMigrationSource,
|
||||||
type SchemaName,
|
type SchemaName,
|
||||||
type MigrationOptions,
|
type MigrationOptions,
|
||||||
|
type SqliteMigrationState,
|
||||||
type MigrationReport,
|
type MigrationReport,
|
||||||
type MigrationProgressEvent,
|
type MigrationProgressEvent,
|
||||||
type MigrationProgressPhase,
|
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. */
|
/** Return true only after a fully verified cutover records its durable marker. */
|
||||||
export async function isSqliteMigrationComplete(
|
export async function isSqliteMigrationComplete(
|
||||||
db: PostgresJsDatabase<Record<string, never>>,
|
db: PostgresJsDatabase<Record<string, never>>,
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export interface DashboardHealthResponse {
|
|||||||
"starting") and this progress snapshot — and OMITS engine/database/
|
"starting") and this progress snapshot — and OMITS engine/database/
|
||||||
taskIdIntegrity. Consumers of those fields must optional-chain (the
|
taskIdIntegrity. Consumers of those fields must optional-chain (the
|
||||||
DashboardBanners gates already do) so boot-window polls don't fire 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;
|
holding?: boolean;
|
||||||
migration?: {
|
migration?: {
|
||||||
@@ -45,6 +46,10 @@ export interface DashboardHealthResponse {
|
|||||||
tableCount?: number;
|
tableCount?: number;
|
||||||
processedRows?: number;
|
processedRows?: number;
|
||||||
sourceRows?: number;
|
sourceRows?: number;
|
||||||
|
durableStatus?: "running" | "failed";
|
||||||
|
lastError?: string | null;
|
||||||
|
migrationKey?: string;
|
||||||
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
engine?: {
|
engine?: {
|
||||||
available: boolean;
|
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.
|
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 { DashboardBannersProps } from "./types";
|
||||||
|
import type { DashboardHealthResponse } from "../../api/health";
|
||||||
import type { SectionId } from "../SettingsModal";
|
import type { SectionId } from "../SettingsModal";
|
||||||
import { TestModeBanner } from "../TestModeBanner";
|
import { TestModeBanner } from "../TestModeBanner";
|
||||||
import { MigrationInProgressBanner } from "../MigrationInProgressBanner";
|
import { MigrationInProgressBanner } from "../MigrationInProgressBanner";
|
||||||
@@ -22,6 +23,19 @@ import { SetupWarningBanner } from "../SetupWarningBanner";
|
|||||||
import { ApprovalNotificationBanner } from "../ApprovalNotificationBanner";
|
import { ApprovalNotificationBanner } from "../ApprovalNotificationBanner";
|
||||||
import { GitHubStarPrompt } from "../GitHubStarPrompt";
|
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 {
|
function isMailboxApprovalCandidate(candidate: DashboardBannersProps["approvalBannerCandidate"]): boolean {
|
||||||
return candidate?.dedupeKey.startsWith("approval:") === true;
|
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
|
must still explain the outage. Clears on the next health poll of the
|
||||||
real server. */}
|
real server. */}
|
||||||
<MigrationInProgressBanner
|
<MigrationInProgressBanner
|
||||||
isActive={dashboardHealth?.status === "migrating"}
|
isActive={isMigrationStatusBannerActive(dashboardHealth)}
|
||||||
progressLabel={dashboardHealth?.migration?.label}
|
progressLabel={dashboardHealth?.migration?.label}
|
||||||
/>
|
/>
|
||||||
{viewMode === "project" && currentProject && (
|
{viewMode === "project" && currentProject && (
|
||||||
@@ -123,7 +137,7 @@ export function DashboardBanners({
|
|||||||
)}
|
)}
|
||||||
{viewMode === "project" && currentProject && (
|
{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
|
<CliBinaryInstallBanner
|
||||||
onOpenSettings={() => openSettingsWithNav("general" as SectionId)}
|
onOpenSettings={() => openSettingsWithNav("general" as SectionId)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,7 +64,17 @@ vi.mock("../../ApprovalNotificationBanner", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("../../GitHubStarPrompt", () => ({ GitHubStarPrompt: () => null }));
|
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 {
|
function buildSession(overrides: Partial<AiSessionSummary> = {}): AiSessionSummary {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import type { AsyncDataLayer, TaskStore } from "@fusion/core";
|
|||||||
const healthMocks = vi.hoisted(() => ({
|
const healthMocks = vi.hoisted(() => ({
|
||||||
checkPostgresHealth: vi.fn(),
|
checkPostgresHealth: vi.fn(),
|
||||||
detectTaskIdIntegrityAnomaliesAsync: vi.fn(),
|
detectTaskIdIntegrityAnomaliesAsync: vi.fn(),
|
||||||
|
getSqliteMigrationState: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@fusion/core")>()),
|
...(await importOriginal<typeof import("@fusion/core")>()),
|
||||||
checkPostgresHealth: healthMocks.checkPostgresHealth,
|
checkPostgresHealth: healthMocks.checkPostgresHealth,
|
||||||
detectTaskIdIntegrityAnomaliesAsync: healthMocks.detectTaskIdIntegrityAnomaliesAsync,
|
detectTaskIdIntegrityAnomaliesAsync: healthMocks.detectTaskIdIntegrityAnomaliesAsync,
|
||||||
|
getSqliteMigrationState: healthMocks.getSqliteMigrationState,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +34,7 @@ describe("evaluateDashboardPostgresHealth", () => {
|
|||||||
checkedAt: "2026-07-14T23:45:00.000Z",
|
checkedAt: "2026-07-14T23:45:00.000Z",
|
||||||
anomalies: [],
|
anomalies: [],
|
||||||
});
|
});
|
||||||
|
healthMocks.getSqliteMigrationState.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives and probes the PostgreSQL layer owned by TaskStore", async () => {
|
it("derives and probes the PostgreSQL layer owned by TaskStore", async () => {
|
||||||
@@ -45,6 +48,48 @@ describe("evaluateDashboardPostgresHealth", () => {
|
|||||||
expect(result.taskIdIntegrity.status).toBe("ok");
|
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", () => {
|
it("uses an explicit integration layer for health and compaction without consulting TaskStore", () => {
|
||||||
const getAsyncLayer = vi.fn(() => null);
|
const getAsyncLayer = vi.fn(() => null);
|
||||||
const store = { getAsyncLayer } as unknown as TaskStore;
|
const store = { getAsyncLayer } as unknown as TaskStore;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { resolve as resolvePath } from "node:path";
|
||||||
import {
|
import {
|
||||||
checkPostgresHealth,
|
checkPostgresHealth,
|
||||||
|
getSqliteMigrationState,
|
||||||
detectTaskIdIntegrityAnomaliesAsync,
|
detectTaskIdIntegrityAnomaliesAsync,
|
||||||
type AsyncDataLayer,
|
type AsyncDataLayer,
|
||||||
type TaskIdIntegrityReport,
|
type TaskIdIntegrityReport,
|
||||||
@@ -15,9 +17,25 @@ export type DashboardTaskIdIntegrityHealth =
|
|||||||
error: string;
|
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 {
|
export interface DashboardPostgresHealthResult {
|
||||||
database: ReturnType<TaskStore["getDatabaseHealth"]>;
|
database: ReturnType<TaskStore["getDatabaseHealth"]>;
|
||||||
taskIdIntegrity: DashboardTaskIdIntegrityHealth;
|
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. */
|
/** 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(
|
export async function evaluateDashboardPostgresHealth(
|
||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
overrideLayer?: AsyncDataLayer,
|
overrideLayer?: AsyncDataLayer,
|
||||||
|
context?: DashboardPostgresHealthContext,
|
||||||
): Promise<DashboardPostgresHealthResult> {
|
): Promise<DashboardPostgresHealthResult> {
|
||||||
const checkedAt = new Date();
|
const checkedAt = new Date();
|
||||||
let layer: AsyncDataLayer | null = null;
|
let layer: AsyncDataLayer | null = null;
|
||||||
@@ -58,9 +77,11 @@ export async function evaluateDashboardPostgresHealth(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const taskIdIntegrity = await detectTaskIdIntegrityAnomaliesAsync(layer.db);
|
const taskIdIntegrity = await detectTaskIdIntegrityAnomaliesAsync(layer.db);
|
||||||
|
const migration = await resolveDashboardMigrationHealth(store, layer, context);
|
||||||
return {
|
return {
|
||||||
database: healthyDatabase(checkedAt),
|
database: healthyDatabase(checkedAt),
|
||||||
taskIdIntegrity,
|
taskIdIntegrity,
|
||||||
|
...(migration ? { migration } : {}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return failedHealth(
|
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"] {
|
function healthyDatabase(checkedAt: Date): DashboardPostgresHealthResult["database"] {
|
||||||
return {
|
return {
|
||||||
healthy: true,
|
healthy: true,
|
||||||
|
|||||||
@@ -127,13 +127,15 @@ function buildTaskIdIntegrityHealth(report: DashboardTaskIdIntegrityHealth) {
|
|||||||
function buildHealthPayload(args: {
|
function buildHealthPayload(args: {
|
||||||
database: ReturnType<TaskStore["getDatabaseHealth"]>;
|
database: ReturnType<TaskStore["getDatabaseHealth"]>;
|
||||||
taskIdIntegrityReport: DashboardTaskIdIntegrityHealth;
|
taskIdIntegrityReport: DashboardTaskIdIntegrityHealth;
|
||||||
|
migration?: import("./dashboard-postgres-health.js").DashboardMigrationHealth;
|
||||||
cliPackageVersion: string;
|
cliPackageVersion: string;
|
||||||
engineAvailable: boolean;
|
engineAvailable: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { database, cliPackageVersion, engineAvailable } = args;
|
const { database, cliPackageVersion, engineAvailable, migration } = args;
|
||||||
const taskIdIntegrity = buildTaskIdIntegrityHealth(args.taskIdIntegrityReport);
|
const taskIdIntegrity = buildTaskIdIntegrityHealth(args.taskIdIntegrityReport);
|
||||||
return {
|
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,
|
version: cliPackageVersion,
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
/*
|
/*
|
||||||
@@ -145,6 +147,7 @@ function buildHealthPayload(args: {
|
|||||||
},
|
},
|
||||||
database,
|
database,
|
||||||
taskIdIntegrity,
|
taskIdIntegrity,
|
||||||
|
...(migration ? { migration } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1727,10 +1730,19 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
* unreachable backend surfaces degraded status + errors.
|
* unreachable backend surfaces degraded status + errors.
|
||||||
*/
|
*/
|
||||||
app.get("/api/health", async (_req, res) => {
|
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({
|
res.json(buildHealthPayload({
|
||||||
database: health.database,
|
database: health.database,
|
||||||
taskIdIntegrityReport: health.taskIdIntegrity,
|
taskIdIntegrityReport: health.taskIdIntegrity,
|
||||||
|
migration: health.migration,
|
||||||
cliPackageVersion,
|
cliPackageVersion,
|
||||||
engineAvailable: hasDashboardEngine(options),
|
engineAvailable: hasDashboardEngine(options),
|
||||||
}));
|
}));
|
||||||
@@ -1931,11 +1943,18 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
* Force-recompute PostgreSQL connectivity and task-ID integrity through
|
* Force-recompute PostgreSQL connectivity and task-ID integrity through
|
||||||
* the live TaskStore layer (or an explicit integration override). Query
|
* the live TaskStore layer (or an explicit integration override). Query
|
||||||
* failures remain visible as degraded health instead of healthy fallback.
|
* 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({
|
res.json(buildHealthPayload({
|
||||||
database: health.database,
|
database: health.database,
|
||||||
taskIdIntegrityReport: health.taskIdIntegrity,
|
taskIdIntegrityReport: health.taskIdIntegrity,
|
||||||
|
migration: health.migration,
|
||||||
cliPackageVersion,
|
cliPackageVersion,
|
||||||
engineAvailable: hasDashboardEngine(options),
|
engineAvailable: hasDashboardEngine(options),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
CentralCore,
|
CentralCore,
|
||||||
TaskStore,
|
TaskStore,
|
||||||
RegisteredProject,
|
RegisteredProject,
|
||||||
|
MigrationProgressEvent,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { ProjectEngine } from "./project-engine.js";
|
import { ProjectEngine } from "./project-engine.js";
|
||||||
import type { ProjectEngineOptions } 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.
|
* project root. Callers may still pass per-call overrides via ensureEngine.
|
||||||
*/
|
*/
|
||||||
externalTaskStore?: ProjectEngineOptions["externalTaskStore"];
|
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). */
|
/** Default interval for background reconciliation (30 seconds). */
|
||||||
@@ -553,6 +556,7 @@ export class ProjectEngineManager {
|
|||||||
maxWorktrees: (settings?.maxWorktrees as number) ?? 10,
|
maxWorktrees: (settings?.maxWorktrees as number) ?? 10,
|
||||||
// Shared global semaphore — all engines share one concurrency pool
|
// Shared global semaphore — all engines share one concurrency pool
|
||||||
globalSemaphore: this.globalSemaphore,
|
globalSemaphore: this.globalSemaphore,
|
||||||
|
onMigrationProgress: this.options.onMigrationProgress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EventEmitter } from "node:events";
|
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";
|
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.
|
* Useful when the caller (e.g. dashboard.ts) owns and watches the store.
|
||||||
*/
|
*/
|
||||||
externalTaskStore?: TaskStore;
|
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`
|
* PR-entity node GitHub ops (U3): the injected `createPr`/`mergePr`/`respond`
|
||||||
* callbacks (+ source resolver + audit) for the `pr-create`/`pr-respond`/
|
* callbacks (+ source resolver + audit) for the `pr-create`/`pr-respond`/
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ export class InProcessRuntime
|
|||||||
const backendBoot = await createTaskStoreForBackend({
|
const backendBoot = await createTaskStoreForBackend({
|
||||||
rootDir: this.config.workingDirectory,
|
rootDir: this.config.workingDirectory,
|
||||||
projectId: this.config.projectId,
|
projectId: this.config.projectId,
|
||||||
|
onMigrationProgress: this.config.onMigrationProgress,
|
||||||
});
|
});
|
||||||
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Engine runtimes must fail
|
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Engine runtimes must fail
|
||||||
// startup when PostgreSQL cannot boot; constructing a SQLite TaskStore is no longer valid.
|
// startup when PostgreSQL cannot boot; constructing a SQLite TaskStore is no longer valid.
|
||||||
|
|||||||
Reference in New Issue
Block a user