## Summary CLI commands, daemon/dashboard startup, packaged desktop startup, and live-data maintenance scripts now share the mandatory PostgreSQL lifecycle. Operators no longer risk a command silently reading or writing a disconnected SQLite shadow when PostgreSQL setup fails. ## Design decisions - Every startup owner retains and awaits its PostgreSQL shutdown callback, including partial-startup failure paths. - CLI project context and lock-retry flows resolve through asynchronous project stores. - Maintenance scripts use the shared backend helper; explicit database migration/inspection remains the only CLI surface allowed to read legacy SQLite sources. ## Validation - CLI and Desktop typechecks pass on the stacked branch. - `pnpm test:gate` passes all 478 gate tests. - This PR changes 54 files. ## Stack - Depends on #2109, which depends on #2108. - Bundled plugins and docs/release follow in later PRs. Related: #2105 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * PostgreSQL is now the authoritative store for structured project and task metadata. * Projects can be recognized and initialized using `.fusion/project.json`, without creating a legacy SQLite database. * CLI commands now retry transient PostgreSQL contention errors. * **Bug Fixes** * Improved cleanup when commands complete, fail, or run in the background, preventing lingering resources. * Improved desktop, server, and session shutdown reliability. * **Documentation** * Updated storage and standalone binary guidance to reflect PostgreSQL and legacy SQLite compatibility. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
99 lines
3.9 KiB
TypeScript
99 lines
3.9 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { createTaskStoreForBackend, type TaskStore } from "@fusion/core";
|
|
import { validateWorkflowIrDryRun } from "@fusion/engine";
|
|
import { cleanupProjectResolution, getStore } from "../project-resolver.js";
|
|
|
|
export interface RunWorkflowValidateOptions {
|
|
workflowId?: string;
|
|
file?: string;
|
|
projectName?: string;
|
|
json?: boolean;
|
|
}
|
|
|
|
interface OwnedWorkflowStore {
|
|
store: TaskStore;
|
|
shutdown: () => Promise<void>;
|
|
}
|
|
|
|
async function resolveStore(projectName?: string): Promise<OwnedWorkflowStore> {
|
|
try {
|
|
const store = await getStore({ project: projectName });
|
|
return { store, shutdown: cleanupProjectResolution };
|
|
} catch (error) {
|
|
if (projectName) throw error;
|
|
// FNXC:PostgresFinalCutover 2026-07-14-17:20: An unregistered CWD still
|
|
// validates workflows against PostgreSQL; it must not revive TaskStore's removed SQLite runtime.
|
|
const boot = await createTaskStoreForBackend({ rootDir: process.cwd() });
|
|
return { store: boot.taskStore, shutdown: boot.shutdown };
|
|
}
|
|
}
|
|
|
|
function printJsonAndExit(payload: unknown, code: number): never {
|
|
console.log(JSON.stringify(payload, null, 2));
|
|
process.exit(code);
|
|
}
|
|
|
|
/**
|
|
* FNXC:WorkflowCli 2026-07-12-00:00:
|
|
* Workflow authors need a script-friendly dry-run command that can validate a saved workflow id or an IR JSON file without creating or mutating workflow rows.
|
|
*/
|
|
export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Promise<void> {
|
|
const workflowId = opts.workflowId?.trim();
|
|
if (!workflowId && !opts.file) {
|
|
const message = "Usage: fn workflow validate <id> | --file <path> [--json]";
|
|
if (opts.json) printJsonAndExit({ valid: false, error: message }, 2);
|
|
console.error(message);
|
|
process.exit(2);
|
|
}
|
|
|
|
let owned: OwnedWorkflowStore | undefined;
|
|
try {
|
|
owned = await resolveStore(opts.projectName);
|
|
const store = owned.store;
|
|
/* FNXC:PostgresCliLifecycle 2026-07-14-19:10: Workflow validation must await the exact startup owner before any process exit; a finally block is insufficient because process.exit skips pending cleanup. */
|
|
const exitWithStore = async (payload: unknown | undefined, code: number): Promise<never> => {
|
|
if (payload !== undefined) console.log(JSON.stringify(payload, null, 2));
|
|
const current = owned;
|
|
owned = undefined;
|
|
await current!.shutdown();
|
|
return process.exit(code);
|
|
};
|
|
let ir: unknown;
|
|
if (opts.file) {
|
|
const filePath = resolve(opts.file);
|
|
try {
|
|
ir = JSON.parse(await readFile(filePath, "utf8"));
|
|
} catch (error) {
|
|
const message = `Failed to read or parse workflow IR file '${opts.file}': ${error instanceof Error ? error.message : String(error)}`;
|
|
if (opts.json) return await exitWithStore({ valid: false, error: message }, 2);
|
|
console.error(message);
|
|
return await exitWithStore(undefined, 2);
|
|
}
|
|
} else {
|
|
const def = await store.getWorkflowDefinition(workflowId!);
|
|
if (!def) {
|
|
const message = `Workflow '${workflowId}' not found`;
|
|
if (opts.json) return await exitWithStore({ valid: false, error: message }, 2);
|
|
console.error(message);
|
|
return await exitWithStore(undefined, 2);
|
|
}
|
|
ir = def.ir;
|
|
}
|
|
|
|
const result = await validateWorkflowIrDryRun(store, ir, false);
|
|
if (opts.json) return await exitWithStore(result.valid ? { valid: true } : { valid: false, errors: result.errors }, result.valid ? 0 : 1);
|
|
if (result.valid) {
|
|
console.log("✓ Workflow IR is valid. No workflow was created or mutated.");
|
|
return await exitWithStore(undefined, 0);
|
|
}
|
|
console.error("✗ Workflow IR is invalid:");
|
|
for (const error of result.errors) console.error(` - ${error.message}`);
|
|
return await exitWithStore(undefined, 1);
|
|
} finally {
|
|
const current = owned;
|
|
owned = undefined;
|
|
await current?.shutdown().catch(() => undefined);
|
|
}
|
|
}
|