Files
fusion/packages/cli/src/commands/workflow.ts
gsxdsm 97172fdcf2 fix(FN-7952): require PostgreSQL in CLI and desktop (#2110)
## 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 -->
2026-07-14 23:18:55 -07:00

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);
}
}