From 2e4fcfcaea09b9dcbccd4443e2f67eb6fed04487 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 22:13:30 -0700 Subject: [PATCH] fix(FN-7952): establish PostgreSQL core authority (#2108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fusion’s core runtime now treats PostgreSQL as the authoritative metadata store without leaving current CLI, dashboard, desktop, or engine composition roots uncompilable between stack layers. This is the 99-file foundation for the larger cutover: subsequent PRs migrate the remaining consumers, plugins, and operator surfaces. ## Design decisions - Runtime store construction fails closed when an asynchronous PostgreSQL layer is unavailable; SQLite remains readable only at explicit migration and identity-recovery boundaries. - Project ownership is enforced across active, archived, workflow, mission, analytics, and plugin-schema data. - The small set of cross-package files in this layer are compatibility-critical call sites required for a green intermediate commit, not the complete consumer migration. - Schema migration 0008 remains assigned to session-advisor state from current `main`; mission lineage idempotency advances to 0009 so neither invariant can be skipped. ## Validation - All affected package typechecks pass: Core, Engine, Dashboard, CLI, and Desktop. - `pnpm test:gate` passes: 478 tests across the engine gate, PostgreSQL core gate, and CLI workflow shape. - The PR changes exactly 99 files. ## Stack This is the base PR. Engine/dashboard, CLI/desktop/ops, plugins, and docs/release follow as stacked PRs, each below 100 changed files. Related: #2105 ## Summary by CodeRabbit * **New Features** * PostgreSQL is now the standard runtime backend, with embedded PostgreSQL enabled by default. * Added project-scoped storage for tasks, archives, chat sessions, missions, knowledge pages, and operational data. * Improved archived-task search, filtering, pagination, and restoration. * Added safer plugin schema initialization with validation and project isolation. * Added PostgreSQL-backed workflow, mission, validator, and dashboard capabilities. * **Bug Fixes** * Improved startup timeout cancellation and resource cleanup. * Prevented cross-project data access and phantom reservation cleanup errors. * Ensured archived tasks remain read-only and asynchronous writes complete reliably. * Retired SQLite opt-out settings with clear startup errors. --- packages/cli/src/commands/daemon.ts | 58 +- packages/cli/src/commands/dashboard.ts | 108 +- packages/cli/src/commands/serve.ts | 168 +- .../__tests__/planner-intervention.test.ts | 10 +- .../src/__tests__/plugin-hot-reload.test.ts | 50 + .../postgres-project-discovery.test.ts | 84 + .../archive-project-isolation.pg.test.ts | 98 + .../postgres/embedded-lifecycle.test.ts | 60 +- .../postgres/mission-store.pg.test.ts | 254 +- .../operational-maintenance.pg.test.ts | 145 + .../postgres/plugin-schema-hook.test.ts | 92 + .../__tests__/postgres/schema-applier.test.ts | 63 +- .../startup-factory-integration.test.ts | 47 +- .../postgres/startup-factory.test.ts | 69 +- .../postgres/store-archive-reads.pg.test.ts | 181 ++ .../postgres/store-safe-defaults.pg.test.ts | 138 + .../postgres/task-lifecycle-e2e.pg.test.ts | 4 +- .../workflow-authoritative-reads.pg.test.ts | 83 + .../src/__tests__/project-identity.test.ts | 10 +- .../src/__tests__/sqlite-validation.test.ts | 5 +- ...tore-phantom-reservation-reconcile.test.ts | 242 +- packages/core/src/agent-prompts.ts | 6 +- packages/core/src/agent-store.ts | 7 +- packages/core/src/async-archive-db.ts | 90 +- packages/core/src/async-central-core.ts | 4 +- packages/core/src/async-central-db.ts | 23 +- packages/core/src/async-knowledge.ts | 148 ++ .../core/src/async-mission-store-queries.ts | 2067 ++++++++++++++ packages/core/src/async-mission-store.ts | 2367 ++++------------- packages/core/src/central-core.ts | 81 +- packages/core/src/central-db.ts | 10 +- packages/core/src/chat-store.ts | 1337 +--------- packages/core/src/cli-session-store.ts | 357 +-- packages/core/src/fs-watch-poll-controller.ts | 10 +- packages/core/src/index.gate.ts | 2 + packages/core/src/index.ts | 18 +- packages/core/src/migration.ts | 92 +- packages/core/src/pi-extensions.ts | 12 +- packages/core/src/planner-intervention.ts | 15 +- packages/core/src/plugin-loader.ts | 34 +- packages/core/src/plugin-types.ts | 20 + .../core/src/postgres/embedded-lifecycle.ts | 72 +- packages/core/src/postgres/index.ts | 18 +- .../core/src/postgres/migration-stamping.ts | 104 +- .../0009_mission_fix_idempotency.sql | 12 + .../core/src/postgres/plugin-schema-hook.ts | 171 ++ packages/core/src/postgres/schema-applier.ts | 25 +- packages/core/src/postgres/schema/index.ts | 2 +- packages/core/src/postgres/schema/plugin.ts | 19 +- packages/core/src/postgres/schema/project.ts | 42 +- packages/core/src/postgres/sqlite-migrator.ts | 6 +- packages/core/src/postgres/startup-factory.ts | 378 ++- packages/core/src/project-identity.ts | 59 +- packages/core/src/project-root-guard.ts | 15 +- packages/core/src/settings-export.ts | 2 +- packages/core/src/sqlite-adapter.ts | 14 +- packages/core/src/sqlite-validation.ts | 10 +- packages/core/src/store.ts | 108 +- .../src/task-store/archive-lifecycle-2.ts | 55 +- .../src/task-store/async-archive-lineage.ts | 39 +- .../task-store/async-comments-attachments.ts | 53 +- .../core/src/task-store/async-lifecycle.ts | 19 +- .../core/src/task-store/async-maintenance.ts | 88 + .../core/src/task-store/async-persistence.ts | 26 +- .../task-store/async-phantom-reservations.ts | 172 ++ packages/core/src/task-store/async-search.ts | 2 +- packages/core/src/task-store/audit-ops.ts | 15 +- .../core/src/task-store/branch-group-ops.ts | 62 +- packages/core/src/task-store/comments-ops.ts | 9 +- packages/core/src/task-store/moves.ts | 2 +- packages/core/src/task-store/reads.ts | 137 +- .../core/src/task-store/remaining-ops-1.ts | 15 +- .../core/src/task-store/remaining-ops-2.ts | 62 +- .../core/src/task-store/remaining-ops-3.ts | 3 +- .../core/src/task-store/remaining-ops-4.ts | 2 +- .../core/src/task-store/remaining-ops-5.ts | 3 +- .../core/src/task-store/remaining-ops-6.ts | 25 +- .../core/src/task-store/remaining-ops-7.ts | 70 +- .../core/src/task-store/remaining-ops-8.ts | 34 +- packages/core/src/task-store/workflow-ops.ts | 8 +- packages/core/src/types.ts | 8 +- packages/dashboard/src/ai-session-store.ts | 712 +---- .../dashboard/src/chat-project-services.ts | 8 +- packages/dashboard/src/require-async-layer.ts | 15 + .../routes/register-settings-sync-routes.ts | 6 +- packages/dashboard/src/server.ts | 91 +- packages/desktop/src/local-runtime.ts | 50 +- packages/desktop/src/local-server.ts | 50 +- .../src/__tests__/plugin-runner.test.ts | 15 +- packages/engine/src/cli-agent/runtime.ts | 27 +- packages/engine/src/executor.ts | 24 +- packages/engine/src/merger.ts | 2 +- packages/engine/src/mesh-lease-manager.ts | 8 +- packages/engine/src/mission-execution-loop.ts | 136 +- packages/engine/src/plugin-runner.ts | 28 +- .../engine/src/runtimes/in-process-runtime.ts | 232 +- packages/engine/src/scheduler.ts | 97 +- packages/engine/src/self-healing.ts | 51 +- .../src/workflow-authoritative-driver.ts | 5 +- 99 files changed, 7026 insertions(+), 5166 deletions(-) create mode 100644 packages/core/src/__tests__/postgres-project-discovery.test.ts create mode 100644 packages/core/src/__tests__/postgres/archive-project-isolation.pg.test.ts create mode 100644 packages/core/src/__tests__/postgres/operational-maintenance.pg.test.ts create mode 100644 packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts create mode 100644 packages/core/src/__tests__/postgres/store-archive-reads.pg.test.ts create mode 100644 packages/core/src/__tests__/postgres/store-safe-defaults.pg.test.ts create mode 100644 packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts create mode 100644 packages/core/src/async-knowledge.ts create mode 100644 packages/core/src/async-mission-store-queries.ts create mode 100644 packages/core/src/postgres/migrations/0009_mission_fix_idempotency.sql create mode 100644 packages/core/src/task-store/async-maintenance.ts create mode 100644 packages/core/src/task-store/async-phantom-reservations.ts create mode 100644 packages/dashboard/src/require-async-layer.ts diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index ebbdf0df68..9abadc6404 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -544,28 +544,12 @@ export async function runDaemon(opts: DaemonOptions = {}) { // Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView) // can discover installed runtimes like Hermes and OpenClaw. try { + /* + FNXC:PluginPostgresSchema 2026-07-14-21:48: + PluginLoader initializes each plugin's schema before publishing that plugin as loaded. Daemon startup must not replay every loaded schema contract as a second batch after loadAllPlugins. + */ const { loaded, errors } = await pluginLoader.loadAllPlugins(); console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`); - - const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); - if (schemaHooks.length > 0) { - try { - /* - * FNXC:SqliteFinalRemoval 2026-06-25-16:25: - * Skip SQLite-specific plugin schema init in backend mode (PostgreSQL - * uses Drizzle migrations for schema management). - */ - if (store.isBackendMode()) { - console.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)"); - } else { - await store.getDatabase().runPluginSchemaInits(schemaHooks); - } - } catch (err) { - console.error( - `[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`, - ); - } - } } catch (err) { console.error( `[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}` @@ -941,14 +925,11 @@ export async function runDaemon(opts: DaemonOptions = {}) { centralCore = null; } } - let localNodeId: string | undefined; - try { if (centralCore) { const nodes = await centralCore.listNodes(); const localNode = nodes.find((node) => node.type === "local"); if (localNode) { - localNodeId = localNode.id; await centralCore.updateNode(localNode.id, { status: "online" }); } } @@ -993,13 +974,32 @@ export async function runDaemon(opts: DaemonOptions = {}) { if (shuttingDown) return; shuttingDown = true; - // Stop all project engines uniformly + /* + FNXC:PostgresResourceLifecycle 2026-07-14-21:48: + CentralCore adopts an engine TaskStore layer but retains ownership of its original embedded backend lifecycle. Persist the local-node offline state while the adopted pool is live, then stop engine-owned stores, and only then close CentralCore so its retained backend lifecycle cannot terminate PostgreSQL under a live engine. + */ + if (centralCore) { + try { + await centralCore.markLocalNodeOffline(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[daemon] Failed to set local node offline: ${message}`); + } + } + + // Stop all project engines uniformly; their runtimes own their TaskStores. if (hybridExecutor) { await hybridExecutor.shutdown(); } await engineManager.stopAll(); + /* + FNXC:PostgresResourceLifecycle 2026-07-14-22:07: + Preserve the command-level TaskStore close barrier before CentralCore releases its retained backend. Runtime shutdown normally closes this store first; the idempotent explicit close also covers partial-start and test-owned runtimes. + */ + await store.close(); + // Stop peer exchange service if (peerExchangeService) { try { @@ -1010,15 +1010,6 @@ export async function runDaemon(opts: DaemonOptions = {}) { } } - if (centralCore && localNodeId) { - try { - await centralCore.updateNode(localNodeId, { status: "offline" }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn(`[daemon] Failed to set local node offline: ${message}`); - } - } - if (centralCore) { await centralCore.close().catch(() => { // best-effort @@ -1032,7 +1023,6 @@ export async function runDaemon(opts: DaemonOptions = {}) { // best-effort } - store.close(); process.exit(signal ? (SIGNAL_EXIT_CODES[signal] ?? 128) : 0); }; diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 977899c191..a62db51d69 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -6,7 +6,7 @@ import { stat, readdir, readFile as fsReadFile } from "node:fs/promises"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { - TaskStore, + type TaskStore, AutomationStore, CentralCore, AgentStore, @@ -773,6 +773,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // (they're assigned after initialization, but the variables exist from the start). // prefer-const disabled: callbacks close over these identifiers before the // single assignment below, which requires `let` even though no reassignment occurs. + // eslint-disable-next-line prefer-const let store: TaskStore | undefined; // eslint-disable-next-line prefer-const let agentStore: AgentStore | undefined; @@ -877,28 +878,25 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // FNXC:BackendFlip 2026-06-26-14:40: // Consult the startup factory to boot a PostgreSQL-backed TaskStore. Post // default-flip: the factory boots embedded PG by default when DATABASE_URL - // is unset, external PG when DATABASE_URL is set, and returns null only - // when the operator opted out via FUSION_NO_EMBEDDED_PG=1 (legacy SQLite - // path). When it returns null, the legacy SQLite path runs unchanged. The + // is unset and external PG when DATABASE_URL is set. The // backend shutdown handle is captured so the dashboard teardown path can // release the pool / stop an embedded cluster; it is invoked via the // existing store.close() (which closes the AsyncDataLayer) plus the // dashboardBackendShutdown // registered below for embedded-cluster teardown. - let dashboardBackendShutdown: (() => Promise) | undefined; const dashboardBackendBoot = await createTaskStoreForBackend({ rootDir: cwd }); - if (dashboardBackendBoot) { - store = dashboardBackendBoot.taskStore; - dashboardBackendShutdown = dashboardBackendBoot.shutdown; - } else { - store = new TaskStore(cwd); - } + // FNXC:PostgresFinalCutover 2026-07-14-17:20: Dashboard runtime storage is + // PostgreSQL-only; factory failure is surfaced instead of creating a dead store. + store = dashboardBackendBoot.taskStore; + const dashboardBackendShutdown = dashboardBackendBoot.shutdown; + const dashboardLayer = store.getAsyncLayer(); + if (!dashboardLayer) throw new Error("Dashboard runtime requires the project PostgreSQL AsyncDataLayer"); // FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:05: // Propagate the backend mode (asyncLayer) from the resolved TaskStore so // AutomationStore does not construct a SQLite file under PostgreSQL. The // `?? undefined` coerces `AsyncDataLayer | null` to the optional option // shape used by the other satellite stores. - const automationStore = new AutomationStore(cwd, { asyncLayer: store.getAsyncLayer() ?? undefined }); + const automationStore = new AutomationStore(cwd, { asyncLayer: dashboardLayer }); // CentralCore.init() is independent of store inits — start it early so it // overlaps with plugin loading and extension resolution instead of running @@ -916,7 +914,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // kanban board and all dashboard UI flows. const centralCoreInitPromise = !noEngine ? (async () => { - const core = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() ?? undefined }); + const core = new CentralCore(undefined, { asyncLayer: dashboardLayer }); try { await core.init(); } catch { /* non-fatal — fallback defaults */ } return core; })() @@ -936,13 +934,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: } }; - // TaskStore / AutomationStore / PluginStore / AgentStore all open the SAME - // .fusion/fusion.db file and run addColumnIfMissing migrations (a TOCTOU - // `hasColumn` → ALTER pattern with no per-process lock). node:sqlite's - // DatabaseSync is synchronous, so Promise.all on these gives no real - // parallelism anyway — explicit sequencing keeps the schema-migration race - // from triggering if any init() body ever introduces an `await` between - // hasColumn and ALTER TABLE. + // FNXC:PostgresFinalCutover 2026-07-14-17:20: Initialize the PostgreSQL-backed + // store and satellite adapters in dependency order so each receives the live + // AsyncDataLayer before watchers and engines begin dispatching work. await phaseTime("store.init", () => store.init()); await phaseTime("automationStore.init", () => automationStore.init()); const pluginStore = store.getPluginStore(); @@ -956,7 +950,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // any getter touches `this.db`. Mirrors the AutomationStore fix on line ~893 // (VAL-CROSS-008 dashboard boot on embedded PostgreSQL). The `?? undefined` // coerces `AsyncDataLayer | null` to the optional option shape. - agentStore = new AgentStore({ rootDir: store.getFusionDir(), asyncLayer: store.getAsyncLayer() ?? undefined }); + agentStore = new AgentStore({ rootDir: store.getFusionDir(), asyncLayer: dashboardLayer }); if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.initializingAgentStore); await phaseTime("agentStore.init", () => agentStore!.init()); // store.watch() is filesystem-watcher setup — no DB schema work, safe to @@ -1004,13 +998,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: projectStore = store; } else { const boot = await createTaskStoreForBackend({ rootDir: projectPath }); - if (boot) { - projectStore = boot.taskStore; - projectStoreShutdowns.set(projectPath, boot.shutdown); - } else { - projectStore = new TaskStore(projectPath); - await projectStore.init(); - } + projectStore = boot.taskStore; + projectStoreShutdowns.set(projectPath, boot.shutdown); } projectStores.set(projectPath, projectStore); return projectStore; @@ -1175,7 +1164,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: event: string | symbol; handler: (...args: any[]) => void; }> = []; - const disposeCallbacks: Array<() => void> = []; + const disposeCallbacks: Array<() => Promise | void> = []; let disposed = false; let shutdownInProgress = false; @@ -1451,21 +1440,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); if (schemaHooks.length > 0) { try { - /* - * FNXC:SqliteFinalRemoval 2026-06-25-16:25: - * Skip SQLite-specific plugin schema init in backend mode (PostgreSQL - * uses Drizzle migrations for schema management). - */ - if (store.isBackendMode()) { - logSink.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)"); - } else { - await store.getDatabase().runPluginSchemaInits(schemaHooks); - } + /* FNXC:PluginPostgresSchema 2026-07-14-17:30: Dashboard startup materializes runtime-loaded plugin schemas through the backend-aware TaskStore contract instead of skipping PostgreSQL hooks. */ + await store.runPluginSchemaInits(schemaHooks); } catch (err) { logSink.log( `Schema initialization failed: ${err instanceof Error ? err.message : err}`, "plugins", ); + throw err; } } } catch (err) { @@ -1924,7 +1906,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: }) : undefined; - function dispose(): void { + async function disposeAsync(): Promise { if (disposed) return; disposed = true; @@ -1945,29 +1927,37 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // after q" regression. We are exiting; drop teardown output instead. // FUSION_DEBUG_SHUTDOWN still surfaces per-step timing on stderr. logSink.silence(); - void tui.stop(); + await tui.stop(); } for (const { target, event, handler } of handlers) { target.off(event, handler); } handlers.length = 0; - for (const callback of disposeCallbacks.splice(0)) { - callback(); + /* FNXC:PostgresDashboardLifecycle 2026-07-14-19:10: Teardown runs in reverse ownership order and is fully awaited before process.exit, so engines stop before their shared PostgreSQL backend and no pool shutdown is fire-and-forget. */ + for (const callback of disposeCallbacks.splice(0).reverse()) { + try { + await callback(); + } catch (error) { + logSink.warn(`Dashboard dispose callback failed: ${error instanceof Error ? error.message : String(error)}`, "dashboard"); + } } } - // FNXC:RuntimeStartupWiring 2026-06-24-10:20: - // Register the backend shutdown (release PG pool / stop embedded cluster) - // so it runs during dispose(). store.close() already closes the - // AsyncDataLayer pool; this adds embedded-cluster teardown. - if (dashboardBackendShutdown) { - disposeCallbacks.push(() => { - void dashboardBackendShutdown!().catch(() => undefined); - }); - } - disposeCallbacks.push(() => { - void closeProjectStores(); + const dispose = (): void => { + void disposeAsync(); + }; + + /* + FNXC:PostgresDashboardLifecycle 2026-07-14-22:07: + Dispose secondary stores first, explicitly close the cwd TaskStore so its watcher and timers stop, then invoke the startup factory shutdown that releases the remaining backend resources. The exported dispose path must await every stage. + */ + disposeCallbacks.push(async () => { + await closeProjectStores(); + await store?.close(); + if (dashboardBackendShutdown) { + await dashboardBackendShutdown().catch(() => undefined); + } }); // ── createServer: deferred until engine is conditionally started ──── @@ -2297,7 +2287,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: } await logShutdownDiagnostics(signal); - dispose(); + await disposeAsync(); stopDiagnosticInterval(); // Tear down user-project dev-server children (and their process groups) @@ -2331,8 +2321,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: closeCentralCoreBestEffort(centralCoreForEngine, `shutdown (${signal})`), ); - await timeShutdownStep("closeProjectStores", () => closeProjectStores()); - store.close(); process.exit(shutdownExitCode); }; /* @@ -2370,7 +2358,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // instance for peer exchange and mDNS discovery. // try { - centralCoreForMesh = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() ?? undefined }); + centralCoreForMesh = new CentralCore(undefined, { asyncLayer: dashboardLayer }); await centralCoreForMesh.init(); peerExchangeService = new PeerExchangeService(centralCoreForMesh); @@ -2635,7 +2623,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: } await logShutdownDiagnostics(signal); - dispose(); + await disposeAsync(); stopDiagnosticInterval(); if (triggerScheduler) triggerScheduler.stop(); if (heartbeatMonitorImpl) heartbeatMonitorImpl.stop(); @@ -2665,8 +2653,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ); } - await timeShutdownStep("closeProjectStores", () => closeProjectStores()); - store.close(); process.exit(shutdownExitCode); }; // FNXC:SystemPanel 2026-07-12-11:00: System panel restart binding for diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e42e94b544..0e4aa4f7d2 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -287,27 +287,29 @@ export async function runServe( * the TaskStore) as the externalTaskStore for the cwd project's engine so * the connection pool is shared — no second embedded PG instance is started. */ - let centralBootResult: { taskStore: import("@fusion/core").TaskStore; asyncLayer: import("@fusion/core").AsyncDataLayer; shutdown: () => Promise } | null = null; + const { createTaskStoreForBackend } = await import("@fusion/core"); + /* + * FNXC:PostgresFinalCutover 2026-07-14-17:20: + * Serve must share one successfully booted PostgreSQL layer between CentralCore and the cwd engine. A backend boot error is fatal; constructing a layerless CentralCore would make project discovery appear empty and split control-plane state. + */ + const centralBootResult = await createTaskStoreForBackend({ rootDir: cwd }); + let centralBackendShutdownPromise: Promise | undefined; + const shutdownCentralBackendOnce = (): Promise => { + centralBackendShutdownPromise ??= centralBootResult.shutdown(); + return centralBackendShutdownPromise; + }; + sharedCentralCore = new CentralCore(undefined, { asyncLayer: centralBootResult.asyncLayer }); try { - const { createTaskStoreForBackend } = await import("@fusion/core"); - centralBootResult = await createTaskStoreForBackend({ rootDir: cwd }); - if (centralBootResult) { - sharedCentralCore = new CentralCore(undefined, { asyncLayer: centralBootResult.asyncLayer }); - } else { - sharedCentralCore = new CentralCore(); - } await sharedCentralCore.init(); - } catch { - if (!sharedCentralCore) { - sharedCentralCore = new CentralCore(); - try { - await sharedCentralCore.init(); - } catch { - // Non-fatal — engine uses fallback defaults - } - } + } catch (error) { + /* FNXC:PostgresServeLifecycle 2026-07-14-18:03: A failed shared CentralCore boot occurs before serve installs signal teardown, so release the sole shared TaskStore pool and embedded lifecycle here. */ + await shutdownCentralBackendOnce().catch(() => undefined); + throw error; } + let startupEngineManager: ProjectEngineManager | undefined; + try { + // ── ProjectEngineManager: uniform engine lifecycle for all projects ── // // Every registered project gets an identical ProjectEngine with the @@ -363,24 +365,13 @@ export async function runServe( } }; - if (!sharedCentralCore) { - sharedCentralCore = new CentralCore(); - try { - await sharedCentralCore.init(); - } catch { - // Non-fatal — engine uses fallback defaults - } - } - - if (sharedCentralCore) { - const registered = await ensureCwdProjectRegistered({ - cwd, - central: sharedCentralCore, - logPrefix: "serve", - autoRegister: !opts.noAutoRegister, - }); - ntfyProjectId = registered?.id; - } + const registered = await ensureCwdProjectRegistered({ + cwd, + central: sharedCentralCore, + logPrefix: "serve", + autoRegister: !opts.noAutoRegister, + }); + ntfyProjectId = registered?.id; try { registerGithubTrackingHook?.(); @@ -391,7 +382,7 @@ export async function runServe( const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url); const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion; - const engineManager = new ProjectEngineManager(sharedCentralCore, { + const engineManager = startupEngineManager = new ProjectEngineManager(sharedCentralCore, { cliPackageVersion, getMergeStrategy, processPullRequestMerge: (s, wd, taskId, pool) => @@ -404,9 +395,8 @@ export async function runServe( onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), // FNXC:SqliteFinalRemoval 2026-06-26-11:15: share the central boot's TaskStore // as the externalTaskStore so the cwd engine reuses the same connection pool - // (no second embedded PG). When centralBootResult is null (legacy mode), the - // engine creates its own TaskStore via createTaskStoreForBackend as before. - ...(centralBootResult ? { externalTaskStore: centralBootResult.taskStore } : {}), + // (no second embedded PG). + externalTaskStore: centralBootResult.taskStore, }); // Start engines for all registered projects eagerly @@ -620,36 +610,35 @@ export async function runServe( // Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView) // can discover installed runtimes like Hermes and OpenClaw. + /* + FNXC:PluginPostgresSchema 2026-07-14-21:48: + Optional plugin module-load failures remain nonfatal for serve compatibility. A schema initialization failure from a loaded plugin is instead a fatal storage-integrity error and must escape startup rather than being swallowed by the module-load catch. + */ + let pluginsLoaded = false; try { const { loaded, errors } = await pluginLoader.loadAllPlugins(); console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`); - - const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); - if (schemaHooks.length > 0) { - try { - /* - * FNXC:SqliteFinalRemoval 2026-06-25-16:25: - * In backend mode (PostgreSQL), plugin schema inits are handled by the - * Drizzle schema applier at startup, not the SQLite Database class. - * Skip the SQLite-specific runPluginSchemaInits path in backend mode. - */ - if (store.isBackendMode()) { - console.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)"); - } else { - await store.getDatabase().runPluginSchemaInits(schemaHooks); - } - } catch (err) { - console.error( - `[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`, - ); - } - } + pluginsLoaded = true; } catch (err) { console.error( `[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}` ); } + if (pluginsLoaded) { + const schemaHooks = pluginLoader.getPluginSchemaInitHooks?.() ?? []; + if (schemaHooks.length > 0) { + try { + await store.runPluginSchemaInits(schemaHooks); + } catch (err) { + console.error( + `[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`, + ); + throw err; + } + } + } + // Get subsystems from the primary engine for the HTTP layer const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor(); const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot(); @@ -1066,15 +1055,6 @@ export async function runServe( // If it wasn't initialized successfully, create a new one for node registration. // let centralCore: CentralCore | null = sharedCentralCore; - // sharedCentralCore was already init'd; if null, try again for node registration - if (!centralCore) { - try { - centralCore = new CentralCore(); - await centralCore.init(); - } catch { - centralCore = null; - } - } let localNodeId: string | undefined; try { @@ -1121,7 +1101,7 @@ export async function runServe( } console.log(); - let shuttingDown = false; + let shutdownPromise: Promise | undefined; /* FNXC:DaemonSignalExit 2026-07-10-14:00: @@ -1134,9 +1114,6 @@ export async function runServe( const SIGNAL_EXIT_CODES: Record = { SIGINT: 130, SIGTERM: 143 }; const shutdown = async (signal?: NodeJS.Signals) => { - if (shuttingDown) return; - shuttingDown = true; - // Log active handles at shutdown for diagnostics const handleTypes: Record = {}; try { @@ -1155,12 +1132,14 @@ export async function runServe( // Ignore errors getting handle types } - if (hybridExecutor) { - await hybridExecutor.shutdown(); - } + if (hybridExecutor) await hybridExecutor.shutdown().catch((error) => { + console.warn(`[serve] Hybrid executor shutdown failed: ${error instanceof Error ? error.message : String(error)}`); + }); // Stop all project engines uniformly - await engineManager.stopAll(); + await engineManager.stopAll().catch((error) => { + console.warn(`[serve] Engine shutdown failed: ${error instanceof Error ? error.message : String(error)}`); + }); // Stop peer exchange service if (peerExchangeService) { @@ -1202,16 +1181,36 @@ export async function runServe( // best-effort } - stopDiagnosticInterval(); - store.close(); + try { + stopDiagnosticInterval(); + } catch (error) { + console.warn(`[serve] Diagnostic teardown failed: ${error instanceof Error ? error.message : String(error)}`); + } + /* + * FNXC:PostgresServeLifecycle 2026-07-14-18:08: + * The serve bootstrap owns the TaskStore pool and any embedded PostgreSQL + * process because its runtime receives that store externally. Release the + * complete boot result after every engine and CentralCore user has stopped. + */ + await shutdownCentralBackendOnce().catch((error) => { + console.warn(`[serve] PostgreSQL shutdown failed: ${error instanceof Error ? error.message : String(error)}`); + }); process.exit(signal ? (SIGNAL_EXIT_CODES[signal] ?? 128) : 0); }; + /* FNXC:PostgresServeLifecycle 2026-07-14-19:10: Every shutdown request observes the same promise so repeated signals cannot duplicate pool/postmaster teardown and asynchronous failures are never left as unhandled rejections. */ + const requestShutdown = (signal?: NodeJS.Signals): void => { + shutdownPromise ??= shutdown(signal); + void shutdownPromise.catch((error) => { + console.error(`[serve] Shutdown failed: ${error instanceof Error ? error.message : String(error)}`); + }); + }; + process.on("SIGINT", () => { - void shutdown("SIGINT"); + requestShutdown("SIGINT"); }); process.on("SIGTERM", () => { - void shutdown("SIGTERM"); + requestShutdown("SIGTERM"); }); // Ignore SIGHUP so the server survives SSH session disconnects. @@ -1221,4 +1220,11 @@ export async function runServe( process.on("SIGHUP", () => { console.log("[serve] Received SIGHUP (terminal disconnected) — ignoring"); }); + } catch (error) { + /* FNXC:PostgresServeLifecycle 2026-07-14-19:10: Any startup failure after the shared PostgreSQL boot must unwind partially-started engines and CentralCore before releasing the sole backend owner exactly once. */ + await startupEngineManager?.stopAll().catch(() => undefined); + await sharedCentralCore?.close().catch(() => undefined); + await shutdownCentralBackendOnce().catch(() => undefined); + throw error; + } } diff --git a/packages/core/src/__tests__/planner-intervention.test.ts b/packages/core/src/__tests__/planner-intervention.test.ts index 2e0f141403..7c13320e5c 100644 --- a/packages/core/src/__tests__/planner-intervention.test.ts +++ b/packages/core/src/__tests__/planner-intervention.test.ts @@ -36,7 +36,7 @@ class FakeRunAuditStore implements PlannerInterventionStore { return event; } - getRunAuditEvents(options: RunAuditEventFilter = {}): RunAuditEvent[] { + async getRunAuditEventsAsync(options: RunAuditEventFilter = {}): Promise { return this.events .filter((event) => (options.taskId ? event.taskId === options.taskId : true)) .filter((event) => (options.mutationType ? event.mutationType === options.mutationType : true)) @@ -98,7 +98,7 @@ describe("recordPlannerIntervention", () => { }); describe("getPlannerInterventionTimeline", () => { - it("returns entries newest-first and filters out non-intervention events", () => { + it("returns entries newest-first and filters out non-intervention events", async () => { const store = new FakeRunAuditStore(); recordPlannerIntervention(store, { @@ -127,16 +127,16 @@ describe("getPlannerInterventionTimeline", () => { timestamp: "2026-07-04T11:00:00.000Z", }); - const timeline = getPlannerInterventionTimeline(store, "FN-3"); + const timeline = await getPlannerInterventionTimeline(store, "FN-3"); expect(timeline).toHaveLength(2); expect(timeline[0].reason).toBe("Second intervention"); expect(timeline[1].reason).toBe("First intervention"); }); - it("returns [] when there are no interventions for the task", () => { + it("returns [] when there are no interventions for the task", async () => { const store = new FakeRunAuditStore(); - expect(getPlannerInterventionTimeline(store, "FN-4")).toEqual([]); + expect(await getPlannerInterventionTimeline(store, "FN-4")).toEqual([]); }); }); diff --git a/packages/core/src/__tests__/plugin-hot-reload.test.ts b/packages/core/src/__tests__/plugin-hot-reload.test.ts index e5ff5eac76..66bb6a2f93 100644 --- a/packages/core/src/__tests__/plugin-hot-reload.test.ts +++ b/packages/core/src/__tests__/plugin-hot-reload.test.ts @@ -40,6 +40,8 @@ async function writePluginModule( routes?: Array<{ method: string; path: string }>; onLoad?: string; onUnload?: string; + onSchemaInit?: string; + onPostgresSchemaInit?: string; } = {}, ): Promise { const filepath = join(dir, filename); @@ -57,6 +59,8 @@ const plugin = { hooks: { ${options.onLoad ? `onLoad: ${options.onLoad},` : ""} ${options.onUnload ? `onUnload: ${options.onUnload},` : ""} + ${options.onSchemaInit ? `onSchemaInit: ${options.onSchemaInit},` : ""} + ${options.onPostgresSchemaInit ? `onPostgresSchemaInit: ${options.onPostgresSchemaInit},` : ""} }, tools: ${toolsStr}, routes: ${routesStr}, @@ -75,6 +79,8 @@ function createMockTaskStore() { return { on: vi.fn(), off: vi.fn(), + preflightPluginSchema: vi.fn().mockReturnValue(null), + runPluginSchemaInits: vi.fn().mockResolvedValue(undefined), } as any; } @@ -178,6 +184,50 @@ describe("PluginLoader Hot-Reload", () => { }); describe("loadPlugin() - runtime loading", () => { + it("preflights and installs an external PostgreSQL schema before onLoad", async () => { + const order: string[] = []; + (globalThis as typeof globalThis & { __pluginSchemaOrder?: string[] }).__pluginSchemaOrder = order; + await writePluginModule(tmpDir, "plugin.js", baseManifest, { + onPostgresSchemaInit: `() => ({ version: 1, tablePrefix: "external_fixture_", statements: [\`CREATE TABLE IF NOT EXISTS project.external_fixture_rows (project_id text NOT NULL, id text NOT NULL, PRIMARY KEY (project_id, id))\`] })`, + onLoad: `() => globalThis.__pluginSchemaOrder.push("onLoad")`, + }); + mockTaskStore.preflightPluginSchema.mockImplementation((pluginId: string, hooks: FusionPlugin["hooks"]) => { + order.push("preflight"); + return { pluginId, postgresSchema: hooks.onPostgresSchemaInit?.() }; + }); + mockTaskStore.runPluginSchemaInits.mockImplementation(async () => { + order.push("schema"); + }); + + await pluginLoader.loadPlugin("hot-reload-test"); + + expect(order).toEqual(["preflight", "schema", "onLoad"]); + expect(pluginLoader.getPluginSchemaInitHooks()).toEqual([ + expect.objectContaining({ + pluginId: "hot-reload-test", + postgresSchema: expect.objectContaining({ version: 1 }), + }), + ]); + delete (globalThis as typeof globalThis & { __pluginSchemaOrder?: string[] }).__pluginSchemaOrder; + }); + + it("does not run onLoad when PostgreSQL schema preflight rejects a legacy-only plugin", async () => { + const onLoad = vi.fn(); + (globalThis as typeof globalThis & { __legacyPluginOnLoad?: () => void }).__legacyPluginOnLoad = onLoad; + await writePluginModule(tmpDir, "plugin.js", baseManifest, { + onSchemaInit: `() => undefined`, + onLoad: `() => globalThis.__legacyPluginOnLoad()`, + }); + mockTaskStore.preflightPluginSchema.mockImplementation(() => { + throw new Error("legacy SQLite onSchemaInit has no registered PostgreSQL schema hook"); + }); + + await expect(pluginLoader.loadPlugin("hot-reload-test")).rejects.toThrow("legacy SQLite"); + expect(onLoad).not.toHaveBeenCalled(); + expect(pluginLoader.isPluginLoaded("hot-reload-test")).toBe(false); + delete (globalThis as typeof globalThis & { __legacyPluginOnLoad?: () => void }).__legacyPluginOnLoad; + }); + it("should load a plugin after initial startup", async () => { // Initially no plugins loaded expect(pluginLoader.getPluginTools()).toEqual([]); diff --git a/packages/core/src/__tests__/postgres-project-discovery.test.ts b/packages/core/src/__tests__/postgres-project-discovery.test.ts new file mode 100644 index 0000000000..311c7696a8 --- /dev/null +++ b/packages/core/src/__tests__/postgres-project-discovery.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node + +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { FirstRunDetector } from "../migration.js"; +import { + assertNotLinkedWorktreeOfExistingProject, + LinkedWorktreeBootstrapRefusedError, +} from "../project-root-guard.js"; +import { writeProjectIdentity } from "../project-identity.js"; + +const cleanupPaths: string[] = []; + +function temporaryDirectory(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)); + cleanupPaths.push(directory); + return directory; +} + +afterEach(() => { + delete process.env.FUSION_TEST_LINKED_WORKTREE_GUARD; + for (const path of cleanupPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("PostgreSQL project discovery", () => { + /** + * FNXC:PostgresProjectDiscovery 2026-07-14-17:30: + * Current project discovery must work with only the filesystem identity + * marker; creating a SQLite database is neither required nor expected. + */ + it("discovers a project.json-only project while walking up from a child", async () => { + const projectRoot = temporaryDirectory("fusion-project-marker-"); + const child = join(projectRoot, "src", "nested"); + mkdirSync(child, { recursive: true }); + writeProjectIdentity(join(projectRoot, ".fusion"), { + id: "proj_0123456789abcdef", + createdAt: "2026-07-14T17:30:00.000Z", + }); + + const detected = await new FirstRunDetector(join(projectRoot, "global")).detectExistingProjects(child); + + expect(detected).toEqual([ + expect.objectContaining({ + path: projectRoot, + hasDb: true, + identityId: "proj_0123456789abcdef", + }), + ]); + }); + + it("derives first-run state from the central project registry", async () => { + const detector = new FirstRunDetector(temporaryDirectory("fusion-central-state-")); + const central = { + listProjects: async () => [{ id: "proj_0123456789abcdef" }], + }; + + await expect(detector.detectFirstRunState(central as never)).resolves.toBe("normal-operation"); + expect(detector.hasCentralDb()).toBe(true); + }); + + it("refuses nested initialization in a linked worktree when the parent has only project.json", () => { + const parent = temporaryDirectory("fusion-parent-project-"); + const worktree = `${parent}-worktree`; + cleanupPaths.push(worktree); + execFileSync("git", ["init", "-q"], { cwd: parent }); + execFileSync("git", ["config", "user.email", "fusion-test@example.invalid"], { cwd: parent }); + execFileSync("git", ["config", "user.name", "Fusion Test"], { cwd: parent }); + execFileSync("git", ["commit", "--allow-empty", "-qm", "initial"], { cwd: parent }); + execFileSync("git", ["worktree", "add", "-q", worktree, "-b", "marker-guard-test"], { cwd: parent }); + writeProjectIdentity(join(parent, ".fusion"), { + id: "proj_fedcba9876543210", + createdAt: "2026-07-14T17:30:00.000Z", + }); + process.env.FUSION_TEST_LINKED_WORKTREE_GUARD = "1"; + + expect(() => assertNotLinkedWorktreeOfExistingProject(worktree, "test")) + .toThrow(LinkedWorktreeBootstrapRefusedError); + }); +}); diff --git a/packages/core/src/__tests__/postgres/archive-project-isolation.pg.test.ts b/packages/core/src/__tests__/postgres/archive-project-isolation.pg.test.ts new file mode 100644 index 0000000000..d58eeeb62c --- /dev/null +++ b/packages/core/src/__tests__/postgres/archive-project-isolation.pg.test.ts @@ -0,0 +1,98 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import type { AsyncDataLayer } from "../../postgres/data-layer.js"; +import * as schema from "../../postgres/schema/index.js"; +import type { ArchivedTaskEntry } from "../../types.js"; +import { insertTaskRow } from "../../task-store/async-persistence.js"; +import { getLiveTaskColumn } from "../../task-store/async-comments-attachments.js"; +import { + archiveParentTaskWithLineageGate, + deleteArchivedTaskEntry, + filterArchivedTaskEntries, + findArchivedTaskEntry, + listArchivedTaskEntries, + restoreTaskFromArchive, +} from "../../task-store/async-archive-lineage.js"; + +pgDescribe("archive project isolation", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_archive_isolation", + }); + + beforeAll(h.beforeAll); + afterAll(h.afterAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + + it("isolates duplicate task ids across archive, delete, and restore transactions", async () => { + /* + FNXC:ArchiveProjectIsolation 2026-07-14-16:20: + A shared PostgreSQL cluster permits the same task ID in separate project partitions. Prove every cold-archive read and mutation plus the live-row archive/restore transaction remains confined to its bound project, even when both projects use the identical ID. + */ + const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId }); + const projectA = bind("archive-project-a"); + const projectB = bind("archive-project-b"); + const id = "FN-SAME"; + const now = "2026-07-14T23:20:00.000Z"; + const task = (description: string) => ({ + id, + description, + column: "todo", + currentStep: 0, + createdAt: now, + updatedAt: now, + }); + const entry = (description: string): ArchivedTaskEntry => ({ + ...task(description), + lineageId: `${description}-lineage`, + column: "archived", + dependencies: [], + steps: [], + log: [], + archivedAt: now, + } as ArchivedTaskEntry); + + await insertTaskRow(projectA, task("project A"), { lineageId: "lineage-a" }); + await insertTaskRow(projectB, task("project B"), { lineageId: "lineage-b" }); + await expect(archiveParentTaskWithLineageGate(projectA, id, entry("project A"), { now })) + .resolves.toEqual({ archived: true }); + /* + FNXC:ArchiveProjectIsolation 2026-07-14-21:48: + Comment, log, document, and artifact state gates must distinguish duplicate task IDs by project. Project A is archived here while project B remains live, making an unscoped first-row lookup observably wrong. + */ + await expect(getLiveTaskColumn(h.layer().db, id, projectA.projectId)).resolves.toBe("archived"); + await expect(getLiveTaskColumn(h.layer().db, id, projectB.projectId)).resolves.toBe("todo"); + await expect(archiveParentTaskWithLineageGate(projectB, id, entry("project B"), { now })) + .resolves.toEqual({ archived: true }); + + expect((await findArchivedTaskEntry(h.layer().db, id, projectA.projectId))?.description) + .toBe("project A"); + expect((await findArchivedTaskEntry(h.layer().db, id, projectB.projectId))?.description) + .toBe("project B"); + expect((await listArchivedTaskEntries(h.layer().db, projectA.projectId)).map((row) => row.description)) + .toEqual(["project A"]); + expect(await filterArchivedTaskEntries(h.layer().db, [id], projectB.projectId)) + .toEqual(new Set([id])); + + await deleteArchivedTaskEntry(h.layer().db, id, projectA.projectId); + expect(await findArchivedTaskEntry(h.layer().db, id, projectA.projectId)).toBeUndefined(); + expect((await findArchivedTaskEntry(h.layer().db, id, projectB.projectId))?.description) + .toBe("project B"); + + await restoreTaskFromArchive(projectB, entry("project B"), { now: "2026-07-14T23:21:00.000Z" }); + const rows = await h.adminDb() + .select({ projectId: schema.project.tasks.projectId, deletedAt: schema.project.tasks.deletedAt }) + .from(schema.project.tasks) + .where(and(eq(schema.project.tasks.id, id), eq(schema.project.tasks.column, "archived"))); + expect(rows).toEqual(expect.arrayContaining([ + { projectId: "archive-project-a", deletedAt: now }, + { projectId: "archive-project-b", deletedAt: null }, + ])); + expect(await findArchivedTaskEntry(h.layer().db, id, projectB.projectId)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts index d31edb0a03..f3828f53df 100644 --- a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts +++ b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts @@ -14,7 +14,7 @@ * - VAL-CONN-007: graceful shutdown stops the Postgres process; no orphan. */ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { mkdtempSync, existsSync, @@ -36,6 +36,7 @@ import { isDataDirInitialized, normalizeMacosEmbeddedPostgresDylibSymlinks, readPortFromPostmasterPid, + __setEmbeddedPostgresCtorForTests, resolveElectronAsarUnpackedPath, fingerprintEmbeddedPostgresNativeRoot, buildEmbeddedPostgresMaterializationMarker, @@ -57,6 +58,8 @@ const tracked: Array<{ }> = []; afterEach(async () => { + __setEmbeddedPostgresCtorForTests(null); + vi.useRealTimers(); while (tracked.length > 0) { const { lifecycle, dataDir } = tracked.pop()!; try { @@ -666,6 +669,61 @@ describe("embedded-lifecycle: startup timeout (P1 #24)", () => { expect(lifecycle).toBeDefined(); expect(lifecycle.isRunning()).toBe(false); }); + + it("cancels a delayed postmaster start without publishing hooks or a running instance", async () => { + vi.useFakeTimers(); + const dataDir = makeDataDir(); + writeFileSync(join(dataDir, "PG_VERSION"), "15\n"); + let releaseStart!: () => void; + const delayedStart = new Promise((resolve) => { releaseStart = resolve; }); + let resolveLateStop!: () => void; + const lateStop = new Promise((resolve) => { resolveLateStop = resolve; }); + const running = { value: false }; + const stop = vi.fn(async () => { + running.value = false; + if (stop.mock.calls.length === 2) resolveLateStop(); + }); + + class DelayedEmbeddedPostgres { + initialise = vi.fn(async () => {}); + async start() { + await delayedStart; + running.value = true; + } + stop = stop; + createDatabase = vi.fn(async () => {}); + getPgClient() { + return { + connect: vi.fn(async () => {}), + query: vi.fn(() => ({ rowCount: 1 })), + end: vi.fn(async () => {}), + }; + } + } + + __setEmbeddedPostgresCtorForTests(DelayedEmbeddedPostgres as never); + const beforeExitListeners = process.listenerCount("beforeExit"); + const lifecycle = new EmbeddedPostgresLifecycle({ + ...baseOptions(dataDir), + port: 55439, + startTimeoutMs: 25, + }); + + const start = lifecycle.start(); + const timeoutRejection = expect(start).rejects.toBeInstanceOf(EmbeddedStartTimeoutError); + await vi.advanceTimersByTimeAsync(25); + await timeoutRejection; + expect(running.value).toBe(false); + + releaseStart(); + await lateStop; + + expect(stop).toHaveBeenCalledTimes(2); + expect(running.value).toBe(false); + expect(lifecycle.isRunning()).toBe(false); + expect(process.listenerCount("beforeExit")).toBe(beforeExitListeners); + rmSync(dataDir, { recursive: true, force: true }); + }); }); describe("embedded-lifecycle: readPortFromPostmasterPid (P1 code-review fix)", () => { diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index ce506b757e..18f81fcf88 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -14,6 +14,7 @@ */ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { sql } from "drizzle-orm"; import { pgDescribe, @@ -21,7 +22,17 @@ import { type SharedPgTaskStoreHarness, } from "../../__test-utils__/pg-test-harness.js"; import * as schema from "../../postgres/schema/index.js"; -import type { AsyncMissionStore } from "../../async-mission-store.js"; +import { + AsyncMissionStore, + createMission as createMissionRow, + createMilestone as createMilestoneRow, + deleteMission as deleteMissionRow, + getMission as getMissionRow, + insertMissionEvent, + listMilestones as listMilestoneRows, + listMissionEvents, + listMissions as listMissionRows, +} from "../../async-mission-store.js"; const pgTest = pgDescribe; @@ -43,6 +54,69 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(() => missions()).not.toThrow(); }); + /* + FNXC:MissionProjectIsolation 2026-07-14-21:35: + Two projects sharing one PostgreSQL schema may reuse every mission-local identifier. Mission helpers must bind inserts, direct CRUD, hierarchy lists, and event queries to the session project partition even on an administrative connection that bypasses row-level security; an unbound session may see only quarantined legacy rows. + */ + it("isolates duplicate mission hierarchies across two project scopes", async () => { + const db = h.adminDb(); + const now = new Date().toISOString(); + const missionInput = (title: string) => ({ + id: "M-SHARED", + title, + status: "planning", + interviewState: "not_started", + autoAdvance: false, + autopilotEnabled: false, + autopilotState: "inactive", + createdAt: now, + updatedAt: now, + }); + const seedProject = async (projectId: string, title: string): Promise => { + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('fusion.project_id', ${projectId}, true)`); + await createMissionRow(tx, missionInput(`${title} mission`)); + await createMilestoneRow(tx, { + id: "MS-SHARED", missionId: "M-SHARED", title: `${title} milestone`, + status: "planning", orderIndex: 0, interviewState: "not_started", + dependencies: [], createdAt: now, updatedAt: now, + }); + await insertMissionEvent(tx, { + id: "ME-SHARED", missionId: "M-SHARED", eventType: "created", + description: `${title} event`, timestamp: now, seq: 1, + }); + }); + }; + const readProject = async (projectId: string) => db.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('fusion.project_id', ${projectId}, true)`); + return { + missions: await listMissionRows(tx), + milestones: await listMilestoneRows(tx, "M-SHARED"), + events: await listMissionEvents(tx, "M-SHARED"), + }; + }); + + await seedProject("project-a", "Project A"); + await seedProject("project-b", "Project B"); + + const projectA = await readProject("project-a"); + const projectB = await readProject("project-b"); + expect(projectA.missions.map(({ title }) => title)).toEqual(["Project A mission"]); + expect(projectB.missions.map(({ title }) => title)).toEqual(["Project B mission"]); + expect(projectA.milestones.map(({ title }) => title)).toEqual(["Project A milestone"]); + expect(projectB.milestones.map(({ title }) => title)).toEqual(["Project B milestone"]); + expect(projectA.events.map(({ description }) => description)).toEqual(["Project A event"]); + expect(projectB.events.map(({ description }) => description)).toEqual(["Project B event"]); + expect(await listMissionRows(db)).toEqual([]); + + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('fusion.project_id', 'project-a', true)`); + expect(await deleteMissionRow(tx, "M-SHARED")).toBe(true); + expect(await getMissionRow(tx, "M-SHARED")).toBeUndefined(); + }); + expect((await readProject("project-b")).missions.map(({ title }) => title)).toEqual(["Project B mission"]); + }); + it("createMission → addMilestone → addSlice → addFeature assembles getMissionWithHierarchy tree", async () => { const m = missions(); const mission = await m.createMission({ title: "Ship payments" }); @@ -167,6 +241,184 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(fetched?.id).toBe(run.id); }); + it("runs the validator/fix lifecycle and reaps stale runs in PostgreSQL", async () => { + /* + FNXC:PostgresMissionRuntime 2026-07-14-17:23: + Mission validation and generated remediation are runtime capabilities in PostgreSQL, including durable failures, idempotent fix creation, terminal run events, retry state, and stale-owner recovery. + */ + const m = missions(); + const mission = await m.createMission({ title: "Validator lifecycle" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Feature", acceptanceCriteria: "observable result" }); + const [assertion] = await m.ensureFeatureAssertionLinked(feature.id); + expect(assertion).toBeDefined(); + + await m.transitionLoopState(feature.id, "implementing"); + const run = await m.startValidatorRun(feature.id, "task_completion"); + const failures = await m.recordValidatorFailures(run.id, [{ + featureId: feature.id, + assertionId: assertion!.id, + expected: "expected", + actual: "actual", + }]); + expect(failures).toHaveLength(1); + expect(await m.getFailuresForRun(run.id)).toHaveLength(1); + + const completed = await m.completeValidatorRun(run.id, "failed", "needs repair"); + expect(completed.status).toBe("failed"); + expect((await m.getFeature(feature.id))?.loopState).toBe("needs_fix"); + + const fix = await m.createGeneratedFixFeature(feature.id, run.id, [assertion!.id], "expected vs actual"); + expect(fix.generatedFromFeatureId).toBe(feature.id); + expect((await m.createGeneratedFixFeature(feature.id, run.id, [assertion!.id])).id).toBe(fix.id); + expect((await m.getFeature(feature.id))?.implementationAttemptCount).toBe(1); + + const staleRun = await m.startValidatorRun(fix.id, "scheduled"); + expect((await m.listStaleRunningValidatorRuns(-1)).map((candidate) => candidate.id)).toContain(staleRun.id); + const reaped = await m.reapValidatorRun(staleRun.id, "owner disappeared"); + expect(reaped.status).toBe("error"); + expect(reaped.summary).toBe("owner disappeared"); + expect((await m.getFeature(fix.id))?.loopState).toBe("needs_fix"); + }); + + it("allows exactly one terminal validator transition when completion races the stale reaper", async () => { + const primary = missions(); + const competing = new AsyncMissionStore(h.layer(), h.store()); + const mission = await primary.createMission({ title: "Validator race" }); + const milestone = await primary.addMilestone(mission.id, { title: "MS" }); + const slice = await primary.addSlice(milestone.id, { title: "SL" }); + const feature = await primary.addFeature(slice.id, { title: "F" }); + await primary.transitionLoopState(feature.id, "implementing"); + const run = await primary.startValidatorRun(feature.id, "scheduled"); + const terminalEvents: string[] = []; + primary.on("validator-run:completed", (completed) => terminalEvents.push(completed.status)); + competing.on("validator-run:completed", (completed) => terminalEvents.push(completed.status)); + + const [completion, reaping] = await Promise.all([ + primary.completeValidatorRun(run.id, "passed", "validator won"), + competing.reapValidatorRun(run.id, "reaper won"), + ]); + const persistedRun = await primary.getValidatorRun(run.id); + const persistedFeature = await primary.getFeature(feature.id); + + expect(completion.status).toBe(persistedRun?.status); + expect(reaping.status).toBe(persistedRun?.status); + expect(terminalEvents).toEqual([persistedRun?.status]); + if (persistedRun?.status === "passed") { + expect(persistedFeature?.loopState).toBe("passed"); + expect(persistedFeature?.lastValidatorStatus).toBe("passed"); + } else { + expect(persistedRun?.status).toBe("error"); + expect(persistedFeature?.loopState).toBe("needs_fix"); + expect(persistedFeature?.lastValidatorStatus).toBe("error"); + } + }); + + it("creates one generated fix and consumes one retry under concurrent stores", async () => { + const primary = missions(); + const competing = new AsyncMissionStore(h.layer(), h.store()); + const mission = await primary.createMission({ title: "Fix race" }); + const milestone = await primary.addMilestone(mission.id, { title: "MS" }); + const slice = await primary.addSlice(milestone.id, { title: "SL" }); + const feature = await primary.addFeature(slice.id, { title: "F" }); + await primary.transitionLoopState(feature.id, "implementing"); + const run = await primary.startValidatorRun(feature.id, "scheduled"); + await primary.completeValidatorRun(run.id, "failed", "repair"); + + const [first, second] = await Promise.all([ + primary.createGeneratedFixFeature(feature.id, run.id, [], "first"), + competing.createGeneratedFixFeature(feature.id, run.id, [], "second"), + ]); + + expect(first.id).toBe(second.id); + expect((await primary.getFeature(feature.id))?.implementationAttemptCount).toBe(1); + const lineageRows = await h.layer().db + .select({ id: schema.project.missionFixFeatureLineage.id }) + .from(schema.project.missionFixFeatureLineage) + .where(sql`${schema.project.missionFixFeatureLineage.sourceFeatureId} = ${feature.id} AND ${schema.project.missionFixFeatureLineage.runId} = ${run.id}`); + expect(lineageRows).toHaveLength(1); + }); + + it("persists validator failure batches and reads snapshot failures across the run set", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Bulk validator failures" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "F", acceptanceCriteria: "bulk observable" }); + const [assertion] = await m.ensureFeatureAssertionLinked(feature.id); + await m.transitionLoopState(feature.id, "implementing"); + const run = await m.startValidatorRun(feature.id, "manual"); + const failures = await m.recordValidatorFailures(run.id, Array.from({ length: 32 }, (_, index) => ({ + featureId: feature.id, + assertionId: assertion!.id, + message: `failure-${index}`, + expected: "expected", + actual: `actual-${index}`, + }))); + expect(failures).toHaveLength(32); + expect(await m.getFailuresForRun(run.id)).toHaveLength(32); + const snapshot = await m.getFeatureLoopSnapshot(feature.id); + expect(snapshot.failures.map((failure) => failure.message)).toEqual(Array.from({ length: 32 }, (_, index) => `failure-${index}`)); + }); + + it("seeds assertion batches idempotently including duplicate rows in one request", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Bulk assertion seed" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const features = await Promise.all(Array.from({ length: 12 }, (_, index) => m.addFeature(slice.id, { title: `F-${index}` }))); + const inputs = features.map((feature, index) => ({ + featureId: feature.id, + milestoneId: milestone.id, + title: `Assertion ${index}`, + assertion: `observable outcome ${index}`, + })); + inputs.push({ ...inputs[0]! }); + + /* FNXC:PostgresMissionAssertionSeeding 2026-07-14-17:55: One real-PG seed call proves multi-row creation/linking and within-batch deduplication; a second call proves durable idempotence. */ + expect(await m.seedContractAssertionsForFeatures(inputs)).toEqual({ + scanned: 13, + created: 12, + linked: 12, + skippedExisting: 1, + }); + expect(await m.seedContractAssertionsForFeatures(inputs)).toEqual({ + scanned: 13, + created: 0, + linked: 0, + skippedExisting: 13, + }); + const seeded = (await m.listContractAssertions(milestone.id)).filter((assertion) => assertion.title.startsWith("Assertion ")); + expect(seeded).toHaveLength(12); + for (const feature of features) { + expect((await m.listAssertionsForFeature(feature.id)).filter((assertion) => assertion.title.startsWith("Assertion "))).toHaveLength(1); + } + }); + + it("derives task goal provenance through its owning mission", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Goal provenance" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Feature" }); + const task = await h.store().createTask({ description: "mission delivery" }); + const now = new Date().toISOString(); + await h.store().getAsyncLayer()!.db.insert(schema.project.goals).values({ + id: "G-TASK-PROVENANCE", + title: "Task goal", + description: null, + status: "active", + createdAt: now, + updatedAt: now, + }); + await m.linkGoal(mission.id, "G-TASK-PROVENANCE"); + await m.linkFeatureToTask(feature.id, task.id); + + expect(await m.listGoalIdsForTask(task.id)).toEqual(["G-TASK-PROVENANCE"]); + expect((await m.listGoalsForTask(task.id)).map((goal) => goal.id)).toEqual(["G-TASK-PROVENANCE"]); + }); + it("computeMissionStatus reflects milestone state", async () => { const m = missions(); const mission = await m.createMission({ title: "Status" }); diff --git a/packages/core/src/__tests__/postgres/operational-maintenance.pg.test.ts b/packages/core/src/__tests__/postgres/operational-maintenance.pg.test.ts new file mode 100644 index 0000000000..ce19a4a069 --- /dev/null +++ b/packages/core/src/__tests__/postgres/operational-maintenance.pg.test.ts @@ -0,0 +1,145 @@ +/** + * FNXC:PostgresRetention 2026-07-14-18:15: + * Operational retention must delete only rows older than the cutoff in the bound project. Newer rows and equally old rows owned by another project must survive the same maintenance pass. + */ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { eq, inArray } from "drizzle-orm"; +import * as schema from "../../postgres/schema/index.js"; +import { pruneOperationalLogsAsync } from "../../task-store/async-maintenance.js"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +pgDescribe("PostgreSQL operational maintenance", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_maintenance" }); + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("prunes expired rows only from the bound project", async () => { + const old = new Date(Date.now() - 10 * 86_400_000).toISOString(); + const recent = new Date().toISOString(); + await h.adminDb().insert(schema.project.activityLog).values([ + { projectId: "project-a", id: "a-old", timestamp: old, type: "test", details: "old" }, + { projectId: "project-a", id: "a-new", timestamp: recent, type: "test", details: "new" }, + { projectId: "project-b", id: "b-old", timestamp: old, type: "test", details: "other project" }, + ]); + + const result = await pruneOperationalLogsAsync({ ...h.layer(), projectId: "project-a" }, 86_400_000); + expect(result.deletedByTable.activityLog).toBe(1); + const remaining = await h.adminDb() + .select({ id: schema.project.activityLog.id }) + .from(schema.project.activityLog) + .where(inArray(schema.project.activityLog.id, ["a-old", "a-new", "b-old"])); + expect(remaining.map((row) => row.id).sort()).toEqual(["a-new", "b-old"]); + + await h.adminDb().delete(schema.project.activityLog).where(eq(schema.project.activityLog.type, "test")); + }); + + it("returns an aggregate delete count for a large expired set", async () => { + const old = new Date(Date.now() - 10 * 86_400_000).toISOString(); + await h.adminDb().insert(schema.project.activityLog).values( + Array.from({ length: 120 }, (_, index) => ({ + projectId: "project-a", + id: `aggregate-old-${index}`, + timestamp: old, + type: "aggregate-test", + details: "expired", + })), + ); + const result = await pruneOperationalLogsAsync({ ...h.layer(), projectId: "project-a" }, 86_400_000); + expect(result.deletedByTable.activityLog).toBe(120); + expect(result.deletedTotal).toBeGreaterThanOrEqual(120); + }); + + it("covers every operational table while retaining recent rows and each agent's newest revision", async () => { + /* + FNXC:PostgresRetentionCoverage 2026-07-14-18:55: + Retention is an operational contract for every migrated history table, not only activity_log. Exercise every delete branch with real PostgreSQL rows so column drift and project-scope regressions cannot silently disable cleanup. + */ + const old = new Date(Date.now() - 10 * 86_400_000).toISOString(); + const lessOld = new Date(Date.now() - 9 * 86_400_000).toISOString(); + const recent = new Date().toISOString(); + const projectId = h.layer().projectId?.trim() || "__legacy_unscoped__"; + const task = await h.store().createTask({ description: "Retention owner task" }); + await h.adminDb().insert(schema.project.agents).values({ + projectId, + id: "retention-agent", + name: "Retention agent", + role: "executor", + createdAt: old, + updatedAt: recent, + }); + await h.adminDb().insert(schema.project.runAuditEvents).values([ + { id: "audit-old", timestamp: old, taskId: task.id, agentId: "retention-agent", runId: "run", domain: "task", mutationType: "test", target: task.id }, + { id: "audit-new", timestamp: recent, taskId: task.id, agentId: "retention-agent", runId: "run", domain: "task", mutationType: "test", target: task.id }, + ]); + await h.adminDb().insert(schema.project.agentHeartbeats).values([ + { projectId, agentId: "retention-agent", timestamp: old, status: "idle", runId: "run-old" }, + { projectId, agentId: "retention-agent", timestamp: recent, status: "idle", runId: "run-new" }, + ]); + await h.adminDb().insert(schema.project.agentRuns).values([ + { projectId, id: "agent-run-old", agentId: "retention-agent", data: {}, startedAt: old, endedAt: old, status: "complete" }, + { projectId, id: "agent-run-new", agentId: "retention-agent", data: {}, startedAt: recent, endedAt: recent, status: "complete" }, + { projectId, id: "agent-run-active", agentId: "retention-agent", data: {}, startedAt: old, endedAt: null, status: "running" }, + ]); + await h.adminDb().insert(schema.project.agentConfigRevisions).values([ + { projectId, id: "revision-old", agentId: "retention-agent", data: {}, createdAt: old }, + { projectId, id: "revision-newest", agentId: "retention-agent", data: {}, createdAt: lessOld }, + ]); + await h.adminDb().insert(schema.project.usageEvents).values([ + { projectId, ts: old, kind: "test", taskId: task.id }, + { projectId, ts: recent, kind: "test", taskId: task.id }, + ]); + + const result = await pruneOperationalLogsAsync({ ...h.layer(), projectId }, 86_400_000); + + expect(result.deletedByTable).toMatchObject({ + runAuditEvents: 1, + agentHeartbeats: 1, + agentRuns: 1, + agentConfigRevisions: 1, + usageEvents: 1, + }); + expect((await h.adminDb().select().from(schema.project.runAuditEvents).where(inArray(schema.project.runAuditEvents.id, ["audit-old", "audit-new"]))).map((row) => row.id)).toEqual(["audit-new"]); + expect((await h.adminDb().select().from(schema.project.agentRuns).where(inArray(schema.project.agentRuns.id, ["agent-run-old", "agent-run-new", "agent-run-active"]))).map((row) => row.id).sort()).toEqual(["agent-run-active", "agent-run-new"]); + expect((await h.adminDb().select().from(schema.project.agentConfigRevisions).where(inArray(schema.project.agentConfigRevisions.id, ["revision-old", "revision-newest"]))).map((row) => row.id)).toEqual(["revision-newest"]); + }); + + it("warns before using the legacy project sentinel and reports camelCase metric keys", async () => { + /* + FNXC:PostgresRetention 2026-07-14-21:55: + An unbound retention pass remains compatible with legacy-unscoped data, but operators must see the scope fallback and receive the same camelCase metric naming used by every other maintenance table. + */ + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + try { + const result = await pruneOperationalLogsAsync({ ...h.layer(), projectId: undefined }, 86_400_000); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("legacy unscoped project sentinel")); + expect(result.deletedByTable).toHaveProperty("usageEvents"); + expect(result.deletedByTable).not.toHaveProperty("usage_events"); + } finally { + warn.mockRestore(); + } + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])("treats invalid retention %s as a no-op", async (retentionMs) => { + const old = new Date(Date.now() - 10 * 86_400_000).toISOString(); + const id = `invalid-retention-${String(retentionMs)}`; + await h.adminDb().insert(schema.project.activityLog).values({ + projectId: "project-a", + id, + timestamp: old, + type: "invalid-retention-test", + details: "must survive", + }); + + expect(await pruneOperationalLogsAsync({ ...h.layer(), projectId: "project-a" }, retentionMs)).toEqual({ + deletedByTable: {}, + deletedTotal: 0, + }); + expect(await h.adminDb().select().from(schema.project.activityLog).where(eq(schema.project.activityLog.id, id))).toHaveLength(1); + }); +}); diff --git a/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts b/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts new file mode 100644 index 0000000000..672d68c3ac --- /dev/null +++ b/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, + runLoadedPluginSchemaInitHooks, + validatePluginPostgresSchema, +} from "../../postgres/plugin-schema-hook.js"; + +describe("PostgreSQL plugin schema registry", () => { + /* + FNXC:PluginPostgresSchema 2026-07-14-18:45: + Every bundled legacy onSchemaInit declaration requires a named PostgreSQL equivalent. Derive the declarations from the bundled plugin entrypoints so adding a hook cannot leave a second hardcoded inventory green after the cutover. + */ + it("registers every bundled plugin that declares onSchemaInit", () => { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../.."); + const pluginsRoot = join(repoRoot, "plugins"); + const declaredLegacyHooks = readdirSync(pluginsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith("fusion-plugin-")) + .flatMap((entry) => { + const source = readFileSync(join(pluginsRoot, entry.name, "src", "index.ts"), "utf8"); + if (!/\bonSchemaInit\s*:/.test(source)) return []; + const pluginId = source.match(/\bid\s*:\s*["']([^"']+)["']/)?.[1]; + if (!pluginId) throw new Error(`Bundled plugin ${entry.name} declares onSchemaInit without a literal manifest id`); + return [pluginId]; + }) + .sort(); + const registered = new Set(DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS.map((hook) => hook.pluginId)); + + expect(declaredLegacyHooks).not.toHaveLength(0); + expect(declaredLegacyHooks.filter((pluginId) => !registered.has(pluginId))).toEqual([]); + }); + + it("runs the registered PostgreSQL hook instead of the legacy callback", async () => { + const execute = vi.fn().mockResolvedValue([]); + const legacy = vi.fn(); + await runLoadedPluginSchemaInitHooks({ execute } as never, [{ + pluginId: "fusion-plugin-even-realities-glasses", + hook: legacy, + }]); + expect(execute).toHaveBeenCalledWith(expect.objectContaining({})); + expect(legacy).not.toHaveBeenCalled(); + }); + + it("rejects a SQLite-only plugin without a PostgreSQL contract", async () => { + await expect(runLoadedPluginSchemaInitHooks({} as never, [{ + pluginId: "third-party-sqlite-only", + hook: vi.fn(), + }])).rejects.toThrow( + 'Plugin "third-party-sqlite-only" declares legacy SQLite onSchemaInit but has no registered PostgreSQL schema hook', + ); + }); + + /* FNXC:PluginPostgresContract 2026-07-14-18:32: External plugins use declarative, project-owned DDL; Fusion applies isolation with its privileged executor without handing the plugin a database connection. */ + it("runs a third-party declarative schema and installs its isolation envelope", async () => { + const execute = vi.fn().mockResolvedValue([]); + const definition = { + version: 1, + tablePrefix: "external_fixture_", + statements: [ + "CREATE TABLE IF NOT EXISTS project.external_fixture_rows (project_id text NOT NULL, id text NOT NULL, PRIMARY KEY (project_id, id))", + "CREATE INDEX IF NOT EXISTS idx_external_fixture_rows ON project.external_fixture_rows(project_id, id)", + ], + } as const; + + await runLoadedPluginSchemaInitHooks({ execute } as never, [{ + pluginId: "external-fixture", + postgresSchema: definition, + }]); + + expect(execute).toHaveBeenCalledTimes(3); + }); + + it("rejects unscoped or privileged third-party DDL", () => { + expect(() => validatePluginPostgresSchema("external-fixture", { + version: 1, + tablePrefix: "bad_", + statements: ["CREATE TABLE IF NOT EXISTS public.bad_rows (id text PRIMARY KEY)"], + })).toThrow("project schema"); + expect(() => validatePluginPostgresSchema("external-fixture", { + version: 1, + tablePrefix: "bad_", + statements: ["CREATE TABLE IF NOT EXISTS project.bad_rows (id text PRIMARY KEY)"], + })).toThrow("project_id text NOT NULL"); + expect(() => validatePluginPostgresSchema("external-fixture", { + version: 1, + tablePrefix: "bad_", + statements: ["DROP TABLE project.tasks"], + })).toThrow("project schema"); + }); +}); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index cae8364cef..9a72994159 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -36,6 +36,7 @@ import { LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION, MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION, MULTI_PROJECT_CUTOVER_SCHEMA_VERSION, + MISSION_FIX_IDEMPOTENCY_VERSION, PROJECT_OWNERSHIP_SCHEMA_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, @@ -82,7 +83,12 @@ describe("schema-applier: immutable migration identities", () => { it("keeps session advisor enabled column assigned to version 0008", () => { expect(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION).toBe("0008"); - expect(SCHEMA_BASELINE_VERSION).toBe(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); + expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION)); + }); + + it("keeps mission fix idempotency assigned to version 0009", () => { + expect(MISSION_FIX_IDEMPOTENCY_VERSION).toBe("0009"); + expect(SCHEMA_BASELINE_VERSION).toBe(MISSION_FIX_IDEMPOTENCY_VERSION); }); }); @@ -935,7 +941,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { const versions = (await ctx.db.execute(sql` SELECT version FROM public.fusion_schema_migrations ORDER BY version `)) as unknown as Array<{ version: string }>; - expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]); + expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -959,14 +965,14 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { applySchemaBaseline(ctx.db, { pluginHooks: [] }), ]); expect(results.filter(({ applied }) => applied)).toHaveLength(1); - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]); }); it("upgrades a 0001 database by backfilling analytics ownership", async () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0002', '0003', '0004', '0005', '0006', '0007', '0008'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0002', '0003', '0004', '0005', '0006', '0007', '0008', '0009'); DROP POLICY fusion_project_isolation ON project.activity_log; DROP POLICY fusion_project_isolation ON project.agent_runs; DROP POLICY fusion_project_isolation ON project.usage_events; @@ -995,7 +1001,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009"]); }); /** @@ -1006,7 +1012,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0003', '0004', '0005', '0006', '0007', '0008'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0003', '0004', '0005', '0006', '0007', '0008', '0009'); DROP POLICY fusion_project_isolation ON project.deployments; DROP POLICY fusion_project_isolation ON project.incidents; DROP POLICY fusion_project_isolation ON project.approval_request_audit_events; @@ -1033,7 +1039,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009"]); }); /* @@ -1044,7 +1050,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0004', '0005', '0006', '0007', '0008'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0004', '0005', '0006', '0007', '0008', '0009'); DROP TABLE project.project_auth_sessions; DROP TABLE project.project_auth_providers; DROP TABLE project.project_auth_memberships; @@ -1071,7 +1077,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { "project_auth_users", "task_reviewer_runs", ]); - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009"]); }); }); @@ -1371,6 +1377,45 @@ pgDescribe("schema-applier: VAL-SCHEMA-007 plugin-owned tables materialize via s ]); }); + /* FNXC:EvenRealitiesPostgres 2026-07-14-17:45: Fresh PostgreSQL databases must include the glasses notification snapshot with project-local task identity. */ + it("default plugin hooks materialize project-isolated Even Realities snapshots", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db); + const tables = (await ctx.db.execute(sql` + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'project' AND table_name = 'even_realities_seen_tasks' + `)) as unknown as Array<{ table_name: string }>; + expect(tables).toEqual([{ table_name: "even_realities_seen_tasks" }]); + await ctx.db.execute(sql` + INSERT INTO project.even_realities_seen_tasks(project_id, task_id, last_column, updated_at) + VALUES ('project-a', 'FN-1', 'todo', '2026-07-14'), + ('project-b', 'FN-1', 'done', '2026-07-14') + `); + const rows = (await ctx.db.execute(sql` + SELECT project_id, task_id FROM project.even_realities_seen_tasks ORDER BY project_id + `)) as unknown as Array<{ project_id: string; task_id: string }>; + expect(rows).toEqual([ + { project_id: "project-a", task_id: "FN-1" }, + { project_id: "project-b", task_id: "FN-1" }, + ]); + }); + + it("repairs an already-versioned database that predates the Even Realities PostgreSQL hook", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db); + await ctx.db.execute(sql`DROP TABLE project.even_realities_seen_tasks`); + + const result = await applySchemaBaseline(ctx.db); + expect(result.pluginHooksRun).toBeGreaterThan(0); + const rows = (await ctx.db.execute(sql` + SELECT c.relrowsecurity AS rls, c.relforcerowsecurity AS forced + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'project' AND c.relname = 'even_realities_seen_tasks' + `)) as unknown as Array<{ rls: boolean; forced: boolean }>; + expect(rows).toEqual([{ rls: true, forced: true }]); + }); + it("roadmap FK cascade: deleting a roadmap removes its milestones and features", async () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [roadmapPluginInitHook] }); diff --git a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts index d77010a386..8811fc3dd4 100644 --- a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts @@ -412,6 +412,7 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { const { createConnectionSetFromUrl } = await import("../../postgres/connection.js"); const { applySchemaBaseline } = await import("../../postgres/schema-applier.js"); const { stampMigratedProjectRows } = await import("../../postgres/migration-stamping.js"); + const { recordSqliteMigrationComplete } = await import("../../postgres/sqlite-migrator.js"); const { resolveBackendWithOptions } = await import("../../postgres/backend-resolver.js"); const connections = await createConnectionSetFromUrl( @@ -422,11 +423,14 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { await applySchemaBaseline(connections.migration); const db = connections.migration; - // Current migrators stamp task ownership during copy; the helper still - // re-keys historical config/workflow identities. + /* + FNXC:ProjectMigrationStamping 2026-07-14-16:50: + Migration 0006 rewrites explicit empty ownership to the quarantine before this post-copy helper runs. A unique migration-ledger owner makes those freshly quarantined rows safe to claim; a second project marker must leave later quarantine rows untouched. + */ + await recordSqliteMigrationComplete(db, "project:proj_help", "proj_help"); await db.execute( `INSERT INTO project.tasks (project_id, id, description, "column", created_at, updated_at) - VALUES ('proj_help', 'FN-HELP-1', 'd', 'todo', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z')`, + VALUES ('', 'FN-HELP-1', 'd', 'todo', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z')`, ); await db.execute( `INSERT INTO project.config (project_id, settings, updated_at) @@ -451,6 +455,32 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { VALUES ('wf_a', '${fakeRootDir}', '{"executor":"x"}'::jsonb, '2026-06-01T00:00:00Z')`, ); + /* + FNXC:ProjectMigrationStamping 2026-07-14-21:55: + Force the last table promotion to fail and prove earlier task/config/workflow updates roll back with it; a retry after removing the fault must then stamp the complete project. + */ + await db.execute(` + CREATE FUNCTION public.fail_migration_stamp_for_test() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced migration stamp failure'; END $$; + CREATE TRIGGER fail_migration_stamp_for_test + BEFORE UPDATE ON project.workflow_prompt_overrides + FOR EACH ROW EXECUTE FUNCTION public.fail_migration_stamp_for_test(); + `); + await expect(stampMigratedProjectRows(db, { projectId: "proj_help", rootDir: fakeRootDir })) + .rejects.toThrow("Failed query: UPDATE project.workflow_prompt_overrides"); + const rolledBackTask = (await db.execute( + `SELECT project_id FROM project.tasks WHERE id = 'FN-HELP-1'`, + )) as unknown as Array<{ project_id: string }>; + expect(rolledBackTask[0]?.project_id).toBe("__legacy_unscoped__"); + const rolledBackWorkflow = (await db.execute( + `SELECT project_id FROM project.workflow_settings WHERE workflow_id = 'wf_a'`, + )) as unknown as Array<{ project_id: string }>; + expect(rolledBackWorkflow[0]?.project_id).toBe(fakeRootDir); + await db.execute(` + DROP TRIGGER fail_migration_stamp_for_test ON project.workflow_prompt_overrides; + DROP FUNCTION public.fail_migration_stamp_for_test(); + `); + const result = await stampMigratedProjectRows(db, { projectId: "proj_help", rootDir: fakeRootDir }); expect(result.stamped).toBe(true); @@ -486,6 +516,17 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { )) as unknown as Array<{ project_id: string }>; expect(overrides[0]?.project_id).toBe("proj_help"); + await recordSqliteMigrationComplete(db, "project:other", "other"); + await db.execute( + `INSERT INTO project.tasks (project_id, id, description, "column", created_at, updated_at) + VALUES ('', 'FN-AMBIGUOUS', 'd', 'todo', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z')`, + ); + await stampMigratedProjectRows(db, { projectId: "proj_help", rootDir: fakeRootDir }); + const ambiguous = (await db.execute( + `SELECT project_id FROM project.tasks WHERE id = 'FN-AMBIGUOUS'`, + )) as unknown as Array<{ project_id: string }>; + expect(ambiguous[0]?.project_id, "multi-project quarantine must not be claimed").toBe("__legacy_unscoped__"); + // No-op when projectId is empty. const noop = await stampMigratedProjectRows(db, { projectId: "", rootDir: fakeRootDir }); expect(noop.stamped).toBe(false); diff --git a/packages/core/src/__tests__/postgres/startup-factory.test.ts b/packages/core/src/__tests__/postgres/startup-factory.test.ts index 2091316549..8694d08328 100644 --- a/packages/core/src/__tests__/postgres/startup-factory.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory.test.ts @@ -3,15 +3,16 @@ * Tests for the runtime startup factory (createTaskStoreForBackend). * * Post default-flip (flip-embedded-pg-default), embedded PostgreSQL is the - * DEFAULT backend when DATABASE_URL is unset. FUSION_NO_EMBEDDED_PG=1 is the - * opt-out back to legacy SQLite. These gate-relevant tests assert the + * DEFAULT backend when DATABASE_URL is unset. The former + * FUSION_NO_EMBEDDED_PG escape hatch is rejected after the final cutover so + * production cannot silently return to a removed SQLite runtime. These + * gate-relevant tests assert the * resolution contract without requiring a real embedded boot (the merge gate * must stay green without running initdb): * - isEmbeddedPgRequested / isEmbeddedPgOptedOut resolution (opt-out * semantics: embedded is on by default unless opted out). - * - shouldUsePostgresBackend resolution (true by default; false only on opt-out). - * - createTaskStoreForBackend returns null ONLY when the operator opted out - * (FUSION_NO_EMBEDDED_PG=1) or passed embeddedPgRequested:false. + * - shouldUsePostgresBackend always selects PostgreSQL. + * - createTaskStoreForBackend rejects obsolete SQLite opt-out controls. * - createTaskStoreForBackend requires rootDir when projectId is absent. * * The external-mode and embedded-boot integration tests (real PG / real initdb) @@ -34,9 +35,8 @@ import { resolveBackend } from "../../postgres/backend-resolver.js"; describe("startup-factory: isEmbeddedPgOptedOut (FUSION_NO_EMBEDDED_PG)", () => { // FNXC:BackendFlip 2026-06-26-15:00: - // Post default-flip, the opt-out is the single control. Truthy values opt - // OUT of embedded PG (back to legacy SQLite); everything else keeps the - // embedded default. + // The parser remains for a precise startup diagnostic. Truthy values detect + // the removed opt-out configuration; createTaskStoreForBackend rejects it. const cases: Array<[string, boolean]> = [ ["1", true], ["true", true], @@ -53,7 +53,7 @@ describe("startup-factory: isEmbeddedPgOptedOut (FUSION_NO_EMBEDDED_PG)", () => ]; for (const [raw, expected] of cases) { - it(`treats FUSION_NO_EMBEDDED_PG="${raw}" as ${expected ? "opted-out (legacy SQLite)" : "not opted-out (embedded PG default)"}`, () => { + it(`treats FUSION_NO_EMBEDDED_PG="${raw}" as ${expected ? "obsolete opt-out configured" : "embedded PG default"}`, () => { expect(isEmbeddedPgOptedOut({ [NO_EMBEDDED_PG_ENV]: raw })).toBe(expected); }); } @@ -67,12 +67,13 @@ describe("startup-factory: isEmbeddedPgOptedOut (FUSION_NO_EMBEDDED_PG)", () => describe("startup-factory: isEmbeddedPgRequested (inverted: default-on)", () => { // FNXC:BackendFlip 2026-06-26-15:00: // isEmbeddedPgRequested is now the logical inverse of isEmbeddedPgOptedOut: - // embedded PG is requested (used) UNLESS FUSION_NO_EMBEDDED_PG opts out. + // embedded PG is requested UNLESS the obsolete opt-out is present; startup + // rejects that configuration instead of selecting another backend. it("returns true by default (embedded PG is the default backend)", () => { expect(isEmbeddedPgRequested({})).toBe(true); }); - it("returns false when FUSION_NO_EMBEDDED_PG=1 is set (opt-out to legacy SQLite)", () => { + it("returns false when obsolete FUSION_NO_EMBEDDED_PG=1 is detected", () => { expect(isEmbeddedPgRequested({ [NO_EMBEDDED_PG_ENV]: "1" })).toBe(false); }); @@ -88,7 +89,7 @@ describe("startup-factory: isEmbeddedPgRequested (inverted: default-on)", () => // FNXC:BackendFlip 2026-06-26-15:00: // Setting FUSION_EMBEDDED_PG=1 used to opt in; now it is a no-op because // embedded is already the default. Setting it to 0 also does nothing - // (it cannot opt out — only FUSION_NO_EMBEDDED_PG can). + // (it cannot opt out; the former opt-out now fails startup). expect(isEmbeddedPgRequested({ [EMBEDDED_PG_ENV]: "1" })).toBe(true); expect(isEmbeddedPgRequested({ [EMBEDDED_PG_ENV]: "0" })).toBe(true); }); @@ -112,14 +113,14 @@ describe("startup-factory: shouldUsePostgresBackend", () => { expect(shouldUsePostgresBackend({ DATABASE_URL: " " })).toBe(true); }); - it("returns false when DATABASE_URL is unset AND FUSION_NO_EMBEDDED_PG=1 (opt-out)", () => { + it("returns true when the obsolete SQLite opt-out is present", () => { expect( shouldUsePostgresBackend({ [NO_EMBEDDED_PG_ENV]: "1" }), - ).toBe(false); + ).toBe(true); }); - it("returns false when embeddedPgRequested override is false (force legacy SQLite)", () => { - expect(shouldUsePostgresBackend({}, { embeddedPgRequested: false })).toBe(false); + it("returns true when the obsolete embedded override is false", () => { + expect(shouldUsePostgresBackend({}, { embeddedPgRequested: false })).toBe(true); }); it("returns true when embeddedPgRequested override is true (force embedded)", () => { @@ -138,33 +139,26 @@ describe("startup-factory: createTaskStoreForBackend resolution (no real boot)", await rm(rootDir, { recursive: true, force: true }); }); - it("returns null when FUSION_NO_EMBEDDED_PG=1 opts out (legacy SQLite path)", async () => { - // FNXC:BackendFlip 2026-06-26-15:00: - // The ONLY way to get the legacy SQLite null result post default-flip is - // the explicit opt-out. This keeps the gate fast (no initdb) for tests - // that need the legacy path. - const result = await createTaskStoreForBackend({ + it("rejects FUSION_NO_EMBEDDED_PG instead of falling back to SQLite", async () => { + await expect(createTaskStoreForBackend({ rootDir, - env: { [NO_EMBEDDED_PG_ENV]: "1" }, // no DATABASE_URL, opt-out - }); - expect(result).toBeNull(); + env: { [NO_EMBEDDED_PG_ENV]: "1" }, + })).rejects.toThrow(/SQLite opt-out.*removed/i); }); - it("returns null when DATABASE_URL is whitespace and FUSION_NO_EMBEDDED_PG=1", async () => { - const result = await createTaskStoreForBackend({ + it("rejects the SQLite opt-out when DATABASE_URL is whitespace", async () => { + await expect(createTaskStoreForBackend({ rootDir, env: { DATABASE_URL: " ", [NO_EMBEDDED_PG_ENV]: "1" }, - }); - expect(result).toBeNull(); + })).rejects.toThrow(/SQLite opt-out.*removed/i); }); - it("returns null when embeddedPgRequested override is false (force legacy SQLite)", async () => { - const result = await createTaskStoreForBackend({ + it("rejects embeddedPgRequested:false instead of forcing SQLite", async () => { + await expect(createTaskStoreForBackend({ rootDir, env: {}, embeddedPgRequested: false, - }); - expect(result).toBeNull(); + })).rejects.toThrow(/SQLite opt-out.*removed/i); }); it("throws when rootDir is missing and projectId is absent (and PG is requested)", async () => { @@ -176,13 +170,10 @@ describe("startup-factory: createTaskStoreForBackend resolution (no real boot)", ).rejects.toThrow(/rootDir is required/i); }); - it("does not throw on the legacy SQLite opt-out path even without rootDir (short-circuits before the guard)", async () => { - // FNXC:BackendFlip 2026-06-26-15:00: - // Opt-out path: returns null before reaching the rootDir guard. - const result = await createTaskStoreForBackend({ + it("rejects the removed SQLite opt-out before validating a project root", async () => { + await expect(createTaskStoreForBackend({ env: { [NO_EMBEDDED_PG_ENV]: "1" }, - }); - expect(result).toBeNull(); + })).rejects.toThrow(/SQLite opt-out.*removed/i); }); }); diff --git a/packages/core/src/__tests__/postgres/store-archive-reads.pg.test.ts b/packages/core/src/__tests__/postgres/store-archive-reads.pg.test.ts new file mode 100644 index 0000000000..2c28709bba --- /dev/null +++ b/packages/core/src/__tests__/postgres/store-archive-reads.pg.test.ts @@ -0,0 +1,181 @@ +/** + * FNXC:PostgresArchiveReads 2026-07-14-17:07: + * PostgreSQL cold storage is part of the public TaskStore read model. After a real archiveTask call, includeArchived list/search and task detail must read the archive snapshot, while active-only reads must continue to exclude it. Merged pagination is applied after active and archived results are composed so page boundaries cannot silently drop cold-storage tasks. + */ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import * as schema from "../../postgres/schema/index.js"; +import { findArchivedTaskEntry } from "../../task-store/async-archive-lineage.js"; + +pgDescribe("TaskStore archived read parity (PostgreSQL)", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_archive_reads", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("composes archived snapshots into list, search, and detail reads", async () => { + const store = h.store(); + const first = await store.createTaskWithReservedId( + { description: "active alpha", column: "todo" }, + { + taskId: "FN-101", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + applyDefaultWorkflowSteps: false, + }, + ); + const archivedSource = await store.createTaskWithReservedId( + { description: "cold-storage-needle beta", column: "done" }, + { + taskId: "FN-102", + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + applyDefaultWorkflowSteps: false, + }, + ); + const last = await store.createTaskWithReservedId( + { description: "active gamma", column: "todo" }, + { + taskId: "FN-103", + createdAt: "2026-01-03T00:00:00.000Z", + updatedAt: "2026-01-03T00:00:00.000Z", + applyDefaultWorkflowSteps: false, + }, + ); + await store.archiveTask(archivedSource.id, { cleanup: false }); + + expect((await store.listTasks({ includeArchived: false })).map((task) => task.id)).toEqual([ + first.id, + last.id, + ]); + expect((await store.listTasks({ includeArchived: true })).map((task) => task.id)).toEqual([ + first.id, + archivedSource.id, + last.id, + ]); + expect((await store.listTasks({ includeArchived: true, column: "archived" })).map((task) => task.id)).toEqual([ + archivedSource.id, + ]); + expect((await store.listTasks({ includeArchived: true, limit: 1, offset: 1 })).map((task) => task.id)).toEqual([ + archivedSource.id, + ]); + + const slim = await store.listTasks({ includeArchived: true, column: "archived", slim: true }); + expect(slim[0]?.log).toEqual([]); + const full = await store.listTasks({ includeArchived: true, column: "archived", slim: false }); + expect(full[0]?.log).not.toEqual([]); + + expect(await store.searchTasks("cold-storage-needle", { includeArchived: false })).toEqual([]); + expect((await store.searchTasks("cold-storage-needle", { includeArchived: true })).map((task) => task.id)).toEqual([ + archivedSource.id, + ]); + expect((await store.searchTasks("alpha cold-storage-needle", { + includeArchived: true, + limit: 1, + offset: 1, + })).map((task) => task.id)).toEqual([archivedSource.id]); + + const detail = await store.getTask(archivedSource.id); + expect(detail.id).toBe(archivedSource.id); + expect(detail.column).toBe("archived"); + expect(detail.description).toBe("cold-storage-needle beta"); + expect(detail.prompt).toContain("cold-storage-needle beta"); + }); + + it("keeps globally ordered pages exact across multiple live/cold boundaries", async () => { + const store = h.store(); + const tasks = []; + for (let index = 1; index <= 12; index += 1) { + tasks.push(await store.createTaskWithReservedId( + { description: `bounded-page-probe ${index}`, column: index % 2 === 0 ? "todo" : "done" }, + { + taskId: `FN-${200 + index}`, + createdAt: `2026-02-${String(index).padStart(2, "0")}T00:00:00.000Z`, + updatedAt: `2026-02-${String(index).padStart(2, "0")}T00:00:00.000Z`, + applyDefaultWorkflowSteps: false, + }, + )); + } + for (const task of tasks.filter((_, index) => index % 2 === 0)) { + await store.archiveTask(task.id, { cleanup: false }); + } + + /* + FNXC:PostgresArchiveReadPerformance 2026-07-14-17:50: + Small pages that cross several live/cold boundaries must remain identical to a complete globally ordered merge; bounding each source query must never shift or omit a row at the page edge. + */ + expect((await store.listTasks({ includeArchived: true, offset: 7, limit: 3 })).map((task) => task.id)).toEqual([ + "FN-208", + "FN-209", + "FN-210", + ]); + expect((await store.searchTasks("bounded-page-probe", { includeArchived: true, offset: 5, limit: 3 })).map((task) => task.id)).toEqual([ + "FN-212", + "FN-211", + "FN-209", + ]); + }); + + /* + FNXC:ArchiveRestore 2026-07-14-21:48: + Cold storage is sufficient to reconstruct a task whose project.tasks row was removed by cleanup. Unarchive must materialize the snapshot before consuming it, while the pre-existing live archived-row path remains supported without requiring a snapshot. + */ + it("rebuilds a missing live row before consuming its archive snapshot", async () => { + const store = h.store(); + const task = await store.createTaskWithReservedId( + { description: "restore from snapshot only", column: "done" }, + { taskId: "FN-301", applyDefaultWorkflowSteps: false }, + ); + await store.archiveTask(task.id, { cleanup: false }); + expect(await findArchivedTaskEntry(h.layer().db, task.id, h.layer().projectId)).toBeDefined(); + + await h.adminDb() + .delete(schema.project.tasks) + .where(and( + eq(schema.project.tasks.projectId, h.layer().projectId ?? "__legacy_unscoped__"), + eq(schema.project.tasks.id, task.id), + )); + + const persistRestoredRow = store.atomicWriteTaskJson.bind(store); + const persistSpy = vi.spyOn(store, "atomicWriteTaskJson").mockImplementation(async (dir, restoredTask) => { + await persistRestoredRow(dir, restoredTask); + const durableRows = await h.adminDb() + .select({ id: schema.project.tasks.id }) + .from(schema.project.tasks) + .where(and( + eq(schema.project.tasks.projectId, h.layer().projectId ?? "__legacy_unscoped__"), + eq(schema.project.tasks.id, task.id), + )); + expect(durableRows).toHaveLength(1); + expect(await findArchivedTaskEntry(h.layer().db, task.id, h.layer().projectId)).toBeDefined(); + }); + + const restored = await store.unarchiveTask(task.id); + expect(persistSpy).toHaveBeenCalledOnce(); + expect(restored.id).toBe(task.id); + expect(restored.description).toBe("restore from snapshot only"); + expect(restored.column).toBe("todo"); + expect(await findArchivedTaskEntry(h.layer().db, task.id, h.layer().projectId)).toBeUndefined(); + }); + + it("keeps the existing live archived-row unarchive path", async () => { + const store = h.store(); + const task = await store.createTaskWithReservedId( + { description: "live archived row", column: "archived" }, + { taskId: "FN-302", applyDefaultWorkflowSteps: false }, + ); + + const restored = await store.unarchiveTask(task.id); + expect(restored.id).toBe(task.id); + expect(restored.column).toBe("todo"); + }); +}); diff --git a/packages/core/src/__tests__/postgres/store-safe-defaults.pg.test.ts b/packages/core/src/__tests__/postgres/store-safe-defaults.pg.test.ts new file mode 100644 index 0000000000..17479a3c6b --- /dev/null +++ b/packages/core/src/__tests__/postgres/store-safe-defaults.pg.test.ts @@ -0,0 +1,138 @@ +/** + * FNXC:PostgresSafeDefaults 2026-07-14-17:36: + * PostgreSQL production paths must execute durable cleanup and safety invariants instead of returning empty safe defaults. This suite covers authoritative audit reads, deleted-branch reference cleanup, archived write rejection, and soft-delete column repair through the public TaskStore seams. + */ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import * as schema from "../../postgres/schema/index.js"; +import { ChatStore } from "../../chat-store.js"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +pgDescribe("TaskStore PostgreSQL safe-default removal", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_safe_defaults" }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("reads run audit authoritatively and clears stale execution-start branches", async () => { + const store = h.store(); + const owner = await store.createTask({ description: "branch owner" }); + const dependent = await store.createTask({ description: "branch dependent" }); + await store.updateTask(owner.id, { executionStartBranch: "fusion/deleted" }); + await store.updateTask(dependent.id, { executionStartBranch: "fusion/deleted" }); + + await store.recordRunAuditEvent({ + taskId: dependent.id, + agentId: "test", + runId: "safe-default-audit", + domain: "database", + mutationType: "task:updated", + target: dependent.id, + }); + expect((await store.getRunAuditEventsAsync({ runId: "safe-default-audit" })).map((event) => event.target)).toEqual([dependent.id]); + + expect(await store.clearStaleExecutionStartBranchReferences(["fusion/deleted"], owner.id)).toEqual([dependent.id]); + expect((await store.getTask(owner.id)).executionStartBranch).toBe("fusion/deleted"); + expect((await store.getTask(dependent.id)).executionStartBranch).toBeUndefined(); + }); + + it("keeps archived logs, comments, documents, and artifacts read-only", async () => { + const store = h.store(); + const task = await store.createTask({ description: "archive safety" }); + const commented = await store.addComment(task.id, "before archive", "user"); + const commentId = commented.comments?.[0]?.id; + expect(commentId).toBeTruthy(); + await store.upsertTaskDocument(task.id, { key: "spec", content: "before archive" }); + const artifact = await store.registerArtifact({ + type: "document", + title: "before archive", + content: "body", + authorId: "user", + authorType: "user", + taskId: task.id, + }); + await store.archiveTask(task.id, { cleanup: false }); + + await expect(store.logEntry(task.id, "must reject")).rejects.toThrow(/archived.*read-only/); + await expect(store.addComment(task.id, "must reject", "user")).rejects.toThrow(/archived.*read-only/); + await expect(store.updateTaskComment(task.id, commentId!, "must reject")).rejects.toThrow(/archived.*read-only/); + await expect(store.deleteTaskComment(task.id, commentId!)).rejects.toThrow(/archived.*read-only/); + await expect(store.upsertTaskDocument(task.id, { key: "spec", content: "must reject" })).rejects.toThrow(/archived.*read-only/); + await expect(store.deleteTaskDocument(task.id, "spec")).rejects.toThrow(/archived.*read-only/); + await expect(store.updateArtifact(artifact.id, { title: "must reject" })).rejects.toThrow(/archived.*read-only/); + await expect(store.registerArtifact({ + type: "document", + title: "must reject", + content: "body", + authorId: "user", + authorType: "user", + taskId: task.id, + })).rejects.toThrow(/archived.*read-only/); + expect(await store.getTaskDocuments(task.id)).toEqual([]); + expect(await store.getArtifacts(task.id)).toEqual([]); + }); + + it("repairs soft-deleted task column drift and audits the repaired row", async () => { + const store = h.store(); + const task = await store.createTask({ description: "drift repair" }); + await store.archiveTask(task.id, { cleanup: false }); + await h.layer().db + .update(schema.project.tasks) + .set({ column: "todo" }) + .where(eq(schema.project.tasks.id, task.id)); + const audited: Array<{ id: string; previousColumn: string }> = []; + + expect(await store.reconcileSoftDeletedColumnDriftBackend(async (candidate) => { + audited.push(candidate); + })).toEqual({ reconciled: 1 }); + expect(audited).toEqual([{ id: task.id, previousColumn: "todo" }]); + const [row] = await h.layer().db + .select({ column: schema.project.tasks.column }) + .from(schema.project.tasks) + .where(eq(schema.project.tasks.id, task.id)); + expect(row?.column).toBe("archived"); + }); + + it("clears live near-duplicate metadata when its canonical task becomes inactive", async () => { + const store = h.store(); + const canonical = await store.createTask({ description: "canonical" }); + const duplicate = await store.createTask({ description: "duplicate" }); + await store.updateTask(duplicate.id, { + sourceMetadataPatch: { + nearDuplicateOf: canonical.id, + nearDuplicateScore: 0.92, + nearDuplicateSharedTokens: ["same"], + retained: true, + }, + }); + + expect((await store.clearNearDuplicateReferencesTo(canonical.id, { + column: "done", + reason: "completed", + })).map((task) => task.id)).toEqual([duplicate.id]); + const updated = await store.getTask(duplicate.id); + expect(updated.sourceMetadata).toEqual({ retained: true }); + expect(updated.log.at(-1)?.action).toContain("cleared duplicate flag"); + }); + + it("persists and lists chat token usage without a fire-and-forget race", async () => { + const chat = new ChatStore(h.layer()); + const recorded = await chat.recordTokenUsage({ + sourceKind: "chat", + inputTokens: 12, + outputTokens: 7, + cachedTokens: 3, + cacheWriteTokens: 0, + agentId: "agent-pg", + createdAt: "2026-07-14T18:49:00.000Z", + }); + + expect((await chat.listTokenUsageAsync()).map((entry) => entry.id)).toEqual([recorded?.id]); + }); +}); diff --git a/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts b/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts index 2bcc8c95ea..096f0dec9f 100644 --- a/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts +++ b/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts @@ -74,8 +74,8 @@ pgTest("VAL-CROSS-001: End-to-end task lifecycle (PostgreSQL)", () => { const archived = await store.archiveTask(task.id, { cleanup: false }); expect(archived.id).toBe(task.id); - // Archived task should not appear in default listTasks - const live = await store.listTasks(); + // FNXC:PostgresArchiveReads 2026-07-14-17:10: Active-only callers opt out of cold storage explicitly; listTasks keeps its backward-compatible includeArchived default. + const live = await store.listTasks({ includeArchived: false }); expect(live.find((t) => t.id === task.id)).toBeUndefined(); }); diff --git a/packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts b/packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts new file mode 100644 index 0000000000..6970bd16ac --- /dev/null +++ b/packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts @@ -0,0 +1,83 @@ +/** + * FNXC:PostgresWorkflowAuthority 2026-07-14-17:52: + * Workflow lifecycle guards, legacy-column evacuation, and settings exports must read PostgreSQL as the source of truth. These regressions exercise the public TaskStore and export seams so a synchronous empty fallback cannot silently bypass production behavior. + */ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../../builtin-coding-workflow-ir.js"; +import { exportSettings } from "../../settings-export.js"; +import type { WorkflowIrV2 } from "../../workflow-ir-types.js"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +function workflowWithCustomColumn(): WorkflowIrV2 { + const ir = structuredClone(BUILTIN_CODING_WORKFLOW_IR) as WorkflowIrV2; + ir.name = "postgres-authoritative-workflow"; + ir.columns.push({ id: "custom-hold", name: "Custom hold", traits: [] }); + return ir; +} + +pgDescribe("PostgreSQL workflow authoritative reads", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_workflow_authority", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("blocks removal of a PostgreSQL-occupied workflow column", async () => { + const store = h.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const ir = workflowWithCustomColumn(); + const workflow = await store.createWorkflowDefinition({ name: "Occupancy", ir, layout: {} }); + const task = await store.createTask({ description: "occupies custom column" }); + await store.selectTaskWorkflow(task.id, workflow.id); + await store.moveTask(task.id, "custom-hold", { moveSource: "engine", bypassGuards: true, recoveryRehome: true }); + + expect(await store.listWorkflowOccupantTaskIds(workflow.id, false)).toEqual([task.id]); + expect(await store.occupantsByColumnForWorkflow(workflow.id, false)).toEqual( + new Map([["custom-hold", 1]]), + ); + + const nextIr = structuredClone(ir); + nextIr.columns = nextIr.columns.filter((column) => column.id !== "custom-hold"); + await expect(store.updateWorkflowDefinition(workflow.id, { ir: nextIr })).rejects.toMatchObject({ + name: "OccupiedColumnsError", + workflowId: workflow.id, + }); + }); + + it("evacuates PostgreSQL custom-column cards to the legacy entry column", async () => { + const store = h.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const ir = workflowWithCustomColumn(); + const workflow = await store.createWorkflowDefinition({ name: "Evacuation", ir, layout: {} }); + const task = await store.createTask({ description: "must evacuate" }); + await store.selectTaskWorkflow(task.id, workflow.id); + await store.moveTask(task.id, "custom-hold", { moveSource: "engine", bypassGuards: true, recoveryRehome: true }); + + expect(await store.evacuateCustomColumnsToLegacy("flag-toggled-off")).toEqual({ + scanned: 1, + evacuated: 1, + }); + expect((await store.getTask(task.id)).column).toBe("triage"); + }); + + it("lists and exports project-scoped PostgreSQL workflow setting values", async () => { + const store = h.store(); + const projectId = store.getWorkflowSettingsProjectId(); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 420_000, + }); + + const expected = { + "builtin:coding": { workflowStepTimeoutMs: 420_000 }, + }; + expect(await store.listWorkflowSettingValuesForProject()).toEqual(expected); + expect((await exportSettings(store, { scope: "project" })).workflowSettings).toEqual(expected); + }); +}); diff --git a/packages/core/src/__tests__/project-identity.test.ts b/packages/core/src/__tests__/project-identity.test.ts index 13a39c2ddf..d033baa947 100644 --- a/packages/core/src/__tests__/project-identity.test.ts +++ b/packages/core/src/__tests__/project-identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -7,7 +7,6 @@ import { readProjectIdentity, writeProjectIdentity, } from "../project-identity.js"; -import { DatabaseSync } from "../sqlite-adapter.js"; describe("project identity", () => { it("returns null for missing db", () => { @@ -22,6 +21,9 @@ describe("project identity", () => { mkdirSync(fusionDir); writeProjectIdentity(fusionDir, { id: "proj_0123456789abcdef", createdAt: "2026-01-01T00:00:00.000Z" }); expect(readProjectIdentity(fusionDir)?.id).toBe("proj_0123456789abcdef"); + expect(JSON.parse(readFileSync(join(fusionDir, "project.json"), "utf8"))).toMatchObject({ + id: "proj_0123456789abcdef", + }); }); it("throws mismatch on different id", () => { @@ -54,9 +56,7 @@ describe("project identity", () => { const fusionDir = join(dir, ".fusion"); mkdirSync(fusionDir); writeProjectIdentity(fusionDir, { id: "proj_0123456789abcdef", createdAt: "2026-01-01T00:00:00.000Z" }); - const db = new DatabaseSync(join(fusionDir, "fusion.db")); - db.prepare("UPDATE __meta SET value = 'bad' WHERE key = 'projectId'").run(); - db.close(); + writeFileSync(join(fusionDir, "project.json"), JSON.stringify({ id: "bad", createdAt: "2026-01-01T00:00:00.000Z" })); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); expect(readProjectIdentity(fusionDir)).toBeNull(); warn.mockRestore(); diff --git a/packages/core/src/__tests__/sqlite-validation.test.ts b/packages/core/src/__tests__/sqlite-validation.test.ts index 9c5db11071..095617a1ea 100644 --- a/packages/core/src/__tests__/sqlite-validation.test.ts +++ b/packages/core/src/__tests__/sqlite-validation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { isValidSqliteDatabaseFile } from "../sqlite-validation.js"; @@ -24,13 +24,14 @@ describe("isValidSqliteDatabaseFile", () => { expect(isValidSqliteDatabaseFile(join(dir, "missing.db"))).toBe(false); }); - it("returns true for a zero-byte bootstrap database", () => { + it("accepts a zero-byte legacy bootstrap file without upgrading it", () => { const dir = makeTempDir(); dirs.push(dir); const dbPath = join(dir, "fusion.db"); writeFileSync(dbPath, ""); expect(isValidSqliteDatabaseFile(dbPath)).toBe(true); + expect(statSync(dbPath).size).toBe(0); }); it("returns false for plain text files", () => { diff --git a/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts b/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts index 5cf8ec1c78..056ac0bda8 100644 --- a/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts +++ b/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts @@ -1,6 +1,8 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, beforeAll, beforeEach, afterAll, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, afterAll, expect, it, vi } from "vitest"; +import { and, eq, inArray } from "drizzle-orm"; +import * as schema from "../postgres/schema/index.js"; import { pgDescribe, createSharedPgTaskStoreTestHarness, @@ -13,10 +15,8 @@ const pgTest = pgDescribe; * FNXC:TaskStoreConsistency 2026-07-12-00:00: * FN-7069 phantom committed-reservation archive-path tests. * The reconciliation logic (reconcilePhantomCommittedReservations) is a - * documented no-op in backend mode until the async layer gains an equivalent - * method (store.ts:686-702). Only the archive-path tests that don't depend on - * reconcile are kept; the pruning/idempotency/store-open tests are dropped - * because they test behavior that is intentionally unimplemented in PG mode. + * PostgreSQL reconciliation preserves committed reservations and audit history + * while removing orphaned task child rows. */ pgTest("TaskStore phantom committed-reservation reconciliation", () => { @@ -47,4 +47,236 @@ pgTest("TaskStore phantom committed-reservation reconciliation", () => { const archived = await store.archiveTask(task.id, false); expect(archived).toMatchObject({ id: task.id, column: "archived" }); }); + + it("prunes PostgreSQL child rows for a phantom while preserving the committed reservation", async () => { + const store = h.store(); + const layer = h.layer(); + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const task = await store.createTask({ description: "Phantom committed reservation" }); + await rm(join(h.rootDir(), ".fusion", "tasks", task.id), { recursive: true, force: true }); + await layer.db.delete(schema.project.tasks).where(and( + eq(schema.project.tasks.projectId, projectId), + eq(schema.project.tasks.id, task.id), + )); + await layer.db.insert(schema.project.activityLog).values({ + projectId, + id: `activity-${task.id}`, + timestamp: new Date().toISOString(), + type: "task:created", + taskId: task.id, + details: "orphan activity", + }); + await layer.db.insert(schema.project.agents).values({ + projectId, + id: `agent-${task.id}`, + name: "Phantom agent", + role: "executor", + taskId: task.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const result = await store.reconcilePhantomCommittedReservations(); + + expect(result.reconciled).toContain(task.id); + expect(await layer.db.select().from(schema.project.activityLog).where(eq(schema.project.activityLog.taskId, task.id))).toHaveLength(0); + expect(await layer.db.select().from(schema.project.agents).where(eq(schema.project.agents.taskId, task.id))).toHaveLength(0); + const reservations = await layer.db.select().from(schema.project.distributedTaskIdReservations).where(and( + eq(schema.project.distributedTaskIdReservations.projectId, projectId), + eq(schema.project.distributedTaskIdReservations.taskId, task.id), + )); + expect(reservations).toHaveLength(1); + expect(reservations[0]?.status).toBe("committed"); + expect(await layer.db.select().from(schema.project.runAuditEvents).where(and( + eq(schema.project.runAuditEvents.taskId, task.id), + eq(schema.project.runAuditEvents.mutationType, "task:reconcile-phantom-committed-reservation"), + ))).toHaveLength(1); + }); + + it("batch-reconciles multiple phantoms while retaining represented reservations", async () => { + const store = h.store(); + const layer = h.layer(); + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const phantomTasks = await Promise.all([ + store.createTask({ description: "Batch phantom one" }), + store.createTask({ description: "Batch phantom two" }), + ]); + const represented = await store.createTask({ description: "Still represented" }); + for (const task of phantomTasks) { + await rm(join(h.rootDir(), ".fusion", "tasks", task.id), { recursive: true, force: true }); + } + await layer.db.delete(schema.project.tasks).where(and( + eq(schema.project.tasks.projectId, projectId), + // Both IDs are removed in one setup mutation, mirroring the batch repair. + inArray(schema.project.tasks.id, phantomTasks.map((task) => task.id)), + )); + await layer.db.insert(schema.project.activityLog).values(phantomTasks.map((task) => ({ + projectId, + id: `batch-activity-${task.id}`, + timestamp: new Date().toISOString(), + type: "task:created", + taskId: task.id, + details: "orphan activity", + }))); + + const result = await store.reconcilePhantomCommittedReservations(); + expect(result.reconciled).toEqual(expect.arrayContaining(phantomTasks.map((task) => task.id))); + expect(result.skipped).toContainEqual({ id: represented.id, reason: "task-row-present" }); + expect(await layer.db.select().from(schema.project.activityLog).where( + inArray(schema.project.activityLog.taskId, phantomTasks.map((task) => task.id)), + )).toHaveLength(0); + }); + + it("isolates audit failures to the affected reconciled reservation", async () => { + /* + FNXC:PostgresReservationRecovery 2026-07-14-21:55: + Batch cleanup may reconcile several IDs before audit emission. A later ID's audit failure must not mark an earlier successfully audited ID as skipped, and bookkeeping must continue for the remaining IDs. + */ + const store = h.store(); + const layer = h.layer(); + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const phantomTasks = await Promise.all([ + store.createTask({ description: "Audit succeeds" }), + store.createTask({ description: "Audit fails" }), + store.createTask({ description: "Audit continues" }), + ]); + for (const task of phantomTasks) { + await rm(join(h.rootDir(), ".fusion", "tasks", task.id), { recursive: true, force: true }); + } + await layer.db.delete(schema.project.tasks).where(and( + eq(schema.project.tasks.projectId, projectId), + inArray(schema.project.tasks.id, phantomTasks.map((task) => task.id)), + )); + await layer.db.insert(schema.project.activityLog).values(phantomTasks.map((task) => ({ + projectId, + id: `audit-isolation-${task.id}`, + timestamp: new Date().toISOString(), + type: "task:created", + taskId: task.id, + details: "orphan activity", + }))); + + const originalRecordRunAuditEvent = store.recordRunAuditEvent.bind(store); + const recordRunAuditEvent = vi.spyOn(store, "recordRunAuditEvent").mockImplementation(async (input) => { + if (input.taskId === phantomTasks[1].id) throw new Error("forced audit failure"); + return originalRecordRunAuditEvent(input); + }); + try { + const result = await store.reconcilePhantomCommittedReservations(); + expect(result.reconciled).toEqual(expect.arrayContaining([phantomTasks[0].id, phantomTasks[2].id])); + expect(result.skipped).not.toContainEqual(expect.objectContaining({ id: phantomTasks[0].id })); + expect(result.skipped).toContainEqual({ id: phantomTasks[1].id, reason: "audit-failed: forced audit failure" }); + expect(recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ taskId: phantomTasks[2].id })); + } finally { + recordRunAuditEvent.mockRestore(); + } + }); + + it("does not prune a reservation represented by an archive row", async () => { + const store = h.store(); + const layer = h.layer(); + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const task = await store.createTask({ description: "Archive representation guard" }); + await rm(join(h.rootDir(), ".fusion", "tasks", task.id), { recursive: true, force: true }); + await layer.db.delete(schema.project.tasks).where(and( + eq(schema.project.tasks.projectId, projectId), + eq(schema.project.tasks.id, task.id), + )); + await layer.db.insert(schema.project.archivedTasks).values({ + id: task.id, + projectId, + data: JSON.stringify(task), + archivedAt: new Date().toISOString(), + }); + + const result = await store.reconcilePhantomCommittedReservations(); + + expect(result.reconciled).not.toContain(task.id); + expect(result.skipped).toContainEqual({ id: task.id, reason: "archived-task-present" }); + }); + + it("does not prune a reservation represented only by the cold archive", async () => { + /* + FNXC:PostgresReservationRecoveryCoverage 2026-07-14-18:51: + The cold archive is an independent recovery tier from project.archived_tasks. A committed reservation and its children must survive when only archive.archived_tasks still represents the task. + */ + const store = h.store(); + const layer = h.layer(); + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const task = await store.createTask({ description: "Cold archive representation guard" }); + await rm(join(h.rootDir(), ".fusion", "tasks", task.id), { recursive: true, force: true }); + await layer.db.delete(schema.project.tasks).where(and( + eq(schema.project.tasks.projectId, projectId), + eq(schema.project.tasks.id, task.id), + )); + await layer.db.insert(schema.archive.archivedTasks).values({ + id: task.id, + projectId, + taskJson: JSON.stringify(task), + archivedAt: new Date().toISOString(), + title: task.title, + description: task.description, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + }); + + const result = await store.reconcilePhantomCommittedReservations(); + + expect(result.reconciled).not.toContain(task.id); + expect(result.skipped).toContainEqual({ id: task.id, reason: "archived-task-present" }); + }); + + it("does not prune a reservation while task.json still represents it", async () => { + /* + FNXC:PostgresReservationRecoveryCoverage 2026-07-14-19:05: + Phantom cleanup requires absence across live, archive, and filesystem representations. Preserve task-local recovery material when PostgreSQL alone is missing the task row. + */ + const store = h.store(); + const layer = h.layer(); + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const task = await store.createTask({ description: "Filesystem representation guard" }); + await layer.db.delete(schema.project.tasks).where(and( + eq(schema.project.tasks.projectId, projectId), + eq(schema.project.tasks.id, task.id), + )); + + const result = await store.reconcilePhantomCommittedReservations(); + + expect(result.reconciled).not.toContain(task.id); + expect(result.skipped).toContainEqual({ id: task.id, reason: "task-json-present" }); + }); + + it("re-proves absence transactionally before deleting child rows", async () => { + const store = h.store(); + const layer = h.layer(); + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const task = await store.createTask({ description: "Transactional representation guard" }); + await rm(join(h.rootDir(), ".fusion", "tasks", task.id), { recursive: true, force: true }); + await layer.db.delete(schema.project.tasks).where(and( + eq(schema.project.tasks.projectId, projectId), + eq(schema.project.tasks.id, task.id), + )); + const originalTransaction = layer.transactionImmediate.bind(layer); + let injected = false; + const mutableLayer = layer as unknown as { transactionImmediate: typeof layer.transactionImmediate }; + mutableLayer.transactionImmediate = (async (callback: Parameters[0]) => { + if (!injected) { + injected = true; + await layer.db.insert(schema.project.archivedTasks).values({ + id: task.id, + projectId, + data: JSON.stringify(task), + archivedAt: new Date().toISOString(), + }); + } + return originalTransaction(callback); + }) as typeof layer.transactionImmediate; + try { + const result = await store.reconcilePhantomCommittedReservations(); + expect(result.reconciled).not.toContain(task.id); + expect(result.skipped).toContainEqual({ id: task.id, reason: "representation-present-after-proof" }); + } finally { + mutableLayer.transactionImmediate = originalTransaction; + } + }); }); diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index c42353bacf..9335059310 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -298,7 +298,7 @@ For bug-class/bug-fix tasks only; feature/docs/non-bug tasks do not need this se Keep the project default workflow (usually \`builtin:coding\`) unless the user explicitly requested a specific workflow for this task, or you created that task yourself. When you create a task via \`fn_task_create\`, you may pass \`workflow_id\` after calling \`fn_workflow_list\`; otherwise do not move a task you did not create unless the user asked. Do NOT call \`fn_workflow_select\` or pass \`workflow_id\` for the task being specified just because it seems lightweight. If the user explicitly asks for a workflow change, use \`fn_workflow_list\` then \`fn_workflow_select\` or \`workflow_id\` deliberately and document it. ## Task Artifact Location -For forensic, reconciliation, or recovery tasks targeting another task ID, tell agents to inspect the real project root artifacts: \`/.fusion/tasks/{TARGET_ID}/\` for PROMPT.md/task.json/logs/attachments and \`/.fusion/fusion.db\` for the task store. Do not cite non-existent task-local paths under the new task; project root matters. +For forensic, reconciliation, or recovery tasks targeting another task ID, tell agents to inspect the real project root artifacts: \`/.fusion/tasks/{TARGET_ID}/\` for PROMPT.md/task.json/logs/attachments and use \`TaskStore\` APIs for the authoritative PostgreSQL task store. A legacy \`/.fusion/fusion.db\` is migration evidence only. Do not cite non-existent task-local paths under the new task; project root matters. ## Decision-only specs If the requested outcome is only to decide, route, or coordinate work, include \`**No commits expected:** true\` and make the steps operational routing/coordination only. Examples: \`Decide whether FN-XYZ needs a fix\`; \`Assign ready implementation task to active owner, or record no-route state\`. Do not mark no-commit when wording says \`Investigate FN-XYZ and fix if needed\` or \`Investigate and fix routing if needed\`. @@ -631,8 +631,8 @@ Write the PROMPT.md directly using the write tool and stop. The workflow graph o If the task targets a different task ID (audit, forensic walk, historical reconciliation, task-ID-collision investigation, live task metadata repair, or any work where evidence is another task's \`task.json\` / \`PROMPT.md\` / DB row), include this guidance in the generated PROMPT.md \`## Context to Read First\` and \`## File Scope\`: - Authoritative target-task artifacts live at the **project root**: \`/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, agent logs). -- Authoritative task DB rows live at the **project root** SQLite file: \`/.fusion/fusion.db\` (WAL mode). Read via \`TaskStore\` APIs; do not instruct direct SQL surgery. -- \`.fusion/\` is gitignored, so a fresh worktree from \`main\` does **not** include \`.fusion/tasks/{TARGET_ID}/\` or \`.fusion/fusion.db\`. The running worktree's own \`.fusion/\` (if present) is scratch/session state for the running task only, not source of truth. +- Authoritative task rows live in the project-scoped PostgreSQL store. Read and mutate them through \`TaskStore\` APIs; do not instruct direct SQL surgery. A local \`.fusion/fusion.db\`, when present, is a retained pre-cutover migration input or backup. +- \`.fusion/\` is gitignored, so a fresh worktree from \`main\` does **not** include \`.fusion/tasks/{TARGET_ID}/\` compatibility artifacts. The running worktree's own \`.fusion/\` (if present) is scratch/session state for the running task only, not the authoritative database. - Prefer \`fn_task_show\` / \`fn_task_list\` when the target task ID is known; fall back to project-root filesystem reads only when tools cannot provide needed evidence. `;; diff --git a/packages/core/src/agent-store.ts b/packages/core/src/agent-store.ts index 590bce3baa..6cc66c46f0 100644 --- a/packages/core/src/agent-store.ts +++ b/packages/core/src/agent-store.ts @@ -1,8 +1,9 @@ /** - * AgentStore - SQLite-backed persistence for agent lifecycle management. + * AgentStore - persistence for agent lifecycle management. * + * FNXC:PostgresRuntimeStorage 2026-07-14-18:49: * Agent records, heartbeat events, runs, task sessions, API keys, config - * revisions, and blocked-state snapshots are stored in `.fusion/fusion.db`. + * revisions, and blocked-state snapshots are stored in project-scoped PostgreSQL tables. * Managed instruction bundle markdown files remain on disk because they are * edited as normal project files. */ @@ -1812,7 +1813,7 @@ export class AgentStore extends EventEmitter { } if (this.claimStore && this.claimProjectId && task.checkoutNodeId) { - this.claimStore.releaseTaskClaim({ + await this.claimStore.releaseTaskClaim({ projectId: this.claimProjectId, taskId, nodeId: task.checkoutNodeId, diff --git a/packages/core/src/async-archive-db.ts b/packages/core/src/async-archive-db.ts index 725fd57483..0f16d0dfbe 100644 --- a/packages/core/src/async-archive-db.ts +++ b/packages/core/src/async-archive-db.ts @@ -39,10 +39,11 @@ * PostgreSQL integration tests consume. They target the stable * `AsyncDataLayer` interface (U4), not the underlying driver. */ -import { and, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; import type { ArchivedTaskEntry } from "./types.js"; +import { buildTsqueryFragment, sanitizeSearchTokens } from "./task-store/async-search.js"; /** A query-capable handle: either the top-level db or a transaction handle. */ type QueryHandle = AsyncDataLayer["db"] | DbTransaction; @@ -151,6 +152,28 @@ export async function listArchivedTasks( return rows.map((row) => JSON.parse((row as { taskJson: string }).taskJson) as ArchivedTaskEntry); } +/** + * FNXC:PostgresArchiveReadPerformance 2026-07-14-17:50: + * Merged live/cold task pages fetch only the prefix that can contribute to the requested global page. This order exactly matches TaskStore.listTasks: createdAt ASC followed by the numeric task-id suffix. + */ +export async function listArchivedTasksByCreatedOrder( + handle: QueryHandle, + limit: number, + projectId?: string, +): Promise { + if (limit <= 0) return []; + const rows = await handle + .select({ taskJson: archivedTaskColumns.taskJson }) + .from(schema.archive.archivedTasks) + .where(archiveProjectScope(projectId)) + .orderBy( + asc(archivedTaskColumns.createdAt), + sql`COALESCE(substring(${archivedTaskColumns.id} from '-([0-9]+)$')::int, 0) ASC`, + ) + .limit(limit); + return rows.map((row) => JSON.parse((row as { taskJson: string }).taskJson) as ArchivedTaskEntry); +} + /** * FNXC:ArchivePagination 2026-07-08-00:00: * Bounded page of archived task snapshots for the Archived board column @@ -185,11 +208,12 @@ export async function listArchivedTaskEntriesPage( export async function getArchivedTask( handle: QueryHandle, id: string, + projectId?: string, ): Promise { const rows = await handle .select({ taskJson: archivedTaskColumns.taskJson }) .from(schema.archive.archivedTasks) - .where(eq(archivedTaskColumns.id, id)) + .where(and(eq(archivedTaskColumns.id, id), archiveProjectScope(projectId))) .limit(1); const row = rows[0] as { taskJson: string } | undefined; return row ? (JSON.parse(row.taskJson) as ArchivedTaskEntry) : undefined; @@ -260,11 +284,8 @@ export async function getArchivedRowCount(handle: QueryHandle, projectId?: strin /** * FNXC:ArchiveDatabase 2026-06-24-19:40: - * Full-text search over archived tasks. Mirrors sync - * `ArchiveDatabase.search()` but uses an ILIKE-based scan (the sync LIKE - * fallback). The tsvector/GIN path slots in here when U7 (fts-replacement) - * lands; until then the ILIKE scan provides the same row-membership contract - * the SQLite LIKE fallback did. + * Full-text search over archived tasks through the generated tsvector and GIN + * index, preserving the SQLite FTS prefix/OR membership contract. * * Tokenization matches the sync path: the query is split on whitespace, * FTS-special characters are stripped, and every token must OR-match across @@ -278,45 +299,44 @@ export async function getArchivedRowCount(handle: QueryHandle, projectId?: strin export async function searchArchivedTasks( handle: QueryHandle, query: string, - limit: number, + limit: number | undefined, projectId?: string, + offset = 0, ): Promise { const trimmed = query?.trim(); if (!trimmed) return []; - const tokens = trimmed - .split(/\s+/) - .filter((t) => t.length > 0) - .map((t) => t.replace(/["{}:*^+()]/g, "")) - .filter((t) => t.length > 0); + const tokens = sanitizeSearchTokens(trimmed); if (tokens.length === 0) return []; - // Build an OR across tokens; within each token, OR across the searchable - // columns. ILIKE is case-insensitive; the sync LIKE fallback used ESCAPE '\' - // on a %pattern%. Each token is escaped for LIKE special chars. - // - // The columns: id, title, description (text), and comments (jsonb, cast to - // text so token search covers the serialized comment payload — matching the - // SQLite LIKE-over-text behavior). - const tokenClauses: SQL[] = []; - for (const token of tokens) { - const pattern = `%${token.replace(/[\\%_]/g, "\\$&")}%`; - const columnLikes = [ - ilike(archivedTaskColumns.id, pattern), - ilike(archivedTaskColumns.title, pattern), - ilike(archivedTaskColumns.description, pattern), - ilike(sql`${archivedTaskColumns.comments}::text`, pattern), - ]; - tokenClauses.push(or(...columnLikes) ?? sql`false`); - } - const where = or(...tokenClauses); + const tsquery = buildTsqueryFragment(tokens.join(" ")); + /* + FNXC:ArchiveSearch 2026-07-14-19:02: + Normal archive queries use search_vector @@ to_tsquery so PostgreSQL can use idxArchivedTasksSearchVector. If sanitization leaves only tsquery operators, retain the escaped ILIKE safety fallback instead of throwing or broadening the query. + */ + const where = tsquery + ? sql`${schema.archive.archivedTasks.searchVector} @@ ${tsquery}` + : or(...tokens.map((token) => { + const pattern = `%${token.replace(/[\\%_]/g, "\\$&")}%`; + return or( + ilike(archivedTaskColumns.id, pattern), + ilike(archivedTaskColumns.title, pattern), + ilike(archivedTaskColumns.description, pattern), + ilike(sql`${archivedTaskColumns.comments}::text`, pattern), + ) ?? sql`false`; + })); if (!where) return []; - const rows = await handle + const baseQuery = handle .select({ taskJson: archivedTaskColumns.taskJson }) .from(schema.archive.archivedTasks) .where(and(where, archiveProjectScope(projectId))) - .orderBy(desc(archivedTaskColumns.archivedAt)) - .limit(limit); + .orderBy( + ...(tsquery ? [sql`ts_rank(${schema.archive.archivedTasks.searchVector}, ${tsquery}) DESC`] : []), + desc(archivedTaskColumns.archivedAt), + ); + const rows = limit === undefined + ? (offset > 0 ? await baseQuery.offset(offset) : await baseQuery) + : await baseQuery.limit(Math.max(0, limit)).offset(Math.max(0, offset)); return rows.map((row) => JSON.parse((row as { taskJson: string }).taskJson) as ArchivedTaskEntry); } diff --git a/packages/core/src/async-central-core.ts b/packages/core/src/async-central-core.ts index a96396c29b..86546885c8 100644 --- a/packages/core/src/async-central-core.ts +++ b/packages/core/src/async-central-core.ts @@ -41,8 +41,8 @@ * central.projects table and cascade correctly. * * These helpers program against the stable `AsyncDataLayer` interface so the - * backend swap is invisible to the CentralCore contract. The sync SQLite path - * remains as the legacy fallback for `FUSION_NO_EMBEDDED_PG` mode. + * backend swap is invisible to the CentralCore contract. Production startup + * requires this PostgreSQL path; `FUSION_NO_EMBEDDED_PG` is rejected. */ import { and, asc, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; diff --git a/packages/core/src/async-central-db.ts b/packages/core/src/async-central-db.ts index 10c929b0c2..0212100181 100644 --- a/packages/core/src/async-central-db.ts +++ b/packages/core/src/async-central-db.ts @@ -46,7 +46,7 @@ import { and, eq } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; -import type { TaskClaimRow } from "./types.js"; +import type { CentralClaimStore, TaskClaimRow } from "./types.js"; /** A query-capable handle: either the top-level db or a transaction handle. */ type QueryHandle = AsyncDataLayer["db"] | DbTransaction; @@ -352,3 +352,24 @@ export async function releaseClaimsForNode( .returning({ projectId: schema.central.taskClaims.projectId }); return deleted.length; } + +/** Awaitable CentralClaimStore adapter used by the PostgreSQL engine runtime. */ +export class AsyncCentralClaimStore implements CentralClaimStore { + constructor(private readonly layer: AsyncDataLayer) {} + + tryClaimTask(input: TryClaimInput): Promise { + return tryClaimTask(this.layer, input); + } + + renewTaskClaim(input: RenewClaimInput): Promise { + return renewTaskClaim(this.layer, input); + } + + releaseTaskClaim(input: ReleaseClaimInput): Promise { + return releaseTaskClaim(this.layer, input); + } + + getTaskClaim(projectId: string, taskId: string): Promise { + return getTaskClaim(this.layer.db, projectId, taskId); + } +} diff --git a/packages/core/src/async-knowledge.ts b/packages/core/src/async-knowledge.ts new file mode 100644 index 0000000000..b6229d31ee --- /dev/null +++ b/packages/core/src/async-knowledge.ts @@ -0,0 +1,148 @@ +/** + * PostgreSQL persistence for the dashboard knowledge index. + * + * FNXC:KnowledgeIndex 2026-07-14-16:42: + * Task and PR history must remain incrementally searchable after PostgreSQL cutover. Keep Drizzle and project-partition enforcement in core so dashboard does not acquire a database-driver dependency, and reject unbound layers because this index contains sensitive repository history. + */ +import { and, desc, eq, ilike, sql } from "drizzle-orm"; +import type { AsyncDataLayer } from "./postgres/data-layer.js"; +import * as schema from "./postgres/schema/index.js"; + +export interface AsyncKnowledgePageInput { + sourceKind: "task" | "pr"; + sourceId: string; + title: string; + summary?: string | null; + content: string; + tags?: string[]; + now?: string; +} + +export interface AsyncKnowledgePage { + id: number; + sourceKind: "task" | "pr"; + sourceId: string; + sourceKey: string; + title: string; + summary: string | null; + content: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +export interface AsyncKnowledgeQueryOptions { + terms: string[]; + sourceKind?: "task" | "pr"; + limit: number; +} + +function projectIdFor(layer: AsyncDataLayer): string { + const projectId = layer.projectId?.trim(); + if (!projectId) throw new Error("PostgreSQL knowledge index access requires asyncLayer.projectId"); + return projectId; +} + +function toPage(row: typeof schema.project.knowledgePages.$inferSelect): AsyncKnowledgePage { + return { + id: row.id, + sourceKind: row.sourceKind as "task" | "pr", + sourceId: row.sourceId, + sourceKey: row.sourceKey, + title: row.title, + summary: row.summary, + content: row.content, + tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [], + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function upsertKnowledgePageInPostgres( + layer: AsyncDataLayer, + input: AsyncKnowledgePageInput, + searchText: string, +): Promise<{ page: AsyncKnowledgePage; created: boolean }> { + const projectId = projectIdFor(layer); + const now = input.now ?? new Date().toISOString(); + const sourceKey = `${input.sourceKind}:${input.sourceId}`; + const values = { + projectId, + sourceKind: input.sourceKind, + sourceId: input.sourceId, + sourceKey, + title: input.title, + summary: input.summary ?? null, + content: input.content, + tags: input.tags ?? [], + searchText, + createdAt: now, + updatedAt: now, + }; + const result = await layer.transactionImmediate(async (tx) => { + /* + FNXC:KnowledgeIndex 2026-07-14-18:10: + Creation reporting must be determined by the atomic insert result so concurrent first writers cannot both report that they created the same project-scoped page. + */ + const [inserted] = await tx + .insert(schema.project.knowledgePages) + .values(values) + .onConflictDoNothing({ + target: [schema.project.knowledgePages.projectId, schema.project.knowledgePages.sourceKey], + }) + .returning(); + if (inserted) return { row: inserted, created: true }; + + const [updated] = await tx + .update(schema.project.knowledgePages) + .set({ + title: input.title, + summary: input.summary ?? null, + content: input.content, + tags: input.tags ?? [], + searchText, + updatedAt: now, + }) + .where(and( + eq(schema.project.knowledgePages.projectId, projectId), + eq(schema.project.knowledgePages.sourceKey, sourceKey), + )) + .returning(); + return { row: updated, created: false }; + }); + if (!result.row) throw new Error(`knowledge page ${sourceKey} was not returned after upsert`); + return { page: toPage(result.row), created: result.created }; +} + +export async function queryKnowledgePagesInPostgres( + layer: AsyncDataLayer, + options: AsyncKnowledgeQueryOptions, +): Promise { + const projectId = projectIdFor(layer); + const predicates = [eq(schema.project.knowledgePages.projectId, projectId)]; + for (const term of options.terms) { + /* + FNXC:KnowledgeIndex 2026-07-14-21:55: + Knowledge search preserves the case-insensitive substring behavior of the legacy index while treating user-provided percent, underscore, and backslash characters literally instead of as PostgreSQL wildcard syntax. + */ + const escaped = term.replace(/[\\%_]/g, "\\$&"); + predicates.push(ilike(schema.project.knowledgePages.searchText, `%${escaped}%`)); + } + if (options.sourceKind) predicates.push(eq(schema.project.knowledgePages.sourceKind, options.sourceKind)); + const rows = await layer.db + .select() + .from(schema.project.knowledgePages) + .where(and(...predicates)) + .orderBy(desc(schema.project.knowledgePages.updatedAt), desc(schema.project.knowledgePages.id)) + .limit(options.limit); + return rows.map(toPage); +} + +export async function countKnowledgePagesInPostgres(layer: AsyncDataLayer): Promise { + const projectId = projectIdFor(layer); + const [row] = await layer.db + .select({ count: sql`count(*)::int` }) + .from(schema.project.knowledgePages) + .where(eq(schema.project.knowledgePages.projectId, projectId)); + return row?.count ?? 0; +} diff --git a/packages/core/src/async-mission-store-queries.ts b/packages/core/src/async-mission-store-queries.ts new file mode 100644 index 0000000000..23fa1ede66 --- /dev/null +++ b/packages/core/src/async-mission-store-queries.ts @@ -0,0 +1,2067 @@ +/** + * PostgreSQL mission row mappings and query helpers. + * + * FNXC:MissionStoreMaintainability 2026-07-14-19:24: + * Keep persistence projections, row conversion, and standalone SQL operations + * separate from the event-emitting AsyncMissionStore facade. This preserves the + * public helper exports while making lifecycle and concurrency changes reviewable. + */ +/** + * Async Drizzle MissionStore helpers (U6 satellite-mission-store). + * + * FNXC:MissionStore 2026-06-24-09:00: + * Async equivalents of the sync SQLite MissionStore call sites in + * mission-store.ts (~4382 lines, 84 prepare() calls). These helpers target + * the PostgreSQL `project` schema tables (missions, milestones, slices, + * mission_features, mission_events, mission_goals, mission_contract_assertions, + * mission_feature_assertions, mission_validator_runs, mission_validator_failures, + * mission_fix_feature_lineage) via Drizzle. + * + * SQLite → PostgreSQL notes (see library/satellite-store-migration-pattern.md): + * - jsonb columns (milestones.dependencies, mission_events.metadata, + * mission_fix_feature_lineage.failed_assertion_ids) return already-parsed + * JS values, so fromJson() is replaced by direct field access. On write, + * pass the JS value directly (Drizzle serializes it). + * - text columns (milestones.acceptanceCriteria, mission_features.acceptanceCriteria, + * slices.planningNotes/verification, milestones.planningNotes/verification) + * stay as plain strings — the U3 snapshot incorrectly mapped acceptanceCriteria + * as jsonb but it is plain text (derived criteria bullet list). Fixed in this + * feature's schema updates. + * - boolean 0/1 integer columns (missions.autoAdvance/autoMerge/autopilotEnabled) + * are kept as integer in PostgreSQL, so `row.autoAdvance === 1` checks work. + * - DELETE results: postgres.js does not expose rowCount on delete. Use + * .returning({ id }) and check .length (see async-todo-store.ts precedent). + * - ON CONFLICT: insert().onConflictDoUpdate() for upserts (snapshot apply), + * insert().onConflictDoNothing() for INSERT OR IGNORE semantics (mission_goals, + * mission_events snapshot, mission_feature_assertions snapshot). + * - Transactions: layer.transactionImmediate(async (tx) => ...) for multi-statement + * mutations (linkGoal existence checks + insert, startValidatorRun insert + update, + * deleteMilestone force-clear + delete, reorder operations). + * + * FNXC:PostgresFinalCutover 2026-07-14-19:24: + * These helpers are the production MissionStore persistence path and program + * against AsyncDataLayer rather than a synchronous SQLite database. + */ +import { and, asc, desc, eq, inArray, sql, type AnyColumn, type SQL } from "drizzle-orm"; +import * as schema from "./postgres/schema/index.js"; +import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; +import { normalizeMissionAssertionType } from "./mission-types.js"; +import type { + Mission, + MissionBranchStrategy, + Milestone, + Slice, + MissionFeature, + MissionValidatorRun, + MissionAssertionFailureRecord, + MissionFixFeatureLineage, + MissionCreateInput, + MissionEvent, + MissionStatus, + MilestoneStatus, + SliceStatus, + FeatureStatus, + InterviewState, + AutopilotState, + MissionContractAssertion, + FeatureAssertionLink, + MissionGoalLink, + MilestoneValidationState, + SlicePlanState, + ValidatorRunStatus, + FeatureLoopState, +} from "./mission-types.js"; +import type { Goal, GoalStatus } from "./goal-types.js"; + +/** + * FNXC:MissionStore 2026-06-27-15:00: + * Default retry budget for implementation attempts (mirrors mission-store.ts). + * When implementationAttemptCount reaches this limit, the feature loop blocks + * instead of re-implementing. + */ +export const DEFAULT_IMPLEMENTATION_RETRY_BUDGET = 3; + +/** + * FNXC:MissionStore 2026-06-27-15:00: + * Local replica of the (non-exported) sync `missionBranchStrategyDefaults`. + * Resolves a mission's branch strategy into a concrete {branch, assignmentMode} + * used by triage. + */ +export function missionBranchStrategyDefaults(strategy?: MissionBranchStrategy): { + branch?: string; + assignmentMode: "shared" | "per-task-derived"; +} { + if (!strategy) return { assignmentMode: "shared" }; + if (strategy.mode === "auto-per-task") return { assignmentMode: "per-task-derived" }; + if ((strategy.mode === "existing" || strategy.mode === "custom-new") && strategy.branchName?.trim()) { + return { branch: strategy.branchName.trim(), assignmentMode: "shared" }; + } + return { assignmentMode: "shared" }; +} + +/** A query-capable handle: either the top-level db or a transaction handle. */ +export type QueryHandle = AsyncDataLayer["db"] | DbTransaction; + +/* +FNXC:MissionProjectIsolation 2026-07-14-21:35: +Mission data lives in the shared PostgreSQL project schema, so every mission-owned insert and predicate must use the session's authoritative project partition even when an administrative connection bypasses row-level security. An unbound maintenance session is confined to the explicit legacy quarantine rather than becoming a cross-project reader. +*/ +function missionProjectId(): SQL { + return sql`COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__')`; +} + +function missionProjectScope(column: AnyColumn): SQL { + return eq(column, missionProjectId()); +} + +// ── Row shapes (camelCase column aliases via Drizzle) ─────────────── + +interface MissionRow { + id: string; + title: string; + description: string | null; + status: string; + interviewState: string; + baseBranch: string | null; + branchStrategy: string | null; + autoMerge: number | null; + autoAdvance: number | null; + autopilotEnabled: number | null; + autopilotState: string | null; + lastAutopilotActivityAt: string | null; + createdAt: string; + updatedAt: string; +} + +interface MilestoneRow { + id: string; + missionId: string; + title: string; + description: string | null; + status: string; + orderIndex: number; + interviewState: string; + dependencies: string[] | null; + planningNotes: string | null; + verification: string | null; + acceptanceCriteria: string | null; + validationState: string | null; + createdAt: string; + updatedAt: string; +} + +interface SliceRow { + id: string; + milestoneId: string; + title: string; + description: string | null; + status: string; + orderIndex: number; + activatedAt: string | null; + planState: string | null; + planningNotes: string | null; + verification: string | null; + createdAt: string; + updatedAt: string; +} + +interface FeatureRow { + id: string; + sliceId: string; + taskId: string | null; + title: string; + description: string | null; + acceptanceCriteria: string | null; + status: string; + createdAt: string; + updatedAt: string; + loopState: string | null; + implementationAttemptCount: number | null; + validatorAttemptCount: number | null; + lastValidatorRunId: string | null; + lastValidatorStatus: string | null; + generatedFromFeatureId: string | null; + generatedFromRunId: string | null; +} + +interface MissionEventRow { + id: string; + missionId: string; + eventType: string; + description: string; + metadata: unknown; + timestamp: string; + seq: number | null; +} + +interface MissionGoalRow { + missionId: string; + goalId: string; + createdAt: string; +} + +interface GoalRow { + id: string; + title: string; + description: string | null; + status: GoalStatus; + createdAt: string; + updatedAt: string; +} + +export interface AssertionRow { + id: string; + milestoneId: string; + title: string; + assertion: string; + status: string; + type: string | null; + orderIndex: number; + sourceFeatureId: string | null; + createdAt: string; + updatedAt: string; +} + +interface FeatureAssertionLinkRow { + featureId: string; + assertionId: string; + createdAt: string; +} + +interface ValidatorRunRow { + id: string; + featureId: string; + milestoneId: string; + sliceId: string; + status: string; + triggerType: string | null; + implementationAttempt: number | null; + validatorAttempt: number | null; + taskId: string | null; + summary: string | null; + blockedReason: string | null; + startedAt: string; + completedAt: string | null; + createdAt: string; + updatedAt: string; +} + +interface FailureRow { + id: string; + runId: string; + featureId: string; + assertionId: string; + message: string | null; + expected: string | null; + actual: string | null; + createdAt: string; +} + +interface LineageRow { + id: string; + sourceFeatureId: string; + fixFeatureId: string; + runId: string; + failedAssertionIds: string[] | null; + createdAt: string; +} + +// ── Column projections (select only what we need) ──────────────────── + +const missionColumns = { + id: schema.project.missions.id, + title: schema.project.missions.title, + description: schema.project.missions.description, + status: schema.project.missions.status, + interviewState: schema.project.missions.interviewState, + baseBranch: schema.project.missions.baseBranch, + branchStrategy: schema.project.missions.branchStrategy, + autoMerge: schema.project.missions.autoMerge, + autoAdvance: schema.project.missions.autoAdvance, + autopilotEnabled: schema.project.missions.autopilotEnabled, + autopilotState: schema.project.missions.autopilotState, + lastAutopilotActivityAt: schema.project.missions.lastAutopilotActivityAt, + createdAt: schema.project.missions.createdAt, + updatedAt: schema.project.missions.updatedAt, +}; + +const milestoneColumns = { + id: schema.project.milestones.id, + missionId: schema.project.milestones.missionId, + title: schema.project.milestones.title, + description: schema.project.milestones.description, + status: schema.project.milestones.status, + orderIndex: schema.project.milestones.orderIndex, + interviewState: schema.project.milestones.interviewState, + dependencies: schema.project.milestones.dependencies, + planningNotes: schema.project.milestones.planningNotes, + verification: schema.project.milestones.verification, + acceptanceCriteria: schema.project.milestones.acceptanceCriteria, + validationState: schema.project.milestones.validationState, + createdAt: schema.project.milestones.createdAt, + updatedAt: schema.project.milestones.updatedAt, +}; + +const sliceColumns = { + id: schema.project.slices.id, + milestoneId: schema.project.slices.milestoneId, + title: schema.project.slices.title, + description: schema.project.slices.description, + status: schema.project.slices.status, + orderIndex: schema.project.slices.orderIndex, + activatedAt: schema.project.slices.activatedAt, + planState: schema.project.slices.planState, + planningNotes: schema.project.slices.planningNotes, + verification: schema.project.slices.verification, + createdAt: schema.project.slices.createdAt, + updatedAt: schema.project.slices.updatedAt, +}; + +const featureColumns = { + id: schema.project.missionFeatures.id, + sliceId: schema.project.missionFeatures.sliceId, + taskId: schema.project.missionFeatures.taskId, + title: schema.project.missionFeatures.title, + description: schema.project.missionFeatures.description, + acceptanceCriteria: schema.project.missionFeatures.acceptanceCriteria, + status: schema.project.missionFeatures.status, + createdAt: schema.project.missionFeatures.createdAt, + updatedAt: schema.project.missionFeatures.updatedAt, + loopState: schema.project.missionFeatures.loopState, + implementationAttemptCount: schema.project.missionFeatures.implementationAttemptCount, + validatorAttemptCount: schema.project.missionFeatures.validatorAttemptCount, + lastValidatorRunId: schema.project.missionFeatures.lastValidatorRunId, + lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus, + generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId, + generatedFromRunId: schema.project.missionFeatures.generatedFromRunId, +}; + +const eventColumns = { + id: schema.project.missionEvents.id, + missionId: schema.project.missionEvents.missionId, + eventType: schema.project.missionEvents.eventType, + description: schema.project.missionEvents.description, + metadata: schema.project.missionEvents.metadata, + timestamp: schema.project.missionEvents.timestamp, + seq: schema.project.missionEvents.seq, +}; + +const missionGoalColumns = { + missionId: schema.project.missionGoals.missionId, + goalId: schema.project.missionGoals.goalId, + createdAt: schema.project.missionGoals.createdAt, +}; + +export const assertionColumns = { + id: schema.project.missionContractAssertions.id, + milestoneId: schema.project.missionContractAssertions.milestoneId, + title: schema.project.missionContractAssertions.title, + assertion: schema.project.missionContractAssertions.assertion, + status: schema.project.missionContractAssertions.status, + type: schema.project.missionContractAssertions.type, + orderIndex: schema.project.missionContractAssertions.orderIndex, + sourceFeatureId: schema.project.missionContractAssertions.sourceFeatureId, + createdAt: schema.project.missionContractAssertions.createdAt, + updatedAt: schema.project.missionContractAssertions.updatedAt, +}; + +const validatorRunColumns = { + id: schema.project.missionValidatorRuns.id, + featureId: schema.project.missionValidatorRuns.featureId, + milestoneId: schema.project.missionValidatorRuns.milestoneId, + sliceId: schema.project.missionValidatorRuns.sliceId, + status: schema.project.missionValidatorRuns.status, + triggerType: schema.project.missionValidatorRuns.triggerType, + implementationAttempt: schema.project.missionValidatorRuns.implementationAttempt, + validatorAttempt: schema.project.missionValidatorRuns.validatorAttempt, + taskId: schema.project.missionValidatorRuns.taskId, + summary: schema.project.missionValidatorRuns.summary, + blockedReason: schema.project.missionValidatorRuns.blockedReason, + startedAt: schema.project.missionValidatorRuns.startedAt, + completedAt: schema.project.missionValidatorRuns.completedAt, + createdAt: schema.project.missionValidatorRuns.createdAt, + updatedAt: schema.project.missionValidatorRuns.updatedAt, +}; + +const failureColumns = { + id: schema.project.missionValidatorFailures.id, + runId: schema.project.missionValidatorFailures.runId, + featureId: schema.project.missionValidatorFailures.featureId, + assertionId: schema.project.missionValidatorFailures.assertionId, + message: schema.project.missionValidatorFailures.message, + expected: schema.project.missionValidatorFailures.expected, + actual: schema.project.missionValidatorFailures.actual, + createdAt: schema.project.missionValidatorFailures.createdAt, +}; + +const lineageColumns = { + id: schema.project.missionFixFeatureLineage.id, + sourceFeatureId: schema.project.missionFixFeatureLineage.sourceFeatureId, + fixFeatureId: schema.project.missionFixFeatureLineage.fixFeatureId, + runId: schema.project.missionFixFeatureLineage.runId, + failedAssertionIds: schema.project.missionFixFeatureLineage.failedAssertionIds, + createdAt: schema.project.missionFixFeatureLineage.createdAt, +}; + +// ── Row-to-object converters ──────────────────────────────────────── + +function rowToMission(row: MissionRow): Mission { + let branchStrategy: MissionBranchStrategy | undefined; + if (row.branchStrategy) { + try { + branchStrategy = JSON.parse(row.branchStrategy) as MissionBranchStrategy; + } catch { + branchStrategy = undefined; + } + } + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + status: row.status as MissionStatus, + interviewState: row.interviewState as InterviewState, + baseBranch: row.baseBranch ?? undefined, + branchStrategy, + autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge), + autoAdvance: Boolean(row.autoAdvance ?? 0), + autopilotEnabled: Boolean(row.autopilotEnabled ?? 0), + autopilotState: (row.autopilotState as AutopilotState) || "inactive", + lastAutopilotActivityAt: row.lastAutopilotActivityAt ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function rowToMilestone(row: MilestoneRow): Milestone { + return { + id: row.id, + missionId: row.missionId, + title: row.title, + description: row.description ?? undefined, + status: row.status as MilestoneStatus, + orderIndex: row.orderIndex, + interviewState: row.interviewState as InterviewState, + // FNXC:MissionStore 2026-06-24-09:10: + // dependencies is jsonb in PostgreSQL (was TEXT DEFAULT '[]' in SQLite). + // Drizzle returns it as a parsed JS array. Guard against null for rows + // that pre-date the jsonb default. + dependencies: Array.isArray(row.dependencies) ? row.dependencies : [], + planningNotes: row.planningNotes ?? undefined, + verification: row.verification ?? undefined, + acceptanceCriteria: row.acceptanceCriteria ?? undefined, + validationState: (row.validationState as MilestoneValidationState) || "not_started", + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function rowToSlice(row: SliceRow): Slice { + return { + id: row.id, + milestoneId: row.milestoneId, + title: row.title, + description: row.description ?? undefined, + status: row.status as SliceStatus, + orderIndex: row.orderIndex, + activatedAt: row.activatedAt ?? undefined, + planState: (row.planState as SlicePlanState) || "not_started", + planningNotes: row.planningNotes ?? undefined, + verification: row.verification ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function rowToFeature(row: FeatureRow): MissionFeature { + return { + id: row.id, + sliceId: row.sliceId, + taskId: row.taskId ?? undefined, + title: row.title, + description: row.description ?? undefined, + acceptanceCriteria: row.acceptanceCriteria ?? undefined, + status: row.status as FeatureStatus, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + loopState: (row.loopState as FeatureLoopState) || "idle", + implementationAttemptCount: row.implementationAttemptCount ?? 0, + validatorAttemptCount: row.validatorAttemptCount ?? 0, + lastValidatorRunId: row.lastValidatorRunId ?? undefined, + lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined, + generatedFromFeatureId: row.generatedFromFeatureId ?? undefined, + generatedFromRunId: row.generatedFromRunId ?? undefined, + }; +} + +function rowToMissionEvent(row: MissionEventRow): MissionEvent { + return { + id: row.id, + missionId: row.missionId, + eventType: row.eventType as MissionEvent["eventType"], + description: row.description, + // FNXC:MissionStore 2026-06-24-09:15: + // metadata is jsonb in PostgreSQL (was TEXT in SQLite). Drizzle returns + // it already-parsed. Null stays null. + metadata: (row.metadata as Record | null) ?? null, + timestamp: row.timestamp, + seq: row.seq ?? 0, + }; +} + +function rowToMissionGoalLink(row: MissionGoalRow): MissionGoalLink { + return { missionId: row.missionId, goalId: row.goalId, createdAt: row.createdAt }; +} + +function rowToGoal(row: GoalRow): Goal { + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function rowToAssertion(row: AssertionRow): MissionContractAssertion { + return { + id: row.id, + milestoneId: row.milestoneId, + sourceFeatureId: row.sourceFeatureId ?? undefined, + title: row.title, + assertion: row.assertion, + status: row.status as MissionContractAssertion["status"], + type: normalizeMissionAssertionType(row.type), + orderIndex: row.orderIndex, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function rowToFeatureAssertionLink(row: FeatureAssertionLinkRow): FeatureAssertionLink { + return { featureId: row.featureId, assertionId: row.assertionId, createdAt: row.createdAt }; +} + +function rowToValidatorRun(row: ValidatorRunRow): MissionValidatorRun { + return { + id: row.id, + featureId: row.featureId, + milestoneId: row.milestoneId, + sliceId: row.sliceId, + status: row.status as ValidatorRunStatus, + triggerType: row.triggerType ?? undefined, + implementationAttempt: row.implementationAttempt ?? 0, + validatorAttempt: row.validatorAttempt ?? 0, + taskId: row.taskId ?? undefined, + summary: row.summary ?? undefined, + blockedReason: row.blockedReason ?? undefined, + startedAt: row.startedAt, + completedAt: row.completedAt ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function rowToFailure(row: FailureRow): MissionAssertionFailureRecord { + return { + id: row.id, + runId: row.runId, + featureId: row.featureId, + assertionId: row.assertionId, + message: row.message ?? undefined, + expected: row.expected ?? undefined, + actual: row.actual ?? undefined, + createdAt: row.createdAt, + }; +} + +function rowToLineage(row: LineageRow): MissionFixFeatureLineage { + return { + id: row.id, + sourceFeatureId: row.sourceFeatureId, + fixFeatureId: row.fixFeatureId, + runId: row.runId, + // failedAssertionIds is jsonb in PostgreSQL (was TEXT in SQLite). + failedAssertionIds: Array.isArray(row.failedAssertionIds) ? row.failedAssertionIds : [], + createdAt: row.createdAt, + }; +} + +// ── Helpers for write serialization ───────────────────────────────── + +/** + * FNXC:MissionStore 2026-06-24-09:20: + * Serialize a MissionBranchStrategy for the text branchStrategy column. + * The column stores the strategy as a JSON string (parsed on read by rowToMission). + */ +function serializeBranchStrategy(strategy: MissionBranchStrategy | undefined): string | null { + return strategy ? JSON.stringify(strategy) : null; +} + +// ════════════════════════════════════════════════════════════════════ +// MISSION CRUD +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-09:25: + * Create a mission (non-destructive INSERT, VAL-DATA-009). Missions are always + * created with status "planning" and autopilot disabled. + */ +export async function createMission( + handle: QueryHandle, + input: { id: string } & MissionCreateInput & { createdAt: string; updatedAt: string; status: string; interviewState: string; autoAdvance: boolean; autopilotEnabled: boolean; autopilotState: string }, +): Promise { + await handle.insert(schema.project.missions).values({ + projectId: missionProjectId(), + id: input.id, + title: input.title, + description: input.description ?? null, + status: input.status, + interviewState: input.interviewState, + baseBranch: input.baseBranch ?? null, + branchStrategy: serializeBranchStrategy(input.branchStrategy), + autoMerge: input.autoMerge === undefined ? null : input.autoMerge ? 1 : 0, + autoAdvance: input.autoAdvance ? 1 : 0, + autopilotEnabled: input.autopilotEnabled ? 1 : 0, + autopilotState: input.autopilotState ?? "inactive", + lastAutopilotActivityAt: null, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + return (await getMission(handle, input.id))!; +} + +/** Get a single mission by id. */ +export async function getMission(handle: QueryHandle, id: string): Promise { + const rows = await handle + .select(missionColumns) + .from(schema.project.missions) + .where(and(missionProjectScope(schema.project.missions.projectId), eq(schema.project.missions.id, id))); + return rows[0] ? rowToMission(rows[0] as MissionRow) : undefined; +} + +/** List all missions, ordered by createdAt DESC (newest first). */ +export async function listMissions(handle: QueryHandle): Promise { + const rows = await handle + .select(missionColumns) + .from(schema.project.missions) + .where(missionProjectScope(schema.project.missions.projectId)) + .orderBy(desc(schema.project.missions.createdAt)); + return rows.map((row) => rowToMission(row as MissionRow)); +} + +/** + * FNXC:MissionStore 2026-06-24-09:30: + * Update a mission's mutable columns. branchStrategy is serialized as JSON text. + */ +export async function updateMission( + handle: QueryHandle, + mission: Mission, +): Promise { + await handle + .update(schema.project.missions) + .set({ + title: mission.title, + description: mission.description ?? null, + status: mission.status, + interviewState: mission.interviewState, + baseBranch: mission.baseBranch ?? null, + branchStrategy: serializeBranchStrategy(mission.branchStrategy), + autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0, + autoAdvance: mission.autoAdvance ? 1 : 0, + autopilotEnabled: mission.autopilotEnabled ? 1 : 0, + autopilotState: mission.autopilotState ?? "inactive", + lastAutopilotActivityAt: mission.lastAutopilotActivityAt ?? null, + updatedAt: mission.updatedAt, + }) + .where(and(missionProjectScope(schema.project.missions.projectId), eq(schema.project.missions.id, mission.id))); +} + +/** Delete a mission by id (cascades to milestones/slices/features/events). Returns true if a row was deleted. */ +export async function deleteMission(handle: QueryHandle, id: string): Promise { + const result = await handle + .delete(schema.project.missions) + .where(and(missionProjectScope(schema.project.missions.projectId), eq(schema.project.missions.id, id))) + .returning({ id: schema.project.missions.id }); + return result.length > 0; +} + +/** Check whether a mission with the given id exists. */ +export async function missionExists(handle: QueryHandle, id: string): Promise { + const rows = await handle + .select({ id: schema.project.missions.id }) + .from(schema.project.missions) + .where(and(missionProjectScope(schema.project.missions.projectId), eq(schema.project.missions.id, id))); + return rows.length > 0; +} + +// ════════════════════════════════════════════════════════════════════ +// MILESTONE CRUD +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-09:35: + * Create a milestone (non-destructive INSERT). dependencies is a jsonb array. + */ +export async function createMilestone( + handle: QueryHandle, + milestone: Milestone, +): Promise { + await handle.insert(schema.project.milestones).values({ + projectId: missionProjectId(), + id: milestone.id, + missionId: milestone.missionId, + title: milestone.title, + description: milestone.description ?? null, + status: milestone.status, + orderIndex: milestone.orderIndex, + interviewState: milestone.interviewState, + dependencies: milestone.dependencies, + planningNotes: milestone.planningNotes ?? null, + verification: milestone.verification ?? null, + acceptanceCriteria: milestone.acceptanceCriteria ?? null, + validationState: milestone.validationState ?? "not_started", + createdAt: milestone.createdAt, + updatedAt: milestone.updatedAt, + }); + return (await getMilestone(handle, milestone.id))!; +} + +/** Get a single milestone by id. */ +export async function getMilestone(handle: QueryHandle, id: string): Promise { + const rows = await handle + .select(milestoneColumns) + .from(schema.project.milestones) + .where(and(missionProjectScope(schema.project.milestones.projectId), eq(schema.project.milestones.id, id))); + return rows[0] ? rowToMilestone(rows[0] as MilestoneRow) : undefined; +} + +/** List milestones for a mission, ordered by orderIndex ASC. */ +export async function listMilestones(handle: QueryHandle, missionId: string): Promise { + const rows = await handle + .select(milestoneColumns) + .from(schema.project.milestones) + .where(and(missionProjectScope(schema.project.milestones.projectId), eq(schema.project.milestones.missionId, missionId))) + .orderBy(asc(schema.project.milestones.orderIndex)); + return rows.map((row) => rowToMilestone(row as MilestoneRow)); +} + +/** List ALL milestones across all missions, ordered by orderIndex ASC. */ +export async function listAllMilestones(handle: QueryHandle): Promise { + const rows = await handle + .select(milestoneColumns) + .from(schema.project.milestones) + .where(missionProjectScope(schema.project.milestones.projectId)) + .orderBy(asc(schema.project.milestones.orderIndex)); + return rows.map((row) => rowToMilestone(row as MilestoneRow)); +} + +/** Update a milestone's mutable columns. */ +export async function updateMilestone(handle: QueryHandle, milestone: Milestone): Promise { + await handle + .update(schema.project.milestones) + .set({ + title: milestone.title, + description: milestone.description ?? null, + status: milestone.status, + orderIndex: milestone.orderIndex, + interviewState: milestone.interviewState, + dependencies: milestone.dependencies, + planningNotes: milestone.planningNotes ?? null, + verification: milestone.verification ?? null, + acceptanceCriteria: milestone.acceptanceCriteria ?? null, + validationState: milestone.validationState || "not_started", + updatedAt: milestone.updatedAt, + }) + .where(and(missionProjectScope(schema.project.milestones.projectId), eq(schema.project.milestones.id, milestone.id))); +} + +/** Delete a milestone by id (cascades to slices/features). Returns true if deleted. */ +export async function deleteMilestone(handle: QueryHandle, id: string): Promise { + const result = await handle + .delete(schema.project.milestones) + .where(and(missionProjectScope(schema.project.milestones.projectId), eq(schema.project.milestones.id, id))) + .returning({ id: schema.project.milestones.id }); + return result.length > 0; +} + +/** + * FNXC:MissionStore 2026-06-24-09:40: + * Reorder milestones transactionally. Each milestone's orderIndex is set to its + * array position. The entire reorder runs in one transaction so partial reorders + * never persist. + */ +export async function reorderMilestones( + layer: AsyncDataLayer, + orderedIds: string[], +): Promise { + const now = new Date().toISOString(); + await layer.transactionImmediate(async (tx) => { + for (let i = 0; i < orderedIds.length; i++) { + await tx + .update(schema.project.milestones) + .set({ orderIndex: i, updatedAt: now }) + .where(and(missionProjectScope(schema.project.milestones.projectId), eq(schema.project.milestones.id, orderedIds[i]!))); + } + }); +} + +// ════════════════════════════════════════════════════════════════════ +// SLICE CRUD +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-09:45: + * Create a slice (non-destructive INSERT). + */ +export async function createSlice(handle: QueryHandle, slice: Slice): Promise { + await handle.insert(schema.project.slices).values({ + projectId: missionProjectId(), + id: slice.id, + milestoneId: slice.milestoneId, + title: slice.title, + description: slice.description ?? null, + status: slice.status, + orderIndex: slice.orderIndex, + activatedAt: slice.activatedAt ?? null, + planState: slice.planState ?? "not_started", + planningNotes: slice.planningNotes ?? null, + verification: slice.verification ?? null, + createdAt: slice.createdAt, + updatedAt: slice.updatedAt, + }); + return (await getSlice(handle, slice.id))!; +} + +/** Get a single slice by id. */ +export async function getSlice(handle: QueryHandle, id: string): Promise { + const rows = await handle + .select(sliceColumns) + .from(schema.project.slices) + .where(and(missionProjectScope(schema.project.slices.projectId), eq(schema.project.slices.id, id))); + return rows[0] ? rowToSlice(rows[0] as SliceRow) : undefined; +} + +/** List slices for a milestone, ordered by orderIndex ASC. */ +export async function listSlices(handle: QueryHandle, milestoneId: string): Promise { + const rows = await handle + .select(sliceColumns) + .from(schema.project.slices) + .where(and(missionProjectScope(schema.project.slices.projectId), eq(schema.project.slices.milestoneId, milestoneId))) + .orderBy(asc(schema.project.slices.orderIndex)); + return rows.map((row) => rowToSlice(row as SliceRow)); +} + +/** List ALL slices across all milestones, ordered by orderIndex ASC. */ +export async function listAllSlices(handle: QueryHandle): Promise { + const rows = await handle + .select(sliceColumns) + .from(schema.project.slices) + .where(missionProjectScope(schema.project.slices.projectId)) + .orderBy(asc(schema.project.slices.orderIndex)); + return rows.map((row) => rowToSlice(row as SliceRow)); +} + +/** Update a slice's mutable columns. */ +export async function updateSlice(handle: QueryHandle, slice: Slice): Promise { + await handle + .update(schema.project.slices) + .set({ + title: slice.title, + description: slice.description ?? null, + status: slice.status, + orderIndex: slice.orderIndex, + activatedAt: slice.activatedAt ?? null, + planState: slice.planState ?? "not_started", + planningNotes: slice.planningNotes ?? null, + verification: slice.verification ?? null, + updatedAt: slice.updatedAt, + }) + .where(and(missionProjectScope(schema.project.slices.projectId), eq(schema.project.slices.id, slice.id))); +} + +/** Delete a slice by id (cascades to features). Returns true if deleted. */ +export async function deleteSlice(handle: QueryHandle, id: string): Promise { + const result = await handle + .delete(schema.project.slices) + .where(and(missionProjectScope(schema.project.slices.projectId), eq(schema.project.slices.id, id))) + .returning({ id: schema.project.slices.id }); + return result.length > 0; +} + +/** Reorder slices transactionally within a milestone. */ +export async function reorderSlices( + layer: AsyncDataLayer, + orderedIds: string[], +): Promise { + const now = new Date().toISOString(); + await layer.transactionImmediate(async (tx) => { + for (let i = 0; i < orderedIds.length; i++) { + await tx + .update(schema.project.slices) + .set({ orderIndex: i, updatedAt: now }) + .where(and(missionProjectScope(schema.project.slices.projectId), eq(schema.project.slices.id, orderedIds[i]!))); + } + }); +} + +// ════════════════════════════════════════════════════════════════════ +// FEATURE CRUD +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-09:50: + * Create a feature (non-destructive INSERT). + */ +export async function createFeature(handle: QueryHandle, feature: MissionFeature): Promise { + await handle.insert(schema.project.missionFeatures).values({ + projectId: missionProjectId(), + id: feature.id, + sliceId: feature.sliceId, + taskId: feature.taskId ?? null, + title: feature.title, + description: feature.description ?? null, + acceptanceCriteria: feature.acceptanceCriteria ?? null, + status: feature.status, + createdAt: feature.createdAt, + updatedAt: feature.updatedAt, + loopState: feature.loopState ?? "idle", + implementationAttemptCount: feature.implementationAttemptCount ?? 0, + validatorAttemptCount: feature.validatorAttemptCount ?? 0, + lastValidatorRunId: feature.lastValidatorRunId ?? null, + lastValidatorStatus: feature.lastValidatorStatus ?? null, + generatedFromFeatureId: feature.generatedFromFeatureId ?? null, + generatedFromRunId: feature.generatedFromRunId ?? null, + }); + return (await getFeature(handle, feature.id))!; +} + +/** Get a single feature by id. */ +export async function getFeature(handle: QueryHandle, id: string): Promise { + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.id, id))); + return rows[0] ? rowToFeature(rows[0] as FeatureRow) : undefined; +} + +/** + * FNXC:PostgresMissionBulkReads 2026-07-14-17:55: + * Mission reconciliation resolves feature sets in one query. Empty inputs short-circuit so callers never emit an invalid IN (). + */ +export async function listFeaturesByIds(handle: QueryHandle, ids: string[]): Promise { + if (ids.length === 0) return []; + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), inArray(schema.project.missionFeatures.id, [...new Set(ids)]))); + return rows.map((row) => rowToFeature(row as FeatureRow)); +} + +/** List features for a slice, ordered by createdAt ASC. */ +export async function listFeatures(handle: QueryHandle, sliceId: string): Promise { + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.sliceId, sliceId))) + .orderBy(asc(schema.project.missionFeatures.createdAt)); + return rows.map((row) => rowToFeature(row as FeatureRow)); +} + +/** Fetch every feature under one milestone without a slice-by-slice query loop. */ +export async function listFeaturesForMilestone(handle: QueryHandle, milestoneId: string): Promise { + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .innerJoin(schema.project.slices, and( + eq(schema.project.slices.projectId, schema.project.missionFeatures.projectId), + eq(schema.project.slices.id, schema.project.missionFeatures.sliceId), + )) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.slices.milestoneId, milestoneId))) + .orderBy(asc(schema.project.missionFeatures.createdAt)); + return rows.map((row) => rowToFeature(row as FeatureRow)); +} + +/** List ALL features across all slices, ordered by createdAt ASC. */ +export async function listAllFeatures(handle: QueryHandle): Promise { + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .where(missionProjectScope(schema.project.missionFeatures.projectId)) + .orderBy(asc(schema.project.missionFeatures.createdAt)); + return rows.map((row) => rowToFeature(row as FeatureRow)); +} + +/** + * FNXC:MissionStore 2026-06-24-09:55: + * Update a feature's mutable columns. This is the core mutation surface for the + * implement→validate→fix loop (loopState, attempt counts, last validator linkage). + */ +export async function updateFeature(handle: QueryHandle, feature: MissionFeature): Promise { + await handle + .update(schema.project.missionFeatures) + .set({ + taskId: feature.taskId ?? null, + title: feature.title, + description: feature.description ?? null, + acceptanceCriteria: feature.acceptanceCriteria ?? null, + status: feature.status, + updatedAt: feature.updatedAt, + loopState: feature.loopState ?? "idle", + implementationAttemptCount: feature.implementationAttemptCount ?? 0, + validatorAttemptCount: feature.validatorAttemptCount ?? 0, + lastValidatorRunId: feature.lastValidatorRunId ?? null, + lastValidatorStatus: feature.lastValidatorStatus ?? null, + generatedFromFeatureId: feature.generatedFromFeatureId ?? null, + generatedFromRunId: feature.generatedFromRunId ?? null, + }) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.id, feature.id))); +} + +/** Delete a feature by id. Returns true if deleted. */ +export async function deleteFeature(handle: QueryHandle, id: string): Promise { + const result = await handle + .delete(schema.project.missionFeatures) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.id, id))) + .returning({ id: schema.project.missionFeatures.id }); + return result.length > 0; +} + +/** Get a feature by its linked taskId (null if no feature is linked). */ +export async function getFeatureByTaskId(handle: QueryHandle, taskId: string): Promise { + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.taskId, taskId))); + return rows[0] ? rowToFeature(rows[0] as FeatureRow) : undefined; +} + +/** + * FNXC:MissionStore 2026-06-24-10:00: + * Unlink a feature from its task (set taskId = NULL). Used when force-deleting + * a slice/milestone or unlinking a feature from a task. + */ +export async function unlinkFeatureFromTaskId(handle: QueryHandle, featureId: string): Promise { + const now = new Date().toISOString(); + await handle + .update(schema.project.missionFeatures) + .set({ taskId: null, updatedAt: now }) + .where(and(missionProjectScope(schema.project.missionFeatures.projectId), eq(schema.project.missionFeatures.id, featureId))); +} + +// ════════════════════════════════════════════════════════════════════ +// MISSION EVENTS +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-10:05: + * Get the maximum event seq for the mission_events table (used to initialize + * the event sequence counter on store open so new events have unique seqs). + */ +export async function getMaxEventSeq(handle: QueryHandle): Promise { + const rows = await handle + .select({ maxSeq: sql`max(${schema.project.missionEvents.seq})` }) + .from(schema.project.missionEvents) + .where(missionProjectScope(schema.project.missionEvents.projectId)); + return rows[0]?.maxSeq ?? 0; +} + +/** + * FNXC:MissionStore 2026-06-24-10:10: + * Insert a mission event (non-destructive). metadata is a jsonb column. + */ +export async function insertMissionEvent(handle: QueryHandle, event: MissionEvent): Promise { + await handle.insert(schema.project.missionEvents).values({ + projectId: missionProjectId(), + id: event.id, + missionId: event.missionId, + eventType: event.eventType, + description: event.description, + metadata: event.metadata, + timestamp: event.timestamp, + seq: event.seq, + }); +} + +/** + * FNXC:MissionStore 2026-06-24-10:15: + * Insert a mission event with INSERT OR IGNORE semantics (snapshot apply). + */ +export async function insertMissionEventIfAbsent(handle: QueryHandle, event: MissionEvent): Promise { + await handle + .insert(schema.project.missionEvents) + .values({ + projectId: missionProjectId(), + id: event.id, + missionId: event.missionId, + eventType: event.eventType, + description: event.description, + metadata: event.metadata, + timestamp: event.timestamp, + seq: event.seq, + }) + .onConflictDoNothing(); +} + +/** Count events for a mission. */ +export async function countMissionEvents(handle: QueryHandle, missionId: string): Promise { + const rows = await handle + .select({ count: sql`count(*)::int` }) + .from(schema.project.missionEvents) + .where(and(missionProjectScope(schema.project.missionEvents.projectId), eq(schema.project.missionEvents.missionId, missionId))); + return rows[0]?.count ?? 0; +} + +/** Get events for a mission, ordered by seq DESC (or timestamp DESC, id DESC), with optional limit. */ +export async function listMissionEvents( + handle: QueryHandle, + missionId: string, + limit?: number, +): Promise { + let query = handle + .select(eventColumns) + .from(schema.project.missionEvents) + .where(and(missionProjectScope(schema.project.missionEvents.projectId), eq(schema.project.missionEvents.missionId, missionId))) + .orderBy(desc(schema.project.missionEvents.seq), desc(schema.project.missionEvents.id)); + if (limit !== undefined) { + query = query.limit(limit) as typeof query; + } + const rows = await query; + return rows.map((row) => rowToMissionEvent(row as MissionEventRow)); +} + +/** Count events grouped by missionId (batch query for summaries). */ +export async function countEventsByMission(handle: QueryHandle): Promise> { + const rows = await handle + .select({ + missionId: schema.project.missionEvents.missionId, + count: sql`count(*)::int`, + }) + .from(schema.project.missionEvents) + .where(missionProjectScope(schema.project.missionEvents.projectId)) + .groupBy(schema.project.missionEvents.missionId); + return new Map(rows.map((row) => [row.missionId, row.count])); +} + +/** + * FNXC:MissionStore 2026-06-24-10:20: + * Get the latest error event per mission (batch query for health rollup). + * Ordered by seq DESC, id DESC so the first row per missionId is the latest. + */ +export async function listErrorEventsForHealth(handle: QueryHandle): Promise> { + return handle + .select({ + missionId: schema.project.missionEvents.missionId, + timestamp: schema.project.missionEvents.timestamp, + description: schema.project.missionEvents.description, + }) + .from(schema.project.missionEvents) + .where(and(missionProjectScope(schema.project.missionEvents.projectId), eq(schema.project.missionEvents.eventType, "error"))) + .orderBy(desc(schema.project.missionEvents.seq), desc(schema.project.missionEvents.id)); +} + +// ════════════════════════════════════════════════════════════════════ +// MISSION-GOAL LINKS +// ════════════════════════════════════════════════════════════════════ + +/** Get a mission-goal link row if it exists. */ +export async function getMissionGoalLink( + handle: QueryHandle, + missionId: string, + goalId: string, +): Promise { + const rows = await handle + .select(missionGoalColumns) + .from(schema.project.missionGoals) + .where( + and( + missionProjectScope(schema.project.missionGoals.projectId), + eq(schema.project.missionGoals.missionId, missionId), + eq(schema.project.missionGoals.goalId, goalId), + ), + ); + return rows[0] ? rowToMissionGoalLink(rows[0] as MissionGoalRow) : undefined; +} + +/** + * FNXC:MissionStore 2026-06-24-10:25: + * Insert a mission-goal link with INSERT OR IGNORE semantics (idempotent link). + */ +export async function insertMissionGoalLink( + handle: QueryHandle, + missionId: string, + goalId: string, + createdAt: string, +): Promise { + await handle + .insert(schema.project.missionGoals) + .values({ projectId: missionProjectId(), missionId, goalId, createdAt }) + .onConflictDoNothing(); +} + +/** Delete a mission-goal link. Returns true if a row was deleted. */ +export async function deleteMissionGoalLink( + handle: QueryHandle, + missionId: string, + goalId: string, +): Promise { + const result = await handle + .delete(schema.project.missionGoals) + .where( + and( + missionProjectScope(schema.project.missionGoals.projectId), + eq(schema.project.missionGoals.missionId, missionId), + eq(schema.project.missionGoals.goalId, goalId), + ), + ) + .returning({ missionId: schema.project.missionGoals.missionId }); + return result.length > 0; +} + +/** List goal IDs linked to a mission, ordered by createdAt ASC, goalId ASC. */ +export async function listGoalIdsForMission(handle: QueryHandle, missionId: string): Promise { + const rows = await handle + .select({ goalId: schema.project.missionGoals.goalId }) + .from(schema.project.missionGoals) + .where(and(missionProjectScope(schema.project.missionGoals.projectId), eq(schema.project.missionGoals.missionId, missionId))) + .orderBy(asc(schema.project.missionGoals.createdAt), asc(schema.project.missionGoals.goalId)); + return rows.map((row) => row.goalId); +} + +/** List mission IDs linked to a goal, ordered by createdAt ASC, missionId ASC. */ +export async function listMissionIdsForGoal(handle: QueryHandle, goalId: string): Promise { + const rows = await handle + .select({ missionId: schema.project.missionGoals.missionId }) + .from(schema.project.missionGoals) + .where(and(missionProjectScope(schema.project.missionGoals.projectId), eq(schema.project.missionGoals.goalId, goalId))) + .orderBy(asc(schema.project.missionGoals.createdAt), asc(schema.project.missionGoals.missionId)); + return rows.map((row) => row.missionId); +} + +/** Count goals linked per mission (batch query for summaries). */ +export async function countGoalsByMission(handle: QueryHandle): Promise> { + const rows = await handle + .select({ + missionId: schema.project.missionGoals.missionId, + count: sql`count(*)::int`, + }) + .from(schema.project.missionGoals) + .where(missionProjectScope(schema.project.missionGoals.projectId)) + .groupBy(schema.project.missionGoals.missionId); + return new Map(rows.map((row) => [row.missionId, row.count])); +} + +/** Check whether a goal exists (for link validation). */ +export async function goalExists(handle: QueryHandle, goalId: string): Promise { + const rows = await handle + .select({ id: schema.project.goals.id }) + .from(schema.project.goals) + .where(and(missionProjectScope(schema.project.goals.projectId), eq(schema.project.goals.id, goalId))); + return rows.length > 0; +} + +/** Get a goal by id. */ +export async function getGoal(handle: QueryHandle, goalId: string): Promise { + const rows = await handle + .select({ + id: schema.project.goals.id, + title: schema.project.goals.title, + description: schema.project.goals.description, + status: schema.project.goals.status, + createdAt: schema.project.goals.createdAt, + updatedAt: schema.project.goals.updatedAt, + }) + .from(schema.project.goals) + .where(and(missionProjectScope(schema.project.goals.projectId), eq(schema.project.goals.id, goalId))); + return rows[0] ? rowToGoal(rows[0] as GoalRow) : undefined; +} + +/** Get goals by IDs (batch fetch). */ +export async function listGoalsByIds(handle: QueryHandle, goalIds: string[]): Promise { + if (goalIds.length === 0) return []; + const rows = await handle + .select({ + id: schema.project.goals.id, + title: schema.project.goals.title, + description: schema.project.goals.description, + status: schema.project.goals.status, + createdAt: schema.project.goals.createdAt, + updatedAt: schema.project.goals.updatedAt, + }) + .from(schema.project.goals) + .where(and(missionProjectScope(schema.project.goals.projectId), inArray(schema.project.goals.id, goalIds))); + return rows.map((row) => rowToGoal(row as GoalRow)); +} + +// ════════════════════════════════════════════════════════════════════ +// CONTRACT ASSERTIONS +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-10:30: + * Create a contract assertion (non-destructive INSERT). + */ +export async function createContractAssertion( + handle: QueryHandle, + assertion: MissionContractAssertion, +): Promise { + await handle.insert(schema.project.missionContractAssertions).values({ + projectId: missionProjectId(), + id: assertion.id, + milestoneId: assertion.milestoneId, + title: assertion.title, + assertion: assertion.assertion, + status: assertion.status, + type: normalizeMissionAssertionType(assertion.type), + orderIndex: assertion.orderIndex, + sourceFeatureId: assertion.sourceFeatureId ?? null, + createdAt: assertion.createdAt, + updatedAt: assertion.updatedAt, + }); + return (await getContractAssertion(handle, assertion.id))!; +} + +/** Get a contract assertion by id. */ +export async function getContractAssertion(handle: QueryHandle, id: string): Promise { + const rows = await handle + .select(assertionColumns) + .from(schema.project.missionContractAssertions) + .where(and(missionProjectScope(schema.project.missionContractAssertions.projectId), eq(schema.project.missionContractAssertions.id, id))); + return rows[0] ? rowToAssertion(rows[0] as AssertionRow) : undefined; +} + +/** List contract assertions for a milestone, ordered by orderIndex, createdAt, id. */ +export async function listContractAssertions(handle: QueryHandle, milestoneId: string): Promise { + const rows = await handle + .select(assertionColumns) + .from(schema.project.missionContractAssertions) + .where(and(missionProjectScope(schema.project.missionContractAssertions.projectId), eq(schema.project.missionContractAssertions.milestoneId, milestoneId))) + .orderBy( + asc(schema.project.missionContractAssertions.orderIndex), + asc(schema.project.missionContractAssertions.createdAt), + asc(schema.project.missionContractAssertions.id), + ); + return rows.map((row) => rowToAssertion(row as AssertionRow)); +} + +/** Read linked assertions for a feature set in one join. */ +export async function listLinkedAssertionsForFeatures( + handle: QueryHandle, + featureIds: string[], +): Promise> { + if (featureIds.length === 0) return []; + const rows = await handle + .select({ featureId: schema.project.missionFeatureAssertions.featureId, ...assertionColumns }) + .from(schema.project.missionFeatureAssertions) + .innerJoin( + schema.project.missionContractAssertions, + and( + eq(schema.project.missionContractAssertions.projectId, schema.project.missionFeatureAssertions.projectId), + eq(schema.project.missionContractAssertions.id, schema.project.missionFeatureAssertions.assertionId), + ), + ) + .where(and(missionProjectScope(schema.project.missionFeatureAssertions.projectId), inArray(schema.project.missionFeatureAssertions.featureId, [...new Set(featureIds)]))); + return rows.map((row) => ({ + featureId: row.featureId, + assertion: rowToAssertion(row as unknown as AssertionRow), + })); +} + +/** Return the linked subset of an assertion ID set in one query. */ +export async function listLinkedAssertionIds(handle: QueryHandle, assertionIds: string[]): Promise> { + if (assertionIds.length === 0) return new Set(); + const rows = await handle + .select({ assertionId: schema.project.missionFeatureAssertions.assertionId }) + .from(schema.project.missionFeatureAssertions) + .where(and(missionProjectScope(schema.project.missionFeatureAssertions.projectId), inArray(schema.project.missionFeatureAssertions.assertionId, [...new Set(assertionIds)]))); + return new Set(rows.map((row) => row.assertionId)); +} + +/** Update a contract assertion's mutable columns. */ +export async function updateContractAssertion(handle: QueryHandle, assertion: MissionContractAssertion): Promise { + await handle + .update(schema.project.missionContractAssertions) + .set({ + title: assertion.title, + assertion: assertion.assertion, + status: assertion.status, + type: normalizeMissionAssertionType(assertion.type), + orderIndex: assertion.orderIndex, + sourceFeatureId: assertion.sourceFeatureId ?? null, + updatedAt: assertion.updatedAt, + }) + .where(and(missionProjectScope(schema.project.missionContractAssertions.projectId), eq(schema.project.missionContractAssertions.id, assertion.id))); +} + +/** Delete a contract assertion by id. Returns true if deleted. */ +export async function deleteContractAssertion(handle: QueryHandle, id: string): Promise { + const result = await handle + .delete(schema.project.missionContractAssertions) + .where(and(missionProjectScope(schema.project.missionContractAssertions.projectId), eq(schema.project.missionContractAssertions.id, id))) + .returning({ id: schema.project.missionContractAssertions.id }); + return result.length > 0; +} + +/** Reorder contract assertions transactionally. */ +export async function reorderContractAssertions( + layer: AsyncDataLayer, + orderedIds: string[], +): Promise { + const now = new Date().toISOString(); + await layer.transactionImmediate(async (tx) => { + for (let i = 0; i < orderedIds.length; i++) { + await tx + .update(schema.project.missionContractAssertions) + .set({ orderIndex: i, updatedAt: now }) + .where(and(missionProjectScope(schema.project.missionContractAssertions.projectId), eq(schema.project.missionContractAssertions.id, orderedIds[i]!))); + } + }); +} + +// ════════════════════════════════════════════════════════════════════ +// FEATURE-ASSERTION LINKS +// ════════════════════════════════════════════════════════════════════ + +/** Check whether a feature-assertion link exists. */ +export async function featureAssertionLinkExists( + handle: QueryHandle, + featureId: string, + assertionId: string, +): Promise { + const rows = await handle + .select({ featureId: schema.project.missionFeatureAssertions.featureId }) + .from(schema.project.missionFeatureAssertions) + .where( + and( + missionProjectScope(schema.project.missionFeatureAssertions.projectId), + eq(schema.project.missionFeatureAssertions.featureId, featureId), + eq(schema.project.missionFeatureAssertions.assertionId, assertionId), + ), + ); + return rows.length > 0; +} + +/** Insert a feature-assertion link with INSERT OR IGNORE semantics. */ +export async function linkFeatureToAssertion( + handle: QueryHandle, + featureId: string, + assertionId: string, + createdAt: string, +): Promise { + await handle + .insert(schema.project.missionFeatureAssertions) + .values({ projectId: missionProjectId(), featureId, assertionId, createdAt }) + .onConflictDoNothing(); +} + +/** Delete a feature-assertion link. Returns true if deleted. */ +export async function unlinkFeatureFromAssertion( + handle: QueryHandle, + featureId: string, + assertionId: string, +): Promise { + const result = await handle + .delete(schema.project.missionFeatureAssertions) + .where( + and( + missionProjectScope(schema.project.missionFeatureAssertions.projectId), + eq(schema.project.missionFeatureAssertions.featureId, featureId), + eq(schema.project.missionFeatureAssertions.assertionId, assertionId), + ), + ) + .returning({ featureId: schema.project.missionFeatureAssertions.featureId }); + return result.length > 0; +} + +/** List all feature-assertion links, ordered by createdAt ASC. */ +export async function listAllFeatureAssertionLinks(handle: QueryHandle): Promise { + const rows = await handle + .select({ + featureId: schema.project.missionFeatureAssertions.featureId, + assertionId: schema.project.missionFeatureAssertions.assertionId, + createdAt: schema.project.missionFeatureAssertions.createdAt, + }) + .from(schema.project.missionFeatureAssertions) + .where(missionProjectScope(schema.project.missionFeatureAssertions.projectId)) + .orderBy(asc(schema.project.missionFeatureAssertions.createdAt)); + return rows.map((row) => rowToFeatureAssertionLink(row as FeatureAssertionLinkRow)); +} + +// ════════════════════════════════════════════════════════════════════ +// VALIDATOR RUNS +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-10:35: + * Create a validator run (non-destructive INSERT). + */ +export async function createValidatorRun(handle: QueryHandle, run: MissionValidatorRun): Promise { + await handle.insert(schema.project.missionValidatorRuns).values({ + projectId: missionProjectId(), + id: run.id, + featureId: run.featureId, + milestoneId: run.milestoneId, + sliceId: run.sliceId, + status: run.status, + triggerType: run.triggerType ?? "auto", + implementationAttempt: run.implementationAttempt, + validatorAttempt: run.validatorAttempt, + taskId: run.taskId ?? null, + summary: run.summary ?? null, + blockedReason: run.blockedReason ?? null, + startedAt: run.startedAt, + completedAt: run.completedAt ?? null, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + }); + return (await getValidatorRun(handle, run.id))!; +} + +/** Get a validator run by id. */ +export async function getValidatorRun(handle: QueryHandle, id: string): Promise { + const rows = await handle + .select(validatorRunColumns) + .from(schema.project.missionValidatorRuns) + .where(and(missionProjectScope(schema.project.missionValidatorRuns.projectId), eq(schema.project.missionValidatorRuns.id, id))); + return rows[0] ? rowToValidatorRun(rows[0] as ValidatorRunRow) : undefined; +} + +/** List validator runs for a feature, ordered by startedAt DESC. */ +export async function listValidatorRunsByFeature(handle: QueryHandle, featureId: string): Promise { + const rows = await handle + .select(validatorRunColumns) + .from(schema.project.missionValidatorRuns) + .where(and(missionProjectScope(schema.project.missionValidatorRuns.projectId), eq(schema.project.missionValidatorRuns.featureId, featureId))) + .orderBy(desc(schema.project.missionValidatorRuns.startedAt)); + return rows.map((row) => rowToValidatorRun(row as ValidatorRunRow)); +} + +/** List stale running validator runs older than the cutoff, ordered by startedAt ASC. */ +export async function listStaleRunningValidatorRuns(handle: QueryHandle, cutoffIso: string): Promise { + const rows = await handle + .select(validatorRunColumns) + .from(schema.project.missionValidatorRuns) + .where( + and( + missionProjectScope(schema.project.missionValidatorRuns.projectId), + eq(schema.project.missionValidatorRuns.status, "running"), + sql`${schema.project.missionValidatorRuns.startedAt} < ${cutoffIso}`, + ), + ) + .orderBy(asc(schema.project.missionValidatorRuns.startedAt)); + return rows.map((row) => rowToValidatorRun(row as ValidatorRunRow)); +} + +/** Update a validator run's mutable columns (status, summary, blockedReason, completedAt). */ +export async function updateValidatorRun(handle: QueryHandle, run: MissionValidatorRun): Promise { + await handle + .update(schema.project.missionValidatorRuns) + .set({ + status: run.status, + summary: run.summary ?? null, + blockedReason: run.blockedReason ?? null, + completedAt: run.completedAt ?? null, + updatedAt: run.updatedAt, + }) + .where(and(missionProjectScope(schema.project.missionValidatorRuns.projectId), eq(schema.project.missionValidatorRuns.id, run.id))); +} + +/** + * FNXC:MissionValidatorConcurrency 2026-07-14-18:45: + * Validator completion and stale-run reaping compete for the same terminal transition. PostgreSQL chooses exactly one winner by conditioning the write on status='running'; losers must not mutate the feature or emit a second terminal event. + */ +export async function transitionRunningValidatorRun( + handle: QueryHandle, + run: MissionValidatorRun, +): Promise { + const rows = await handle + .update(schema.project.missionValidatorRuns) + .set({ + status: run.status, + summary: run.summary ?? null, + blockedReason: run.blockedReason ?? null, + completedAt: run.completedAt ?? null, + updatedAt: run.updatedAt, + }) + .where(and( + missionProjectScope(schema.project.missionValidatorRuns.projectId), + eq(schema.project.missionValidatorRuns.id, run.id), + eq(schema.project.missionValidatorRuns.status, "running"), + )) + .returning(validatorRunColumns); + return rows[0] ? rowToValidatorRun(rows[0] as ValidatorRunRow) : undefined; +} + +// ════════════════════════════════════════════════════════════════════ +// VALIDATOR FAILURES +// ════════════════════════════════════════════════════════════════════ + +/** Insert a validator failure record (non-destructive INSERT). */ +export async function insertValidatorFailure(handle: QueryHandle, failure: MissionAssertionFailureRecord): Promise { + await handle.insert(schema.project.missionValidatorFailures).values({ + projectId: missionProjectId(), + id: failure.id, + runId: failure.runId, + featureId: failure.featureId, + assertionId: failure.assertionId, + message: failure.message ?? null, + expected: failure.expected ?? null, + actual: failure.actual ?? null, + createdAt: failure.createdAt, + }); +} + +/** Bulk insert all failures observed by one validator run. */ +export async function insertValidatorFailures(handle: QueryHandle, failures: MissionAssertionFailureRecord[]): Promise { + if (failures.length === 0) return; + await handle.insert(schema.project.missionValidatorFailures).values(failures.map((failure) => ({ + projectId: missionProjectId(), + id: failure.id, + runId: failure.runId, + featureId: failure.featureId, + assertionId: failure.assertionId, + message: failure.message ?? null, + expected: failure.expected ?? null, + actual: failure.actual ?? null, + createdAt: failure.createdAt, + }))); +} + +/** List failures for a run, ordered by createdAt ASC. */ +export async function listFailuresForRun(handle: QueryHandle, runId: string): Promise { + const rows = await handle + .select(failureColumns) + .from(schema.project.missionValidatorFailures) + .where(and(missionProjectScope(schema.project.missionValidatorFailures.projectId), eq(schema.project.missionValidatorFailures.runId, runId))) + .orderBy(asc(schema.project.missionValidatorFailures.createdAt)); + return rows.map((row) => rowToFailure(row as FailureRow)); +} + + +/** Fetch failure history for a validator-run set in one ordered query. */ +export async function listFailuresForRuns(handle: QueryHandle, runIds: string[]): Promise { + if (runIds.length === 0) return []; + const rows = await handle + .select(failureColumns) + .from(schema.project.missionValidatorFailures) + .where(and(missionProjectScope(schema.project.missionValidatorFailures.projectId), inArray(schema.project.missionValidatorFailures.runId, [...new Set(runIds)]))) + .orderBy(asc(schema.project.missionValidatorFailures.createdAt)); + return rows.map((row) => rowToFailure(row as FailureRow)); +} + +/** Return feature ids that have at least one linked assertion in one query. */ +export async function listFeatureIdsWithAssertions(handle: QueryHandle, featureIds: string[]): Promise> { + if (featureIds.length === 0) return new Set(); + const rows = await handle + .selectDistinct({ featureId: schema.project.missionFeatureAssertions.featureId }) + .from(schema.project.missionFeatureAssertions) + .where(and(missionProjectScope(schema.project.missionFeatureAssertions.projectId), inArray(schema.project.missionFeatureAssertions.featureId, [...new Set(featureIds)]))); + return new Set(rows.map((row) => row.featureId)); +} + +// ════════════════════════════════════════════════════════════════════ +// FIX-FEATURE LINEAGE +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-10:40: + * Insert a fix-feature lineage row. failedAssertionIds is a jsonb array. + */ +export async function insertFixFeatureLineage(handle: QueryHandle, lineage: MissionFixFeatureLineage): Promise { + await handle.insert(schema.project.missionFixFeatureLineage).values({ + projectId: missionProjectId(), + id: lineage.id, + sourceFeatureId: lineage.sourceFeatureId, + fixFeatureId: lineage.fixFeatureId, + runId: lineage.runId, + failedAssertionIds: lineage.failedAssertionIds, + createdAt: lineage.createdAt, + }); +} + +/** Find the fix-feature ID for a source feature + run (first match, ordered by createdAt). */ +export async function findFixFeatureId(handle: QueryHandle, sourceFeatureId: string, runId: string): Promise { + const rows = await handle + .select({ fixFeatureId: schema.project.missionFixFeatureLineage.fixFeatureId }) + .from(schema.project.missionFixFeatureLineage) + .where( + and( + missionProjectScope(schema.project.missionFixFeatureLineage.projectId), + eq(schema.project.missionFixFeatureLineage.sourceFeatureId, sourceFeatureId), + eq(schema.project.missionFixFeatureLineage.runId, runId), + ), + ) + .orderBy(asc(schema.project.missionFixFeatureLineage.createdAt)) + .limit(1); + return rows[0]?.fixFeatureId; +} + +/** Find all fix-feature IDs for a source feature, ordered by createdAt ASC. */ +export async function findFixFeatureIdsForSource(handle: QueryHandle, sourceFeatureId: string): Promise { + const rows = await handle + .select({ fixFeatureId: schema.project.missionFixFeatureLineage.fixFeatureId }) + .from(schema.project.missionFixFeatureLineage) + .where(and(missionProjectScope(schema.project.missionFixFeatureLineage.projectId), eq(schema.project.missionFixFeatureLineage.sourceFeatureId, sourceFeatureId))) + .orderBy(asc(schema.project.missionFixFeatureLineage.createdAt)); + return rows.map((row) => row.fixFeatureId); +} + +/** Get lineage rows for a source feature. */ +export async function listLineageForSourceFeature(handle: QueryHandle, sourceFeatureId: string): Promise { + const rows = await handle + .select(lineageColumns) + .from(schema.project.missionFixFeatureLineage) + .where(and(missionProjectScope(schema.project.missionFixFeatureLineage.projectId), eq(schema.project.missionFixFeatureLineage.sourceFeatureId, sourceFeatureId))); + return rows.map((row) => rowToLineage(row as LineageRow)); +} + +/** Get lineage rows where the feature is a fix (fixFeatureId match). */ +export async function listLineageForFixFeature(handle: QueryHandle, fixFeatureId: string): Promise { + const rows = await handle + .select(lineageColumns) + .from(schema.project.missionFixFeatureLineage) + .where(and(missionProjectScope(schema.project.missionFixFeatureLineage.projectId), eq(schema.project.missionFixFeatureLineage.fixFeatureId, fixFeatureId))); + return rows.map((row) => rowToLineage(row as LineageRow)); +} + +// ════════════════════════════════════════════════════════════════════ +// SNAPSHOT APPLY (upserts) +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-24-10:45: + * Upsert a mission (snapshot apply / mesh replication). On conflict, update all + * mutable columns. This is the ON CONFLICT(id) DO UPDATE SET ... pattern from + * the sync applyMissionHierarchySnapshot. + */ +export async function upsertMission(handle: QueryHandle, mission: Mission): Promise { + await handle + .insert(schema.project.missions) + .values({ + projectId: missionProjectId(), + id: mission.id, + title: mission.title, + description: mission.description ?? null, + status: mission.status, + interviewState: mission.interviewState, + baseBranch: mission.baseBranch ?? null, + branchStrategy: serializeBranchStrategy(mission.branchStrategy), + autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0, + autoAdvance: mission.autoAdvance ? 1 : 0, + autopilotEnabled: mission.autopilotEnabled ? 1 : 0, + autopilotState: mission.autopilotState, + lastAutopilotActivityAt: mission.lastAutopilotActivityAt ?? null, + createdAt: mission.createdAt, + updatedAt: mission.updatedAt, + }) + .onConflictDoUpdate({ + target: [schema.project.missions.projectId, schema.project.missions.id], + set: { + title: sql`excluded.title`, + description: sql`excluded.description`, + status: sql`excluded.status`, + interviewState: sql`excluded.interview_state`, + baseBranch: sql`excluded.base_branch`, + branchStrategy: sql`excluded.branch_strategy`, + autoMerge: sql`excluded.auto_merge`, + autoAdvance: sql`excluded.auto_advance`, + autopilotEnabled: sql`excluded.autopilot_enabled`, + autopilotState: sql`excluded.autopilot_state`, + lastAutopilotActivityAt: sql`excluded.last_autopilot_activity_at`, + updatedAt: sql`excluded.updated_at`, + }, + }); +} + +/** Upsert a milestone (snapshot apply). */ +export async function upsertMilestone(handle: QueryHandle, milestone: Milestone): Promise { + await handle + .insert(schema.project.milestones) + .values({ + projectId: missionProjectId(), + id: milestone.id, + missionId: milestone.missionId, + title: milestone.title, + description: milestone.description ?? null, + status: milestone.status, + orderIndex: milestone.orderIndex, + interviewState: milestone.interviewState, + dependencies: milestone.dependencies, + planningNotes: milestone.planningNotes ?? null, + verification: milestone.verification ?? null, + acceptanceCriteria: milestone.acceptanceCriteria ?? null, + validationState: milestone.validationState ?? "not_started", + createdAt: milestone.createdAt, + updatedAt: milestone.updatedAt, + }) + .onConflictDoUpdate({ + target: [schema.project.milestones.projectId, schema.project.milestones.id], + set: { + title: sql`excluded.title`, + description: sql`excluded.description`, + status: sql`excluded.status`, + orderIndex: sql`excluded.order_index`, + interviewState: sql`excluded.interview_state`, + dependencies: sql`excluded.dependencies`, + planningNotes: sql`excluded.planning_notes`, + verification: sql`excluded.verification`, + acceptanceCriteria: sql`excluded.acceptance_criteria`, + validationState: sql`excluded.validation_state`, + updatedAt: sql`excluded.updated_at`, + }, + }); +} + +/** Upsert a slice (snapshot apply). */ +export async function upsertSlice(handle: QueryHandle, slice: Slice): Promise { + await handle + .insert(schema.project.slices) + .values({ + projectId: missionProjectId(), + id: slice.id, + milestoneId: slice.milestoneId, + title: slice.title, + description: slice.description ?? null, + status: slice.status, + orderIndex: slice.orderIndex, + activatedAt: slice.activatedAt ?? null, + planState: slice.planState ?? "not_started", + planningNotes: slice.planningNotes ?? null, + verification: slice.verification ?? null, + createdAt: slice.createdAt, + updatedAt: slice.updatedAt, + }) + .onConflictDoUpdate({ + target: [schema.project.slices.projectId, schema.project.slices.id], + set: { + title: sql`excluded.title`, + description: sql`excluded.description`, + status: sql`excluded.status`, + orderIndex: sql`excluded.order_index`, + activatedAt: sql`excluded.activated_at`, + planState: sql`excluded.plan_state`, + planningNotes: sql`excluded.planning_notes`, + verification: sql`excluded.verification`, + updatedAt: sql`excluded.updated_at`, + }, + }); +} + +/** Upsert a feature (snapshot apply). */ +export async function upsertFeature(handle: QueryHandle, feature: MissionFeature): Promise { + await handle + .insert(schema.project.missionFeatures) + .values({ + projectId: missionProjectId(), + id: feature.id, + sliceId: feature.sliceId, + taskId: feature.taskId ?? null, + title: feature.title, + description: feature.description ?? null, + acceptanceCriteria: feature.acceptanceCriteria ?? null, + status: feature.status, + createdAt: feature.createdAt, + updatedAt: feature.updatedAt, + loopState: feature.loopState ?? "idle", + implementationAttemptCount: feature.implementationAttemptCount ?? 0, + validatorAttemptCount: feature.validatorAttemptCount ?? 0, + lastValidatorRunId: feature.lastValidatorRunId ?? null, + lastValidatorStatus: feature.lastValidatorStatus ?? null, + generatedFromFeatureId: feature.generatedFromFeatureId ?? null, + generatedFromRunId: feature.generatedFromRunId ?? null, + }) + .onConflictDoUpdate({ + target: [schema.project.missionFeatures.projectId, schema.project.missionFeatures.id], + set: { + taskId: sql`excluded.task_id`, + title: sql`excluded.title`, + description: sql`excluded.description`, + acceptanceCriteria: sql`excluded.acceptance_criteria`, + status: sql`excluded.status`, + updatedAt: sql`excluded.updated_at`, + loopState: sql`excluded.loop_state`, + implementationAttemptCount: sql`excluded.implementation_attempt_count`, + validatorAttemptCount: sql`excluded.validator_attempt_count`, + lastValidatorRunId: sql`excluded.last_validator_run_id`, + lastValidatorStatus: sql`excluded.last_validator_status`, + generatedFromFeatureId: sql`excluded.generated_from_feature_id`, + generatedFromRunId: sql`excluded.generated_from_run_id`, + }, + }); +} + +/** Upsert a contract assertion (snapshot apply). */ +export async function upsertContractAssertion(handle: QueryHandle, assertion: MissionContractAssertion): Promise { + await handle + .insert(schema.project.missionContractAssertions) + .values({ + projectId: missionProjectId(), + id: assertion.id, + milestoneId: assertion.milestoneId, + title: assertion.title, + assertion: assertion.assertion, + status: assertion.status, + type: normalizeMissionAssertionType(assertion.type), + orderIndex: assertion.orderIndex, + sourceFeatureId: assertion.sourceFeatureId ?? null, + createdAt: assertion.createdAt, + updatedAt: assertion.updatedAt, + }) + .onConflictDoUpdate({ + target: [ + schema.project.missionContractAssertions.projectId, + schema.project.missionContractAssertions.id, + ], + set: { + title: sql`excluded.title`, + assertion: sql`excluded.assertion`, + status: sql`excluded.status`, + type: sql`excluded.type`, + orderIndex: sql`excluded.order_index`, + sourceFeatureId: sql`excluded.source_feature_id`, + updatedAt: sql`excluded.updated_at`, + }, + }); +} + +// ════════════════════════════════════════════════════════════════════ +// U5 ADDED HELPERS — JOIN lists, event paging, task-linkage guards +// ════════════════════════════════════════════════════════════════════ + +/** + * FNXC:MissionStore 2026-06-27-15:05: + * Paginated mission events with total count and optional eventType filter. + * Mirrors sync `MissionStore.getMissionEvents` ordering: + * COALESCE(seq,0) DESC, timestamp DESC, id DESC. + */ +export async function getMissionEventsPage( + handle: QueryHandle, + missionId: string, + options?: { limit?: number; offset?: number; eventType?: string }, +): Promise<{ events: MissionEvent[]; total: number }> { + const limit = Math.max(0, options?.limit ?? 50); + const offset = Math.max(0, options?.offset ?? 0); + const conditions = [ + missionProjectScope(schema.project.missionEvents.projectId), + eq(schema.project.missionEvents.missionId, missionId), + ]; + if (options?.eventType) conditions.push(eq(schema.project.missionEvents.eventType, options.eventType)); + const totalRows = await handle + .select({ count: sql`count(*)::int` }) + .from(schema.project.missionEvents) + .where(and(...conditions)); + const total = totalRows[0]?.count ?? 0; + const rows = await handle + .select(eventColumns) + .from(schema.project.missionEvents) + .where(and(...conditions)) + .orderBy( + desc(sql`coalesce(${schema.project.missionEvents.seq}, 0)`), + desc(schema.project.missionEvents.timestamp), + desc(schema.project.missionEvents.id), + ) + .limit(limit) + .offset(offset); + return { events: rows.map((row) => rowToMissionEvent(row as MissionEventRow)), total }; +} + +/** + * FNXC:MissionStore 2026-06-27-15:05: + * List assertions linked to a feature (JOIN mission_feature_assertions), + * ordered orderIndex ASC, createdAt ASC, id ASC — mirrors sync `listAssertionsForFeature`. + */ +export async function listAssertionsForFeature(handle: QueryHandle, featureId: string): Promise { + const rows = await handle + .select(assertionColumns) + .from(schema.project.missionContractAssertions) + .innerJoin( + schema.project.missionFeatureAssertions, + and( + eq(schema.project.missionContractAssertions.projectId, schema.project.missionFeatureAssertions.projectId), + eq(schema.project.missionContractAssertions.id, schema.project.missionFeatureAssertions.assertionId), + ), + ) + .where(and(missionProjectScope(schema.project.missionFeatureAssertions.projectId), eq(schema.project.missionFeatureAssertions.featureId, featureId))) + .orderBy( + asc(schema.project.missionContractAssertions.orderIndex), + asc(schema.project.missionContractAssertions.createdAt), + asc(schema.project.missionContractAssertions.id), + ); + return rows.map((row) => rowToAssertion(row as AssertionRow)); +} + +/** + * FNXC:MissionStore 2026-06-27-15:05: + * List features linked to an assertion (JOIN), ordered createdAt ASC. + */ +export async function listFeaturesForAssertion(handle: QueryHandle, assertionId: string): Promise { + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .innerJoin( + schema.project.missionFeatureAssertions, + and( + eq(schema.project.missionFeatures.projectId, schema.project.missionFeatureAssertions.projectId), + eq(schema.project.missionFeatures.id, schema.project.missionFeatureAssertions.featureId), + ), + ) + .where(and(missionProjectScope(schema.project.missionFeatureAssertions.projectId), eq(schema.project.missionFeatureAssertions.assertionId, assertionId))) + .orderBy(asc(schema.project.missionFeatures.createdAt)); + return rows.map((row) => rowToFeature(row as FeatureRow)); +} + +/** Filter the given task ids to those that are live (not deleted, not archived). */ +export async function listLiveLinkedTaskIds(handle: QueryHandle, taskIds: string[]): Promise> { + if (taskIds.length === 0) return new Set(); + const rows = await handle + .select({ id: schema.project.tasks.id }) + .from(schema.project.tasks) + .where( + and( + missionProjectScope(schema.project.tasks.projectId), + inArray(schema.project.tasks.id, taskIds), + sql`${schema.project.tasks.deletedAt} is null`, + sql`${schema.project.tasks.column} <> 'archived'`, + ), + ); + return new Set(rows.map((row) => row.id)); +} + +/** Get a live (non-deleted) task's id + column, or undefined. */ +export async function getLiveTaskById(handle: QueryHandle, taskId: string): Promise<{ id: string; column: string } | undefined> { + const rows = await handle + .select({ id: schema.project.tasks.id, column: schema.project.tasks.column }) + .from(schema.project.tasks) + .where(and(missionProjectScope(schema.project.tasks.projectId), eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} is null`)); + const row = rows[0]; + return row ? { id: row.id, column: row.column as string } : undefined; +} + +/** Set a live task's mission/slice linkage (bidirectional link). */ +export async function setTaskMissionLinkage(handle: QueryHandle, taskId: string, missionId: string, sliceId: string): Promise { + await handle + .update(schema.project.tasks) + .set({ missionId, sliceId }) + .where(and(missionProjectScope(schema.project.tasks.projectId), eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} is null`)); +} + +/** Clear a live task's mission/slice linkage. */ +export async function clearTaskMissionLinkage(handle: QueryHandle, taskId: string): Promise { + await handle + .update(schema.project.tasks) + .set({ missionId: null, sliceId: null }) + .where(and(missionProjectScope(schema.project.tasks.projectId), eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} is null`)); +} + +/** Set of all failed (non-deleted) task ids — for health rollup. */ +export async function listFailedTaskIds(handle: QueryHandle): Promise> { + const rows = await handle + .select({ id: schema.project.tasks.id }) + .from(schema.project.tasks) + .where(and( + missionProjectScope(schema.project.tasks.projectId), + eq(schema.project.tasks.status, "failed"), + sql`${schema.project.tasks.deletedAt} is null`, + )); + return new Set(rows.map((row) => row.id)); +} diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 348b69a309..1018bb7139 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -1,57 +1,22 @@ /** - * Async Drizzle MissionStore helpers (U6 satellite-mission-store). + * Event-emitting PostgreSQL MissionStore facade. * - * FNXC:MissionStore 2026-06-24-09:00: - * Async equivalents of the sync SQLite MissionStore call sites in - * mission-store.ts (~4382 lines, 84 prepare() calls). These helpers target - * the PostgreSQL `project` schema tables (missions, milestones, slices, - * mission_features, mission_events, mission_goals, mission_contract_assertions, - * mission_feature_assertions, mission_validator_runs, mission_validator_failures, - * mission_fix_feature_lineage) via Drizzle. - * - * SQLite → PostgreSQL notes (see library/satellite-store-migration-pattern.md): - * - jsonb columns (milestones.dependencies, mission_events.metadata, - * mission_fix_feature_lineage.failed_assertion_ids) return already-parsed - * JS values, so fromJson() is replaced by direct field access. On write, - * pass the JS value directly (Drizzle serializes it). - * - text columns (milestones.acceptanceCriteria, mission_features.acceptanceCriteria, - * slices.planningNotes/verification, milestones.planningNotes/verification) - * stay as plain strings — the U3 snapshot incorrectly mapped acceptanceCriteria - * as jsonb but it is plain text (derived criteria bullet list). Fixed in this - * feature's schema updates. - * - boolean 0/1 integer columns (missions.autoAdvance/autoMerge/autopilotEnabled) - * are kept as integer in PostgreSQL, so `row.autoAdvance === 1` checks work. - * - DELETE results: postgres.js does not expose rowCount on delete. Use - * .returning({ id }) and check .length (see async-todo-store.ts precedent). - * - ON CONFLICT: insert().onConflictDoUpdate() for upserts (snapshot apply), - * insert().onConflictDoNothing() for INSERT OR IGNORE semantics (mission_goals, - * mission_events snapshot, mission_feature_assertions snapshot). - * - Transactions: layer.transactionImmediate(async (tx) => ...) for multi-statement - * mutations (linkGoal existence checks + insert, startValidatorRun insert + update, - * deleteMilestone force-clear + delete, reorder operations). - * - * Transition context (see library/satellite-store-migration-pattern.md): - * `getDatabase()` still returns the sync `Database` until the coordinated flip. - * The sync MissionStore keeps its sync path (the gate depends on it). These - * helpers are the async target the PostgreSQL integration tests consume and - * that the MissionStore facade will delegate to after the getDatabase() flip. - * They program against the stable `AsyncDataLayer` interface (U4), not the - * underlying driver. + * FNXC:MissionStoreMaintainability 2026-07-14-19:24: + * The facade owns domain orchestration, concurrency guards, rollups, and live + * events; reusable SQL and row mapping live in async-mission-store-queries.ts. */ import { EventEmitter } from "node:events"; -import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; -import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; +import type { AsyncDataLayer } from "./postgres/data-layer.js"; import { normalizeMissionAssertionType } from "./mission-types.js"; import type { Mission, - MissionBranchStrategy, Milestone, Slice, MissionFeature, MissionValidatorRun, MissionAssertionFailureRecord, - MissionFixFeatureLineage, MissionFeatureLoopSnapshot, MissionCreateInput, MilestoneCreateInput, @@ -66,19 +31,15 @@ import type { SliceStatus, FeatureStatus, InterviewState, - AutopilotState, MissionContractAssertion, - FeatureAssertionLink, MissionGoalLink, MilestoneValidationState, MilestoneValidationRollup, ContractAssertionCreateInput, ContractAssertionUpdateInput, - SlicePlanState, - ValidatorRunStatus, FeatureLoopState, } from "./mission-types.js"; -import type { Goal, GoalStatus } from "./goal-types.js"; +import type { Goal } from "./goal-types.js"; import { deriveMilestoneAcceptanceCriteriaFromFeatures, } from "./mission-store.js"; @@ -86,1824 +47,103 @@ import type { MissionSummary, MissionAssertionBackfillReport, MissionAssertionTextSource, + MissionAssertionSeedInput, + MissionAssertionSeedReport, MissionStoreEvents, } from "./mission-store.js"; import { reconcileDeterministicDuplicate, runDeterministicDuplicateGuard } from "./duplicate-guard.js"; import { resolveEntryPointBranchAssignment } from "./branch-assignment.js"; -/** - * FNXC:MissionStore 2026-06-27-15:00: - * Default retry budget for implementation attempts (mirrors mission-store.ts). - * When implementationAttemptCount reaches this limit, the feature loop blocks - * instead of re-implementing. - */ -const DEFAULT_IMPLEMENTATION_RETRY_BUDGET = 3; -/** - * FNXC:MissionStore 2026-06-27-15:00: - * Local replica of the (non-exported) sync `missionBranchStrategyDefaults`. - * Resolves a mission's branch strategy into a concrete {branch, assignmentMode} - * used by triage. - */ -function missionBranchStrategyDefaults(strategy?: MissionBranchStrategy): { - branch?: string; - assignmentMode: "shared" | "per-task-derived"; -} { - if (!strategy) return { assignmentMode: "shared" }; - if (strategy.mode === "auto-per-task") return { assignmentMode: "per-task-derived" }; - if ((strategy.mode === "existing" || strategy.mode === "custom-new") && strategy.branchName?.trim()) { - return { branch: strategy.branchName.trim(), assignmentMode: "shared" }; - } - return { assignmentMode: "shared" }; -} - -/** A query-capable handle: either the top-level db or a transaction handle. */ -type QueryHandle = AsyncDataLayer["db"] | DbTransaction; - -// ── Row shapes (camelCase column aliases via Drizzle) ─────────────── - -interface MissionRow { - id: string; - title: string; - description: string | null; - status: string; - interviewState: string; - baseBranch: string | null; - branchStrategy: string | null; - autoMerge: number | null; - autoAdvance: number | null; - autopilotEnabled: number | null; - autopilotState: string | null; - lastAutopilotActivityAt: string | null; - createdAt: string; - updatedAt: string; -} - -interface MilestoneRow { - id: string; - missionId: string; - title: string; - description: string | null; - status: string; - orderIndex: number; - interviewState: string; - dependencies: string[] | null; - planningNotes: string | null; - verification: string | null; - acceptanceCriteria: string | null; - validationState: string | null; - createdAt: string; - updatedAt: string; -} - -interface SliceRow { - id: string; - milestoneId: string; - title: string; - description: string | null; - status: string; - orderIndex: number; - activatedAt: string | null; - planState: string | null; - planningNotes: string | null; - verification: string | null; - createdAt: string; - updatedAt: string; -} - -interface FeatureRow { - id: string; - sliceId: string; - taskId: string | null; - title: string; - description: string | null; - acceptanceCriteria: string | null; - status: string; - createdAt: string; - updatedAt: string; - loopState: string | null; - implementationAttemptCount: number | null; - validatorAttemptCount: number | null; - lastValidatorRunId: string | null; - lastValidatorStatus: string | null; - generatedFromFeatureId: string | null; - generatedFromRunId: string | null; -} - -interface MissionEventRow { - id: string; - missionId: string; - eventType: string; - description: string; - metadata: unknown; - timestamp: string; - seq: number | null; -} - -interface MissionGoalRow { - missionId: string; - goalId: string; - createdAt: string; -} - -interface GoalRow { - id: string; - title: string; - description: string | null; - status: GoalStatus; - createdAt: string; - updatedAt: string; -} - -interface AssertionRow { - id: string; - milestoneId: string; - title: string; - assertion: string; - status: string; - type: string | null; - orderIndex: number; - sourceFeatureId: string | null; - createdAt: string; - updatedAt: string; -} - -interface FeatureAssertionLinkRow { - featureId: string; - assertionId: string; - createdAt: string; -} - -interface ValidatorRunRow { - id: string; - featureId: string; - milestoneId: string; - sliceId: string; - status: string; - triggerType: string | null; - implementationAttempt: number | null; - validatorAttempt: number | null; - taskId: string | null; - summary: string | null; - blockedReason: string | null; - startedAt: string; - completedAt: string | null; - createdAt: string; - updatedAt: string; -} - -interface FailureRow { - id: string; - runId: string; - featureId: string; - assertionId: string; - message: string | null; - expected: string | null; - actual: string | null; - createdAt: string; -} - -interface LineageRow { - id: string; - sourceFeatureId: string; - fixFeatureId: string; - runId: string; - failedAssertionIds: string[] | null; - createdAt: string; -} - -// ── Column projections (select only what we need) ──────────────────── - -const missionColumns = { - id: schema.project.missions.id, - title: schema.project.missions.title, - description: schema.project.missions.description, - status: schema.project.missions.status, - interviewState: schema.project.missions.interviewState, - baseBranch: schema.project.missions.baseBranch, - branchStrategy: schema.project.missions.branchStrategy, - autoMerge: schema.project.missions.autoMerge, - autoAdvance: schema.project.missions.autoAdvance, - autopilotEnabled: schema.project.missions.autopilotEnabled, - autopilotState: schema.project.missions.autopilotState, - lastAutopilotActivityAt: schema.project.missions.lastAutopilotActivityAt, - createdAt: schema.project.missions.createdAt, - updatedAt: schema.project.missions.updatedAt, -}; - -const milestoneColumns = { - id: schema.project.milestones.id, - missionId: schema.project.milestones.missionId, - title: schema.project.milestones.title, - description: schema.project.milestones.description, - status: schema.project.milestones.status, - orderIndex: schema.project.milestones.orderIndex, - interviewState: schema.project.milestones.interviewState, - dependencies: schema.project.milestones.dependencies, - planningNotes: schema.project.milestones.planningNotes, - verification: schema.project.milestones.verification, - acceptanceCriteria: schema.project.milestones.acceptanceCriteria, - validationState: schema.project.milestones.validationState, - createdAt: schema.project.milestones.createdAt, - updatedAt: schema.project.milestones.updatedAt, -}; - -const sliceColumns = { - id: schema.project.slices.id, - milestoneId: schema.project.slices.milestoneId, - title: schema.project.slices.title, - description: schema.project.slices.description, - status: schema.project.slices.status, - orderIndex: schema.project.slices.orderIndex, - activatedAt: schema.project.slices.activatedAt, - planState: schema.project.slices.planState, - planningNotes: schema.project.slices.planningNotes, - verification: schema.project.slices.verification, - createdAt: schema.project.slices.createdAt, - updatedAt: schema.project.slices.updatedAt, -}; - -const featureColumns = { - id: schema.project.missionFeatures.id, - sliceId: schema.project.missionFeatures.sliceId, - taskId: schema.project.missionFeatures.taskId, - title: schema.project.missionFeatures.title, - description: schema.project.missionFeatures.description, - acceptanceCriteria: schema.project.missionFeatures.acceptanceCriteria, - status: schema.project.missionFeatures.status, - createdAt: schema.project.missionFeatures.createdAt, - updatedAt: schema.project.missionFeatures.updatedAt, - loopState: schema.project.missionFeatures.loopState, - implementationAttemptCount: schema.project.missionFeatures.implementationAttemptCount, - validatorAttemptCount: schema.project.missionFeatures.validatorAttemptCount, - lastValidatorRunId: schema.project.missionFeatures.lastValidatorRunId, - lastValidatorStatus: schema.project.missionFeatures.lastValidatorStatus, - generatedFromFeatureId: schema.project.missionFeatures.generatedFromFeatureId, - generatedFromRunId: schema.project.missionFeatures.generatedFromRunId, -}; - -const eventColumns = { - id: schema.project.missionEvents.id, - missionId: schema.project.missionEvents.missionId, - eventType: schema.project.missionEvents.eventType, - description: schema.project.missionEvents.description, - metadata: schema.project.missionEvents.metadata, - timestamp: schema.project.missionEvents.timestamp, - seq: schema.project.missionEvents.seq, -}; - -const missionGoalColumns = { - missionId: schema.project.missionGoals.missionId, - goalId: schema.project.missionGoals.goalId, - createdAt: schema.project.missionGoals.createdAt, -}; - -const assertionColumns = { - id: schema.project.missionContractAssertions.id, - milestoneId: schema.project.missionContractAssertions.milestoneId, - title: schema.project.missionContractAssertions.title, - assertion: schema.project.missionContractAssertions.assertion, - status: schema.project.missionContractAssertions.status, - type: schema.project.missionContractAssertions.type, - orderIndex: schema.project.missionContractAssertions.orderIndex, - sourceFeatureId: schema.project.missionContractAssertions.sourceFeatureId, - createdAt: schema.project.missionContractAssertions.createdAt, - updatedAt: schema.project.missionContractAssertions.updatedAt, -}; - -const validatorRunColumns = { - id: schema.project.missionValidatorRuns.id, - featureId: schema.project.missionValidatorRuns.featureId, - milestoneId: schema.project.missionValidatorRuns.milestoneId, - sliceId: schema.project.missionValidatorRuns.sliceId, - status: schema.project.missionValidatorRuns.status, - triggerType: schema.project.missionValidatorRuns.triggerType, - implementationAttempt: schema.project.missionValidatorRuns.implementationAttempt, - validatorAttempt: schema.project.missionValidatorRuns.validatorAttempt, - taskId: schema.project.missionValidatorRuns.taskId, - summary: schema.project.missionValidatorRuns.summary, - blockedReason: schema.project.missionValidatorRuns.blockedReason, - startedAt: schema.project.missionValidatorRuns.startedAt, - completedAt: schema.project.missionValidatorRuns.completedAt, - createdAt: schema.project.missionValidatorRuns.createdAt, - updatedAt: schema.project.missionValidatorRuns.updatedAt, -}; - -const failureColumns = { - id: schema.project.missionValidatorFailures.id, - runId: schema.project.missionValidatorFailures.runId, - featureId: schema.project.missionValidatorFailures.featureId, - assertionId: schema.project.missionValidatorFailures.assertionId, - message: schema.project.missionValidatorFailures.message, - expected: schema.project.missionValidatorFailures.expected, - actual: schema.project.missionValidatorFailures.actual, - createdAt: schema.project.missionValidatorFailures.createdAt, -}; - -const lineageColumns = { - id: schema.project.missionFixFeatureLineage.id, - sourceFeatureId: schema.project.missionFixFeatureLineage.sourceFeatureId, - fixFeatureId: schema.project.missionFixFeatureLineage.fixFeatureId, - runId: schema.project.missionFixFeatureLineage.runId, - failedAssertionIds: schema.project.missionFixFeatureLineage.failedAssertionIds, - createdAt: schema.project.missionFixFeatureLineage.createdAt, -}; - -// ── Row-to-object converters ──────────────────────────────────────── - -function rowToMission(row: MissionRow): Mission { - let branchStrategy: MissionBranchStrategy | undefined; - if (row.branchStrategy) { - try { - branchStrategy = JSON.parse(row.branchStrategy) as MissionBranchStrategy; - } catch { - branchStrategy = undefined; - } - } - return { - id: row.id, - title: row.title, - description: row.description ?? undefined, - status: row.status as MissionStatus, - interviewState: row.interviewState as InterviewState, - baseBranch: row.baseBranch ?? undefined, - branchStrategy, - autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge), - autoAdvance: Boolean(row.autoAdvance ?? 0), - autopilotEnabled: Boolean(row.autopilotEnabled ?? 0), - autopilotState: (row.autopilotState as AutopilotState) || "inactive", - lastAutopilotActivityAt: row.lastAutopilotActivityAt ?? undefined, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function rowToMilestone(row: MilestoneRow): Milestone { - return { - id: row.id, - missionId: row.missionId, - title: row.title, - description: row.description ?? undefined, - status: row.status as MilestoneStatus, - orderIndex: row.orderIndex, - interviewState: row.interviewState as InterviewState, - // FNXC:MissionStore 2026-06-24-09:10: - // dependencies is jsonb in PostgreSQL (was TEXT DEFAULT '[]' in SQLite). - // Drizzle returns it as a parsed JS array. Guard against null for rows - // that pre-date the jsonb default. - dependencies: Array.isArray(row.dependencies) ? row.dependencies : [], - planningNotes: row.planningNotes ?? undefined, - verification: row.verification ?? undefined, - acceptanceCriteria: row.acceptanceCriteria ?? undefined, - validationState: (row.validationState as MilestoneValidationState) || "not_started", - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function rowToSlice(row: SliceRow): Slice { - return { - id: row.id, - milestoneId: row.milestoneId, - title: row.title, - description: row.description ?? undefined, - status: row.status as SliceStatus, - orderIndex: row.orderIndex, - activatedAt: row.activatedAt ?? undefined, - planState: (row.planState as SlicePlanState) || "not_started", - planningNotes: row.planningNotes ?? undefined, - verification: row.verification ?? undefined, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function rowToFeature(row: FeatureRow): MissionFeature { - return { - id: row.id, - sliceId: row.sliceId, - taskId: row.taskId ?? undefined, - title: row.title, - description: row.description ?? undefined, - acceptanceCriteria: row.acceptanceCriteria ?? undefined, - status: row.status as FeatureStatus, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - loopState: (row.loopState as FeatureLoopState) || "idle", - implementationAttemptCount: row.implementationAttemptCount ?? 0, - validatorAttemptCount: row.validatorAttemptCount ?? 0, - lastValidatorRunId: row.lastValidatorRunId ?? undefined, - lastValidatorStatus: (row.lastValidatorStatus as ValidatorRunStatus) ?? undefined, - generatedFromFeatureId: row.generatedFromFeatureId ?? undefined, - generatedFromRunId: row.generatedFromRunId ?? undefined, - }; -} - -function rowToMissionEvent(row: MissionEventRow): MissionEvent { - return { - id: row.id, - missionId: row.missionId, - eventType: row.eventType as MissionEvent["eventType"], - description: row.description, - // FNXC:MissionStore 2026-06-24-09:15: - // metadata is jsonb in PostgreSQL (was TEXT in SQLite). Drizzle returns - // it already-parsed. Null stays null. - metadata: (row.metadata as Record | null) ?? null, - timestamp: row.timestamp, - seq: row.seq ?? 0, - }; -} - -function rowToMissionGoalLink(row: MissionGoalRow): MissionGoalLink { - return { missionId: row.missionId, goalId: row.goalId, createdAt: row.createdAt }; -} - -function rowToGoal(row: GoalRow): Goal { - return { - id: row.id, - title: row.title, - description: row.description ?? undefined, - status: row.status, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function rowToAssertion(row: AssertionRow): MissionContractAssertion { - return { - id: row.id, - milestoneId: row.milestoneId, - sourceFeatureId: row.sourceFeatureId ?? undefined, - title: row.title, - assertion: row.assertion, - status: row.status as MissionContractAssertion["status"], - type: normalizeMissionAssertionType(row.type), - orderIndex: row.orderIndex, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function rowToFeatureAssertionLink(row: FeatureAssertionLinkRow): FeatureAssertionLink { - return { featureId: row.featureId, assertionId: row.assertionId, createdAt: row.createdAt }; -} - -function rowToValidatorRun(row: ValidatorRunRow): MissionValidatorRun { - return { - id: row.id, - featureId: row.featureId, - milestoneId: row.milestoneId, - sliceId: row.sliceId, - status: row.status as ValidatorRunStatus, - triggerType: row.triggerType ?? undefined, - implementationAttempt: row.implementationAttempt ?? 0, - validatorAttempt: row.validatorAttempt ?? 0, - taskId: row.taskId ?? undefined, - summary: row.summary ?? undefined, - blockedReason: row.blockedReason ?? undefined, - startedAt: row.startedAt, - completedAt: row.completedAt ?? undefined, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function rowToFailure(row: FailureRow): MissionAssertionFailureRecord { - return { - id: row.id, - runId: row.runId, - featureId: row.featureId, - assertionId: row.assertionId, - message: row.message ?? undefined, - expected: row.expected ?? undefined, - actual: row.actual ?? undefined, - createdAt: row.createdAt, - }; -} - -function rowToLineage(row: LineageRow): MissionFixFeatureLineage { - return { - id: row.id, - sourceFeatureId: row.sourceFeatureId, - fixFeatureId: row.fixFeatureId, - runId: row.runId, - // failedAssertionIds is jsonb in PostgreSQL (was TEXT in SQLite). - failedAssertionIds: Array.isArray(row.failedAssertionIds) ? row.failedAssertionIds : [], - createdAt: row.createdAt, - }; -} - -// ── Helpers for write serialization ───────────────────────────────── - -/** - * FNXC:MissionStore 2026-06-24-09:20: - * Serialize a MissionBranchStrategy for the text branchStrategy column. - * The column stores the strategy as a JSON string (parsed on read by rowToMission). - */ -function serializeBranchStrategy(strategy: MissionBranchStrategy | undefined): string | null { - return strategy ? JSON.stringify(strategy) : null; -} - -// ════════════════════════════════════════════════════════════════════ -// MISSION CRUD -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-09:25: - * Create a mission (non-destructive INSERT, VAL-DATA-009). Missions are always - * created with status "planning" and autopilot disabled. - */ -export async function createMission( - handle: QueryHandle, - input: { id: string } & MissionCreateInput & { createdAt: string; updatedAt: string; status: string; interviewState: string; autoAdvance: boolean; autopilotEnabled: boolean; autopilotState: string }, -): Promise { - await handle.insert(schema.project.missions).values({ - id: input.id, - title: input.title, - description: input.description ?? null, - status: input.status, - interviewState: input.interviewState, - baseBranch: input.baseBranch ?? null, - branchStrategy: serializeBranchStrategy(input.branchStrategy), - autoMerge: input.autoMerge === undefined ? null : input.autoMerge ? 1 : 0, - autoAdvance: input.autoAdvance ? 1 : 0, - autopilotEnabled: input.autopilotEnabled ? 1 : 0, - autopilotState: input.autopilotState ?? "inactive", - lastAutopilotActivityAt: null, - createdAt: input.createdAt, - updatedAt: input.updatedAt, - }); - return (await getMission(handle, input.id))!; -} - -/** Get a single mission by id. */ -export async function getMission(handle: QueryHandle, id: string): Promise { - const rows = await handle - .select(missionColumns) - .from(schema.project.missions) - .where(eq(schema.project.missions.id, id)); - return rows[0] ? rowToMission(rows[0] as MissionRow) : undefined; -} - -/** List all missions, ordered by createdAt DESC (newest first). */ -export async function listMissions(handle: QueryHandle): Promise { - const rows = await handle - .select(missionColumns) - .from(schema.project.missions) - .orderBy(desc(schema.project.missions.createdAt)); - return rows.map((row) => rowToMission(row as MissionRow)); -} - -/** - * FNXC:MissionStore 2026-06-24-09:30: - * Update a mission's mutable columns. branchStrategy is serialized as JSON text. - */ -export async function updateMission( - handle: QueryHandle, - mission: Mission, -): Promise { - await handle - .update(schema.project.missions) - .set({ - title: mission.title, - description: mission.description ?? null, - status: mission.status, - interviewState: mission.interviewState, - baseBranch: mission.baseBranch ?? null, - branchStrategy: serializeBranchStrategy(mission.branchStrategy), - autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0, - autoAdvance: mission.autoAdvance ? 1 : 0, - autopilotEnabled: mission.autopilotEnabled ? 1 : 0, - autopilotState: mission.autopilotState ?? "inactive", - lastAutopilotActivityAt: mission.lastAutopilotActivityAt ?? null, - updatedAt: mission.updatedAt, - }) - .where(eq(schema.project.missions.id, mission.id)); -} - -/** Delete a mission by id (cascades to milestones/slices/features/events). Returns true if a row was deleted. */ -export async function deleteMission(handle: QueryHandle, id: string): Promise { - const result = await handle - .delete(schema.project.missions) - .where(eq(schema.project.missions.id, id)) - .returning({ id: schema.project.missions.id }); - return result.length > 0; -} - -/** Check whether a mission with the given id exists. */ -export async function missionExists(handle: QueryHandle, id: string): Promise { - const rows = await handle - .select({ id: schema.project.missions.id }) - .from(schema.project.missions) - .where(eq(schema.project.missions.id, id)); - return rows.length > 0; -} - -// ════════════════════════════════════════════════════════════════════ -// MILESTONE CRUD -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-09:35: - * Create a milestone (non-destructive INSERT). dependencies is a jsonb array. - */ -export async function createMilestone( - handle: QueryHandle, - milestone: Milestone, -): Promise { - await handle.insert(schema.project.milestones).values({ - id: milestone.id, - missionId: milestone.missionId, - title: milestone.title, - description: milestone.description ?? null, - status: milestone.status, - orderIndex: milestone.orderIndex, - interviewState: milestone.interviewState, - dependencies: milestone.dependencies, - planningNotes: milestone.planningNotes ?? null, - verification: milestone.verification ?? null, - acceptanceCriteria: milestone.acceptanceCriteria ?? null, - validationState: milestone.validationState ?? "not_started", - createdAt: milestone.createdAt, - updatedAt: milestone.updatedAt, - }); - return (await getMilestone(handle, milestone.id))!; -} - -/** Get a single milestone by id. */ -export async function getMilestone(handle: QueryHandle, id: string): Promise { - const rows = await handle - .select(milestoneColumns) - .from(schema.project.milestones) - .where(eq(schema.project.milestones.id, id)); - return rows[0] ? rowToMilestone(rows[0] as MilestoneRow) : undefined; -} - -/** List milestones for a mission, ordered by orderIndex ASC. */ -export async function listMilestones(handle: QueryHandle, missionId: string): Promise { - const rows = await handle - .select(milestoneColumns) - .from(schema.project.milestones) - .where(eq(schema.project.milestones.missionId, missionId)) - .orderBy(asc(schema.project.milestones.orderIndex)); - return rows.map((row) => rowToMilestone(row as MilestoneRow)); -} - -/** List ALL milestones across all missions, ordered by orderIndex ASC. */ -export async function listAllMilestones(handle: QueryHandle): Promise { - const rows = await handle - .select(milestoneColumns) - .from(schema.project.milestones) - .orderBy(asc(schema.project.milestones.orderIndex)); - return rows.map((row) => rowToMilestone(row as MilestoneRow)); -} - -/** Update a milestone's mutable columns. */ -export async function updateMilestone(handle: QueryHandle, milestone: Milestone): Promise { - await handle - .update(schema.project.milestones) - .set({ - title: milestone.title, - description: milestone.description ?? null, - status: milestone.status, - orderIndex: milestone.orderIndex, - interviewState: milestone.interviewState, - dependencies: milestone.dependencies, - planningNotes: milestone.planningNotes ?? null, - verification: milestone.verification ?? null, - acceptanceCriteria: milestone.acceptanceCriteria ?? null, - validationState: milestone.validationState || "not_started", - updatedAt: milestone.updatedAt, - }) - .where(eq(schema.project.milestones.id, milestone.id)); -} - -/** Delete a milestone by id (cascades to slices/features). Returns true if deleted. */ -export async function deleteMilestone(handle: QueryHandle, id: string): Promise { - const result = await handle - .delete(schema.project.milestones) - .where(eq(schema.project.milestones.id, id)) - .returning({ id: schema.project.milestones.id }); - return result.length > 0; -} - -/** - * FNXC:MissionStore 2026-06-24-09:40: - * Reorder milestones transactionally. Each milestone's orderIndex is set to its - * array position. The entire reorder runs in one transaction so partial reorders - * never persist. - */ -export async function reorderMilestones( - layer: AsyncDataLayer, - orderedIds: string[], -): Promise { - const now = new Date().toISOString(); - await layer.transactionImmediate(async (tx) => { - for (let i = 0; i < orderedIds.length; i++) { - await tx - .update(schema.project.milestones) - .set({ orderIndex: i, updatedAt: now }) - .where(eq(schema.project.milestones.id, orderedIds[i]!)); - } - }); -} - -// ════════════════════════════════════════════════════════════════════ -// SLICE CRUD -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-09:45: - * Create a slice (non-destructive INSERT). - */ -export async function createSlice(handle: QueryHandle, slice: Slice): Promise { - await handle.insert(schema.project.slices).values({ - id: slice.id, - milestoneId: slice.milestoneId, - title: slice.title, - description: slice.description ?? null, - status: slice.status, - orderIndex: slice.orderIndex, - activatedAt: slice.activatedAt ?? null, - planState: slice.planState ?? "not_started", - planningNotes: slice.planningNotes ?? null, - verification: slice.verification ?? null, - createdAt: slice.createdAt, - updatedAt: slice.updatedAt, - }); - return (await getSlice(handle, slice.id))!; -} - -/** Get a single slice by id. */ -export async function getSlice(handle: QueryHandle, id: string): Promise { - const rows = await handle - .select(sliceColumns) - .from(schema.project.slices) - .where(eq(schema.project.slices.id, id)); - return rows[0] ? rowToSlice(rows[0] as SliceRow) : undefined; -} - -/** List slices for a milestone, ordered by orderIndex ASC. */ -export async function listSlices(handle: QueryHandle, milestoneId: string): Promise { - const rows = await handle - .select(sliceColumns) - .from(schema.project.slices) - .where(eq(schema.project.slices.milestoneId, milestoneId)) - .orderBy(asc(schema.project.slices.orderIndex)); - return rows.map((row) => rowToSlice(row as SliceRow)); -} - -/** List ALL slices across all milestones, ordered by orderIndex ASC. */ -export async function listAllSlices(handle: QueryHandle): Promise { - const rows = await handle - .select(sliceColumns) - .from(schema.project.slices) - .orderBy(asc(schema.project.slices.orderIndex)); - return rows.map((row) => rowToSlice(row as SliceRow)); -} - -/** Update a slice's mutable columns. */ -export async function updateSlice(handle: QueryHandle, slice: Slice): Promise { - await handle - .update(schema.project.slices) - .set({ - title: slice.title, - description: slice.description ?? null, - status: slice.status, - orderIndex: slice.orderIndex, - activatedAt: slice.activatedAt ?? null, - planState: slice.planState ?? "not_started", - planningNotes: slice.planningNotes ?? null, - verification: slice.verification ?? null, - updatedAt: slice.updatedAt, - }) - .where(eq(schema.project.slices.id, slice.id)); -} - -/** Delete a slice by id (cascades to features). Returns true if deleted. */ -export async function deleteSlice(handle: QueryHandle, id: string): Promise { - const result = await handle - .delete(schema.project.slices) - .where(eq(schema.project.slices.id, id)) - .returning({ id: schema.project.slices.id }); - return result.length > 0; -} - -/** Reorder slices transactionally within a milestone. */ -export async function reorderSlices( - layer: AsyncDataLayer, - orderedIds: string[], -): Promise { - const now = new Date().toISOString(); - await layer.transactionImmediate(async (tx) => { - for (let i = 0; i < orderedIds.length; i++) { - await tx - .update(schema.project.slices) - .set({ orderIndex: i, updatedAt: now }) - .where(eq(schema.project.slices.id, orderedIds[i]!)); - } - }); -} - -// ════════════════════════════════════════════════════════════════════ -// FEATURE CRUD -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-09:50: - * Create a feature (non-destructive INSERT). - */ -export async function createFeature(handle: QueryHandle, feature: MissionFeature): Promise { - await handle.insert(schema.project.missionFeatures).values({ - id: feature.id, - sliceId: feature.sliceId, - taskId: feature.taskId ?? null, - title: feature.title, - description: feature.description ?? null, - acceptanceCriteria: feature.acceptanceCriteria ?? null, - status: feature.status, - createdAt: feature.createdAt, - updatedAt: feature.updatedAt, - loopState: feature.loopState ?? "idle", - implementationAttemptCount: feature.implementationAttemptCount ?? 0, - validatorAttemptCount: feature.validatorAttemptCount ?? 0, - lastValidatorRunId: feature.lastValidatorRunId ?? null, - lastValidatorStatus: feature.lastValidatorStatus ?? null, - generatedFromFeatureId: feature.generatedFromFeatureId ?? null, - generatedFromRunId: feature.generatedFromRunId ?? null, - }); - return (await getFeature(handle, feature.id))!; -} - -/** Get a single feature by id. */ -export async function getFeature(handle: QueryHandle, id: string): Promise { - const rows = await handle - .select(featureColumns) - .from(schema.project.missionFeatures) - .where(eq(schema.project.missionFeatures.id, id)); - return rows[0] ? rowToFeature(rows[0] as FeatureRow) : undefined; -} - -/** List features for a slice, ordered by createdAt ASC. */ -export async function listFeatures(handle: QueryHandle, sliceId: string): Promise { - const rows = await handle - .select(featureColumns) - .from(schema.project.missionFeatures) - .where(eq(schema.project.missionFeatures.sliceId, sliceId)) - .orderBy(asc(schema.project.missionFeatures.createdAt)); - return rows.map((row) => rowToFeature(row as FeatureRow)); -} - -/** List ALL features across all slices, ordered by createdAt ASC. */ -export async function listAllFeatures(handle: QueryHandle): Promise { - const rows = await handle - .select(featureColumns) - .from(schema.project.missionFeatures) - .orderBy(asc(schema.project.missionFeatures.createdAt)); - return rows.map((row) => rowToFeature(row as FeatureRow)); -} - -/** - * FNXC:MissionStore 2026-06-24-09:55: - * Update a feature's mutable columns. This is the core mutation surface for the - * implement→validate→fix loop (loopState, attempt counts, last validator linkage). - */ -export async function updateFeature(handle: QueryHandle, feature: MissionFeature): Promise { - await handle - .update(schema.project.missionFeatures) - .set({ - taskId: feature.taskId ?? null, - title: feature.title, - description: feature.description ?? null, - acceptanceCriteria: feature.acceptanceCriteria ?? null, - status: feature.status, - updatedAt: feature.updatedAt, - loopState: feature.loopState ?? "idle", - implementationAttemptCount: feature.implementationAttemptCount ?? 0, - validatorAttemptCount: feature.validatorAttemptCount ?? 0, - lastValidatorRunId: feature.lastValidatorRunId ?? null, - lastValidatorStatus: feature.lastValidatorStatus ?? null, - generatedFromFeatureId: feature.generatedFromFeatureId ?? null, - generatedFromRunId: feature.generatedFromRunId ?? null, - }) - .where(eq(schema.project.missionFeatures.id, feature.id)); -} - -/** Delete a feature by id. Returns true if deleted. */ -export async function deleteFeature(handle: QueryHandle, id: string): Promise { - const result = await handle - .delete(schema.project.missionFeatures) - .where(eq(schema.project.missionFeatures.id, id)) - .returning({ id: schema.project.missionFeatures.id }); - return result.length > 0; -} - -/** Get a feature by its linked taskId (null if no feature is linked). */ -export async function getFeatureByTaskId(handle: QueryHandle, taskId: string): Promise { - const rows = await handle - .select(featureColumns) - .from(schema.project.missionFeatures) - .where(eq(schema.project.missionFeatures.taskId, taskId)); - return rows[0] ? rowToFeature(rows[0] as FeatureRow) : undefined; -} - -/** - * FNXC:MissionStore 2026-06-24-10:00: - * Unlink a feature from its task (set taskId = NULL). Used when force-deleting - * a slice/milestone or unlinking a feature from a task. - */ -export async function unlinkFeatureFromTaskId(handle: QueryHandle, featureId: string): Promise { - const now = new Date().toISOString(); - await handle - .update(schema.project.missionFeatures) - .set({ taskId: null, updatedAt: now }) - .where(eq(schema.project.missionFeatures.id, featureId)); -} - -// ════════════════════════════════════════════════════════════════════ -// MISSION EVENTS -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-10:05: - * Get the maximum event seq for the mission_events table (used to initialize - * the event sequence counter on store open so new events have unique seqs). - */ -export async function getMaxEventSeq(handle: QueryHandle): Promise { - const rows = await handle - .select({ maxSeq: sql`max(${schema.project.missionEvents.seq})` }) - .from(schema.project.missionEvents); - return rows[0]?.maxSeq ?? 0; -} - -/** - * FNXC:MissionStore 2026-06-24-10:10: - * Insert a mission event (non-destructive). metadata is a jsonb column. - */ -export async function insertMissionEvent(handle: QueryHandle, event: MissionEvent): Promise { - await handle.insert(schema.project.missionEvents).values({ - id: event.id, - missionId: event.missionId, - eventType: event.eventType, - description: event.description, - metadata: event.metadata, - timestamp: event.timestamp, - seq: event.seq, - }); -} - -/** - * FNXC:MissionStore 2026-06-24-10:15: - * Insert a mission event with INSERT OR IGNORE semantics (snapshot apply). - */ -export async function insertMissionEventIfAbsent(handle: QueryHandle, event: MissionEvent): Promise { - await handle - .insert(schema.project.missionEvents) - .values({ - id: event.id, - missionId: event.missionId, - eventType: event.eventType, - description: event.description, - metadata: event.metadata, - timestamp: event.timestamp, - seq: event.seq, - }) - .onConflictDoNothing(); -} - -/** Count events for a mission. */ -export async function countMissionEvents(handle: QueryHandle, missionId: string): Promise { - const rows = await handle - .select({ count: sql`count(*)::int` }) - .from(schema.project.missionEvents) - .where(eq(schema.project.missionEvents.missionId, missionId)); - return rows[0]?.count ?? 0; -} - -/** Get events for a mission, ordered by seq DESC (or timestamp DESC, id DESC), with optional limit. */ -export async function listMissionEvents( - handle: QueryHandle, - missionId: string, - limit?: number, -): Promise { - let query = handle - .select(eventColumns) - .from(schema.project.missionEvents) - .where(eq(schema.project.missionEvents.missionId, missionId)) - .orderBy(desc(schema.project.missionEvents.seq), desc(schema.project.missionEvents.id)); - if (limit !== undefined) { - query = query.limit(limit) as typeof query; - } - const rows = await query; - return rows.map((row) => rowToMissionEvent(row as MissionEventRow)); -} - -/** Count events grouped by missionId (batch query for summaries). */ -export async function countEventsByMission(handle: QueryHandle): Promise> { - const rows = await handle - .select({ - missionId: schema.project.missionEvents.missionId, - count: sql`count(*)::int`, - }) - .from(schema.project.missionEvents) - .groupBy(schema.project.missionEvents.missionId); - return new Map(rows.map((row) => [row.missionId, row.count])); -} - -/** - * FNXC:MissionStore 2026-06-24-10:20: - * Get the latest error event per mission (batch query for health rollup). - * Ordered by seq DESC, id DESC so the first row per missionId is the latest. - */ -export async function listErrorEventsForHealth(handle: QueryHandle): Promise> { - return handle - .select({ - missionId: schema.project.missionEvents.missionId, - timestamp: schema.project.missionEvents.timestamp, - description: schema.project.missionEvents.description, - }) - .from(schema.project.missionEvents) - .where(eq(schema.project.missionEvents.eventType, "error")) - .orderBy(desc(schema.project.missionEvents.seq), desc(schema.project.missionEvents.id)); -} - -// ════════════════════════════════════════════════════════════════════ -// MISSION-GOAL LINKS -// ════════════════════════════════════════════════════════════════════ - -/** Get a mission-goal link row if it exists. */ -export async function getMissionGoalLink( - handle: QueryHandle, - missionId: string, - goalId: string, -): Promise { - const rows = await handle - .select(missionGoalColumns) - .from(schema.project.missionGoals) - .where( - and( - eq(schema.project.missionGoals.missionId, missionId), - eq(schema.project.missionGoals.goalId, goalId), - ), - ); - return rows[0] ? rowToMissionGoalLink(rows[0] as MissionGoalRow) : undefined; -} - -/** - * FNXC:MissionStore 2026-06-24-10:25: - * Insert a mission-goal link with INSERT OR IGNORE semantics (idempotent link). - */ -export async function insertMissionGoalLink( - handle: QueryHandle, - missionId: string, - goalId: string, - createdAt: string, -): Promise { - await handle - .insert(schema.project.missionGoals) - .values({ missionId, goalId, createdAt }) - .onConflictDoNothing(); -} - -/** Delete a mission-goal link. Returns true if a row was deleted. */ -export async function deleteMissionGoalLink( - handle: QueryHandle, - missionId: string, - goalId: string, -): Promise { - const result = await handle - .delete(schema.project.missionGoals) - .where( - and( - eq(schema.project.missionGoals.missionId, missionId), - eq(schema.project.missionGoals.goalId, goalId), - ), - ) - .returning({ missionId: schema.project.missionGoals.missionId }); - return result.length > 0; -} - -/** List goal IDs linked to a mission, ordered by createdAt ASC, goalId ASC. */ -export async function listGoalIdsForMission(handle: QueryHandle, missionId: string): Promise { - const rows = await handle - .select({ goalId: schema.project.missionGoals.goalId }) - .from(schema.project.missionGoals) - .where(eq(schema.project.missionGoals.missionId, missionId)) - .orderBy(asc(schema.project.missionGoals.createdAt), asc(schema.project.missionGoals.goalId)); - return rows.map((row) => row.goalId); -} - -/** List mission IDs linked to a goal, ordered by createdAt ASC, missionId ASC. */ -export async function listMissionIdsForGoal(handle: QueryHandle, goalId: string): Promise { - const rows = await handle - .select({ missionId: schema.project.missionGoals.missionId }) - .from(schema.project.missionGoals) - .where(eq(schema.project.missionGoals.goalId, goalId)) - .orderBy(asc(schema.project.missionGoals.createdAt), asc(schema.project.missionGoals.missionId)); - return rows.map((row) => row.missionId); -} - -/** Count goals linked per mission (batch query for summaries). */ -export async function countGoalsByMission(handle: QueryHandle): Promise> { - const rows = await handle - .select({ - missionId: schema.project.missionGoals.missionId, - count: sql`count(*)::int`, - }) - .from(schema.project.missionGoals) - .groupBy(schema.project.missionGoals.missionId); - return new Map(rows.map((row) => [row.missionId, row.count])); -} - -/** Check whether a goal exists (for link validation). */ -export async function goalExists(handle: QueryHandle, goalId: string): Promise { - const rows = await handle - .select({ id: schema.project.goals.id }) - .from(schema.project.goals) - .where(eq(schema.project.goals.id, goalId)); - return rows.length > 0; -} - -/** Get a goal by id. */ -export async function getGoal(handle: QueryHandle, goalId: string): Promise { - const rows = await handle - .select({ - id: schema.project.goals.id, - title: schema.project.goals.title, - description: schema.project.goals.description, - status: schema.project.goals.status, - createdAt: schema.project.goals.createdAt, - updatedAt: schema.project.goals.updatedAt, - }) - .from(schema.project.goals) - .where(eq(schema.project.goals.id, goalId)); - return rows[0] ? rowToGoal(rows[0] as GoalRow) : undefined; -} - -/** Get goals by IDs (batch fetch). */ -export async function listGoalsByIds(handle: QueryHandle, goalIds: string[]): Promise { - if (goalIds.length === 0) return []; - const rows = await handle - .select({ - id: schema.project.goals.id, - title: schema.project.goals.title, - description: schema.project.goals.description, - status: schema.project.goals.status, - createdAt: schema.project.goals.createdAt, - updatedAt: schema.project.goals.updatedAt, - }) - .from(schema.project.goals) - .where(inArray(schema.project.goals.id, goalIds)); - return rows.map((row) => rowToGoal(row as GoalRow)); -} - -// ════════════════════════════════════════════════════════════════════ -// CONTRACT ASSERTIONS -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-10:30: - * Create a contract assertion (non-destructive INSERT). - */ -export async function createContractAssertion( - handle: QueryHandle, - assertion: MissionContractAssertion, -): Promise { - await handle.insert(schema.project.missionContractAssertions).values({ - id: assertion.id, - milestoneId: assertion.milestoneId, - title: assertion.title, - assertion: assertion.assertion, - status: assertion.status, - type: normalizeMissionAssertionType(assertion.type), - orderIndex: assertion.orderIndex, - sourceFeatureId: assertion.sourceFeatureId ?? null, - createdAt: assertion.createdAt, - updatedAt: assertion.updatedAt, - }); - return (await getContractAssertion(handle, assertion.id))!; -} - -/** Get a contract assertion by id. */ -export async function getContractAssertion(handle: QueryHandle, id: string): Promise { - const rows = await handle - .select(assertionColumns) - .from(schema.project.missionContractAssertions) - .where(eq(schema.project.missionContractAssertions.id, id)); - return rows[0] ? rowToAssertion(rows[0] as AssertionRow) : undefined; -} - -/** List contract assertions for a milestone, ordered by orderIndex, createdAt, id. */ -export async function listContractAssertions(handle: QueryHandle, milestoneId: string): Promise { - const rows = await handle - .select(assertionColumns) - .from(schema.project.missionContractAssertions) - .where(eq(schema.project.missionContractAssertions.milestoneId, milestoneId)) - .orderBy( - asc(schema.project.missionContractAssertions.orderIndex), - asc(schema.project.missionContractAssertions.createdAt), - asc(schema.project.missionContractAssertions.id), - ); - return rows.map((row) => rowToAssertion(row as AssertionRow)); -} - -/** Update a contract assertion's mutable columns. */ -export async function updateContractAssertion(handle: QueryHandle, assertion: MissionContractAssertion): Promise { - await handle - .update(schema.project.missionContractAssertions) - .set({ - title: assertion.title, - assertion: assertion.assertion, - status: assertion.status, - type: normalizeMissionAssertionType(assertion.type), - orderIndex: assertion.orderIndex, - sourceFeatureId: assertion.sourceFeatureId ?? null, - updatedAt: assertion.updatedAt, - }) - .where(eq(schema.project.missionContractAssertions.id, assertion.id)); -} - -/** Delete a contract assertion by id. Returns true if deleted. */ -export async function deleteContractAssertion(handle: QueryHandle, id: string): Promise { - const result = await handle - .delete(schema.project.missionContractAssertions) - .where(eq(schema.project.missionContractAssertions.id, id)) - .returning({ id: schema.project.missionContractAssertions.id }); - return result.length > 0; -} - -/** Reorder contract assertions transactionally. */ -export async function reorderContractAssertions( - layer: AsyncDataLayer, - orderedIds: string[], -): Promise { - const now = new Date().toISOString(); - await layer.transactionImmediate(async (tx) => { - for (let i = 0; i < orderedIds.length; i++) { - await tx - .update(schema.project.missionContractAssertions) - .set({ orderIndex: i, updatedAt: now }) - .where(eq(schema.project.missionContractAssertions.id, orderedIds[i]!)); - } - }); -} - -// ════════════════════════════════════════════════════════════════════ -// FEATURE-ASSERTION LINKS -// ════════════════════════════════════════════════════════════════════ - -/** Check whether a feature-assertion link exists. */ -export async function featureAssertionLinkExists( - handle: QueryHandle, - featureId: string, - assertionId: string, -): Promise { - const rows = await handle - .select({ featureId: schema.project.missionFeatureAssertions.featureId }) - .from(schema.project.missionFeatureAssertions) - .where( - and( - eq(schema.project.missionFeatureAssertions.featureId, featureId), - eq(schema.project.missionFeatureAssertions.assertionId, assertionId), - ), - ); - return rows.length > 0; -} - -/** Insert a feature-assertion link with INSERT OR IGNORE semantics. */ -export async function linkFeatureToAssertion( - handle: QueryHandle, - featureId: string, - assertionId: string, - createdAt: string, -): Promise { - await handle - .insert(schema.project.missionFeatureAssertions) - .values({ featureId, assertionId, createdAt }) - .onConflictDoNothing(); -} - -/** Delete a feature-assertion link. Returns true if deleted. */ -export async function unlinkFeatureFromAssertion( - handle: QueryHandle, - featureId: string, - assertionId: string, -): Promise { - const result = await handle - .delete(schema.project.missionFeatureAssertions) - .where( - and( - eq(schema.project.missionFeatureAssertions.featureId, featureId), - eq(schema.project.missionFeatureAssertions.assertionId, assertionId), - ), - ) - .returning({ featureId: schema.project.missionFeatureAssertions.featureId }); - return result.length > 0; -} - -/** List all feature-assertion links, ordered by createdAt ASC. */ -export async function listAllFeatureAssertionLinks(handle: QueryHandle): Promise { - const rows = await handle - .select({ - featureId: schema.project.missionFeatureAssertions.featureId, - assertionId: schema.project.missionFeatureAssertions.assertionId, - createdAt: schema.project.missionFeatureAssertions.createdAt, - }) - .from(schema.project.missionFeatureAssertions) - .orderBy(asc(schema.project.missionFeatureAssertions.createdAt)); - return rows.map((row) => rowToFeatureAssertionLink(row as FeatureAssertionLinkRow)); -} - -// ════════════════════════════════════════════════════════════════════ -// VALIDATOR RUNS -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-10:35: - * Create a validator run (non-destructive INSERT). - */ -export async function createValidatorRun(handle: QueryHandle, run: MissionValidatorRun): Promise { - await handle.insert(schema.project.missionValidatorRuns).values({ - id: run.id, - featureId: run.featureId, - milestoneId: run.milestoneId, - sliceId: run.sliceId, - status: run.status, - triggerType: run.triggerType ?? "auto", - implementationAttempt: run.implementationAttempt, - validatorAttempt: run.validatorAttempt, - taskId: run.taskId ?? null, - summary: run.summary ?? null, - blockedReason: run.blockedReason ?? null, - startedAt: run.startedAt, - completedAt: run.completedAt ?? null, - createdAt: run.createdAt, - updatedAt: run.updatedAt, - }); - return (await getValidatorRun(handle, run.id))!; -} - -/** Get a validator run by id. */ -export async function getValidatorRun(handle: QueryHandle, id: string): Promise { - const rows = await handle - .select(validatorRunColumns) - .from(schema.project.missionValidatorRuns) - .where(eq(schema.project.missionValidatorRuns.id, id)); - return rows[0] ? rowToValidatorRun(rows[0] as ValidatorRunRow) : undefined; -} - -/** List validator runs for a feature, ordered by startedAt DESC. */ -export async function listValidatorRunsByFeature(handle: QueryHandle, featureId: string): Promise { - const rows = await handle - .select(validatorRunColumns) - .from(schema.project.missionValidatorRuns) - .where(eq(schema.project.missionValidatorRuns.featureId, featureId)) - .orderBy(desc(schema.project.missionValidatorRuns.startedAt)); - return rows.map((row) => rowToValidatorRun(row as ValidatorRunRow)); -} - -/** List stale running validator runs older than the cutoff, ordered by startedAt ASC. */ -export async function listStaleRunningValidatorRuns(handle: QueryHandle, cutoffIso: string): Promise { - const rows = await handle - .select(validatorRunColumns) - .from(schema.project.missionValidatorRuns) - .where( - and( - eq(schema.project.missionValidatorRuns.status, "running"), - sql`${schema.project.missionValidatorRuns.startedAt} < ${cutoffIso}`, - ), - ) - .orderBy(asc(schema.project.missionValidatorRuns.startedAt)); - return rows.map((row) => rowToValidatorRun(row as ValidatorRunRow)); -} - -/** Update a validator run's mutable columns (status, summary, blockedReason, completedAt). */ -export async function updateValidatorRun(handle: QueryHandle, run: MissionValidatorRun): Promise { - await handle - .update(schema.project.missionValidatorRuns) - .set({ - status: run.status, - summary: run.summary ?? null, - blockedReason: run.blockedReason ?? null, - completedAt: run.completedAt ?? null, - updatedAt: run.updatedAt, - }) - .where(eq(schema.project.missionValidatorRuns.id, run.id)); -} - -// ════════════════════════════════════════════════════════════════════ -// VALIDATOR FAILURES -// ════════════════════════════════════════════════════════════════════ - -/** Insert a validator failure record (non-destructive INSERT). */ -export async function insertValidatorFailure(handle: QueryHandle, failure: MissionAssertionFailureRecord): Promise { - await handle.insert(schema.project.missionValidatorFailures).values({ - id: failure.id, - runId: failure.runId, - featureId: failure.featureId, - assertionId: failure.assertionId, - message: failure.message ?? null, - expected: failure.expected ?? null, - actual: failure.actual ?? null, - createdAt: failure.createdAt, - }); -} - -/** List failures for a run, ordered by createdAt ASC. */ -export async function listFailuresForRun(handle: QueryHandle, runId: string): Promise { - const rows = await handle - .select(failureColumns) - .from(schema.project.missionValidatorFailures) - .where(eq(schema.project.missionValidatorFailures.runId, runId)) - .orderBy(asc(schema.project.missionValidatorFailures.createdAt)); - return rows.map((row) => rowToFailure(row as FailureRow)); -} - -// ════════════════════════════════════════════════════════════════════ -// FIX-FEATURE LINEAGE -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-10:40: - * Insert a fix-feature lineage row. failedAssertionIds is a jsonb array. - */ -export async function insertFixFeatureLineage(handle: QueryHandle, lineage: MissionFixFeatureLineage): Promise { - await handle.insert(schema.project.missionFixFeatureLineage).values({ - id: lineage.id, - sourceFeatureId: lineage.sourceFeatureId, - fixFeatureId: lineage.fixFeatureId, - runId: lineage.runId, - failedAssertionIds: lineage.failedAssertionIds, - createdAt: lineage.createdAt, - }); -} - -/** Find the fix-feature ID for a source feature + run (first match, ordered by createdAt). */ -export async function findFixFeatureId(handle: QueryHandle, sourceFeatureId: string, runId: string): Promise { - const rows = await handle - .select({ fixFeatureId: schema.project.missionFixFeatureLineage.fixFeatureId }) - .from(schema.project.missionFixFeatureLineage) - .where( - and( - eq(schema.project.missionFixFeatureLineage.sourceFeatureId, sourceFeatureId), - eq(schema.project.missionFixFeatureLineage.runId, runId), - ), - ) - .orderBy(asc(schema.project.missionFixFeatureLineage.createdAt)) - .limit(1); - return rows[0]?.fixFeatureId; -} - -/** Find all fix-feature IDs for a source feature, ordered by createdAt ASC. */ -export async function findFixFeatureIdsForSource(handle: QueryHandle, sourceFeatureId: string): Promise { - const rows = await handle - .select({ fixFeatureId: schema.project.missionFixFeatureLineage.fixFeatureId }) - .from(schema.project.missionFixFeatureLineage) - .where(eq(schema.project.missionFixFeatureLineage.sourceFeatureId, sourceFeatureId)) - .orderBy(asc(schema.project.missionFixFeatureLineage.createdAt)); - return rows.map((row) => row.fixFeatureId); -} - -/** Get lineage rows for a source feature. */ -export async function listLineageForSourceFeature(handle: QueryHandle, sourceFeatureId: string): Promise { - const rows = await handle - .select(lineageColumns) - .from(schema.project.missionFixFeatureLineage) - .where(eq(schema.project.missionFixFeatureLineage.sourceFeatureId, sourceFeatureId)); - return rows.map((row) => rowToLineage(row as LineageRow)); -} - -/** Get lineage rows where the feature is a fix (fixFeatureId match). */ -export async function listLineageForFixFeature(handle: QueryHandle, fixFeatureId: string): Promise { - const rows = await handle - .select(lineageColumns) - .from(schema.project.missionFixFeatureLineage) - .where(eq(schema.project.missionFixFeatureLineage.fixFeatureId, fixFeatureId)); - return rows.map((row) => rowToLineage(row as LineageRow)); -} - -// ════════════════════════════════════════════════════════════════════ -// SNAPSHOT APPLY (upserts) -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-24-10:45: - * Upsert a mission (snapshot apply / mesh replication). On conflict, update all - * mutable columns. This is the ON CONFLICT(id) DO UPDATE SET ... pattern from - * the sync applyMissionHierarchySnapshot. - */ -export async function upsertMission(handle: QueryHandle, mission: Mission): Promise { - await handle - .insert(schema.project.missions) - .values({ - id: mission.id, - title: mission.title, - description: mission.description ?? null, - status: mission.status, - interviewState: mission.interviewState, - baseBranch: mission.baseBranch ?? null, - branchStrategy: serializeBranchStrategy(mission.branchStrategy), - autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0, - autoAdvance: mission.autoAdvance ? 1 : 0, - autopilotEnabled: mission.autopilotEnabled ? 1 : 0, - autopilotState: mission.autopilotState, - lastAutopilotActivityAt: mission.lastAutopilotActivityAt ?? null, - createdAt: mission.createdAt, - updatedAt: mission.updatedAt, - }) - .onConflictDoUpdate({ - target: [schema.project.missions.projectId, schema.project.missions.id], - set: { - title: sql`excluded.title`, - description: sql`excluded.description`, - status: sql`excluded.status`, - interviewState: sql`excluded.interview_state`, - baseBranch: sql`excluded.base_branch`, - branchStrategy: sql`excluded.branch_strategy`, - autoMerge: sql`excluded.auto_merge`, - autoAdvance: sql`excluded.auto_advance`, - autopilotEnabled: sql`excluded.autopilot_enabled`, - autopilotState: sql`excluded.autopilot_state`, - lastAutopilotActivityAt: sql`excluded.last_autopilot_activity_at`, - updatedAt: sql`excluded.updated_at`, - }, - }); -} - -/** Upsert a milestone (snapshot apply). */ -export async function upsertMilestone(handle: QueryHandle, milestone: Milestone): Promise { - await handle - .insert(schema.project.milestones) - .values({ - id: milestone.id, - missionId: milestone.missionId, - title: milestone.title, - description: milestone.description ?? null, - status: milestone.status, - orderIndex: milestone.orderIndex, - interviewState: milestone.interviewState, - dependencies: milestone.dependencies, - planningNotes: milestone.planningNotes ?? null, - verification: milestone.verification ?? null, - acceptanceCriteria: milestone.acceptanceCriteria ?? null, - validationState: milestone.validationState ?? "not_started", - createdAt: milestone.createdAt, - updatedAt: milestone.updatedAt, - }) - .onConflictDoUpdate({ - target: [schema.project.milestones.projectId, schema.project.milestones.id], - set: { - title: sql`excluded.title`, - description: sql`excluded.description`, - status: sql`excluded.status`, - orderIndex: sql`excluded.order_index`, - interviewState: sql`excluded.interview_state`, - dependencies: sql`excluded.dependencies`, - planningNotes: sql`excluded.planning_notes`, - verification: sql`excluded.verification`, - acceptanceCriteria: sql`excluded.acceptance_criteria`, - validationState: sql`excluded.validation_state`, - updatedAt: sql`excluded.updated_at`, - }, - }); -} - -/** Upsert a slice (snapshot apply). */ -export async function upsertSlice(handle: QueryHandle, slice: Slice): Promise { - await handle - .insert(schema.project.slices) - .values({ - id: slice.id, - milestoneId: slice.milestoneId, - title: slice.title, - description: slice.description ?? null, - status: slice.status, - orderIndex: slice.orderIndex, - activatedAt: slice.activatedAt ?? null, - planState: slice.planState ?? "not_started", - planningNotes: slice.planningNotes ?? null, - verification: slice.verification ?? null, - createdAt: slice.createdAt, - updatedAt: slice.updatedAt, - }) - .onConflictDoUpdate({ - target: [schema.project.slices.projectId, schema.project.slices.id], - set: { - title: sql`excluded.title`, - description: sql`excluded.description`, - status: sql`excluded.status`, - orderIndex: sql`excluded.order_index`, - activatedAt: sql`excluded.activated_at`, - planState: sql`excluded.plan_state`, - planningNotes: sql`excluded.planning_notes`, - verification: sql`excluded.verification`, - updatedAt: sql`excluded.updated_at`, - }, - }); -} - -/** Upsert a feature (snapshot apply). */ -export async function upsertFeature(handle: QueryHandle, feature: MissionFeature): Promise { - await handle - .insert(schema.project.missionFeatures) - .values({ - id: feature.id, - sliceId: feature.sliceId, - taskId: feature.taskId ?? null, - title: feature.title, - description: feature.description ?? null, - acceptanceCriteria: feature.acceptanceCriteria ?? null, - status: feature.status, - createdAt: feature.createdAt, - updatedAt: feature.updatedAt, - loopState: feature.loopState ?? "idle", - implementationAttemptCount: feature.implementationAttemptCount ?? 0, - validatorAttemptCount: feature.validatorAttemptCount ?? 0, - lastValidatorRunId: feature.lastValidatorRunId ?? null, - lastValidatorStatus: feature.lastValidatorStatus ?? null, - generatedFromFeatureId: feature.generatedFromFeatureId ?? null, - generatedFromRunId: feature.generatedFromRunId ?? null, - }) - .onConflictDoUpdate({ - target: [schema.project.missionFeatures.projectId, schema.project.missionFeatures.id], - set: { - taskId: sql`excluded.task_id`, - title: sql`excluded.title`, - description: sql`excluded.description`, - acceptanceCriteria: sql`excluded.acceptance_criteria`, - status: sql`excluded.status`, - updatedAt: sql`excluded.updated_at`, - loopState: sql`excluded.loop_state`, - implementationAttemptCount: sql`excluded.implementation_attempt_count`, - validatorAttemptCount: sql`excluded.validator_attempt_count`, - lastValidatorRunId: sql`excluded.last_validator_run_id`, - lastValidatorStatus: sql`excluded.last_validator_status`, - generatedFromFeatureId: sql`excluded.generated_from_feature_id`, - generatedFromRunId: sql`excluded.generated_from_run_id`, - }, - }); -} - -/** Upsert a contract assertion (snapshot apply). */ -export async function upsertContractAssertion(handle: QueryHandle, assertion: MissionContractAssertion): Promise { - await handle - .insert(schema.project.missionContractAssertions) - .values({ - id: assertion.id, - milestoneId: assertion.milestoneId, - title: assertion.title, - assertion: assertion.assertion, - status: assertion.status, - type: normalizeMissionAssertionType(assertion.type), - orderIndex: assertion.orderIndex, - sourceFeatureId: assertion.sourceFeatureId ?? null, - createdAt: assertion.createdAt, - updatedAt: assertion.updatedAt, - }) - .onConflictDoUpdate({ - target: [ - schema.project.missionContractAssertions.projectId, - schema.project.missionContractAssertions.id, - ], - set: { - title: sql`excluded.title`, - assertion: sql`excluded.assertion`, - status: sql`excluded.status`, - type: sql`excluded.type`, - orderIndex: sql`excluded.order_index`, - sourceFeatureId: sql`excluded.source_feature_id`, - updatedAt: sql`excluded.updated_at`, - }, - }); -} - -// ════════════════════════════════════════════════════════════════════ -// U5 ADDED HELPERS — JOIN lists, event paging, task-linkage guards -// ════════════════════════════════════════════════════════════════════ - -/** - * FNXC:MissionStore 2026-06-27-15:05: - * Paginated mission events with total count and optional eventType filter. - * Mirrors sync `MissionStore.getMissionEvents` ordering: - * COALESCE(seq,0) DESC, timestamp DESC, id DESC. - */ -export async function getMissionEventsPage( - handle: QueryHandle, - missionId: string, - options?: { limit?: number; offset?: number; eventType?: string }, -): Promise<{ events: MissionEvent[]; total: number }> { - const limit = Math.max(0, options?.limit ?? 50); - const offset = Math.max(0, options?.offset ?? 0); - const conditions = [eq(schema.project.missionEvents.missionId, missionId)]; - if (options?.eventType) conditions.push(eq(schema.project.missionEvents.eventType, options.eventType)); - const totalRows = await handle - .select({ count: sql`count(*)::int` }) - .from(schema.project.missionEvents) - .where(and(...conditions)); - const total = totalRows[0]?.count ?? 0; - const rows = await handle - .select(eventColumns) - .from(schema.project.missionEvents) - .where(and(...conditions)) - .orderBy( - desc(sql`coalesce(${schema.project.missionEvents.seq}, 0)`), - desc(schema.project.missionEvents.timestamp), - desc(schema.project.missionEvents.id), - ) - .limit(limit) - .offset(offset); - return { events: rows.map((row) => rowToMissionEvent(row as MissionEventRow)), total }; -} - -/** - * FNXC:MissionStore 2026-06-27-15:05: - * List assertions linked to a feature (JOIN mission_feature_assertions), - * ordered orderIndex ASC, createdAt ASC, id ASC — mirrors sync `listAssertionsForFeature`. - */ -export async function listAssertionsForFeature(handle: QueryHandle, featureId: string): Promise { - const rows = await handle - .select(assertionColumns) - .from(schema.project.missionContractAssertions) - .innerJoin( - schema.project.missionFeatureAssertions, - eq(schema.project.missionContractAssertions.id, schema.project.missionFeatureAssertions.assertionId), - ) - .where(eq(schema.project.missionFeatureAssertions.featureId, featureId)) - .orderBy( - asc(schema.project.missionContractAssertions.orderIndex), - asc(schema.project.missionContractAssertions.createdAt), - asc(schema.project.missionContractAssertions.id), - ); - return rows.map((row) => rowToAssertion(row as AssertionRow)); -} - -/** - * FNXC:MissionStore 2026-06-27-15:05: - * List features linked to an assertion (JOIN), ordered createdAt ASC. - */ -export async function listFeaturesForAssertion(handle: QueryHandle, assertionId: string): Promise { - const rows = await handle - .select(featureColumns) - .from(schema.project.missionFeatures) - .innerJoin( - schema.project.missionFeatureAssertions, - eq(schema.project.missionFeatures.id, schema.project.missionFeatureAssertions.featureId), - ) - .where(eq(schema.project.missionFeatureAssertions.assertionId, assertionId)) - .orderBy(asc(schema.project.missionFeatures.createdAt)); - return rows.map((row) => rowToFeature(row as FeatureRow)); -} - -/** Filter the given task ids to those that are live (not deleted, not archived). */ -export async function listLiveLinkedTaskIds(handle: QueryHandle, taskIds: string[]): Promise> { - if (taskIds.length === 0) return new Set(); - const rows = await handle - .select({ id: schema.project.tasks.id }) - .from(schema.project.tasks) - .where( - and( - inArray(schema.project.tasks.id, taskIds), - sql`${schema.project.tasks.deletedAt} is null`, - sql`${schema.project.tasks.column} <> 'archived'`, - ), - ); - return new Set(rows.map((row) => row.id)); -} - -/** Get a live (non-deleted) task's id + column, or undefined. */ -export async function getLiveTaskById(handle: QueryHandle, taskId: string): Promise<{ id: string; column: string } | undefined> { - const rows = await handle - .select({ id: schema.project.tasks.id, column: schema.project.tasks.column }) - .from(schema.project.tasks) - .where(and(eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} is null`)); - const row = rows[0]; - return row ? { id: row.id, column: row.column as string } : undefined; -} - -/** Set a live task's mission/slice linkage (bidirectional link). */ -export async function setTaskMissionLinkage(handle: QueryHandle, taskId: string, missionId: string, sliceId: string): Promise { - await handle - .update(schema.project.tasks) - .set({ missionId, sliceId }) - .where(and(eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} is null`)); -} - -/** Clear a live task's mission/slice linkage. */ -export async function clearTaskMissionLinkage(handle: QueryHandle, taskId: string): Promise { - await handle - .update(schema.project.tasks) - .set({ missionId: null, sliceId: null }) - .where(and(eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} is null`)); -} - -/** Set of all failed (non-deleted) task ids — for health rollup. */ -export async function listFailedTaskIds(handle: QueryHandle): Promise> { - const rows = await handle - .select({ id: schema.project.tasks.id }) - .from(schema.project.tasks) - .where(and(eq(schema.project.tasks.status, "failed"), sql`${schema.project.tasks.deletedAt} is null`)); - return new Set(rows.map((row) => row.id)); -} +/* +FNXC:MissionStoreMaintainability 2026-07-14-19:24: +The event-emitting facade delegates standalone PostgreSQL queries to a focused module while preserving every existing top-level helper export. +*/ +export * from "./async-mission-store-queries.js"; +import { + DEFAULT_IMPLEMENTATION_RETRY_BUDGET, + missionBranchStrategyDefaults, + QueryHandle, + AssertionRow, + assertionColumns, + rowToAssertion, + createMission, + getMission, + listMissions, + updateMission, + deleteMission, + missionExists, + createMilestone, + getMilestone, + listMilestones, + listAllMilestones, + updateMilestone, + deleteMilestone, + reorderMilestones, + createSlice, + getSlice, + listSlices, + listAllSlices, + updateSlice, + deleteSlice, + reorderSlices, + createFeature, + getFeature, + listFeaturesByIds, + listFeatures, + listFeaturesForMilestone, + listAllFeatures, + updateFeature, + deleteFeature, + getFeatureByTaskId, + unlinkFeatureFromTaskId, + getMaxEventSeq, + insertMissionEvent, + countMissionEvents, + countEventsByMission, + listErrorEventsForHealth, + getMissionGoalLink, + insertMissionGoalLink, + deleteMissionGoalLink, + listGoalIdsForMission, + listMissionIdsForGoal, + countGoalsByMission, + goalExists, + listGoalsByIds, + createContractAssertion, + getContractAssertion, + listContractAssertions, + listLinkedAssertionsForFeatures, + listLinkedAssertionIds, + updateContractAssertion, + deleteContractAssertion, + reorderContractAssertions, + featureAssertionLinkExists, + linkFeatureToAssertion, + unlinkFeatureFromAssertion, + createValidatorRun, + getValidatorRun, + listValidatorRunsByFeature, + listStaleRunningValidatorRuns, + transitionRunningValidatorRun, + insertValidatorFailures, + listFailuresForRun, + listFailuresForRuns, + listFeatureIdsWithAssertions, + insertFixFeatureLineage, + findFixFeatureId, + findFixFeatureIdsForSource, + listLineageForSourceFeature, + listLineageForFixFeature, + getMissionEventsPage, + listAssertionsForFeature, + listFeaturesForAssertion, + listLiveLinkedTaskIds, + getLiveTaskById, + setTaskMissionLinkage, + clearTaskMissionLinkage, + listFailedTaskIds, +} from "./async-mission-store-queries.js"; // ════════════════════════════════════════════════════════════════════ // FNXC:MissionStore 2026-06-27-15:10: @@ -1916,15 +156,10 @@ export async function listFailedTaskIds(handle: QueryHandle): Promise { private idSequence = 0; @@ -2335,6 +568,29 @@ export class AsyncMissionStore extends EventEmitter { return listMissionIdsForGoal(this.db, goalId); } + async listGoalIdsForTask(taskId: string): Promise { + const feature = await getFeatureByTaskId(this.db, taskId); + let missionId: string | undefined; + if (feature) { + const slice = await getSlice(this.db, feature.sliceId); + const milestone = slice ? await getMilestone(this.db, slice.milestoneId) : undefined; + missionId = milestone?.missionId; + } + if (!missionId) { + const rows = await this.db + .select({ missionId: schema.project.tasks.missionId }) + .from(schema.project.tasks) + .where(and(eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} IS NULL`)) + .limit(1); + missionId = rows[0]?.missionId ?? undefined; + } + return missionId ? this.listGoalIdsForMission(missionId) : []; + } + + async listGoalsForTask(taskId: string): Promise { + return listGoalsByIds(this.db, await this.listGoalIdsForTask(taskId)); + } + // ════════════════ MILESTONE OPS ════════════════ async addMilestone(missionId: string, input: MilestoneCreateInput): Promise { const mission = await getMission(this.db, missionId); @@ -2719,12 +975,259 @@ export class AsyncMissionStore extends EventEmitter { return listFailuresForRun(this.db, runId); } + async completeValidatorRun( + runId: string, + result: "passed" | "failed" | "blocked" | "error", + summary?: string, + blockedReason?: string, + ): Promise { + const run = await getValidatorRun(this.db, runId); + if (!run) throw new Error(`Validator run ${runId} not found`); + if (run.status !== "running") throw new Error(`Validator run ${runId} is not in 'running' status`); + const feature = await getFeature(this.db, run.featureId); + if (!feature) throw new Error(`Feature ${run.featureId} not found`); + const now = new Date().toISOString(); + const loopState: FeatureLoopState = result === "passed" ? "passed" : result === "failed" ? "needs_fix" : result === "blocked" ? "blocked" : "validating"; + const updatedRun: MissionValidatorRun = { ...run, status: result, summary, blockedReason, completedAt: now, updatedAt: now }; + const won = await this.layer.transactionImmediate(async (tx) => { + const winner = await transitionRunningValidatorRun(tx, updatedRun); + if (!winner) return false; + await updateFeature(tx, { ...feature, loopState, lastValidatorStatus: result, updatedAt: now }); + return true; + }); + if (!won) return (await getValidatorRun(this.db, runId)) ?? updatedRun; + const updatedFeature = await getFeature(this.db, feature.id); + if (updatedFeature) this.emit("feature:updated", updatedFeature); + await this.recomputeSliceStatus(feature.sliceId); + const durationMs = Math.max(0, Date.parse(now) - Date.parse(run.startedAt)); + this.emit("validator-run:completed", updatedRun, result, durationMs); + if (result === "passed") await this.reconcileSupersededGeneratedFixFeatures(feature.sliceId); + return updatedRun; + } + + async recordValidatorFailures( + runId: string, + failures: Array<{ featureId: string; assertionId: string; message?: string; expected?: string; actual?: string }>, + ): Promise { + if (!(await getValidatorRun(this.db, runId))) throw new Error(`Validator run ${runId} not found`); + const records = failures.map((failure) => ({ + ...failure, + id: this.generateId("VF"), + runId, + createdAt: new Date().toISOString(), + })); + await this.layer.transactionImmediate(async (tx) => { + /* + FNXC:PostgresMissionValidatorFailures 2026-07-14-17:55: + One validator result is one durable observation batch. Persist every assertion failure with one INSERT statement so run cost does not scale by one database round trip per failed assertion. + */ + await insertValidatorFailures(tx, records); + }); + return records; + } + + async listStaleRunningValidatorRuns(maxAgeMs: number, now = Date.now()): Promise { + return listStaleRunningValidatorRuns(this.db, new Date(now - maxAgeMs).toISOString()); + } + + async reapValidatorRun(runId: string, reason: string): Promise { + const run = await getValidatorRun(this.db, runId); + if (!run) throw new Error(`Validator run ${runId} not found`); + if (run.status !== "running") return run; + const feature = await getFeature(this.db, run.featureId); + if (!feature) throw new Error(`Feature ${run.featureId} not found`); + const slice = await getSlice(this.db, feature.sliceId); + const milestone = slice ? await getMilestone(this.db, slice.milestoneId) : undefined; + const mission = milestone ? await getMission(this.db, milestone.missionId) : undefined; + if (!slice) throw new Error(`Slice ${feature.sliceId} not found`); + if (!milestone) throw new Error(`Milestone ${slice.milestoneId} not found`); + if (!mission) throw new Error(`Mission ${milestone.missionId} not found`); + const now = new Date().toISOString(); + const updatedRun: MissionValidatorRun = { ...run, status: "error", summary: reason, completedAt: now, updatedAt: now }; + const shouldUpdateFeature = mission.status !== "archived" && mission.status !== "complete" && feature.status !== "done"; + const won = await this.layer.transactionImmediate(async (tx) => { + const winner = await transitionRunningValidatorRun(tx, updatedRun); + if (!winner) return false; + if (shouldUpdateFeature) await updateFeature(tx, { ...feature, loopState: "needs_fix", lastValidatorStatus: "error", updatedAt: now }); + return true; + }); + if (!won) return (await getValidatorRun(this.db, runId)) ?? updatedRun; + if (shouldUpdateFeature) { + const updatedFeature = await getFeature(this.db, feature.id); + if (updatedFeature) this.emit("feature:updated", updatedFeature); + await this.recomputeSliceStatus(feature.sliceId); + } + this.emit("validator-run:completed", updatedRun, "error", Math.max(0, Date.parse(now) - Date.parse(run.startedAt))); + return updatedRun; + } + + async findGeneratedFixFeature(sourceFeatureId: string, runId: string): Promise { + const id = await findFixFeatureId(this.db, sourceFeatureId, runId); + return id ? getFeature(this.db, id) : undefined; + } + + async findOpenGeneratedFixFeature(sourceFeatureId: string): Promise { + const ids = await findFixFeatureIdsForSource(this.db, sourceFeatureId); + const featuresById = new Map((await listFeaturesByIds(this.db, ids)).map((feature) => [feature.id, feature])); + return ids.map((id) => featuresById.get(id)).find((feature) => feature && feature.status !== "done" && feature.status !== "blocked"); + } + + async createGeneratedFixFeature( + sourceFeatureId: string, + runId: string, + failedAssertionIds: string[], + failureReason?: string, + title?: string, + ): Promise { + const run = await getValidatorRun(this.db, runId); + if (!run) throw new Error(`Validator run ${runId} not found`); + if (run.featureId !== sourceFeatureId) throw new Error(`Validator run ${runId} belongs to feature ${run.featureId}, expected ${sourceFeatureId}`); + const now = new Date().toISOString(); + const reasonText = failureReason?.trim(); + /* + FNXC:MissionFixIdempotency 2026-07-14-18:45: + Generated remediation is one source/run operation. Lock the source feature, re-check lineage/open fixes under that lock, and increment the retry counter in the same transaction so concurrent validator workers cannot create duplicates or consume two attempts. + */ + const outcome = await this.layer.transactionImmediate(async (tx): Promise< + | { kind: "existing"; feature: MissionFeature } + | { kind: "created"; feature: MissionFeature } + | { kind: "exhausted" } + > => { + const locked = await tx + .select({ id: schema.project.missionFeatures.id }) + .from(schema.project.missionFeatures) + .where(eq(schema.project.missionFeatures.id, sourceFeatureId)) + .for("update"); + if (locked.length === 0) throw new Error(`Feature ${sourceFeatureId} not found`); + const source = await getFeature(tx, sourceFeatureId); + if (!source) throw new Error(`Feature ${sourceFeatureId} not found`); + + const exactId = await findFixFeatureId(tx, sourceFeatureId, runId); + if (exactId) { + const exact = await getFeature(tx, exactId); + if (exact) return { kind: "existing", feature: exact }; + } + const openIds = await findFixFeatureIdsForSource(tx, sourceFeatureId); + const openFeatures = await listFeaturesByIds(tx, openIds); + const open = openFeatures.find((candidate) => candidate.status !== "done" && candidate.status !== "blocked"); + if (open) return { kind: "existing", feature: open }; + + if ((source.implementationAttemptCount ?? 0) >= DEFAULT_IMPLEMENTATION_RETRY_BUDGET) { + await updateFeature(tx, { ...source, loopState: "blocked", updatedAt: now }); + return { kind: "exhausted" }; + } + + const feature: MissionFeature = { + id: this.generateId("F"), + sliceId: source.sliceId, + title: title ?? `Fix: ${source.title}`, + description: reasonText ? `${source.description ? `${source.description}\n\n` : ""}## Verification failure detail\n${reasonText}` : source.description, + acceptanceCriteria: source.acceptanceCriteria, + status: "defined", + createdAt: now, + updatedAt: now, + loopState: "idle", + implementationAttemptCount: 0, + validatorAttemptCount: 0, + generatedFromFeatureId: sourceFeatureId, + generatedFromRunId: runId, + }; + await createFeature(tx, feature); + await insertFixFeatureLineage(tx, { id: this.generateId("FFL"), sourceFeatureId, fixFeatureId: feature.id, runId, failedAssertionIds, createdAt: now }); + const bumped = await tx + .update(schema.project.missionFeatures) + .set({ + implementationAttemptCount: sql`${schema.project.missionFeatures.implementationAttemptCount} + 1`, + loopState: "implementing", + updatedAt: now, + }) + .where(and( + eq(schema.project.missionFeatures.id, sourceFeatureId), + sql`${schema.project.missionFeatures.implementationAttemptCount} < ${DEFAULT_IMPLEMENTATION_RETRY_BUDGET}`, + )) + .returning({ id: schema.project.missionFeatures.id }); + if (bumped.length !== 1) throw new Error(`Feature ${sourceFeatureId} retry budget changed while creating its generated fix`); + return { kind: "created", feature }; + }); + if (outcome.kind === "existing") return outcome.feature; + if (outcome.kind === "exhausted") { + const updatedSource = await getFeature(this.db, sourceFeatureId); + if (updatedSource) this.emit("feature:updated", updatedSource); + throw new Error(`Feature ${sourceFeatureId} has exhausted its retry budget (${DEFAULT_IMPLEMENTATION_RETRY_BUDGET} attempts). Transitioning to 'blocked' state.`); + } + const feature = outcome.feature; + this.emit("feature:created", feature); + const updatedSource = await getFeature(this.db, sourceFeatureId); + if (updatedSource) this.emit("feature:updated", updatedSource); + this.emit("fix-feature:created", { feature, sourceFeatureId, runId, failedAssertionIds }); + return feature; + } + + async reconcileSupersededGeneratedFixFeatures(sliceId: string): Promise<{ supersededCount: number; featureIds: string[] }> { + const features = await listFeatures(this.db, sliceId); + const byId = new Map(features.map((feature) => [feature.id, feature])); + let missingSourceIds = [...new Set(features.map((feature) => feature.generatedFromFeatureId).filter((id): id is string => Boolean(id) && !byId.has(id!)))]; + while (missingSourceIds.length > 0) { + const sources = await listFeaturesByIds(this.db, missingSourceIds); + for (const source of sources) byId.set(source.id, source); + missingSourceIds = [...new Set(sources.map((source) => source.generatedFromFeatureId).filter((id): id is string => Boolean(id) && !byId.has(id!)))]; + } + const passed = (feature?: MissionFeature) => feature?.lastValidatorStatus === "passed" || feature?.loopState === "passed"; + const hasPassedAncestor = (feature: MissionFeature, seen = new Set()): boolean => { + const sourceId = feature.generatedFromFeatureId; + if (!sourceId || seen.has(sourceId)) return false; + seen.add(sourceId); + const source = byId.get(sourceId); + return passed(source) || (source ? hasPassedAncestor(source, seen) : false); + }; + const ids: string[] = []; + for (const feature of features) { + if (!feature.generatedFromFeatureId || !(passed(feature) || hasPassedAncestor(feature))) continue; + if (feature.status !== "done" || feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed" || feature.taskId) ids.push(feature.id); + } + if (ids.length > 0) { + const now = new Date().toISOString(); + /* + FNXC:PostgresMissionStatusReconciliation 2026-07-14-17:55: + Superseded generated fixes are one reconciliation set. Update their terminal status in one statement instead of routing every ID through updateFeature/getFeature/cascade reads; emit the same per-feature observable events after persistence. + */ + await this.db.update(schema.project.missionFeatures).set({ + status: "done", + taskId: null, + loopState: "passed", + lastValidatorStatus: "passed", + updatedAt: now, + }).where(inArray(schema.project.missionFeatures.id, ids)); + for (const id of ids) { + const feature = byId.get(id)!; + const updated = { ...feature, status: "done" as const, taskId: undefined, loopState: "passed" as const, lastValidatorStatus: "passed" as const, updatedAt: now }; + this.emit("feature:updated", updated); + if (feature.taskId) await clearTaskMissionLinkage(this.db, feature.taskId); + } + await this.recomputeSliceStatus(sliceId); + } + return { supersededCount: ids.length, featureIds: ids }; + } + + async transitionLoopState(featureId: string, newState: FeatureLoopState): Promise { + const feature = await getFeature(this.db, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + const current = feature.loopState ?? "idle"; + const valid: Record = { idle: ["implementing"], implementing: ["validating"], validating: ["needs_fix", "passed", "blocked"], needs_fix: ["implementing"], passed: [], blocked: [] }; + if (!valid[current].includes(newState)) throw new Error(`Invalid loop state transition from '${current}' to '${newState}'. Allowed transitions from '${current}': ${valid[current].join(", ") || "none"}`); + if (newState === "implementing" && (feature.implementationAttemptCount ?? 0) >= DEFAULT_IMPLEMENTATION_RETRY_BUDGET) { + await this.updateFeature(featureId, { loopState: "blocked" }); + throw new Error(`Feature ${featureId} has exhausted its retry budget (${DEFAULT_IMPLEMENTATION_RETRY_BUDGET} attempts). Transitioning to 'blocked' state.`); + } + return this.updateFeature(featureId, { loopState: newState }); + } + async getFeatureLoopSnapshot(featureId: string): Promise { const feature = await getFeature(this.db, featureId); if (!feature) throw new Error(`Feature ${featureId} not found`); const validatorRuns = await listValidatorRunsByFeature(this.db, featureId); - const failures: MissionAssertionFailureRecord[] = []; - for (const run of validatorRuns) failures.push(...(await listFailuresForRun(this.db, run.id))); + /* FNXC:PostgresMissionBulkReads 2026-07-14-17:55: Snapshot history fetches every run's failures with one IN query rather than one query per run. */ + const failures = await listFailuresForRuns(this.db, validatorRuns.map((run) => run.id)); const lineage = [ ...(await listLineageForSourceFeature(this.db, featureId)), ...(await listLineageForFixFeature(this.db, featureId)), @@ -2846,6 +1349,93 @@ export class AsyncMissionStore extends EventEmitter { return listFeaturesForAssertion(this.db, assertionId); } + async ensureFeatureAssertionLinked(featureId: string): Promise { + const feature = await getFeature(this.db, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + await this.ensureFeatureAssertion(feature); + return listAssertionsForFeature(this.db, featureId); + } + + async seedContractAssertionsForFeatures(inputs: MissionAssertionSeedInput[]): Promise { + const report: MissionAssertionSeedReport = { scanned: inputs.length, created: 0, linked: 0, skippedExisting: 0 }; + if (inputs.length === 0) return report; + const featureIds = [...new Set(inputs.map((input) => input.featureId))]; + const milestoneIds = [...new Set(inputs.map((input) => input.milestoneId))]; + const [features, milestones, linked, milestoneAssertions] = await Promise.all([ + listFeaturesByIds(this.db, featureIds), + this.db.select({ id: schema.project.milestones.id }).from(schema.project.milestones).where(inArray(schema.project.milestones.id, milestoneIds)), + listLinkedAssertionsForFeatures(this.db, featureIds), + this.db.select(assertionColumns).from(schema.project.missionContractAssertions).where(inArray(schema.project.missionContractAssertions.milestoneId, milestoneIds)), + ]); + const featureSet = new Set(features.map((feature) => feature.id)); + const milestoneSet = new Set(milestones.map((milestone) => milestone.id)); + const existingKeys = new Set(linked.map(({ featureId, assertion }) => + `${featureId}\u0000${assertion.milestoneId}\u0000${assertion.title.trim()}\u0000${assertion.assertion.trim()}`)); + const nextOrder = new Map(); + for (const row of milestoneAssertions) { + const assertion = rowToAssertion(row as AssertionRow); + nextOrder.set(assertion.milestoneId, Math.max(nextOrder.get(assertion.milestoneId) ?? 0, assertion.orderIndex + 1)); + } + const created: MissionContractAssertion[] = []; + const links: Array<{ featureId: string; assertionId: string; createdAt: string }> = []; + for (const input of inputs) { + if (!milestoneSet.has(input.milestoneId)) throw new Error(`Milestone ${input.milestoneId} not found`); + if (!featureSet.has(input.featureId)) throw new Error(`Feature ${input.featureId} not found`); + const key = `${input.featureId}\u0000${input.milestoneId}\u0000${input.title.trim()}\u0000${input.assertion.trim()}`; + if (existingKeys.has(key)) { + report.skippedExisting += 1; + continue; + } + existingKeys.add(key); + const now = new Date().toISOString(); + const assertion: MissionContractAssertion = { + id: this.generateId("CA"), + milestoneId: input.milestoneId, + title: input.title, + assertion: input.assertion, + status: "pending", + type: "static", + orderIndex: nextOrder.get(input.milestoneId) ?? 0, + sourceFeatureId: input.featureId, + createdAt: now, + updatedAt: now, + }; + nextOrder.set(input.milestoneId, assertion.orderIndex + 1); + created.push(assertion); + links.push({ featureId: input.featureId, assertionId: assertion.id, createdAt: now }); + } + if (created.length === 0) return report; + /* + FNXC:PostgresMissionAssertionSeeding 2026-07-14-17:55: + Authored assertion seeds are idempotent batches. Resolve existing links/features/milestones up front, insert all new assertions and links transactionally, and recompute each affected milestone once instead of performing a read/write/recompute cycle per seed row. + */ + await this.layer.transactionImmediate(async (tx) => { + await tx.insert(schema.project.missionContractAssertions).values(created.map((assertion) => ({ + id: assertion.id, + milestoneId: assertion.milestoneId, + title: assertion.title, + assertion: assertion.assertion, + status: assertion.status, + type: normalizeMissionAssertionType(assertion.type), + orderIndex: assertion.orderIndex, + sourceFeatureId: assertion.sourceFeatureId ?? null, + createdAt: assertion.createdAt, + updatedAt: assertion.updatedAt, + }))); + await tx.insert(schema.project.missionFeatureAssertions).values(links).onConflictDoNothing(); + }); + report.created = created.length; + report.linked = links.length; + for (let index = 0; index < created.length; index += 1) { + this.emit("assertion:created", created[index]!); + this.emit("assertion:linked", { featureId: links[index]!.featureId, assertionId: created[index]!.id }); + } + for (const milestoneId of new Set(created.map((assertion) => assertion.milestoneId))) { + await this.recomputeMilestoneValidation(milestoneId); + } + return report; + } + // ════════════════ VALIDATION ROLLUP ════════════════ async getMilestoneValidationRollup(milestoneId: string): Promise { const milestone = await getMilestone(this.db, milestoneId); @@ -2853,16 +1443,11 @@ export class AsyncMissionStore extends EventEmitter { const assertions = await listContractAssertions(this.db, milestoneId); const totalAssertions = assertions.length; const proseOnMilestone = (milestone.acceptanceCriteria ?? "").trim().length > 0; - let proseOnFeatures = false; - for (const slice of await listSlices(this.db, milestoneId)) { - for (const feature of await listFeatures(this.db, slice.id)) { - if ((feature.acceptanceCriteria ?? "").trim().length > 0) { - proseOnFeatures = true; - break; - } - } - if (proseOnFeatures) break; - } + const [milestoneFeatures, linkedAssertionIds] = await Promise.all([ + listFeaturesForMilestone(this.db, milestoneId), + listLinkedAssertionIds(this.db, assertions.map((assertion) => assertion.id)), + ]); + const proseOnFeatures = milestoneFeatures.some((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0); const hasProseButNoAssertions = totalAssertions === 0 && (proseOnMilestone || proseOnFeatures); let passedAssertions = 0; @@ -2877,10 +1462,14 @@ export class AsyncMissionStore extends EventEmitter { case "blocked": blockedAssertions++; break; case "pending": pendingAssertions++; break; } - const linkedFeatures = await listFeaturesForAssertion(this.db, assertion.id); - if (linkedFeatures.length === 0) unlinkedAssertions++; + if (!linkedAssertionIds.has(assertion.id)) unlinkedAssertions++; } + /* + FNXC:PostgresMissionValidationRollup 2026-07-14-17:55: + Milestone validation computes prose coverage and linked assertion membership with two bulk queries. Assertion count no longer multiplies database round trips during every status reconciliation or seed batch. + */ + let state: MilestoneValidationState; if (totalAssertions === 0) state = "not_started"; else if (failedAssertions > 0) state = "failed"; @@ -2904,6 +1493,10 @@ export class AsyncMissionStore extends EventEmitter { }; } + async milestoneHasProseButNoAssertions(milestoneId: string): Promise { + return (await this.getMilestoneValidationRollup(milestoneId)).hasProseButNoAssertions; + } + async backfillFeatureAssertions(options?: { missionId?: string; dryRun?: boolean }): Promise { const dryRun = options?.dryRun ?? true; const missionFilter = options?.missionId; @@ -3106,10 +1699,12 @@ export class AsyncMissionStore extends EventEmitter { async computeSliceStatus(sliceId: string): Promise { const features = await listFeatures(this.db, sliceId); if (features.length === 0) return "pending"; + /* FNXC:MissionStatusPerformance 2026-07-14-18:45: Slice reconciliation loads assertion membership for the whole feature set once; status rollups must not issue one assertion query per feature. */ + const featureIdsWithAssertions = await listFeatureIdsWithAssertions(this.db, features.map((feature) => feature.id)); let allDone = true; for (const feature of features) { if (feature.status !== "done") { allDone = false; break; } - const hasLinkedAssertions = (await listAssertionsForFeature(this.db, feature.id)).length > 0; + const hasLinkedAssertions = featureIdsWithAssertions.has(feature.id); if (!hasLinkedAssertions) continue; if (feature.lastValidatorStatus === "passed") continue; if (feature.loopState === "idle" || feature.loopState === undefined) continue; diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index e60d1b2cec..c04ff57cd0 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -103,8 +103,7 @@ import { * CentralCore operations. When an AsyncDataLayer is injected, CentralCore * delegates to these helpers against the central schema via the SHARED * connection pool (the same one TaskStore and the satellite stores use — NOT - * a separate connection). The SQLite CentralDatabase path is preserved as the - * legacy fallback for FUSION_NO_EMBEDDED_PG mode. + * a separate connection). Layer-less construction bootstraps PostgreSQL. */ import type { AsyncDataLayer } from "./postgres/data-layer.js"; import * as asyncCentralCore from "./async-central-core.js"; @@ -191,9 +190,8 @@ export interface CentralCoreOptions { * FNXC:CentralCore 2026-06-26-12:30: * When an AsyncDataLayer is injected, CentralCore operates in "backend mode": * all data access delegates to PostgreSQL via Drizzle against the central - * schema and no SQLite CentralDatabase is constructed. When absent, the - * legacy SQLite path is byte-identical to pre-migration. This mirrors the - * TaskStore/PluginStore/AgentStore dual-path pattern. + * schema and no SQLite CentralDatabase is constructed. When absent, init() + * creates and owns an unscoped PostgreSQL layer. */ asyncLayer?: AsyncDataLayer; } @@ -206,6 +204,8 @@ export class CentralCore extends EventEmitter { private discoveryConfig: DiscoveryConfig | null = null; private readonly discoveredNodes = new Map(); private readonly ensureGitRepositoryForProjectPath: typeof ensureGitRepositoryForProjectPath; + private ownedBackendShutdown: (() => Promise) | null = null; + private ownedBackendReleaseConnections: (() => Promise) | null = null; /** * FNXC:CentralCore 2026-06-26-12:30: @@ -236,6 +236,18 @@ export class CentralCore extends EventEmitter { if (!layer) { throw new Error("attachBackendLayer requires a non-null AsyncDataLayer"); } + // Release a central-only pool before adopting the runtime's shared layer. + if (this.ownedBackendReleaseConnections) { + /* + * FNXC:CentralPostgresLifecycle 2026-07-14-17:39: + * Release the central-only pool when adopting a TaskStore layer, but keep + * ownership of the embedded postmaster until CentralCore closes. The + * TaskStore lifecycle may only be an observer of that same process, so a + * full shutdown here would terminate PostgreSQL underneath its live pool. + */ + await this.ownedBackendReleaseConnections(); + this.ownedBackendReleaseConnections = null; + } // Close any open SQLite handle from a prior legacy init(). if (this.db) { try { @@ -306,22 +318,30 @@ export class CentralCore extends EventEmitter { } /* - * FNXC:SqliteFinalRemoval 2026-06-26-10:55: - * The legacy non-backend (SQLite) CentralDatabase path is removed - * (VAL-REMOVAL-005). The CentralDatabase class body is deleted; constructing - * it would throw. In the runtime serve path, CentralCore is constructed - * before the backend is resolved, then attachBackendLayer() is called once - * the TaskStore's AsyncDataLayer is available (InProcessRuntime.start). - * - * Non-backend init() is now a graceful no-op: it creates the global dir but - * leaves this.db = null and marks the instance initialized. Data methods - * check `this.db` before use and return empty/degrade when null (see - * readPathsUseDb guard). The serve command and reconciliation loop proceed - * with empty results; once attachBackendLayer injects the AsyncDataLayer, - * init() re-runs in backend mode and bootstraps against PostgreSQL. + * FNXC:CentralPostgresCutover 2026-07-14-17:14: + * Layer-less CentralCore instances are common in project/node CLI commands + * and dashboard route fallbacks. A no-op initialization made every read + * empty and every write fail after CentralDatabase was removed. Bootstrap + * an unscoped PostgreSQL layer here so every public construction path uses + * the central schema even before a project TaskStore exists. */ await mkdir(this.globalDir, { recursive: true }); - this.initialized = true; + const { createCentralBackendLayer } = await import("./postgres/startup-factory.js"); + const backend = await createCentralBackendLayer({ globalSettingsDir: this.globalDir }); + try { + await asyncCentralCore.ensureBackendBootstrap(backend.asyncLayer); + (this as { asyncLayer: AsyncDataLayer | null }).asyncLayer = backend.asyncLayer; + this.ownedBackendShutdown = backend.shutdown; + this.ownedBackendReleaseConnections = backend.releaseConnections; + this.initialized = true; + } catch (error) { + /* + FNXC:PostgresResourceLifecycle 2026-07-14-18:02: + A layer-less CentralCore owns the central backend it creates. Bootstrap failure must release both its pool and embedded lifecycle before the rejected init escapes, because callers cannot close an instance that never initialized successfully. + */ + await backend.shutdown().catch(() => undefined); + throw error; + } } /** @@ -333,6 +353,10 @@ export class CentralCore extends EventEmitter { this.stopDiscovery(); } + await this.markLocalNodeOffline().catch((error) => { + console.warn("[central-core] Failed to persist local node offline during close", error); + }); + // FNXC:CentralCore 2026-06-26-12:30: In backend mode there is no SQLite // CentralDatabase to close; the shared connection pool is owned by the // TaskStore/startup factory. CentralCore does not close the pool. @@ -340,10 +364,29 @@ export class CentralCore extends EventEmitter { this.db.close(); this.db = null; } + if (this.ownedBackendShutdown) { + await this.ownedBackendShutdown(); + this.ownedBackendShutdown = null; + this.ownedBackendReleaseConnections = null; + (this as { asyncLayer: AsyncDataLayer | null }).asyncLayer = null; + } this.initialized = false; this.removeAllListeners(); } + /** Persist the local mesh node's terminal state before its backend closes. */ + async markLocalNodeOffline(): Promise { + if (!this.initialized) return; + /* + FNXC:PostgresResourceLifecycle 2026-07-14-18:42: + Mesh shutdown state must be committed before project engines release the PostgreSQL pool that CentralCore adopted. Keep this operation on the central authority so dashboard, server, and manager shutdown paths cannot reorder the write behind backend closure. + */ + const localNode = await this.getLocalNode(); + if (localNode && localNode.status !== "offline") { + await this.updateNode(localNode.id, { status: "offline" }); + } + } + /** * Check if the central infrastructure is initialized. */ diff --git a/packages/core/src/central-db.ts b/packages/core/src/central-db.ts index c900454911..deaa9583d4 100644 --- a/packages/core/src/central-db.ts +++ b/packages/core/src/central-db.ts @@ -7,10 +7,9 @@ * BEGIN IMMEDIATE + SAVEPOINT nested transactions, task-claim mutex) was the * central-project-registry data layer. The runtime CentralCore now delegates * ALL central data access to PostgreSQL via the async `AsyncDataLayer` - * (Drizzle, central schema) — see `async-central-core.ts`. The SQLite path is - * only reachable in non-backend mode (FUSION_NO_EMBEDDED_PG test/migrator - * fallback), and the mesh lease recovery path that constructed it in - * `in-process-runtime.ts` now skips construction in backend mode. + * (Drizzle, central schema) — see `async-central-core.ts`. The removed + * `FUSION_NO_EMBEDDED_PG` runtime fallback is rejected by startup; only the + * throwing compatibility type and explicit legacy migration readers remain. * * This module now re-exports the JSON utilities and `getDefaultCentralDbPath` * (still used by backup.ts and the onboard CLI), and provides a stub @@ -56,8 +55,7 @@ function throwSqliteRemoved(): never { * FNXC:SqliteFinalRemoval 2026-06-26-09:45: * The ~1090-line SQLite CentralDatabase body is DELETED. This stub preserves * the public method signatures (and the CentralClaimStore interface contract) - * so consumers (plugin-store.ts sync else-branch, in-process-runtime mesh - * lease fallback, quarantined tests) continue to type-check. Every method + * so legacy API consumers and migration-oriented tests continue to type-check. Every method * throws because the SQLite runtime is gone; production CentralCore runs in * backend mode and never reaches these. */ diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 7b5f1fb433..8d99507a63 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -4,24 +4,21 @@ * Manages CRUD operations for chat sessions and messages. * Provides event emission for dashboard reactivity. * - * Follows the same patterns as MissionStore: - * - EventEmitter for change notifications - * - SQLite for structured data storage - * - JSON columns for nested data + * Uses PostgreSQL through the project AsyncDataLayer and emits change events + * for dashboard reactivity. */ import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; -import type { Database } from "./db.js"; -import { fromJson, toJsonNullable } from "./db.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; import { sql } from "drizzle-orm"; +import { asc } from "drizzle-orm"; +import * as schema from "./postgres/schema/index.js"; import * as asyncChatStore from "./async-chat-store.js"; import type { ChatSession, ChatSessionStatus, ChatMessage, - ChatMessageRole, ChatAttachment, ChatMessageCreateInput, ChatSessionCreateInput, @@ -77,222 +74,17 @@ export interface ChatStoreEvents { "chat:room:messages:cleared": [payload: { roomId: string; deletedCount: number }]; } -// ── Row Interfaces ─────────────────────────────────────────────────── - -/** Database row shape for chat_sessions. */ -interface ChatSessionRow { - id: string; - agentId: string; - title: string | null; - status: string; - projectId: string | null; - modelProvider: string | null; - modelId: string | null; - thinkingLevel: string | null; - createdAt: string; - updatedAt: string; - cliSessionFile: string | null; - inFlightGeneration: string | null; - cliExecutorAdapterId: string | null; -} - -/** Database row shape for chat_messages. */ -interface ChatMessageRow { - id: string; - sessionId: string; - role: string; - content: string; - thinkingOutput: string | null; - metadata: string | null; - attachments: string | null; - createdAt: string; -} - -interface ChatRoomRow { - id: string; - name: string; - slug: string; - description: string | null; - projectId: string | null; - createdBy: string | null; - status: string; - thinkingLevel: string | null; - createdAt: string; - updatedAt: string; -} - -interface ChatRoomMemberRow { - roomId: string; - agentId: string; - role: string; - addedAt: string; -} - -interface ChatRoomMessageRow { - id: string; - roomId: string; - role: string; - content: string; - thinkingOutput: string | null; - metadata: string | null; - attachments: string | null; - senderAgentId: string | null; - mentions: string | null; - createdAt: string; -} - -interface ChatTokenUsageRow { - id: string; - sourceKind: string; - chatSessionId: string | null; - roomId: string | null; - messageId: string | null; - projectId: string | null; - agentId: string | null; - modelProvider: string | null; - modelId: string | null; - inputTokens: number; - outputTokens: number; - cachedTokens: number; - cacheWriteTokens: number; - totalTokens: number; - createdAt: string; -} - // ── ChatStore Class ───────────────────────────────────────────────── export class ChatStore extends EventEmitter { /** - * FNXC:ChatStore 2026-06-24-21:30: - * When non-null, the store is in backend (PostgreSQL) mode and delegates to - * the async helpers in async-chat-store.ts. The sync db is unused in this - * mode. This is the dual-path pattern for the chat system. + * FNXC:PostgresChatStore 2026-07-14-19:15: + * Chat persistence is PostgreSQL-only after the storage cutover. Requiring + * AsyncDataLayer at construction prevents a reachable SQLite fallback. */ - private readonly asyncLayer: AsyncDataLayer | null; - - constructor( - private fusionDir: string, - private db: Database | null, - options?: { asyncLayer?: AsyncDataLayer | null }, - ) { + constructor(private readonly asyncLayer: AsyncDataLayer) { super(); this.setMaxListeners(100); - this.asyncLayer = options?.asyncLayer ?? null; - } - - /** True when the store is backed by PostgreSQL (AsyncDataLayer present). */ - private get backendMode(): boolean { - return this.asyncLayer !== null; - } - - /** - * FNXC:ChatStore 2026-06-24-21:35: - * Asserts the sync SQLite database is available. In backend mode this is - * never called (the async branch returns first). - */ - private syncDb(): Database { - if (!this.db) { - throw new Error("ChatStore: sync Database is null (backend mode requires asyncLayer)"); - } - return this.db; - } - - // ── Row-to-Object Converters ─────────────────────────────────────── - - /** - * Convert a database row to a ChatSession object. - */ - private rowToSession(row: ChatSessionRow): ChatSession { - return { - id: row.id, - agentId: row.agentId, - title: row.title ?? null, - status: row.status as ChatSessionStatus, - projectId: row.projectId ?? null, - modelProvider: row.modelProvider ?? null, - modelId: row.modelId ?? null, - thinkingLevel: row.thinkingLevel ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - cliSessionFile: row.cliSessionFile ?? null, - inFlightGeneration: fromJson(row.inFlightGeneration) ?? null, - cliExecutorAdapterId: row.cliExecutorAdapterId ?? null, - }; - } - - /** - * Convert a database row to a ChatMessage object. - */ - private rowToMessage(row: ChatMessageRow): ChatMessage { - return { - id: row.id, - sessionId: row.sessionId, - role: row.role as ChatMessageRole, - content: row.content, - thinkingOutput: row.thinkingOutput ?? null, - metadata: fromJson>(row.metadata) ?? null, - attachments: fromJson(row.attachments) ?? undefined, - createdAt: row.createdAt, - }; - } - - private rowToRoom(row: ChatRoomRow): ChatRoom { - return { - id: row.id, - name: row.name, - slug: row.slug, - description: row.description ?? null, - projectId: row.projectId ?? null, - createdBy: row.createdBy ?? null, - status: row.status as ChatRoomStatus, - thinkingLevel: row.thinkingLevel ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; - } - - private rowToRoomMember(row: ChatRoomMemberRow): ChatRoomMember { - return { - roomId: row.roomId, - agentId: row.agentId, - role: row.role as RoomMemberRole, - addedAt: row.addedAt, - }; - } - - private rowToRoomMessage(row: ChatRoomMessageRow): ChatRoomMessage { - return { - id: row.id, - roomId: row.roomId, - role: row.role as ChatMessageRole, - content: row.content, - thinkingOutput: row.thinkingOutput ?? null, - metadata: fromJson>(row.metadata) ?? null, - attachments: fromJson(row.attachments) ?? undefined, - senderAgentId: row.senderAgentId ?? null, - mentions: fromJson(row.mentions) ?? [], - createdAt: row.createdAt, - }; - } - - private rowToTokenUsage(row: ChatTokenUsageRow): ChatTokenUsageRecord { - return { - id: row.id, - sourceKind: row.sourceKind as ChatTokenUsageSourceKind, - chatSessionId: row.chatSessionId ?? null, - roomId: row.roomId ?? null, - messageId: row.messageId ?? null, - projectId: row.projectId ?? null, - agentId: row.agentId ?? null, - modelProvider: row.modelProvider ?? null, - modelId: row.modelId ?? null, - inputTokens: row.inputTokens ?? 0, - outputTokens: row.outputTokens ?? 0, - cachedTokens: row.cachedTokens ?? 0, - cacheWriteTokens: row.cacheWriteTokens ?? 0, - totalTokens: row.totalTokens ?? 0, - createdAt: row.createdAt, - }; } private normalizeRoomName(name: string): string { @@ -316,32 +108,9 @@ export class ChatStore extends EventEmitter { * @returns The created session */ async createSession(input: ChatSessionCreateInput): Promise { - if (this.backendMode) { - const now = new Date().toISOString(); - const session: ChatSession = { - id: `chat-${randomUUID().slice(0, 8)}`, - agentId: input.agentId, - title: input.title ?? null, - status: "active", - projectId: input.projectId ?? null, - modelProvider: input.modelProvider ?? null, - modelId: input.modelId ?? null, - thinkingLevel: input.thinkingLevel ?? null, - createdAt: now, - updatedAt: now, - cliSessionFile: null, - inFlightGeneration: null, - cliExecutorAdapterId: input.cliExecutorAdapterId ?? null, - }; - const created = await asyncChatStore.createChatSession(this.asyncLayer!.db, session); - this.emit("chat:session:created", created); - return created; - } const now = new Date().toISOString(); - const id = `chat-${randomUUID().slice(0, 8)}`; - const session: ChatSession = { - id, + id: `chat-${randomUUID().slice(0, 8)}`, agentId: input.agentId, title: input.title ?? null, status: "active", @@ -355,28 +124,9 @@ export class ChatStore extends EventEmitter { inFlightGeneration: null, cliExecutorAdapterId: input.cliExecutorAdapterId ?? null, }; - - this.syncDb().prepare(` - INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, thinkingLevel, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - session.id, - session.agentId, - session.title, - session.status, - session.projectId, - session.modelProvider, - session.modelId, - session.thinkingLevel, - session.createdAt, - session.updatedAt, - null, - session.cliExecutorAdapterId, - ); - - this.syncDb().bumpLastModified(); - this.emit("chat:session:created", session); - return session; + const created = await asyncChatStore.createChatSession(this.asyncLayer.db, session); + this.emit("chat:session:created", created); + return created; } /** @@ -386,12 +136,7 @@ export class ChatStore extends EventEmitter { * @returns The session, or undefined if not found */ async getSession(id: string): Promise { - if (this.backendMode) { - return asyncChatStore.getChatSession(this.asyncLayer!.db, id); - } - const row = this.syncDb().prepare("SELECT * FROM chat_sessions WHERE id = ?").get(id) as unknown as ChatSessionRow | undefined; - if (!row) return undefined; - return this.rowToSession(row); + return asyncChatStore.getChatSession(this.asyncLayer.db, id); } /** @@ -405,32 +150,7 @@ export class ChatStore extends EventEmitter { agentId?: string; status?: ChatSessionStatus; }): Promise { - if (this.backendMode) { - return asyncChatStore.listChatSessions(this.asyncLayer!.db, options); - } - const whereClauses: string[] = []; - const params: string[] = []; - - if (options?.projectId) { - whereClauses.push("projectId = ?"); - params.push(options.projectId); - } - if (options?.agentId) { - whereClauses.push("agentId = ?"); - params.push(options.agentId); - } - if (options?.status) { - whereClauses.push("status = ?"); - params.push(options.status); - } - - const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; - - const rows = this.syncDb().prepare(` - SELECT * FROM chat_sessions ${whereSql} ORDER BY updatedAt DESC - `).all(...params); - - return (rows as unknown as ChatSessionRow[]).map((row) => this.rowToSession(row)); + return asyncChatStore.listChatSessions(this.asyncLayer.db, options); } /** @@ -446,62 +166,7 @@ export class ChatStore extends EventEmitter { modelProvider?: string; modelId?: string; }): Promise { - if (this.backendMode) { - return asyncChatStore.findLatestActiveChatSessionForTarget(this.asyncLayer!.db, options); - } - const normalizedAgentId = options.agentId.trim(); - if (!normalizedAgentId) { - return undefined; - } - - const normalizedProvider = options.modelProvider?.trim(); - const normalizedModelId = options.modelId?.trim(); - - if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) { - throw new Error("modelProvider and modelId must both be provided together, or neither"); - } - - const whereClauses: string[] = ["status = ?", "agentId = ?"]; - const baseParams: string[] = ["active", normalizedAgentId]; - - if (options.projectId && options.projectId.trim()) { - whereClauses.push("projectId = ?"); - baseParams.push(options.projectId.trim()); - } - - const baseWhereSql = whereClauses.join(" AND "); - - if (normalizedProvider && normalizedModelId) { - const row = this.syncDb().prepare(` - SELECT * FROM chat_sessions - WHERE ${baseWhereSql} AND modelProvider = ? AND modelId = ? - ORDER BY updatedAt DESC - LIMIT 1 - `).get(...baseParams, normalizedProvider, normalizedModelId) as ChatSessionRow | undefined; - return row ? this.rowToSession(row) : undefined; - } - - const modelLessRow = this.syncDb().prepare(` - SELECT * FROM chat_sessions - WHERE ${baseWhereSql} - AND COALESCE(TRIM(modelProvider), '') = '' - AND COALESCE(TRIM(modelId), '') = '' - ORDER BY updatedAt DESC - LIMIT 1 - `).get(...baseParams) as ChatSessionRow | undefined; - - if (modelLessRow) { - return this.rowToSession(modelLessRow); - } - - const fallbackRow = this.syncDb().prepare(` - SELECT * FROM chat_sessions - WHERE ${baseWhereSql} - ORDER BY updatedAt DESC - LIMIT 1 - `).get(...baseParams) as ChatSessionRow | undefined; - - return fallbackRow ? this.rowToSession(fallbackRow) : undefined; + return asyncChatStore.findLatestActiveChatSessionForTarget(this.asyncLayer.db, options); } /** @@ -512,56 +177,8 @@ export class ChatStore extends EventEmitter { * @returns The updated session, or undefined if not found */ async updateSession(id: string, input: ChatSessionUpdateInput): Promise { - if (this.backendMode) { - const updated = await asyncChatStore.updateChatSession(this.asyncLayer!.db, id, input); - if (updated) this.emit("chat:session:updated", updated); - return updated; - } - const existing = await this.getSession(id); - if (!existing) return undefined; - - const now = new Date().toISOString(); - const setClauses: string[] = ["updatedAt = ?"]; - const params: (string | null)[] = [now]; - - if (input.title !== undefined) { - setClauses.push("title = ?"); - params.push(input.title); - } - if (input.status !== undefined) { - setClauses.push("status = ?"); - params.push(input.status); - } - if (input.modelProvider !== undefined) { - setClauses.push("modelProvider = ?"); - params.push(input.modelProvider); - } - if (input.modelId !== undefined) { - setClauses.push("modelId = ?"); - params.push(input.modelId); - } - /* - * FNXC:Chat-ModelSwitch 2026-07-12-00:00: - * Existing direct chats must be able to retarget to a real agent without recreating the conversation. Keep this independent from modelProvider/modelId so omitted model keys remain untouched. - */ - if (input.agentId !== undefined) { - setClauses.push("agentId = ?"); - params.push(input.agentId); - } - if (input.thinkingLevel !== undefined) { - setClauses.push("thinkingLevel = ?"); - params.push(input.thinkingLevel); - } - - params.push(id); - - this.syncDb().prepare(` - UPDATE chat_sessions SET ${setClauses.join(", ")} WHERE id = ? - `).run(...params); - - const updated = (await this.getSession(id))!; - this.syncDb().bumpLastModified(); - this.emit("chat:session:updated", updated); + const updated = await asyncChatStore.updateChatSession(this.asyncLayer.db, id, input); + if (updated) this.emit("chat:session:updated", updated); return updated; } @@ -588,14 +205,8 @@ export class ChatStore extends EventEmitter { * @param cliSessionFile - Absolute path to the session file, or null to clear */ async setCliSessionFile(id: string, cliSessionFile: string | null): Promise { - if (this.backendMode) { - await asyncChatStore.setCliSessionFile(this.asyncLayer!.db, id, cliSessionFile); - return; - } - this.syncDb() - .prepare("UPDATE chat_sessions SET cliSessionFile = ? WHERE id = ?") - .run(cliSessionFile, id); - this.syncDb().bumpLastModified(); + await asyncChatStore.setCliSessionFile(this.asyncLayer.db, id, cliSessionFile); + return; } /** @@ -608,38 +219,14 @@ export class ChatStore extends EventEmitter { * @param adapterId - cli-agent adapter id, or null to revert to the provider path */ async setCliExecutorAdapterId(id: string, adapterId: string | null): Promise { - if (this.backendMode) { - const updated = await asyncChatStore.setCliExecutorAdapterId(this.asyncLayer!.db, id, adapterId); - if (updated) this.emit("chat:session:updated", updated); - return updated; - } - const existing = await this.getSession(id); - if (!existing) return undefined; - this.syncDb() - .prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?") - .run(adapterId, new Date().toISOString(), id); - this.syncDb().bumpLastModified(); - const updated = (await this.getSession(id))!; - this.emit("chat:session:updated", updated); + const updated = await asyncChatStore.setCliExecutorAdapterId(this.asyncLayer.db, id, adapterId); + if (updated) this.emit("chat:session:updated", updated); return updated; } async setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): Promise { - if (this.backendMode) { - const updated = await asyncChatStore.setInFlightGeneration(this.asyncLayer!.db, id, inFlightGeneration); - if (updated) this.emit("chat:session:updated", updated); - return updated; - } - const existing = await this.getSession(id); - if (!existing) return undefined; - - this.syncDb() - .prepare("UPDATE chat_sessions SET inFlightGeneration = ? WHERE id = ?") - .run(toJsonNullable(inFlightGeneration), id); - - const updated = (await this.getSession(id))!; - this.syncDb().bumpLastModified(); - this.emit("chat:session:updated", updated); + const updated = await asyncChatStore.setInFlightGeneration(this.asyncLayer.db, id, inFlightGeneration); + if (updated) this.emit("chat:session:updated", updated); return updated; } @@ -651,18 +238,9 @@ export class ChatStore extends EventEmitter { * @returns true if deleted, false if not found */ async deleteSession(id: string): Promise { - if (this.backendMode) { - const deleted = await asyncChatStore.deleteChatSession(this.asyncLayer!.db, id); - if (deleted) this.emit("chat:session:deleted", id); - return deleted; - } - const existing = await this.getSession(id); - if (!existing) return false; - - this.syncDb().prepare("DELETE FROM chat_sessions WHERE id = ?").run(id); - this.syncDb().bumpLastModified(); - this.emit("chat:session:deleted", id); - return true; + const deleted = await asyncChatStore.deleteChatSession(this.asyncLayer.db, id); + if (deleted) this.emit("chat:session:deleted", id); + return deleted; } async deleteSessionsForAgentId(agentId: string, options?: { projectId?: string | null }): Promise { @@ -697,86 +275,27 @@ export class ChatStore extends EventEmitter { if (!session) { throw new Error(`Chat session ${sessionId} not found`); } - - if (this.backendMode) { - const now = new Date().toISOString(); - const message: ChatMessage = { - id: `msg-${randomUUID().slice(0, 8)}`, - sessionId, - role: input.role, - content: input.content, - thinkingOutput: input.thinkingOutput ?? null, - metadata: input.metadata ?? null, - attachments: input.attachments, - createdAt: now, - }; - const created = await asyncChatStore.addChatMessage(this.asyncLayer!.db, message); - this.emit("chat:message:added", created); - return created; - } - const now2 = new Date().toISOString(); - const id = `msg-${randomUUID().slice(0, 8)}`; - + const now = new Date().toISOString(); const message: ChatMessage = { - id, + id: `msg-${randomUUID().slice(0, 8)}`, sessionId, role: input.role, content: input.content, thinkingOutput: input.thinkingOutput ?? null, metadata: input.metadata ?? null, attachments: input.attachments, - createdAt: now2, + createdAt: now, }; - - this.syncDb().prepare(` - INSERT INTO chat_messages (id, sessionId, role, content, thinkingOutput, metadata, attachments, createdAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - message.id, - message.sessionId, - message.role, - message.content, - message.thinkingOutput, - toJsonNullable(message.metadata), - toJsonNullable(message.attachments), - message.createdAt, - ); - - // Update session's updatedAt timestamp - this.syncDb().prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now2, sessionId); - - this.syncDb().bumpLastModified(); - this.emit("chat:message:added", message); - return message; + const created = await asyncChatStore.addChatMessage(this.asyncLayer.db, message); + this.emit("chat:message:added", created); + return created; } /** * Append a file attachment metadata record to an existing message. */ async addMessageAttachment(sessionId: string, messageId: string, attachment: ChatAttachment): Promise { - if (this.backendMode) { - const updated = await asyncChatStore.addChatMessageAttachment(this.asyncLayer!.db, sessionId, messageId, attachment); - this.emit("chat:message:updated", updated); - return updated; - } - const message = await this.getMessage(messageId); - if (!message || message.sessionId !== sessionId) { - throw new Error(`Message ${messageId} not found in session ${sessionId}`); - } - - const updatedAttachments = [...(message.attachments ?? []), attachment]; - this.syncDb().prepare(` - UPDATE chat_messages - SET attachments = ? - WHERE id = ? - `).run(toJsonNullable(updatedAttachments), messageId); - - const updated = await this.getMessage(messageId); - if (!updated) { - throw new Error(`Failed to update message ${messageId}`); - } - - this.syncDb().bumpLastModified(); + const updated = await asyncChatStore.addChatMessageAttachment(this.asyncLayer.db, sessionId, messageId, attachment); this.emit("chat:message:updated", updated); return updated; } @@ -789,31 +308,7 @@ export class ChatStore extends EventEmitter { * @returns Array of messages ordered by createdAt ASC (default) or DESC */ async getMessages(sessionId: string, filter?: ChatMessagesFilter): Promise { - if (this.backendMode) { - return asyncChatStore.getChatMessages(this.asyncLayer!.db, sessionId, filter); - } - const whereClauses: string[] = ["sessionId = ?"]; - const params: (string | number)[] = [sessionId]; - - // Cursor-based pagination: only return messages created before the cursor - if (filter?.before) { - whereClauses.push("createdAt < ?"); - params.push(filter.before); - } - - const whereSql = whereClauses.join(" AND "); - const limit = filter?.limit ?? 100; - const offset = filter?.offset ?? 0; - const order = filter?.order === "desc" ? "DESC" : "ASC"; - - const rows = this.syncDb().prepare(` - SELECT * FROM chat_messages - WHERE ${whereSql} - ORDER BY createdAt ${order} - LIMIT ? OFFSET ? - `).all(...params, limit, offset); - - return (rows as unknown as ChatMessageRow[]).map((row) => this.rowToMessage(row)); + return asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, filter); } /** @@ -823,12 +318,7 @@ export class ChatStore extends EventEmitter { * @returns The message, or undefined if not found */ async getMessage(id: string): Promise { - if (this.backendMode) { - return asyncChatStore.getChatMessage(this.asyncLayer!.db, id); - } - const row = this.syncDb().prepare("SELECT * FROM chat_messages WHERE id = ?").get(id) as unknown as ChatMessageRow | undefined; - if (!row) return undefined; - return this.rowToMessage(row); + return asyncChatStore.getChatMessage(this.asyncLayer.db, id); } /** @@ -839,52 +329,11 @@ export class ChatStore extends EventEmitter { * @returns Map of sessionId -> latest ChatMessage for that session */ async getLastMessageForSessions(sessionIds: string[]): Promise> { - if (this.backendMode) { - return asyncChatStore.getLastMessageForSessions(this.asyncLayer!.db, sessionIds); - } - if (!sessionIds || sessionIds.length === 0) { - return new Map(); - } - - // Create placeholders for the IN clause - const placeholders = sessionIds.map(() => "?").join(", "); - - // Use a subquery to get the latest message per session using MAX(createdAt) - // Then join back to get the full message row - const rows = this.syncDb().prepare(` - SELECT cm.* FROM chat_messages cm - INNER JOIN ( - SELECT sessionId, MAX(createdAt) as maxCreatedAt - FROM chat_messages - WHERE sessionId IN (${placeholders}) - GROUP BY sessionId - ) latest ON cm.sessionId = latest.sessionId AND cm.createdAt = latest.maxCreatedAt - `).all(...sessionIds); - - const result = new Map(); - for (const row of rows as unknown as ChatMessageRow[]) { - const message = this.rowToMessage(row); - result.set(message.sessionId, message); - } - return result; + return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds); } - hasMessages(sessionId: string): boolean { - if (this.backendMode) { - // Async path not available for sync query; callers in backend mode should use getMessages - return false; - } - const row = this.syncDb().prepare("SELECT 1 FROM chat_messages WHERE sessionId = ? LIMIT 1").get(sessionId) as { 1: number } | undefined; - return Boolean(row); - } - - /** - * Escape a raw search term for safe use inside a SQL `LIKE ... ESCAPE '\'` pattern. - * Escapes the LIKE wildcard characters (`%`, `_`) and the escape character itself (`\`) - * so a literal user-typed `%`/`_` is matched literally instead of acting as a wildcard. - */ - private escapeLikePattern(raw: string): string { - return raw.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + async hasMessages(sessionId: string): Promise { + return (await asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, { limit: 1 })).length > 0; } /** @@ -913,37 +362,7 @@ export class ChatStore extends EventEmitter { if (!trimmed || !sessionIds || sessionIds.length === 0) { return new Map(); } - - if (this.backendMode) { - return asyncChatStore.searchChatSessionsByMessageContent(this.asyncLayer!.db, trimmed, sessionIds); - } - - const escaped = this.escapeLikePattern(trimmed); - const pattern = `%${escaped}%`; - const placeholders = sessionIds.map(() => "?").join(", "); - - // Single bounded query: find the most recent matching message per session via a - // GROUP BY + join-back, avoiding N+1 per-session queries. Ties on createdAt (common in - // fast test/bulk-insert scenarios where multiple messages share a millisecond timestamp) - // are broken by SQLite's implicit rowid, which tracks insertion order. - const rows = this.syncDb().prepare(` - SELECT cm.* FROM chat_messages cm - INNER JOIN ( - SELECT sessionId, MAX(rowid) as maxRowid - FROM chat_messages - WHERE sessionId IN (${placeholders}) AND content LIKE ? ESCAPE '\\' - GROUP BY sessionId - ) matched ON cm.sessionId = matched.sessionId AND cm.rowid = matched.maxRowid - `).all(...sessionIds, pattern); - - const result = new Map(); - for (const row of rows as unknown as ChatMessageRow[]) { - const message = this.rowToMessage(row); - if (result.has(message.sessionId)) continue; - const content = message.content || ""; - result.set(message.sessionId, content.length > 100 ? content.slice(0, 100) + "…" : content); - } - return result; + return asyncChatStore.searchChatSessionsByMessageContent(this.asyncLayer.db, trimmed, sessionIds); } /** @@ -953,38 +372,15 @@ export class ChatStore extends EventEmitter { * @returns true if deleted, false if not found */ async deleteMessage(id: string): Promise { - if (this.backendMode) { - const existing = await asyncChatStore.getChatMessage(this.asyncLayer!.db, id); - if (!existing) return false; - const deleted = await asyncChatStore.deleteChatMessage(this.asyncLayer!.db, id); - if (deleted) { - this.emit("chat:message:deleted", id); - const updatedSession = await this.getSession(existing.sessionId); - if (updatedSession) this.emit("chat:session:updated", updatedSession); - } - return deleted; - } - const existing = await this.getMessage(id); + const existing = await asyncChatStore.getChatMessage(this.asyncLayer.db, id); if (!existing) return false; - - const sessionId = existing.sessionId; - const now = new Date().toISOString(); - - this.syncDb().prepare("DELETE FROM chat_messages WHERE id = ?").run(id); - - // Update the parent session's updatedAt timestamp - this.syncDb().prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now, sessionId); - - this.syncDb().bumpLastModified(); - this.emit("chat:message:deleted", id); - - // Emit session:updated for the parent session - const updatedSession = await this.getSession(sessionId); - if (updatedSession) { - this.emit("chat:session:updated", updatedSession); + const deleted = await asyncChatStore.deleteChatMessage(this.asyncLayer.db, id); + if (deleted) { + this.emit("chat:message:deleted", id); + const updatedSession = await this.getSession(existing.sessionId); + if (updatedSession) this.emit("chat:session:updated", updatedSession); } - - return true; + return deleted; } /** @@ -994,77 +390,23 @@ export class ChatStore extends EventEmitter { * transcript here AND from the model's resumable pi session context (rewound separately by * ChatManager.rewindSessionForEdit) — so future responses are not biased by discarded turns. * - * Ordering is resolved by (createdAt ASC, rowid ASC) rather than createdAt alone, since - * multiple messages can share an identical createdAt timestamp (same-millisecond inserts); - * rowid is SQLite's implicit monotonic insertion-order tiebreaker, guaranteeing the edited - * message and every later message (in true insertion order) are always included, with no - * sibling straggler surviving the truncation. The Postgres backend has no rowid, so it - * tiebreaks on (createdAt ASC, id ASC) — deterministic, matching getLastMessageForSessions. + * Ordering uses (createdAt ASC, id ASC), so same-millisecond messages have a + * deterministic PostgreSQL tiebreaker matching getLastMessageForSessions. * * @param sessionId - Parent session ID * @param fromMessageId - Id of the earliest message to delete (inclusive) * @returns deletedIds (in ASC order) and retained messages (pre-edit history, ASC order) */ async deleteMessagesFrom(sessionId: string, fromMessageId: string): Promise<{ deletedIds: string[]; retained: ChatMessage[] }> { - if (this.backendMode) { - const result = await asyncChatStore.deleteChatMessagesFrom(this.asyncLayer!.db, sessionId, fromMessageId); - if (result.deletedIds.length > 0) { - for (const id of result.deletedIds) { - this.emit("chat:message:deleted", id); - } - const updatedSession = await this.getSession(sessionId); - if (updatedSession) this.emit("chat:session:updated", updatedSession); + const result = await asyncChatStore.deleteChatMessagesFrom(this.asyncLayer.db, sessionId, fromMessageId); + if (result.deletedIds.length > 0) { + for (const id of result.deletedIds) { + this.emit("chat:message:deleted", id); } - return result; + const updatedSession = await this.getSession(sessionId); + if (updatedSession) this.emit("chat:session:updated", updatedSession); } - - const target = this.syncDb().prepare( - "SELECT id, sessionId, rowid as rowid_ FROM chat_messages WHERE id = ?", - ).get(fromMessageId) as { id: string; sessionId: string; rowid_: number } | undefined; - - if (!target || target.sessionId !== sessionId) { - return { deletedIds: [], retained: await this.getMessages(sessionId) }; - } - - // Ordered id list for the session (createdAt ASC, rowid ASC tiebreak) so we can - // deterministically split retained-vs-deleted around the target message. - const orderedRows = this.syncDb().prepare( - "SELECT id, rowid as rowid_ FROM chat_messages WHERE sessionId = ? ORDER BY createdAt ASC, rowid_ ASC", - ).all(sessionId) as { id: string; rowid_: number }[]; - - const targetIndex = orderedRows.findIndex((row) => row.id === fromMessageId); - if (targetIndex === -1) { - return { deletedIds: [], retained: await this.getMessages(sessionId) }; - } - - const retainedIds = orderedRows.slice(0, targetIndex).map((row) => row.id); - const deletedIds = orderedRows.slice(targetIndex).map((row) => row.id); - - const retained: ChatMessage[] = []; - for (const id of retainedIds) { - const message = await this.getMessage(id); - if (message) retained.push(message); - } - - if (deletedIds.length === 0) { - return { deletedIds: [], retained }; - } - - const now = new Date().toISOString(); - const placeholders = deletedIds.map(() => "?").join(", "); - this.syncDb().prepare(`DELETE FROM chat_messages WHERE id IN (${placeholders})`).run(...deletedIds); - this.syncDb().prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now, sessionId); - this.syncDb().bumpLastModified(); - - for (const id of deletedIds) { - this.emit("chat:message:deleted", id); - } - const updatedSession = await this.getSession(sessionId); - if (updatedSession) { - this.emit("chat:session:updated", updatedSession); - } - - return { deletedIds, retained }; + return result; } /** @@ -1075,30 +417,7 @@ export class ChatStore extends EventEmitter { * is what lets a later edit rewind losslessly via SessionManager.branch()/resetLeaf(). */ async updateMessageMetadata(messageId: string, metadata: Record | null, options?: { merge?: boolean }): Promise { - if (this.backendMode) { - const updated = await asyncChatStore.updateChatMessageMetadata(this.asyncLayer!.db, messageId, metadata, options); - this.emit("chat:message:updated", updated); - return updated; - } - - const existing = await this.getMessage(messageId); - if (!existing) { - throw new Error(`Message ${messageId} not found`); - } - - const merge = options?.merge !== false; - const nextMetadata = metadata === null - ? (merge ? existing.metadata : null) - : (merge ? { ...(existing.metadata ?? {}), ...metadata } : metadata); - - this.syncDb().prepare("UPDATE chat_messages SET metadata = ? WHERE id = ?").run(toJsonNullable(nextMetadata), messageId); - - const updated = await this.getMessage(messageId); - if (!updated) { - throw new Error(`Failed to update message ${messageId}`); - } - - this.syncDb().bumpLastModified(); + const updated = await asyncChatStore.updateChatMessageMetadata(this.asyncLayer.db, messageId, metadata, options); this.emit("chat:message:updated", updated); return updated; } @@ -1125,300 +444,89 @@ export class ChatStore extends EventEmitter { }; const memberIds = [...new Set((input.memberAgentIds ?? []).map((id) => id.trim()).filter(Boolean))]; - - if (this.backendMode) { - const result = await asyncChatStore.createChatRoom(this.asyncLayer!, room, memberIds); - this.emit("chat:room:created", result.room); - for (const member of result.members) { - this.emit("chat:room:member:added", member); - } - return result.room; - } - - const existingSlug = this.syncDb().prepare( - "SELECT id FROM chat_rooms WHERE projectId IS ? AND slug = ?", - ).get(room.projectId, room.slug) as { id: string } | undefined; - if (existingSlug) { - throw new Error(`Room slug ${room.slug} already exists in this project`); - } - - this.syncDb().transaction(() => { - this.syncDb().prepare(` - INSERT INTO chat_rooms (id, name, slug, description, projectId, createdBy, status, thinkingLevel, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - room.id, - room.name, - room.slug, - room.description, - room.projectId, - room.createdBy, - room.status, - room.thinkingLevel, - room.createdAt, - room.updatedAt, - ); - - const insertMember = this.syncDb().prepare(` - INSERT INTO chat_room_members (roomId, agentId, role, addedAt) - VALUES (?, ?, ?, ?) - `); - for (const agentId of memberIds) { - const role: RoomMemberRole = room.createdBy !== null && agentId === room.createdBy ? "owner" : "member"; - insertMember.run(room.id, agentId, role, now); - } - }); - - const insertedMembers = await this.listRoomMembers(room.id); - this.syncDb().bumpLastModified(); - this.emit("chat:room:created", room); - for (const member of insertedMembers) { + const result = await asyncChatStore.createChatRoom(this.asyncLayer, room, memberIds); + this.emit("chat:room:created", result.room); + for (const member of result.members) { this.emit("chat:room:member:added", member); } - return room; + return result.room; } async getRoom(id: string): Promise { - if (this.backendMode) { - return asyncChatStore.getChatRoom(this.asyncLayer!.db, id); - } - const row = this.syncDb().prepare("SELECT * FROM chat_rooms WHERE id = ?").get(id) as ChatRoomRow | undefined; - return row ? this.rowToRoom(row) : undefined; + return asyncChatStore.getChatRoom(this.asyncLayer.db, id); } async getRoomBySlug(projectId: string | null, slug: string): Promise { - if (this.backendMode) { - return asyncChatStore.getChatRoomBySlug(this.asyncLayer!.db, projectId, slug); - } - const row = this.syncDb().prepare("SELECT * FROM chat_rooms WHERE projectId IS ? AND slug = ?").get(projectId, slug) as ChatRoomRow | undefined; - return row ? this.rowToRoom(row) : undefined; + return asyncChatStore.getChatRoomBySlug(this.asyncLayer.db, projectId, slug); } async listRooms(options?: { projectId?: string; status?: ChatRoomStatus }): Promise { - if (this.backendMode) { - return asyncChatStore.listChatRooms(this.asyncLayer!.db, options); - } - const whereClauses: string[] = []; - const params: string[] = []; - if (options?.projectId) { - whereClauses.push("projectId = ?"); - params.push(options.projectId); - } - if (options?.status) { - whereClauses.push("status = ?"); - params.push(options.status); - } - const whereSql = whereClauses.length ? `WHERE ${whereClauses.join(" AND ")}` : ""; - const rows = this.syncDb().prepare(`SELECT * FROM chat_rooms ${whereSql} ORDER BY updatedAt DESC`).all(...params) as ChatRoomRow[]; - return rows.map((row) => this.rowToRoom(row)); + return asyncChatStore.listChatRooms(this.asyncLayer.db, options); } async updateRoom(id: string, input: ChatRoomUpdateInput): Promise { - if (this.backendMode) { - // Build slug/name from the input mirroring the sync path. - let updateInput: Parameters[2] = {}; - if (input.name !== undefined) { - const normalizedName = this.normalizeRoomName(input.name); - if (!normalizedName) throw new Error("Room name cannot be empty"); - const slug = this.buildRoomSlug(normalizedName); - if (!slug) throw new Error("Room name must include letters or numbers"); - const existing = await this.getRoom(id); - if (existing) { - const slugConflict = await asyncChatStore.getChatRoomBySlug(this.asyncLayer!.db, existing.projectId, slug); - if (slugConflict && slugConflict.id !== id) { - throw new Error(`Room slug ${slug} already exists in this project`); - } - } - updateInput = { name: normalizedName, slug }; - } - if (input.description !== undefined) updateInput.description = input.description; - if (input.status !== undefined) updateInput.status = input.status; - const updated = await asyncChatStore.updateChatRoom(this.asyncLayer!.db, id, updateInput); - if (updated) this.emit("chat:room:updated", updated); - return updated; - } - const existing = await this.getRoom(id); - if (!existing) return undefined; - - const now = new Date().toISOString(); - const setClauses: string[] = ["updatedAt = ?"]; - const params: Array = [now]; - + // Build slug/name from the input mirroring the sync path. + let updateInput: Parameters[2] = {}; if (input.name !== undefined) { const normalizedName = this.normalizeRoomName(input.name); if (!normalizedName) throw new Error("Room name cannot be empty"); const slug = this.buildRoomSlug(normalizedName); if (!slug) throw new Error("Room name must include letters or numbers"); - - const existingSlug = this.syncDb().prepare( - "SELECT id FROM chat_rooms WHERE projectId IS ? AND slug = ? AND id != ?", - ).get(existing.projectId, slug, id) as { id: string } | undefined; - if (existingSlug) { - throw new Error(`Room slug ${slug} already exists in this project`); + const existing = await this.getRoom(id); + if (existing) { + const slugConflict = await asyncChatStore.getChatRoomBySlug(this.asyncLayer.db, existing.projectId, slug); + if (slugConflict && slugConflict.id !== id) { + throw new Error(`Room slug ${slug} already exists in this project`); + } } - - setClauses.push("name = ?", "slug = ?"); - params.push(normalizedName, slug); + updateInput = { name: normalizedName, slug }; } - if (input.description !== undefined) { - setClauses.push("description = ?"); - params.push(input.description); - } - if (input.status !== undefined) { - setClauses.push("status = ?"); - params.push(input.status); - } - if (input.thinkingLevel !== undefined) { - setClauses.push("thinkingLevel = ?"); - params.push(input.thinkingLevel); - } - - params.push(id); - this.syncDb().prepare(`UPDATE chat_rooms SET ${setClauses.join(", ")} WHERE id = ?`).run(...params); - - const updated = (await this.getRoom(id))!; - this.syncDb().bumpLastModified(); - this.emit("chat:room:updated", updated); + if (input.description !== undefined) updateInput.description = input.description; + if (input.status !== undefined) updateInput.status = input.status; + const updated = await asyncChatStore.updateChatRoom(this.asyncLayer.db, id, updateInput); + if (updated) this.emit("chat:room:updated", updated); return updated; } async deleteRoom(id: string): Promise { - if (this.backendMode) { - const deleted = await asyncChatStore.deleteChatRoom(this.asyncLayer!.db, id); - if (deleted) this.emit("chat:room:deleted", id); - return deleted; - } - const existing = await this.getRoom(id); - if (!existing) return false; - - this.syncDb().prepare("DELETE FROM chat_rooms WHERE id = ?").run(id); - this.syncDb().bumpLastModified(); - this.emit("chat:room:deleted", id); - return true; + const deleted = await asyncChatStore.deleteChatRoom(this.asyncLayer.db, id); + if (deleted) this.emit("chat:room:deleted", id); + return deleted; } async cleanupOldChats(maxAgeMs: number): Promise<{ sessionsDeleted: number; roomsDeleted: number }> { - if (this.backendMode) { - const result = await asyncChatStore.cleanupOldChats(this.asyncLayer!.db, maxAgeMs); - for (const sessionId of result.deletedSessionIds) { - this.emit("chat:session:deleted", sessionId); - } - for (const roomId of result.deletedRoomIds) { - this.emit("chat:room:deleted", roomId); - } - return { sessionsDeleted: result.sessionsDeleted, roomsDeleted: result.roomsDeleted }; - } - if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) { - return { sessionsDeleted: 0, roomsDeleted: 0 }; - } - - const cutoff = new Date(Date.now() - maxAgeMs).toISOString(); - - const result = this.syncDb().transaction(() => { - const staleSessionRows = this.syncDb().prepare("SELECT id FROM chat_sessions WHERE updatedAt < ?").all(cutoff) as Array<{ id: string }>; - const staleRoomRows = this.syncDb().prepare("SELECT id FROM chat_rooms WHERE updatedAt < ?").all(cutoff) as Array<{ id: string }>; - - if (staleSessionRows.length > 0) { - this.syncDb().prepare("DELETE FROM chat_sessions WHERE updatedAt < ?").run(cutoff); - } - if (staleRoomRows.length > 0) { - this.syncDb().prepare("DELETE FROM chat_rooms WHERE updatedAt < ?").run(cutoff); - } - - return { - staleSessionIds: staleSessionRows.map((row) => row.id), - staleRoomIds: staleRoomRows.map((row) => row.id), - }; - }); - - if (result.staleSessionIds.length === 0 && result.staleRoomIds.length === 0) { - return { sessionsDeleted: 0, roomsDeleted: 0 }; - } - - this.syncDb().bumpLastModified(); - for (const sessionId of result.staleSessionIds) { + const result = await asyncChatStore.cleanupOldChats(this.asyncLayer.db, maxAgeMs); + for (const sessionId of result.deletedSessionIds) { this.emit("chat:session:deleted", sessionId); } - for (const roomId of result.staleRoomIds) { + for (const roomId of result.deletedRoomIds) { this.emit("chat:room:deleted", roomId); } - - return { - sessionsDeleted: result.staleSessionIds.length, - roomsDeleted: result.staleRoomIds.length, - }; + return { sessionsDeleted: result.sessionsDeleted, roomsDeleted: result.roomsDeleted }; } async addRoomMember(roomId: string, agentId: string, role: RoomMemberRole = "member"): Promise { const now = new Date().toISOString(); - if (this.backendMode) { - await asyncChatStore.addChatRoomMember(this.asyncLayer!.db, roomId, agentId, role, now); - const members = await this.listRoomMembers(roomId); - const member = members.find((m) => m.agentId === agentId); - if (!member) throw new Error(`Failed to load room member ${agentId}`); - this.emit("chat:room:member:added", member); - return member; - } - const result = this.syncDb().prepare(` - INSERT OR IGNORE INTO chat_room_members (roomId, agentId, role, addedAt) - VALUES (?, ?, ?, ?) - `).run(roomId, agentId, role, now); - - const member = this.syncDb().prepare("SELECT * FROM chat_room_members WHERE roomId = ? AND agentId = ?").get(roomId, agentId) as ChatRoomMemberRow | undefined; + await asyncChatStore.addChatRoomMember(this.asyncLayer.db, roomId, agentId, role, now); + const members = await this.listRoomMembers(roomId); + const member = members.find((m) => m.agentId === agentId); if (!member) throw new Error(`Failed to load room member ${agentId}`); - const mapped = this.rowToRoomMember(member); - - if (result.changes > 0) { - this.syncDb().bumpLastModified(); - this.emit("chat:room:member:added", mapped); - } - return mapped; + this.emit("chat:room:member:added", member); + return member; } async removeRoomMember(roomId: string, agentId: string): Promise { - if (this.backendMode) { - const removed = await asyncChatStore.removeChatRoomMember(this.asyncLayer!.db, roomId, agentId); - if (removed) this.emit("chat:room:member:removed", { roomId, agentId }); - return removed; - } - const result = this.syncDb().prepare("DELETE FROM chat_room_members WHERE roomId = ? AND agentId = ?").run(roomId, agentId); - const removed = result.changes > 0; - if (removed) { - this.syncDb().bumpLastModified(); - this.emit("chat:room:member:removed", { roomId, agentId }); - } + const removed = await asyncChatStore.removeChatRoomMember(this.asyncLayer.db, roomId, agentId); + if (removed) this.emit("chat:room:member:removed", { roomId, agentId }); return removed; } async listRoomMembers(roomId: string): Promise { - if (this.backendMode) { - return asyncChatStore.listChatRoomMembers(this.asyncLayer!.db, roomId); - } - const rows = this.syncDb().prepare("SELECT * FROM chat_room_members WHERE roomId = ? ORDER BY addedAt ASC").all(roomId) as ChatRoomMemberRow[]; - return rows.map((row) => this.rowToRoomMember(row)); + return asyncChatStore.listChatRoomMembers(this.asyncLayer.db, roomId); } async listRoomsForAgent(agentId: string, options?: { projectId?: string; status?: ChatRoomStatus }): Promise { - if (this.backendMode) { - return asyncChatStore.listChatRoomsForAgent(this.asyncLayer!.db, agentId, options); - } - const whereClauses: string[] = ["m.agentId = ?"]; - const params: string[] = [agentId]; - if (options?.projectId) { - whereClauses.push("r.projectId = ?"); - params.push(options.projectId); - } - if (options?.status) { - whereClauses.push("r.status = ?"); - params.push(options.status); - } - const rows = this.syncDb().prepare(` - SELECT r.* FROM chat_rooms r - INNER JOIN chat_room_members m ON m.roomId = r.id - WHERE ${whereClauses.join(" AND ")} - ORDER BY r.updatedAt DESC - `).all(...params) as ChatRoomRow[]; - return rows.map((row) => this.rowToRoom(row)); + return asyncChatStore.listChatRoomsForAgent(this.asyncLayer.db, agentId, options); } async addRoomMessage(roomId: string, input: ChatRoomMessageCreateInput): Promise { @@ -1426,27 +534,7 @@ export class ChatStore extends EventEmitter { if (!room) { throw new Error(`Chat room ${roomId} not found`); } - - if (this.backendMode) { - const now = new Date().toISOString(); - const message: ChatRoomMessage = { - id: `rmsg-${randomUUID().slice(0, 8)}`, - roomId, - role: input.role, - content: input.content, - thinkingOutput: input.thinkingOutput ?? null, - metadata: input.metadata ?? null, - attachments: input.attachments, - senderAgentId: input.senderAgentId ?? null, - mentions: input.mentions ?? [], - createdAt: now, - }; - const created = await asyncChatStore.addChatRoomMessage(this.asyncLayer!.db, message); - this.emit("chat:room:message:added", created); - return created; - } - - const now2 = new Date().toISOString(); + const now = new Date().toISOString(); const message: ChatRoomMessage = { id: `rmsg-${randomUUID().slice(0, 8)}`, roomId, @@ -1457,52 +545,15 @@ export class ChatStore extends EventEmitter { attachments: input.attachments, senderAgentId: input.senderAgentId ?? null, mentions: input.mentions ?? [], - createdAt: now2, + createdAt: now, }; - - this.syncDb().prepare(` - INSERT INTO chat_room_messages (id, roomId, role, content, thinkingOutput, metadata, attachments, senderAgentId, mentions, createdAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - message.id, - message.roomId, - message.role, - message.content, - message.thinkingOutput, - toJsonNullable(message.metadata), - toJsonNullable(message.attachments), - message.senderAgentId, - toJsonNullable(message.mentions), - message.createdAt, - ); - - this.syncDb().prepare("UPDATE chat_rooms SET updatedAt = ? WHERE id = ?").run(now2, roomId); - this.syncDb().bumpLastModified(); - this.emit("chat:room:message:added", message); - return message; + const created = await asyncChatStore.addChatRoomMessage(this.asyncLayer.db, message); + this.emit("chat:room:message:added", created); + return created; } async getRoomMessages(roomId: string, filter?: ChatRoomMessagesFilter): Promise { - if (this.backendMode) { - return asyncChatStore.getChatRoomMessages(this.asyncLayer!.db, roomId, filter); - } - const whereClauses: string[] = ["roomId = ?"]; - const params: Array = [roomId]; - if (filter?.before) { - whereClauses.push("createdAt < ?"); - params.push(filter.before); - } - - const order = filter?.order === "desc" ? "DESC" : "ASC"; - const rows = this.syncDb().prepare(` - SELECT * FROM chat_room_messages - WHERE ${whereClauses.join(" AND ")} - ORDER BY createdAt ${order} - LIMIT ? OFFSET ? - `).all(...params, filter?.limit ?? 100, filter?.offset ?? 0) as ChatRoomMessageRow[]; - - const normalizedRows = filter?.order === "desc" ? [...rows].reverse() : rows; - return normalizedRows.map((row) => this.rowToRoomMessage(row)); + return asyncChatStore.getChatRoomMessages(this.asyncLayer.db, roomId, filter); } async listRoomMessagesSince( @@ -1510,126 +561,38 @@ export class ChatStore extends EventEmitter { sinceIso: string, options?: { excludeSenderAgentId?: string; limit?: number }, ): Promise { - if (this.backendMode) { - return asyncChatStore.listChatRoomMessagesSince(this.asyncLayer!.db, roomId, sinceIso, options); - } - const whereClauses: string[] = ["roomId = ?", "createdAt > ?"]; - const params: Array = [roomId, sinceIso]; - - if (options?.excludeSenderAgentId) { - whereClauses.push("(senderAgentId IS NULL OR senderAgentId != ?)"); - params.push(options.excludeSenderAgentId); - } - - const rows = this.syncDb().prepare(` - SELECT * FROM chat_room_messages - WHERE ${whereClauses.join(" AND ")} - ORDER BY createdAt ASC - LIMIT ? - `).all(...params, options?.limit ?? 50) as ChatRoomMessageRow[]; - - return rows.map((row) => this.rowToRoomMessage(row)); + return asyncChatStore.listChatRoomMessagesSince(this.asyncLayer.db, roomId, sinceIso, options); } async getRoomMessage(id: string): Promise { - if (this.backendMode) { - return asyncChatStore.getChatRoomMessage(this.asyncLayer!.db, id); - } - const row = this.syncDb().prepare("SELECT * FROM chat_room_messages WHERE id = ?").get(id) as ChatRoomMessageRow | undefined; - return row ? this.rowToRoomMessage(row) : undefined; + return asyncChatStore.getChatRoomMessage(this.asyncLayer.db, id); } async deleteRoomMessage(id: string): Promise { - if (this.backendMode) { - const existing = await asyncChatStore.getChatRoomMessage(this.asyncLayer!.db, id); - if (!existing) return false; - const deleted = await asyncChatStore.deleteChatRoomMessage(this.asyncLayer!.db, id); - if (deleted) { - this.emit("chat:room:message:deleted", id); - const updatedRoom = await this.getRoom(existing.roomId); - if (updatedRoom) this.emit("chat:room:updated", updatedRoom); - } - return deleted; + const existing = await asyncChatStore.getChatRoomMessage(this.asyncLayer.db, id); + if (!existing) return false; + const deleted = await asyncChatStore.deleteChatRoomMessage(this.asyncLayer.db, id); + if (deleted) { + this.emit("chat:room:message:deleted", id); + const updatedRoom = await this.getRoom(existing.roomId); + if (updatedRoom) this.emit("chat:room:updated", updatedRoom); } - const message = await this.getRoomMessage(id); - if (!message) return false; - - const now = new Date().toISOString(); - this.syncDb().prepare("DELETE FROM chat_room_messages WHERE id = ?").run(id); - this.syncDb().prepare("UPDATE chat_rooms SET updatedAt = ? WHERE id = ?").run(now, message.roomId); - - this.syncDb().bumpLastModified(); - this.emit("chat:room:message:deleted", id); - - const updatedRoom = await this.getRoom(message.roomId); - if (updatedRoom) { - this.emit("chat:room:updated", updatedRoom); - } - - return true; + return deleted; } async clearRoomMessages(roomId: string): Promise { - if (this.backendMode) { - const deleted = await asyncChatStore.clearChatRoomMessages(this.asyncLayer!.db, roomId); - if (deleted > 0) this.emit("chat:room:messages:cleared", { roomId, deletedCount: deleted }); - return deleted; - } - const room = await this.getRoom(roomId); - if (!room) { - return 0; - } - - const deleted = this.syncDb().prepare("DELETE FROM chat_room_messages WHERE roomId = ?").run(roomId); - const deletedCount = Number(deleted.changes); - if (deletedCount <= 0) { - return 0; - } - - const now = new Date().toISOString(); - this.syncDb().prepare("UPDATE chat_rooms SET updatedAt = ? WHERE id = ?").run(now, roomId); - this.syncDb().bumpLastModified(); - this.emit("chat:room:messages:cleared", { roomId, deletedCount }); - - const updatedRoom = await this.getRoom(roomId); - if (updatedRoom) { - this.emit("chat:room:updated", updatedRoom); - } - - return deletedCount; + const deleted = await asyncChatStore.clearChatRoomMessages(this.asyncLayer.db, roomId); + if (deleted > 0) this.emit("chat:room:messages:cleared", { roomId, deletedCount: deleted }); + return deleted; } async addRoomMessageAttachment(roomId: string, messageId: string, attachment: ChatAttachment): Promise { - if (this.backendMode) { - const updated = await asyncChatStore.addChatRoomMessageAttachment(this.asyncLayer!.db, roomId, messageId, attachment); - this.emit("chat:room:message:updated", updated); - return updated; - } - const message = await this.getRoomMessage(messageId); - if (!message || message.roomId !== roomId) { - throw new Error(`Message ${messageId} not found in room ${roomId}`); - } - - const updatedAttachments = [...(message.attachments ?? []), attachment]; - this.syncDb().prepare("UPDATE chat_room_messages SET attachments = ? WHERE id = ?").run( - toJsonNullable(updatedAttachments), - messageId, - ); - - const now = new Date().toISOString(); - this.syncDb().prepare("UPDATE chat_rooms SET updatedAt = ? WHERE id = ?").run(now, roomId); - - const updated = await this.getRoomMessage(messageId); - if (!updated) { - throw new Error(`Failed to update room message ${messageId}`); - } - - this.syncDb().bumpLastModified(); + const updated = await asyncChatStore.addChatRoomMessageAttachment(this.asyncLayer.db, roomId, messageId, attachment); this.emit("chat:room:message:updated", updated); return updated; } - recordTokenUsage(input: ChatTokenUsageCreateInput): ChatTokenUsageRecord | undefined { + async recordTokenUsage(input: ChatTokenUsageCreateInput): Promise { const inputTokens = Math.max(0, Math.trunc(input.inputTokens)); const outputTokens = Math.max(0, Math.trunc(input.outputTokens)); const cachedTokens = Math.max(0, Math.trunc(input.cachedTokens)); @@ -1656,55 +619,35 @@ export class ChatStore extends EventEmitter { totalTokens, createdAt: input.createdAt ?? new Date().toISOString(), }; - - /* - * FNXC:ChatTokenAccounting 2026-07-02-00:00: - * Chat interactions are first-class token consumers for Command Center totals, but they are stored in a separate append-only table instead of task.tokenUsage so task execution panels stay task-scoped and planner chat cannot double-count executor/reviewer/triage/merger sessions. - */ - if (this.backendMode) { - const layer = this.asyncLayer!; - void layer.db.execute(sql`INSERT INTO project.chat_token_usage ( - id, source_kind, chat_session_id, room_id, message_id, project_id, agent_id, - model_provider, model_id, input_tokens, output_tokens, cached_tokens, - cache_write_tokens, total_tokens, created_at - ) VALUES ( - ${record.id}, ${record.sourceKind}, ${record.chatSessionId}, ${record.roomId}, - ${record.messageId}, ${record.projectId}, ${record.agentId}, - ${record.modelProvider}, ${record.modelId}, ${record.inputTokens}, ${record.outputTokens}, - ${record.cachedTokens}, ${record.cacheWriteTokens}, ${record.totalTokens}, ${record.createdAt} - )`); - return record; - } - this.syncDb().prepare(` - INSERT INTO chat_token_usage ( - id, sourceKind, chatSessionId, roomId, messageId, projectId, agentId, - modelProvider, modelId, inputTokens, outputTokens, cachedTokens, - cacheWriteTokens, totalTokens, createdAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - record.id, - record.sourceKind, - record.chatSessionId, - record.roomId, - record.messageId, - record.projectId, - record.agentId, - record.modelProvider, - record.modelId, - record.inputTokens, - record.outputTokens, - record.cachedTokens, - record.cacheWriteTokens, - record.totalTokens, - record.createdAt, - ); - this.syncDb().bumpLastModified(); + const layer = this.asyncLayer; + /* FNXC:PostgresChatUsage 2026-07-14-18:49: Token accounting is durable before a chat turn reports completion; callers await the insert so shutdown and immediate analytics cannot lose or race the record. */ + await layer.db.execute(sql`INSERT INTO project.chat_token_usage ( + id, source_kind, chat_session_id, room_id, message_id, project_id, agent_id, + model_provider, model_id, input_tokens, output_tokens, cached_tokens, + cache_write_tokens, total_tokens, created_at + ) VALUES ( + ${record.id}, ${record.sourceKind}, ${record.chatSessionId}, ${record.roomId}, + ${record.messageId}, ${record.projectId}, ${record.agentId}, + ${record.modelProvider}, ${record.modelId}, ${record.inputTokens}, ${record.outputTokens}, + ${record.cachedTokens}, ${record.cacheWriteTokens}, ${record.totalTokens}, ${record.createdAt} + )`); return record; } - listTokenUsage(): ChatTokenUsageRecord[] { - if (this.backendMode) return []; - const rows = this.syncDb().prepare("SELECT * FROM chat_token_usage ORDER BY createdAt ASC").all() as ChatTokenUsageRow[]; - return rows.map((row) => this.rowToTokenUsage(row)); + /** Authoritative PostgreSQL token-usage reader. */ + async listTokenUsageAsync(): Promise { + /* FNXC:PostgresChatUsage 2026-07-14-18:40: Public chat accounting reads must return durable PostgreSQL records instead of the synchronous compatibility facade's empty value. */ + const rows = await this.asyncLayer.db + .select() + .from(schema.project.chatTokenUsage) + .orderBy(asc(schema.project.chatTokenUsage.createdAt), asc(schema.project.chatTokenUsage.id)); + return rows.map((row) => ({ + id: row.id, sourceKind: row.sourceKind as ChatTokenUsageSourceKind, + chatSessionId: row.chatSessionId, roomId: row.roomId, messageId: row.messageId, + projectId: row.projectId, agentId: row.agentId, modelProvider: row.modelProvider, + modelId: row.modelId, inputTokens: row.inputTokens, outputTokens: row.outputTokens, + cachedTokens: row.cachedTokens, cacheWriteTokens: row.cacheWriteTokens, + totalTokens: row.totalTokens, createdAt: row.createdAt, + })); } } diff --git a/packages/core/src/cli-session-store.ts b/packages/core/src/cli-session-store.ts index 05c624f9ae..0deb300fa4 100644 --- a/packages/core/src/cli-session-store.ts +++ b/packages/core/src/cli-session-store.ts @@ -1,22 +1,19 @@ /** - * CliSessionStore - Data layer for durable CLI agent session records - * (CLI Agent Executor, U1). + * Durable PostgreSQL store for experimental CLI Agent Executor sessions. * - * Manages CRUD for the `cli_sessions` table: the long-lived record that - * survives executor restarts so a session can be reasoned about, resumed, - * or reaped from its persisted state. - * - * Follows the same patterns as ChatStore: - * - EventEmitter for change notifications. - * - SQLite for structured data storage. - * - JSON columns for nested data (autonomyPosture). - * - Validation at the store boundary: invalid enum values are rejected. + * FNXC:CliAgentPostgres 2026-07-14-12:00: + * CLI-agent execution must remain available after the PostgreSQL cutover. Keep + * the runtime-facing API synchronous by hydrating a project-scoped cache before + * construction, while serializing every mutation through the injected + * AsyncDataLayer. Callers that cross a durability boundary (PTY launch and + * runtime shutdown) await flush(). */ - -import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; -import type { Database } from "./db.js"; -import { fromJson, toJsonNullable } from "./db.js"; +import { EventEmitter } from "node:events"; +import { and, desc, eq } from "drizzle-orm"; +import * as schema from "./postgres/schema/index.js"; +import type { AsyncDataLayer } from "./postgres/data-layer.js"; +import { fromJson } from "./db-helpers.js"; import { isCliAgentState, isCliSessionPurpose, @@ -30,119 +27,107 @@ import { type CliTerminationReason, } from "./cli-session-types.js"; -// ── Event Types ───────────────────────────────────────────────────────── - export interface CliSessionStoreEvents { - /** Emitted when a CLI session record is created. */ "cli-session:created": [session: CliSession]; - /** Emitted when a CLI session record is updated. */ "cli-session:updated": [session: CliSession]; - /** Emitted when a CLI session record is deleted. */ "cli-session:deleted": [sessionId: string]; } -// ── Row Interface ──────────────────────────────────────────────────────── +type CliSessionRow = typeof schema.project.cliSessions.$inferSelect; -/** Database row shape for cli_sessions. */ -interface CliSessionRow { - id: string; - taskId: string | null; - chatSessionId: string | null; - purpose: string; - projectId: string; - adapterId: string; - agentState: string; - terminationReason: string | null; - nativeSessionId: string | null; - resumeAttempts: number; - autonomyPosture: string | null; - worktreePath: string | null; - createdAt: string; - updatedAt: string; +function parsePosture(value: string | null): CliAutonomyPosture | null { + return fromJson(value) ?? null; } -// ── CliSessionStore Class ──────────────────────────────────────────────── +function rowToSession(row: CliSessionRow): CliSession { + return { + id: row.id, + taskId: row.taskId, + chatSessionId: row.chatSessionId, + purpose: row.purpose as CliSessionPurpose, + projectId: row.projectId, + adapterId: row.adapterId, + agentState: row.agentState as CliAgentState, + terminationReason: row.terminationReason as CliTerminationReason | null, + nativeSessionId: row.nativeSessionId, + resumeAttempts: row.resumeAttempts, + autonomyPosture: parsePosture(row.autonomyPosture), + worktreePath: row.worktreePath, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} export class CliSessionStore extends EventEmitter { - constructor( - private fusionDir: string, - private db: Database, + private readonly sessions = new Map(); + private writeTail: Promise = Promise.resolve(); + private writeError: unknown; + + private constructor( + private readonly layer: AsyncDataLayer, + private readonly projectId: string, ) { super(); this.setMaxListeners(100); } - // ── Row-to-Object Converter ────────────────────────────────────────── - - private rowToSession(row: CliSessionRow): CliSession { - return { - id: row.id, - taskId: row.taskId ?? null, - chatSessionId: row.chatSessionId ?? null, - purpose: row.purpose as CliSessionPurpose, - projectId: row.projectId, - adapterId: row.adapterId, - agentState: row.agentState as CliAgentState, - terminationReason: (row.terminationReason as CliTerminationReason | null) ?? null, - nativeSessionId: row.nativeSessionId ?? null, - resumeAttempts: row.resumeAttempts ?? 0, - autonomyPosture: fromJson(row.autonomyPosture) ?? null, - worktreePath: row.worktreePath ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; + /** Hydrate all project sessions before exposing the synchronous cache API. */ + static async create(layer: AsyncDataLayer, projectId: string): Promise { + const store = new CliSessionStore(layer, projectId); + const rows = await layer.db + .select() + .from(schema.project.cliSessions) + .where(eq(schema.project.cliSessions.projectId, projectId)) + .orderBy(desc(schema.project.cliSessions.updatedAt)); + for (const row of rows) store.sessions.set(row.id, rowToSession(row)); + return store; } - // ── Boundary validation ────────────────────────────────────────────── + /** Wait until all mutations queued before this call are durable. */ + async flush(): Promise { + await this.writeTail; + if (this.writeError !== undefined) throw this.writeError; + } + + private enqueue(write: () => Promise): void { + this.writeTail = this.writeTail + .then(async () => { + await write(); + }) + .catch((error: unknown) => { + // Event-driven state transitions cannot await storage directly. Retain + // the first failure for the next explicit durability boundary without + // creating an unhandled rejection, and keep later writes ordered. + this.writeError ??= error; + }); + } private assertAgentState(value: unknown): asserts value is CliAgentState { - if (!isCliAgentState(value)) { - throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`); - } + if (!isCliAgentState(value)) throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`); } private assertPurpose(value: unknown): asserts value is CliSessionPurpose { - if (!isCliSessionPurpose(value)) { - throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`); - } + if (!isCliSessionPurpose(value)) throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`); } - private assertTerminationReason( - value: unknown, - ): asserts value is CliTerminationReason | null { - if (value === null || value === undefined) return; - if (!isCliTerminationReason(value)) { + private assertTerminationReason(value: unknown): asserts value is CliTerminationReason | null { + if (value !== null && value !== undefined && !isCliTerminationReason(value)) { throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`); } } - // ── CRUD Operations ────────────────────────────────────────────────── - - /** - * Create a new CLI session record. - * - * @throws Error if any enum value (purpose / agentState / terminationReason) - * is invalid, or required fields are missing. - */ createSession(input: CliSessionCreateInput): CliSession { this.assertPurpose(input.purpose); - const agentState: CliAgentState = input.agentState ?? "starting"; + const agentState = input.agentState ?? "starting"; this.assertAgentState(agentState); this.assertTerminationReason(input.terminationReason ?? null); - - if (!input.projectId) { - throw new Error("CLI session requires a projectId"); - } - if (!input.adapterId) { - throw new Error("CLI session requires an adapterId"); - } + if (!input.projectId) throw new Error("CLI session requires a projectId"); + if (input.projectId !== this.projectId) throw new Error(`CLI session projectId must be ${this.projectId}`); + if (!input.adapterId) throw new Error("CLI session requires an adapterId"); const now = new Date().toISOString(); - const id = input.id ?? `cli-${randomUUID().slice(0, 8)}`; - const resumeAttempts = input.resumeAttempts ?? 0; - const session: CliSession = { - id, + id: input.id ?? `cli-${randomUUID().slice(0, 8)}`, taskId: input.taskId ?? null, chatSessionId: input.chatSessionId ?? null, purpose: input.purpose, @@ -151,57 +136,25 @@ export class CliSessionStore extends EventEmitter { agentState, terminationReason: input.terminationReason ?? null, nativeSessionId: input.nativeSessionId ?? null, - resumeAttempts, + resumeAttempts: input.resumeAttempts ?? 0, autonomyPosture: input.autonomyPosture ?? null, worktreePath: input.worktreePath ?? null, createdAt: now, updatedAt: now, }; - - this.db - .prepare( - `INSERT INTO cli_sessions ( - id, taskId, chatSessionId, purpose, projectId, adapterId, - agentState, terminationReason, nativeSessionId, resumeAttempts, - autonomyPosture, worktreePath, createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - session.id, - session.taskId, - session.chatSessionId, - session.purpose, - session.projectId, - session.adapterId, - session.agentState, - session.terminationReason, - session.nativeSessionId, - session.resumeAttempts, - toJsonNullable(session.autonomyPosture), - session.worktreePath, - session.createdAt, - session.updatedAt, - ); - - this.db.bumpLastModified(); + this.sessions.set(session.id, session); + this.enqueue(() => this.layer.db.insert(schema.project.cliSessions).values({ + ...session, + autonomyPosture: session.autonomyPosture ? JSON.stringify(session.autonomyPosture) : null, + })); this.emit("cli-session:created", session); return session; } - /** Get a CLI session record by ID. */ getSession(id: string): CliSession | undefined { - const row = this.db - .prepare("SELECT * FROM cli_sessions WHERE id = ?") - .get(id) as unknown as CliSessionRow | undefined; - if (!row) return undefined; - return this.rowToSession(row); + return this.sessions.get(id); } - /** - * List CLI session records with optional filtering. - * - * @returns Array of sessions ordered by updatedAt DESC. - */ listSessions(options?: { taskId?: string; chatSessionId?: string; @@ -209,127 +162,61 @@ export class CliSessionStore extends EventEmitter { agentState?: CliAgentState; purpose?: CliSessionPurpose; }): CliSession[] { - const whereClauses: string[] = []; - const params: string[] = []; - - if (options?.taskId !== undefined) { - whereClauses.push("taskId = ?"); - params.push(options.taskId); - } - if (options?.chatSessionId !== undefined) { - whereClauses.push("chatSessionId = ?"); - params.push(options.chatSessionId); - } - if (options?.projectId !== undefined) { - whereClauses.push("projectId = ?"); - params.push(options.projectId); - } - if (options?.agentState !== undefined) { - this.assertAgentState(options.agentState); - whereClauses.push("agentState = ?"); - params.push(options.agentState); - } - if (options?.purpose !== undefined) { - this.assertPurpose(options.purpose); - whereClauses.push("purpose = ?"); - params.push(options.purpose); - } - - const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; - const rows = this.db - .prepare(`SELECT * FROM cli_sessions ${whereSql} ORDER BY updatedAt DESC`) - .all(...params); - - return (rows as unknown as CliSessionRow[]).map((row) => this.rowToSession(row)); + if (options?.agentState !== undefined) this.assertAgentState(options.agentState); + if (options?.purpose !== undefined) this.assertPurpose(options.purpose); + return [...this.sessions.values()] + .filter((session) => options?.taskId === undefined || session.taskId === options.taskId) + .filter((session) => options?.chatSessionId === undefined || session.chatSessionId === options.chatSessionId) + .filter((session) => options?.projectId === undefined || session.projectId === options.projectId) + .filter((session) => options?.agentState === undefined || session.agentState === options.agentState) + .filter((session) => options?.purpose === undefined || session.purpose === options.purpose) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } - /** List CLI session records owned by a task. */ listByTask(taskId: string): CliSession[] { return this.listSessions({ taskId }); } - /** List CLI session records owned by a chat session. */ listByChatSession(chatSessionId: string): CliSession[] { return this.listSessions({ chatSessionId }); } - /** - * Update a CLI session record. - * - * State, terminationReason, and resumeAttempts are written atomically in a - * single UPDATE statement, so a state transition that also records why the - * session ended and how many resumes were attempted cannot tear. - * - * @throws Error if any provided enum value is invalid. - * @returns The updated session, or undefined if not found. - */ updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined { - const existing = this.getSession(id); + const existing = this.sessions.get(id); if (!existing) return undefined; - - if (input.agentState !== undefined) { - this.assertAgentState(input.agentState); - } - if (input.terminationReason !== undefined) { - this.assertTerminationReason(input.terminationReason); - } - - const now = new Date().toISOString(); - const setClauses: string[] = ["updatedAt = ?"]; - const params: (string | number | null)[] = [now]; - - if (input.taskId !== undefined) { - setClauses.push("taskId = ?"); - params.push(input.taskId); - } - if (input.chatSessionId !== undefined) { - setClauses.push("chatSessionId = ?"); - params.push(input.chatSessionId); - } - if (input.agentState !== undefined) { - setClauses.push("agentState = ?"); - params.push(input.agentState); - } - if (input.terminationReason !== undefined) { - setClauses.push("terminationReason = ?"); - params.push(input.terminationReason); - } - if (input.nativeSessionId !== undefined) { - setClauses.push("nativeSessionId = ?"); - params.push(input.nativeSessionId); - } - if (input.resumeAttempts !== undefined) { - setClauses.push("resumeAttempts = ?"); - params.push(input.resumeAttempts); - } - if (input.autonomyPosture !== undefined) { - setClauses.push("autonomyPosture = ?"); - params.push(toJsonNullable(input.autonomyPosture)); - } - if (input.worktreePath !== undefined) { - setClauses.push("worktreePath = ?"); - params.push(input.worktreePath); - } - - params.push(id); - - this.db - .prepare(`UPDATE cli_sessions SET ${setClauses.join(", ")} WHERE id = ?`) - .run(...params); - - const updated = this.getSession(id)!; - this.db.bumpLastModified(); + if (input.agentState !== undefined) this.assertAgentState(input.agentState); + if (input.terminationReason !== undefined) this.assertTerminationReason(input.terminationReason); + const updated: CliSession = { ...existing, ...input, updatedAt: new Date().toISOString() }; + this.sessions.set(id, updated); + this.enqueue(() => this.layer.db + .update(schema.project.cliSessions) + .set({ + taskId: updated.taskId, + chatSessionId: updated.chatSessionId, + agentState: updated.agentState, + terminationReason: updated.terminationReason, + nativeSessionId: updated.nativeSessionId, + resumeAttempts: updated.resumeAttempts, + autonomyPosture: updated.autonomyPosture ? JSON.stringify(updated.autonomyPosture) : null, + worktreePath: updated.worktreePath, + updatedAt: updated.updatedAt, + }) + .where(and( + eq(schema.project.cliSessions.id, id), + eq(schema.project.cliSessions.projectId, this.projectId), + ))); this.emit("cli-session:updated", updated); return updated; } - /** Delete a CLI session record. */ deleteSession(id: string): boolean { - const existing = this.getSession(id); - if (!existing) return false; - - this.db.prepare("DELETE FROM cli_sessions WHERE id = ?").run(id); - this.db.bumpLastModified(); + if (!this.sessions.delete(id)) return false; + this.enqueue(() => this.layer.db + .delete(schema.project.cliSessions) + .where(and( + eq(schema.project.cliSessions.id, id), + eq(schema.project.cliSessions.projectId, this.projectId), + ))); this.emit("cli-session:deleted", id); return true; } diff --git a/packages/core/src/fs-watch-poll-controller.ts b/packages/core/src/fs-watch-poll-controller.ts index aeae12de90..7b36529d31 100644 --- a/packages/core/src/fs-watch-poll-controller.ts +++ b/packages/core/src/fs-watch-poll-controller.ts @@ -1,11 +1,11 @@ import { watch, type FSWatcher } from "node:fs"; /** - * FNXC:CoreStores 2026-07-09-14:20: - * `TaskStore` and `AgentStore` both need cross-process change detection over - * the shared `.fusion/fusion.db` — an in-process instance must notice writes - * made by ANOTHER process (or another store instance) sharing the same DB - * file, without a message bus. Both stores independently hand-rolled the + * FNXC:CoreStores 2026-07-14-18:49: + * `TaskStore` and `AgentStore` historically needed filesystem polling for + * SQLite. The controller remains a compatibility lifecycle primitive while + * PostgreSQL-backed stores use the shared database as their change source. + * Both stores independently hand-rolled the * identical mechanism: a fail-soft `fs.watch()` fast-path nudge (some * platforms/filesystems don't support it) plus an always-on `setInterval` * poll fallback, with identical teardown. `FsWatchPollController` owns ONLY diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 2fa3093b7a..5b9f0de13b 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -869,6 +869,8 @@ export { ProjectIdentityMismatchError, readProjectIdentity, writeProjectIdentity, + hasProjectIdentity, + PROJECT_IDENTITY_FILENAME, } from "./project-identity.js"; export { ProcessSupervisor, superviseSpawn } from "./process-supervisor.js"; export type { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59112ac9be..e2042f1115 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -897,6 +897,8 @@ export { ProjectIdentityMismatchError, readProjectIdentity, writeProjectIdentity, + hasProjectIdentity, + PROJECT_IDENTITY_FILENAME, readProjectIdentityAsync, writeProjectIdentityAsync, } from "./project-identity.js"; @@ -1178,6 +1180,8 @@ export type { PluginOnLoad, PluginOnUnload, PluginOnSchemaInit, + PluginOnPostgresSchemaInit, + PluginPostgresSchemaDefinition, PluginOnTaskCreated, PluginOnTaskMoved, PluginOnTaskCompleted, @@ -1813,6 +1817,7 @@ export { InsightLifecycleError, InsightStore, computeInsightFingerprint } from " // so the dashboard insights routes + run sweeper can type the run-execution store // path as the `InsightStore | AsyncInsightStore` union (insight-run execution in PG mode). export { AsyncInsightStore } from "./async-insight-store.js"; +export { AsyncCentralClaimStore } from "./async-central-db.js"; export { classifyInsightRunError, executeInsightRunLifecycle, @@ -2262,8 +2267,9 @@ export { // Runtime startup factory (cutover milestone). Production construction sites // (engine, dashboard, CLI serve/dashboard, desktop) consult this to boot // against PostgreSQL. Post default-flip: embedded PG is the default when - // DATABASE_URL is unset; FUSION_NO_EMBEDDED_PG=1 opts back to legacy SQLite. + // DATABASE_URL is unset; obsolete SQLite opt-out settings fail explicitly. createTaskStoreForBackend, + createCentralBackendLayer, shouldUsePostgresBackend, isEmbeddedPgRequested, isEmbeddedPgOptedOut, @@ -2277,6 +2283,7 @@ export type { PostgresConnections, CreateConnectionOptions, AsyncDataLayer, + CentralBackendLayerResult, DrizzleDb, DbTransaction, TransactionOptions, @@ -2298,6 +2305,7 @@ export type { StampMigratedProjectRowsResult, BackendBootResult, CreateTaskStoreForBackendOptions, + LoadedPluginSchemaContract, } from "./postgres/index.js"; // FNXC:RuntimeSatelliteAsync 2026-06-24-13:30: @@ -2362,6 +2370,14 @@ export { sql as drizzleSql, eq as drizzleEq } from "drizzle-orm"; // postgres internals. The shape definitions are harmless to expose: they only // describe tables the AsyncDataLayer can already reach. export { schema as postgresSchema } from "./postgres/index.js"; +export { + countKnowledgePagesInPostgres, + queryKnowledgePagesInPostgres, + upsertKnowledgePageInPostgres, + type AsyncKnowledgePage, + type AsyncKnowledgePageInput, + type AsyncKnowledgeQueryOptions, +} from "./async-knowledge.js"; export { upsertWorkflowStepResult, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS, diff --git a/packages/core/src/migration.ts b/packages/core/src/migration.ts index b8ce2678b5..ee3e7b03e1 100644 --- a/packages/core/src/migration.ts +++ b/packages/core/src/migration.ts @@ -35,6 +35,18 @@ function hasProjectDbFile(dir: string, folderName: string, dbName: string): bool return isValidSqliteDatabaseFile(dbPath); } +/** + * FNXC:PostgresProjectDiscovery 2026-07-14-17:30: + * PostgreSQL-era projects are identified by `.fusion/project.json`. An openable + * `fusion.db` remains accepted only so pre-cutover projects can enter the + * one-time migration flow; it is no longer the current project marker. + */ +function hasFusionProjectMarkerOrLegacyDb(dir: string): boolean { + const fusionDir = join(dir, ".fusion"); + return readProjectIdentity(fusionDir) !== null + || hasProjectDbFile(dir, ".fusion", "fusion.db"); +} + // ── Types ──────────────────────────────────────────────────────────── /** First-run state detection results */ @@ -49,7 +61,7 @@ export interface DetectedProject { path: string; /** Auto-generated or derived project name */ name: string; - /** Whether the project has a valid fusion.db */ + /** Whether the directory has a current project marker or valid legacy migration DB. */ hasDb: boolean; /** Persisted project identity id if present */ identityId?: string; @@ -129,16 +141,12 @@ export class FirstRunDetector { * @param existingCentral — Optional existing CentralCore instance to use instead of creating a new one */ async detectFirstRunState(existingCentral?: CentralCore): Promise { - const hasCentral = this.hasCentralDb(); - - if (!hasCentral) { - // No central DB - check for local project in cwd or parent directories - const cwd = process.cwd(); - const detected = await this.detectExistingProjects(cwd); - return detected.length > 0 ? "setup-wizard" : "fresh-install"; - } - - // Central DB exists - check if it has projects + /* + * FNXC:PostgresProjectDiscovery 2026-07-14-17:30: + * First-run state comes from the PostgreSQL central project registry, not + * the removed `fusion-central.db` file. A reachable empty registry with a + * local marker enters setup; a populated registry is normal operation. + */ let central: CentralCore | undefined = existingCentral; let shouldClose = false; @@ -148,17 +156,19 @@ export class FirstRunDetector { await central.init(); shouldClose = true; } catch { - // Central DB exists but is unreadable — treat as fresh install - return "fresh-install"; + const detected = await this.detectExistingProjects(process.cwd()); + return detected.length > 0 ? "setup-wizard" : "fresh-install"; } } try { const projects = await central.listProjects(); - return projects.length === 0 ? "setup-wizard" : "normal-operation"; + if (projects.length > 0) return "normal-operation"; + const detected = await this.detectExistingProjects(process.cwd()); + return detected.length > 0 ? "setup-wizard" : "fresh-install"; } catch { - // Central DB exists but is unreadable - treat as setup wizard - return "setup-wizard"; + const detected = await this.detectExistingProjects(process.cwd()); + return detected.length > 0 ? "setup-wizard" : "fresh-install"; } finally { if (shouldClose && central) { await central.close(); @@ -167,15 +177,21 @@ export class FirstRunDetector { } /** - * Check if the central database exists. + * Compatibility predicate for the mandatory central PostgreSQL backend. */ hasCentralDb(): boolean { - const centralDbPath = join(this.globalDir, "fusion-central.db"); - return existsSync(centralDbPath); + /* + * FNXC:PostgresProjectDiscovery 2026-07-14-17:30: + * PostgreSQL is mandatory after cutover, so filesystem presence cannot + * represent central availability. Keep this compatibility predicate true; + * callers needing health/state must initialize CentralCore and query the + * central project registry. + */ + return true; } /** - * Get the path to the central database. + * Get the legacy central SQLite path used only by migration tooling. */ getCentralDbPath(): string { return join(this.globalDir, "fusion-central.db"); @@ -184,7 +200,8 @@ export class FirstRunDetector { /** * Detect existing projects by walking up the directory tree. * - * Starting from `cwd`, walks up looking for `.fusion/fusion.db` files. + * Starting from `cwd`, walks up looking for `.fusion/project.json` markers + * or legacy `.fusion/fusion.db` migration inputs. * Stops at home directory or root. * * @param cwd — Starting directory (default: process.cwd()) @@ -351,10 +368,10 @@ export class FirstRunDetector { } /** - * Check if a directory contains a valid fusion project (.fusion/fusion.db). + * Check for a current marker or a valid legacy migration database. */ private hasFusionProject(dir: string): boolean { - return hasProjectDbFile(dir, ".fusion", "fusion.db"); + return hasFusionProjectMarkerOrLegacyDb(dir); } private getDefaultGlobalDir(): string { @@ -598,10 +615,10 @@ export class MigrationCoordinator { } /** - * Check if a directory is a valid fusion project (has .fusion/fusion.db). + * Check for a current marker or a valid legacy migration database. */ private isValidFusionProject(dir: string): boolean { - return hasProjectDbFile(dir, ".fusion", "fusion.db"); + return hasFusionProjectMarkerOrLegacyDb(dir); } } @@ -631,26 +648,22 @@ export class BackwardCompat { * 1. If `projectId` provided → look up that project * 2. If no `projectId` and single project registered → auto-use it * 3. If no `projectId` and multiple projects → throw ProjectRequiredError - * 4. If no central DB → return legacy mode (use cwd directly) * - * @param cwd — Current working directory + * @param _cwd — Retained for API compatibility; registry paths are authoritative. * @param projectId — Optional explicit project ID/name * @returns Resolved context * @throws ProjectRequiredError when multiple projects and no selection */ async resolveProjectContext( - cwd: string, + _cwd: string, projectId?: string ): Promise { - // Check for legacy mode (no central DB) - const detector = new FirstRunDetector(this.central.getGlobalDir()); - if (!detector.hasCentralDb()) { - return { - projectId: "legacy", - workingDirectory: cwd, - isLegacy: true, - }; - } + /* + * FNXC:PostgresProjectDiscovery 2026-07-14-17:30: + * Runtime project resolution always consults the PostgreSQL registry. The + * old filesystem test for fusion-central.db could incorrectly route a + * healthy PostgreSQL installation into removed SQLite legacy mode. + */ // Explicit project ID provided if (projectId) { @@ -696,11 +709,10 @@ export class BackwardCompat { } /** - * Check if running in legacy mode (no central database). + * Report whether removed SQLite legacy runtime mode is active. */ async isLegacyMode(): Promise { - const detector = new FirstRunDetector(this.central.getGlobalDir()); - return !detector.hasCentralDb(); + return false; } /** diff --git a/packages/core/src/pi-extensions.ts b/packages/core/src/pi-extensions.ts index 7c141e051f..26e1062484 100644 --- a/packages/core/src/pi-extensions.ts +++ b/packages/core/src/pi-extensions.ts @@ -101,13 +101,11 @@ export function getProjectRootFromWorktree( * refusal on a bind-mounted repo owned by a different UID, or any other non-zero * exit) returned null with NO thrown error — by design, so a non-worktree cwd * doesn't explode — but with no non-git fallback. resolveProjectRoot's caller then - * fell back to a naive upward walk for the first ancestor with a `.fusion` dir, - * which matched IMMEDIATELY at the task's own worktree (hydrateWorktreeDb's - * ensureWorktreeSchema already created a local, one-way-hydrated `.fusion/fusion.db` - * there for the dependency-closure copy). Every write tool call then silently - * landed in that throwaway worktree-local db — never synced back to the project - * root — with zero error surfaced. See task FN-7730 `research` document for the - * full investigation. + * FNXC:PostgresWorktreeStorage 2026-07-14-18:49: + * The fallback landed on the first ancestor with a `.fusion` directory. + * That historical SQLite hydration behavior is removed; worktrees now share + * the project-scoped PostgreSQL store. Resolving the main repository remains + * required so filesystem artifacts and project identity use the correct root. * * Fix: resolve the linked-worktree relationship directly from git's own on-disk * worktree metadata (the `.git` file + its `commondir` sidecar) FIRST. This is diff --git a/packages/core/src/planner-intervention.ts b/packages/core/src/planner-intervention.ts index 736528664d..bd65dfd234 100644 --- a/packages/core/src/planner-intervention.ts +++ b/packages/core/src/planner-intervention.ts @@ -24,7 +24,12 @@ import { OVERSEER_INTERVENTION_MUTATION } from "./types.js"; /** Minimal store seam this module depends on (satisfied by `TaskStore`). */ export interface PlannerInterventionStore { recordRunAuditEvent(input: RunAuditEventInput): RunAuditEvent | Promise; - getRunAuditEvents(options?: RunAuditEventFilter): RunAuditEvent[]; +} + +/** Read-capable seam used only by timeline queries. */ +export interface PlannerInterventionTimelineStore extends PlannerInterventionStore { + /* FNXC:PostgresStackSplit 2026-07-14-20:56: Keep event emitters compatible with write-only overseer adapters while requiring the asynchronous PostgreSQL reader only for timeline queries. */ + getRunAuditEventsAsync(options?: RunAuditEventFilter): Promise; } /** Input for recording a planner-intervention timeline entry. */ @@ -202,12 +207,12 @@ export function parseInterventionEntry(event: RunAuditEvent): PlannerInterventio } /** Reads the planner-intervention timeline for a task, newest-first. Returns `[]` when there are none. */ -export function getPlannerInterventionTimeline( - store: PlannerInterventionStore, +export async function getPlannerInterventionTimeline( + store: PlannerInterventionTimelineStore, taskId: string, opts?: { limit?: number }, -): PlannerInterventionEntry[] { - const events = store.getRunAuditEvents({ +): Promise { + const events = await store.getRunAuditEventsAsync({ taskId, mutationType: OVERSEER_INTERVENTION_MUTATION, limit: opts?.limit, diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index b2595a99c9..26d5692c20 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -26,7 +26,6 @@ import type { PluginUiSlotDefinition, PluginUiContributionDefinition, PluginDashboardViewDefinition, - PluginOnSchemaInit, PluginRuntimeRegistration, CliProviderContribution, PluginInstallation, @@ -40,6 +39,7 @@ import type { PluginSetupHooks, PluginSetupCheckResult, } from "./plugin-types.js"; +import type { LoadedPluginSchemaContract } from "./postgres/plugin-schema-hook.js"; import type { WorkflowExtensionContribution } from "./workflow-extension-types.js"; import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js"; import { createLogger } from "./logger.js"; @@ -208,6 +208,7 @@ export class PluginLoader extends EventEmitter<{ /** Absolute plugin package roots keyed by plugin id. */ private pluginRoots: Map = new Map(); + private pluginSchemaContracts: Map = new Map(); private readonly log = createLogger("plugin-loader"); @@ -388,6 +389,15 @@ export class PluginLoader extends EventEmitter<{ // Resolve dependencies await this.resolveDependencies(plugin); + /* + FNXC:PluginPostgresContract 2026-07-14-18:32: + Schema compatibility and DDL must finish before started state, map + publication, or onLoad. A SQLite-only third-party plugin therefore fails + without leaving subscriptions, timers, or other onLoad side effects. + */ + const schemaContract = this.options.taskStore.preflightPluginSchema(pluginId, plugin.hooks); + if (schemaContract) await this.options.taskStore.runPluginSchemaInits([schemaContract]); + // Update state to started await this.updatePluginState(pluginId, "started"); @@ -395,6 +405,7 @@ export class PluginLoader extends EventEmitter<{ plugin.state = "started"; this.plugins.set(pluginId, plugin); this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath)); + if (schemaContract) this.pluginSchemaContracts.set(pluginId, schemaContract); // Call onLoad hook const ctx = await this.createContext(plugin); @@ -404,6 +415,7 @@ export class PluginLoader extends EventEmitter<{ // onLoad failed - clean up and propagate error this.plugins.delete(pluginId); this.pluginRoots.delete(pluginId); + this.pluginSchemaContracts.delete(pluginId); const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr); await this.updatePluginState( pluginId, @@ -425,6 +437,7 @@ export class PluginLoader extends EventEmitter<{ // (it may have been added above before the onLoad hook) this.plugins.delete(pluginId); this.pluginRoots.delete(pluginId); + this.pluginSchemaContracts.delete(pluginId); // Error isolation: set error state but don't crash const errorMsg = err instanceof Error ? err.message : String(err); @@ -599,6 +612,7 @@ export class PluginLoader extends EventEmitter<{ // Snapshot old plugin for rollback const snapshot = { ...oldPlugin }; + const oldSchemaContract = this.pluginSchemaContracts.get(pluginId); try { // Re-import the plugin module @@ -614,11 +628,15 @@ export class PluginLoader extends EventEmitter<{ } // Update plugin state + const schemaContract = this.options.taskStore.preflightPluginSchema(pluginId, newPlugin.hooks); + if (schemaContract) await this.options.taskStore.runPluginSchemaInits([schemaContract]); newPlugin.state = "started"; // Replace in plugins map this.plugins.set(pluginId, newPlugin); this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath)); + if (schemaContract) this.pluginSchemaContracts.set(pluginId, schemaContract); + else this.pluginSchemaContracts.delete(pluginId); // Create fresh context and call onLoad const ctx = await this.createContext(newPlugin); @@ -646,6 +664,8 @@ export class PluginLoader extends EventEmitter<{ // Restore old plugin this.plugins.set(pluginId, snapshot); this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath)); + if (oldSchemaContract) this.pluginSchemaContracts.set(pluginId, oldSchemaContract); + else this.pluginSchemaContracts.delete(pluginId); // Attempt to reactivate old plugin const ctx = await this.createContext(snapshot); @@ -668,6 +688,7 @@ export class PluginLoader extends EventEmitter<{ this.plugins.delete(pluginId); this.pluginRoots.delete(pluginId); + this.pluginSchemaContracts.delete(pluginId); const originalError = err instanceof Error ? err.message : String(err); const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); @@ -887,6 +908,7 @@ export class PluginLoader extends EventEmitter<{ // Remove from loaded plugins this.plugins.delete(pluginId); this.pluginRoots.delete(pluginId); + this.pluginSchemaContracts.delete(pluginId); // Invalidate module cache for clean re-import this.invalidateModuleCache(pluginPath); @@ -1291,14 +1313,8 @@ export class PluginLoader extends EventEmitter<{ /** * Get all schema initialization hooks from loaded plugins. */ - getPluginSchemaInitHooks(): Array<{ pluginId: string; hook: PluginOnSchemaInit }> { - const hooks: Array<{ pluginId: string; hook: PluginOnSchemaInit }> = []; - for (const [pluginId, plugin] of this.plugins) { - if (plugin.hooks.onSchemaInit) { - hooks.push({ pluginId, hook: plugin.hooks.onSchemaInit }); - } - } - return hooks; + getPluginSchemaInitHooks(): LoadedPluginSchemaContract[] { + return [...this.pluginSchemaContracts.values()]; } /** diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 0461e2cdff..e3721ec7b6 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -311,6 +311,25 @@ export type PluginOnLoad = (ctx: PluginContext) => Promise | void; export type PluginOnUnload = (ctx: PluginContext) => Promise | void; /** Lifecycle hook: called during database schema initialization */ export type PluginOnSchemaInit = (db: Database) => Promise | void; +/** + * Declarative PostgreSQL schema owned by a plugin. + * + * FNXC:PluginPostgresContract 2026-07-14-18:32: + * PostgreSQL plugins declare idempotent project-schema DDL without receiving + * the host's privileged migration connection. Fusion validates this immutable + * plan before onLoad and executes it through a short-lived migration-only + * capability, keeping ordinary plugin runtime code on the forced-RLS role. + */ +export interface PluginPostgresSchemaDefinition { + /** Monotonically increasing plugin schema version for diagnostics. */ + version: number; + /** Stable snake_case namespace prefix for every referenced table (must end in `_`). */ + tablePrefix: string; + /** One idempotent CREATE TABLE, CREATE INDEX, or ALTER TABLE statement per item. */ + statements: readonly string[]; +} +/** PostgreSQL-native schema hook. It receives no database handle. */ +export type PluginOnPostgresSchemaInit = () => PluginPostgresSchemaDefinition; /** Lifecycle hook: called when a task is created */ export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise | void; /** Lifecycle hook: called when a task moves between columns */ @@ -1123,6 +1142,7 @@ export interface FusionPlugin { onTaskCompleted?: PluginOnTaskCompleted; onError?: PluginOnError; onSchemaInit?: PluginOnSchemaInit; + onPostgresSchemaInit?: PluginOnPostgresSchemaInit; onAgentRunStart?: PluginOnAgentRunStart; onAgentRunEnd?: PluginOnAgentRunEnd; }; diff --git a/packages/core/src/postgres/embedded-lifecycle.ts b/packages/core/src/postgres/embedded-lifecycle.ts index ab4b364db7..14cd46600c 100644 --- a/packages/core/src/postgres/embedded-lifecycle.ts +++ b/packages/core/src/postgres/embedded-lifecycle.ts @@ -428,7 +428,7 @@ export function uninstallElectronAsarNativePathPatchForTests(): void { * first call. Throws if the package is not installed (e.g. a stripped-down * build that omitted the embedded binary). */ -type EmbeddedPostgresCtor = new (opts: Record) => { +export type EmbeddedPostgresCtor = new (opts: Record) => { initialise(): Promise; start(): Promise; stop(): Promise; @@ -442,6 +442,12 @@ type EmbeddedPostgresCtor = new (opts: Record) => { /** Instance type produced by the embedded-postgres constructor. */ type EmbeddedPostgresInstance = InstanceType; let embeddedPostgresCtorCache: EmbeddedPostgresCtor | null = null; + +/** Test-only constructor seam for deterministic lifecycle cancellation coverage. */ +export function __setEmbeddedPostgresCtorForTests(ctor: EmbeddedPostgresCtor | null): void { + embeddedPostgresCtorCache = ctor; +} + function getEmbeddedPostgresCtor(): EmbeddedPostgresCtor { if (embeddedPostgresCtorCache) return embeddedPostgresCtorCache; // FNXC:DesktopEmbeddedPostgres 2026-07-14-18:30: @@ -933,9 +939,12 @@ export class EmbeddedPostgresLifecycle { if (this.options.startTimeoutMs <= 0) { return this.startInternal(); } + const controller = new AbortController(); + const startAttempt = this.startInternal(controller.signal); let timer: NodeJS.Timeout | undefined; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { + controller.abort(); reject( new EmbeddedStartTimeoutError( this.options.startTimeoutMs, @@ -948,8 +957,9 @@ export class EmbeddedPostgresLifecycle { }); this.startTimer = timer ?? null; try { - return await Promise.race([this.startInternal(), timeout]); + return await Promise.race([startAttempt, timeout]); } catch (err) { + controller.abort(); // On timeout (or any failure), best-effort clean up the partial state so // a retry starts fresh. stop() is safe to call even when not fully running. await this.stop().catch(() => undefined); @@ -964,15 +974,16 @@ export class EmbeddedPostgresLifecycle { * The actual start sequence, with no timeout wrapper. Called by {@link start} * either directly (timeout disabled) or via Promise.race with the timeout. */ - private async startInternal(): Promise { + private async startInternal(signal?: AbortSignal): Promise { const port = this.options.port ?? (await findFreePort()); + if (signal?.aborted) throw new EmbeddedStartCancelledError(this.options.dataDir); this.resolvedPort = port; const alreadyInitialized = isDataDirInitialized(this.options.dataDir); normalizeBundledMacosDylibs(this.options.onLog); - this.pg = new (getEmbeddedPostgresCtor())({ + const pg = new (getEmbeddedPostgresCtor())({ databaseDir: this.options.dataDir, user: this.options.user, password: this.options.password, @@ -984,6 +995,7 @@ export class EmbeddedPostgresLifecycle { onLog: this.options.onLog, onError: this.options.onError, }); + this.pg = pg; // FNXC:PostgresEmbedded 2026-06-24-09:06: // initialise() always runs initdb, which fails on an existing data dir. @@ -996,10 +1008,23 @@ export class EmbeddedPostgresLifecycle { this.options.onLog( `embedded postgres: initializing new data directory at ${this.options.dataDir} (initdb)`, ); - await this.pg.initialise(); + await pg.initialise(); } - await this.pg.start(); + if (signal?.aborted) { + await this.settleCancelledStart(pg); + throw new EmbeddedStartCancelledError(this.options.dataDir); + } + + await pg.start(); + /* + FNXC:PostgresResourceLifecycle 2026-07-14-18:42: + Promise.race does not cancel the losing embedded-postgres startup. Check the cooperative cancellation signal after every delayed phase and stop the exact late instance before it can publish running state, registry ownership, or process hooks. A timeout may already have attempted stop while pg.start() was pending, so the post-resolution stop is intentionally repeated to catch a postmaster that appeared after that first cleanup. + */ + if (signal?.aborted) { + await this.settleCancelledStart(pg); + throw new EmbeddedStartCancelledError(this.options.dataDir); + } this.running = true; this.ownsProcess = true; @@ -1009,7 +1034,20 @@ export class EmbeddedPostgresLifecycle { database: this.options.database, }); - await this.ensureDatabase(); + try { + await this.ensureDatabase(); + } catch (error) { + if (signal?.aborted) { + await this.settleCancelledStart(pg); + throw new EmbeddedStartCancelledError(this.options.dataDir); + } + throw error; + } + + if (signal?.aborted) { + await this.settleCancelledStart(pg); + throw new EmbeddedStartCancelledError(this.options.dataDir); + } this.installShutdownHook(); @@ -1026,6 +1064,19 @@ export class EmbeddedPostgresLifecycle { }; } + private async settleCancelledStart(pg: EmbeddedPostgresInstance): Promise { + try { + await pg.stop(); + } catch (error) { + this.options.onError(`embedded postgres: cancelled startup cleanup failed: ${String(error)}`); + } finally { + if (this.pg === pg) this.pg = null; + this.running = false; + runningInstances.delete(this.options.dataDir); + this.uninstallShutdownHook(); + } + } + /** * Ensure the application database exists on the running cluster. * @@ -1175,6 +1226,13 @@ export class EmbeddedPostgresLifecycle { }; } +class EmbeddedStartCancelledError extends Error { + constructor(dataDir: string) { + super(`embedded postgres: cancelled late startup for ${dataDir}`); + this.name = "EmbeddedStartCancelledError"; + } +} + /** * FNXC:PostgresEmbedded 2026-06-26-16:30 (fix migration-review P1 #24): * Thrown when the embedded PostgreSQL start sequence (initdb + pg_ctl start + diff --git a/packages/core/src/postgres/index.ts b/packages/core/src/postgres/index.ts index a3292489e0..d1b32d2acb 100644 --- a/packages/core/src/postgres/index.ts +++ b/packages/core/src/postgres/index.ts @@ -81,11 +81,16 @@ export { export { roadmapPluginSchemaInit, cePluginSchemaInit, + whatsappPluginSchemaInit, + evenRealitiesPluginSchemaInit, reportsPluginSchemaInit, cliPressPluginSchemaInit, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, runPluginSchemaInitHooks, + runLoadedPluginSchemaInitHooks, + assertLoadedPluginSchemaInitHooksSupported, type PluginSchemaInitHook, + type LoadedPluginSchemaContract, } from "./plugin-schema-hook.js"; /** @@ -189,23 +194,20 @@ export { * FNXC:BackendFlip 2026-06-26-14:30: * Runtime startup factory (cutover milestone). `createTaskStoreForBackend()` * is the single entry point production construction sites consult to decide - * whether to boot against PostgreSQL or fall back to the legacy SQLite path. - * Post default-flip (flip-embedded-pg-default): when DATABASE_URL is unset, - * the factory boots embedded PostgreSQL by default; FUSION_NO_EMBEDDED_PG=1 - * is the opt-out back to legacy SQLite. When DATABASE_URL is set, external - * PostgreSQL is used. When it returns a `BackendBootResult`, the call site - * uses the ready TaskStore and registers the result's `shutdown()` for - * process teardown. When it returns `null`, the call site constructs the - * SQLite-backed TaskStore exactly as before (byte-identical legacy path). + * how to boot PostgreSQL. Embedded PostgreSQL is the zero-config default and + * DATABASE_URL selects an external service; the removed SQLite opt-out is + * rejected so callers always receive a live backend result. */ export { createTaskStoreForBackend, + createCentralBackendLayer, shouldUsePostgresBackend, isEmbeddedPgRequested, isEmbeddedPgOptedOut, EMBEDDED_PG_ENV, NO_EMBEDDED_PG_ENV, type BackendBootResult, + type CentralBackendLayerResult, type CreateTaskStoreForBackendOptions, } from "./startup-factory.js"; diff --git a/packages/core/src/postgres/migration-stamping.ts b/packages/core/src/postgres/migration-stamping.ts index a431198625..167a9edef7 100644 --- a/packages/core/src/postgres/migration-stamping.ts +++ b/packages/core/src/postgres/migration-stamping.ts @@ -23,6 +23,7 @@ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; /** The Drizzle instance type startup-factory uses for its `connections.migration`. */ type MigrationDb = PostgresJsDatabase>; +type MigrationTransaction = Parameters[0]>[0]; /** Inputs for stamping migrated rows with a project partition key. */ export interface StampMigratedProjectRowsInput { @@ -62,13 +63,37 @@ export async function rekeyFallbackProjectPartition( if (!fallbackProjectId || fallbackProjectId === registeredProjectId) return false; return db.transaction(async (tx) => { - const ownedRows = (await tx.execute(sql` - SELECT EXISTS ( - SELECT 1 FROM project.tasks WHERE project_id = ${fallbackProjectId} - UNION ALL - SELECT 1 FROM archive.archived_tasks WHERE project_id = ${fallbackProjectId} - ) AS found - `)) as unknown as Array<{ found: boolean }>; + const tables = (await tx.execute(sql` + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'project' AND column_name = 'project_id' + ORDER BY table_name + `)) as unknown as Array<{ table_name: string }>; + /* + FNXC:ProjectIdentityPromotion 2026-07-14-18:58: + Fallback ownership can exist only in satellite tables such as agents, reports, or mission state. Inspect every project-owned table before deciding promotion is a no-op; task/archive-only detection stranded those partitions after central registration. + */ + let ownsProjectRows = false; + for (const { table_name: tableName } of tables) { + const rows = (await tx.execute(sql` + SELECT EXISTS ( + SELECT 1 FROM ${sql.identifier("project")}.${sql.identifier(tableName)} + WHERE project_id = ${fallbackProjectId} + ) AS found + `)) as unknown as Array<{ found: boolean }>; + if (rows[0]?.found) { + ownsProjectRows = true; + break; + } + } + if (!ownsProjectRows) { + const rows = (await tx.execute(sql` + SELECT EXISTS ( + SELECT 1 FROM archive.archived_tasks WHERE project_id = ${fallbackProjectId} + ) AS found + `)) as unknown as Array<{ found: boolean }>; + ownsProjectRows = rows[0]?.found === true; + } const migrationState = (await tx.execute(sql` SELECT to_regclass('public.fusion_sqlite_migrations') IS NOT NULL AS exists `)) as unknown as Array<{ exists: boolean }>; @@ -82,15 +107,9 @@ export async function rekeyFallbackProjectPartition( `)) as unknown as Array<{ found: boolean }>; ownsMigrationState = markerRows[0]?.found === true; } - if (!ownedRows[0]?.found && !ownsMigrationState) return false; + if (!ownsProjectRows && !ownsMigrationState) return false; await tx.execute(sql`SET CONSTRAINTS ALL DEFERRED`); - const tables = (await tx.execute(sql` - SELECT table_name - FROM information_schema.columns - WHERE table_schema = 'project' AND column_name = 'project_id' - ORDER BY table_name - `)) as unknown as Array<{ table_name: string }>; for (const { table_name: tableName } of tables) { await tx.execute(sql` UPDATE ${sql.identifier("project")}.${sql.identifier(tableName)} @@ -171,19 +190,52 @@ export async function stampMigratedProjectRows( exactly the boot that performs most real-world migrations. The resolution now falls back to a central-registry path lookup (done by the caller). - FNXC:ProjectDataIsolation 2026-07-14-12:55: - Schema migration 0006 quarantines ambiguous ownerless rows as __legacy_unscoped__. Never bulk-stamp that quarantine here: the SQLite migrator reconciles rows against a specific source and project, while this legacy helper may only claim its historical NULL rows. + FNXC:ProjectDataIsolation 2026-07-14-16:50: + Schema migration 0006 quarantines ownerless rows when no unique migration owner existed at schema-upgrade time. A later verified startup cutover may claim that quarantine only when the migration ledger now identifies exactly one non-empty project and it is this project; multi-project or otherwise ambiguous quarantines remain untouched for operator reconciliation. */ - await db.execute( - sql`UPDATE project.tasks SET project_id = ${projectId} WHERE project_id IS NULL`, + /* + FNXC:ProjectMigrationStamping 2026-07-14-21:55: + Partition stamping is one atomic promotion. If any table cannot be re-keyed, roll back every earlier update so startup never exposes a partially migrated project identity. + */ + return db.transaction((tx) => stampMigratedProjectRowsWithinTransaction(tx, projectId, rootDir)); +} + +async function stampMigratedProjectRowsWithinTransaction( + tx: MigrationTransaction, + projectId: string, + rootDir: string, +): Promise { + const stateTable = (await tx.execute(sql` + SELECT to_regclass('public.fusion_sqlite_migrations') IS NOT NULL AS exists + `)) as unknown as Array<{ exists: boolean }>; + let canClaimLegacyQuarantine = false; + if (stateTable[0]?.exists) { + const ownershipRows = (await tx.execute(sql` + SELECT count(DISTINCT project_id)::int AS project_count, + min(project_id) AS only_project_id + FROM public.fusion_sqlite_migrations + WHERE project_id IS NOT NULL AND project_id <> '' + `)) as unknown as Array<{ project_count: number; only_project_id: string | null }>; + canClaimLegacyQuarantine = + ownershipRows[0]?.project_count === 1 && ownershipRows[0]?.only_project_id === projectId; + } + + await tx.execute( + sql`UPDATE project.tasks SET project_id = ${projectId} + WHERE project_id IS NULL + OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__')`, ); - await db.execute( - sql`UPDATE project.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`, + await tx.execute( + sql`UPDATE project.archived_tasks SET project_id = ${projectId} + WHERE project_id IS NULL + OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__')`, ); // The cold-storage archive is also partitioned (PR #2007 review P1); migrated // snapshots must be owned by this project too. - await db.execute( - sql`UPDATE archive.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`, + await tx.execute( + sql`UPDATE archive.archived_tasks SET project_id = ${projectId} + WHERE project_id IS NULL + OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__')`, ); /* @@ -197,9 +249,9 @@ export async function stampMigratedProjectRows( clobbered (then the '' row is left for manual reconciliation rather than destroying either copy). */ - await db.execute( + await tx.execute( sql`UPDATE project.config SET project_id = ${projectId} - WHERE project_id = '' + WHERE (project_id = '' OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__')) AND NOT EXISTS (SELECT 1 FROM project.config WHERE project_id = ${projectId})`, ); @@ -217,7 +269,7 @@ export async function stampMigratedProjectRows( unique violation never clobbers a pre-existing per-project row (the outer table alias in the correlated subquery references the row being updated). */ - await db.execute( + await tx.execute( sql`UPDATE project.workflow_settings SET project_id = ${projectId} WHERE project_id = ${rootDir} AND NOT EXISTS ( @@ -226,7 +278,7 @@ export async function stampMigratedProjectRows( AND w2.project_id = ${projectId} )`, ); - await db.execute( + await tx.execute( sql`UPDATE project.workflow_prompt_overrides SET project_id = ${projectId} WHERE project_id = ${rootDir} AND NOT EXISTS ( diff --git a/packages/core/src/postgres/migrations/0009_mission_fix_idempotency.sql b/packages/core/src/postgres/migrations/0009_mission_fix_idempotency.sql new file mode 100644 index 0000000000..b574c02f4f --- /dev/null +++ b/packages/core/src/postgres/migrations/0009_mission_fix_idempotency.sql @@ -0,0 +1,12 @@ +/* +FNXC:MissionFixIdempotency 2026-07-14-18:55: +A source feature can produce at most one generated fix for a validator run within a project. The unique index is intentionally additive and fails closed if historical duplicates exist so operators can reconcile conflicting remediation records explicitly. +*/ +DO $$ +BEGIN + IF to_regclass('project.mission_fix_feature_lineage') IS NOT NULL THEN + CREATE UNIQUE INDEX IF NOT EXISTS idx_fix_lineage_project_source_run + ON project.mission_fix_feature_lineage (project_id, source_feature_id, run_id); + END IF; +END +$$; diff --git a/packages/core/src/postgres/plugin-schema-hook.ts b/packages/core/src/postgres/plugin-schema-hook.ts index b8593ad96f..d839404286 100644 --- a/packages/core/src/postgres/plugin-schema-hook.ts +++ b/packages/core/src/postgres/plugin-schema-hook.ts @@ -17,6 +17,15 @@ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { sql } from "drizzle-orm"; +import type { PluginPostgresSchemaDefinition } from "../plugin-types.js"; + +export interface LoadedPluginSchemaContract { + pluginId: string; + /** @deprecated compatibility alias for legacyHook. */ + hook?: unknown; + legacyHook?: unknown; + postgresSchema?: PluginPostgresSchemaDefinition; +} /** * A plugin schema-init hook. Receives the Drizzle connection and is expected @@ -300,6 +309,46 @@ export const whatsappPluginSchemaInit: PluginSchemaInitHook = { }, }; +/** + * FNXC:EvenRealitiesPostgres 2026-07-14-17:25: + * The bundled glasses notifier previously registered only SQLite DDL, so backend startup skipped its table and onLoad later reached a removed synchronous database. Materialize the project-owned PostgreSQL snapshot table explicitly; arbitrary SQLite hook SQL is never translated or executed as PostgreSQL. + */ +export const evenRealitiesPluginSchemaInit: PluginSchemaInitHook = { + pluginId: "fusion-plugin-even-realities-glasses", + async init(db) { + await db.execute(sql.raw(` + CREATE TABLE IF NOT EXISTS project.even_realities_seen_tasks ( + project_id text NOT NULL DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'), + task_id text NOT NULL, + last_column text NOT NULL, + updated_at text NOT NULL, + PRIMARY KEY (project_id, task_id) + ); + CREATE INDEX IF NOT EXISTS "idxEvenRealitiesSeenTasksProjectUpdated" + ON project.even_realities_seen_tasks(project_id, updated_at, task_id); + ALTER TABLE project.even_realities_seen_tasks ENABLE ROW LEVEL SECURITY; + ALTER TABLE project.even_realities_seen_tasks FORCE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS fusion_project_isolation ON project.even_realities_seen_tasks; + CREATE POLICY fusion_project_isolation ON project.even_realities_seen_tasks + USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)) + WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)); + DO $even_realities_runtime$ + BEGIN + IF to_regprocedure('project.fusion_assign_project_id()') IS NOT NULL THEN + DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.even_realities_seen_tasks; + CREATE TRIGGER fusion_assign_project_id + BEFORE INSERT OR UPDATE OF project_id ON project.even_realities_seen_tasks + FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON project.even_realities_seen_tasks TO fusion_runtime; + END IF; + END + $even_realities_runtime$; + `)); + }, +}; + /** * FNXC:PostgresSchema 2026-07-04-00:00: * Reports plugin schema-init hook. Creates the reports table in the project @@ -455,10 +504,132 @@ export const DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS: readonly PluginSchemaInitHook[] = roadmapPluginSchemaInit, cePluginSchemaInit, whatsappPluginSchemaInit, + evenRealitiesPluginSchemaInit, reportsPluginSchemaInit, cliPressPluginSchemaInit, ]; +const POSTGRES_PLUGIN_SCHEMA_HOOKS = new Map( + DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS.map((hook) => [hook.pluginId, hook] as const), +); + +const SAFE_POSTGRES_PLUGIN_STATEMENT = /^(?:CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+project\.[a-z][a-z0-9_]*\s*\(|CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+(?:"[^"]+"|[a-z][a-z0-9_]*)\s+ON\s+project\.[a-z][a-z0-9_]*\s*\(|ALTER\s+TABLE\s+project\.[a-z][a-z0-9_]*\s+)/i; +const CREATE_PLUGIN_TABLE = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+project\.([a-z][a-z0-9_]*)\s*\(/i; + +/** + * Validate a third-party schema plan before plugin lifecycle side effects run. + * This is a capability boundary, not a SQL sandbox: installed plugins already + * execute JavaScript, but ordinary hooks never receive migration credentials. + */ +export function validatePluginPostgresSchema( + pluginId: string, + definition: PluginPostgresSchemaDefinition, +): void { + if (!Number.isSafeInteger(definition.version) || definition.version < 1) { + throw new Error(`Plugin "${pluginId}" PostgreSQL schema version must be a positive integer`); + } + if (!/^[a-z][a-z0-9_]*_$/.test(definition.tablePrefix)) { + throw new Error(`Plugin "${pluginId}" PostgreSQL tablePrefix must be lowercase snake_case ending in underscore`); + } + if (!Array.isArray(definition.statements) || definition.statements.length === 0) { + throw new Error(`Plugin "${pluginId}" PostgreSQL schema must declare at least one statement`); + } + for (const statement of definition.statements) { + const normalized = statement.trim().replace(/;\s*$/, ""); + if (!normalized || normalized.includes(";")) { + throw new Error(`Plugin "${pluginId}" PostgreSQL schema requires exactly one statement per item`); + } + if (!SAFE_POSTGRES_PLUGIN_STATEMENT.test(normalized)) { + throw new Error( + `Plugin "${pluginId}" PostgreSQL schema may only use idempotent CREATE TABLE/INDEX or ALTER TABLE statements in the project schema`, + ); + } + for (const [, table] of normalized.matchAll(/\bproject\.([a-z][a-z0-9_]*)\b/gi)) { + if (!table.toLowerCase().startsWith(definition.tablePrefix)) { + throw new Error(`Plugin "${pluginId}" PostgreSQL schema may only reference tables beginning with ${definition.tablePrefix}`); + } + } + if (CREATE_PLUGIN_TABLE.test(normalized)) { + if (!/\bproject_id\s+text\s+NOT\s+NULL\b/i.test(normalized)) { + throw new Error(`Plugin "${pluginId}" PostgreSQL tables must declare project_id text NOT NULL`); + } + if (!/\bPRIMARY\s+KEY\s*\(\s*project_id\s*,/i.test(normalized)) { + throw new Error(`Plugin "${pluginId}" PostgreSQL tables must use a project_id-leading composite primary key`); + } + } + } +} + +/** + * Validate runtime-loaded legacy hooks against the PostgreSQL registry. + * Runtime AsyncDataLayer connections intentionally have DML-only privileges; + * DDL is executed by applySchemaBaseline's migration connection on every boot. + */ +export function assertLoadedPluginSchemaInitHooksSupported( + hooks: ReadonlyArray, +): void { + for (const loaded of hooks) { + if (loaded.postgresSchema) { + validatePluginPostgresSchema(loaded.pluginId, loaded.postgresSchema); + continue; + } + if ((loaded.legacyHook ?? loaded.hook) && !POSTGRES_PLUGIN_SCHEMA_HOOKS.has(loaded.pluginId)) { + throw new Error( + `Plugin "${loaded.pluginId}" declares legacy SQLite onSchemaInit but has no registered PostgreSQL schema hook`, + ); + } + } +} + +/** + * Run the explicit PostgreSQL schema contract for plugins loaded at runtime. + * A legacy `onSchemaInit(Database)` callback is evidence that schema is needed, + * but its SQLite SQL is not portable. Only registered PostgreSQL equivalents + * may run; unknown hooks fail loudly with an actionable contract error. + */ +export async function runLoadedPluginSchemaInitHooks( + db: PostgresJsDatabase>, + hooks: ReadonlyArray, +): Promise { + assertLoadedPluginSchemaInitHooksSupported(hooks); + for (const loaded of hooks) { + if (loaded.postgresSchema) { + const tables = new Set(); + for (const statement of loaded.postgresSchema.statements) { + const normalized = statement.trim().replace(/;\s*$/, ""); + const table = normalized.match(CREATE_PLUGIN_TABLE)?.[1]; + if (table) tables.add(table); + await db.execute(sql.raw(normalized)); + } + for (const table of tables) { + /* + FNXC:PluginPostgresContract 2026-07-14-18:32: + Fusion owns the isolation envelope for third-party tables. Plugins + declare project-local keys; the privileged executor installs forced + RLS, ownership stamping, runtime grants, and a single scoped policy. + */ + await db.execute(sql.raw(` + ALTER TABLE project."${table}" ALTER COLUMN project_id + SET DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'); + ALTER TABLE project."${table}" ENABLE ROW LEVEL SECURITY; + ALTER TABLE project."${table}" FORCE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS fusion_project_isolation ON project."${table}"; + CREATE POLICY fusion_project_isolation ON project."${table}" + USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)) + WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)); + DROP TRIGGER IF EXISTS fusion_assign_project_id ON project."${table}"; + CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id + ON project."${table}" FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); + GRANT SELECT, INSERT, UPDATE, DELETE ON project."${table}" TO fusion_runtime; + `)); + } + continue; + } + const postgresHook = POSTGRES_PLUGIN_SCHEMA_HOOKS.get(loaded.pluginId); + if (postgresHook) await postgresHook.init(db); + } +} + /** * Run the given plugin schema-init hooks in registration order. Each hook is * expected to be idempotent; this function does not swallow hook errors. diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 17ed250ef2..71793c452a 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -27,7 +27,7 @@ import { sql } from "drizzle-orm"; import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js"; /** The latest PostgreSQL schema version known to this applier. */ -export const SCHEMA_BASELINE_VERSION = "0008"; +export const SCHEMA_BASELINE_VERSION = "0009"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -46,6 +46,7 @@ export const SQLITE_SCHEMA_PARITY_VERSION = "0007"; * advisor overrides. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances. */ export const SESSION_ADVISOR_ENABLED_SCHEMA_VERSION = "0008"; +export const MISSION_FIX_IDEMPOTENCY_VERSION = "0009"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; @@ -92,6 +93,11 @@ const SESSION_ADVISOR_ENABLED_MIGRATION_PATH = join( "migrations", "0008_session_advisor_enabled.sql", ); +const MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH = join( + __dirname, + "migrations", + "0009_mission_fix_idempotency.sql", +); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -157,6 +163,7 @@ export async function applySchemaBaseline( const projectOwnershipAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_SCHEMA_VERSION); const sqliteSchemaParityAlreadyApplied = applied.includes(SQLITE_SCHEMA_PARITY_VERSION); const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); + const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION); let schemaChanged = false; if (!baselineAlreadyApplied) { @@ -364,6 +371,22 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* + FNXC:MissionFixIdempotency 2026-07-14-18:55: + Existing PostgreSQL databases receive the validator-run lineage uniqueness invariant independently of earlier schema versions. Duplicate historical rows fail the migration visibly instead of being silently discarded. + + FNXC:PostgresConflictResolution 2026-07-14-20:52: + Main assigned migration 0008 to session-advisor state before the cutover landed, so mission lineage uniqueness advances to 0009. Both migrations must run in order; sharing a bookkeeping version would silently skip one invariant. + */ + if (!missionFixIdempotencyAlreadyApplied) { + const migrationSql = await readFile(MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_FIX_IDEMPOTENCY_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/index.ts b/packages/core/src/postgres/schema/index.ts index a6461091ee..a1736029e7 100644 --- a/packages/core/src/postgres/schema/index.ts +++ b/packages/core/src/postgres/schema/index.ts @@ -27,4 +27,4 @@ export * as plugin from "./plugin.js"; export { projectTableNames } from "./project.js"; export { centralTableNames } from "./central.js"; export { archiveTableNames } from "./archive.js"; -export { roadmapPluginTableNames, cePluginTableNames, reportsPluginTableNames, cliPressPluginTableNames } from "./plugin.js"; +export { roadmapPluginTableNames, cePluginTableNames, evenRealitiesPluginTableNames, reportsPluginTableNames, cliPressPluginTableNames } from "./plugin.js"; diff --git a/packages/core/src/postgres/schema/plugin.ts b/packages/core/src/postgres/schema/plugin.ts index 80bd2bc35a..4a1eacfddc 100644 --- a/packages/core/src/postgres/schema/plugin.ts +++ b/packages/core/src/postgres/schema/plugin.ts @@ -16,7 +16,7 @@ * while still materializing on a fresh database. */ -import { text, integer, bigint, boolean, foreignKey, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { text, integer, bigint, boolean, foreignKey, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; import { projectSchema } from "./project.js"; /** @@ -71,6 +71,23 @@ export const roadmapPluginTableNames = [ "roadmap_features", ] as const; +// ── Even Realities plugin tables ─────────────────────────────────── +/** + * FNXC:EvenRealitiesPostgres 2026-07-14-17:25: + * Notification dedupe state is durable, project-private plugin data. The PostgreSQL runtime stores one snapshot row per project/task so identical task IDs in separate projects never suppress each other's glasses notifications. + */ +export const evenRealitiesSeenTasks = projectSchema.table("even_realities_seen_tasks", { + projectId: text("project_id").notNull(), + taskId: text("task_id").notNull(), + lastColumn: text("last_column").notNull(), + updatedAt: text("updated_at").notNull(), +}, (t) => [ + primaryKey({ columns: [t.projectId, t.taskId] }), + index("idxEvenRealitiesSeenTasksProjectUpdated").on(t.projectId, t.updatedAt, t.taskId), +]); + +export const evenRealitiesPluginTableNames = ["even_realities_seen_tasks"] as const; + // ── Compound Engineering plugin tables ────────────────────────────── // FNXC:PostgresSchema 2026-07-04-00:00: // Mirror of plugins/fusion-plugin-compound-engineering/src/schema.ts diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 2dd802edbe..b0de061f20 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1228,22 +1228,27 @@ export const pullRequestThreadState = projectSchema.table("pull_request_thread_s ]); export const goals = projectSchema.table("goals", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), title: text("title").notNull(), description: text("description"), status: text("status").notNull(), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), -}, (t) => [index("idxGoalsStatus").on(t.status)]); +}, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), + index("idxGoalsStatus").on(t.status), +]); export const missionGoals = projectSchema.table("mission_goals", { + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), missionId: text("mission_id").notNull(), goalId: text("goal_id").notNull(), createdAt: text("created_at").notNull(), }, (t) => [ - primaryKey({ columns: [t.missionId, t.goalId] }), - foreignKey({ columns: [t.missionId], foreignColumns: [missions.id] }).onDelete("cascade"), - foreignKey({ columns: [t.goalId], foreignColumns: [goals.id] }).onDelete("cascade"), + primaryKey({ columns: [t.projectId, t.missionId, t.goalId] }), + foreignKey({ columns: [t.projectId, t.missionId], foreignColumns: [missions.projectId, missions.id] }).onDelete("cascade"), + foreignKey({ columns: [t.projectId, t.goalId], foreignColumns: [goals.projectId, goals.id] }).onDelete("cascade"), index("idxMissionGoalsGoalId").on(t.goalId), ]); @@ -1351,7 +1356,8 @@ export const missionFeatures = projectSchema.table("mission_features", { ]); export const missionEvents = projectSchema.table("mission_events", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), missionId: text("mission_id").notNull(), eventType: text("event_type").notNull(), description: text("description").notNull(), @@ -1359,7 +1365,8 @@ export const missionEvents = projectSchema.table("mission_events", { timestamp: text("timestamp").notNull(), seq: integer("seq").notNull().default(0), }, (t) => [ - foreignKey({ columns: [t.missionId], foreignColumns: [missions.id] }).onDelete("cascade"), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ columns: [t.projectId, t.missionId], foreignColumns: [missions.projectId, missions.id] }).onDelete("cascade"), index("idxMissionEventsMissionId").on(t.missionId), index("idxMissionEventsTimestamp").on(t.timestamp), index("idxMissionEventsType").on(t.eventType), @@ -1535,9 +1542,12 @@ export const pluginActivations = projectSchema.table("plugin_activations", { export const knowledgePages = projectSchema.table("knowledge_pages", { id: integer("id").generatedAlwaysAsIdentity().primaryKey(), + // FNXC:KnowledgeIndex 2026-07-14-16:35: + // Knowledge pages contain task and PR history, so their Drizzle model must expose the project ownership added by migration 0006. Async dashboard reads and upserts use this key explicitly in addition to the database RLS policy. + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), sourceKind: text("source_kind").notNull(), sourceId: text("source_id").notNull(), - sourceKey: text("source_key").notNull().unique(), + sourceKey: text("source_key").notNull(), title: text("title").notNull(), summary: text("summary"), content: text("content").notNull(), @@ -1546,6 +1556,7 @@ export const knowledgePages = projectSchema.table("knowledge_pages", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ + uniqueIndex("knowledge_pages_source_key_unique").on(t.projectId, t.sourceKey), index("idxKnowledgePagesSourceKind").on(t.sourceKind), index("idxKnowledgePagesUpdatedAt").on(t.updatedAt), ]); @@ -1786,17 +1797,19 @@ export const missionContractAssertions = projectSchema.table("mission_contract_a ]); export const missionFeatureAssertions = projectSchema.table("mission_feature_assertions", { + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), featureId: text("feature_id").notNull(), assertionId: text("assertion_id").notNull(), createdAt: text("created_at").notNull(), }, (t) => [ - primaryKey({ columns: [t.featureId, t.assertionId] }), + primaryKey({ columns: [t.projectId, t.featureId, t.assertionId] }), index("idxFeatureAssertionsFeatureId").on(t.featureId), index("idxFeatureAssertionsAssertionId").on(t.assertionId), ]); export const missionValidatorRuns = projectSchema.table("mission_validator_runs", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), featureId: text("feature_id").notNull(), milestoneId: text("milestone_id").notNull(), sliceId: text("slice_id").notNull(), @@ -1812,6 +1825,7 @@ export const missionValidatorRuns = projectSchema.table("mission_validator_runs" updatedAt: text("updated_at").notNull(), taskId: text("task_id"), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), index("idxValidatorRunsFeatureId").on(t.featureId), index("idxValidatorRunsMilestoneId").on(t.milestoneId), index("idxValidatorRunsSliceId").on(t.sliceId), @@ -1819,7 +1833,8 @@ export const missionValidatorRuns = projectSchema.table("mission_validator_runs" ]); export const missionValidatorFailures = projectSchema.table("mission_validator_failures", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), runId: text("run_id").notNull(), featureId: text("feature_id").notNull(), assertionId: text("assertion_id").notNull(), @@ -1828,19 +1843,22 @@ export const missionValidatorFailures = projectSchema.table("mission_validator_f actual: text("actual"), createdAt: text("created_at").notNull(), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), index("idxValidatorFailuresRunId").on(t.runId), index("idxValidatorFailuresFeatureId").on(t.featureId), index("idxValidatorFailuresAssertionId").on(t.assertionId), ]); export const missionFixFeatureLineage = projectSchema.table("mission_fix_feature_lineage", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), sourceFeatureId: text("source_feature_id").notNull(), fixFeatureId: text("fix_feature_id").notNull(), runId: text("run_id").notNull(), failedAssertionIds: jsonb("failed_assertion_ids").notNull().default([]), createdAt: text("created_at").notNull(), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), index("idxFixLineageSourceFeatureId").on(t.sourceFeatureId), index("idxFixLineageFixFeatureId").on(t.fixFeatureId), index("idxFixLineageRunId").on(t.runId), diff --git a/packages/core/src/postgres/sqlite-migrator.ts b/packages/core/src/postgres/sqlite-migrator.ts index 92a03d8e43..b68dc42451 100644 --- a/packages/core/src/postgres/sqlite-migrator.ts +++ b/packages/core/src/postgres/sqlite-migrator.ts @@ -773,10 +773,8 @@ function openSqlite(path: string): DatabaseSync { // ":memory:". The migrator is a cutover tool run by operators against a // real .fusion path, so the real-path guard is bypassed only when the path // is explicit. Here we use the standard constructor; tests pass temp paths. - const db = new DatabaseSync(path); - // Read-only guard: open with immutable so we never modify the source. - // (node:sqlite does not have a read-only open flag in the constructor; we - // simply never issue writes against the source.) + // FNXC:LegacySqliteBoundary 2026-07-14-18:42: the cutover migrator reads legacy sources without checkpointing or modifying them. + const db = new DatabaseSync(path, { readOnly: true }); return db; } diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 0261cda537..c1740a3755 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -4,17 +4,14 @@ * FNXC:RuntimeStartupWiring 2026-06-26-14:00: * This is the single startup entry point that production construction sites * (engine InProcessRuntime, dashboard project-store-resolver, CLI serve/ - * dashboard commands, desktop local-server/local-runtime) consult to decide - * whether to boot against PostgreSQL or fall back to the legacy SQLite path. + * dashboard commands, desktop local-server/local-runtime) use to boot + * PostgreSQL. * * The factory encapsulates the five-step backend boot sequence so individual * call sites do not each re-implement backend resolution, connection opening, * schema application, AsyncDataLayer construction, and dual-read harness - * integration. A call site asks: "given the current environment, do I get a - * PostgreSQL-backed TaskStore, or do I keep the SQLite default?" The factory - * answers with either a ready {@link BackendBootResult} or `null` (meaning: - * use the legacy SQLite construction — byte-identical to the pre-migration - * path). + * integration. The factory always returns a ready {@link BackendBootResult} + * or throws an actionable startup error. * * Resolution rules (matching the mission architecture): * - DATABASE_URL set (external mode): connect to the external PostgreSQL @@ -24,23 +21,19 @@ * PostgreSQL, then proceed like external mode against the embedded URL. * This is the DEFAULT production path — embedded PG is the zero-config * backend, mirroring the zero-config SQLite experience it replaces. - * - DATABASE_URL unset AND FUSION_NO_EMBEDDED_PG=1: return `null`. The caller - * constructs the legacy SQLite-backed TaskStore. This is the explicit - * opt-out for the legacy SQLite path, available for backward compatibility - * during the cutover window. + * - DATABASE_URL unset AND FUSION_NO_EMBEDDED_PG=1: reject the obsolete + * configuration. SQLite files are accepted only as migration inputs. * * FNXC:BackendFlip 2026-06-26-14:05: * The default backend was flipped from SQLite to embedded PostgreSQL in this * change (feature flip-embedded-pg-default, cutover milestone). Previously * embedded PG required an explicit opt-in via FUSION_EMBEDDED_PG=1; now it is - * the default and FUSION_NO_EMBEDDED_PG=1 is the opt-out back to legacy - * SQLite. FUSION_EMBEDDED_PG=1 is still honored as a no-op alias for backward + * the default. FUSION_EMBEDDED_PG=1 is still honored as a no-op alias for backward * compatibility (it cannot force embedded when DATABASE_URL is set, since * external mode always wins). The flip is safe because the embedded-postgres * platform binaries are now bundled for macOS/Linux/Windows (arm64/x64) and * the boot smoke has been updated to exercise the embedded path by default - * with an initdb-aware health-check timeout. Tests that need the fast SQLite - * default (no initdb, no binary) set FUSION_NO_EMBEDDED_PG=1 explicitly. + * with an initdb-aware health-check timeout. */ import { join, resolve } from "node:path"; @@ -58,10 +51,11 @@ import { import { createConnectionSet, createConnectionSetFromUrl, - DatabaseConnectionError, + type PostgresConnections, } from "./connection.js"; import { applySchemaBaseline } from "./schema-applier.js"; import { createAsyncDataLayer, type AsyncDataLayer } from "./data-layer.js"; +import { runLoadedPluginSchemaInitHooks, type LoadedPluginSchemaContract } from "./plugin-schema-hook.js"; import { lookupRegisteredProjectIdByPath, rekeyFallbackProjectPartition, @@ -106,12 +100,8 @@ export const EMBEDDED_PG_ENV = "FUSION_EMBEDDED_PG"; /** * FNXC:BackendFlip 2026-06-26-14:10: - * Opt-out environment variable that forces the legacy SQLite backend when - * DATABASE_URL is unset. This is the escape hatch for the cutover window: - * operators or tests that need the fast, no-binary SQLite default set - * FUSION_NO_EMBEDDED_PG=1. Truthy values: 1, true, yes, on (case-insensitive). - * Everything else (unset, 0, no, false, off) means "use the embedded PG - * default". + * Retired SQLite opt-out variable. It remains parseable so startup can return + * a clear migration error instead of silently ignoring stale operator config. */ export const NO_EMBEDDED_PG_ENV = "FUSION_NO_EMBEDDED_PG"; @@ -121,14 +111,11 @@ export const NO_EMBEDDED_PG_ENV = "FUSION_NO_EMBEDDED_PG"; * * FNXC:BackendFlip 2026-06-26-14:15: * Post default-flip, embedded PG is the DEFAULT in embedded mode. The legacy - * FUSION_EMBEDDED_PG opt-in is now a no-op (setting it does nothing because - * embedded is already on). The only way to opt OUT of embedded PG back to - * legacy SQLite is FUSION_NO_EMBEDDED_PG=1. This function returns true unless - * the opt-out is set. + * FUSION_EMBEDDED_PG opt-in is a no-op. A false result identifies obsolete + * opt-out configuration that the startup factory rejects. * - * The opt-out is honored when set to a truthy value: 1, true, yes, on - * (case-insensitive). Everything else (unset, 0, no, false, off) means - * "use the embedded PG default" (return true). + * A retired opt-out value is detected so startup can reject it with a clear + * migration message. Everything else uses embedded PostgreSQL by default. * * @returns true when embedded PG should be used (the default); false when the * operator explicitly opted out via FUSION_NO_EMBEDDED_PG=1. @@ -139,9 +126,7 @@ export function isEmbeddedPgRequested(env: NodeJS.ProcessEnv = process.env): boo /** * FNXC:BackendFlip 2026-06-26-14:15: - * Return true when the operator has explicitly opted out of embedded PG via - * FUSION_NO_EMBEDDED_PG=1 (the legacy SQLite escape hatch). Exposed for test - * assertion and call-site cheap checks. + * Detect obsolete FUSION_NO_EMBEDDED_PG configuration for diagnostics. */ export function isEmbeddedPgOptedOut(env: NodeJS.ProcessEnv = process.env): boolean { const raw = (env[NO_EMBEDDED_PG_ENV] ?? "").trim().toLowerCase(); @@ -169,6 +154,158 @@ export interface BackendBootResult { shutdown(): Promise; } +/** PostgreSQL resources used by CentralCore before a project TaskStore exists. */ +export interface CentralBackendLayerResult { + readonly backend: ResolvedBackend; + readonly asyncLayer: AsyncDataLayer; + releaseConnections(): Promise; + shutdown(): Promise; +} + +interface SchemaBackendBootResult { + readonly backend: ResolvedBackend; + readonly connections: PostgresConnections; + readonly embeddedLifecycle: EmbeddedLifecycleLike | null; +} + +/** + * FNXC:PostgresStartupLifecycle 2026-07-14-19:18: + * Central-registry and project-store startup must share one backend-resolution, + * embedded-lifecycle, administrative-connection, and schema-baseline path. + * Callers retain ownership of the returned resources and may replace the + * administrative pool with an RLS-bound runtime pool after migration. + */ +async function bootSchemaBackend( + options: Pick, + bypassProjectIsolation = false, +): Promise { + const env = options.env ?? process.env; + const backend = options.backend ?? resolveBackend(env); + const embeddedRequested = options.embeddedPgRequested ?? isEmbeddedPgRequested(env); + if (backend.mode === "embedded" && !embeddedRequested) { + throw new Error( + "The SQLite opt-out has been removed. Unset FUSION_NO_EMBEDDED_PG and use embedded PostgreSQL, or configure DATABASE_URL.", + ); + } + + let embeddedLifecycle: EmbeddedLifecycleLike | null = null; + let resolvedBackend = backend; + if (backend.mode === "embedded") { + const { EmbeddedPostgresLifecycle, defaultEmbeddedDataDir, DEFAULT_EMBEDDED_DATABASE } = + await import("./embedded-lifecycle.js"); + const dataDir = resolve(options.embeddedDataDir ?? defaultEmbeddedDataDir()); + log.log(`startup-factory: starting embedded PostgreSQL (data dir ${dataDir})`); + embeddedLifecycle = new EmbeddedPostgresLifecycle({ + dataDir, + database: DEFAULT_EMBEDDED_DATABASE, + onLog: (message) => log.log(message), + onError: (error) => log.error(String(error)), + }); + try { + resolvedBackend = await embeddedLifecycle.start(); + } catch (error) { + await embeddedLifecycle.stop().catch(() => undefined); + throw new Error( + `startup-factory: failed to start embedded PostgreSQL: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + log.log(describeBackendForLog(resolvedBackend)); + let connections: PostgresConnections | undefined; + try { + connections = resolvedBackend.mode === "external" + ? await createConnectionSet(env, { + backend: resolvedBackend, + poolMax: options.poolMax, + bypassProjectIsolation, + }) + : await createConnectionSetFromUrl(resolvedBackend, { + poolMax: options.poolMax, + bypassProjectIsolation, + }); + await applySchemaBaseline(connections.migration); + return { backend: resolvedBackend, connections, embeddedLifecycle }; + } catch (error) { + await connections?.close().catch(() => undefined); + await embeddedLifecycle?.stop().catch(() => undefined); + throw error; + } +} + +/** + * Open an unscoped PostgreSQL layer for the central project/node registry. + * + * FNXC:CentralPostgresCutover 2026-07-14-17:12: + * CentralCore is used by project discovery and node commands before any + * project-scoped TaskStore exists. It therefore needs a first-class backend + * bootstrap that applies the shared schema without inventing a project root or + * constructing a dead SQLite CentralDatabase. Embedded instances are reused by + * the lifecycle registry, while each caller owns only its connection pool. + */ +export async function createCentralBackendLayer( + options: Pick = {}, +): Promise { + const boot = await bootSchemaBackend(options, true); + const { backend: resolvedBackend, connections, embeddedLifecycle } = boot; + try { + /* + FNXC:CentralPostgresCutover 2026-07-14-19:06: + Central-only startup must import and verify fusion-central.db before exposing the registry layer. Project startup is not guaranteed to run first, so deferring this source made legacy projects and nodes appear missing from PostgreSQL-only commands. + */ + let globalDir = options.globalSettingsDir; + if (!globalDir) { + const { resolveGlobalDir } = await import("../global-settings.js"); + globalDir = resolveGlobalDir(); + } + const legacyCentralPath = join(globalDir, "fusion-central.db"); + const { + CENTRAL_SQLITE_MIGRATION_KEY, + formatMigrationProgress, + isSqliteMigrationComplete, + migrateSqliteToPostgres, + } = await import("./sqlite-migrator.js"); + const centralMigrationComplete = await isSqliteMigrationComplete( + connections.migration, + CENTRAL_SQLITE_MIGRATION_KEY, + ); + if (!centralMigrationComplete && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) { + const report = await migrateSqliteToPostgres(connections.migration, [{ + sqlitePath: legacyCentralPath, + pgSchema: "central", + }], { + skipBaseline: true, + migrationKey: CENTRAL_SQLITE_MIGRATION_KEY, + onProgress: (event) => log.log(`central startup: SQLite migration — ${formatMigrationProgress(event)}`), + }); + const failures = report.tables.filter((table) => !table.skipped && !table.verified); + if (failures.length > 0) { + throw new Error(`${failures.length} central table(s) failed verification: ${failures.map((table) => table.table).join(", ")}`); + } + } + const asyncLayer = createAsyncDataLayer(connections); + let connectionsReleased = false; + const releaseConnections = async (): Promise => { + if (connectionsReleased) return; + connectionsReleased = true; + await asyncLayer.close().catch(() => undefined); + }; + return { + backend: resolvedBackend, + asyncLayer, + releaseConnections, + async shutdown(): Promise { + await releaseConnections(); + await embeddedLifecycle?.stop().catch(() => undefined); + }, + }; + } catch (error) { + await connections.close().catch(() => undefined); + await embeddedLifecycle?.stop().catch(() => undefined); + throw error; + } +} + /** * Options for {@link createTaskStoreForBackend}. */ @@ -191,9 +328,8 @@ export interface CreateTaskStoreForBackendOptions { readonly backend?: ResolvedBackend; /** * Override the embedded-PG decision (tests). When omitted, the decision is - * read from the environment: embedded PG is on by default unless - * FUSION_NO_EMBEDDED_PG=1 is set. Pass `true` to force embedded, `false` to - * force the legacy SQLite path. + * read from the environment. Pass `true` to force embedded in tests; + * `false` exercises the retired-opt-out error path. */ readonly embeddedPgRequested?: boolean; /** @@ -215,32 +351,32 @@ export interface CreateTaskStoreForBackendOptions { /** * Decide whether the factory should attempt a PostgreSQL boot for the given - * environment. Returns true when DATABASE_URL is set (external) or embedded PG - * is the default (DATABASE_URL unset, no opt-out). Returns false only when the - * operator explicitly opted out via FUSION_NO_EMBEDDED_PG=1. + * environment. PostgreSQL is the only runtime backend, so this compatibility + * probe always returns true. * * Exposed so call sites can cheaply check "should I even try PostgreSQL?" * before awaiting the full factory (which opens connections). */ export function shouldUsePostgresBackend( - env: NodeJS.ProcessEnv = process.env, - opts: { embeddedPgRequested?: boolean } = {}, + _env: NodeJS.ProcessEnv = process.env, + _opts: { embeddedPgRequested?: boolean } = {}, ): boolean { - const backend = resolveBackend(env); - if (backend.mode === "external") return true; - const embeddedRequested = opts.embeddedPgRequested ?? isEmbeddedPgRequested(env); - return embeddedRequested; + /* + * FNXC:PostgresFinalCutover 2026-07-14-17:08: + * PostgreSQL is the only runtime backend after the final migration. Keep this + * compatibility probe deterministic so old callers cannot interpret an + * obsolete environment flag as permission to construct a SQLite TaskStore. + */ + return true; } /** - * Construct a PostgreSQL-backed TaskStore for the current environment, or - * return `null` when the legacy SQLite path should be used. + * Construct a PostgreSQL-backed TaskStore for the current environment. * * FNXC:BackendFlip 2026-06-26-14:20: * Post default-flip, the sequence is: * 1. Resolve the backend (external via DATABASE_URL, or embedded when unset). - * 2. If embedded mode AND the operator opted out (FUSION_NO_EMBEDDED_PG=1), - * return null — caller uses legacy SQLite. + * 2. Reject the retired SQLite opt-out in embedded mode. * 3. For external mode: open connections via createConnectionSet. * 4. For embedded mode: start the EmbeddedPostgresLifecycle, then open * connections via createConnectionSetFromUrl with the resolved URL. @@ -255,23 +391,27 @@ export function shouldUsePostgresBackend( * which redacts the password (VAL-CONN-004, VAL-CONN-005). The resolved * backend is logged via describeBackendForLog (password redacted). * - * @returns the backend boot result, or `null` to use the legacy SQLite path. + * @returns the mandatory PostgreSQL backend boot result. */ export async function createTaskStoreForBackend( options: CreateTaskStoreForBackendOptions, -): Promise { +): Promise { const env = options.env ?? process.env; const backend = options.backend ?? resolveBackend(env); const embeddedRequested = options.embeddedPgRequested ?? isEmbeddedPgRequested(env); - // FNXC:BackendFlip 2026-06-26-14:25: - // Step 2: the ONLY way to reach the legacy SQLite path post default-flip is - // the explicit opt-out (FUSION_NO_EMBEDDED_PG=1). When the operator opts out, - // `embeddedRequested` is false and we return null so the caller constructs the - // legacy SQLite-backed TaskStore. In every other embedded-mode case, embedded - // PG is the default and we proceed to boot it. + /* + * FNXC:PostgresFinalCutover 2026-07-14-17:08: + * The SQLite runtime and its Database implementation have been removed, so + * the historical opt-out must fail explicitly. Returning null here caused + * dozens of callers to construct a non-functional TaskStore and split the + * central registry away from PostgreSQL. External DATABASE_URL always wins; + * the obsolete flag is only rejected when it would have selected SQLite. + */ if (backend.mode === "embedded" && !embeddedRequested) { - return null; + throw new Error( + "The SQLite opt-out has been removed. Unset FUSION_NO_EMBEDDED_PG and use embedded PostgreSQL, or configure DATABASE_URL.", + ); } // When constructing via the constructor (no projectId), rootDir is required. @@ -282,89 +422,16 @@ export async function createTaskStoreForBackend( } const rootDir = options.rootDir ?? ""; - let embeddedLifecycle: EmbeddedLifecycleLike | null = null; - let resolvedBackend: ResolvedBackend = backend; - - // Step 4: embedded mode — start the bundled PostgreSQL first so we have a - // connection URL. createConnectionSet throws in embedded mode without a URL. - // - // FNXC:BackendFlip 2026-06-26-14:25: - // This branch now runs by default in embedded mode (DATABASE_URL unset) - // unless the operator opted out. The embedded-lifecycle module is imported - // LAZILY here (see the note at the top of the file) so the `embedded-postgres` - // package and its platform-specific dynamic imports stay out of the CLI - // bundle unless embedded PG is actually used at runtime. - if (backend.mode === "embedded" && embeddedRequested) { - const { EmbeddedPostgresLifecycle, defaultEmbeddedDataDir, DEFAULT_EMBEDDED_DATABASE } = - await import("./embedded-lifecycle.js"); - const dataDir = resolve(options.embeddedDataDir ?? defaultEmbeddedDataDir()); - log.log(`startup-factory: starting embedded PostgreSQL (data dir ${dataDir})`); - embeddedLifecycle = new EmbeddedPostgresLifecycle({ - dataDir, - database: DEFAULT_EMBEDDED_DATABASE, - onLog: (msg) => log.log(msg), - onError: (err) => log.error(String(err)), - }); - try { - resolvedBackend = await embeddedLifecycle.start(); - } catch (err) { - // FNXC:BackendFlip 2026-06-26-14:25: - // Embedded startup failure is fatal — embedded PG is the default and the - // operator did not opt out. Surface a clear error rather than silently - // falling back to SQLite (which would mask a real binary/environment - // problem and could split-write two backends). - await embeddedLifecycle.stop().catch(() => undefined); - throw new Error( - `startup-factory: failed to start embedded PostgreSQL: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - } - - log.log(describeBackendForLog(resolvedBackend)); - - // Steps 3 & 4 (connection opening). External mode uses createConnectionSet - // (resolves from env); embedded mode uses createConnectionSetFromUrl with - // the lifecycle-provided URL. - let connections; + let boot: SchemaBackendBootResult; try { - if (resolvedBackend.mode === "external") { - connections = await createConnectionSet(env, { - backend: resolvedBackend, - poolMax: options.poolMax, - }); - } else { - connections = await createConnectionSetFromUrl(resolvedBackend, { - poolMax: options.poolMax, - }); - } + boot = await bootSchemaBackend(options); } catch (err) { - // VAL-CONN-004: unreachable DATABASE_URL fails loudly. If we started an - // embedded cluster, stop it before propagating. - if (embeddedLifecycle) { - await embeddedLifecycle.stop().catch(() => undefined); - } - if (err instanceof DatabaseConnectionError) { - throw err; - } - throw err; - } - - // Step 5: apply the schema baseline (idempotent) to the migration connection. - try { - await applySchemaBaseline(connections.migration); - } catch (err) { - await connections.close().catch(() => undefined); - if (embeddedLifecycle) { - await embeddedLifecycle.stop().catch(() => undefined); - } throw new Error( - `startup-factory: failed to apply PostgreSQL schema baseline: ${ - err instanceof Error ? err.message : String(err) - }`, + `startup-factory: failed to initialize PostgreSQL schema backend: ${err instanceof Error ? err.message : String(err)}`, ); } + let { connections } = boot; + const { backend: resolvedBackend, embeddedLifecycle } = boot; /* FNXC:PostgresMigration 2026-07-10: @@ -416,7 +483,8 @@ export async function createTaskStoreForBackend( const legacyCentralPath = globalDir ? join(globalDir, "fusion-central.db") : undefined; if (!migrationProjectId && legacyCentralPath && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) { const { DatabaseSync } = await import("../sqlite-adapter.js"); - const legacyCentral = new DatabaseSync(legacyCentralPath); + // FNXC:LegacySqliteBoundary 2026-07-14-18:42: central identity lookup is migration-only and read-only. + const legacyCentral = new DatabaseSync(legacyCentralPath, { readOnly: true }); try { const row = legacyCentral.prepare(`SELECT id FROM projects WHERE path = ? LIMIT 1`).get(rootDir) as | { id: string } @@ -452,15 +520,11 @@ export async function createTaskStoreForBackend( instead surface through scoped post-copy verification and fail closed. Without a registered identity the legacy whole-table check applies. - FNXC:PostgresCutover 2026-07-13-20:50: + FNXC:PostgresCutover 2026-07-14-18:42: Order matters: the PostgreSQL emptiness count runs BEFORE the SQLite - validity probe. isValidSqliteDatabaseFile opens the file with a - read-write DatabaseSync, and that open/close performs WAL recovery and - a checkpoint — i.e. it WRITES to the legacy fusion.db on every boot. - Post-cutover the legacy files must stay byte-quiet: steady-state boots - (PG already populated) must not open SQLite at all. The probe now runs - only on the rare empty-PG path where auto-migration is actually being - considered. + validity probe. The probe is read-only, and steady-state boots (PG + already populated) still avoid opening SQLite entirely. It runs only + on the empty-PG path where one-time auto-migration is considered. */ const migrationKey = `project:${migrationProjectId ?? rootDir}`; const { migrateSqliteToPostgres, defaultMigrationSources, formatMigrationProgress, isSqliteMigrationComplete, completeSqliteMigration, recordSqliteMigrationComplete, CENTRAL_SQLITE_MIGRATION_KEY } = await import("./sqlite-migrator.js"); @@ -576,7 +640,7 @@ export async function createTaskStoreForBackend( await embeddedLifecycle.stop().catch(() => undefined); } throw new Error( - `startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; run 'fn db migrate' manually or set FUSION_NO_EMBEDDED_PG=1 to stay on SQLite): ${ + `startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; restore the retained backup and run 'fn db migrate' manually): ${ err instanceof Error ? err.message : String(err) }`, ); @@ -670,6 +734,30 @@ export async function createTaskStoreForBackend( ); } + /* + FNXC:PluginPostgresContract 2026-07-14-18:32: + Plugin DDL uses a one-shot administrative pool created only after a validated + declarative plan arrives. The TaskStore holds the executor capability, not + the connection, and PluginContext exposes neither one to runtime hooks. + */ + taskStore.setPluginPostgresSchemaExecutor(async (contracts: readonly LoadedPluginSchemaContract[]) => { + const schemaConnections = resolvedBackend.mode === "external" + ? await createConnectionSet(env, { + backend: resolvedBackend, + poolMax: 1, + bypassProjectIsolation: true, + }) + : await createConnectionSetFromUrl(resolvedBackend, { + poolMax: 1, + bypassProjectIsolation: true, + }); + try { + await runLoadedPluginSchemaInitHooks(schemaConnections.migration, contracts); + } finally { + await schemaConnections.close(); + } + }); + /* FNXC:PostgresMigrationBanner 2026-07-12: Step 7.5 — persist the auto-migration notice into project settings so the diff --git a/packages/core/src/project-identity.ts b/packages/core/src/project-identity.ts index 83390c93bc..92486e1f89 100644 --- a/packages/core/src/project-identity.ts +++ b/packages/core/src/project-identity.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { and, eq } from "drizzle-orm"; import { DatabaseSync } from "./sqlite-adapter.js"; @@ -12,6 +12,7 @@ const PROJECT_ID_RE = /^proj_[a-f0-9]{16}$/; /** __meta keys backing the project identity stamp. */ const META_KEY_PROJECT_ID = "projectId"; const META_KEY_PROJECT_CREATED_AT = "projectCreatedAt"; +export const PROJECT_IDENTITY_FILENAME = "project.json"; export type ProjectIdentity = { id: string; createdAt: string }; @@ -45,13 +46,38 @@ function readMeta(db: DatabaseSync, key: string): string | undefined { } export function readProjectIdentity(fusionDir: string): ProjectIdentity | null { - const dbPath = join(resolveFusionDir(fusionDir), "fusion.db"); + const resolvedFusionDir = resolveFusionDir(fusionDir); + const markerPath = join(resolvedFusionDir, PROJECT_IDENTITY_FILENAME); + /* + * FNXC:ProjectIdentityMarker 2026-07-14-17:10: + * Project discovery and identity must not require opening fusion.db after the + * PostgreSQL cutover. Prefer a small filesystem marker; read the SQLite meta + * table only as a one-way compatibility path for pre-cutover projects. + */ + if (existsSync(markerPath)) { + try { + const parsed = JSON.parse(readFileSync(markerPath, "utf8")) as Partial; + if (typeof parsed.id !== "string" || !PROJECT_ID_RE.test(parsed.id) || typeof parsed.createdAt !== "string" || !parsed.createdAt) { + log.warn(`Ignoring malformed project identity in ${markerPath}`); + return null; + } + return { id: parsed.id, createdAt: parsed.createdAt }; + } catch (error) { + log.warn(`Unable to read project identity from ${markerPath}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + const dbPath = join(resolvedFusionDir, "fusion.db"); if (!existsSync(dbPath)) return null; let db: DatabaseSync | undefined; try { - db = new DatabaseSync(dbPath); - db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); + /* FNXC:LegacySqliteBoundary 2026-07-14-18:42: + * This is a one-way identity import for projects without project.json. + * It must be read-only and must not materialize __meta in a legacy file. + */ + db = new DatabaseSync(dbPath, { readOnly: true }); const id = readMeta(db, "projectId"); const createdAt = readMeta(db, "projectCreatedAt"); if (!id || !createdAt) return null; @@ -77,20 +103,19 @@ export function writeProjectIdentity(fusionDir: string, identity: ProjectIdentit if (!existsSync(resolvedFusionDir)) { mkdirSync(resolvedFusionDir, { recursive: true }); } - const dbPath = join(resolvedFusionDir, "fusion.db"); - let db: DatabaseSync | undefined; - try { - db = new DatabaseSync(dbPath); - db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); - const existingId = readMeta(db, "projectId"); - if (existingId && existingId !== identity.id) { - throw new ProjectIdentityMismatchError(existingId, identity.id); - } - db.prepare("INSERT OR REPLACE INTO __meta (key, value) VALUES (?, ?)").run("projectId", identity.id); - db.prepare("INSERT OR REPLACE INTO __meta (key, value) VALUES (?, ?)").run("projectCreatedAt", identity.createdAt); - } finally { - db?.close(); + const existing = readProjectIdentity(resolvedFusionDir); + if (existing && existing.id !== identity.id) { + throw new ProjectIdentityMismatchError(existing.id, identity.id); } + const markerPath = join(resolvedFusionDir, PROJECT_IDENTITY_FILENAME); + const temporaryPath = `${markerPath}.${process.pid}.tmp`; + writeFileSync(temporaryPath, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(temporaryPath, markerPath); +} + +/** Return true when a PostgreSQL-era marker or a legacy SQLite identity exists. */ +export function hasProjectIdentity(fusionDir: string): boolean { + return readProjectIdentity(fusionDir) !== null; } // ───────────────────────────────────────────────────────────────────── diff --git a/packages/core/src/project-root-guard.ts b/packages/core/src/project-root-guard.ts index 81a85f9c6f..d9ec8beef8 100644 --- a/packages/core/src/project-root-guard.ts +++ b/packages/core/src/project-root-guard.ts @@ -7,9 +7,20 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { PROJECT_IDENTITY_FILENAME } from "./project-identity.js"; const FUSION_DIR_SUFFIX = /(?:^|[\\/])\.fusion(?:[\\/])?$/; +/** + * FNXC:PostgresProjectDiscovery 2026-07-14-17:30: + * The PostgreSQL-era project marker protects linked worktrees from accidental + * nested initialization. `fusion.db` remains a legacy signal only. + */ +function hasFusionProjectSignal(rootDir: string): boolean { + return existsSync(join(rootDir, ".fusion", PROJECT_IDENTITY_FILENAME)) + || existsSync(join(rootDir, ".fusion", "fusion.db")); +} + export class LinkedWorktreeBootstrapRefusedError extends Error { constructor(cwd: string, parentRoot: string) { super( @@ -30,7 +41,7 @@ export function assertProjectRootDir(rootDir: string, caller: string): void { export function assertNotLinkedWorktreeOfExistingProject(rootDir: string, _caller: string): void { const resolvedRootDir = resolve(rootDir); - if (existsSync(join(resolvedRootDir, ".fusion", "fusion.db"))) { + if (hasFusionProjectSignal(resolvedRootDir)) { return; } if ( @@ -66,7 +77,7 @@ export function assertNotLinkedWorktreeOfExistingProject(rootDir: string, _calle ? dirname(resolvedCommonDir) : resolvedCommonDir; - if (!existsSync(join(parentRoot, ".fusion", "fusion.db"))) { + if (!hasFusionProjectSignal(parentRoot)) { return; } diff --git a/packages/core/src/settings-export.ts b/packages/core/src/settings-export.ts index bf9ae0dc46..fbffe33c1b 100644 --- a/packages/core/src/settings-export.ts +++ b/packages/core/src/settings-export.ts @@ -217,7 +217,7 @@ export async function exportSettings( ) as Partial; } - const workflowSettings = store.listWorkflowSettingValuesForProject(); + const workflowSettings = await store.listWorkflowSettingValuesForProject(); // Only attach non-empty rows; an empty table omits the section entirely. const nonEmpty: WorkflowSettingsExportSection = {}; for (const [workflowId, values] of Object.entries(workflowSettings)) { diff --git a/packages/core/src/sqlite-adapter.ts b/packages/core/src/sqlite-adapter.ts index cca09a0aff..8f8e86de93 100644 --- a/packages/core/src/sqlite-adapter.ts +++ b/packages/core/src/sqlite-adapter.ts @@ -51,7 +51,7 @@ interface RawDatabase { deserialize?: (data: Uint8Array) => void; } -type DatabaseCtor = new (path: string) => RawDatabase; +type DatabaseCtor = new (path: string, options?: Record) => RawDatabase; let cachedCtor: DatabaseCtor | null = null; @@ -75,10 +75,18 @@ function loadDatabaseCtor(): DatabaseCtor { export class DatabaseSync { private impl: RawDatabase; - constructor(path: string) { + constructor(path: string, options?: { readOnly?: boolean }) { assertOutsideRealFusionPath(path, "SQLite database open"); const Ctor = loadDatabaseCtor(); - this.impl = new Ctor(path); + /* FNXC:LegacySqliteBoundary 2026-07-14-18:42: + * Remaining SQLite access is migration/import/validation only. Open those + * sources read-only so discovery cannot create files, recover WALs, or + * checkpoint legacy databases during normal PostgreSQL startup. + */ + const runtimeOptions = options?.readOnly + ? (isBun ? { readonly: true } : { readOnly: true }) + : undefined; + this.impl = runtimeOptions ? new Ctor(path, runtimeOptions) : new Ctor(path); } exec(sql: string): void { diff --git a/packages/core/src/sqlite-validation.ts b/packages/core/src/sqlite-validation.ts index e062ae5dfe..cbd265330d 100644 --- a/packages/core/src/sqlite-validation.ts +++ b/packages/core/src/sqlite-validation.ts @@ -4,9 +4,10 @@ import { DatabaseSync } from "./sqlite-adapter.js"; /** * Validate that a path points to a SQLite database file that can be opened. * - * Zero-byte files are treated as valid bootstrap databases because SQLite - * upgrades them in place on first open. Non-existent paths and unreadable or - * malformed files return false. + * This legacy-migration probe is read-only. A zero-byte bootstrap file remains + * a valid migration signal, while non-existent, unreadable, or malformed files + * return false. PostgreSQL-era startup never creates or upgrades a SQLite file + * as a side effect of project discovery. */ export function isValidSqliteDatabaseFile(dbPath: string): boolean { if (!existsSync(dbPath)) { @@ -23,7 +24,8 @@ export function isValidSqliteDatabaseFile(dbPath: string): boolean { let db: DatabaseSync | null = null; try { - db = new DatabaseSync(dbPath); + // FNXC:LegacySqliteBoundary 2026-07-14-18:42: validation may inspect a legacy migration input but must never mutate it. + db = new DatabaseSync(dbPath, { readOnly: true }); db.prepare("PRAGMA schema_version").get(); return true; } catch { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 63abcd5155..c87d0d6303 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -44,6 +44,8 @@ export function isWorkflowColumnsCompatibilityFlagEnabled(settings: Pick { return getOrCreateForProjectImpl(this, projectId, centralCore, globalSettingsDir, asyncLayer); } - /** Hybrid storage: task metadata in SQLite, blob files on disk. Reads tolerate missing files/dirs. */ + /** FNXC:PostgresRuntimeStorage 2026-07-14-18:47: Task metadata is authoritative in PostgreSQL; task document/blob files remain on disk. */ public fusionDir: string; public tasksDir: string; public configPath: string; @@ -324,12 +330,12 @@ export class TaskStore extends EventEmitter { public _archiveDb: ArchiveDatabase | null = null; /** - * FNXC:RuntimeBackendInjection 2026-06-24-14:00: When an AsyncDataLayer is injected, TaskStore operates in "backend mode": all data access delegates to PostgreSQL via Drizzle and no SQLite Database is constructed. - * When absent, the legacy SQLite path is byte-identical to pre-migration. Co-located stores receive the layer via getAsyncLayer(). + * FNXC:PostgresRuntimeStorage 2026-07-14-18:47: Production TaskStores receive an AsyncDataLayer and delegate all persistence to PostgreSQL. A missing layer is a construction error; retained sync members exist only until compatibility tests and types are removed. */ public readonly asyncLayer: AsyncDataLayer | null = null; + private pluginPostgresSchemaExecutor: ((contracts: readonly LoadedPluginSchemaContract[]) => Promise) | null = null; - /** True when AsyncDataLayer was injected. Gates all SQLite construction sites. */ + /** True when the mandatory production AsyncDataLayer was injected. */ /** @internal TaskStore decomposition: accessible to extracted modules */ public get backendMode(): boolean { return this.asyncLayer !== null; @@ -682,23 +688,12 @@ export class TaskStore extends EventEmitter { return reconcileOrphanedTaskDirsImpl(this, opts); } - /** - * FNXC:TaskStoreConsistency 2026-06-27-15:00: - * FN-7069 phantoms are committed task-id reservations without any task row or task.json. - * Maintenance must prune their orphaned child rows without resurrecting/freeing the ID. - * In backend mode (PostgreSQL), this is a no-op returning empty results until the async - * layer gains an equivalent reconciliation method. The SQLite path is unreachable because - * production runs in backend mode. - */ + /** Reconcile committed reservations whose task and archive representations are absent. */ async reconcilePhantomCommittedReservations(): Promise<{ reconciled: string[]; skipped: Array<{ id: string; reason: string }>; }> { - if (this.backendMode) { - return { reconciled: [], skipped: [] }; - } - // SQLite fallback (unreachable in production — backend mode is the default). - return { reconciled: [], skipped: [] }; + return reconcilePhantomCommittedReservationsAsync(this); } public async readTaskJson(dir: string): Promise { return readTaskJsonImpl(this, dir); @@ -1166,7 +1161,7 @@ export class TaskStore extends EventEmitter { getWorkflowSettingsProjectId(): string { return getWorkflowSettingsProjectIdImpl(this); } - listWorkflowSettingValuesForProject(): Record> { + async listWorkflowSettingValuesForProject(): Promise>> { return listWorkflowSettingValuesForProjectImpl(this); } async computeMovedSettingsTargetWorkflowIds(): Promise> { @@ -1458,12 +1453,33 @@ export class TaskStore extends EventEmitter { getRunAuditEvents(options: RunAuditEventFilter = {}): RunAuditEvent[] { return getRunAuditEventsImpl(this, options); } - getWorkflowParitySummary(options: { since?: string; limit?: number } = {}): WorkflowParitySummary { + /** PostgreSQL-authoritative audit reader; sync fallback remains for test doubles. */ + async getRunAuditEventsAsync(options: RunAuditEventFilter = {}): Promise { + if (this.asyncLayer) { + const events = await queryRunAuditEvents(this.asyncLayer.db, options); + return events.map((event) => ({ + ...event, + taskId: event.taskId ?? undefined, + metadata: event.metadata ?? undefined, + domain: event.domain as RunAuditEvent["domain"], + mutationType: event.mutationType as RunAuditEvent["mutationType"], + })); + } + return getRunAuditEventsImpl(this, options); + } + /** PostgreSQL soft-delete invariant repair used by engine self-healing. */ + async reconcileSoftDeletedColumnDriftBackend( + recordAudit: (candidate: { id: string; previousColumn: string }) => Promise, + ): Promise<{ reconciled: number }> { + if (!this.asyncLayer) return { reconciled: 0 }; + return reconcileSoftDeletedColumnDriftAsync(this.asyncLayer, recordAudit); + } + async getWorkflowParitySummary(options: { since?: string; limit?: number } = {}): Promise { return getWorkflowParitySummaryImpl(this, options); } /** Aggregate the `workflowColumns` flag default-flip criteria (U12, KTD-8) into */ - computeWorkflowColumnsGraduationReport( options: { since?: string; limit?: number } = {}, ): WorkflowColumnsGraduationReport { + async computeWorkflowColumnsGraduationReport( options: { since?: string; limit?: number } = {}, ): Promise { return computeWorkflowColumnsGraduationReportImpl(this, options); } @@ -1818,7 +1834,7 @@ export class TaskStore extends EventEmitter { public async cleanupBranchForTask(task: Task): Promise { return cleanupBranchForTaskImpl(this, task); } - clearStaleExecutionStartBranchReferences(deletedBranches: string[], ownerTaskId?: string): string[] { + async clearStaleExecutionStartBranchReferences(deletedBranches: string[], ownerTaskId?: string): Promise { return clearStaleExecutionStartBranchReferencesImpl(this, deletedBranches, ownerTaskId); } public async collectMergeDetails( _id: string, _branch: string, task: Task, commitMessage: string, mergeTarget?: { branch: string; source: "task-base-branch" | "task-branch-context" | "branch-group-integration" | "project-default" | "legacy-main"; }, ): Promise { @@ -2188,13 +2204,13 @@ export class TaskStore extends EventEmitter { public async workflowColumnsFlagOn(): Promise { return isWorkflowColumnsCompatibilityFlagEnabled(await this.getSettingsFast()); } - public listWorkflowOccupantTaskIds(workflowId: string, includeNullSelection: boolean): string[] { + public async listWorkflowOccupantTaskIds(workflowId: string, includeNullSelection: boolean): Promise { return listWorkflowOccupantTaskIdsImpl(this, workflowId, includeNullSelection); } /** Map column id → occupant count for the tasks selecting `workflowId` * (plus null-selection tasks when `includeNullSelection`). */ - public occupantsByColumnForWorkflow( workflowId: string, includeNullSelection: boolean, ): Map { + public async occupantsByColumnForWorkflow( workflowId: string, includeNullSelection: boolean, ): Promise> { return occupantsByColumnForWorkflowImpl(this, workflowId, includeNullSelection); } public async rehomeOccupant( taskId: string, targetColumn: string, reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome", metadata: Record, ): Promise { @@ -2400,6 +2416,13 @@ export class TaskStore extends EventEmitter { pruneOperationalLogs(retentionMs: number): { deletedByTable: Record; deletedTotal: number } { return this.db.pruneOperationalLogs(retentionMs); } + + async pruneOperationalLogsAsync(retentionMs: number): Promise { + if (!this.asyncLayer) { + return this.pruneOperationalLogs(retentionMs); + } + return pruneOperationalLogsAsync(this.asyncLayer, retentionMs); + } pruneAgentLogFiles(retentionDays: number): { prunedFiles: number; prunedEntries: number; freedBytes: number } { return pruneAgentLogFilesImpl(this, retentionDays); } @@ -2508,6 +2531,45 @@ export class TaskStore extends EventEmitter { getPluginStore(): PluginStore { return getPluginStoreImpl(this); } + /** + * FNXC:PluginPostgresSchema 2026-07-14-17:25: + * Every host (engine, CLI, and desktop) uses this backend-aware schema entrypoint after loading plugins. PostgreSQL executes only registered PG-native hooks and fails on SQLite-only third-party hooks; legacy mode retains the existing Database runner. + */ + /** @internal Installed by the backend startup factory; never exposed through PluginContext. */ + setPluginPostgresSchemaExecutor( + executor: (contracts: readonly LoadedPluginSchemaContract[]) => Promise, + ): void { + this.pluginPostgresSchemaExecutor = executor; + } + + preflightPluginSchema( + pluginId: string, + hooks: { onSchemaInit?: PluginOnSchemaInit; onPostgresSchemaInit?: () => PluginPostgresSchemaDefinition }, + ): LoadedPluginSchemaContract | null { + const postgresSchema = hooks.onPostgresSchemaInit?.(); + const contract = hooks.onSchemaInit || postgresSchema + ? { pluginId, legacyHook: hooks.onSchemaInit, postgresSchema } + : null; + if (this.backendMode && contract) assertLoadedPluginSchemaInitHooksSupported([contract]); + return contract; + } + + async runPluginSchemaInits(hooks: LoadedPluginSchemaContract[]): Promise { + if (this.backendMode) { + if (!this.getAsyncLayer()) throw new Error("backend TaskStore is missing its AsyncDataLayer"); + assertLoadedPluginSchemaInitHooksSupported(hooks); + if (!this.pluginPostgresSchemaExecutor) { + throw new Error("backend TaskStore is missing its PostgreSQL plugin schema executor"); + } + await this.pluginPostgresSchemaExecutor(hooks); + return; + } + await this.getDatabase().runPluginSchemaInits( + hooks.flatMap((entry) => entry.legacyHook + ? [{ pluginId: entry.pluginId, hook: entry.legacyHook as PluginOnSchemaInit }] + : []), + ); + } public async isPluginInstalled(pluginId: string): Promise { return isPluginInstalledImpl(this, pluginId); } diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index c783a0d488..f96fe66ea9 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -10,7 +10,7 @@ import {TaskStore, storeLog} from "../store.js"; import {TaskHasLineageChildrenError, TaskSelfDeleteError} from "./errors.js"; import {mkdir, writeFile} from "node:fs/promises"; import {join} from "node:path"; -import {eq} from "drizzle-orm"; +import {and, eq} from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; import type {Task, Column, ArchivedTaskEntry, GithubIssueAction} from "../types.js"; import "../builtin-traits.js"; @@ -18,8 +18,8 @@ import {normalizeTaskPriority} from "../task-priority.js"; import {generateTaskLineageId} from "../task-lineage.js"; import {sanitizeFileScopeInPromptContent} from "../task-store/file-scope.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; -import {softDeleteTaskRow as softDeleteTaskRowAsync, readTaskRow as readTaskRowAsync} from "../task-store/async-persistence.js"; -import {findLiveLineageChildren as findLiveLineageChildrenAsync, removeLineageReferences} from "../task-store/async-lifecycle.js"; +import {softDeleteTaskRowInTransaction, readTaskRow as readTaskRowAsync} from "../task-store/async-persistence.js"; +import {findLiveLineageChildren as findLiveLineageChildrenAsync, projectPartition, removeLineageReferences} from "../task-store/async-lifecycle.js"; import {archiveParentTaskWithLineageGate, findArchivedTaskEntry, deleteArchivedTaskEntry, restoreTaskFromArchive} from "../task-store/async-archive-lineage.js"; import {getArchivedRowCount, listArchivedTaskEntriesPage} from "../async-archive-db.js"; @@ -110,7 +110,7 @@ export async function deleteTaskBackendImpl(store: TaskStore, id: string, option } // Lineage-integrity gate (VAL-DATA-010). - const lineageChildIds = await findLiveLineageChildrenAsync(layer.db, id); + const lineageChildIds = await findLiveLineageChildrenAsync(layer.db, id, layer.projectId); if (lineageChildIds.length > 0 && !options?.removeLineageReferences) { throw new TaskHasLineageChildrenError(id, lineageChildIds); } @@ -122,10 +122,10 @@ export async function deleteTaskBackendImpl(store: TaskStore, id: string, option await layer.transactionImmediate(async (tx) => { // Clear lineage references on live children so the parent can be deleted. if (lineageChildIds.length > 0) { - await removeLineageReferences(tx, id, lineageChildIds, deletedAt); + await removeLineageReferences(tx, id, lineageChildIds, deletedAt, layer.projectId); } // Soft-delete the task row. - await softDeleteTaskRowAsync(layer, id, deletedAt, allowResurrection); + await softDeleteTaskRowInTransaction(tx, id, deletedAt, allowResurrection, layer.projectId); // Record the audit event. await store.recordRunAuditEventBackend(tx, { domain: "database", @@ -258,25 +258,27 @@ export async function unarchiveTaskImpl(store: TaskStore, id: string): Promise { const rows = await db .select({ taskJson: schema.archive.archivedTasks.taskJson }) .from(schema.archive.archivedTasks) - .where(eq(schema.archive.archivedTasks.id, id)) + .where(and( + eq(schema.archive.archivedTasks.projectId, projectPartition(projectId)), + eq(schema.archive.archivedTasks.id, id), + )) .limit(1); const row = rows[0]; if (!row?.taskJson) return undefined; @@ -147,10 +151,12 @@ export async function findArchivedTaskEntry( */ export async function listArchivedTaskEntries( db: AsyncDataLayer["db"] | DbTransaction, + projectId?: string, ): Promise { const rows = await db .select({ taskJson: schema.archive.archivedTasks.taskJson }) .from(schema.archive.archivedTasks) + .where(eq(schema.archive.archivedTasks.projectId, projectPartition(projectId))) .orderBy(desc(schema.archive.archivedTasks.archivedAt)); const entries: ArchivedTaskEntry[] = []; for (const row of rows) { @@ -173,10 +179,14 @@ export async function listArchivedTaskEntries( export async function deleteArchivedTaskEntry( db: AsyncDataLayer["db"] | DbTransaction, id: string, + projectId?: string, ): Promise { await db .delete(schema.archive.archivedTasks) - .where(eq(schema.archive.archivedTasks.id, id)); + .where(and( + eq(schema.archive.archivedTasks.projectId, projectPartition(projectId)), + eq(schema.archive.archivedTasks.id, id), + )); } /** @@ -194,6 +204,7 @@ export async function deleteArchivedTaskEntry( export async function filterArchivedTaskEntries( db: AsyncDataLayer["db"] | DbTransaction, ids: readonly string[], + projectId?: string, ): Promise> { if (ids.length === 0) return new Set(); const result = new Set(); @@ -203,7 +214,10 @@ export async function filterArchivedTaskEntries( const rows = await db .select({ id: schema.archive.archivedTasks.id }) .from(schema.archive.archivedTasks) - .where(inArray(schema.archive.archivedTasks.id, chunk)); + .where(and( + eq(schema.archive.archivedTasks.projectId, projectPartition(projectId)), + inArray(schema.archive.archivedTasks.id, chunk), + )); for (const row of rows) result.add(row.id); } return result; @@ -248,14 +262,14 @@ export async function archiveParentTaskWithLineageGate( return layer.transactionImmediate(async (tx) => { // 1. Lineage gate — check for live children inside the transaction. - const liveChildIds = await findLiveLineageChildren(tx, taskId); + const liveChildIds = await findLiveLineageChildren(tx, taskId, layer.projectId); if (liveChildIds.length > 0 && !options.removeLineageReferences) { return { archived: false as const, liveChildIds }; } // 2. Lineage clear (if requested and there are live children). if (liveChildIds.length > 0 && options.removeLineageReferences) { - await removeLineageReferences(tx, taskId, liveChildIds, now); + await removeLineageReferences(tx, taskId, liveChildIds, now, layer.projectId); } // 3. Archive snapshot to cold storage (VAL-CROSS-015 — preserves for restore). @@ -271,7 +285,7 @@ export async function archiveParentTaskWithLineageGate( // so the UPDATE participates in this transaction. The previous call used // softDeleteTaskRow(layer) which bound layer.db and ran OUTSIDE the txn, // breaking atomicity (a later rollback left the soft-delete persisted). - await softDeleteTaskRowInTransaction(tx, taskId, now); + await softDeleteTaskRowInTransaction(tx, taskId, now, false, layer.projectId); return { archived: true as const }; }); @@ -309,7 +323,7 @@ export async function restoreTaskFromArchive( // the read participates in this transaction (consistent snapshot). The // previous call used readTaskRow(layer) which bound layer.db and read // OUTSIDE the txn. - const existing = await readTaskRowInTransaction(tx, entry.id, { includeDeleted: true }); + const existing = await readTaskRowInTransaction(tx, entry.id, { includeDeleted: true }, layer.projectId); if (existing) { // Row exists (was soft-deleted). Restore it: clear deleted_at, keep // column as "archived" so the caller (unarchiveTaskImpl) can verify the @@ -323,7 +337,10 @@ export async function restoreTaskFromArchive( column: "archived", updatedAt: now, }) - .where(eq(schema.project.tasks.id, entry.id)); + .where(and( + eq(schema.project.tasks.projectId, projectPartition(layer.projectId)), + eq(schema.project.tasks.id, entry.id), + )); } else { // Row was hard-deleted. We cannot fully reconstruct it from the archive // snapshot alone here (the entry carries the public Task shape, not the @@ -333,7 +350,7 @@ export async function restoreTaskFromArchive( } // Remove the cold-storage snapshot (project row is the source of truth again). - await deleteArchivedTaskEntry(tx, entry.id); + await deleteArchivedTaskEntry(tx, entry.id, layer.projectId); }); } diff --git a/packages/core/src/task-store/async-comments-attachments.ts b/packages/core/src/task-store/async-comments-attachments.ts index 5381f51457..2abeb55b15 100644 --- a/packages/core/src/task-store/async-comments-attachments.ts +++ b/packages/core/src/task-store/async-comments-attachments.ts @@ -30,6 +30,7 @@ import { randomUUID } from "node:crypto"; import * as schema from "../postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; import { ACTIVE_TASK_FILTER } from "./async-persistence.js"; +import { projectPartition } from "./async-lifecycle.js"; import type { Artifact, ArtifactCreateInput, @@ -109,18 +110,26 @@ function rowToArtifact(row: ArtifactRow): Artifact { * or soft-deleted tasks. Returns the task's column if live, or `null` if the * task is absent, archived, or soft-deleted. */ -async function getLiveTaskColumn( +export async function getLiveTaskColumn( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, + projectId?: string, ): Promise { + /* + FNXC:PostgresArchiveSafety 2026-07-14-21:48: + PostgreSQL async log, comment, document, and artifact paths must distinguish an archived or soft-deleted parent from a missing task within the bound project. Task IDs repeat across projects, so the state gate must never borrow another project's live or archived row. + */ const rows = await db - .select({ column: schema.project.tasks.column }) + .select({ column: schema.project.tasks.column, deletedAt: schema.project.tasks.deletedAt }) .from(schema.project.tasks) - .where(and(eq(schema.project.tasks.id, taskId), ACTIVE_TASK_FILTER)) + .where(and( + eq(schema.project.tasks.projectId, projectPartition(projectId)), + eq(schema.project.tasks.id, taskId), + )) .limit(1); const row = rows[0]; if (!row) return null; - if (row.column === "archived") return null; + if (row.column === "archived" || row.deletedAt != null) return "archived"; return row.column; } @@ -137,10 +146,11 @@ export async function getTaskDocument( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, key: string, + projectId?: string, ): Promise { // Gate on the parent task being live. - const column = await getLiveTaskColumn(db, taskId); - if (column === null) return null; + const column = await getLiveTaskColumn(db, taskId, projectId); + if (column === null || column === "archived") return null; const rows = await db .select() @@ -177,7 +187,7 @@ export async function upsertTaskDocument( ): Promise { return layer.transactionImmediate(async (tx) => { // Gate: reject writes against archived/soft-deleted/absent tasks. - const column = await getLiveTaskColumn(tx, taskId); + const column = await getLiveTaskColumn(tx, taskId, layer.projectId); if (column === "archived") { throw new Error(`Task ${taskId} is archived — documents are read-only`); } @@ -271,9 +281,10 @@ export async function upsertTaskDocument( export async function listTaskDocuments( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, + projectId?: string, ): Promise { - const column = await getLiveTaskColumn(db, taskId); - if (column === null) return []; + const column = await getLiveTaskColumn(db, taskId, projectId); + if (column === null || column === "archived") return []; const rows = await db .select() @@ -290,9 +301,10 @@ export async function getTaskDocumentRevisions( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, key: string, + projectId?: string, ): Promise { - const column = await getLiveTaskColumn(db, taskId); - if (column === null) return []; + const column = await getLiveTaskColumn(db, taskId, projectId); + if (column === null || column === "archived") return []; const rows = await db .select() @@ -313,9 +325,9 @@ export async function getTaskDocumentRevisions( * equivalent of the sync `deleteTaskDocument`: it verifies the document exists * (throwing the same "not found" error otherwise), then removes the revisions * and the document row inside a single transaction so a partial delete can - * never leave orphaned revisions. Unlike the read/upsert paths it intentionally - * does NOT gate on the parent task's live state — the sync path deletes by - * (taskId, key) existence alone, and this preserves that behavior. + * never leave orphaned revisions. Archived-task documents are retained for + * restore and remain read-only, so deletion uses the same parent-state gate as + * upsert. * * @param layer The async data layer (the delete runs in its own transaction). * @param taskId The parent task id. @@ -327,6 +339,9 @@ export async function deleteTaskDocument( key: string, ): Promise { return layer.transactionImmediate(async (tx) => { + const state = await getLiveTaskColumn(tx, taskId, layer.projectId); + if (state === "archived") throw new Error(`Task ${taskId} is archived — documents are read-only`); + if (state === null) throw new Error(`Task ${taskId} not found`); const existing = await tx .select({ id: schema.project.taskDocuments.id }) .from(schema.project.taskDocuments) @@ -384,7 +399,7 @@ export async function insertArtifactRow( return layer.transactionImmediate(async (tx) => { // Gate: if taskId is set, the parent must be live. if (input.taskId) { - const column = await getLiveTaskColumn(tx, input.taskId); + const column = await getLiveTaskColumn(tx, input.taskId, layer.projectId); if (column === "archived") { throw new Error(`Task ${input.taskId} is archived — artifacts are read-only`); } @@ -441,10 +456,11 @@ export async function updateArtifactRow( throw new Error(`Artifact ${id} not found`); } if (existing.taskId) { - const column = await getLiveTaskColumn(tx, existing.taskId); + const column = await getLiveTaskColumn(tx, existing.taskId, layer.projectId); if (column === "archived") { throw new Error(`Task ${existing.taskId} is archived — artifacts are read-only`); } + if (column === null) throw new Error(`Task ${existing.taskId} not found`); } if (updates.content !== undefined && existing.uri) { throw new Error(`Artifact ${id} stores a binary payload; its content is not editable`); @@ -496,9 +512,10 @@ export async function getArtifact( export async function getArtifacts( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, + projectId?: string, ): Promise { - const column = await getLiveTaskColumn(db, taskId); - if (column === null) return []; + const column = await getLiveTaskColumn(db, taskId, projectId); + if (column === null || column === "archived") return []; const rows = await db .select() diff --git a/packages/core/src/task-store/async-lifecycle.ts b/packages/core/src/task-store/async-lifecycle.ts index 208f706482..b53e8566df 100644 --- a/packages/core/src/task-store/async-lifecycle.ts +++ b/packages/core/src/task-store/async-lifecycle.ts @@ -52,8 +52,17 @@ import { ACTIVE_TASK_FILTER } from "./async-persistence.js"; * WHERE sourceParentTaskId = ? AND id != ? AND "column" != 'archived' * AND */ -export function liveLineageChildFilter(parentId: string) { +/* +FNXC:ArchiveProjectIsolation 2026-07-14-21:48: +Task and lineage IDs are project-local. Archive, lineage, comment, document, artifact, and log gates share this partition resolver so same-ID rows cannot be read or mutated across projects; unbound compatibility callers remain confined to the explicit legacy quarantine. +*/ +export function projectPartition(projectId?: string): string { + return projectId?.trim() || "__legacy_unscoped__"; +} + +export function liveLineageChildFilter(parentId: string, projectId?: string) { return and( + eq(schema.project.tasks.projectId, projectPartition(projectId)), eq(schema.project.tasks.sourceParentTaskId, parentId), ne(schema.project.tasks.id, parentId), ne(schema.project.tasks.column, "archived"), @@ -83,11 +92,12 @@ export function liveLineageChildFilter(parentId: string) { export async function findLiveLineageChildren( db: AsyncDataLayer["db"] | DbTransaction, parentId: string, + projectId?: string, ): Promise { const rows = await db .select({ id: schema.project.tasks.id }) .from(schema.project.tasks) - .where(liveLineageChildFilter(parentId)); + .where(liveLineageChildFilter(parentId, projectId)); return rows.map((row) => row.id); } @@ -120,6 +130,7 @@ export async function removeLineageReferences( parentId: string, childIds: readonly string[], nowIso: string, + projectId?: string, ): Promise { // FNXC:TaskStoreLifecycle 2026-06-24-06:05: // A single bulk UPDATE clears all children that still point at this parent. @@ -139,6 +150,7 @@ export async function removeLineageReferences( }) .where( and( + eq(schema.project.tasks.projectId, projectPartition(projectId)), sql`${schema.project.tasks.id} IN ${childIds}`, eq(schema.project.tasks.sourceParentTaskId, parentId), ), @@ -163,11 +175,12 @@ export async function removeLineageReferences( export async function hasLiveLineageChildren( db: AsyncDataLayer["db"] | DbTransaction, parentId: string, + projectId?: string, ): Promise { const rows = await db .select({ one: sql`1` }) .from(schema.project.tasks) - .where(liveLineageChildFilter(parentId)) + .where(liveLineageChildFilter(parentId, projectId)) .limit(1); return rows.length > 0; } diff --git a/packages/core/src/task-store/async-maintenance.ts b/packages/core/src/task-store/async-maintenance.ts new file mode 100644 index 0000000000..db12809c7f --- /dev/null +++ b/packages/core/src/task-store/async-maintenance.ts @@ -0,0 +1,88 @@ +import { sql } from "drizzle-orm"; +import type { AsyncDataLayer } from "../postgres/data-layer.js"; + +export interface OperationalLogPruneResult { + deletedByTable: Record; + deletedTotal: number; +} + +/** + * Delete project-scoped operational history older than the retention cutoff. + * + * FNXC:PostgresRetention 2026-07-14-17:16: + * PostgreSQL autovacuum reclaims deleted tuples but does not enforce product + * retention. Apply the same bounded-history policy as the former local store, + * explicitly partitioned by project, and always retain each agent's newest + * configuration revision. + */ +export async function pruneOperationalLogsAsync( + layer: AsyncDataLayer, + retentionMs: number, +): Promise { + const deletedByTable: Record = {}; + if (!Number.isFinite(retentionMs) || retentionMs <= 0) { + return { deletedByTable, deletedTotal: 0 }; + } + const boundProjectId = layer.projectId?.trim(); + /* + FNXC:PostgresRetention 2026-07-14-21:55: + Operational retention should normally be project-bound. Preserve the legacy sentinel fallback for compatibility, but make every unbound maintenance pass visible before it can target legacy-unscoped rows. + */ + if (!boundProjectId) { + console.warn("[fusion] PostgreSQL operational maintenance is using the legacy unscoped project sentinel because asyncLayer.projectId is missing"); + } + const projectId = boundProjectId || "__legacy_unscoped__"; + const cutoff = new Date(Date.now() - retentionMs).toISOString(); + const count = async (name: string, statement: ReturnType): Promise => { + const rows = await layer.db.execute(sql`WITH deleted AS (${statement}) SELECT count(*)::int AS count FROM deleted`) as unknown as Array<{ count: number | string }>; + deletedByTable[name] = Number(rows[0]?.count ?? 0); + }; + + /* + FNXC:PostgresRetentionPerformance 2026-07-14-17:50: + Retention reports one aggregate count per table. The database counts deleted tuples inside a CTE so maintenance never transfers or materializes every deleted primary key in application memory. + */ + await count("activityLog", sql`DELETE FROM project.activity_log WHERE project_id = ${projectId} AND timestamp < ${cutoff} RETURNING 1`); + /* + FNXC:PostgresRetention 2026-07-14-18:12: + Legacy run-audit and heartbeat rows do not carry project_id. Scope deletion through their owning task/agent instead; taskless audit rows remain retained because their project cannot be proven, which is safer than cross-project deletion. + */ + await count("runAuditEvents", sql` + DELETE FROM project.run_audit_events AS events + WHERE events.timestamp < ${cutoff} + AND events.task_id IN ( + SELECT id FROM project.tasks WHERE project_id = ${projectId} + ) + RETURNING 1 + `); + await count("agentHeartbeats", sql` + DELETE FROM project.agent_heartbeats AS heartbeats + WHERE heartbeats.timestamp < ${cutoff} + AND heartbeats.project_id = ${projectId} + AND heartbeats.agent_id IN ( + SELECT id FROM project.agents WHERE project_id = ${projectId} + ) + RETURNING 1 + `); + await count("agentRuns", sql`DELETE FROM project.agent_runs WHERE project_id = ${projectId} AND ended_at IS NOT NULL AND ended_at < ${cutoff} RETURNING 1`); + await count("agentConfigRevisions", sql` + DELETE FROM project.agent_config_revisions AS revisions + WHERE revisions.project_id = ${projectId} + AND revisions.created_at < ${cutoff} + AND revisions.id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY created_at DESC, id DESC) AS row_number + FROM project.agent_config_revisions + WHERE project_id = ${projectId} + ) ranked + WHERE row_number = 1 + ) + RETURNING 1 + `); + await count("usageEvents", sql`DELETE FROM project.usage_events WHERE project_id = ${projectId} AND ts < ${cutoff} RETURNING 1`); + + return { + deletedByTable, + deletedTotal: Object.values(deletedByTable).reduce((total, value) => total + value, 0), + }; +} diff --git a/packages/core/src/task-store/async-persistence.ts b/packages/core/src/task-store/async-persistence.ts index 1ddcd2890b..57056153f7 100644 --- a/packages/core/src/task-store/async-persistence.ts +++ b/packages/core/src/task-store/async-persistence.ts @@ -191,7 +191,10 @@ export async function softDeleteTaskRow( allowResurrection: allowResurrection ? 1 : 0, updatedAt: deletedAt, }) - .where(eq(schema.project.tasks.id, id)); + .where(and( + eq(schema.project.tasks.projectId, layer.projectId?.trim() || "__legacy_unscoped__"), + eq(schema.project.tasks.id, id), + )); } /** @@ -219,7 +222,12 @@ export async function softDeleteTaskRowInTransaction( id: string, deletedAt: string, allowResurrection = false, + projectId?: string, ): Promise { + /* + FNXC:ArchiveProjectIsolation 2026-07-14-16:20: + Transactional archive/delete helpers receive the owning project explicitly because task IDs repeat across projects. The composite predicate is required for atomicity to protect the intended row instead of whichever same-ID row PostgreSQL returns first. + */ await tx .update(schema.project.tasks) .set({ @@ -228,7 +236,10 @@ export async function softDeleteTaskRowInTransaction( allowResurrection: allowResurrection ? 1 : 0, updatedAt: deletedAt, }) - .where(eq(schema.project.tasks.id, id)); + .where(and( + eq(schema.project.tasks.projectId, projectId?.trim() || "__legacy_unscoped__"), + eq(schema.project.tasks.id, id), + )); } /** @@ -283,8 +294,12 @@ export async function readTaskRowInTransaction( tx: DbTransaction, id: string, options?: { includeDeleted?: boolean }, + projectId?: string, ): Promise | undefined> { - const conditions = [eq(schema.project.tasks.id, id)]; + const conditions = [ + eq(schema.project.tasks.projectId, projectId?.trim() || "__legacy_unscoped__"), + eq(schema.project.tasks.id, id), + ]; if (!options?.includeDeleted) { conditions.push(ACTIVE_TASK_FILTER); } @@ -454,7 +469,10 @@ export async function updateTaskColumns( await layer.db .update(schema.project.tasks) .set(updates as never) - .where(eq(schema.project.tasks.id, id)); + .where(and( + eq(schema.project.tasks.projectId, layer.projectId?.trim() || "__legacy_unscoped__"), + eq(schema.project.tasks.id, id), + )); } /** diff --git a/packages/core/src/task-store/async-phantom-reservations.ts b/packages/core/src/task-store/async-phantom-reservations.ts new file mode 100644 index 0000000000..7b80ed9da1 --- /dev/null +++ b/packages/core/src/task-store/async-phantom-reservations.ts @@ -0,0 +1,172 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import * as schema from "../postgres/schema/index.js"; +import type { TaskStore } from "../store.js"; + +export interface PhantomReservationReconcileResult { + reconciled: string[]; + skipped: Array<{ id: string; reason: string }>; +} + +/** + * FNXC:PostgresReservationRecovery 2026-07-14-17:22: + * A committed distributed reservation permanently burns its task ID even when + * the later task materialization vanished. PostgreSQL maintenance must prune + * only child activity/agent state after proving that live, deleted, archived, + * and task.json representations are all absent; the reservation and prior + * audit history remain durable. + */ +export async function reconcilePhantomCommittedReservationsAsync( + store: TaskStore, +): Promise { + const layer = store.getAsyncLayer(); + if (!layer) return { reconciled: [], skipped: [] }; + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + const projectArchive = alias(schema.project.archivedTasks, "phantom_project_archive"); + const coldArchive = alias(schema.archive.archivedTasks, "phantom_cold_archive"); + const result: PhantomReservationReconcileResult = { reconciled: [], skipped: [] }; + /* + FNXC:PostgresReservationRecoveryPerformance 2026-07-14-17:50: + Classify every committed reservation with one project-scoped join instead of issuing three existence queries per ID. Filesystem proof remains per candidate because task.json is intentionally outside PostgreSQL. + */ + const reservations = await layer.db + .select({ + taskId: schema.project.distributedTaskIdReservations.taskId, + liveId: schema.project.tasks.id, + projectArchiveId: projectArchive.id, + coldArchiveId: coldArchive.id, + }) + .from(schema.project.distributedTaskIdReservations) + .leftJoin(schema.project.tasks, and( + eq(schema.project.tasks.projectId, projectId), + eq(schema.project.tasks.id, schema.project.distributedTaskIdReservations.taskId), + )) + .leftJoin(projectArchive, and( + eq(projectArchive.projectId, projectId), + eq(projectArchive.id, schema.project.distributedTaskIdReservations.taskId), + )) + .leftJoin(coldArchive, and( + eq(coldArchive.projectId, projectId), + eq(coldArchive.id, schema.project.distributedTaskIdReservations.taskId), + )) + .where(and( + eq(schema.project.distributedTaskIdReservations.projectId, projectId), + eq(schema.project.distributedTaskIdReservations.status, "committed"), + )) + .orderBy( + asc(schema.project.distributedTaskIdReservations.prefix), + asc(schema.project.distributedTaskIdReservations.sequence), + ); + + const filesystemApproved: string[] = []; + for (const { taskId, liveId, projectArchiveId, coldArchiveId } of reservations) { + if (liveId !== null) { + result.skipped.push({ id: taskId, reason: "task-row-present" }); + continue; + } + if (projectArchiveId !== null || coldArchiveId !== null) { + result.skipped.push({ id: taskId, reason: "archived-task-present" }); + continue; + } + if (existsSync(join(store.taskDir(taskId), "task.json"))) { + result.skipped.push({ id: taskId, reason: "task-json-present" }); + continue; + } + filesystemApproved.push(taskId); + } + + if (filesystemApproved.length === 0) return result; + + let prunedByTask: Map; + try { + prunedByTask = await layer.transactionImmediate(async (tx) => { + // Re-prove database absence inside the delete transaction so a task that + // materialized after the classification query cannot lose child rows. + const safeRows = await tx + .select({ taskId: schema.project.distributedTaskIdReservations.taskId }) + .from(schema.project.distributedTaskIdReservations) + .leftJoin(schema.project.tasks, and( + eq(schema.project.tasks.projectId, projectId), + eq(schema.project.tasks.id, schema.project.distributedTaskIdReservations.taskId), + )) + .leftJoin(projectArchive, and( + eq(projectArchive.projectId, projectId), + eq(projectArchive.id, schema.project.distributedTaskIdReservations.taskId), + )) + .leftJoin(coldArchive, and( + eq(coldArchive.projectId, projectId), + eq(coldArchive.id, schema.project.distributedTaskIdReservations.taskId), + )) + .where(and( + eq(schema.project.distributedTaskIdReservations.projectId, projectId), + eq(schema.project.distributedTaskIdReservations.status, "committed"), + inArray(schema.project.distributedTaskIdReservations.taskId, filesystemApproved), + isNull(schema.project.tasks.id), + isNull(projectArchive.id), + isNull(coldArchive.id), + )); + const safeIds = safeRows.map((row) => row.taskId); + if (safeIds.length === 0) return new Map(); + + const activity = await tx.delete(schema.project.activityLog).where(and( + eq(schema.project.activityLog.projectId, projectId), + inArray(schema.project.activityLog.taskId, safeIds), + )).returning({ taskId: schema.project.activityLog.taskId }); + const agents = await tx.delete(schema.project.agents).where(and( + eq(schema.project.agents.projectId, projectId), + inArray(schema.project.agents.taskId, safeIds), + )).returning({ taskId: schema.project.agents.taskId }); + const counts = new Map(safeIds.map((id) => [id, { prunedActivityLog: 0, prunedAgents: 0 }])); + for (const row of activity) { + if (row.taskId) counts.get(row.taskId)!.prunedActivityLog += 1; + } + for (const row of agents) { + if (row.taskId) counts.get(row.taskId)!.prunedAgents += 1; + } + return counts; + }); + } catch (error) { + for (const taskId of filesystemApproved) { + result.skipped.push({ + id: taskId, + reason: `reconcile-failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + return result; + } + + for (const taskId of filesystemApproved) { + const pruned = prunedByTask.get(taskId); + if (!pruned) { + result.skipped.push({ id: taskId, reason: "representation-present-after-proof" }); + continue; + } + if (pruned.prunedActivityLog > 0 || pruned.prunedAgents > 0) { + try { + await store.recordRunAuditEvent({ + agentId: "self-healing", + runId: `phantom-reservation:${taskId}`, + taskId, + domain: "database", + mutationType: "task:reconcile-phantom-committed-reservation", + target: taskId, + metadata: { reservationStatus: "committed", ...pruned }, + }); + } catch (error) { + /* + FNXC:PostgresReservationRecovery 2026-07-14-21:55: + Audit emission is isolated per reconciled reservation. One failed audit must not relabel earlier successful reconciliations as skipped or prevent later IDs from completing their own bookkeeping. + */ + result.skipped.push({ + id: taskId, + reason: `audit-failed: ${error instanceof Error ? error.message : String(error)}`, + }); + continue; + } + } + result.reconciled.push(taskId); + } + return result; +} diff --git a/packages/core/src/task-store/async-search.ts b/packages/core/src/task-store/async-search.ts index 86bd7cc308..678b5d007f 100644 --- a/packages/core/src/task-store/async-search.ts +++ b/packages/core/src/task-store/async-search.ts @@ -291,7 +291,7 @@ export const FTS_TS_CONFIG = "simple"; * @returns A `SQL` fragment binding the to_tsquery, or `undefined` if the * query produces no valid tokens. */ -function buildTsqueryFragment(query: string): SQL | undefined { +export function buildTsqueryFragment(query: string): SQL | undefined { const tokens = sanitizeSearchTokens(query); if (tokens.length === 0) return undefined; diff --git a/packages/core/src/task-store/audit-ops.ts b/packages/core/src/task-store/audit-ops.ts index eabb398282..7d5a5f9838 100644 --- a/packages/core/src/task-store/audit-ops.ts +++ b/packages/core/src/task-store/audit-ops.ts @@ -17,6 +17,7 @@ import "../builtin-traits.js"; import {toJson, fromJson} from "../db.js"; import {__setTaskActivityLogLimitsForTesting, truncateTaskLogOutcome, getTaskActivityLogEntryLimit} from "../task-store/comments.js"; import {readTaskRow, updateTaskColumns} from "../task-store/async-persistence.js"; +import { getLiveTaskColumn } from "./async-comments-attachments.js"; export async function runPluginColumnTransitionHooksImpl(store: TaskStore, taskId: string, workflowIr: WorkflowIr, fromColumn: string, toColumn: string,): Promise { const registry = getTraitRegistry(); @@ -112,6 +113,12 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string, outcome: truncateTaskLogOutcome(outcome), }; if (runContext) { + if (store.backendMode) { + const layer = store.asyncLayer!; + const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + if (state === "archived") throw new Error(`Task ${id} is archived — logging is read-only`); + if (state === null) throw new Error(`Task ${id} not found`); + } if (store.isTaskArchived(id)) { throw new Error(`Task ${id} is archived — logging is read-only`); } @@ -158,14 +165,11 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string, // available in backend mode" (discovered by sqlite-final-removal session 3). if (store.backendMode) { const layer = store.asyncLayer!; - const pgRow = await readTaskRow(layer, id, { includeDeleted: false }); + const pgRow = await readTaskRow(layer, id, { includeDeleted: true }); if (!pgRow) { - if (store.isTaskArchived(id)) { - throw new Error(`Task ${id} is archived — logging is read-only`); - } throw new Error(`Task ${id} not found`); } - if (pgRow.column === "archived") { + if (pgRow.column === "archived" || pgRow.deletedAt != null) { throw new Error(`Task ${id} is archived — logging is read-only`); } // PG jsonb columns arrive already-parsed; convert to the TaskLogEntry[] shape. @@ -234,4 +238,3 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string, return emittedTask; }); } - diff --git a/packages/core/src/task-store/branch-group-ops.ts b/packages/core/src/task-store/branch-group-ops.ts index 6435577261..7052da61cc 100644 --- a/packages/core/src/task-store/branch-group-ops.ts +++ b/packages/core/src/task-store/branch-group-ops.ts @@ -16,6 +16,8 @@ import {type TaskRow} from "../task-store/persistence.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import type {ArtifactRow} from "../task-store/row-types.js"; import {listArtifacts as listArtifactsAsync} from "./async-comments-attachments.js"; +import { and, eq, isNull, ne, sql } from "drizzle-orm"; +import * as schema from "../postgres/schema/index.js"; export function saveWorkflowRunBranchImpl(store: TaskStore, state: { taskId: string; runId: string; branchId: string; currentNodeId: string; status: string; }): void { try { @@ -47,18 +49,42 @@ export async function clearNearDuplicateReferencesToImpl(store: TaskStore, canon return []; } - /* - * FNXC:SqliteFinalRemoval 2026-06-24-15:35: - * In backend mode (PostgreSQL), the near-duplicate reference cleanup is a - * best-effort optimization that uses SQLite-specific json_extract(). Skip - * it in backend mode rather than throwing — the async archive/delete paths - * already complete the core operation; this is a post-hoc cleanup of stale - * duplicate flags on OTHER tasks, not a correctness requirement. The - * PostgreSQL equivalent would use a jsonb path query; deferred to a future - * enhancement since clearNearDuplicateReferencesToFailSoft swallows errors. - */ if (store.backendMode) { - return []; + /* + * FNXC:PostgresNearDuplicateCleanup 2026-07-14-18:30: + * Archiving, deleting, or completing a canonical task must clear every + * live duplicate marker in the same project. Stale JSONB markers alter + * operator decisions, so PostgreSQL applies the same cleanup and audit + * behavior as the legacy store instead of treating it as optional. + */ + const layer = store.asyncLayer!; + const table = schema.project.tasks; + const conditions = [ + isNull(table.deletedAt), + ne(table.column, "archived"), + ne(table.column, "done"), + sql`${table.sourceMetadata}->>'nearDuplicateOf' = ${canonicalId}`, + ]; + if (layer.projectId) conditions.push(eq(table.projectId, layer.projectId)); + const rows = await layer.db + .update(table) + .set({ + sourceMetadata: sql`COALESCE(${table.sourceMetadata}, '{}'::jsonb) - 'nearDuplicateOf' - 'nearDuplicateScore' - 'nearDuplicateSharedTokens' - 'nearDuplicateDismissed'`, + updatedAt: new Date().toISOString(), + }) + .where(and(...conditions)) + .returning({ id: table.id }); + + const updatedTasks: Task[] = []; + for (const row of rows) { + await store.logEntry( + row.id, + `Near-duplicate canonical ${canonicalId} is now inactive (${inactiveState.reason}); cleared duplicate flag (informational, no decision required)`, + ); + const task = await store.getTask(row.id); + if (task) updatedTasks.push(task); + } + return updatedTasks; } const selectClause = store.getTaskSelectClause(false, "t"); @@ -365,7 +391,18 @@ export async function listArtifactsImpl(store: TaskStore, options?: { type?: Art } export async function rehomeOccupantImpl(store: TaskStore, taskId: string, targetColumn: string, reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome", metadata: Record,): Promise { - const current = store.readTaskFromDb(taskId, { includeDeleted: false }); + /* + FNXC:PostgresWorkflowEvacuation 2026-07-14-17:49: + Re-homing is an async workflow mutation and must read its current task through the authoritative PostgreSQL path; otherwise ON→OFF evacuation discovers custom-column cards but the SQLite-only read prevents every move. + */ + let current: Task | undefined; + try { + current = store.backendMode + ? await store.getTask(taskId, { includeDeleted: false }) + : store.readTaskFromDb(taskId, { includeDeleted: false }); + } catch { + current = undefined; + } if (!current) return; const fromColumn = current.column; if (fromColumn === targetColumn) { @@ -412,4 +449,3 @@ export async function rehomeOccupantImpl(store: TaskStore, taskId: string, targe metadata: { ...metadata, reason, fromColumn, toColumn: targetColumn, abortRan, moved, error }, }); } - diff --git a/packages/core/src/task-store/comments-ops.ts b/packages/core/src/task-store/comments-ops.ts index a464f8034c..31b50b867d 100644 --- a/packages/core/src/task-store/comments-ops.ts +++ b/packages/core/src/task-store/comments-ops.ts @@ -16,10 +16,16 @@ import {validateDocumentKey} from "../types.js"; import "../builtin-traits.js"; import {toJsonNullable} from "../db.js"; import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub} from "../task-store/comments.js"; -import {upsertTaskDocument as upsertTaskDocumentAsync} from "../task-store/async-comments-attachments.js"; +import {getLiveTaskColumn, upsertTaskDocument as upsertTaskDocumentAsync} from "../task-store/async-comments-attachments.js"; import type {TaskDocumentRow} from "../task-store/row-types.js"; export async function addCommentImpl(store: TaskStore, id: string, text: string, author: string = "user", options?: { skipRefinement?: boolean; source?: "user" | "agent" | "github-review" | "github-review-comment"; externalId?: string; reviewState?: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED"; }, runContext?: RunMutationContext,): Promise { + if (store.backendMode) { + const layer = store.asyncLayer!; + const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`); + if (state === null) throw new Error(`Task ${id} not found`); + } // Phase 1: Add comment under lock const task = await store.withTaskLock(id, async () => { const dir = store.taskDir(id); @@ -329,4 +335,3 @@ export async function upsertTaskDocumentImpl(store: TaskStore, taskId: string, i return document; } - diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index a55e36e507..f5a5656ed8 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -188,7 +188,7 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum if (store.backendMode) { const layer = store.asyncLayer!; await layer.transactionImmediate(async (tx) => { - const liveRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }); + const liveRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); if (liveRow?.deletedAt) { throw new HandoffInvariantViolationError( id, diff --git a/packages/core/src/task-store/reads.ts b/packages/core/src/task-store/reads.ts index 49cf62974e..93b09d2c56 100644 --- a/packages/core/src/task-store/reads.ts +++ b/packages/core/src/task-store/reads.ts @@ -10,7 +10,7 @@ import {TaskStore, storeLog} from "../store.js"; import {readFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync, statSync} from "node:fs"; -import type {Task, TaskDetail, ColumnId} from "../types.js"; +import type {Task, TaskDetail, ColumnId, ArchivedTaskEntry} from "../types.js"; import "../builtin-traits.js"; import {allowsAutoMergeProcessing} from "../task-merge.js"; import {getInReviewStallReason, DEFAULT_STALE_MERGING_MIN_AGE_MS} from "../in-review-stall.js"; @@ -22,6 +22,15 @@ import {getTaskAgeStalenessSignal, type TaskAgeStalenessThresholds} from "../tas import {detectStalledReview} from "../stalled-review-detector.js"; import {computeRetrySummary} from "../retry-summary.js"; +/** Merge storage tiers while preserving primary-source authority and order. */ +function mergePrimaryById(primary: T[], secondary: T[]): T[] { + const byId = new Map(primary.map((entry) => [entry.id, entry])); + for (const entry of secondary) { + if (!byId.has(entry.id)) byId.set(entry.id, entry); + } + return [...byId.values()]; +} + /** * Latest agent-log activity for a task: newest matching in-memory buffer entry * or the on-disk agent-log.jsonl mtime, whichever is fresher. Mirrors main's @@ -86,21 +95,37 @@ import {type TaskRow} from "../task-store/persistence.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {readTaskRow, readLiveTaskRows} from "../task-store/async-persistence.js"; import {searchTasksTsvector, searchTasksLike} from "../task-store/async-search.js"; +import { + getArchivedTask, + listArchivedTasks as listArchivedTaskEntries, + listArchivedTasksByCreatedOrder, + searchArchivedTasks, +} from "../async-archive-db.js"; export async function getTaskImpl(store: TaskStore, id: string, options?: { activityLogLimit?: number; includeDeleted?: boolean }): Promise { return store.withTaskLock(id, async () => { // FNXC:RuntimePersistenceAsync 2026-06-24-10:50: // Backend-mode getTask: read the task row via async helper, convert to - // Task via pgRowToTaskRow + rowToTask, hydrate derived fields. The archive - // fallback is not yet wired (archive is a separate subsystem converted by - // runtime-workflow-async); if the task is not in the live table, throw - // not-found (same as SQLite path when no archive entry exists). + // Task via pgRowToTaskRow + rowToTask, and hydrate derived fields. if (store.backendMode) { - const pgRow = await readTaskRow(store.asyncLayer!, id, { + const layer = store.asyncLayer!; + const pgRow = await readTaskRow(layer, id, { includeDeleted: options?.includeDeleted, }); if (!pgRow) { - throw new Error(`Task ${id} not found`); + /* + FNXC:PostgresArchiveReads 2026-07-14-17:09: + Archive is cold storage, not deletion from the public read model. Task detail must fall back to the project-scoped archive snapshot so an archived card remains inspectable after its live row is tombstoned. + */ + const archived = await getArchivedTask(layer.db, id, layer.projectId); + if (!archived) { + throw new Error(`Task ${id} not found`); + } + const archivedTask = store.archiveEntryToTask(archived, false); + return { + ...archivedTask, + prompt: archived.prompt ?? store.generatePromptFromArchiveEntry(archived), + }; } const task = store.rowToTask(store.pgRowToTaskRow(pgRow)); const now = Date.now(); @@ -272,10 +297,7 @@ export async function listTasksImpl(store: TaskStore, options?: { limit?: number // FNXC:RuntimePersistenceAsync 2026-06-24-10:55: // Backend-mode listTasks: read live task rows via async helper, convert to - // Tasks, hydrate derived fields. Archive-task merging is not yet wired in - // backend mode (archive is converted by runtime-workflow-async). The - // column filter and includeArchived filtering are applied client-side - // (the async helper reads all live rows; soft-delete is filtered in SQL). + // Tasks, and hydrate derived fields. if (store.backendMode) { const layer = store.asyncLayer!; /* @@ -298,12 +320,25 @@ export async function listTasksImpl(store: TaskStore, options?: { limit?: number */ const paginationOffset = Math.max(0, options?.offset ?? 0); const paginationLimit = options?.limit !== undefined ? Math.max(0, options.limit) : undefined; - const sqlPaginated = paginationLimit !== undefined || paginationOffset > 0; + /* + FNXC:PostgresArchiveReads 2026-07-14-17:09: + Pagination belongs to the composed active-plus-archive result. When cold storage participates, fetch both sources before sorting, deduplicating, and slicing; paginating only project.tasks can make archived rows unreachable or shift them onto the wrong page. + */ + const includeColdStorage = includeArchived && (!columnFilter || columnFilter === "archived"); + const boundedMergedPrefix = includeColdStorage && paginationLimit !== undefined + ? paginationOffset + paginationLimit + : undefined; + const sqlPaginated = (!includeColdStorage && (paginationLimit !== undefined || paginationOffset > 0)) + || boundedMergedPrefix !== undefined; const filteredRows = await readLiveTaskRows(layer, { includeDeleted: options?.includeDeleted, column: columnFilter ?? undefined, excludeColumn: !columnFilter && !includeArchived ? "archived" : undefined, - ...(sqlPaginated ? { limit: paginationLimit, offset: paginationOffset } : {}), + ...(boundedMergedPrefix !== undefined + ? { limit: boundedMergedPrefix, offset: 0 } + : sqlPaginated + ? { limit: paginationLimit, offset: paginationOffset } + : {}), }); const now = Date.now(); const settings = await store.getSettingsFast(); @@ -390,17 +425,30 @@ export async function listTasksImpl(store: TaskStore, options?: { limit?: number } })); // Sort by createdAt, then by numeric ID suffix for tie-breaking - const sorted = tasks.sort((a, b) => { + /* + FNXC:PostgresArchiveReadPerformance 2026-07-14-17:50: + A global page ending at K can only contain rows from each source's first K entries. Bound both SQL reads to K, then apply live-ID authority and the exact shared comparator before slicing. Unbounded callers retain the complete-result contract. + */ + const archiveEntries = includeColdStorage + ? boundedMergedPrefix !== undefined + ? await listArchivedTasksByCreatedOrder(layer.db, boundedMergedPrefix, layer.projectId) + : await listArchivedTaskEntries(layer.db, layer.projectId) + : []; + const archivedTasks = archiveEntries.map((entry) => store.archiveEntryToTask(entry, slim)); + // Match the legacy merge invariant: a forensic live row is authoritative + // when the same id also has an archive snapshot. + const sorted = mergePrimaryById(tasks, archivedTasks).sort((a, b) => { const cmp = a.createdAt.localeCompare(b.createdAt); if (cmp !== 0) return cmp; const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0; const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0; return aNum - bNum; }); - // FNXC:TaskStoreReadsPerf 2026-07-11 (PR #1793 review): pagination was - // already applied in SQL above (readLiveTaskRows LIMIT/OFFSET with the - // matching order); the JS sort is a stable no-op over the fetched page. - return sorted; + // Active-only pages were already bounded in SQL. Merged pages are sliced + // here after composition so cold-storage rows share the same cursor. + if (!includeColdStorage) return sorted; + if (paginationLimit === undefined) return sorted.slice(paginationOffset); + return sorted.slice(paginationOffset, paginationOffset + paginationLimit); } // Slim mode drops ONLY the agent log column. On busy boards `log` accounts // for ~99% of the row payload (60+ MB across 1200 tasks); every other JSON @@ -531,9 +579,7 @@ export async function listTasksImpl(store: TaskStore, options?: { limit?: number })); const archivedTasks = includeArchived && (!columnFilter || columnFilter === "archived") ? store.archiveDb.list().map((entry) => store.archiveEntryToTask(entry, slim)) : []; // FNXC:BoardConsistency 2026-06-21-08:34: FN-6851's cache-sync fix is primary; listTasks still collapses duplicate storage sources so one task ID cannot render in two columns. Active SQLite rows are authoritative over archive snapshots. - const tasksById = new Map(activeTasks.map((task) => [task.id, task])); - for (const task of archivedTasks) if (!tasksById.has(task.id)) tasksById.set(task.id, task); - const tasks = [...tasksById.values()]; + const tasks = mergePrimaryById(activeTasks, archivedTasks); // Sort by createdAt, then by numeric ID suffix for tie-breaking const sorted = tasks.sort((a, b) => { const cmp = a.createdAt.localeCompare(b.createdAt); @@ -759,11 +805,8 @@ export async function listTasksModifiedSinceImpl(store: TaskStore, since: string export async function searchTasksImpl(store: TaskStore, query: string, options?: { limit?: number; offset?: number; slim?: boolean; includeArchived?: boolean }): Promise { // FNXC:RuntimePersistenceAsync 2026-06-24-11:00: - // Backend-mode searchTasks: delegate to the async tsvector search helper - // (the PG schema has the search_vector generated column with a GIN index). - // The result rows are converted to Tasks via pgRowToTaskRow + rowToTask and - // hydrated with the same derived fields as the SQLite path. Archive search - // is not yet wired (converted by runtime-workflow-async). + // Backend-mode searchTasks delegates live rows to the generated tsvector + // index and composes cold-storage matches when requested. if (store.backendMode) { const trimmedQuery = query?.trim(); if (!trimmedQuery) { @@ -772,14 +815,20 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: const layer = store.asyncLayer!; const limit = options?.limit; const offset = options?.offset ?? 0; + if (limit !== undefined && Math.max(0, limit) === 0) return []; const includeArchived = options?.includeArchived ?? true; const slim = options?.slim ?? false; // The tsvector path is the primary search (GIN-backed). The LIKE path is // a fallback if the tsvector query returns no results (e.g., if the search // index is cold). + const mergedPrefixLimit = includeArchived && limit !== undefined + ? Math.max(0, offset) + Math.max(0, limit) + : undefined; + const sourceLimit = includeArchived ? mergedPrefixLimit : limit; + const sourceOffset = includeArchived ? 0 : offset; let pgRows = await searchTasksTsvector(layer.db, trimmedQuery, { - limit, - offset, + limit: sourceLimit, + offset: sourceOffset, includeArchived, // FNXC:MultiProjectIsolation 2026-07-10: scope search to the bound project // (load-bearing for the CREATE-time near-duplicate check via searchTasks). @@ -787,8 +836,8 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: }); if (pgRows.length === 0) { pgRows = await searchTasksLike(layer.db, trimmedQuery, { - limit, - offset, + limit: sourceLimit, + offset: sourceOffset, includeArchived, projectId: layer.projectId, }); @@ -845,7 +894,32 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: return task; } })); - return tasks; + if (!includeArchived) return tasks; + /* + FNXC:PostgresArchiveReads 2026-07-14-17:09: + Search pagination is global across live and archived matches. Query both project-scoped sources without per-source offsets, keep the established live-then-archive ordering, deduplicate by task id, then apply the requested page. + */ + /* + FNXC:PostgresArchiveReadPerformance 2026-07-14-17:50: + Search preserves its live-results-first contract. For a finite page only the first offset+limit live matches can contribute; cold matches are fetched in bounded chunks until deduplication against authoritative live IDs fills the requested prefix or cold storage is exhausted. + */ + const target = mergedPrefixLimit; + const archiveEntries: ArchivedTaskEntry[] = []; + if (target === undefined || tasks.length < target) { + const chunkSize = target === undefined ? undefined : Math.max(1, target - tasks.length); + let archiveOffset = 0; + while (true) { + const chunk = await searchArchivedTasks(layer.db, trimmedQuery, chunkSize, layer.projectId, archiveOffset); + archiveEntries.push(...chunk); + if (chunkSize === undefined || chunk.length < chunkSize) break; + const uniqueCount = mergePrimaryById(tasks, archiveEntries.map((entry) => store.archiveEntryToTask(entry, slim))).length; + if (target !== undefined && uniqueCount >= target) break; + archiveOffset += chunk.length; + } + } + const matches = mergePrimaryById(tasks, archiveEntries.map((entry) => store.archiveEntryToTask(entry, slim))); + if (limit === undefined) return matches.slice(offset); + return matches.slice(offset, offset + Math.max(0, limit)); } // Fall back to listTasks for empty/whitespace-only queries const trimmedQuery = query?.trim(); @@ -1005,4 +1079,3 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: const matches = [...activeMatches, ...archiveMatches]; return limit >= 0 ? matches.slice(0, limit) : matches; } - diff --git a/packages/core/src/task-store/remaining-ops-1.ts b/packages/core/src/task-store/remaining-ops-1.ts index 88f2e4e65a..09109a28ae 100644 --- a/packages/core/src/task-store/remaining-ops-1.ts +++ b/packages/core/src/task-store/remaining-ops-1.ts @@ -43,6 +43,9 @@ import {listGoalCitations as listGoalCitationsAsync} from "../task-store/async-e import type {GoalCitationRow, RunAuditEventRow} from "../task-store/row-types.js"; export async function getOrCreateForProjectImpl(store: typeof TaskStore, projectId?: string, centralCore?: CentralCore, globalSettingsDir?: string, asyncLayer?: AsyncDataLayer,): Promise { + if (!asyncLayer) { + throw new Error("TaskStore.getOrCreateForProject requires a project-bound PostgreSQL AsyncDataLayer"); + } /* FNXC:PostgresCutover 2026-07-13-20:05: The fallback CentralCore must be bound to the caller's AsyncDataLayer. @@ -55,7 +58,7 @@ export async function getOrCreateForProjectImpl(store: typeof TaskStore, project dashboard project-store-resolver): dashboard UI came up but the engine never connected. */ - const central = centralCore ?? new CentralCore(undefined, asyncLayer ? { asyncLayer } : {}); + const central = centralCore ?? new CentralCore(undefined, { asyncLayer }); let initializedHere = false; if (!centralCore) { @@ -73,7 +76,7 @@ export async function getOrCreateForProjectImpl(store: typeof TaskStore, project const store = new TaskStore( context.workingDirectory, resolvedGlobalSettingsDir, - asyncLayer ? { asyncLayer } : undefined, + { asyncLayer }, ); await store.init(); return store; @@ -157,7 +160,7 @@ export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: st if (store.backendMode) { const layer = store.asyncLayer!; const existingRow = await layer.transactionImmediate(async (tx) => { - const row = await readTaskRowInTransaction(tx, id, { includeDeleted: true }); + const row = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); if (row && row.deletedAt != null) { return { deletedAt: row.deletedAt as string }; } @@ -626,15 +629,15 @@ export function getRunAuditEventsImpl(store: TaskStore, options: RunAuditEventFi return rows.map((row) => store.rowToRunAuditEvent(row)); } -export function getWorkflowParitySummaryImpl(store: TaskStore, options: { since?: string; limit?: number } = {}): WorkflowParitySummary { +export async function getWorkflowParitySummaryImpl(store: TaskStore, options: { since?: string; limit?: number } = {}): Promise { const limit = options.limit ?? 1000; - const observed = store.getRunAuditEvents({ + const observed = await store.getRunAuditEventsAsync({ domain: "database", mutationType: WORKFLOW_PARITY_OBSERVED_MUTATION as unknown as RunAuditEvent["mutationType"], startTime: options.since, limit, }); - const driftEvents = store.getRunAuditEvents({ + const driftEvents = await store.getRunAuditEventsAsync({ domain: "database", mutationType: WORKFLOW_PARITY_DRIFT_MUTATION as unknown as RunAuditEvent["mutationType"], startTime: options.since, diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index 0159fb9a90..8a884d614c 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -31,6 +31,7 @@ import {and, asc, eq, isNotNull, isNull, sql} from "drizzle-orm"; import {recoverExpiredMergeQueueLeases as recoverExpiredMergeQueueLeasesAsync} from "../task-store/async-merge-coordination.js"; import {updateBranchGroup as updateBranchGroupAsync, updatePrEntity as updatePrEntityAsync} from "../task-store/async-branch-groups.js"; import {recordCompletionHandoff as recordCompletionHandoffAsync, getCompletionHandoffMarker as getCompletionHandoffMarkerAsync} from "../task-store/async-workflow-workitems.js"; +import { taskProjectScope } from "../postgres/data-layer.js"; import {getActivityLog as getActivityLogAsync} from "../task-store/async-audit.js"; import {insertArtifactRow as insertArtifactRowAsync} from "../task-store/async-comments-attachments.js"; import type { ArtifactRow } from "./row-types.js"; @@ -497,7 +498,7 @@ export async function renewCheckoutLeaseImpl(store: TaskStore, taskId: string, u const layer = store.asyncLayer!; const dir = store.taskDir(taskId); const outcome = await layer.transactionImmediate(async (tx) => { - const row = await readTaskRowInTransaction(tx, taskId, { includeDeleted: true }); + const row = await readTaskRowInTransaction(tx, taskId, { includeDeleted: true }, layer.projectId); if (row?.deletedAt) { return { deletedAt: row.deletedAt as string, current: undefined }; } @@ -512,7 +513,7 @@ export async function renewCheckoutLeaseImpl(store: TaskStore, taskId: string, u if (result.length === 0) { return { deletedAt: undefined, current: undefined }; } - const fresh = await readTaskRowInTransaction(tx, taskId); + const fresh = await readTaskRowInTransaction(tx, taskId, undefined, layer.projectId); return { deletedAt: undefined, current: fresh }; }); @@ -943,7 +944,7 @@ export async function cleanupBranchForTaskImpl(store: TaskStore, task: Task): Pr } } if (deleted.length > 0) { - store.clearStaleExecutionStartBranchReferences(deleted, task.id); + await store.clearStaleExecutionStartBranchReferences(deleted, task.id); } return deleted; } @@ -1315,7 +1316,49 @@ ${deps} ${stepsSection}`; } -export function listWorkflowOccupantTaskIdsImpl(store: TaskStore, workflowId: string, includeNullSelection: boolean): string[] { +export async function listWorkflowOccupantTaskIdsImpl(store: TaskStore, workflowId: string, includeNullSelection: boolean): Promise { + /* + FNXC:PostgresWorkflowOccupancy 2026-07-14-17:44: + Workflow edits and deletes must discover occupants from PostgreSQL before changing an IR or clearing selection rows. Archived and soft-deleted tasks are never occupants; optionally include live tasks whose selection resolves implicitly to the default workflow. + */ + if (store.backendMode) { + const layer = store.asyncLayer!; + const selected = await layer.db + .select({ taskId: schema.project.taskWorkflowSelection.taskId }) + .from(schema.project.taskWorkflowSelection) + .innerJoin(schema.project.tasks, and( + eq(schema.project.tasks.id, schema.project.taskWorkflowSelection.taskId), + eq(schema.project.tasks.projectId, schema.project.taskWorkflowSelection.projectId), + )) + .where(and( + eq(schema.project.taskWorkflowSelection.workflowId, workflowId), + isNull(schema.project.tasks.deletedAt), + taskProjectScope(layer), + layer.projectId + ? eq(schema.project.taskWorkflowSelection.projectId, layer.projectId) + : undefined, + )); + const ids = selected.map((row) => row.taskId); + if (includeNullSelection) { + const unselected = await layer.db + .select({ id: schema.project.tasks.id }) + .from(schema.project.tasks) + .leftJoin( + schema.project.taskWorkflowSelection, + and( + eq(schema.project.taskWorkflowSelection.taskId, schema.project.tasks.id), + eq(schema.project.taskWorkflowSelection.projectId, schema.project.tasks.projectId), + ), + ) + .where(and( + isNull(schema.project.tasks.deletedAt), + isNull(schema.project.taskWorkflowSelection.taskId), + taskProjectScope(layer), + )); + ids.push(...unselected.map((row) => row.id)); + } + return ids; + } const ids: string[] = []; const selected = store.db .prepare( @@ -1347,9 +1390,14 @@ export async function evacuateCustomColumnsToLegacyImpl(store: TaskStore, trigge // (triage). Falls back to "triage" defensively if the IR can't be resolved. const targetColumn = resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR) ?? "triage"; - const rows = store.db - .prepare(`SELECT id, "column" AS col FROM tasks WHERE deletedAt IS NULL`) - .all() as Array<{ id: string; col: string }>; + const rows: Array<{ id: string; col: string }> = store.backendMode + ? (await store.asyncLayer!.db + .select({ id: schema.project.tasks.id, col: schema.project.tasks.column }) + .from(schema.project.tasks) + .where(and(isNull(schema.project.tasks.deletedAt), taskProjectScope(store.asyncLayer!)))) + : store.db + .prepare(`SELECT id, "column" AS col FROM tasks WHERE deletedAt IS NULL`) + .all() as Array<{ id: string; col: string }>; for (const { id, col } of rows) { scanned += 1; diff --git a/packages/core/src/task-store/remaining-ops-3.ts b/packages/core/src/task-store/remaining-ops-3.ts index 16601378a0..03cc269540 100644 --- a/packages/core/src/task-store/remaining-ops-3.ts +++ b/packages/core/src/task-store/remaining-ops-3.ts @@ -155,7 +155,7 @@ export async function readTaskForMoveImpl(store: TaskStore, id: string): Promise return store.rowToTask(store.pgRowToTaskRow(pgRow)); } // Fall back to archive lookup (soft-deleted/archived tasks). - const entry = await findArchivedTaskEntry(layer.db, id); + const entry = await findArchivedTaskEntry(layer.db, id, layer.projectId); if (entry) { return store.archiveEntryToTask(entry, false); } @@ -237,4 +237,3 @@ export function rowToRunAuditEventImpl(store: TaskStore, row: RunAuditEventRow): metadata: fromJson>(row.metadata), }; } - diff --git a/packages/core/src/task-store/remaining-ops-4.ts b/packages/core/src/task-store/remaining-ops-4.ts index ca9226c63b..fe84798942 100644 --- a/packages/core/src/task-store/remaining-ops-4.ts +++ b/packages/core/src/task-store/remaining-ops-4.ts @@ -122,7 +122,7 @@ export async function atomicWriteTaskJsonImpl2(store: TaskStore, dir: string, ta always wrote the changed-column subset. */ await layer.transactionImmediate(async (tx) => { - const pgRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }); + const pgRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); if (!pgRow || pgRow.deletedAt != null) { // Update-only path: never resurrect a soft-deleted row; a missing row // falls through to the legacy full upsert (matches sqlite's diff --git a/packages/core/src/task-store/remaining-ops-5.ts b/packages/core/src/task-store/remaining-ops-5.ts index 0a5a82ee73..775be408e1 100644 --- a/packages/core/src/task-store/remaining-ops-5.ts +++ b/packages/core/src/task-store/remaining-ops-5.ts @@ -495,7 +495,7 @@ export function findLiveDependentsImpl(store: TaskStore, id: string): string[] { export async function findLiveLineageChildrenImpl(store: TaskStore, id: string): Promise { if (store.backendMode) { const layer = store.asyncLayer!; - return findLiveLineageChildrenAsync(layer.db, id); + return findLiveLineageChildrenAsync(layer.db, id, layer.projectId); } const rows = store.db .prepare( @@ -907,4 +907,3 @@ export async function createBranchGroupImpl(store: TaskStore, input: BranchGroup const created = await store.getBranchGroup(id); return created!; } - diff --git a/packages/core/src/task-store/remaining-ops-6.ts b/packages/core/src/task-store/remaining-ops-6.ts index 7fbe7ab346..ec1e169055 100644 --- a/packages/core/src/task-store/remaining-ops-6.ts +++ b/packages/core/src/task-store/remaining-ops-6.ts @@ -744,19 +744,24 @@ export function getWorkflowSettingsProjectIdImpl(store: TaskStore): string { } } -export function listWorkflowSettingValuesForProjectImpl(store: TaskStore): Record> { +export async function listWorkflowSettingValuesForProjectImpl(store: TaskStore): Promise>> { /* - * FNXC:SqliteFinalRemoval 2026-06-26: - * P1 fix: no backendMode branch existed, so this threw in PG mode. In - * backend mode, sync reads of workflow_settings are not possible (the - * async layer is the authoritative reader). Return empty (the default) - * so sync callers (e.g. settings export snapshots composing a sync view) - * do not throw; async callers use the async listWorkflowSettingValues - * path. The async `getSettingsByScope`-composed dashboard routes read - * workflow settings through the async helpers, not this sync method. + * FNXC:PostgresWorkflowSettings 2026-07-14-17:46: + * Settings exports, dashboard scope responses, memory settings, and cross-node comparisons must include the project-bound PostgreSQL workflow_settings rows. The project id is resolved through the same store binding used for writes so one project's values cannot leak into another export. */ if (store.backendMode) { - return {}; + const projectId = store.getWorkflowSettingsProjectId(); + const rows = await store.asyncLayer!.db + .select({ workflowId: schema.project.workflowSettings.workflowId, values: schema.project.workflowSettings.values }) + .from(schema.project.workflowSettings) + .where(eq(schema.project.workflowSettings.projectId, projectId)); + const out: Record> = {}; + for (const row of rows) { + if (row.values && typeof row.values === "object" && !Array.isArray(row.values)) { + out[row.workflowId] = row.values as Record; + } + } + return out; } const projectId = store.getWorkflowSettingsProjectId(); const rows = store.db diff --git a/packages/core/src/task-store/remaining-ops-7.ts b/packages/core/src/task-store/remaining-ops-7.ts index f2437bf033..8bf32361be 100644 --- a/packages/core/src/task-store/remaining-ops-7.ts +++ b/packages/core/src/task-store/remaining-ops-7.ts @@ -12,12 +12,12 @@ import { countAgentLogEntries, readAgentLogEntries } from "../agent-log-file-sto import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { toJsonNullable } from "../db.js"; import { DbTransaction, recordRunAuditEventWithinTransaction } from "../postgres/data-layer.js"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, isNull, ne } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; import { runCommandAsync } from "../run-command.js"; import { getStepParser } from "../step-parsers.js"; import { getTaskMergeBlocker } from "../task-merge.js"; -import { deleteTaskDocument as deleteTaskDocumentAsync, getArtifact as getArtifactAsync, getArtifacts as getArtifactsAsync, getTaskDocument as getTaskDocumentAsync, getTaskDocumentRevisions as getTaskDocumentRevisionsAsync, listTaskDocuments as listTaskDocumentsAsync, updateArtifactRow as updateArtifactRowAsync } from "./async-comments-attachments.js"; +import { deleteTaskDocument as deleteTaskDocumentAsync, getArtifact as getArtifactAsync, getArtifacts as getArtifactsAsync, getLiveTaskColumn, getTaskDocument as getTaskDocumentAsync, getTaskDocumentRevisions as getTaskDocumentRevisionsAsync, listTaskDocuments as listTaskDocumentsAsync, updateArtifactRow as updateArtifactRowAsync } from "./async-comments-attachments.js"; import { emitUsageEvent as emitUsageEventAsync, recordPluginActivation as recordPluginActivationAsync } from "./async-events.js"; import { enqueueMergeQueue as enqueueMergeQueueAsync, peekMergeQueue as peekMergeQueueAsync, peekMergeQueueHead as peekMergeQueueHeadAsync } from "./async-merge-coordination.js"; import { clearCompletionHandoffMarker as clearCompletionHandoffMarkerAsync, getCompletionHandoffMarker as getCompletionHandoffMarkerAsync } from "./async-workflow-workitems.js"; @@ -110,15 +110,15 @@ export async function recordPluginActivationImpl(store: TaskStore, input: Plugin }; } -export function computeWorkflowColumnsGraduationReportImpl(store: TaskStore, +export async function computeWorkflowColumnsGraduationReportImpl(store: TaskStore, options: { since?: string; limit?: number } = {}, - ): WorkflowColumnsGraduationReport { + ): Promise { const limit = options.limit ?? 1000; - const parity = store.getWorkflowParitySummary(options); + const parity = await store.getWorkflowParitySummary(options); const dualAcceptEvents: RunAuditEvent[] = []; for (const mutationType of DUAL_ACCEPT_PARITY_MUTATIONS) { dualAcceptEvents.push( - ...store.getRunAuditEvents({ + ...await store.getRunAuditEventsAsync({ domain: "database", mutationType: mutationType as unknown as RunAuditEvent["mutationType"], startTime: options.since, @@ -387,16 +387,36 @@ export async function runGitCommandImpl(store: TaskStore, command: string, timeo }); } -export function clearStaleExecutionStartBranchReferencesImpl(store: TaskStore, deletedBranches: string[], ownerTaskId?: string): string[] { +export async function clearStaleExecutionStartBranchReferencesImpl(store: TaskStore, deletedBranches: string[], ownerTaskId?: string): Promise { if (deletedBranches.length === 0) return []; - /* - FNXC:PostgresCutover 2026-07-04-00:00: - Intentional PG safe-default: stale-branch clearing is best-effort cleanup (executionStartBranch - references to deleted branches). Returning [] means no branches cleared in PG mode — stale - references accumulate but don't break functionality. Converting to async would cascade through - 15+ test mocks (vi.fn().mockReturnValue([])). Disproportionate to the low risk. - */ - if (store.backendMode) return []; + if (store.backendMode) { + /* + FNXC:PostgresBranchCleanup 2026-07-14-17:30: + Deleted execution-start branches must be cleared from every other live task in PostgreSQL. Returning an empty safe default leaves durable references to branches that no longer exist and turns later worktree creation into a false hard failure. + */ + const now = new Date().toISOString(); + const conditions = [ + isNull(schema.project.tasks.deletedAt), + inArray(schema.project.tasks.executionStartBranch, deletedBranches), + ]; + if (ownerTaskId) conditions.push(ne(schema.project.tasks.id, ownerTaskId)); + const rows = await store.asyncLayer!.db + .update(schema.project.tasks) + .set({ executionStartBranch: null, updatedAt: now }) + .where(and(...conditions)) + .returning({ id: schema.project.tasks.id }); + const clearedIds = rows.map((row) => row.id); + if (store.isWatching) { + for (const id of clearedIds) { + const cached = store.taskCache.get(id); + if (cached) { + cached.executionStartBranch = undefined; + cached.updatedAt = now; + } + } + } + return clearedIds; + } const placeholders = deletedBranches.map(() => "?").join(","); const params: string[] = [...deletedBranches]; let whereClause = `executionStartBranch IN (${placeholders})`; @@ -603,6 +623,12 @@ export async function addSteeringCommentImpl(store: TaskStore, id: string, text: } export async function updateTaskCommentImpl(store: TaskStore, id: string, commentId: string, text: string): Promise { + if (store.backendMode) { + const layer = store.asyncLayer!; + const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`); + if (state === null) throw new Error(`Task ${id} not found`); + } return store.withTaskLock(id, async () => { const dir = store.taskDir(id); const task = await store.readTaskJson(dir); @@ -631,6 +657,12 @@ export async function updateTaskCommentImpl(store: TaskStore, id: string, commen } export async function deleteTaskCommentImpl(store: TaskStore, id: string, commentId: string): Promise { + if (store.backendMode) { + const layer = store.asyncLayer!; + const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`); + if (state === null) throw new Error(`Task ${id} not found`); + } return store.withTaskLock(id, async () => { const dir = store.taskDir(id); const task = await store.readTaskJson(dir); @@ -768,7 +800,7 @@ export async function updateArtifactImpl(store: TaskStore, id: string, updates: export async function getArtifactsImpl(store: TaskStore, taskId: string): Promise { if (store.backendMode) { const layer = store.asyncLayer!; - return getArtifactsAsync(layer.db, taskId); + return getArtifactsAsync(layer.db, taskId, layer.projectId); } if (!store.hasActiveTask(taskId)) { return []; @@ -783,7 +815,7 @@ export async function getArtifactsImpl(store: TaskStore, taskId: string): Promis export async function getTaskDocumentsImpl(store: TaskStore, taskId: string): Promise { if (store.backendMode) { const layer = store.asyncLayer!; - return listTaskDocumentsAsync(layer.db, taskId); + return listTaskDocumentsAsync(layer.db, taskId, layer.projectId); } if (!store.hasActiveTask(taskId)) { return []; @@ -798,7 +830,7 @@ export async function getTaskDocumentsImpl(store: TaskStore, taskId: string): Pr export async function getTaskDocumentImpl(store: TaskStore, taskId: string, key: string): Promise { if (store.backendMode) { const layer = store.asyncLayer!; - return getTaskDocumentAsync(layer.db, taskId, key); + return getTaskDocumentAsync(layer.db, taskId, key, layer.projectId); } if (!store.hasActiveTask(taskId)) { return null; @@ -825,7 +857,7 @@ export async function getTaskDocumentRevisionsImpl(store: TaskStore, */ if (store.backendMode) { const layer = store.asyncLayer!; - const rows = await getTaskDocumentRevisionsAsync(layer.db, taskId, key); + const rows = await getTaskDocumentRevisionsAsync(layer.db, taskId, key, layer.projectId); const sorted = [...rows].sort((a, b) => b.revision - a.revision); const mapped = sorted.map((row) => store.rowToTaskDocumentRevision(row)); return options?.limit !== undefined ? mapped.slice(0, Math.max(0, options.limit)) : mapped; diff --git a/packages/core/src/task-store/remaining-ops-8.ts b/packages/core/src/task-store/remaining-ops-8.ts index 68e3d6bf16..9fa8264da9 100644 --- a/packages/core/src/task-store/remaining-ops-8.ts +++ b/packages/core/src/task-store/remaining-ops-8.ts @@ -24,13 +24,14 @@ import { PluginStore } from "../plugin-store.js"; import { SecretsStore } from "../secrets-store.js"; import { createAsyncDistributedTaskIdAllocator } from "./async-allocator.js"; import { getWorkflowRow, listWorkflowRows } from "../async-workflow-store.js"; +import { taskProjectScope } from "../postgres/data-layer.js"; import { getInReviewDurationEvents as getInReviewDurationEventsAsync, getTaskMergedTaskIds as getTaskMergedTaskIdsAsync } from "./async-audit.js"; import { readProjectConfig, writeProjectConfig } from "./async-settings.js"; import { compactTaskActivityLog } from "./comments.js"; import { type TaskRow } from "./persistence.js"; import { ActivityLogRow } from "./row-types.js"; import { ActivityEventType, ActivityLogEntry, AgentLogEntry, ArchivedTaskEntry, DEFAULT_SETTINGS, Settings } from "../types.js"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; import { normalizeWorkflowIcon, type StoredWorkflowRow, type WorkflowDefinition, type WorkflowDefinitionInput, type WorkflowNodeLayout } from "../workflow-definition-types.js"; import { WorkflowIr } from "../workflow-ir-types.js"; @@ -293,23 +294,26 @@ export async function getWorkflowDefinitionImpl(store: TaskStore, return row ? store.toWorkflowDefinition(row) : undefined; } -export function occupantsByColumnForWorkflowImpl(store: TaskStore, +export async function occupantsByColumnForWorkflowImpl(store: TaskStore, workflowId: string, includeNullSelection: boolean, - ): Map { - /* - FNXC:PostgresCutover 2026-07-04-00:00: - Assessed safe-default: the occupied-column guard is skipped in PG mode (empty Map → - removed=[] → no OccupiedColumnsError). The async enforcement path in workflow-ops.ts - (lines 365-372) does read columns via Drizzle, but it's gated on removed.length > 0 - which is always false here. Full fix requires async listWorkflowOccupantTaskIds + - async occupancy map — a deep refactor of the workflow occupancy system. Low-impact: - flag-gated (workflowColumnsFlagOn, off by default), only triggers on workflow IR - edits that remove columns. Tasks in removed columns are orphaned but not lost. - */ - if (store.backendMode) return new Map(); + ): Promise> { const counts = new Map(); - for (const taskId of store.listWorkflowOccupantTaskIds(workflowId, includeNullSelection)) { + const taskIds = await store.listWorkflowOccupantTaskIds(workflowId, includeNullSelection); + if (store.backendMode) { + if (taskIds.length === 0) return counts; + const rows = await store.asyncLayer!.db + .select({ column: schema.project.tasks.column }) + .from(schema.project.tasks) + .where(and( + inArray(schema.project.tasks.id, taskIds), + isNull(schema.project.tasks.deletedAt), + taskProjectScope(store.asyncLayer!), + )); + for (const row of rows) counts.set(row.column, (counts.get(row.column) ?? 0) + 1); + return counts; + } + for (const taskId of taskIds) { const row = store.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as | { column: string } | undefined; diff --git a/packages/core/src/task-store/workflow-ops.ts b/packages/core/src/task-store/workflow-ops.ts index 213633d27a..79735028d2 100644 --- a/packages/core/src/task-store/workflow-ops.ts +++ b/packages/core/src/task-store/workflow-ops.ts @@ -346,7 +346,7 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, const existingForCheck = await store.getWorkflowDefinition(id); if (!existingForCheck) throw new Error(`Workflow '${id}' not found`); const nextIrForCheck = parseWorkflowIr(updates.ir); - const occupantsByColumn = store.occupantsByColumnForWorkflow(id, false); + const occupantsByColumn = await store.occupantsByColumnForWorkflow(id, false); const removed = computeRemovedOccupiedColumns( existingForCheck.ir, nextIrForCheck, @@ -360,7 +360,7 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, // Collect the occupant task ids of the removed columns to re-home AFTER // the IR save commits, so the cards land in a column the new IR defines. const removedSet = new Set(removed.map((r) => r.columnId)); - const allOccupantTaskIds = store.listWorkflowOccupantTaskIds(id, false); + const allOccupantTaskIds = await store.listWorkflowOccupantTaskIds(id, false); let occupantTaskIds: string[]; if (layer) { // FNXC:PostgresCutover 2026-06-28: async read for column check @@ -401,7 +401,7 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, const fieldsChanged = JSON.stringify(oldFields) !== JSON.stringify(newFields); if (fieldsChanged) { - const occupantTaskIds = store.listWorkflowOccupantTaskIds(id, false); + const occupantTaskIds = await store.listWorkflowOccupantTaskIds(id, false); const occupantsByField = new Map(); for (const taskId of occupantTaskIds) { let values: Record = {}; @@ -529,7 +529,7 @@ export async function deleteWorkflowDefinitionImpl(store: TaskStore, id: string) // their selection rows, so we can re-home them to the DEFAULT workflow's // entry column once their selection resolves back to the default (KTD-1). const flagOn = await store.workflowColumnsFlagOn(); - const occupantTaskIds = flagOn ? store.listWorkflowOccupantTaskIds(id, false) : []; + const occupantTaskIds = flagOn ? await store.listWorkflowOccupantTaskIds(id, false) : []; if (layer) { // FNXC:PostgresCutover 2026-06-28: async deletes for backend mode diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0f8e0a397e..c49a1e5703 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1877,7 +1877,7 @@ export interface CentralClaimStore { runId: string | null; renewedAt: string; expectedEpoch?: number | null; - }): { ok: true; claim: TaskClaimRow } | { ok: false; reason: "conflict"; current: TaskClaimRow }; + }): { ok: true; claim: TaskClaimRow } | { ok: false; reason: "conflict"; current: TaskClaimRow } | Promise<{ ok: true; claim: TaskClaimRow } | { ok: false; reason: "conflict"; current: TaskClaimRow }>; renewTaskClaim(input: { projectId: string; taskId: string; @@ -1886,14 +1886,14 @@ export interface CentralClaimStore { runId: string | null; renewedAt: string; expectedEpoch: number; - }): { ok: true; claim: TaskClaimRow } | { ok: false; reason: "conflict" | "not_found"; current: TaskClaimRow | null }; + }): { ok: true; claim: TaskClaimRow } | { ok: false; reason: "conflict" | "not_found"; current: TaskClaimRow | null } | Promise<{ ok: true; claim: TaskClaimRow } | { ok: false; reason: "conflict" | "not_found"; current: TaskClaimRow | null }>; releaseTaskClaim(input: { projectId: string; taskId: string; nodeId: string; agentId: string; - }): { ok: true } | { ok: false; reason: "not_owner" | "not_found"; current: TaskClaimRow | null }; - getTaskClaim(projectId: string, taskId: string): TaskClaimRow | null; + }): { ok: true } | { ok: false; reason: "not_owner" | "not_found"; current: TaskClaimRow | null } | Promise<{ ok: true } | { ok: false; reason: "not_owner" | "not_found"; current: TaskClaimRow | null }>; + getTaskClaim(projectId: string, taskId: string): TaskClaimRow | null | Promise; } /** diff --git a/packages/dashboard/src/ai-session-store.ts b/packages/dashboard/src/ai-session-store.ts index abf5878ed0..042840cf07 100644 --- a/packages/dashboard/src/ai-session-store.ts +++ b/packages/dashboard/src/ai-session-store.ts @@ -2,7 +2,7 @@ * AI Session Store * * Persists long-running AI session state (planning, subtask breakdown, - * mission interview) to SQLite so users can dismiss modals and return + * mission interview) to PostgreSQL so users can dismiss modals and return * later — even from a different browser. * * The in-memory session Maps in planning.ts / subtask-breakdown.ts / @@ -11,7 +11,7 @@ */ import { EventEmitter } from "node:events"; -import { THINKING_LEVELS, type Database, type AsyncDataLayer, type ThinkingLevel } from "@fusion/core"; +import { THINKING_LEVELS, type AsyncDataLayer, type ThinkingLevel } from "@fusion/core"; import { upsertAiSession, getAiSession, @@ -63,64 +63,28 @@ export interface AiSessionSummary { type: AiSessionType; status: AiSessionStatus; title: string; - /** - * For draft planning sessions only: a short, derived preview of the - * persisted initialPlan so the sidebar can distinguish multiple drafts - * before the user has started any of them. Computed at read time from - * inputPayload — never persisted as the title — so unfinished keystrokes - * don't end up baked into the row's permanent title. - */ preview?: string; projectId: string | null; updatedAt: string; archived?: boolean; } -/** Max characters of initialPlan surfaced as a sidebar preview for drafts. */ const DRAFT_PREVIEW_MAX_CHARS = 80; export interface AiSessionStoreEvents { "ai_session:updated": [AiSessionSummary]; - "ai_session:deleted": [string]; // session id + "ai_session:deleted": [string]; } -// ── Constants ─────────────────────────────────────────────────────────── - -/** Max stored thinking output (50 KB). Older content trimmed from front. */ -const MAX_THINKING_BYTES = 50 * 1024; - -/** Debounce interval for thinking-only writes (ms). */ const THINKING_DEBOUNCE_MS = 2000; -/** Default max age before stale AI sessions are eligible for cleanup (7 days). */ export const SESSION_CLEANUP_DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; - -/** Default scheduled interval for stale session cleanup runs (6 hours). */ export const SESSION_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; /** * FNXC:AiSessionStore 2026-07-13-00:00: - * FN-7949 — deleting a Planning Mode session while its background generation - * is still in flight let the session silently reappear. Root cause: - * `runGenerationWithTimeout` (planning.ts) and the equivalent wrappers in - * subtask-breakdown.ts/mission-interview.ts/milestone-slice-interview.ts use - * `Promise.race([operation(...), abortPromise])` to abort generation — that - * only stops the *caller* from awaiting `operation`, it does NOT cancel the - * underlying `session.agent.session.prompt()` call. If the session is deleted - * while that promise is still pending, the abandoned call later resolves and - * calls `persistSession(...)` -> `upsert()`, which used to unconditionally - * re-INSERT the row and re-emit `ai_session:updated`, resurrecting a session - * the user explicitly deleted. - * - * Fix: `AiSessionStore` remembers deleted ids in a bounded-TTL tombstone map. - * `upsert()` drops (no-ops) any write for an id tombstoned within the TTL - * window, so a straggling write can never resurrect a deleted session. This - * lives here — the single shared store — rather than being duplicated in each - * producer, so the invariant holds for every AiSessionType (planning, subtask, - * mission_interview, milestone_interview, slice_interview) without forking - * the fix per-producer. 10 minutes is generously longer than any realistic - * straggling generation write (session ids are UUIDs, never legitimately - * reused), so id-reuse racing past the TTL is not an expected production path. + * Deleted session ids remain tombstoned long enough to reject writes from an + * already-abandoned generation promise after the user deletes that session. */ export const DELETE_TOMBSTONE_TTL_MS = 10 * 60 * 1000; @@ -135,42 +99,28 @@ const diagnostics = createSessionDiagnostics("ai-session-store"); // ── Store ─────────────────────────────────────────────────────────────── export class AiSessionStore extends EventEmitter { - /** Pending debounce timers for thinking-only writes, keyed by session id. */ private thinkingTimers = new Map>(); - /** Interval used for periodic stale-session cleanup. */ private cleanupTimer: ReturnType | undefined; /** - * FNXC:AiSessionStore 2026-06-24-23:50: - * When non-null, the store is in backend (PostgreSQL) mode and delegates to - * the async helpers. The sync db is unused in this mode. This is the dual-path - * pattern for the AI session system. + * FNXC:PostgresAiSessionStore 2026-07-14-19:20: + * Background AI-session persistence is PostgreSQL-only. Requiring the + * project AsyncDataLayer prevents deleted or resumed sessions from landing + * in a disconnected SQLite shadow store. */ - private readonly asyncLayer: AsyncDataLayer | null; + private readonly asyncLayer: AsyncDataLayer; /** * FN-7949 delete tombstones: id -> deletion timestamp (ms since epoch). * Consulted by `upsert()` to drop straggling writes for ids deleted within * `DELETE_TOMBSTONE_TTL_MS`. See the FNXC:AiSessionStore comment above. */ private deletedIds = new Map(); - - - constructor(private db: Database, options?: { asyncLayer?: AsyncDataLayer | null }) { + constructor(asyncLayer: AsyncDataLayer) { super(); - this.asyncLayer = options?.asyncLayer ?? null; + this.asyncLayer = asyncLayer; } - /** True when the store is backed by PostgreSQL (AsyncDataLayer present). */ - private get backendMode(): boolean { - return this.asyncLayer !== null; - } - - /** - * FNXC:AiSessionStore 2026-06-24-23:50: - * Returns the async layer db handle for delegation. Throws if not in backend - * mode (should never be called when backendMode is false). - */ private get dbAsync(): AsyncDataLayer["db"] { - return this.asyncLayer!.db; + return this.asyncLayer.db; } // ── CRUD ──────────────────────────────────────────────────────────── @@ -181,7 +131,7 @@ export class AiSessionStore extends EventEmitter { */ async upsert(session: AiSessionRow): Promise { // FNXC:AiSessionStore 2026-07-13-00:00: FN-7949 tombstone guard — drop any - // upsert for an id that was deleted within the TTL window (both backends), + // upsert for an id that was deleted within the TTL window, // so a straggling post-delete generation write can never resurrect a // deleted session. if (this.isTombstoned(session.id)) { @@ -191,55 +141,10 @@ export class AiSessionStore extends EventEmitter { }); return; } - - if (this.backendMode) { - this.clearThinkingTimer(session.id); - const row = await upsertAiSession(this.dbAsync, session as import("@fusion/core").AsyncAiSessionRow); - this.emit("ai_session:updated", toSummary(row as AiSessionRow, row.updatedAt)); - return; - } - const now = new Date().toISOString(); - // FNXC:PlanningMode 2026-07-02-00:00: Planning checkpoints persist pending summaries inside inputPayload, so every session upsert must refresh inputPayload on existing rows instead of treating it as create-only draft metadata. - const thinking = trimThinking(session.thinkingOutput); - - this.db - .prepare( - `INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - status = excluded.status, - title = excluded.title, - inputPayload = excluded.inputPayload, - conversationHistory = excluded.conversationHistory, - currentQuestion = excluded.currentQuestion, - result = excluded.result, - thinkingOutput = excluded.thinkingOutput, - error = excluded.error, - updatedAt = excluded.updatedAt`, - ) - .run( - session.id, - session.type, - session.status, - session.title, - session.inputPayload, - session.conversationHistory, - session.currentQuestion ?? null, - session.result ?? null, - thinking, - session.error ?? null, - session.projectId ?? null, - session.createdAt || now, - now, - ); - - // Cancel any pending thinking debounce for this session this.clearThinkingTimer(session.id); - - const row = await this.get(session.id); - if (row) { - this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } + const row = await upsertAiSession(this.dbAsync, session as import("@fusion/core").AsyncAiSessionRow); + this.emit("ai_session:updated", toSummary(row as AiSessionRow, row.updatedAt)); + return; } /** @@ -266,13 +171,7 @@ export class AiSessionStore extends EventEmitter { * Fetch a single session by ID. Returns null if not found. */ async get(id: string): Promise { - if (this.backendMode) { - return getAiSession(this.dbAsync, id) as Promise; - } - const row = this.db - .prepare("SELECT * FROM ai_sessions WHERE id = ?") - .get(id) as unknown as AiSessionRow | undefined; - return row ?? null; + return getAiSession(this.dbAsync, id) as Promise; } /** @@ -280,65 +179,21 @@ export class AiSessionStore extends EventEmitter { * Returns false when the session does not exist. */ async updateStatus(id: string, status: AiSessionStatus, error?: string): Promise { - if (this.backendMode) { - const changed = await updateAiSessionStatus(this.dbAsync, id, status, error); - if (changed) { - const row = await this.get(id); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return changed; + const changed = await updateAiSessionStatus(this.dbAsync, id, status, error); + if (changed) { + const row = await this.get(id); + if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); } - const now = new Date().toISOString(); - const result = this.db - .prepare( - `UPDATE ai_sessions - SET status = ?, error = ?, updatedAt = ? - WHERE id = ?`, - ) - .run(status, error ?? null, now, id) as { changes?: number }; - - const changed = Number(result.changes ?? 0) > 0; - if (!changed) { - return false; - } - - const row = await this.get(id); - if (row) { - this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - - return true; + return changed; } async updateTitle(id: string, title: string): Promise { - if (this.backendMode) { - const changed = await updateAiSessionTitle(this.dbAsync, id, title); - if (changed) { - const row = await this.get(id); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return changed; + const changed = await updateAiSessionTitle(this.dbAsync, id, title); + if (changed) { + const row = await this.get(id); + if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); } - const now = new Date().toISOString(); - const result = this.db - .prepare( - `UPDATE ai_sessions - SET title = ?, updatedAt = ? - WHERE id = ?`, - ) - .run(title, now, id) as { changes?: number }; - - const changed = Number(result.changes ?? 0) > 0; - if (!changed) { - return false; - } - - const row = await this.get(id); - if (row) { - this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - - return true; + return changed; } /** @@ -349,55 +204,22 @@ export class AiSessionStore extends EventEmitter { * are preserved by merge — this method only touches `summarizedFor`. */ async markDraftSummarized(id: string, title: string, summarizedFor: string): Promise { - if (this.backendMode) { - const existing = await this.get(id); - if (!existing || existing.type !== "planning") return false; - let payload: Record = {}; - if (existing.inputPayload) { - try { - const parsed = JSON.parse(existing.inputPayload); - if (parsed && typeof parsed === "object") payload = parsed as Record; - } catch { /* ignore */ } - } - payload.summarizedFor = summarizedFor; - const changed = await markDraftSummarizedAsync(this.dbAsync, id, title, JSON.stringify(payload)); - if (changed) { - const row = await this.get(id); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return changed; - } const existing = await this.get(id); if (!existing || existing.type !== "planning") return false; - let payload: Record = {}; if (existing.inputPayload) { try { const parsed = JSON.parse(existing.inputPayload); if (parsed && typeof parsed === "object") payload = parsed as Record; - } catch { - // Fall through with empty payload — better to lose stale fields than - // to refuse the update and leave the title out of sync with reality. - } + } catch { /* ignore */ } } payload.summarizedFor = summarizedFor; - const inputPayload = JSON.stringify(payload); - - const now = new Date().toISOString(); - const result = this.db - .prepare( - `UPDATE ai_sessions - SET title = ?, inputPayload = ?, updatedAt = ? - WHERE id = ? AND type = 'planning'`, - ) - .run(title, inputPayload, now, id) as { changes?: number }; - - const changed = Number(result.changes ?? 0) > 0; - if (!changed) return false; - - const row = await this.get(id); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - return true; + const changed = await markDraftSummarizedAsync(this.dbAsync, id, title, JSON.stringify(payload)); + if (changed) { + const row = await this.get(id); + if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); + } + return changed; } /** @@ -417,56 +239,6 @@ export class AiSessionStore extends EventEmitter { id: string, draft: { initialPlan: string; modelProvider?: string; modelId?: string; thinkingLevel?: ThinkingLevel }, ): Promise { - if (this.backendMode) { - const existing = await this.get(id); - let preservedSummarizedFor: string | undefined; - let preservedThinkingLevel: ThinkingLevel | undefined; - if (existing?.inputPayload) { - try { - const prev = JSON.parse(existing.inputPayload) as { - summarizedFor?: unknown; - modelProvider?: unknown; - modelId?: unknown; - thinkingLevel?: unknown; - }; - if (THINKING_LEVELS.includes(prev.thinkingLevel as ThinkingLevel)) { - preservedThinkingLevel = prev.thinkingLevel as ThinkingLevel; - } - const trimmedPlan = draft.initialPlan.trim(); - const hasModelOverride = Boolean(draft.modelProvider && draft.modelId); - const prevProvider = typeof prev.modelProvider === "string" ? prev.modelProvider : undefined; - const prevModelId = typeof prev.modelId === "string" ? prev.modelId : undefined; - const newProvider = hasModelOverride ? draft.modelProvider : undefined; - const newModelId = hasModelOverride ? draft.modelId : undefined; - const modelUnchanged = prevProvider === newProvider && prevModelId === newModelId; - if (typeof prev.summarizedFor === "string" && prev.summarizedFor === trimmedPlan && modelUnchanged) { - preservedSummarizedFor = prev.summarizedFor; - } - } catch { /* ignore */ } - } - const inputPayload = JSON.stringify({ - initialPlan: draft.initialPlan.trim(), - ...(draft.modelProvider && draft.modelId ? { modelProvider: draft.modelProvider, modelId: draft.modelId } : {}), - ...(preservedSummarizedFor ? { summarizedFor: preservedSummarizedFor } : {}), - ...((draft.thinkingLevel ?? preservedThinkingLevel) ? { thinkingLevel: draft.thinkingLevel ?? preservedThinkingLevel } : {}), - }); - const changed = await updateDraftAsync(this.dbAsync, id, inputPayload); - if (changed) { - const row = await this.get(id); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return changed; - } - const now = new Date().toISOString(); - const trimmedPlan = draft.initialPlan.trim(); - const hasModelOverride = Boolean(draft.modelProvider && draft.modelId); - - // Preserve the prior `summarizedFor` field so summarize results aren't - // wiped on every draft sync, but only when both the plan text AND the - // model identity are unchanged. A model switch invalidates the prior - // summary even with identical text — otherwise startExistingSession - // would skip re-summarize and run the session with a title produced - // under a model the user just abandoned. const existing = await this.get(id); let preservedSummarizedFor: string | undefined; let preservedThinkingLevel: ThinkingLevel | undefined; @@ -478,51 +250,33 @@ export class AiSessionStore extends EventEmitter { modelId?: unknown; thinkingLevel?: unknown; }; + if (THINKING_LEVELS.includes(prev.thinkingLevel as ThinkingLevel)) { + preservedThinkingLevel = prev.thinkingLevel as ThinkingLevel; + } + const trimmedPlan = draft.initialPlan.trim(); + const hasModelOverride = Boolean(draft.modelProvider && draft.modelId); const prevProvider = typeof prev.modelProvider === "string" ? prev.modelProvider : undefined; const prevModelId = typeof prev.modelId === "string" ? prev.modelId : undefined; const newProvider = hasModelOverride ? draft.modelProvider : undefined; const newModelId = hasModelOverride ? draft.modelId : undefined; const modelUnchanged = prevProvider === newProvider && prevModelId === newModelId; - if (THINKING_LEVELS.includes(prev.thinkingLevel as ThinkingLevel)) { - preservedThinkingLevel = prev.thinkingLevel as ThinkingLevel; - } - if ( - typeof prev.summarizedFor === "string" - && prev.summarizedFor === trimmedPlan - && modelUnchanged - ) { + if (typeof prev.summarizedFor === "string" && prev.summarizedFor === trimmedPlan && modelUnchanged) { preservedSummarizedFor = prev.summarizedFor; } - } catch { - // Ignore malformed prior payloads — treat as no summary on file. - } + } catch { /* ignore */ } } - const inputPayload = JSON.stringify({ - initialPlan: trimmedPlan, - ...(hasModelOverride ? { modelProvider: draft.modelProvider, modelId: draft.modelId } : {}), - ...((draft.thinkingLevel ?? preservedThinkingLevel) ? { thinkingLevel: draft.thinkingLevel ?? preservedThinkingLevel } : {}), + initialPlan: draft.initialPlan.trim(), + ...(draft.modelProvider && draft.modelId ? { modelProvider: draft.modelProvider, modelId: draft.modelId } : {}), ...(preservedSummarizedFor ? { summarizedFor: preservedSummarizedFor } : {}), + ...((draft.thinkingLevel ?? preservedThinkingLevel) ? { thinkingLevel: draft.thinkingLevel ?? preservedThinkingLevel } : {}), }); - const result = this.db - .prepare( - `UPDATE ai_sessions - SET inputPayload = ?, updatedAt = ? - WHERE id = ? AND type = 'planning'`, - ) - .run(inputPayload, now, id) as { changes?: number }; - - const changed = Number(result.changes ?? 0) > 0; - if (!changed) { - return false; + const changed = await updateDraftAsync(this.dbAsync, id, inputPayload); + if (changed) { + const row = await this.get(id); + if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); } - - const row = await this.get(id); - if (row) { - this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - - return true; + return changed; } /** @@ -531,15 +285,7 @@ export class AiSessionStore extends EventEmitter { * `ai_session:updated` to avoid high-frequency SSE broadcasts. */ async ping(id: string): Promise { - if (this.backendMode) { - return pingAiSession(this.dbAsync, id); - } - const now = new Date().toISOString(); - const result = this.db - .prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?") - .run(now, id) as { changes?: number }; - - return Number(result.changes ?? 0) > 0; + return pingAiSession(this.dbAsync, id); } /** @@ -547,37 +293,16 @@ export class AiSessionStore extends EventEmitter { * Optionally filtered by projectId. */ async listActive(projectId?: string): Promise { - if (this.backendMode) { - const rows = await listActiveAiSessions(this.dbAsync, projectId) as Array>; - return rows.map((row) => ({ - id: row.id as string, - type: row.type as AiSessionType, - status: row.status as AiSessionStatus, - title: row.title as string, - projectId: (row.projectId as string | null) ?? null, - updatedAt: row.updatedAt as string, - archived: Number(row.archived ?? 0) === 1, - })); - } - if (projectId) { - return this.db - .prepare( - `SELECT id, type, status, title, projectId, updatedAt, archived FROM ai_sessions - WHERE status IN ('generating', 'awaiting_input', 'error') - AND COALESCE(archived, 0) = 0 - AND projectId = ? - ORDER BY updatedAt DESC`, - ) - .all(projectId) as unknown as AiSessionSummary[]; - } - return this.db - .prepare( - `SELECT id, type, status, title, projectId, updatedAt, archived FROM ai_sessions - WHERE status IN ('generating', 'awaiting_input', 'error') - AND COALESCE(archived, 0) = 0 - ORDER BY updatedAt DESC`, - ) - .all() as unknown as AiSessionSummary[]; + const rows = await listActiveAiSessions(this.dbAsync, projectId) as Array>; + return rows.map((row) => ({ + id: row.id as string, + type: row.type as AiSessionType, + status: row.status as AiSessionStatus, + title: row.title as string, + projectId: (row.projectId as string | null) ?? null, + updatedAt: row.updatedAt as string, + archived: Number(row.archived ?? 0) === 1, + })); } /** @@ -590,35 +315,8 @@ export class AiSessionStore extends EventEmitter { * the configured TTL, so this list does not grow unbounded. */ async listAll(projectId?: string, options?: { includeArchived?: boolean }): Promise { - if (this.backendMode) { - const rows = await listAllAiSessions(this.dbAsync, projectId, options) as Array>; - return rows.map((row) => toSidebarSummaryAsync(row)); - } - // Pull `inputPayload` alongside the summary columns so we can derive the - // sidebar preview for draft rows. Non-draft rows ignore the payload — - // toSidebarSummary only inspects it when status === "draft". - const archivedClause = options?.includeArchived ? "" : " WHERE COALESCE(archived, 0) = 0"; - if (projectId) { - const where = options?.includeArchived - ? "WHERE projectId = ?" - : "WHERE projectId = ? AND COALESCE(archived, 0) = 0"; - const rows = this.db - .prepare( - `SELECT id, type, status, title, inputPayload, projectId, updatedAt, archived FROM ai_sessions - ${where} - ORDER BY updatedAt DESC`, - ) - .all(projectId) as Array & Pick>; - return rows.map(toSidebarSummary); - } - const rows = this.db - .prepare( - `SELECT id, type, status, title, inputPayload, projectId, updatedAt, archived FROM ai_sessions - ${archivedClause} - ORDER BY updatedAt DESC`, - ) - .all() as Array & Pick>; - return rows.map(toSidebarSummary); + const rows = await listAllAiSessions(this.dbAsync, projectId, options) as Array>; + return rows.map((row) => toSidebarSummaryAsync(row)); } /** @@ -628,24 +326,7 @@ export class AiSessionStore extends EventEmitter { * the row was updated. Emits `ai_session:updated` so other tabs sync. */ async archive(id: string): Promise { - if (this.backendMode) { - const changed = await archiveAiSession(this.dbAsync, id); - if (changed) { - const row = await this.get(id); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return changed; - } - const now = new Date().toISOString(); - const result = this.db - .prepare( - `UPDATE ai_sessions - SET archived = 1, updatedAt = ? - WHERE id = ? AND status IN ('complete', 'error')`, - ) - .run(now, id) as { changes?: number }; - - const changed = Number(result.changes ?? 0) > 0; + const changed = await archiveAiSession(this.dbAsync, id); if (changed) { const row = await this.get(id); if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); @@ -655,24 +336,7 @@ export class AiSessionStore extends EventEmitter { /** Restore an archived session so it reappears in the sidebar. */ async unarchive(id: string): Promise { - if (this.backendMode) { - const changed = await unarchiveAiSession(this.dbAsync, id); - if (changed) { - const row = await this.get(id); - if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); - } - return changed; - } - const now = new Date().toISOString(); - const result = this.db - .prepare( - `UPDATE ai_sessions - SET archived = 0, updatedAt = ? - WHERE id = ?`, - ) - .run(now, id) as { changes?: number }; - - const changed = Number(result.changes ?? 0) > 0; + const changed = await unarchiveAiSession(this.dbAsync, id); if (changed) { const row = await this.get(id); if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt)); @@ -685,26 +349,7 @@ export class AiSessionStore extends EventEmitter { * Returns full rows for sessions still in progress. */ async listRecoverable(projectId?: string): Promise { - if (this.backendMode) { - return listRecoverableAiSessions(this.dbAsync, projectId) as Promise; - } - if (projectId) { - return this.db - .prepare( - `SELECT * FROM ai_sessions - WHERE status IN ('generating', 'awaiting_input') AND projectId = ? - ORDER BY updatedAt DESC`, - ) - .all(projectId) as unknown as AiSessionRow[]; - } - - return this.db - .prepare( - `SELECT * FROM ai_sessions - WHERE status IN ('generating', 'awaiting_input') - ORDER BY updatedAt DESC`, - ) - .all() as unknown as AiSessionRow[]; + return listRecoverableAiSessions(this.dbAsync, projectId) as Promise; } /* @@ -713,6 +358,10 @@ export class AiSessionStore extends EventEmitter { here with the rest of the per-tab session lock. AI interview sessions are multi-tab: this persisted row is the shared source of truth and every tab may read and interact. See the dead `lockedByTab`/`lockedAt` columns in core's project schema for why they still exist in the DB. + + FNXC:PostgresConflictResolution 2026-07-14-19:47: + Preserve main's lock-free multi-tab contract while keeping AI-session persistence PostgreSQL-only. + Resolving the storage cutover must not restore the deleted lock API or SQLite summary fields. */ /** @@ -720,40 +369,20 @@ export class AiSessionStore extends EventEmitter { */ async delete(id: string): Promise { this.clearThinkingTimer(id); - if (this.backendMode) { - await deleteAiSession(this.dbAsync, id); - this.emit("ai_session:deleted", id); - return; - } - this.db.prepare("DELETE FROM ai_sessions WHERE id = ?").run(id); this.deletedIds.set(id, Date.now()); + await deleteAiSession(this.dbAsync, id); this.emit("ai_session:deleted", id); + return; } async deleteByIdAndType(id: string, type: AiSessionType): Promise { - if (this.backendMode) { - this.clearThinkingTimer(id); - const removed = await deleteAiSessionByIdAndType(this.dbAsync, id, type); - if (removed) this.emit("ai_session:deleted", id); - return removed; - } - const existing = this.db - .prepare("SELECT id FROM ai_sessions WHERE id = ? AND type = ?") - .get(id, type) as { id: string } | undefined; - - if (!existing) { - return false; - } - this.clearThinkingTimer(id); - const result = this.db - .prepare("DELETE FROM ai_sessions WHERE id = ? AND type = ?") - .run(id, type) as { changes?: number }; - - const removed = Number(result.changes ?? 0) > 0; + this.deletedIds.set(id, Date.now()); + const removed = await deleteAiSessionByIdAndType(this.dbAsync, id, type); if (removed) { - this.deletedIds.set(id, Date.now()); this.emit("ai_session:deleted", id); + } else { + this.deletedIds.delete(id); } return removed; } @@ -764,37 +393,7 @@ export class AiSessionStore extends EventEmitter { * - `generating` sessions without -> `error` */ async recoverStaleSessions(): Promise { - if (this.backendMode) { - return recoverStaleAiSessions(this.dbAsync); - } - const now = new Date().toISOString(); - let recovered = 0; - - // Sessions that were generating and had a pending question — recoverable - const withQuestion = this.db - .prepare( - `UPDATE ai_sessions SET status = 'awaiting_input', updatedAt = ? - WHERE status = 'generating' AND currentQuestion IS NOT NULL`, - ) - .run(now) as { changes?: number }; - recovered += Number(withQuestion.changes ?? 0); - - // Sessions that were generating with no question — unrecoverable - const withoutQuestion = this.db - .prepare( - `UPDATE ai_sessions SET status = 'error', error = 'Session interrupted — please restart', updatedAt = ? - WHERE status = 'generating' AND currentQuestion IS NULL`, - ) - .run(now) as { changes?: number }; - recovered += Number(withoutQuestion.changes ?? 0); - - if (recovered > 0) { - diagnostics.info("Recovered stale sessions after restart", { - recovered, - operation: "recover-stale-sessions", - }); - } - return recovered; + return recoverStaleAiSessions(this.dbAsync); } /** @@ -802,35 +401,9 @@ export class AiSessionStore extends EventEmitter { * Returns the number of deleted sessions. */ async cleanupOld(maxAgeMs: number): Promise { - if (this.backendMode) { - const deletedIds = await cleanupOldAiSessions(this.dbAsync, maxAgeMs); - this.emitDeletedSessions(deletedIds.map((id) => ({ id }))); - return deletedIds.length; - } - const cutoff = new Date(Date.now() - maxAgeMs).toISOString(); - - const stale = this.db - .prepare( - `SELECT id FROM ai_sessions - WHERE updatedAt < ? - AND status IN ('complete', 'error')`, - ) - .all(cutoff) as Array<{ id: string }>; - - if (stale.length === 0) { - return 0; - } - - this.db - .prepare( - `DELETE FROM ai_sessions - WHERE updatedAt < ? - AND status IN ('complete', 'error')`, - ) - .run(cutoff); - - this.emitDeletedSessions(stale); - return stale.length; + const deletedIds = await cleanupOldAiSessions(this.dbAsync, maxAgeMs); + this.emitDeletedSessions(deletedIds.map((id) => ({ id }))); + return deletedIds.length; } /** @@ -842,63 +415,22 @@ export class AiSessionStore extends EventEmitter { async cleanupStaleSessions(maxAgeMs = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS): Promise { // FN-7949: piggyback tombstone-map pruning on the existing cleanup cadence. this.pruneExpiredTombstones(); - - if (this.backendMode) { - const result = await cleanupStaleAiSessions(this.dbAsync, maxAgeMs); - this.emitDeletedSessions([ - ...result.terminalDeletedIds.map((id) => ({ id })), - ...result.orphanedDeletedIds.map((id) => ({ id })), - ]); - diagnostics.info("Cleanup removed stale sessions", { - terminalDeleted: result.terminalDeletedIds.length, - orphanedDeleted: result.orphanedDeletedIds.length, - totalDeleted: result.terminalDeletedIds.length + result.orphanedDeletedIds.length, - maxAgeMs, - operation: "cleanup-stale-sessions", - }); - return { - terminalDeleted: result.terminalDeletedIds.length, - orphanedDeleted: result.orphanedDeletedIds.length, - totalDeleted: result.terminalDeletedIds.length + result.orphanedDeletedIds.length, - }; - } - const terminalDeleted = await this.cleanupOld(maxAgeMs); - const cutoff = new Date(Date.now() - maxAgeMs).toISOString(); - - const orphaned = this.db - .prepare( - `SELECT id FROM ai_sessions - WHERE updatedAt < ? - AND status IN ('generating', 'awaiting_input')`, - ) - .all(cutoff) as Array<{ id: string }>; - - let orphanedDeleted = 0; - if (orphaned.length > 0) { - const result = this.db - .prepare( - `DELETE FROM ai_sessions - WHERE updatedAt < ? - AND status IN ('generating', 'awaiting_input')`, - ) - .run(cutoff) as { changes?: number }; - orphanedDeleted = Number(result.changes ?? 0); - this.emitDeletedSessions(orphaned); - } - - const totalDeleted = terminalDeleted + orphanedDeleted; + const result = await cleanupStaleAiSessions(this.dbAsync, maxAgeMs); + this.emitDeletedSessions([ + ...result.terminalDeletedIds.map((id) => ({ id })), + ...result.orphanedDeletedIds.map((id) => ({ id })), + ]); diagnostics.info("Cleanup removed stale sessions", { - terminalDeleted, - orphanedDeleted, - totalDeleted, + terminalDeleted: result.terminalDeletedIds.length, + orphanedDeleted: result.orphanedDeletedIds.length, + totalDeleted: result.terminalDeletedIds.length + result.orphanedDeletedIds.length, maxAgeMs, operation: "cleanup-stale-sessions", }); - return { - terminalDeleted, - orphanedDeleted, - totalDeleted, + terminalDeleted: result.terminalDeletedIds.length, + orphanedDeleted: result.orphanedDeletedIds.length, + totalDeleted: result.terminalDeletedIds.length + result.orphanedDeletedIds.length, }; } @@ -909,14 +441,12 @@ export class AiSessionStore extends EventEmitter { this.stopScheduledCleanup(); const runCleanup = () => { - try { - this.cleanupStaleSessions(ttlMs); - } catch (error) { + void this.cleanupStaleSessions(ttlMs).catch((error) => { diagnostics.errorFromException("Scheduled cleanup failed", error, { ttlMs, operation: "scheduled-cleanup", }); - } + }); }; this.cleanupTimer = setInterval(runCleanup, cleanupIntervalMs); @@ -978,14 +508,8 @@ export class AiSessionStore extends EventEmitter { } private async writeThinking(sessionId: string, thinkingOutput: string): Promise { - if (this.backendMode) { - await updateThinkingAsync(this.dbAsync, sessionId, thinkingOutput); - return; - } - const now = new Date().toISOString(); - this.db - .prepare("UPDATE ai_sessions SET thinkingOutput = ?, updatedAt = ? WHERE id = ?") - .run(trimThinking(thinkingOutput), now, sessionId); + await updateThinkingAsync(this.dbAsync, sessionId, thinkingOutput); + return; } private clearThinkingTimer(id: string): void { @@ -999,17 +523,12 @@ export class AiSessionStore extends EventEmitter { // ── Helpers ───────────────────────────────────────────────────────────── -function trimThinking(output: string): string { - if (output.length <= MAX_THINKING_BYTES) return output; - return output.slice(output.length - MAX_THINKING_BYTES); -} - /** * FNXC:AiSessionStore 2026-06-25-00:00: * Converts a raw Drizzle row (from the async listAllAiSessions helper) into * an AiSessionSummary with the draft preview derived from inputPayload. The - * async helper returns inputPayload as a parsed jsonb value, while the sync - * path stores it as TEXT-serialized JSON. This normalizer handles both shapes. + * async helper returns inputPayload as a parsed jsonb value. This normalizer also + * accepts serialized fixture values to keep public row mapping tolerant. */ function toSidebarSummaryAsync(row: Record): AiSessionSummary { const inputPayload = row.inputPayload; @@ -1054,43 +573,6 @@ function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary { }; } -/** - * Lighter-weight summary builder for `listAll` rows that don't carry every - * column of `AiSessionRow`. Keeps the same preview-derivation behavior as - * `toSummary` (drafts only) without forcing the bulk-list query to SELECT - * conversationHistory / thinkingOutput / etc. - */ -function toSidebarSummary( - row: Partial & Pick, -): AiSessionSummary { - const previewSource: AiSessionRow = { - id: row.id, - type: row.type, - status: row.status, - title: row.title, - inputPayload: row.inputPayload, - conversationHistory: "", - currentQuestion: null, - result: null, - thinkingOutput: "", - error: null, - projectId: row.projectId ?? null, - createdAt: "", - updatedAt: row.updatedAt, - archived: row.archived, - }; - return { - id: row.id, - type: row.type, - status: row.status, - title: row.title, - preview: extractDraftPreview(previewSource), - projectId: row.projectId ?? null, - updatedAt: row.updatedAt, - archived: Number(row.archived ?? 0) === 1, - }; -} - function extractDraftPreview(session: AiSessionRow): string | undefined { if (session.type !== "planning" || session.status !== "draft") return undefined; if (!session.inputPayload) return undefined; diff --git a/packages/dashboard/src/chat-project-services.ts b/packages/dashboard/src/chat-project-services.ts index 8cb2109799..9bc5fed40f 100644 --- a/packages/dashboard/src/chat-project-services.ts +++ b/packages/dashboard/src/chat-project-services.ts @@ -1,6 +1,7 @@ import { AgentStore, ChatStore, type MessageStore, type TaskStore } from "@fusion/core"; import type { ProjectEngineManager } from "@fusion/engine"; import { ChatManager } from "./chat.js"; +import { requireAsyncLayer } from "./require-async-layer.js"; const scopedChatStoreCache = new Map(); @@ -18,10 +19,9 @@ export function getOrCreateScopedChatStore(store: TaskStore, fallbackChatStore?: const cached = scopedChatStoreCache.get(key); if (cached) return cached; - // FNXC:RuntimeSatelliteAsync 2026-06-24-21:50: - // ChatStore dual-path: pass async layer in backend mode, sync DB otherwise. - const layer = store.getAsyncLayer(); - const chatStore = new ChatStore(store.getFusionDir(), layer ? null : store.getDatabase(), { asyncLayer: layer }); + /* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Project-scoped chat stores require the authoritative PostgreSQL layer; missing wiring must not create SQLite state. */ + const layer = requireAsyncLayer(store, "Scoped ChatStore"); + const chatStore = new ChatStore(layer); scopedChatStoreCache.set(key, chatStore); return chatStore; } diff --git a/packages/dashboard/src/require-async-layer.ts b/packages/dashboard/src/require-async-layer.ts new file mode 100644 index 0000000000..c2a798e0d7 --- /dev/null +++ b/packages/dashboard/src/require-async-layer.ts @@ -0,0 +1,15 @@ +import type { AsyncDataLayer, TaskStore } from "@fusion/core"; + +/** + * FNXC:PostgresSatelliteCutover 2026-07-14-17:30: + * Dashboard runtime services share their scoped TaskStore's PostgreSQL layer. + * SQLite fallback construction is forbidden after cutover, so incomplete + * project-store wiring fails at the composition boundary with useful context. + */ +export function requireAsyncLayer(store: Pick, consumer: string): AsyncDataLayer { + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error(`${consumer} requires the project PostgreSQL AsyncDataLayer`); + } + return layer; +} diff --git a/packages/dashboard/src/routes/register-settings-sync-routes.ts b/packages/dashboard/src/routes/register-settings-sync-routes.ts index 790ea14fe9..54aefed3c6 100644 --- a/packages/dashboard/src/routes/register-settings-sync-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-routes.ts @@ -213,7 +213,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { // Get local global settings const globalSettingsStore = store.getGlobalSettingsStore(); const globalSettings = await globalSettingsStore.getSettings(); - const workflowSettings = store.listWorkflowSettingValuesForProject(); + const workflowSettings = await store.listWorkflowSettingValuesForProject(); // Build sync payload const payloadWithoutChecksum = { @@ -316,7 +316,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { // Get local settings for diff comparison const localProjectSettings = await store.getSettingsByScope(); const localGlobalSettings = await store.getGlobalSettingsStore().getSettings(); - const localWorkflowSettings = store.listWorkflowSettingValuesForProject(); + const localWorkflowSettings = await store.listWorkflowSettingValuesForProject(); // Compute diff: field names that differ between local and remote const { global: diffGlobal, project: diffProject, workflowSettings: diffWorkflowSettings } = computeSettingsDiff( @@ -458,7 +458,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { rs, localGlobalSettings as Record, localProjectSettings.project as Record, - store.listWorkflowSettingValuesForProject(), + await store.listWorkflowSettingValuesForProject(), ); diffGlobal = diff.global; diffProject = diff.project; diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 25edeb01a5..dd1be614fc 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -85,6 +85,7 @@ import { } from "./reliability-metrics.js"; import { loadViewChunkManifest, type ViewChunkManifestEntry } from "./view-chunk-manifest.js"; import { maybeStartOtelExporter, type OtelExporterHandle } from "./otel-exporter.js"; +import { requireAsyncLayer } from "./require-async-layer.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -1097,14 +1098,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT } // Create ChatStore for chat session management (available for SSE event forwarding) - // FNXC:RuntimeSatelliteAsync 2026-06-24-21:45: - // ChatStore dual-path: uses async layer in backend mode, sync DB otherwise. - const chatLayer = store.getAsyncLayer(); - const chatStore = options?.chatStore ?? new ChatStore( - store.getFusionDir(), - chatLayer ? null : store.getDatabase(), - { asyncLayer: chatLayer }, - ); + // FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Dashboard chat persistence is PostgreSQL-only and shares the scoped project layer. + const chatLayer = requireAsyncLayer(store, "Dashboard ChatStore"); + const chatStore = options?.chatStore ?? new ChatStore(chatLayer); store.on("task:moved", (data: { task: Task; from: string; to: string }) => { if (data.to !== "archived") return; /* @@ -1161,7 +1157,11 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT if (!projectId) { // Create AgentStore for default project SSE const { AgentStore: AgentStoreClass } = await import("@fusion/core"); - const defaultAgentStore = new AgentStoreClass({ rootDir: store.getFusionDir() }); + /* FNXC:PostgresSseAgentStore 2026-07-14-19:35: SSE fallback stores must subscribe to the authoritative project PostgreSQL layer so agent events never read a SQLite shadow. */ + const defaultAgentStore = new AgentStoreClass({ + rootDir: store.getFusionDir(), + asyncLayer: requireAsyncLayer(store, "Default SSE AgentStore"), + }); await defaultAgentStore.init(); const defaultMessageStore = options?.engine?.getMessageStore(); createSSE( @@ -1202,7 +1202,10 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT // Fallback: create AgentStore if engine doesn't have one if (!agentStore) { const { AgentStore: AgentStoreClass } = await import("@fusion/core"); - agentStore = new AgentStoreClass({ rootDir: scopedStore.getFusionDir() }); + agentStore = new AgentStoreClass({ + rootDir: scopedStore.getFusionDir(), + asyncLayer: requireAsyncLayer(scopedStore, "Project SSE AgentStore"), + }); await agentStore.init(); } if (!automationStore) { @@ -1441,13 +1444,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT } // Create AiSessionStore for background task persistence - // FNXC:RuntimeSatelliteCompletion 2026-06-25-00:05: - // AiSessionStore dual-path: uses async layer in backend mode, sync DB otherwise. - const aiSessionLayer = store.getAsyncLayer(); - const aiSessionStore: AiSessionStore | undefined = options?.aiSessionStore ?? new AiSessionStore( - aiSessionLayer ? null as unknown as import("@fusion/core").Database : store.getDatabase(), - { asyncLayer: aiSessionLayer }, - ); + // FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Background AI sessions must use the authoritative project PostgreSQL layer. + const aiSessionLayer = requireAsyncLayer(store, "Dashboard AiSessionStore"); + const aiSessionStore: AiSessionStore | undefined = options?.aiSessionStore ?? new AiSessionStore(aiSessionLayer); if (aiSessionStore) { // FNXC:RuntimeSatelliteCompletion 2026-06-25-00:20: // recoverStaleSessions + rehydrateFromStore are now async. Fire-and-forget @@ -1480,7 +1479,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT const totalRehydrated = 0; if (totalRehydrated > 0) { runtimeLogger.info("AI session rehydrate summary", { - message: "Rehydrated AI sessions from SQLite", + message: "Rehydrated AI sessions from PostgreSQL", planningRehydratedCount, subtaskRehydratedCount, missionRehydratedCount, @@ -1490,13 +1489,11 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT } // Create AgentStore for chat prompt enrichment (initialized lazily by ChatManager) - // FNXC:SqliteFinalRemoval 2026-06-26-11:00: - // In backend mode, pass the AsyncDataLayer so AgentStore delegates to the - // async helpers; otherwise use the legacy SQLite path. - const chatAgentLayer = store.getAsyncLayer(); + // FNXC:PostgresSseAgentStore 2026-07-14-19:35: Chat enrichment shares the mandatory default-project PostgreSQL layer. + const chatAgentLayer = requireAsyncLayer(store, "Chat AgentStore"); const chatAgentStore = new AgentStore({ rootDir: store.getFusionDir(), - ...(chatAgentLayer ? { asyncLayer: chatAgentLayer } : {}), + asyncLayer: chatAgentLayer, }); // Create ChatManager for AI chat message handling. @@ -1841,7 +1838,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT taskId: event.taskId ?? undefined, metadata: event.metadata ?? undefined, }))) - : Promise.resolve(scopedStore.getRunAuditEvents(auditFilter)); + : scopedStore.getRunAuditEventsAsync(auditFilter); const [runAuditEvents, enteredByDay, bouncedByDay, durationEvents, mergedTaskIds] = await Promise.all([ runAuditEventsPromise, scopedStore.getTaskMovedCountsByDay({ since: startIso, until: endIso, toColumn: "in-review" }), @@ -2543,7 +2540,7 @@ export function setupTerminalWebSocket( }, 60_000); // Stop eviction timer when the server shuts down - server.once("close", () => { + server.once("close", async () => { clearInterval(staleEvictionInterval); }); @@ -2790,18 +2787,49 @@ export function setupBadgeWebSocket( wsManager.addClient(ws, randomUUID(), projectId); }); - server.once("close", () => { + server.once("close", async () => { // Clean up all scoped listeners for (const cleanup of scopedCleanups.values()) { cleanup(); } scopedCleanups.clear(); + /* + FNXC:PostgresResourceLifecycle 2026-07-14-19:05: + Dashboard shutdown must publish this node as offline before project engines close their PostgreSQL pools, then drain every project-scoped store before CentralCore releases its owned embedded backend. This prevents terminal mesh writes or late resolver creations from racing a stopped postmaster. + */ + try { + options?.centralCore?.stopDiscovery(); + await options?.centralCore?.markLocalNodeOffline(); + } catch (error) { + options?.runtimeLogger?.warn("Failed to mark the dashboard node offline during shutdown", { + ...normalizeErrorForLog(error), + }); + } + + try { + if (options?.engineManager) { + await options.engineManager.stopAll(); + } else { + await options?.engine?.stop(); + } + } catch (error) { + options?.runtimeLogger?.warn("Failed to stop dashboard project engines", { + ...normalizeErrorForLog(error), + }); + } + for (const scopedStore of scopedStores.values()) { // Don't close the default store - it's managed externally if (scopedStore !== store) { scopedStore.stopWatching?.(); - scopedStore.close?.(); + try { + await Promise.resolve(scopedStore.close?.()); + } catch (error) { + options?.runtimeLogger?.warn("Failed to close a dashboard-scoped project store", { + ...normalizeErrorForLog(error), + }); + } } } scopedStores.clear(); @@ -2814,7 +2842,14 @@ export function setupBadgeWebSocket( void badgePubSub.dispose(); wss.close(); // Clean up cached project-scoped stores (stop watchers, close DB connections) - evictAllProjectStores(); + await evictAllProjectStores(); + try { + await options?.centralCore?.close(); + } catch (error) { + options?.runtimeLogger?.warn("Failed to close CentralCore during dashboard shutdown", { + ...normalizeErrorForLog(error), + }); + } setRunningAgentCountSource(undefined); dashboardApp.terminalWsServer = null; dashboardApp.badgeWsServer = null; diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index f21b7fc3ba..98fa107b7b 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -1,6 +1,7 @@ import { once } from "node:events"; import { appendFileSync } from "node:fs"; import type { Server } from "node:http"; +import type { AsyncDataLayer, LoadedPluginSchemaContract } from "@fusion/core"; import type { AddressInfo } from "node:net"; import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js"; @@ -46,14 +47,13 @@ export interface DesktopRuntimeStatus { * members this wiring needs beyond the pre-existing init/watch/close surface. */ type PluginStoreLike = { init(): Promise }; -type PluginDatabaseLike = { runPluginSchemaInits(hooks: Array<{ pluginId: string; hook: unknown }>): Promise }; - type TaskStoreLike = { init(): Promise; watch(): Promise; close(): void; getPluginStore(): PluginStoreLike; - getDatabase(): PluginDatabaseLike; + runPluginSchemaInits(hooks: LoadedPluginSchemaContract[]): Promise; + getAsyncLayer(): AsyncDataLayer; }; type RuntimeCleanup = () => Promise | void; @@ -99,21 +99,17 @@ async function createStoreDefault(rootDir: string): Promise { // FNXC:BackendFlip 2026-06-26-14:40: // Consult the startup factory to boot a PostgreSQL-backed TaskStore. Post // default-flip: the factory boots embedded PG by default when DATABASE_URL - // is unset, external PG when DATABASE_URL is set, and returns null only - // when the operator opted out via FUSION_NO_EMBEDDED_PG=1 (legacy SQLite - // path). The backend shutdown handle is stashed on the returned object so + // is unset and external PG when DATABASE_URL is set. The backend shutdown handle is stashed on the returned object so // the runtime manager's stop path can release the pool / stop an embedded // cluster. - const { TaskStore, createTaskStoreForBackend } = await import("@fusion/core"); + const { createTaskStoreForBackend } = await import("@fusion/core"); const backendBoot = await createTaskStoreForBackend({ rootDir }); - if (backendBoot) { - const store = backendBoot.taskStore as unknown as TaskStoreLike; - // Attach the backend shutdown so LocalRuntimeManager can invoke it on stop. - (store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown = - backendBoot.shutdown; - return store; - } - return new TaskStore(rootDir) as TaskStoreLike; + /* FNXC:PostgresDesktopRuntime 2026-07-14-18:34: Desktop startup must fail visibly if PostgreSQL cannot boot; the removed opt-out must never construct an unbacked SQLite TaskStore. */ + const store = backendBoot.taskStore as unknown as TaskStoreLike; + // Attach the backend shutdown so LocalRuntimeManager can invoke it on stop. + (store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown = + backendBoot.shutdown; + return store; } /* @@ -174,7 +170,8 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin * FNXC:DesktopRuntime 2026-06-20-23:39: * Embedded desktop local mode should be an executable Fusion node, not a dashboard-only shell. Start all registered project engines and pass the manager to the API server so project-scoped routes can start newly accessed engines. */ - const centralCore = new CentralCore(); + /* FNXC:PostgresDesktopLifecycle 2026-07-14-19:10: Desktop engines and the dashboard share the TaskStore's AsyncDataLayer; constructing a layerless CentralCore would boot a second pool and repeat schema initialization. */ + const centralCore = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() }); const engineManager = new ProjectEngineManager(centralCore); const providerSeeding: { dispose?: () => void } = {}; const cleanup = async () => { @@ -282,7 +279,8 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin strace(`createDashboardServer: plugins loaded=${loaded} errors=${errors}`); const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); if (schemaHooks.length > 0) { - await store.getDatabase().runPluginSchemaInits(schemaHooks); + /* FNXC:DesktopPluginSchema 2026-07-14-17:30: Embedded desktop must not open the removed sync database in PostgreSQL mode; TaskStore selects the registered PG schema hook. */ + await store.runPluginSchemaInits(schemaHooks); } ensureBundledPluginInstalledCallback = async (pluginId: string): Promise => { @@ -306,6 +304,7 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin } }; } catch (error) { + console.error(`[plugins] Desktop plugin initialization failed: ${error instanceof Error ? error.message : String(error)}`); strace( `createDashboardServer: plugin subsystem init FAILED (non-fatal, dashboard still boots) — ${error instanceof Error ? error.stack : String(error)}`, ); @@ -524,9 +523,11 @@ export class LocalRuntimeManager { server!.close(() => resolve()); }); } - await cleanup?.(); + await Promise.resolve(cleanup?.()).catch(() => undefined); if (store) { - store.close(); + const backendShutdown = (store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown; + if (backendShutdown) await backendShutdown().catch(() => undefined); + else store.close(); } this.runtime = null; strace(`startEmbedded: CATCH/ERROR ${error instanceof Error ? error.stack : String(error)}`); @@ -552,8 +553,12 @@ export class LocalRuntimeManager { const runtime = this.runtime; this.runtime = null; await new Promise((resolve) => runtime.server.close(() => resolve())); - await runtime.cleanup?.(); - runtime.store.close(); + let cleanupError: unknown; + try { + await runtime.cleanup?.(); + } catch (error) { + cleanupError = error; + } // FNXC:RuntimeStartupWiring 2026-06-24-10:30: // Release the backend connection pool / embedded PG cluster if the store // was booted via the startup factory. store.close() already closes the @@ -561,7 +566,10 @@ export class LocalRuntimeManager { const backendShutdown = (runtime.store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown; if (backendShutdown) { await backendShutdown().catch(() => undefined); + } else { + runtime.store.close(); } + if (cleanupError) throw cleanupError; this.status = { source: "none", state: "stopped" }; return this.status; } diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index 07df1f5ab9..df1572abd8 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -1,6 +1,7 @@ import type { AddressInfo } from "node:net"; import { once } from "node:events"; import type { Server } from "node:http"; +import type { AsyncDataLayer, LoadedPluginSchemaContract } from "@fusion/core"; import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js"; import { resolveDesktopBundlePluginDirs } from "./bundled-plugin-dirs.js"; @@ -15,14 +16,13 @@ import { resolveDesktopSystemControl } from "./local-runtime.js"; * paths consistent (see local-runtime.ts's matching comment). */ type PluginStoreLike = { init(): Promise }; -type PluginDatabaseLike = { runPluginSchemaInits(hooks: Array<{ pluginId: string; hook: unknown }>): Promise }; - type TaskStoreLike = { init(): Promise; watch(): Promise; close(): void; getPluginStore(): PluginStoreLike; - getDatabase(): PluginDatabaseLike; + runPluginSchemaInits(hooks: LoadedPluginSchemaContract[]): Promise; + getAsyncLayer(): AsyncDataLayer; }; type RuntimeCleanup = () => Promise | void; @@ -67,31 +67,27 @@ export class DesktopLocalServerManager { let cleanup: RuntimeCleanup | undefined; try { - const { TaskStore, createTaskStoreForBackend } = await import("@fusion/core"); + const { createTaskStoreForBackend } = await import("@fusion/core"); const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core"); const { createServer } = await import("@fusion/dashboard"); const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine"); // FNXC:BackendFlip 2026-06-26-14:40: // Consult the startup factory to boot a PostgreSQL-backed TaskStore. // Post default-flip: the factory boots embedded PG by default when - // DATABASE_URL is unset, external PG when DATABASE_URL is set, and - // returns null only when the operator opted out via - // FUSION_NO_EMBEDDED_PG=1 (legacy SQLite path). + // DATABASE_URL is unset and external PG when DATABASE_URL is set. const backendBoot = await createTaskStoreForBackend({ rootDir: this.rootDir }); - if (backendBoot) { - store = backendBoot.taskStore as unknown as TaskStoreLike; - (store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown = - backendBoot.shutdown; - } else { - store = new TaskStore(this.rootDir) as TaskStoreLike; - } + /* FNXC:PostgresDesktopRuntime 2026-07-14-18:34: The legacy local-server entrypoint shares the same mandatory PostgreSQL startup contract as the primary desktop runtime. */ + store = backendBoot.taskStore as unknown as TaskStoreLike; + (store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown = + backendBoot.shutdown; await store.init(); await store.watch(); /* * FNXC:DesktopRuntime 2026-06-20-23:39: * This legacy desktop local server path still needs to launch project engines so every embedded desktop server follows the same executable-by-default contract. */ - const centralCore = new CentralCore(); + /* FNXC:PostgresDesktopLifecycle 2026-07-14-19:10: The legacy desktop entrypoint must reuse TaskStore's PostgreSQL layer so CentralCore does not allocate a duplicate pool or rerun schema bootstrap. */ + const centralCore = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() }); const engineManager = new ProjectEngineManager(centralCore); const providerSeeding: { dispose?: () => void } = {}; cleanup = async () => { @@ -161,7 +157,8 @@ export class DesktopLocalServerManager { await pluginLoader.loadAllPlugins(); const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); if (schemaHooks.length > 0) { - await store.getDatabase().runPluginSchemaInits(schemaHooks); + /* FNXC:DesktopPluginSchema 2026-07-14-17:30: Legacy desktop server delegates plugin schema work to TaskStore so PostgreSQL never calls getDatabase(). */ + await store.runPluginSchemaInits(schemaHooks); } ensureBundledPluginInstalledCallback = async (pluginId: string): Promise => { @@ -171,7 +168,9 @@ export class DesktopLocalServerManager { const status = await ensureBundledPluginInstalled(boundPluginStore as never, boundPluginLoader, pluginId, resolveDesktopBundlePluginDirs); return status !== "missing-bundle"; }; - } catch { + } catch (error) { + /* FNXC:DesktopPluginSchema 2026-07-14-17:55: Desktop remains fail-soft for availability, but unsupported PostgreSQL plugin schemas must be visible to operators rather than disappearing inside the broad plugin catch. */ + console.error(`[plugins] Desktop plugin initialization failed: ${error instanceof Error ? error.message : String(error)}`); // Plugin subsystem failures must not block embedded dashboard startup (FN-7623). pluginStore = undefined; pluginLoader = undefined; @@ -212,8 +211,10 @@ export class DesktopLocalServerManager { if (server) { await new Promise((resolve) => server!.close(() => resolve())); } - await cleanup?.(); - store?.close(); + await Promise.resolve(cleanup?.()).catch(() => undefined); + const backendShutdown = (store as (TaskStoreLike & { __backendShutdown?: () => Promise }) | null)?.__backendShutdown; + if (backendShutdown) await backendShutdown().catch(() => undefined); + else store?.close(); this.state = { status: "error", error: error instanceof Error ? error.message : String(error), @@ -232,8 +233,12 @@ export class DesktopLocalServerManager { this.runtime = null; await new Promise((resolve) => runtime.server.close(() => resolve())); - await runtime.cleanup?.(); - runtime.store.close(); + let cleanupError: unknown; + try { + await runtime.cleanup?.(); + } catch (error) { + cleanupError = error; + } // FNXC:RuntimeStartupWiring 2026-06-24-10:35: // Release the backend connection pool / embedded PG cluster if the store // was booted via the startup factory. store.close() already closes the @@ -241,7 +246,10 @@ export class DesktopLocalServerManager { const backendShutdown = (runtime.store as TaskStoreLike & { __backendShutdown?: () => Promise }).__backendShutdown; if (backendShutdown) { await backendShutdown().catch(() => undefined); + } else { + runtime.store.close(); } + if (cleanupError) throw cleanupError; this.state = { status: "idle", error: null }; } } diff --git a/packages/engine/src/__tests__/plugin-runner.test.ts b/packages/engine/src/__tests__/plugin-runner.test.ts index 17a5614660..e81055781c 100644 --- a/packages/engine/src/__tests__/plugin-runner.test.ts +++ b/packages/engine/src/__tests__/plugin-runner.test.ts @@ -185,22 +185,15 @@ describe("PluginRunner", () => { expect(mockPluginLoader.loadAllPlugins).toHaveBeenCalled(); }); - it("should execute schema init hooks after plugin load", async () => { - const schemaHook = vi.fn(); + it("does not replay schema init hooks after PluginLoader initializes each plugin", async () => { mockPluginLoader.getPluginSchemaInitHooks.mockReturnValue([ - { pluginId: "plugin-a", hook: schemaHook }, + { pluginId: "plugin-a", hook: vi.fn() }, ]); await pluginRunner.init(); - expect(mockPluginLoader.getPluginSchemaInitHooks).toHaveBeenCalledTimes(1); - expect(mockTaskStore.getDatabase).toHaveBeenCalledTimes(1); - const db = mockTaskStore.getDatabase.mock.results[0]?.value as { - runPluginSchemaInits: ReturnType; - }; - expect(db.runPluginSchemaInits).toHaveBeenCalledWith([ - { pluginId: "plugin-a", hook: schemaHook }, - ]); + expect(mockPluginLoader.getPluginSchemaInitHooks).not.toHaveBeenCalled(); + expect(mockTaskStore.getDatabase).not.toHaveBeenCalled(); }); it("should skip schema init execution when no hooks are registered", async () => { diff --git a/packages/engine/src/cli-agent/runtime.ts b/packages/engine/src/cli-agent/runtime.ts index c14900d3cf..0068636374 100644 --- a/packages/engine/src/cli-agent/runtime.ts +++ b/packages/engine/src/cli-agent/runtime.ts @@ -7,8 +7,8 @@ * factory is the single place that actually instantiates the live bundle and * stitches the seams: * - * - Builds a {@link CliSessionStore} over the project's EXISTING core Database - * (never opens a second connection — the store is a thin query layer). + * - Builds a {@link CliSessionStore} over the project's existing PostgreSQL + * data layer (never opens a second connection). * - Registers all bundled adapters into a fresh {@link CliAdapterRegistry} (a * per-runtime registry, NOT the process-wide `defaultCliAdapterRegistry`, so * multi-project boots never collide on duplicate-registration). @@ -25,7 +25,7 @@ */ import { CliSessionStore } from "@fusion/core"; -import type { Database } from "@fusion/core"; +import type { AsyncDataLayer } from "@fusion/core"; import { CliAdapterRegistry } from "./adapter.js"; import { BUNDLED_CLI_ADAPTERS } from "./adapters/index.js"; import { CliSessionManager, type CliSessionManagerOptions } from "./session-manager.js"; @@ -38,8 +38,8 @@ import type { CliAgentRuntime } from "../executor.js"; export interface CreateCliAgentRuntimeOptions { /** The project's `.fusion` dir (scratch root for hook scripts). */ fusionDir: string; - /** The project's already-open core Database (reused, never re-opened). */ - db: Database; + /** The project's already-open PostgreSQL data layer (reused, never re-opened). */ + asyncLayer: AsyncDataLayer; /** Project this runtime drives (`cli_sessions.projectId`). */ projectId: string; /** @@ -82,7 +82,7 @@ export interface BootstrappedCliAgentRuntime { */ isCliSessionWaitingOnInput: (taskId: string) => boolean; /** Tear down the PTY manager (scoped SIGKILL of this runtime's PTYs only). */ - dispose: () => void; + dispose: () => Promise; } /** @@ -90,13 +90,15 @@ export interface BootstrappedCliAgentRuntime { * beyond the store's reads against the supplied Database; spawning a PTY or * running recovery is the caller's job (`resumeCoordinator.recoverOnStart()`). */ -export function createCliAgentRuntime( +export async function createCliAgentRuntime( options: CreateCliAgentRuntimeOptions, -): BootstrappedCliAgentRuntime { - const { fusionDir, db, projectId, hookEndpointUrl } = options; +): Promise { + const { asyncLayer, projectId, hookEndpointUrl } = options; - // 1. Store over the project's existing Database (thin query layer; no new conn). - const store = new CliSessionStore(fusionDir, db); + // FNXC:CliAgentPostgres 2026-07-14-12:00: + // Hydrate the project-scoped cache before state machines or recovery inspect + // it; mutations remain ordered through the shared PostgreSQL data layer. + const store = await CliSessionStore.create(asyncLayer, projectId); // 2. A per-runtime registry with every bundled adapter (not the process-wide // singleton — avoids duplicate-registration across multi-project boots). @@ -163,8 +165,9 @@ export function createCliAgentRuntime( return false; } }, - dispose: () => { + dispose: async () => { manager.dispose(); + await store.flush(); }, }; } diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index d9ac1d7d37..72435b3d4a 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -11,7 +11,7 @@ const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet = new Set(THINKING_LEVELS import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path"; import { existsSync, lstatSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; -import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core"; +import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core"; import { getUnmetSchedulingDependencies } from "./scheduler.js"; import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore } from "@fusion/core"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; @@ -1693,7 +1693,7 @@ export interface TaskExecutorOptions { pluginRunner?: PluginRunner; /** MessageStore for sending messages to other agents. When provided, executor agents gain fn_send_message capability. */ messageStore?: import("@fusion/core").MessageStore; - missionStore?: MissionStore; + missionStore?: MissionStore | AsyncMissionStore; secretsStore?: Pick; onSliceComplete?: (slice: Slice) => void; onStart?: (task: Task, worktreePath: string) => void; @@ -2344,7 +2344,9 @@ export class TaskExecutor { private get approvalRequestStore(): ApprovalRequestStore { if (!this._approvalRequestStore) { const layer = this.store.getAsyncLayer(); - this._approvalRequestStore = new ApprovalRequestStore(layer ? null : this.store.getDatabase(), { asyncLayer: layer }); + if (!layer) throw new Error("Executor TaskStore is missing its PostgreSQL AsyncDataLayer"); + /* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Runtime approval persistence is PostgreSQL-only; never reopen the removed project SQLite database when backend wiring is incomplete. */ + this._approvalRequestStore = new ApprovalRequestStore(null, { asyncLayer: layer }); } return this._approvalRequestStore; } @@ -6052,9 +6054,7 @@ export class TaskExecutor { }, { columnSequence: this.inferLegacyColumnSequence(live.column) }, ); - const legacyAudit = typeof this.store.getRunAuditEvents === "function" - ? this.store.getRunAuditEvents({ taskId }) - : []; + const legacyAudit = await this.store.getRunAuditEventsAsync({ taskId }); await observeWorkflowParity({ settings, @@ -13603,7 +13603,7 @@ export class TaskExecutor { // FN-009: If worktree directory doesn't exist, skip git validation for task completion. // This is safe because: // 1. Task completion doesn't modify the worktree - // 2. Deliverables (task documents, follow-up tasks) are stored in fusion.db + // FNXC:PostgresRuntimeStorage 2026-07-14-18:47: Deliverables (task documents and follow-up tasks) are stored in the project-scoped PostgreSQL store. // 3. If code changes were made, the worktree would exist // 4. This prevents ENOENT errors when agents complete documentation/coordination tasks if (!existsSync(worktreePath)) { @@ -14583,7 +14583,7 @@ export class TaskExecutor { } if (branchDeleted) { // FN-2165 regression guard: null baseBranch on any task that stored this branch - try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ } + try { await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ } } // Clear worktree tracking @@ -17737,7 +17737,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB }); await this.store.logEntry(taskId, `Deleted branch`, branch); // FN-2165 regression guard: null baseBranch on any task that stored this branch - this.store.clearStaleExecutionStartBranchReferences([branch], taskId); + await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); executorLog.warn(`${taskId}: failed to delete conflicting branch ${branch}: ${msg}`); @@ -17825,7 +17825,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB } try { await execAsync(`git branch -D "${branch}"`, { cwd: this.rootDir }); - this.store.clearStaleExecutionStartBranchReferences([branch], taskId); + await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { // best-effort — branch may not exist, which is fine for a stale-path cleanup } @@ -17874,7 +17874,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB }); await this.store.logEntry(taskId, `Removed stale branch`, branch); // FN-2165 regression guard: null baseBranch on any task that stored this branch - try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ } + try { await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ } return true; } catch (branchDeleteError: unknown) { const branchDeleteErrorMessage = branchDeleteError instanceof Error ? branchDeleteError.message : String(branchDeleteError); @@ -17893,7 +17893,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB }); await this.store.logEntry(taskId, `Force-removed stale branch reference via update-ref`, refPath); // FN-2165 regression guard: null baseBranch on any task that stored this branch - try { this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ } + try { await this.store.clearStaleExecutionStartBranchReferences([branch], taskId); } catch { /* best-effort */ } return true; } catch (updateRefError: unknown) { const updateRefErrorMessage = updateRefError instanceof Error ? updateRefError.message : String(updateRefError); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 07cb9d21df..290cf0655c 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -10757,7 +10757,7 @@ export async function aiMergeTask( // conflict-suffixed branch), null it so the dependent task doesn't // hard-fail at worktree creation once this branch is gone. try { - const cleared = store.clearStaleExecutionStartBranchReferences([branch], taskId); + const cleared = await store.clearStaleExecutionStartBranchReferences([branch], taskId); if (cleared.length > 0) { mergerLog.log(`${taskId}: cleared stale baseBranch on ${cleared.length} dependent task(s): ${cleared.join(", ")}`); } diff --git a/packages/engine/src/mesh-lease-manager.ts b/packages/engine/src/mesh-lease-manager.ts index 0772e91cd2..c80c03f339 100644 --- a/packages/engine/src/mesh-lease-manager.ts +++ b/packages/engine/src/mesh-lease-manager.ts @@ -166,7 +166,7 @@ export class MeshLeaseManager { }); try { - const released = tryRelease(); + const released = await tryRelease(); if (released.ok) { return "released"; } @@ -191,7 +191,7 @@ export class MeshLeaseManager { } catch (_error) { await new Promise((resolve) => setTimeout(resolve, 120)); try { - const released = tryRelease(); + const released = await tryRelease(); if (released.ok) { return "released"; } @@ -235,7 +235,7 @@ export class MeshLeaseManager { return false; } - const claim = centralClaimStore.getTaskClaim(projectId, taskId); + const claim = await centralClaimStore.getTaskClaim(projectId, taskId); const localHasOwner = Boolean(task.checkedOutBy || task.checkoutNodeId); if (!claim && localHasOwner) { @@ -262,7 +262,7 @@ export class MeshLeaseManager { const renewedAtMs = Date.parse(claim.leaseRenewedAt); const staleByTime = Number.isFinite(renewedAtMs) && Date.now() - renewedAtMs > staleCutoff; if (status === "offline" || status === "error" || staleByTime) { - const released = centralClaimStore.releaseTaskClaim({ + const released = await centralClaimStore.releaseTaskClaim({ projectId, taskId, nodeId: claim.ownerNodeId, diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 793d3a0ed6..41e956c965 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -15,6 +15,7 @@ import { EventEmitter } from "node:events"; import type { TaskStore, MissionStore, + AsyncMissionStore, MissionContractAssertion, MissionFeature, MissionValidatorRun, @@ -88,7 +89,7 @@ export interface MissionExecutionLoopOptions { /** Task store for accessing task data */ taskStore: TaskStore; /** Mission store for accessing mission/feature data */ - missionStore: MissionStore; + missionStore: MissionStore | AsyncMissionStore; /** Optional MissionAutopilot for notifying on loop state changes */ missionAutopilot?: { notifyValidationComplete?: (featureId: string, status: "passed" | "failed" | "blocked" | "error") => void | Promise; @@ -114,7 +115,7 @@ export interface MissionExecutionLoopOptions { export class MissionExecutionLoop extends EventEmitter { private running = false; private taskStore: TaskStore; - private missionStore: MissionStore; + private missionStore: MissionStore | AsyncMissionStore; private rootDir: string; private maxRetryBudget: number; private missionAutopilot?: MissionExecutionLoopOptions["missionAutopilot"]; @@ -176,7 +177,7 @@ export class MissionExecutionLoop extends EventEmitter { * terminated by maintenance while their session is still in-flight. */ async reapStaleValidatorRuns(maxAgeMs: number): Promise<{ reapedCount: number }> { - const staleRuns = this.missionStore.listStaleRunningValidatorRuns(maxAgeMs); + const staleRuns = await this.missionStore.listStaleRunningValidatorRuns(maxAgeMs); let reapedCount = 0; for (const run of staleRuns) { @@ -185,15 +186,15 @@ export class MissionExecutionLoop extends EventEmitter { } try { - const reapedRun = this.missionStore.reapValidatorRun( + const reapedRun = await this.missionStore.reapValidatorRun( run.id, `Validator run reaped after exceeding stale threshold (${maxAgeMs}ms) without a live owner.`, ); reapedCount += 1; try { - const milestone = this.missionStore.getMilestone(reapedRun.milestoneId); - const missionId = milestone ? this.missionStore.getMission(milestone.missionId)?.id : undefined; + const milestone = await this.missionStore.getMilestone(reapedRun.milestoneId); + const missionId = milestone ? (await this.missionStore.getMission(milestone.missionId))?.id : undefined; const elapsedMs = Math.max(0, Date.now() - new Date(run.startedAt).getTime()); void this.taskStore.recordRunAuditEvent({ agentId: "store", @@ -238,7 +239,7 @@ export class MissionExecutionLoop extends EventEmitter { } try { - const missions = this.missionStore.listMissions(); + const missions = await this.missionStore.listMissions(); let recoveredCount = 0; for (const mission of missions) { @@ -246,7 +247,7 @@ export class MissionExecutionLoop extends EventEmitter { let hierarchy; try { - hierarchy = this.missionStore.getMissionWithHierarchy(mission.id); + hierarchy = await this.missionStore.getMissionWithHierarchy(mission.id); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); loopLog.warn(`getMissionWithHierarchy failed for mission ${mission.id}: ${errorMessage} — skipping`); @@ -260,7 +261,7 @@ export class MissionExecutionLoop extends EventEmitter { for (const slice of milestone.slices) { if (slice.status !== "active") continue; - const supersededFixes = this.missionStore.reconcileSupersededGeneratedFixFeatures(slice.id); + const supersededFixes = await this.missionStore.reconcileSupersededGeneratedFixFeatures(slice.id); const supersededFeatureIds = new Set(supersededFixes.featureIds); if (supersededFixes.supersededCount > 0) { loopLog.warn( @@ -270,7 +271,13 @@ export class MissionExecutionLoop extends EventEmitter { recoveredCount += supersededFixes.supersededCount; } - for (const feature of slice.features) { + /* + FNXC:PostgresMissionRecoveryPerformance 2026-07-14-17:55: + Superseded-fix reconciliation can change multiple feature states. Refresh the slice once and reuse that coherent snapshot throughout recovery instead of issuing getFeature for every implementing or stranded feature. + */ + const refreshedFeatures = await this.missionStore.listFeatures(slice.id); + const refreshedById = new Map(refreshedFeatures.map((feature) => [feature.id, feature])); + for (const feature of refreshedFeatures) { if (supersededFeatureIds.has(feature.id)) { continue; } @@ -316,7 +323,7 @@ export class MissionExecutionLoop extends EventEmitter { // Features that remained implementing while their linked task already finished // can be stranded after restart; recover by re-triggering task outcome. if (feature.loopState === "implementing" && feature.taskId) { - const currentFeature = this.missionStore.getFeature(feature.id) ?? feature; + const currentFeature = refreshedById.get(feature.id) ?? feature; if ( this.activeValidations.has(feature.id) || currentFeature.loopState === "passed" @@ -386,7 +393,7 @@ export class MissionExecutionLoop extends EventEmitter { && feature.lastValidatorStatus !== "passed" && !this.activeValidations.has(feature.id) ) { - const currentFeature = this.missionStore.getFeature(feature.id) ?? feature; + const currentFeature = refreshedById.get(feature.id) ?? feature; if ( currentFeature.loopState === "passed" || currentFeature.lastValidatorStatus === "passed" @@ -437,7 +444,7 @@ export class MissionExecutionLoop extends EventEmitter { try { // Find the feature linked to this task - const feature = this.missionStore.getFeatureByTaskId(taskId); + const feature = await this.missionStore.getFeatureByTaskId(taskId); if (!feature) { loopLog.log(`Task ${taskId} has no linked feature; skipping validation`); return; @@ -447,10 +454,10 @@ export class MissionExecutionLoop extends EventEmitter { // recoverActiveMissions guard. A parked/blocked/completed mission must // not keep minting validations (and Fix features) for completed tasks. // Features that don't resolve to a mission keep the current behavior. - const mission = this.resolveFeatureMission(feature); + const mission = await this.resolveFeatureMission(feature); if (mission && mission.status !== "active") { loopLog.log(`Feature ${feature.id} belongs to mission ${mission.id} with status "${mission.status}"; skipping validation`); - this.logFeatureWarningEvent(feature.id, "validation_skipped_mission_inactive", `Validation skipped: mission ${mission.id} status is "${mission.status}" (expected "active").`, { + await this.logFeatureWarningEvent(feature.id, "validation_skipped_mission_inactive", `Validation skipped: mission ${mission.id} status is "${mission.status}" (expected "active").`, { taskId, missionId: mission.id, missionStatus: mission.status, @@ -459,14 +466,14 @@ export class MissionExecutionLoop extends EventEmitter { } if (feature.loopState === "needs_fix") { - this.missionStore.transitionLoopState(feature.id, "implementing"); + await this.missionStore.transitionLoopState(feature.id, "implementing"); feature.loopState = "implementing"; } // Only validate features in "implementing" state if (feature.loopState !== "implementing") { loopLog.log(`Feature ${feature.id} loopState is "${feature.loopState}"; skipping validation`); - this.logFeatureWarningEvent(feature.id, "validation_skipped_loop_state", `Validation skipped: feature ${feature.id} is in loopState "${feature.loopState}" (expected "implementing").`, { + await this.logFeatureWarningEvent(feature.id, "validation_skipped_loop_state", `Validation skipped: feature ${feature.id} is in loopState "${feature.loopState}" (expected "implementing").`, { taskId, loopState: feature.loopState, }); @@ -475,7 +482,7 @@ export class MissionExecutionLoop extends EventEmitter { if (this.activeValidations.has(feature.id)) { loopLog.log(`Feature ${feature.id} already has an active validation; skipping duplicate trigger`); - this.logFeatureWarningEvent(feature.id, "validation_deduplicated", `Validation already running for feature ${feature.id}; duplicate trigger ignored.`, { + await this.logFeatureWarningEvent(feature.id, "validation_deduplicated", `Validation already running for feature ${feature.id}; duplicate trigger ignored.`, { taskId, }); return; @@ -500,10 +507,10 @@ export class MissionExecutionLoop extends EventEmitter { private async runFeatureValidation(feature: MissionFeature): Promise { // Lazily guarantee a linked assertion before validation so every feature // is evaluated by the validator even when legacy data is missing links. - let assertions = this.missionStore.listAssertionsForFeature(feature.id); + let assertions = await this.missionStore.listAssertionsForFeature(feature.id); if (assertions.length === 0) { loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`); - assertions = this.missionStore.ensureFeatureAssertionLinked(feature.id); + assertions = await this.missionStore.ensureFeatureAssertionLinked(feature.id); } // Mark feature as being validated @@ -513,7 +520,7 @@ export class MissionExecutionLoop extends EventEmitter { loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`); // Start the validator run (no board task per docs/missions.md) - const run = this.missionStore.startValidatorRun(feature.id, "task_completion"); + const run = await this.missionStore.startValidatorRun(feature.id, "task_completion"); loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`); // Run the validation @@ -622,7 +629,7 @@ export class MissionExecutionLoop extends EventEmitter { ): Promise { loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`); - const milestone = this.resolveFeatureMilestone(feature); + const milestone = await this.resolveFeatureMilestone(feature); // Build the validation prompt const prompt = this.buildValidationPrompt(feature, assertions, milestone); @@ -1288,8 +1295,8 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; return lines.join("\n"); } - private resolveFeatureMilestone(feature: MissionFeature): Milestone | undefined { - const slice = this.missionStore.getSlice(feature.sliceId); + private async resolveFeatureMilestone(feature: MissionFeature): Promise { + const slice = await this.missionStore.getSlice(feature.sliceId); if (!slice) { return undefined; } @@ -1297,8 +1304,8 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; return this.missionStore.getMilestone(slice.milestoneId); } - private resolveFeatureMission(feature: MissionFeature): Mission | undefined { - const milestone = this.resolveFeatureMilestone(feature); + private async resolveFeatureMission(feature: MissionFeature): Promise { + const milestone = await this.resolveFeatureMilestone(feature); if (!milestone) { return undefined; } @@ -1306,27 +1313,27 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; return this.missionStore.getMission(milestone.missionId); } - private completeValidatorRunIfStillRunning( + private async completeValidatorRunIfStillRunning( runId: string | undefined, status: "passed" | "failed" | "blocked" | "error", summaryOrReason?: string, - ): boolean { + ): Promise { if (!runId) { return false; } if (typeof this.missionStore.getValidatorRun !== "function") { - this.missionStore.completeValidatorRun(runId, status, summaryOrReason); + await this.missionStore.completeValidatorRun(runId, status, summaryOrReason); return true; } - const run = this.missionStore.getValidatorRun(runId); + const run = await this.missionStore.getValidatorRun(runId); if (!run || run.status !== "running") { loopLog.warn(`Validator run ${runId} is no longer running; skipping ${status} completion.`); return false; } - this.missionStore.completeValidatorRun(runId, status, summaryOrReason); + await this.missionStore.completeValidatorRun(runId, status, summaryOrReason); return true; } @@ -1339,11 +1346,11 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; summary: string, ): Promise { try { - this.completeValidatorRunIfStillRunning(runId, "passed", summary); + await this.completeValidatorRunIfStillRunning(runId, "passed", summary); - const feature = this.missionStore.getFeature(featureId); + const feature = await this.missionStore.getFeature(featureId); if (feature && feature.status !== "done") { - this.missionStore.updateFeatureStatus(featureId, "done"); + await this.missionStore.updateFeatureStatus(featureId, "done"); } loopLog.log(`Feature ${featureId} passed validation`); @@ -1383,15 +1390,13 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; actual: a.actual, })); - const canCompleteRun = runId - ? typeof this.missionStore.getValidatorRun !== "function" || this.missionStore.getValidatorRun(runId)?.status === "running" - : false; + const canCompleteRun = runId ? (await this.missionStore.getValidatorRun(runId))?.status === "running" : false; if (runId && failures.length > 0 && canCompleteRun) { - this.missionStore.recordValidatorFailures(runId, failures); + await this.missionStore.recordValidatorFailures(runId, failures); } - this.completeValidatorRunIfStillRunning(runId, "failed", result.summary); + await this.completeValidatorRunIfStillRunning(runId, "failed", result.summary); loopLog.log(`Feature ${featureId} failed validation with ${failures.length} failures`); @@ -1401,7 +1406,7 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; // R16 — durable observability: a verification/validation failure is a // persisted mission event, not just a log line. - this.logFeatureMissionEvent(featureId, "error", "validation_failed", `Validation failed for feature ${featureId}: ${result.summary}`, { + await this.logFeatureMissionEvent(featureId, "error", "validation_failed", `Validation failed for feature ${featureId}: ${result.summary}`, { runId: runId ?? null, failedAssertionIds: failures.map((f) => f.assertionId), reason: failureReason, @@ -1410,7 +1415,7 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; // Create fix feature try { - const fixFeature = this.missionStore.createGeneratedFixFeature( + const fixFeature = await this.missionStore.createGeneratedFixFeature( featureId, runId || "unknown", failures.map((f) => f.assertionId), @@ -1429,7 +1434,7 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; // logged. The branch-group-collision learning: silent triage stalls // are invisible mission deadlocks. The Fix Feature was created and can // be triaged manually, so we continue, but the failure is persisted. - this.logFeatureMissionEvent(featureId, "error", "fix_feature_triage_failed", `Auto-triage of fix feature ${fixFeature.id} failed: ${triageMessage}`, { + await this.logFeatureMissionEvent(featureId, "error", "fix_feature_triage_failed", `Auto-triage of fix feature ${fixFeature.id} failed: ${triageMessage}`, { runId: runId ?? null, fixFeatureId: fixFeature.id, error: triageMessage, @@ -1448,14 +1453,14 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; loopLog.warn(`Feature ${featureId} retry budget exhausted; marking as blocked`); // completeValidatorRun already handles the blocked transition when budget is exhausted terminalStatus = "blocked"; - this.logFeatureMissionEvent(featureId, "error", "retry_budget_exhausted", `Feature ${featureId} exhausted its retry budget`, { + await this.logFeatureMissionEvent(featureId, "error", "retry_budget_exhausted", `Feature ${featureId} exhausted its retry budget`, { runId: runId ?? null, }); this.emit("validation:budget_exhausted", { featureId, runId }); } else { loopLog.error(`Error creating fix feature for ${featureId}:`, message); // R16 — a swallowed Fix-Feature creation error is durably recorded. - this.logFeatureMissionEvent(featureId, "error", "fix_feature_creation_failed", `Failed to create fix feature for ${featureId}: ${message}`, { + await this.logFeatureMissionEvent(featureId, "error", "fix_feature_creation_failed", `Failed to create fix feature for ${featureId}: ${message}`, { runId: runId ?? null, error: message, }); @@ -1517,13 +1522,13 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; reason: string | undefined, ): Promise { try { - this.completeValidatorRunIfStillRunning(runId, "blocked", reason); + await this.completeValidatorRunIfStillRunning(runId, "blocked", reason); loopLog.warn(`Feature ${featureId} verification inconclusive: ${reason ?? "no reason provided"}`); // R16/R21 — durable, distinguishable infra-failure event. The `outcome` // marker separates infra-driven non-passes from real behavioral fails so // the infra-failure rate can be tracked without conflating the two. - this.logFeatureMissionEvent(featureId, "warning", "verification_inconclusive", `Verification inconclusive for feature ${featureId}: ${reason ?? "verification could not conclude"}`, { + await this.logFeatureMissionEvent(featureId, "warning", "verification_inconclusive", `Verification inconclusive for feature ${featureId}: ${reason ?? "verification could not conclude"}`, { runId: runId ?? null, reason: reason ?? null, outcome: "inconclusive", @@ -1552,9 +1557,9 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; blockedReason: string | undefined, ): Promise { try { - this.completeValidatorRunIfStillRunning(runId, "blocked", blockedReason); + await this.completeValidatorRunIfStillRunning(runId, "blocked", blockedReason); loopLog.log(`Feature ${featureId} blocked: ${blockedReason}`); - this.logFeatureErrorEvent(featureId, "validation_blocked", `Validation blocked for feature ${featureId}: ${blockedReason ?? "no reason provided"}`, { + await this.logFeatureErrorEvent(featureId, "validation_blocked", `Validation blocked for feature ${featureId}: ${blockedReason ?? "no reason provided"}`, { runId, blockedReason: blockedReason ?? null, }); @@ -1579,9 +1584,9 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; error: string, ): Promise { try { - this.completeValidatorRunIfStillRunning(runId, "error", error); + await this.completeValidatorRunIfStillRunning(runId, "error", error); loopLog.error(`Feature ${featureId} validation error: ${error}`); - this.logFeatureErrorEvent(featureId, "validation_error", `Validation error for feature ${featureId}: ${error}`, { + await this.logFeatureErrorEvent(featureId, "validation_error", `Validation error for feature ${featureId}: ${error}`, { runId, error, }); @@ -1597,40 +1602,39 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; } } - private logFeatureWarningEvent( + private async logFeatureWarningEvent( featureId: string, code: string, description: string, metadata: Record, - ): void { - this.logFeatureMissionEvent(featureId, "warning", code, description, metadata); + ): Promise { + await this.logFeatureMissionEvent(featureId, "warning", code, description, metadata); } - private logFeatureErrorEvent( + private async logFeatureErrorEvent( featureId: string, code: string, description: string, metadata: Record, - ): void { - this.logFeatureMissionEvent(featureId, "error", code, description, metadata); + ): Promise { + await this.logFeatureMissionEvent(featureId, "error", code, description, metadata); } - private logFeatureMissionEvent( + private async logFeatureMissionEvent( featureId: string, eventType: "warning" | "error", code: string, description: string, metadata: Record, - ): void { - const feature = this.missionStore.getFeature(featureId); - if (!feature) return; - const slice = this.missionStore.getSlice(feature.sliceId); - if (!slice) return; - const milestone = this.missionStore.getMilestone(slice.milestoneId); - if (!milestone) return; - + ): Promise { try { - this.missionStore.logMissionEvent?.(milestone.missionId, eventType, description, { + const feature = await this.missionStore.getFeature(featureId); + if (!feature) return; + const slice = await this.missionStore.getSlice(feature.sliceId); + if (!slice) return; + const milestone = await this.missionStore.getMilestone(slice.milestoneId); + if (!milestone) return; + await this.missionStore.logMissionEvent?.(milestone.missionId, eventType, description, { code, featureId, sliceId: slice.id, diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index 7514b0a823..31cfa6ca4f 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -239,30 +239,10 @@ export class PluginRunner { const result = await this.options.pluginLoader.loadAllPlugins(); executorLog.log(`PluginRunner loaded ${result.loaded} plugins (${result.errors} errors)`); - // Execute onSchemaInit hooks from loaded plugins. - const schemaInitHooks = this.options.pluginLoader.getPluginSchemaInitHooks(); - if (schemaInitHooks.length > 0) { - executorLog.log(`Executing onSchemaInit hooks from ${schemaInitHooks.length} plugins`); - try { - /* - * FNXC:PostgresCutover 2026-07-04: - * Skip the SQLite-specific runPluginSchemaInits path in backend mode. - * PostgreSQL uses Drizzle migrations for schema management. Matches the - * daemon.ts / dashboard.ts / serve.ts convention. Previously - * getDatabase() threw in backend mode and the catch swallowed it, so - * plugin onSchemaInit hooks silently never ran. - */ - if (this.options.taskStore.isBackendMode()) { - executorLog.log("onSchemaInit skipped — backend mode (PostgreSQL Drizzle migrations)"); - } else { - const db = this.options.taskStore.getDatabase(); - await db.runPluginSchemaInits(schemaInitHooks); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - executorLog.log(`onSchemaInit execution failed: ${message}`); - } - } + /* + FNXC:PluginPostgresSchema 2026-07-14-21:48: + PluginLoader completes each plugin's backend-specific schema initialization before loadAllPlugins counts it as loaded. PluginRunner must not replay the accumulated contracts after loading. + */ // Subscribe to store events for task lifecycle hooks this.subscribeToStoreEvents(); diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index b55fe4725e..3cd929634c 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -15,7 +15,7 @@ import type { CliSession, NotificationPayload, } from "@fusion/core"; -import { ChatStore, createCentralDatabase, isEphemeralAgent, MissionStore } from "@fusion/core"; +import { AsyncCentralClaimStore, ChatStore, isEphemeralAgent } from "@fusion/core"; import { Scheduler } from "../scheduler.js"; import type { PrMonitor, PrComment } from "../pr-monitor.js"; import type { PrInfo } from "@fusion/core"; @@ -209,7 +209,7 @@ export class InProcessRuntime private usageLimitPauser?: UsageLimitPauser; private selfHealingManager?: SelfHealingManager; private leaseManager?: MeshLeaseManager; - private leaseCentralClaimStore?: ReturnType; + private leaseCentralClaimStore?: AsyncCentralClaimStore; private agentStore?: AgentStore; private heartbeatMonitor?: HeartbeatMonitor; private triggerScheduler?: HeartbeatTriggerScheduler; @@ -293,7 +293,6 @@ export class InProcessRuntime try { // 1. Initialize TaskStore (use external if provided, otherwise create new) const { - TaskStore, PluginStore: PluginStoreClass, PluginLoader: PluginLoaderClass, MessageStore: MessageStoreClass, @@ -301,9 +300,7 @@ export class InProcessRuntime // createTaskStoreForBackend is the startup factory that boots a // PostgreSQL-backed TaskStore. Post default-flip: it boots embedded PG // by default when DATABASE_URL is unset (the zero-config production - // path), external PG when DATABASE_URL is set, and returns null only - // when the operator opted out via FUSION_NO_EMBEDDED_PG=1 (legacy - // SQLite). The engine is the primary construction site for `fn serve` + // path) and external PG when DATABASE_URL is set. The engine is the primary construction site for `fn serve` // / dashboard: every project's TaskStore flows through // InProcessRuntime.start(). When the factory returns a backend result, // the engine owns the result's shutdown() for process teardown. @@ -317,17 +314,13 @@ export class InProcessRuntime rootDir: this.config.workingDirectory, projectId: this.config.projectId, }); - if (backendBoot) { - this.taskStore = backendBoot.taskStore; - this.backendShutdown = backendBoot.shutdown; - runtimeLog.log( - `TaskStore initialized on PostgreSQL (${backendBoot.backend.mode}) for project ${this.config.projectId}`, - ); - } else { - this.taskStore = new TaskStore(this.config.workingDirectory); - await this.taskStore.init(); - runtimeLog.log(`TaskStore initialized for project ${this.config.projectId}`); - } + // FNXC:PostgresFinalCutover 2026-07-14-17:20: Engine runtimes must fail + // startup when PostgreSQL cannot boot; constructing a SQLite TaskStore is no longer valid. + this.taskStore = backendBoot.taskStore; + this.backendShutdown = backendBoot.shutdown; + runtimeLog.log( + `TaskStore initialized on PostgreSQL (${backendBoot.backend.mode}) for project ${this.config.projectId}`, + ); } // Initialize MessageStore early so TaskExecutor receives send_message capability. @@ -335,6 +328,12 @@ export class InProcessRuntime // In backend mode, pass the AsyncDataLayer so MessageStore delegates to the // async helpers; otherwise pass the sync SQLite Database (legacy path). const messageLayer = this.taskStore.getAsyncLayer(); + if (!messageLayer) { + throw new Error("PostgreSQL TaskStore did not expose its AsyncDataLayer"); + } + // FNXC:PostgresMeshClaims 2026-07-14-17:31: Cross-node checkout and + // recovery share the central.task_claims table through the project pool. + this.leaseCentralClaimStore = new AsyncCentralClaimStore(messageLayer); // FNXC:CentralCore 2026-06-26-13:30: // In backend mode, attach the TaskStore's AsyncDataLayer to the shared @@ -354,11 +353,7 @@ export class InProcessRuntime } } - if (messageLayer) { - this.messageStore = new MessageStoreClass(null, { asyncLayer: messageLayer }); - } else { - this.messageStore = new MessageStoreClass(this.taskStore.getDatabase()); - } + this.messageStore = new MessageStoreClass(null, { asyncLayer: messageLayer }); await yieldEventLoop(); @@ -367,9 +362,10 @@ export class InProcessRuntime // In backend mode, pass the AsyncDataLayer so PluginStore delegates to the // async helpers; otherwise use the legacy SQLite path. const pluginLayer = this.taskStore.getAsyncLayer(); - this.pluginStore = pluginLayer - ? new PluginStoreClass(this.config.workingDirectory, { asyncLayer: pluginLayer }) - : new PluginStoreClass(this.config.workingDirectory); + if (!pluginLayer) { + throw new Error("PostgreSQL TaskStore did not expose the plugin AsyncDataLayer"); + } + this.pluginStore = new PluginStoreClass(this.config.workingDirectory, { asyncLayer: pluginLayer }); await this.pluginStore.init(); this.pluginLoader = new PluginLoaderClass({ @@ -472,6 +468,8 @@ export class InProcessRuntime agentStoreForReflection = new AgentStoreClass({ rootDir: this.taskStore.getFusionDir(), taskStore: this.taskStore, + claimStore: this.leaseCentralClaimStore, + projectId: this.config.projectId, ...(agentLayer ? { asyncLayer: agentLayer } : {}), }); await agentStoreForReflection.init(); @@ -502,22 +500,19 @@ export class InProcessRuntime // 5. Initialize Scheduler /* - * FNXC:SqliteFinalRemoval 2026-06-24-15:55: - * In backend mode (PostgreSQL), getMissionStore() throws because the - * MissionStore has not been converted to async yet. Catch the error and - * degrade gracefully: mission autopilot and mission execution loop are - * disabled until the MissionStore is fully converted to the async path. + * FNXC:PostgresMissionRuntime 2026-07-14-17:20: + * Resolve one mission store for autopilot, scheduler, and validator-loop + * behavior. Each consumer awaits the sync-or-async union, so backend mode + * no longer disables mission execution or feature reconciliation. */ - let missionStore: import("@fusion/core").MissionStore | undefined; + let missionStore: + | import("@fusion/core").MissionStore + | import("@fusion/core").AsyncMissionStore + | undefined; // FNXC:MissionStore 2026-06-28-12:45: - // MissionAutopilot's STORE-access path was ported to drive BOTH backends — - // it types its store as `MissionStore | AsyncMissionStore` and awaits every - // call (mirrors the ResearchOrchestrator union+await port). So the autopilot - // is constructed from `autopilotMissionStore`, resolved in BOTH backends with - // NO `instanceof MissionStore` gate; the autopilot LOOP (watch/recover/ - // recompute/persist) now runs in PG mode. The sync-only `missionStore` below - // stays gated for the Scheduler + MissionExecutionLoop, whose slice EXECUTION - // and validator-loop paths are NOT yet ported to async (out of scope). + // MissionAutopilot, Scheduler, and MissionExecutionLoop all await the + // MissionStore | AsyncMissionStore contract, so one resolved instance + // drives every mission lifecycle surface in both backends. let autopilotMissionStore: | import("@fusion/core").MissionStore | import("@fusion/core").AsyncMissionStore @@ -526,9 +521,10 @@ export class InProcessRuntime const resolvedMissionStore = this.taskStore.getMissionStore(); // Union store for the autopilot — works in both SQLite and PG backends. autopilotMissionStore = resolvedMissionStore; - // Sync-only narrowing for the Scheduler + MissionExecutionLoop, which still - // call the store synchronously and are skipped in PG backend mode. - missionStore = resolvedMissionStore instanceof MissionStore ? resolvedMissionStore : undefined; + // FNXC:PostgresMissionRuntime 2026-07-14-17:20: + // Scheduler and validator execution await the MissionStore union, so + // PostgreSQL receives the same mission recovery/validation lifecycle. + missionStore = resolvedMissionStore; } catch (msErr) { runtimeLog.warn( `MissionStore unavailable (${this.taskStore.isBackendMode() ? "backend mode" : "init error"}); mission autopilot disabled:`, @@ -551,15 +547,15 @@ export class InProcessRuntime ? { notifyValidationComplete: async (featureId: string) => { // Pass the feature's linked taskId to handleTaskCompletion, not the featureId - const feature = missionStore.getFeature(featureId); + const feature = await missionStore.getFeature(featureId); if (!feature?.taskId) { return; } - const slice = missionStore.getSlice(feature.sliceId); - const milestone = slice ? missionStore.getMilestone(slice.milestoneId) : undefined; + const slice = await missionStore.getSlice(feature.sliceId); + const milestone = slice ? await missionStore.getMilestone(slice.milestoneId) : undefined; const missionId = milestone?.missionId; if (missionId) { - const mission = missionStore.getMission(missionId); + const mission = await missionStore.getMission(missionId); if (mission?.autopilotEnabled && !missionAutopilot.isWatching(missionId)) { missionAutopilot.watchMission(missionId); } @@ -574,29 +570,6 @@ export class InProcessRuntime }) : undefined; - // FN-4823/FN-4819 §2.5: central-claim-aware recovery when central DB is reachable; - // fallback to local-only recovery remains in MeshLeaseManager for single-node contexts. - // - // FNXC:CentralCore 2026-06-26-13:00: - // In backend mode (PostgreSQL), do NOT construct the legacy SQLite - // CentralDatabase for mesh lease recovery. The sync CentralClaimStore - // contract cannot be satisfied by the async PostgreSQL helpers without a - // blocking bridge, and the single-node embedded-PG default does not need - // cross-node claim coordination. MeshLeaseManager falls back to its - // local-only recovery path (the centralClaimStore=undefined guard). The - // SQLite path remains for FUSION_NO_EMBEDDED_PG (legacy) mode. - if (this.taskStore.isBackendMode()) { - this.leaseCentralClaimStore = undefined; - } else { - try { - this.leaseCentralClaimStore = createCentralDatabase(this.centralCore.getGlobalDir()); - this.leaseCentralClaimStore.init(); - } catch (error) { - runtimeLog.warn(`Failed to initialize central claim store for mesh lease recovery: ${error instanceof Error ? error.message : String(error)}`); - this.leaseCentralClaimStore = undefined; - } - } - this.leaseManager = new MeshLeaseManager({ taskStore: this.taskStore, agentStore: this.agentStore, @@ -639,16 +612,22 @@ export class InProcessRuntime await yieldEventLoop(); // 5a-cli. Initialize the CLI Agent Executor runtime (behind the - // `cliAgentExecutor` experimental flag). Reuses the project's existing core - // Database; predicates feed the self-healing + stuck-task seams below. - if (isExperimentalFeatureEnabled(settings, "cliAgentExecutor") && !this.taskStore.isBackendMode()) { - // FNXC:RuntimeSatelliteAsync 2026-06-24-14:00: - // CLI Agent Executor runtime requires the sync SQLite Database; skip in - // backend mode (the feature is experimental and not yet ported to async). + // `cliAgentExecutor` experimental flag). Reuses the project's existing + // PostgreSQL data layer; predicates feed self-healing + stuck-task seams. + if (isExperimentalFeatureEnabled(settings, "cliAgentExecutor")) { + /* + * FNXC:CliAgentPostgres 2026-07-14-12:00: + * The experimental executor is a supported PostgreSQL runtime surface; + * enabling it must not silently disable sessions after the cutover. + */ try { - this.cliAgentRuntime = createCliAgentRuntime({ + const asyncLayer = this.taskStore.getAsyncLayer(); + if (!asyncLayer) { + throw new Error("CLI Agent Executor requires the PostgreSQL data layer"); + } + this.cliAgentRuntime = await createCliAgentRuntime({ fusionDir: this.taskStore.getFusionDir(), - db: this.taskStore.getDatabase(), + asyncLayer, projectId: this.config.projectId, hookEndpointUrl: this.resolveCliAgentHookEndpointUrl(), onNotification: (info) => { @@ -834,15 +813,10 @@ export class InProcessRuntime // Already started — nothing to do } if (!this.heartbeatMonitor && this.agentStore) { - // FNXC:RuntimeSatelliteAsync 2026-06-24-21:40: - // ChatStore now supports dual-path: in backend mode it uses the - // AsyncDataLayer; in SQLite mode it uses the sync Database. const chatLayer = this.taskStore.getAsyncLayer(); - this.chatStore ??= new ChatStore( - this.taskStore.getFusionDir(), - chatLayer ? null : this.taskStore.getDatabase(), - { asyncLayer: chatLayer }, - ); + if (!chatLayer) throw new Error("Heartbeat ChatStore requires the project PostgreSQL AsyncDataLayer"); + /* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Engine chat services share the authoritative project PostgreSQL layer and never reopen SQLite. */ + this.chatStore ??= new ChatStore(chatLayer); this.heartbeatMonitor = new HeartbeatMonitor({ store: this.agentStore, agentStore: this.agentStore, // enables per-agent config resolution @@ -1054,15 +1028,10 @@ export class InProcessRuntime await yieldEventLoop(); // 7. Initialize SelfHealingManager - // FNXC:RuntimeSatelliteAsync 2026-06-24-21:42: - // ChatStore dual-path: use async layer in backend mode, sync DB otherwise. { const chatLayer2 = this.taskStore.getAsyncLayer(); - this.chatStore ??= new ChatStore( - this.taskStore.getFusionDir(), - chatLayer2 ? null : this.taskStore.getDatabase(), - { asyncLayer: chatLayer2 }, - ); + if (!chatLayer2) throw new Error("Self-healing ChatStore requires the project PostgreSQL AsyncDataLayer"); + this.chatStore ??= new ChatStore(chatLayer2); } this.selfHealingManager = new SelfHealingManager(this.taskStore, { rootDir: this.config.workingDirectory, @@ -1170,17 +1139,18 @@ export class InProcessRuntime // Mission crash recovery: restore autopilot state for missions that were active before crash /* - * FNXC:SqliteFinalRemoval 2026-06-24-16:00: - * In backend mode, getMissionStore() throws (MissionStore not yet async). - * Wrap in try/catch to degrade gracefully — mission crash recovery is - * skipped, same as mission autopilot above. + * FNXC:PostgresMissionRuntime 2026-07-14-17:20: + * Crash recovery and scheduler reconciliation use the same union store in + * both backends; initialization errors remain fail-soft for engine boot. */ - let activeMissionStore: import("@fusion/core").MissionStore | undefined; + let activeMissionStore: + | import("@fusion/core").MissionStore + | import("@fusion/core").AsyncMissionStore + | undefined; // FNXC:MissionStore 2026-06-28-12:45: autopilot crash-recovery now runs in BOTH // backends. `recoverMissions` accepts the `MissionStore | AsyncMissionStore` // union and awaits every store call, so resolve the store WITHOUT an instanceof - // gate here. The sync-only `activeMissionStore` below still gates the - // scheduler-driven `reconcileAllMissionFeatures` (not yet ported to async). + // gate here. Scheduler reconciliation now awaits that same union. let activeAutopilotMissionStore: | import("@fusion/core").MissionStore | import("@fusion/core").AsyncMissionStore @@ -1188,12 +1158,7 @@ export class InProcessRuntime try { const resolvedActive = this.taskStore.getMissionStore(); activeAutopilotMissionStore = resolvedActive; - activeMissionStore = resolvedActive instanceof MissionStore ? resolvedActive : undefined; - // FNXC:MissionStore 2026-06-27-16:30 (review): log the PG-mode degrade for the - // scheduler-driven feature reconciliation that stays sync-only. - if (!activeMissionStore) { - runtimeLog.warn("[runtime] scheduler feature reconciliation skipped: sync MissionStore not available in PG backend mode"); - } + activeMissionStore = resolvedActive; } catch (error) { activeMissionStore = undefined; activeAutopilotMissionStore = undefined; @@ -1242,6 +1207,17 @@ export class InProcessRuntime runtimeLog.log(`InProcessRuntime started for project ${this.config.projectId}`); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); + /* + FNXC:RuntimeStartupWiring 2026-07-14-18:18: + A failed partial startup must unwind every initialized subsystem and the owned PostgreSQL backend before surfacing the original startup error. The normal stop path is deliberately safe against partially initialized fields. + */ + try { + await this.stop(); + } catch (cleanupError) { + runtimeLog.warn( + `Failed to fully unwind partial runtime startup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, + ); + } this.setStatus("errored"); runtimeLog.error(`Failed to start InProcessRuntime:`, err.message); this.emit("error", err); @@ -1276,6 +1252,13 @@ export class InProcessRuntime this.setStatus("stopping"); runtimeLog.log(`Stopping InProcessRuntime for project ${this.config.projectId}`); + /* + FNXC:PostgresResourceLifecycle 2026-07-14-18:42: + Runtime shutdown owns the startup-factory backend handle. Capture and clear it before any subsystem cleanup so concurrent/retried stop calls cannot invoke it twice, then release it from finally even when settings, plugins, or worktree cleanup fails. The first subsystem error remains the observable stop failure; backend cleanup is best-effort and never masks it. + */ + const backendShutdown = this.backendShutdown; + this.backendShutdown = undefined; + let stopError: Error | undefined; try { // 1. Remove concurrency change listener (if we registered one) if (this.concurrencyChangedListener && typeof this.centralCore.off === "function") { @@ -1293,7 +1276,7 @@ export class InProcessRuntime // runtime's own PTYs only — never the dashboard / port 4040). if (this.cliAgentRuntime) { try { - this.cliAgentRuntime.dispose(); + await this.cliAgentRuntime.dispose(); runtimeLog.log("CLI Agent Executor runtime disposed"); } catch (cliErr) { runtimeLog.warn( @@ -1452,39 +1435,28 @@ export class InProcessRuntime } } - if (this.leaseCentralClaimStore) { - this.leaseCentralClaimStore.close(); - this.leaseCentralClaimStore = undefined; - } - - // FNXC:RuntimeStartupWiring 2026-06-24-10:00: - // When the runtime booted a PostgreSQL-backed TaskStore via - // createTaskStoreForBackend, release the connection pool and stop the - // embedded PostgreSQL process (if one was started) now that every - // subsystem has drained. Best-effort: a failure is logged but does not - // mask the (already-clean) stop. On the legacy SQLite path this is a - // no-op (backendShutdown is undefined and the TaskStore closes its own - // SQLite database lazily). - if (this.backendShutdown) { - try { - await this.backendShutdown(); - } catch (err) { - runtimeLog.warn( - `Backend shutdown failed: ${err instanceof Error ? err.message : err}`, - ); - } - this.backendShutdown = undefined; - } + this.leaseCentralClaimStore = undefined; this.setStatus("stopped"); runtimeLog.log(`InProcessRuntime stopped for project ${this.config.projectId}`); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); + stopError = err; this.setStatus("errored"); runtimeLog.error(`Error during shutdown:`, err.message); this.emit("error", err); - throw err; + } finally { + if (backendShutdown) { + try { + await backendShutdown(); + } catch (err) { + runtimeLog.warn( + `Backend shutdown failed: ${err instanceof Error ? err.message : err}`, + ); + } + } } + if (stopError) throw stopError; } /** diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 59ebc8e0b0..3de1f96cfc 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -9,6 +9,7 @@ import { type TaskStore, type Task, type MissionStore, + type AsyncMissionStore, type MissionFeature, type PrInfo, type AgentStore, @@ -517,7 +518,7 @@ export interface SchedulerOptions { /** Optional PR monitor for tracking in-review PRs */ prMonitor?: PrMonitor; /** Optional MissionStore for slice activation and auto-advance */ - missionStore?: MissionStore; + missionStore?: MissionStore | AsyncMissionStore; /** Optional lease manager used to recover stale checkout leases before scheduling. */ leaseManager?: MeshLeaseManager; /** Optional MissionAutopilot for autonomous mission progression */ @@ -725,7 +726,6 @@ export class Scheduler { const settings = await this.store.getSettings(); if (!settings.globalPause && !settings.enginePaused) { const todoTasks = await this.store.listTasks({ column: "todo", slim: true }); - const allTasks = await this.store.listTasks({ slim: true, includeArchived: true }); for (const dependent of todoTasks) { const mentionsCompletedTask = dependent.dependencies.includes(task.id); const currentlyBlockedByCompletedTask = dependent.blockedBy === task.id; @@ -734,9 +734,16 @@ export class Scheduler { const markerAcceptedByTaskId = settings.mergeRequestContractShadowEnabled === true ? new Map(await Promise.all(dependent.dependencies.map(async (depId) => [depId, (await this.store.getCompletionHandoffAcceptedMarker(depId)) !== null] as const))) : undefined; + /* + FNXC:SchedulerArchiveReads 2026-07-14-19:10: + Dependency reconciliation is event-scoped. Resolve only the dependent's referenced IDs so one completed task cannot make the scheduler download and parse the entire cold archive. + */ + const dependencyTasks = (await Promise.all( + dependent.dependencies.map((dependencyId) => this.store.getTask(dependencyId).catch(() => null)), + )).filter((candidate) => candidate !== null); const unresolvedDeps = getUnmetSchedulingDependencies( dependent, - [dependent, ...allTasks], + [dependent, task, ...dependencyTasks], markerAcceptedByTaskId ? { markerAcceptedByTaskId, @@ -887,7 +894,6 @@ export class Scheduler { const todoTasks = await this.store.listTasks({ column: "todo", slim: true }); const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true }); const dependents = [...todoTasks, ...inProgressTasks]; - const allTasks = await this.store.listTasks({ slim: true, includeArchived: true }); for (const dependent of dependents) { const mentionsDeletedTask = dependent.dependencies.includes(task.id); @@ -897,9 +903,12 @@ export class Scheduler { const markerAcceptedByTaskId = settings.mergeRequestContractShadowEnabled === true ? new Map(await Promise.all(dependent.dependencies.map(async (depId) => [depId, (await this.store.getCompletionHandoffAcceptedMarker(depId)) !== null] as const))) : undefined; + const dependencyTasks = (await Promise.all( + dependent.dependencies.map((dependencyId) => this.store.getTask(dependencyId).catch(() => null)), + )).filter((candidate) => candidate !== null); const unresolvedDeps = getUnmetSchedulingDependencies( dependent, - [dependent, ...allTasks], + [dependent, ...dependencyTasks], markerAcceptedByTaskId ? { markerAcceptedByTaskId, @@ -997,12 +1006,15 @@ export class Scheduler { // and start watching all missions with autopilotEnabled: true if (this.options.missionAutopilot && this.options.missionStore) { this.options.missionAutopilot.setScheduler(this); - const missions = this.options.missionStore.listMissions(); - for (const mission of missions) { - if (mission.autopilotEnabled && mission.status !== "complete" && mission.status !== "archived") { - this.options.missionAutopilot.watchMission(mission.id); + const missionStore = this.options.missionStore; + const missionAutopilot = this.options.missionAutopilot; + void Promise.resolve(missionStore.listMissions()).then((missions) => { + for (const mission of missions) { + if (mission.autopilotEnabled && mission.status !== "complete" && mission.status !== "archived") { + missionAutopilot.watchMission(mission.id); + } } - } + }).catch((error) => schedulerLog.error("Failed to initialize mission autopilot watches:", error)); this.options.missionAutopilot.start(); } } @@ -1460,11 +1472,11 @@ export class Scheduler { for (const t of todo) { if (t.sliceId && !blockedSliceIds.has(t.sliceId)) { try { - const slice = this.options.missionStore.getSlice(t.sliceId); + const slice = await this.options.missionStore.getSlice(t.sliceId); if (slice) { - const milestone = this.options.missionStore.getMilestone(slice.milestoneId); + const milestone = await this.options.missionStore.getMilestone(slice.milestoneId); if (milestone) { - const mission = this.options.missionStore.getMission(milestone.missionId); + const mission = await this.options.missionStore.getMission(milestone.missionId); if (mission && mission.status === "blocked") { blockedSliceIds.add(t.sliceId); } @@ -2336,9 +2348,9 @@ export class Scheduler { if (this.options.missionStore && task.sliceId) { try { - const slice = this.options.missionStore.getSlice(task.sliceId); - const milestone = slice ? this.options.missionStore.getMilestone(slice.milestoneId) : undefined; - const mission = milestone ? this.options.missionStore.getMission(milestone.missionId) : undefined; + const slice = await this.options.missionStore.getSlice(task.sliceId); + const milestone = slice ? await this.options.missionStore.getMilestone(slice.milestoneId) : undefined; + const mission = milestone ? await this.options.missionStore.getMission(milestone.missionId) : undefined; if (mission?.status === "blocked") { await this.store.updateTask(task.id, { status: "queued" }); await this.logDispatchQueuedReason(task.id, "queued — mission is blocked"); @@ -2867,7 +2879,7 @@ export class Scheduler { return; } - const feature = this.resolveMissionFeatureForTask(missionStore, task); + const feature = await this.resolveMissionFeatureForTask(missionStore, task); if (!feature) { schedulerLog.log(`No linked feature found for task ${taskId} (sliceId=${task.sliceId ?? "none"}) — skipping mission status update`); return; @@ -2880,9 +2892,7 @@ export class Scheduler { return; } - const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function" - ? missionStore.listAssertionsForFeature(feature.id).length > 0 - : false; + const hasLinkedAssertions = (await missionStore.listAssertionsForFeature(feature.id)).length > 0; const reconciliation = await reconcileMissionFeatureState( this.store, @@ -2904,7 +2914,7 @@ export class Scheduler { const sliceIdBeforeUpdate = feature.sliceId; if (reconciliation.kind === "update") { - missionStore.updateFeatureStatus(feature.id, reconciliation.status); + await missionStore.updateFeatureStatus(feature.id, reconciliation.status); schedulerLog.log( `Feature ${feature.id} marked ${reconciliation.status} (${reconciliation.reason})`, ); @@ -2918,8 +2928,8 @@ export class Scheduler { } } - private resolveMissionFeatureForTask(missionStore: MissionStore, task: Task): MissionFeature | undefined { - const linkedFeature = missionStore.getFeatureByTaskId(task.id); + private async resolveMissionFeatureForTask(missionStore: MissionStore | AsyncMissionStore, task: Task): Promise { + const linkedFeature = await missionStore.getFeatureByTaskId(task.id); if (linkedFeature) { return linkedFeature; } @@ -2929,8 +2939,8 @@ export class Scheduler { } const normalizedTaskTitle = this.normalizeMissionFeatureTitle(task.title); - const matchingFeature = missionStore - .listFeatures(task.sliceId) + const matchingFeature = (await missionStore + .listFeatures(task.sliceId)) .find((feature) => !feature.taskId && this.normalizeMissionFeatureTitle(feature.title) === normalizedTaskTitle @@ -2967,7 +2977,7 @@ export class Scheduler { const missionStore = this.options.missionStore; try { - const feature = missionStore.getFeatureByTaskId(taskId); + const feature = await missionStore.getFeatureByTaskId(taskId); if (!feature) return; if (feature.sliceId !== sliceId) { @@ -2996,7 +3006,7 @@ export class Scheduler { } // Check if the slice became complete after the feature update - const slice = missionStore.getSlice(sliceIdBeforeUpdate); + const slice = await missionStore.getSlice(sliceIdBeforeUpdate); if (slice && slice.status === "complete") { // If MissionAutopilot is available AND actively watching this mission, // delegate progression to it. The autopilot handles: watching missions, @@ -3007,7 +3017,7 @@ export class Scheduler { // autoAdvance=true but no autopilot instance, or autopilot unwatched), // fall back to onSliceComplete() which uses the compatibility rule. const autopilot = this.options.missionAutopilot; - const milestone = missionStore.getMilestone(slice.milestoneId); + const milestone = await missionStore.getMilestone(slice.milestoneId); const missionId = milestone?.missionId; const isWatching = autopilot && missionId ? autopilot.isWatching(missionId) : false; @@ -3031,13 +3041,13 @@ export class Scheduler { const missionStore = this.options.missionStore; try { - const milestone = missionStore.getMilestone(slice.milestoneId); + const milestone = await missionStore.getMilestone(slice.milestoneId); if (!milestone) { schedulerLog.warn(`Milestone ${slice.milestoneId} not found for slice ${slice.id}`); return; } - const mission = missionStore.getMission(milestone.missionId); + const mission = await missionStore.getMission(milestone.missionId); // Use autopilotEnabled as canonical, fall back to autoAdvance for backward compat const shouldAutoAdvance = mission?.autopilotEnabled === true || mission?.autoAdvance === true; @@ -3045,7 +3055,7 @@ export class Scheduler { return; } - const missionHierarchy = missionStore.getMissionWithHierarchy(mission.id); + const missionHierarchy = await missionStore.getMissionWithHierarchy(mission.id); const hasActiveSlice = missionHierarchy?.milestones.some((candidateMilestone) => candidateMilestone.slices.some((candidateSlice) => candidateSlice.id !== slice.id && candidateSlice.status === "active" @@ -3079,7 +3089,7 @@ export class Scheduler { const missionStore = this.options.missionStore; try { - const mission = missionStore.getMissionWithHierarchy(missionId); + const mission = await missionStore.getMissionWithHierarchy(missionId); if (!mission || mission.status !== "active") { schedulerLog.log(`Mission ${missionId}: not active, skipping slice activation`); return null; @@ -3135,7 +3145,7 @@ export class Scheduler { let totalFixed = 0; try { - const missions = missionStore.listMissions(); + const missions = await missionStore.listMissions(); const activeMissions = missions.filter((m) => m.status === "active"); const activeMissionIds = new Set(activeMissions.map((mission) => mission.id)); const taskBySliceAndTitle = new Map(); @@ -3154,7 +3164,7 @@ export class Scheduler { } for (const mission of activeMissions) { - const hierarchy = missionStore.getMissionWithHierarchy(mission.id); + const hierarchy = await missionStore.getMissionWithHierarchy(mission.id); if (!hierarchy) continue; const activeSlices = hierarchy.milestones @@ -3163,8 +3173,7 @@ export class Scheduler { for (const slice of activeSlices) { const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true; - const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures?.(slice.id) - ?? { supersededCount: 0, featureIds: [] }; + const supersededFixes = await missionStore.reconcileSupersededGeneratedFixFeatures(slice.id); if (supersededFixes.supersededCount > 0) { totalFixed += supersededFixes.supersededCount; schedulerLog.warn( @@ -3172,12 +3181,12 @@ export class Scheduler { ); } const features = supersededFixes.supersededCount > 0 - ? missionStore.listFeatures(slice.id) + ? await missionStore.listFeatures(slice.id) : slice.features; const supersededFeatureIds = new Set(supersededFixes.featureIds); if (supersededFixes.supersededCount > 0) { - const refreshedSlice = missionStore.getSlice?.(slice.id); + const refreshedSlice = await missionStore.getSlice(slice.id); if (refreshedSlice?.status === "complete") { /* FNXC:Missions 2026-07-11-12:35: @@ -3218,7 +3227,7 @@ export class Scheduler { schedulerLog.warn( `Repairing one-way mission link during reconciliation: task ${matchedTask.id} matched unlinked feature ${feature.id}`, ); - featureForReconciliation = missionStore.linkFeatureToTask(feature.id, matchedTask.id); + featureForReconciliation = await missionStore.linkFeatureToTask(feature.id, matchedTask.id); task = matchedTask; totalFixed++; await this.emitStrandedFeatureTriageAudit(mission.id, slice.id, feature.id, matchedTask.id); @@ -3231,7 +3240,7 @@ export class Scheduler { schedulerLog.warn( `Blocking stranded generated fix feature ${feature.id}: no linked task and no title-matched task available`, ); - missionStore.updateFeature(feature.id, { + await missionStore.updateFeature(feature.id, { status: "blocked", loopState: "blocked", taskId: undefined, @@ -3246,7 +3255,7 @@ export class Scheduler { try { const featureToTriage = feature.status === "defined" ? feature - : missionStore.updateFeature(feature.id, { + : await missionStore.updateFeature(feature.id, { status: "defined", loopState: "idle", taskId: undefined, @@ -3282,9 +3291,7 @@ export class Scheduler { if (!task) continue; - const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function" - ? missionStore.listAssertionsForFeature(featureForReconciliation.id).length > 0 - : false; + const hasLinkedAssertions = (await missionStore.listAssertionsForFeature(featureForReconciliation.id)).length > 0; const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation, { hasLinkedAssertions, }); @@ -3305,7 +3312,7 @@ export class Scheduler { } if (reconciliation.kind === "update") { - missionStore.updateFeatureStatus(featureForReconciliation.id, reconciliation.status); + await missionStore.updateFeatureStatus(featureForReconciliation.id, reconciliation.status); totalFixed++; } } diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 0dfa1cc888..a5f2651618 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -1000,13 +1000,11 @@ export class SelfHealingManager { return handedOff; } - private hasRecentWorktreeIncompleteDetected(taskId: string, graceMs: number): boolean { + private async hasRecentWorktreeIncompleteDetected(taskId: string, graceMs: number): Promise { if (!Number.isFinite(graceMs) || graceMs <= 0) return false; - const storeWithRunAudit = this.store as { getRunAuditEvents?: (filter: { taskId: string; mutationType: string; limit: number }) => Array<{ timestamp?: string | null }> }; - if (typeof storeWithRunAudit.getRunAuditEvents !== "function") return false; let events: Array<{ timestamp?: string | null }> = []; try { - events = storeWithRunAudit.getRunAuditEvents({ taskId, mutationType: "worktree:incomplete-detected", limit: 20 }) ?? []; + events = await this.store.getRunAuditEventsAsync({ taskId, mutationType: "worktree:incomplete-detected", limit: 20 }); } catch { return false; } @@ -1095,7 +1093,7 @@ export class SelfHealingManager { const anchorMs = input.stalenessAnchor ? Date.parse(input.stalenessAnchor) : Number.NaN; const stalenessMs = Number.isFinite(anchorMs) ? Math.max(0, Date.now() - anchorMs) : Number.POSITIVE_INFINITY; - const noRecentActivity = stalenessMs >= input.graceMs && !this.hasRecentWorktreeIncompleteDetected(task.id, input.graceMs); + const noRecentActivity = stalenessMs >= input.graceMs && !(await this.hasRecentWorktreeIncompleteDetected(task.id, input.graceMs)); const ok = sessionDead && worktreeUnusable && noRecentActivity; return { @@ -2511,18 +2509,9 @@ export class SelfHealingManager { log.log("Maintenance batch 1 step \"prune-operational-logs\" skipped — operationalLogRetentionDays is not enabled"); return; } - /* - * FNXC:SqliteFinalRemoval 2026-06-25-16:15: - * pruneOperationalLogs uses SQLite-specific DELETE on operational - * log tables. In backend mode, PostgreSQL autovacuum handles - * bloat; the operational-log pruning path is skipped until a PG - * equivalent is wired. - */ - if (this.store.isBackendMode()) { - log.log("Maintenance batch 1 step \"prune-operational-logs\" skipped — backend mode (PostgreSQL autovacuum)"); - return; - } - const { deletedTotal, deletedByTable } = this.store.pruneOperationalLogs(days * 86_400_000); + // FNXC:PostgresRetention 2026-07-14-17:16: Autovacuum cannot replace + // retention; await project-scoped deletes on the PostgreSQL layer. + const { deletedTotal, deletedByTable } = await this.store.pruneOperationalLogsAsync(days * 86_400_000); const detail = Object.entries(deletedByTable) .filter(([, n]) => n > 0) .map(([t, n]) => `${t}=${n}`) @@ -5161,15 +5150,29 @@ export class SelfHealingManager { async reconcileSoftDeletedColumnDrift(): Promise<{ reconciled: number }> { try { - // FNXC:RuntimeSatelliteAsync 2026-06-24-22:00: - // In backend mode, the sync SQLite database is not available. The - // column-drift reconciliation uses direct SQL against the sync DB. - // Backend mode does not need this reconciliation (PostgreSQL enforces - // constraints at the DB level), so skip it. - if (this.store.isBackendMode()) return { reconciled: 0 }; const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return { reconciled: 0 }; + if (this.store.isBackendMode()) { + /* + FNXC:PostgresSoftDeleteRepair 2026-07-14-17:32: + PostgreSQL constraints do not prevent a soft-deleted task from drifting out of the archived column. Run the same per-row repair and durable audit contract as the legacy store instead of silently skipping the invariant. + */ + const auditor = createRunAuditor(this.store, { + runId: generateSyntheticRunId("fn5566-soft-delete-column", "global"), + agentId: "self-healing", + phase: "reconcile-soft-delete-column-drift", + }); + return this.store.reconcileSoftDeletedColumnDriftBackend(async (candidate) => { + await auditor.database({ + type: "task:soft-delete-column-reconciled", + target: candidate.id, + metadata: { previousColumn: candidate.previousColumn }, + }); + log.log(`[self-heal] reconcile-soft-delete-column-drift: ${candidate.id} previous=${candidate.previousColumn} → archived`); + }); + } + const db = this.store.getDatabase(); // FN-5147 invariant: only rows with deletedAt are eligible, so live // in-review tasks (including autoMerge: false workflows) are never moved. @@ -12025,7 +12028,7 @@ export class SelfHealingManager { } if (prunedBranches.length > 0) { - const cleared = this.store.clearStaleExecutionStartBranchReferences(prunedBranches); + const cleared = await this.store.clearStaleExecutionStartBranchReferences(prunedBranches); if (cleared.length > 0) { log.log(`Cleared stale baseBranch on ${cleared.length} task(s): ${cleared.join(", ")}`); } diff --git a/packages/engine/src/workflow-authoritative-driver.ts b/packages/engine/src/workflow-authoritative-driver.ts index 787fdafd4d..4889de3945 100644 --- a/packages/engine/src/workflow-authoritative-driver.ts +++ b/packages/engine/src/workflow-authoritative-driver.ts @@ -23,7 +23,7 @@ export interface WorkflowAuthoritativeDriverStore { getTask(taskId: string): Promise; getTaskWorkflowSelection?(taskId: string): { workflowId: string; stepIds: string[] } | undefined; getTaskWorkflowSelectionAsync?(taskId: string): Promise<{ workflowId: string; stepIds: string[] } | undefined>; - getWorkflowParitySummary?(options?: { since?: string; limit?: number }): WorkflowParitySummary; + getWorkflowParitySummary?(options?: { since?: string; limit?: number }): WorkflowParitySummary | Promise; } export interface WorkflowAuthoritativeDriverDeps { @@ -128,7 +128,8 @@ export class WorkflowAuthoritativeDriver { let paritySummary: WorkflowParitySummary | undefined; try { settings = await this.deps.store.getSettings(); - paritySummary = this.deps.store.getWorkflowParitySummary?.(); + /* FNXC:WorkflowParityPostgres 2026-07-14-18:12: Readiness must await the PostgreSQL audit aggregation before deciding whether authoritative workflow execution is safe. */ + paritySummary = await this.deps.store.getWorkflowParitySummary?.(); } catch (error) { const message = error instanceof Error ? error.message : String(error); executorLog.warn(`[workflow-authoritative] ${task.id}: readiness probe failed — falling back to legacy (${message})`);