Files
fusion/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts
gsxdsm 5ae6332563 refactor: collapse dead SQLite dual-path code; keep migration-only readers (#2454)
# Remove dead SQLite dual-path code; keep migration-only readers

## Summary
PostgreSQL cutover left hundreds of production dual-path branches
(`backendMode ? PG : SQLite/store.db`) whose SQLite arms only hit
throwing `Database`/`ArchiveDatabase`/`CentralDatabase` stubs. This
change mechanically collapses those unreachable arms so production
authority is AsyncDataLayer/PostgreSQL only, while preserving the six
authorized read-only migration/recovery `DatabaseSync` seams.

## Dual-path mass removed
| Metric | Before | After |
|---|---|---|
| `if (…backendMode)` (non-test) | ~328 | ~70 |
| `store.db` / `this.db` refs in core (non-test) | ~570+ | ~375 (mostly
pure legacy MissionStore/eval/insight SQLite classes + thin getters) |
| Net diff | — | **~6.7k lines removed** across 41 files |

Remaining `backendMode` checks are intentional (incomplete-PG sync
safe-defaults, settings-sync disabled-on-PG, symbol-lock PG-only gates,
“requires PostgreSQL” config versioning throws), not live SQLite
authority.

## Subsystems cleaned
- **Core TaskStore / task-store/***: collapsed if/else and early-return
dual-path across reads, moves, lifecycle, mutations, workflow, archive,
branch/PR, artifacts, comments, audit, project ops, etc. `initImpl` is
PostgreSQL-only (SQLite startup tail deleted).
- **Satellite stores**: automation, agent, routine, plugin, secrets,
approval-request, central-core dual-path arms collapsed.
- **Plugins**: reports async methods, compound-engineering pipeline +
session stores, CLI Printing Press store — SQLite fallbacks removed; PG
required.
- **Engine**: no functional dual-path change beyond whitespace
(settings-sync / peer-exchange PG-disabled behavior kept).

## Six migration-only readers retained (allowlist unchanged)
1. `packages/core/src/postgres/sqlite-migrator.ts`
2. `packages/core/src/project-identity.ts`
3. `packages/core/src/sqlite-validation.ts`
4. `packages/core/src/postgres/startup-factory.ts`
5. `packages/cli/src/commands/db.ts`
6. `scripts/lib/start-local-project.mjs`

Plus low-level `sqlite-adapter` and migrator/startup-import tests.
Inventory ratchet still requires exactly these six `new DatabaseSync(`
production sites, all `readOnly: true`.

## Not treated as SQLite
- `.fusion/project.json`, `task.json`, `agent-log.jsonl` file storage
- AsyncDataLayer / Drizzle PG paths
- Incomplete-PG sync safe-default stubs (still return empty/false/null
under backend without consulting SQLite)

## Verification
- `sqlite-production-reader-inventory.test.ts` — 15/15 pass
- `incomplete-pg-ports.pg.test.ts` — 6/6 pass
- Targeted PG tests (create-task, move, handoff, runtime-persistence,
agent, mission, insight, central-core) — green
- `tsc --noEmit` for `@fusion/core`, `@fusion/engine`,
`@fusion/dashboard` — green
- `scripts/check-no-getdatabase.mjs` — clean

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Improved end-to-end consistency by making PostgreSQL/async persistence
the standard across core task/workflow, automation, agents, plugins,
routines, secrets, approvals, central operations, and session storage.
* Unified scheduling, settings, configuration revision writes,
run/workflow selection, queues/leases/transitions, and audit/lifecycle
updates around consistent async transaction behavior.
* **Bug Fixes**
* Fixed edge cases for archived/deleted reads, unarchive/recovery flows,
not-found handling, and task/artifact/document/log/comment operations,
including more reliable emissions and hydration across search/list and
lifecycle operations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-26 23:28:42 -07:00

510 lines
20 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, desc, eq, isNull } from "drizzle-orm";
import type { AsyncDataLayer, Database, PluginContext } from "@fusion/core";
import { cePipelineLinks as cePipelineLinksShape, cePipelineState as cePipelineStateShape, cePipelineSyncQueue as cePipelineSyncQueueShape } from "./pg-schema.js";
import { ensureCeSchema } from "../schema.js";
/**
* Plugin-local store for CE pipeline LINK records (U7).
*
* A link record is the addressable, durable association between a board task and
* the CE pipeline/stage/artifact that produced it. Per FN-5719 the back-reference
* lives HERE (a plugin-local table) — NOT in task-row JSON — so board-task
* ownership and CE-pipeline ownership remain separate state machines and cannot
* oscillate. A convenience copy of the ids may also ride along in the task's
* `source.sourceMetadata`, but THIS ROW is the authoritative link.
*
* U7 SURFACE (intentionally minimal): create a link, list links by pipeline, and
* find the link by taskId. U8 will EXTEND this store with the full bidirectional
* pipeline-STATE machine (state column, status transitions, enqueue/reconcile).
* U7 deliberately does not add any state/status field or sync behaviour so U8 can
* layer it on without reworking the link surface.
*/
export interface CePipelineLink {
/** Stable link-record id. */
id: string;
/** The board task this link points at (1:1 for U7). */
taskId: string;
/** The CE pipeline this task was derived under (the originating run). */
cePipelineId: string;
/** The CE stage id within that pipeline (e.g. "work"). */
ceStageId: string;
/** Absolute path to the stage artifact that drove this task, if any. */
ceArtifactPath: string | null;
createdAt: string;
}
interface CePipelineLinkRow {
id: string;
taskId: string;
cePipelineId: string;
ceStageId: string;
ceArtifactPath: string | null;
createdAt: string;
}
export interface CreateCePipelineLinkInput {
taskId: string;
cePipelineId: string;
ceStageId: string;
ceArtifactPath?: string | null;
id?: string;
}
/**
* CE-pipeline STATUS — the pipeline's own lifecycle, DISTINCT from board-task
* columns (KTD4 / FN-5719). The board owns task columns; this owns pipeline
* progress. The two are never encoded in one shared column.
*
* running — pipeline is at `currentStage`, work in flight on the board.
* advancing — a board signal arrived; reconciler is moving it on / feeding
* the next stage (transient, set inside the reconciler sweep).
* awaiting_board — pipeline advanced and is waiting on board task(s) again.
* completed — pipeline reached its terminal stage and finished.
*/
export type CePipelineStatus = "running" | "advancing" | "awaiting_board" | "completed";
/** The CE-pipeline's own state record (separate state machine from the board). */
export interface CePipelineState {
cePipelineId: string;
/** The CE stage the pipeline has reached (a stage id, e.g. "work"). */
currentStage: string;
status: CePipelineStatus;
/** Last artifact the pipeline produced/propagated (CE-authoritative content). */
lastArtifactPath: string | null;
createdAt: string;
updatedAt: string;
}
interface CePipelineStateRow {
cePipelineId: string;
currentStage: string;
status: CePipelineStatus;
lastArtifactPath: string | null;
createdAt: string;
updatedAt: string;
}
export interface UpsertCePipelineStateInput {
cePipelineId: string;
currentStage: string;
status?: CePipelineStatus;
lastArtifactPath?: string | null;
}
/** Why a board change was enqueued for the pipeline (audit + reconcile routing). */
export type CeSyncReason = "task_moved" | "task_completed" | "reconcile";
/** A pending (or drained) board→pipeline sync signal. */
export interface CeSyncQueueEntry {
id: string;
cePipelineId: string;
taskId: string;
reason: CeSyncReason;
fromColumn: string | null;
toColumn: string | null;
enqueuedAt: string;
processedAt: string | null;
}
interface CeSyncQueueRow {
id: string;
cePipelineId: string;
taskId: string;
reason: CeSyncReason;
fromColumn: string | null;
toColumn: string | null;
enqueuedAt: string;
processedAt: string | null;
}
export interface EnqueueSyncInput {
cePipelineId: string;
taskId: string;
reason: CeSyncReason;
fromColumn?: string | null;
toColumn?: string | null;
id?: string;
}
function rowToState(row: CePipelineStateRow): CePipelineState {
return {
cePipelineId: row.cePipelineId,
currentStage: row.currentStage,
status: row.status,
lastArtifactPath: row.lastArtifactPath,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function rowToQueueEntry(row: CeSyncQueueRow): CeSyncQueueEntry {
return {
id: row.id,
cePipelineId: row.cePipelineId,
taskId: row.taskId,
reason: row.reason,
fromColumn: row.fromColumn,
toColumn: row.toColumn,
enqueuedAt: row.enqueuedAt,
processedAt: row.processedAt,
};
}
function rowToLink(row: CePipelineLinkRow): CePipelineLink {
return {
id: row.id,
taskId: row.taskId,
cePipelineId: row.cePipelineId,
ceStageId: row.ceStageId,
ceArtifactPath: row.ceArtifactPath,
createdAt: row.createdAt,
};
}
// Drizzle table refs (CE plugin tables live in the project schema; see
// packages/core/src/postgres/schema/plugin.ts and cePluginSchemaInit).
const cePipelineLinksTable = cePipelineLinksShape;
const cePipelineStateTable = cePipelineStateShape;
const cePipelineSyncQueueTable = cePipelineSyncQueueShape;
export class CePipelineStore {
// FNXC:PostgresCutover 2026-07-04-00:00 RESOLVED:
// Backend (PostgreSQL) mode is now wired. The constructor accepts an optional
// AsyncDataLayer; when present, the *Async() siblings route every query
// through Drizzle against the plugin-owned ce_pipeline_* tables (materialized
// by cePluginSchemaInit). The sync methods remain as the SQLite fallback and
// are used directly by non-backend callers and tests.
private readonly db: Database | null;
private readonly asyncLayer: AsyncDataLayer | null;
constructor(db: Database | null, asyncLayer: AsyncDataLayer | null = null) {
this.db = db;
this.asyncLayer = asyncLayer;
if (db) ensureCeSchema(db);
// PG plugin tables materialize via cePluginSchemaInit at schema-apply time
// (long before any store is constructed), so there is no first-use DDL here.
}
/** True when the store was constructed with a PostgreSQL async layer. */
get backendMode(): boolean {
return this.asyncLayer !== null;
}
/** Asserts sync db is available (SQLite fallback; throws in backend mode). */
private syncDb(): Database {
if (!this.db) throw new Error("CePipelineStore: sync Database is null (backend mode)");
return this.db;
}
/** Drizzle handle for the async siblings (backend mode only). */
private dbAsync() {
if (!this.asyncLayer) throw new Error("CePipelineStore: asyncLayer is null (SQLite mode)");
return this.asyncLayer.db;
}
/** FNXC:CePipelineProjectIsolation 2026-07-14-21:28: Pipeline links, state, and queue rows share one PostgreSQL schema, so every async read and mutation binds the owning AsyncDataLayer project. */
private projectId(): string {
const projectId = this.asyncLayer?.projectId?.trim();
if (!projectId) throw new Error("CePipelineStore: PostgreSQL backend requires asyncLayer.projectId");
return projectId;
}
// ── Links (U7) ─────────────────────────────────────────────────────
/** Record a task→pipeline/artifact link. */
createLink(input: CreateCePipelineLinkInput): CePipelineLink {
const link: CePipelineLink = {
id: input.id ?? randomUUID(),
taskId: input.taskId,
cePipelineId: input.cePipelineId,
ceStageId: input.ceStageId,
ceArtifactPath: input.ceArtifactPath ?? null,
createdAt: new Date().toISOString(),
};
this.syncDb().prepare(`INSERT INTO ce_pipeline_links
(id, taskId, cePipelineId, ceStageId, ceArtifactPath, createdAt)
VALUES (?, ?, ?, ?, ?, ?)`,)
.run(link.id, link.taskId, link.cePipelineId, link.ceStageId, link.ceArtifactPath, link.createdAt);
return link;
}
/** Async sibling: createLink. Routes to Drizzle in backend mode. */
async createLinkAsync(input: CreateCePipelineLinkInput): Promise<CePipelineLink> {
const link: CePipelineLink = {
id: input.id ?? randomUUID(),
taskId: input.taskId,
cePipelineId: input.cePipelineId,
ceStageId: input.ceStageId,
ceArtifactPath: input.ceArtifactPath ?? null,
createdAt: new Date().toISOString(),
};
await this.dbAsync().insert(cePipelineLinksTable).values({
projectId: this.projectId(),
id: link.id,
taskId: link.taskId,
cePipelineId: link.cePipelineId,
ceStageId: link.ceStageId,
ceArtifactPath: link.ceArtifactPath,
createdAt: link.createdAt,
});
return link;
}
/** All links produced by a given CE pipeline, newest first. */
listByPipeline(cePipelineId: string): CePipelineLink[] {
const rows = this.syncDb().prepare(`SELECT * FROM ce_pipeline_links WHERE cePipelineId = ? ORDER BY createdAt DESC, id`)
.all(cePipelineId) as CePipelineLinkRow[];
return rows.map(rowToLink);
}
/** Async sibling: listByPipeline. Routes to Drizzle in backend mode. */
async listByPipelineAsync(cePipelineId: string): Promise<CePipelineLink[]> {
const rows = await this.dbAsync().select()
.from(cePipelineLinksTable)
.where(and(eq(cePipelineLinksTable.projectId, this.projectId()), eq(cePipelineLinksTable.cePipelineId, cePipelineId)))
.orderBy(desc(cePipelineLinksTable.createdAt), cePipelineLinksTable.id);
return rows.map((r) => rowToLink(r as CePipelineLinkRow));
}
/** Resolve a board task back to its CE link (the back-reference). */
findByTaskId(taskId: string): CePipelineLink | undefined {
const row = this.syncDb().prepare(`SELECT * FROM ce_pipeline_links WHERE taskId = ?`)
.get(taskId) as CePipelineLinkRow | undefined;
return row ? rowToLink(row) : undefined;
}
/** Async sibling: findByTaskId. Routes to Drizzle in backend mode. */
async findByTaskIdAsync(taskId: string): Promise<CePipelineLink | undefined> {
const rows = await this.dbAsync().select()
.from(cePipelineLinksTable)
.where(and(eq(cePipelineLinksTable.projectId, this.projectId()), eq(cePipelineLinksTable.taskId, taskId)))
.limit(1);
return rows[0] ? rowToLink(rows[0] as CePipelineLinkRow) : undefined;
}
// ── CE-pipeline STATE machine (U8) ───────────────────────────────────
// Separate from board-task columns: this table is the pipeline's OWN state.
/** Read a pipeline's own state record. */
getState(cePipelineId: string): CePipelineState | undefined {
const row = this.syncDb().prepare(`SELECT * FROM ce_pipeline_state WHERE cePipelineId = ?`)
.get(cePipelineId) as CePipelineStateRow | undefined;
return row ? rowToState(row) : undefined;
}
/** Async sibling: getState. Routes to Drizzle in backend mode. */
async getStateAsync(cePipelineId: string): Promise<CePipelineState | undefined> {
const rows = await this.dbAsync().select()
.from(cePipelineStateTable)
.where(and(eq(cePipelineStateTable.projectId, this.projectId()), eq(cePipelineStateTable.cePipelineId, cePipelineId)))
.limit(1);
return rows[0] ? rowToState(rows[0] as CePipelineStateRow) : undefined;
}
/** All pipeline state records (the reconciler sweeps every one). */
listAllState(): CePipelineState[] {
const rows = this.syncDb().prepare(`SELECT * FROM ce_pipeline_state ORDER BY updatedAt DESC, cePipelineId`)
.all() as CePipelineStateRow[];
return rows.map(rowToState);
}
/** Async sibling: listAllState. Routes to Drizzle in backend mode. */
async listAllStateAsync(): Promise<CePipelineState[]> {
const rows = await this.dbAsync().select()
.from(cePipelineStateTable)
.where(eq(cePipelineStateTable.projectId, this.projectId()))
.orderBy(desc(cePipelineStateTable.updatedAt), cePipelineStateTable.cePipelineId);
return rows.map((r) => rowToState(r as CePipelineStateRow));
}
/**
* Create or update a pipeline's state. Idempotent on `cePipelineId`. `status`
* defaults to `running` on first write and is preserved on update unless given.
*/
upsertState(input: UpsertCePipelineStateInput): CePipelineState {
const now = new Date().toISOString();
const existing = this.getState(input.cePipelineId);
const status = input.status ?? existing?.status ?? "running";
const lastArtifactPath =
input.lastArtifactPath !== undefined ? input.lastArtifactPath : existing?.lastArtifactPath ?? null;
if (existing) {
this.syncDb().prepare(`UPDATE ce_pipeline_state
SET currentStage = ?, status = ?, lastArtifactPath = ?, updatedAt = ?
WHERE cePipelineId = ?`,)
.run(input.currentStage, status, lastArtifactPath, now, input.cePipelineId);
} else {
this.syncDb().prepare(`INSERT INTO ce_pipeline_state
(cePipelineId, currentStage, status, lastArtifactPath, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?)`,)
.run(input.cePipelineId, input.currentStage, status, lastArtifactPath, now, now);
}
return this.getState(input.cePipelineId)!;
}
/** Async sibling: upsertState. Routes to Drizzle in backend mode. */
async upsertStateAsync(input: UpsertCePipelineStateInput): Promise<CePipelineState> {
const now = new Date().toISOString();
/*
FNXC:CompoundEngineeringConcurrency 2026-07-14-23:53:
Pipeline state writers may independently advance status or attach an artifact. A read-before-write upsert lets concurrent callers replace an omitted field with a stale snapshot, so PostgreSQL must resolve the composite-key conflict atomically and retain the stored column whenever the caller omitted it.
*/
const rows = await this.dbAsync().insert(cePipelineStateTable).values({
projectId: this.projectId(),
cePipelineId: input.cePipelineId,
currentStage: input.currentStage,
status: input.status ?? "running",
lastArtifactPath: input.lastArtifactPath ?? null,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: [cePipelineStateTable.projectId, cePipelineStateTable.cePipelineId],
set: {
currentStage: input.currentStage,
...(input.status !== undefined ? { status: input.status } : {}),
...(input.lastArtifactPath !== undefined
? { lastArtifactPath: input.lastArtifactPath }
: {}),
updatedAt: now,
},
}).returning();
return rowToState(rows[0] as CePipelineStateRow);
}
/**
* Transition a pipeline to a new stage/status. Returns the updated state, or
* `undefined` if the pipeline has no state row yet (caller should seed first).
*/
transitionState(
cePipelineId: string,
next: { currentStage?: string; status?: CePipelineStatus; lastArtifactPath?: string | null },
): CePipelineState | undefined {
const existing = this.getState(cePipelineId);
if (!existing) return undefined;
return this.upsertState({
cePipelineId,
currentStage: next.currentStage ?? existing.currentStage,
status: next.status ?? existing.status,
lastArtifactPath:
next.lastArtifactPath !== undefined ? next.lastArtifactPath : existing.lastArtifactPath,
});
}
/** Async sibling: transitionState. Routes to Drizzle in backend mode. */
async transitionStateAsync(
cePipelineId: string,
next: { currentStage?: string; status?: CePipelineStatus; lastArtifactPath?: string | null },
): Promise<CePipelineState | undefined> {
const existing = await this.getStateAsync(cePipelineId);
if (!existing) return undefined;
return this.upsertStateAsync({
cePipelineId,
currentStage: next.currentStage ?? existing.currentStage,
status: next.status ?? existing.status,
lastArtifactPath:
next.lastArtifactPath !== undefined ? next.lastArtifactPath : existing.lastArtifactPath,
});
}
// ── Event-enqueue seam (U8 / FN-5719) ────────────────────────────────
// Hooks write here FAST and return; the reconciler drains. A missed enqueue is
// still recovered because reconcile() re-derives from board state too.
/** Append a pending board→pipeline sync signal. Fast, append-only. */
enqueueSync(input: EnqueueSyncInput): CeSyncQueueEntry {
const entry: CeSyncQueueEntry = {
id: input.id ?? randomUUID(),
cePipelineId: input.cePipelineId,
taskId: input.taskId,
reason: input.reason,
fromColumn: input.fromColumn ?? null,
toColumn: input.toColumn ?? null,
enqueuedAt: new Date().toISOString(),
processedAt: null,
};
this.syncDb().prepare(`INSERT INTO ce_pipeline_sync_queue
(id, cePipelineId, taskId, reason, fromColumn, toColumn, enqueuedAt, processedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`,)
.run(entry.id, entry.cePipelineId, entry.taskId, entry.reason, entry.fromColumn, entry.toColumn, entry.enqueuedAt);
return entry;
}
/** Async sibling: enqueueSync. Routes to Drizzle in backend mode. */
async enqueueSyncAsync(input: EnqueueSyncInput): Promise<CeSyncQueueEntry> {
const entry: CeSyncQueueEntry = {
id: input.id ?? randomUUID(),
cePipelineId: input.cePipelineId,
taskId: input.taskId,
reason: input.reason,
fromColumn: input.fromColumn ?? null,
toColumn: input.toColumn ?? null,
enqueuedAt: new Date().toISOString(),
processedAt: null,
};
await this.dbAsync().insert(cePipelineSyncQueueTable).values({
projectId: this.projectId(),
id: entry.id,
cePipelineId: entry.cePipelineId,
taskId: entry.taskId,
reason: entry.reason,
fromColumn: entry.fromColumn,
toColumn: entry.toColumn,
enqueuedAt: entry.enqueuedAt,
processedAt: null,
});
return entry;
}
/** All pending (un-drained) queue entries, oldest first. */
listPendingSync(): CeSyncQueueEntry[] {
const rows = this.syncDb().prepare(`SELECT * FROM ce_pipeline_sync_queue WHERE processedAt IS NULL ORDER BY enqueuedAt, id`)
.all() as CeSyncQueueRow[];
return rows.map(rowToQueueEntry);
}
/** Async sibling: listPendingSync. Routes to Drizzle in backend mode. */
async listPendingSyncAsync(): Promise<CeSyncQueueEntry[]> {
const rows = await this.dbAsync().select()
.from(cePipelineSyncQueueTable)
.where(and(eq(cePipelineSyncQueueTable.projectId, this.projectId()), isNull(cePipelineSyncQueueTable.processedAt)))
.orderBy(cePipelineSyncQueueTable.enqueuedAt, cePipelineSyncQueueTable.id);
return rows.map((r) => rowToQueueEntry(r as CeSyncQueueRow));
}
/** Mark a queue entry drained (idempotent). */
markSyncProcessed(id: string): void {
this.syncDb().prepare(`UPDATE ce_pipeline_sync_queue SET processedAt = ? WHERE id = ? AND processedAt IS NULL`)
.run(new Date().toISOString(), id);
}
/** Async sibling: markSyncProcessed. Routes to Drizzle in backend mode. */
async markSyncProcessedAsync(id: string): Promise<void> {
/*
FNXC:SqliteDualPathCleanup 2026-07-26-13:42:
Async queue mark-processed is PostgreSQL-only; the former SQLite markSyncProcessed fallback is deleted.
*/
await this.dbAsync().update(cePipelineSyncQueueTable)
.set({ processedAt: new Date().toISOString() })
.where(and(
eq(cePipelineSyncQueueTable.projectId, this.projectId()),
eq(cePipelineSyncQueueTable.id, id),
isNull(cePipelineSyncQueueTable.processedAt),
));
}
}
const storeCache = new WeakMap<object, CePipelineStore>();
/** WeakMap-cached store keyed by the TaskStore instance (mirrors the session store). */
export function getCePipelineStore(ctx: PluginContext): CePipelineStore {
const key = ctx.taskStore as object;
const cached = storeCache.get(key);
if (cached) return cached;
const asyncLayer = ctx.taskStore.getAsyncLayer();
if (!asyncLayer) throw new Error("Compound Engineering pipeline store requires the project PostgreSQL AsyncDataLayer");
/* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Bundled CE pipeline state is PostgreSQL-only at runtime. */
const store = new CePipelineStore(null, asyncLayer);
storeCache.set(key, store);
return store;
}