# Migrate storage from SQLite to PostgreSQL — full dashboard cutover Migrates Fusion's storage layer to the embedded PostgreSQL `AsyncDataLayer` (the default backend) and **completes the satellite-store + feature cutover** so every dashboard and Command Center surface works in PG mode. ## Status — every surface works in embedded-PG mode Verified live against a running embedded-Postgres dashboard (all **200**, zero 5xx) and gate-tested (**23 files / 99 tests** on embedded PG, plus engine-core 294 and ci-shape 63 in the blocking merge gate; core/engine/cli/dashboard typecheck clean). | Area | Surfaces | State | |---|---|---| | Satellite stores | workflows, todos, insights, research, missions, goals, mailbox | ✅ | | Views | artifacts, documents, evals | ✅ | | Command Center | activity, productivity, team, tokens, tools, **workflows**, **github**, **signals**, **plugin-activations**, **live** (all 10) | ✅ | | Run execution | insight generation, research run execution | ✅ (store-path; AI step needs a provider) | | Live updates | SSE push for mission/research/insight events | ✅ | | Workflow editing | create / update / delete / select (+ id counter) | ✅ | | Engine | mission autopilot, incident-signal ingestion, regression storm-guard, agent wake-on-message | ✅ | | Core | tasks, agents, secrets, automations, memory, chat, usage, PRs, git | ✅ | ## Approach Each satellite store gets an `Async<Store>` wrapper exposing the sync store's method names over the existing `async-*-store.ts` helpers; `get<Store>Store()` returns a `Sync | Async` union; consumers `await` (harmless on sync), and engine/CLI paths that can't convert use `instanceof Sync` graceful fallback. Analytics aggregators branch on `"ping" in dbOrLayer` to run schema-qualified raw SQL over `project.*` (snake_case) in PG. Executors/orchestrators/autopilot are await-converted to drive the union store; the async store wrappers extend `EventEmitter` so SSE live-push fires in both backends. Not-yet-ported capabilities degrade gracefully (never 500) and are individually called out in commits. ## Sync with main The branch is kept continuously merged with `main` (currently through FN-7845, 2026-07-12); the earlier "final rebase deferred" note no longer applies. Use **Create a merge commit** (or squash) to land it — GitHub's rebase-merge cannot replay a merge-maintained branch. ## Residual Review Findings Multi-agent code review of the PostgreSQL satellite-store ports (U1–U5) applied 3 safe fixes (see `fix(review): apply autofix feedback`). The following are **real but gated** — recorded here as follow-up work rather than auto-applied. All are SQLite→PostgreSQL **concurrency/atomicity regressions**: the sync stores were immune only by SQLite's single-writer, single-threaded-handler execution; the async ports open multi-await read-modify-write windows. **Reachability is low today** because the execution engines that generate concurrent same-run mutations (insight run executor, research orchestrator/dispatcher) are `instanceof`-gated to sync mode in PG. No process-crash class survived (all engine fallbacks correctly guard the sync store). - **[P1] Research `appendResearchEvent` dual-write is non-atomic** (`packages/core/src/async-research-store.ts`, corroborated: adversarial + reliability). The `research_run_events` insert (own transaction) and the `run.events` jsonb update are separate writes — a crash between them, or two concurrent appends, splits the table count from the jsonb array. **Fix:** perform the seq-insert and the jsonb update in one `layer.transactionImmediate`. - **[P1] Research run terminal-reversion via stale full-row persist** (`async-research-store.ts` `persistResearchRun`/`updateResearchStatus`). Concurrent `PATCH /runs/:id/status` + `POST /runs/:id/events` can revert a terminal run to `running` by overwriting the whole row, bypassing the transition guard. **Fix:** scoped column `UPDATE`s with a `WHERE status …` guard, or optimistic version column. - **[P2] `updateResearchRun`/`updateInsightRun` read-then-write TOCTOU** — concurrent PATCHes last-writer-wins on the lifecycle merge. **Fix:** `SELECT … FOR UPDATE` / enclosing transaction. - **[P2] `upsertRun`/`createRunOrThrowConflict` check-then-create race** (`async-insight-store.ts`) — two callers can each create an "active" run. **Fix:** partial unique index on `(projectId, trigger) WHERE status IN ('pending','running')`. - **[P3] `createResearchRetryRun` return-value divergence** — sync returns the pre-update `queued` snapshot; async returns the reloaded `retry_waiting` run (persisted state is identical). Pick one side for cross-backend parity. - **[P2/perf] Mission `getMissionWithHierarchy`/`getMissionHealth` N+1 fan-out** — O(milestones×slices) sequential round-trips hold one pool slot per request; can starve the pool for large hierarchies. **Fix:** batched/joined reads. - **Testing gaps:** no PG-mode concurrency tests (interleaved status/event mutations), no sync↔async parity assertion for the lifecycle-error codes, and no mission status/health rollup parity test vs the sync `MissionStore`. ~~Out of scope (deferred): AI run *execution* (insight/research) + mission autopilot + live SSE mission events remain sync-gated/degraded in PG mode.~~ **Since ported** — insight/research run execution, mission autopilot, and SSE live push all run on the async layer now, which also makes the concurrency findings above genuinely reachable; they remain open follow-ups. --- ## Update — 2026-07-12: production-readiness hardening & live acceptance Everything below landed on this branch since the description above was written: **Production blockers from review — fixed** - `recoverStaleTransitionPending` ported to the async layer (backend moves write + clear the crash-safe marker; startup/maintenance sweeps no longer throw). - Lost-update class fixed: `atomicWriteTaskJson`/`WithAudit` write changed columns only (full-row upserts silently resurrected stale fields across concurrent store instances — the "task stuck unplanned forever" bug). - First-boot **auto-migration**: booting the PG backend over a project with a legacy `fusion.db` migrates it automatically (loud failure, SQLite kept as backup), and the dashboard shows a one-time **"your data was migrated" banner** with the backup paths and a Need-help Discord link. - `pg_dump`/`pg_restore` discovered from common install locations for embedded-mode backups. - The PG suite is part of the blocking merge gate (`test:pg-gate`). **Multi-project isolation (PR #2007, merged into this branch)** - `project_id` partition key on tasks / archived tasks / config, `taskProjectScope` threaded through every scan/claim/count, per-project config rows, layer bound to the project at startup. - Review P1 follow-up: the shared cold-storage `archive.archived_tasks` table is also partitioned and all archived-board reads/counts/searches are scoped. - Schema drift self-heal generalized to schema-qualified columns so existing databases upgrade in place. **Other changes** - Node settings sync **removed** in PG mode (409 `settings-sync-disabled-postgres`) — nodes share state by connecting to the same database; auth sync kept (per-machine file). - Perf (review findings): `listTasks` pushes column filter + ORDER BY + LIMIT/OFFSET into SQL; `getConversation` capped to the most recent 200 messages. - Fixed a false "operator action required" pause-abort log fired on every successfully auto-merged task. **Live acceptance — PASSED (2026-07-12)** A sandboxed instance (isolated HOME, embedded PG, real Opus executor) ran a task through the complete cycle: create → triage (AI spec) → execute → in-review → AI squash-merge landed on the project's `main` → done. A write+read sweep of every data surface (settings, comments, documents, attachments + artifact bridge + artifact edit, chat with real generation, goals, missions, agent mail, secrets, workflows, memory, CC analytics) was green on embedded PG. **Known remaining work** - The per-project `config` PK re-key has no upgrade path for pre-isolation embedded-PG databases (needs a real `DROP CONSTRAINT`/re-key migration; fresh databases are fine). - `pg_dump`/`pg_restore` binaries are not yet bundled in release artifacts (PATH/common-location discovery only). - The satellite-store concurrency findings listed above. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Phil Larson <hello@phillarson.xyz> Co-authored-by: fusion-merge <fusion-merge@local>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
/**
|
|
* Project context resolution utilities for multi-project CLI operations.
|
|
*
|
|
* Provides project detection, resolution, and TaskStore management
|
|
* for operating on tasks across multiple registered projects.
|
|
*/
|
|
|
|
import { TaskStore, createTaskStoreForBackend, type AsyncDataLayer, type RegisteredProject, CentralCore, GlobalSettingsStore, isValidSqliteDatabaseFile } from "@fusion/core";
|
|
import { resolve, dirname, basename } from "node:path";
|
|
|
|
/** Project context for CLI operations */
|
|
export interface ProjectContext {
|
|
/** Project ID */
|
|
projectId: string;
|
|
/** Absolute path to project directory */
|
|
projectPath: string;
|
|
/** Project name */
|
|
projectName: string;
|
|
/** Whether the project is registered in the central registry */
|
|
isRegistered: boolean;
|
|
/** TaskStore instance for this project */
|
|
store: TaskStore;
|
|
}
|
|
|
|
/** Cache of TaskStore instances by project ID to avoid re-initialization */
|
|
const storeCache = new Map<string, TaskStore>();
|
|
|
|
/**
|
|
* Resolve a project from explicit name flag, default project, or CWD detection.
|
|
*
|
|
* Resolution order:
|
|
* 1. If `projectNameFlag` provided: look up by name (case-insensitive) or ID (exact)
|
|
* 2. Else if default project set in global settings: use that project
|
|
* 3. Else: auto-detect from CWD by finding nearest `.fusion/fusion.db`
|
|
*
|
|
* @param projectNameFlag - Optional explicit project name/ID from --project flag
|
|
* @param cwd - Current working directory for CWD detection (default: process.cwd())
|
|
* @returns ProjectContext with resolved project and initialized TaskStore
|
|
* @throws Error if project not found or no project detected from CWD
|
|
*/
|
|
export async function resolveProject(
|
|
projectNameFlag?: string,
|
|
cwd: string = process.cwd(),
|
|
globalDir?: string,
|
|
): Promise<ProjectContext> {
|
|
const central = new CentralCore(globalDir);
|
|
await central.init();
|
|
|
|
try {
|
|
let project: RegisteredProject | undefined;
|
|
|
|
// 1. Explicit --project flag
|
|
if (projectNameFlag) {
|
|
project = await findProjectByNameOrId(central, projectNameFlag);
|
|
if (!project) {
|
|
throw new Error(
|
|
`Project '${projectNameFlag}' not found. Run 'fusion project list' to see registered projects.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Default project from global settings
|
|
if (!project) {
|
|
const defaultProject = await getDefaultProject(globalDir);
|
|
if (defaultProject) {
|
|
project = await central.getProject(defaultProject.id);
|
|
// If default project was deleted from registry, clear it
|
|
if (!project) {
|
|
await clearDefaultProject(globalDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Auto-detect from CWD
|
|
if (!project) {
|
|
const detected = await detectProjectFromCwd(cwd, central);
|
|
if (!detected) {
|
|
throw new Error(
|
|
`No fusion project found in current directory. Use --project or run from a project directory.`
|
|
);
|
|
}
|
|
|
|
const isRegistered = Boolean(detected.id);
|
|
const store = isRegistered
|
|
? await getStoreForProject(detected.id, detected.path, globalDir)
|
|
: await createLocalStore(detected.path, globalDir);
|
|
|
|
// For unregistered projects, use the path as the project ID
|
|
const projectId = isRegistered ? detected.id : detected.path;
|
|
|
|
return {
|
|
projectId,
|
|
projectPath: detected.path,
|
|
projectName: detected.name,
|
|
isRegistered,
|
|
store,
|
|
};
|
|
}
|
|
|
|
const store = await getStoreForProject(project.id, project.path, globalDir);
|
|
|
|
return {
|
|
projectId: project.id,
|
|
projectPath: project.path,
|
|
projectName: project.name,
|
|
isRegistered: true,
|
|
store,
|
|
};
|
|
} finally {
|
|
await central.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the default project from global settings.
|
|
* Returns undefined if no default is set or if the project no longer exists.
|
|
*/
|
|
export async function getDefaultProject(globalDir?: string): Promise<RegisteredProject | undefined> {
|
|
const globalStore = new GlobalSettingsStore(globalDir);
|
|
await globalStore.init();
|
|
|
|
const settings = await globalStore.getSettings();
|
|
if (!settings.defaultProjectId) {
|
|
return undefined;
|
|
}
|
|
|
|
const central = new CentralCore(globalDir);
|
|
await central.init();
|
|
try {
|
|
return await central.getProject(settings.defaultProjectId);
|
|
} finally {
|
|
await central.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the default project in global settings.
|
|
* @param projectId - Project ID to set as default
|
|
* @throws Error if project not found
|
|
*/
|
|
export async function setDefaultProject(projectId: string, globalDir?: string): Promise<void> {
|
|
// Verify project exists
|
|
const central = new CentralCore(globalDir);
|
|
await central.init();
|
|
try {
|
|
const project = await central.getProject(projectId);
|
|
if (!project) {
|
|
throw new Error(`Project '${projectId}' not found.`);
|
|
}
|
|
} finally {
|
|
await central.close();
|
|
}
|
|
|
|
const globalStore = new GlobalSettingsStore(globalDir);
|
|
await globalStore.init();
|
|
await globalStore.updateSettings({ defaultProjectId: projectId });
|
|
}
|
|
|
|
/**
|
|
* Clear the default project setting.
|
|
*/
|
|
export async function clearDefaultProject(globalDir?: string): Promise<void> {
|
|
const globalStore = new GlobalSettingsStore(globalDir);
|
|
await globalStore.init();
|
|
const current = await globalStore.getSettings();
|
|
|
|
const { defaultProjectId: _, ...rest } = current;
|
|
await globalStore.updateSettings(rest as Record<string, unknown>);
|
|
}
|
|
|
|
/**
|
|
* Detect a project from the current working directory by walking up
|
|
* the directory tree looking for `.fusion/fusion.db`.
|
|
*
|
|
* @param cwd - Starting directory (typically process.cwd())
|
|
* @param central - Initialized CentralCore instance
|
|
* @returns Registered project if found, undefined otherwise
|
|
*/
|
|
export async function detectProjectFromCwd(
|
|
cwd: string,
|
|
central: CentralCore
|
|
): Promise<RegisteredProject | { id: string; name: string; path: string } | undefined> {
|
|
const startDir = resolve(cwd);
|
|
let currentDir = startDir;
|
|
|
|
// Walk up the directory tree
|
|
while (true) {
|
|
// Check for fn database
|
|
const kbPath = resolve(currentDir, ".fusion", "fusion.db");
|
|
if (isValidSqliteDatabaseFile(kbPath)) {
|
|
// Found a fn project - check if it's registered
|
|
const project = await central.getProjectByPath(currentDir);
|
|
if (project) {
|
|
return project;
|
|
}
|
|
|
|
// For unregistered projects, only accept an exact CWD match.
|
|
// This preserves legacy single-project behavior without accidentally
|
|
// resolving unrelated parent directories higher in the filesystem.
|
|
if (currentDir === startDir) {
|
|
return {
|
|
id: "",
|
|
name: basename(currentDir) || "current-project",
|
|
path: currentDir,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Move up to parent
|
|
const parentDir = dirname(currentDir);
|
|
if (parentDir === currentDir) {
|
|
// Reached root, stop
|
|
break;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Find a project by name (case-insensitive) or ID (exact match).
|
|
*/
|
|
async function findProjectByNameOrId(
|
|
central: CentralCore,
|
|
nameOrId: string
|
|
): Promise<RegisteredProject | undefined> {
|
|
// First try exact ID match
|
|
const byId = await central.getProject(nameOrId);
|
|
if (byId) {
|
|
return byId;
|
|
}
|
|
|
|
// Then try case-insensitive name match
|
|
const allProjects = await central.listProjects();
|
|
const lowerName = nameOrId.toLowerCase();
|
|
return allProjects.find((p) => p.name.toLowerCase() === lowerName);
|
|
}
|
|
|
|
/**
|
|
* Get or create a TaskStore for a project.
|
|
* Stores are cached by project ID to avoid re-initialization.
|
|
*
|
|
* @param projectId - Project ID for cache key
|
|
* @param projectPath - Absolute path to project directory
|
|
* @returns Initialized TaskStore
|
|
*/
|
|
export async function getStoreForProject(
|
|
projectId: string,
|
|
projectPath: string,
|
|
globalSettingsDir?: string,
|
|
): Promise<TaskStore> {
|
|
// Check cache first
|
|
const cached = storeCache.get(projectId);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
// FNXC:PostgresCutover 2026-07-04: delegate construction to createLocalStore,
|
|
// which boots the PostgreSQL backend via createTaskStoreForBackend (embedded
|
|
// by default, external via DATABASE_URL) instead of a legacy SQLite TaskStore
|
|
// whose runtime was removed under VAL-REMOVAL-005. Caching the resulting store
|
|
// keeps a single connection pool per project for the CLI process lifetime.
|
|
const store = await createLocalStore(projectPath, globalSettingsDir);
|
|
|
|
// Cache it
|
|
storeCache.set(projectId, store);
|
|
return store;
|
|
}
|
|
|
|
/**
|
|
* Clear the store cache. Useful for testing or memory management.
|
|
*/
|
|
export function clearStoreCache(): void {
|
|
storeCache.clear();
|
|
}
|
|
|
|
export async function createLocalStore(
|
|
projectPath: string,
|
|
globalSettingsDir?: string,
|
|
): Promise<TaskStore> {
|
|
// FNXC:PostgresCutover 2026-07-04: route through createTaskStoreForBackend so
|
|
// standalone CLI commands (and resolveProject().store) boot PostgreSQL instead
|
|
// of the removed SQLite runtime. The factory returns null only on the
|
|
// FUSION_NO_EMBEDDED_PG=1 opt-out; in that case fall back to the legacy
|
|
// TaskStore, which still needs an explicit init().
|
|
// FNXC:PostgresCutover 2026-07-05-12:00: exported so CLI command
|
|
// catch-fallbacks (task/pr/backup/memory-backup/branch-group/mcp) boot their
|
|
// cwd-rooted store through the same factory instead of constructing a legacy
|
|
// SQLite TaskStore directly (its runtime throws in backend mode).
|
|
const boot = await createTaskStoreForBackend({ rootDir: projectPath, globalSettingsDir });
|
|
if (boot) {
|
|
return boot.taskStore;
|
|
}
|
|
const store = new TaskStore(projectPath, globalSettingsDir);
|
|
await store.init();
|
|
return store;
|
|
}
|
|
|
|
/**
|
|
* Format a project for display in CLI output.
|
|
*
|
|
* @param project - Registered project
|
|
* @param isDefault - Whether this is the default project
|
|
* @returns Formatted string like "* my-app /path/to/project [active]"
|
|
*/
|
|
export function formatProjectLine(project: RegisteredProject, isDefault: boolean): string {
|
|
const marker = isDefault ? "* " : " ";
|
|
const status = project.status;
|
|
const name = project.name;
|
|
const path = project.path;
|
|
return `${marker}${name} ${path} [${status}]`;
|
|
}
|
|
|
|
/**
|
|
* Get a TaskStore for a project specified by name or the current directory.
|
|
* This is a convenience function for commands that need a store.
|
|
*
|
|
* @param projectName - Optional project name/ID from --project flag
|
|
* @param cwd - Current working directory
|
|
* @returns Initialized TaskStore
|
|
*/
|
|
export async function getStore(
|
|
projectName?: string,
|
|
cwd: string = process.cwd(),
|
|
globalDir?: string,
|
|
): Promise<TaskStore> {
|
|
const context = await resolveProject(projectName, cwd, globalDir);
|
|
return context.store;
|
|
}
|
|
|
|
/**
|
|
* Resolve the AgentStore rootDir + backend AsyncDataLayer for agent CLI commands.
|
|
*
|
|
* FNXC:PostgresCutover 2026-07-04: agent commands (stop/start, export, import)
|
|
* must construct AgentStore in backend mode so agent data lives in PostgreSQL,
|
|
* not the removed SQLite runtime (VAL-REMOVAL-005). The asyncLayer is borrowed
|
|
* from the resolved project's TaskStore (same connection pool), mirroring the
|
|
* extension.ts getAgentStore injection. When no project resolves (unregistered
|
|
* cwd) the layer is null and AgentStore falls back to its layerless path.
|
|
*/
|
|
export async function resolveAgentStoreBase(
|
|
projectName?: string,
|
|
): Promise<{ rootDir: string; asyncLayer: AsyncDataLayer | null }> {
|
|
try {
|
|
const context = await resolveProject(projectName);
|
|
return { rootDir: context.projectPath, asyncLayer: context.store.getAsyncLayer() };
|
|
} catch {
|
|
return { rootDir: process.cwd(), asyncLayer: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FNXC:CliAgentControl 2026-07-08-00:00:
|
|
* Close a resolved project's `TaskStore` and evict it from `storeCache` when
|
|
* it is that store's owner. `resolveProject()` always constructs (and, for
|
|
* registered/CWD-detected projects, caches) a `TaskStore` even when a caller
|
|
* only needs the resolved `projectPath` — e.g. `fn agent stop/start`
|
|
* (packages/cli/src/commands/agent.ts) never touches `context.store` at all.
|
|
* An unclosed cached store keeps the underlying SQLite connection (and any
|
|
* handles it owns) alive, which can keep the CLI process's event loop alive
|
|
* past the point where the command's real work is done — the process never
|
|
* exits on its own, so a caller bounding the subprocess with a timeout (e.g.
|
|
* a recovery watcher) sees a false "hang" until it force-kills at its own
|
|
* deadline. Close+evict is best-effort and idempotent so it is safe even if
|
|
* another in-process caller already holds/closed the same cached instance.
|
|
*/
|
|
export async function closeProjectStore(context: ProjectContext): Promise<void> {
|
|
try {
|
|
await context.store.close();
|
|
} catch {
|
|
// Best-effort: an already-closed store (or one closed by a concurrent
|
|
// in-process caller) must not throw here.
|
|
}
|
|
if (storeCache.get(context.projectId) === context.store) {
|
|
storeCache.delete(context.projectId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FNXC:CliAgentControl 2026-07-08-00:00:
|
|
* Resolve only the project PATH without leaking the `TaskStore` that
|
|
* `resolveProject()` constructs internally. Use this instead of
|
|
* `resolveProject()` when a command has no use for `context.store` (see
|
|
* `closeProjectStore` above for the underlying leak this avoids).
|
|
*/
|
|
export async function resolveProjectPathOnly(
|
|
projectNameFlag?: string,
|
|
cwd: string = process.cwd(),
|
|
globalDir?: string,
|
|
): Promise<string> {
|
|
const context = await resolveProject(projectNameFlag, cwd, globalDir);
|
|
await closeProjectStore(context);
|
|
return context.projectPath;
|
|
}
|
|
|
|
/**
|
|
* FNXC:CliBoardMutation 2026-07-09-00:00:
|
|
* Wrap an already-constructed, UNCACHED local `TaskStore` (the CWD-fallback
|
|
* branch several board command files build directly via
|
|
* `new TaskStore(process.cwd())` when `resolveProject` throws — e.g.
|
|
* `getBranchGroupContext`/`getPrContext` in `packages/cli/src/commands/
|
|
* branch-group.ts`/`pr.ts`, FN-7738) as a well-formed `ProjectContext` so
|
|
* `closeProjectStore` can close+evict it the same way it handles a cached
|
|
* context, even though `storeCache` holds no matching entry for it (eviction
|
|
* is then a harmless no-op; the `.close()` call is what matters). Mirrors
|
|
* `packages/cli/src/commands/task.ts`'s private `asLocalProjectContext`
|
|
* helper (kept private there per FN-7734/FN-7738 scope boundaries — this
|
|
* export exists so `branch-group.ts`/`pr.ts` do not need to fork a second
|
|
* copy).
|
|
*/
|
|
export function asLocalProjectContext(store: TaskStore): ProjectContext {
|
|
const cwd = process.cwd();
|
|
return {
|
|
projectId: cwd,
|
|
projectPath: cwd,
|
|
projectName: basename(cwd) || "current-project",
|
|
isRegistered: false,
|
|
store,
|
|
};
|
|
}
|