From 5ae6332563b47b1a3587f3522d0685c324c5178a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 23:28:42 -0700 Subject: [PATCH] refactor: collapse dead SQLite dual-path code; keep migration-only readers (#2454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 ## 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. --- packages/core/src/agent-store.ts | 681 ++----- packages/core/src/approval-request-store.ts | 231 +-- packages/core/src/automation-store.ts | 188 +- packages/core/src/central-core.ts | 1732 +++-------------- packages/core/src/plugin-store.ts | 328 +--- packages/core/src/routine-store.ts | 207 +- packages/core/src/secrets-store.ts | 209 +- packages/core/src/store.ts | 76 +- packages/core/src/task-store/agent-logs.ts | 32 +- .../src/task-store/archive-lifecycle-2.ts | 189 +- .../core/src/task-store/archive-lifecycle.ts | 322 +-- packages/core/src/task-store/audit-ops.ts | 101 +- .../src/task-store/branch-and-pr-entities.ts | 1025 +++------- .../core/src/task-store/branch-group-ops.ts | 221 +-- packages/core/src/task-store/comments-ops.ts | 118 +- packages/core/src/task-store/lifecycle-ops.ts | 464 +---- .../core/src/task-store/merge-queue-ops-2.ts | 61 +- .../core/src/task-store/merge-queue-ops.ts | 109 +- packages/core/src/task-store/moves.ts | 394 +--- .../core/src/task-store/project-store-ops.ts | 627 ++---- packages/core/src/task-store/reads.ts | 855 ++------ .../core/src/task-store/settings-ops-2.ts | 229 +-- packages/core/src/task-store/settings-ops.ts | 178 +- .../core/src/task-store/task-artifacts-ops.ts | 433 +---- .../task-store/task-commit-associations.ts | 81 +- packages/core/src/task-store/task-creation.ts | 291 +-- .../core/src/task-store/task-id-integrity.ts | 294 +-- .../core/src/task-store/task-mutation-ops.ts | 988 +++------- .../core/src/task-store/task-row-mappers.ts | 36 +- .../core/src/task-store/task-store-helpers.ts | 84 +- .../core/src/task-store/update-task-deps.ts | 31 +- .../src/task-store/workflow-definitions.ts | 451 ++--- .../core/src/task-store/workflow-integrity.ts | 69 +- packages/core/src/task-store/workflow-ops.ts | 441 ++--- .../task-store/workflow-task-create-ops.ts | 580 ++---- .../task-store/workflow-workitems-ops-2.ts | 256 +-- .../src/task-store/workflow-workitems-ops.ts | 159 +- packages/engine/src/executor.ts | 1 - .../src/store/cli-press-store.ts | 556 ++---- .../src/session/session-store.ts | 38 +- .../src/sync/pipeline-store.ts | 17 +- .../src/store/report-store.ts | 21 +- 42 files changed, 2987 insertions(+), 10417 deletions(-) diff --git a/packages/core/src/agent-store.ts b/packages/core/src/agent-store.ts index 8d2e9f0440..bb613fddba 100644 --- a/packages/core/src/agent-store.ts +++ b/packages/core/src/agent-store.ts @@ -386,23 +386,8 @@ export class AgentStore extends EventEmitter { } private get db(): Database { - if (this.backendMode) { - throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); - } - if (this._db) return this._db; - - const cached = agentStoreDbCache.get(this.rootDir); - if (cached) { - this._db = cached; - return cached; - } - - const fresh = new Database(this.rootDir, { inMemory: false }); - fresh.init(); - agentStoreDbCache.set(this.rootDir, fresh); - this._db = fresh; - return fresh; - } + throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); +} /** * Initialize the store by creating necessary directories. @@ -414,16 +399,9 @@ export class AgentStore extends EventEmitter { * covers these migrations. Only create the agents directory. */ async init(): Promise { - if (this.backendMode) { - await mkdir(this.agentsDir, { recursive: true }); - return; - } - void this.db; - await mkdir(this.agentsDir, { recursive: true }); - await this.importLegacyFileDataOnce(); - await this.migrateTerminatedAgentStateOnce(); - await this.migrateHeartbeatProcedurePathOnce(); - } + await mkdir(this.agentsDir, { recursive: true }); + return; +} /** * One-shot migration that re-points every non-ephemeral agent off the @@ -556,73 +534,8 @@ export class AgentStore extends EventEmitter { to import against PostgreSQL (the PG baseline already covers this), so no-op cleanly instead of laundering a stub throw into a false success. */ - if (this.backendMode) return 0; - const entries = await readdir(this.agentsDir, { withFileTypes: true }).catch(() => []); - const runDirs = entries.filter((entry) => entry.isDirectory() && entry.name.endsWith("-runs")); - - let imported = 0; - for (const dir of runDirs) { - const agentId = dir.name.replace(/-runs$/, ""); - const runDir = join(this.agentsDir, dir.name); - const runFiles = await readdir(runDir).catch(() => [] as string[]); - - for (const file of runFiles) { - if (!file.endsWith(".json")) { - continue; - } - - try { - const content = await readFile(join(runDir, file), "utf-8"); - const run = JSON.parse(content) as Partial; - if ( - typeof run.id !== "string" || - typeof run.startedAt !== "string" || - !["active", "completed", "terminated", "failed"].includes(String(run.status)) - ) { - continue; - } - - const normalizedRun: AgentHeartbeatRun = { - id: run.id, - agentId: typeof run.agentId === "string" ? run.agentId : agentId, - startedAt: run.startedAt, - endedAt: typeof run.endedAt === "string" ? run.endedAt : null, - status: run.status as AgentHeartbeatRun["status"], - invocationSource: run.invocationSource, - triggerDetail: run.triggerDetail, - processPid: run.processPid, - exitCode: run.exitCode, - sessionIdBefore: run.sessionIdBefore, - sessionIdAfter: run.sessionIdAfter, - usageJson: run.usageJson, - resultJson: run.resultJson, - contextSnapshot: run.contextSnapshot, - stdoutExcerpt: run.stdoutExcerpt, - stderrExcerpt: run.stderrExcerpt, - }; - - const result = this.db.prepare(` - INSERT OR IGNORE INTO agentRuns (id, agentId, data, startedAt, endedAt, status) - VALUES (?, ?, ?, ?, ?, ?) - `).run( - normalizedRun.id, - normalizedRun.agentId, - JSON.stringify(normalizedRun), - normalizedRun.startedAt, - normalizedRun.endedAt, - normalizedRun.status, - ); - imported += Number(result.changes); - } catch { - // Legacy run files may be partially written or manually edited; ignore them. - } - } - } - - if (imported > 0) { - this.db.bumpLastModified(); - } - return imported; + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: legacy run-dir import is SQLite-only; no-op under PG. */ + return 0; } private async importLegacyFileDataOnce(): Promise { @@ -699,28 +612,14 @@ export class AgentStore extends EventEmitter { async findAgentByName(name: string): Promise { // FNXC:SqliteFinalRemoval 2026-06-25-23:45: // Backend mode: read via async Drizzle helper, filter ephemeral in-memory. - if (this.backendMode) { - const agents = await findAgentRowsByNameAsync(this.asyncLayer!.db, name); - for (const agent of agents) { - if (!isEphemeralAgent(agent)) { - return this.parseAgent(agent as unknown as AgentData); - } - } - return null; - } - const rows = this.db - .prepare("SELECT * FROM agents WHERE name = ? ORDER BY createdAt DESC") - .all(name) as unknown as AgentRow[]; - - for (const row of rows) { - const agent = this.mapAgentRow(row); + const agents = await findAgentRowsByNameAsync(this.asyncLayer!.db, name); + for (const agent of agents) { if (!isEphemeralAgent(agent)) { - return agent; + return this.parseAgent(agent as unknown as AgentData); } } - return null; - } +} async hasNonEphemeralAgentWithName(name: string): Promise { const normalizedName = name.trim(); @@ -830,21 +729,15 @@ export class AgentStore extends EventEmitter { * @returns The agent, or null if not found */ async getAgent(agentId: string): Promise { - // FNXC:SqliteFinalRemoval 2026-06-25-23:35: - // Backend mode: read via async Drizzle helper instead of sync readAgent. - if (this.backendMode) { - const agent = await readAgentAsync(this.asyncLayer!.db, agentId); - const parsed = agent ? this.parseAgent(agent) : null; - /* - FNXC:IncompletePgPorts 2026-07-26-20:40: - Populate getCachedAgent memory so sync heartbeat resolveAgentConfig can - honor per-agent runtimeConfig without a sync SQLite handle. - */ - if (parsed) this.agentMemoryCache.set(agentId, parsed); - else this.agentMemoryCache.delete(agentId); - return parsed; - } - return this.readAgent(agentId); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:05: + Agent reads are PostgreSQL-only via readAgentAsync. Populate getCachedAgent memory so sync heartbeat resolveAgentConfig can honor per-agent runtimeConfig without a SQLite handle. + */ + const agent = await readAgentAsync(this.asyncLayer!.db, agentId); + const parsed = agent ? this.parseAgent(agent) : null; + if (parsed) this.agentMemoryCache.set(agentId, parsed); + else this.agentMemoryCache.delete(agentId); + return parsed; } /** @@ -976,61 +869,18 @@ export class AgentStore extends EventEmitter { * Backend-mode: delegate to async Drizzle addRating helper. The score CHECK * constraint is enforced by PostgreSQL (VAL-SCHEMA-005). */ - if (this.backendMode) { - const saved = await addRatingAsync(this.asyncLayer!.db, rating); - this.emit("rating:added", saved); - return saved; - } - - this.db.prepare(` - INSERT INTO agentRatings (id, agentId, raterType, raterId, score, category, comment, runId, taskId, createdAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - rating.id, - rating.agentId, - rating.raterType, - rating.raterId ?? null, - rating.score, - rating.category ?? null, - rating.comment ?? null, - rating.runId ?? null, - rating.taskId ?? null, - rating.createdAt, - ); - - this.db.bumpLastModified(); - this.emit("rating:added", rating); - - return rating; - } + const saved = await addRatingAsync(this.asyncLayer!.db, rating); + this.emit("rating:added", saved); + return saved; +} async getRatings(agentId: string, options?: { limit?: number; category?: string }): Promise { /* * FNXC:SqliteFinalRemoval 2026-06-26-09:15: * Backend-mode: delegate to async Drizzle getRatings helper. */ - if (this.backendMode) { - return getRatingsAsync(this.asyncLayer!.db, agentId, options); - } - - const params: Array = [agentId]; - let query = "SELECT * FROM agentRatings WHERE agentId = ?"; - - if (options?.category !== undefined) { - query += " AND category = ?"; - params.push(options.category); - } - - query += " ORDER BY createdAt DESC"; - - if (options?.limit !== undefined) { - query += " LIMIT ?"; - params.push(options.limit); - } - - const rows = this.db.prepare(query).all(...params) as unknown as AgentRatingRow[]; - return rows.map((row) => this.mapRatingRow(row)); - } + return getRatingsAsync(this.asyncLayer!.db, agentId, options); +} async getRatingSummary(agentId: string): Promise { const ratings = await this.getRatings(agentId); @@ -1097,13 +947,9 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:15: * Backend-mode: delegate to async Drizzle deleteRating helper. */ - if (this.backendMode) { - await deleteRatingAsync(this.asyncLayer!.db, ratingId); - return; - } - this.db.prepare("DELETE FROM agentRatings WHERE id = ?").run(ratingId); - this.db.bumpLastModified(); - } + await deleteRatingAsync(this.asyncLayer!.db, ratingId); + return; +} /** * Get the managed instructions directory path for an agent. @@ -1982,36 +1828,14 @@ export class AgentStore extends EventEmitter { async listAgents(filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean }): Promise { // FNXC:SqliteFinalRemoval 2026-06-25-23:50: // Backend mode: read via async Drizzle helper, apply ephemeral filter in-memory. - if (this.backendMode) { - const agents = await listAgentRowsAsync(this.asyncLayer!.db, { - state: filter?.state, - role: filter?.role, - }); - return agents - .map((a) => this.parseAgent(a as unknown as AgentData)) - .filter((agent) => filter?.includeEphemeral === true || !isEphemeralAgent(agent)); - } - const clauses: string[] = []; - const params: string[] = []; - - if (filter?.state) { - clauses.push("state = ?"); - params.push(filter.state); - } - if (filter?.role) { - clauses.push("role = ?"); - params.push(filter.role); - } - - const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; - const rows = this.db - .prepare(`SELECT * FROM agents ${where} ORDER BY createdAt DESC`) - .all(...params) as unknown as AgentRow[]; - - return rows - .map((row) => this.mapAgentRow(row)) + const agents = await listAgentRowsAsync(this.asyncLayer!.db, { + state: filter?.state, + role: filter?.role, + }); + return agents + .map((a) => this.parseAgent(a as unknown as AgentData)) .filter((agent) => filter?.includeEphemeral === true || !isEphemeralAgent(agent)); - } +} /** * Create an API key for an agent. @@ -2041,15 +1865,7 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:20: * Backend-mode: delegate to async Drizzle insertApiKey helper. */ - if (this.backendMode) { - await insertApiKeyAsync(this.asyncLayer!.db, key); - } else { - this.db.prepare(` - INSERT INTO agentApiKeys (id, agentId, data, createdAt, revokedAt) - VALUES (?, ?, ?, ?, ?) - `).run(key.id, key.agentId, JSON.stringify(key), key.createdAt, key.revokedAt ?? null); - this.db.bumpLastModified(); - } + await insertApiKeyAsync(this.asyncLayer!.db, key); return { key, token }; }); @@ -2098,14 +1914,7 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:20: * Backend-mode: delegate to async Drizzle revokeApiKeyRow helper. */ - if (this.backendMode) { - await revokeApiKeyRowAsync(this.asyncLayer!.db, keyId, agentId, revoked); - } else { - this.db.prepare(` - UPDATE agentApiKeys SET data = ?, revokedAt = ? WHERE id = ? AND agentId = ? - `).run(JSON.stringify(revoked), revoked.revokedAt ?? null, keyId, agentId); - this.db.bumpLastModified(); - } + await revokeApiKeyRowAsync(this.asyncLayer!.db, keyId, agentId, revoked); return revoked; }); @@ -2140,12 +1949,8 @@ export class AgentStore extends EventEmitter { // FNXC:SqliteFinalRemoval 2026-06-25-23:55: // Backend mode: delete via async Drizzle helper (cascading FKs handle // heartbeats, runs, task sessions, API keys, config revisions, etc.). - if (this.backendMode) { - await deleteAgentAsync(this.asyncLayer!.db, agentId); - } else { - this.db.prepare("DELETE FROM agents WHERE id = ?").run(agentId); - this.db.bumpLastModified(); - } + await deleteAgentAsync(this.asyncLayer!.db, agentId); + // FN-7723: keep this instance's own change-detection snapshot in sync // with its own delete so a later poll never mistakes the row's absence // for an external delete (deletes are pruned from the cache, not @@ -2193,19 +1998,12 @@ export class AgentStore extends EventEmitter { // FNXC:SqliteFinalRemoval 2026-06-26-00:00: // Backend mode: record heartbeat via async Drizzle helper. - if (this.backendMode) { - await recordHeartbeatAsync(this.asyncLayer!.db, { - agentId, - timestamp: event.timestamp, - status: event.status, - runId: event.runId, - }); - } else { - this.db.prepare(` - INSERT INTO agentHeartbeats (agentId, timestamp, status, runId) - VALUES (?, ?, ?, ?) - `).run(agentId, event.timestamp, event.status, event.runId); - } + await recordHeartbeatAsync(this.asyncLayer!.db, { + agentId, + timestamp: event.timestamp, + status: event.status, + runId: event.runId, + }); // Update agent's lastHeartbeatAt if status is ok if (status === "ok") { @@ -2215,10 +2013,14 @@ export class AgentStore extends EventEmitter { updatedAt: event.timestamp, }; await this.writeAgent(updated); - } else if (!this.backendMode) { - this.db.bumpLastModified(); } + /* + FNXC:SqliteDualPathCleanup 2026-07-27-06:15: + Dual-path collapse left a bare `else` that made this emit the else-body, so + status==="ok" heartbeats never fired agent:heartbeat after a successful write. + Emit for every persisted heartbeat (ok and non-ok). + */ this.emit("agent:heartbeat", agentId, event); return event; @@ -2234,24 +2036,9 @@ export class AgentStore extends EventEmitter { async getHeartbeatHistory(agentId: string, limit = 50): Promise { // FNXC:SqliteFinalRemoval 2026-06-26-00:05: // Backend mode: read via async Drizzle helper. - if (this.backendMode) { - void this.backendProjectId; - return getHeartbeatHistoryAsync(this.asyncLayer!.db, agentId, limit); - } - const rows = this.db.prepare(` - SELECT timestamp, status, runId - FROM agentHeartbeats - WHERE agentId = ? - ORDER BY timestamp DESC - LIMIT ? - `).all(agentId, limit) as Array<{ timestamp: string; status: AgentHeartbeatEvent["status"]; runId: string }>; - - return rows.map((row) => ({ - timestamp: row.timestamp, - status: row.status, - runId: row.runId, - })); - } + void this.backendProjectId; + return getHeartbeatHistoryAsync(this.asyncLayer!.db, agentId, limit); +} /** * Start a new heartbeat run for an agent. @@ -2294,38 +2081,18 @@ export class AgentStore extends EventEmitter { * this.db.prepare(). The saveRun + recordHeartbeat calls below already * have backend-mode branches. */ - let agentId: string; - let existingRun: AgentHeartbeatRun; - if (this.backendMode) { - const found = await getRunByIdAsync(this.asyncLayer!.db, this.backendProjectId, runId); - if (!found) { - return; - } - agentId = found.agentId; - existingRun = found.run ?? { - id: runId, - agentId: found.agentId, - startedAt: now, - endedAt: null, - status: "active", - }; - } else { - const row = this.db.prepare("SELECT agentId, data FROM agentRuns WHERE id = ?").get(runId) as - | { agentId: string; data: string } - | undefined; - - if (!row) { - return; - } - agentId = row.agentId; - existingRun = this.parseJson(row.data, { - id: runId, - agentId: row.agentId, - startedAt: now, - endedAt: null, - status: "active", - }); + const found = await getRunByIdAsync(this.asyncLayer!.db, this.backendProjectId, runId); + if (!found) { + return; } + const agentId = found.agentId; + const existingRun: AgentHeartbeatRun = found.run ?? { + id: runId, + agentId: found.agentId, + startedAt: now, + endedAt: null, + status: "active", + }; const updatedRun: AgentHeartbeatRun = { ...existingRun, @@ -2376,18 +2143,8 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:25: * Backend-mode: delegate to async Drizzle listActiveHeartbeatRuns helper. */ - if (this.backendMode) { - return listActiveHeartbeatRunsAsync(this.asyncLayer!.db, this.backendProjectId); - } - const rows = this.db.prepare(` - SELECT data FROM agentRuns - WHERE status = 'active' - ORDER BY startedAt ASC - `).all() as Array<{ data: string }>; - return rows - .map((row) => this.parseJson(row.data, null)) - .filter((run): run is AgentHeartbeatRun => run !== null); - } + return listActiveHeartbeatRunsAsync(this.asyncLayer!.db, this.backendProjectId); +} // ───────────────────────────────────────────────────────────────────────── // Task Session Management @@ -2404,14 +2161,8 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:30: * Backend-mode: delegate to async Drizzle getTaskSession helper. */ - if (this.backendMode) { - return getTaskSessionAsync(this.asyncLayer!.db, agentId, taskId); - } - const row = this.db.prepare(` - SELECT data FROM agentTaskSessions WHERE agentId = ? AND taskId = ? - `).get(agentId, taskId) as { data: string } | undefined; - return row ? this.parseJson(row.data, null) : null; - } + return getTaskSessionAsync(this.asyncLayer!.db, agentId, taskId); +} /** * Create or update a task session for an agent. @@ -2432,18 +2183,7 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:30: * Backend-mode: delegate to async Drizzle upsertTaskSession helper. */ - if (this.backendMode) { - await upsertTaskSessionAsync(this.asyncLayer!.db, saved); - } else { - this.db.prepare(` - INSERT INTO agentTaskSessions (agentId, taskId, data, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(agentId, taskId) DO UPDATE SET - data = excluded.data, - updatedAt = excluded.updatedAt - `).run(session.agentId, session.taskId, JSON.stringify(saved), saved.createdAt, saved.updatedAt); - this.db.bumpLastModified(); - } + await upsertTaskSessionAsync(this.asyncLayer!.db, saved); return saved; } @@ -2458,13 +2198,9 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:30: * Backend-mode: delegate to async Drizzle deleteTaskSession helper. */ - if (this.backendMode) { - await deleteTaskSessionAsync(this.asyncLayer!.db, agentId, taskId); - return; - } - this.db.prepare("DELETE FROM agentTaskSessions WHERE agentId = ? AND taskId = ?").run(agentId, taskId); - this.db.bumpLastModified(); - } + await deleteTaskSessionAsync(this.asyncLayer!.db, agentId, taskId); + return; +} // ───────────────────────────────────────────────────────────────────────── // Org Hierarchy @@ -2591,22 +2327,9 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:35: * Backend-mode: delegate to async Drizzle saveRun helper. */ - if (this.backendMode) { - await saveRunAsync(this.asyncLayer!.db, this.backendProjectId, run); - return; - } - this.db.prepare(` - INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - agentId = excluded.agentId, - data = excluded.data, - startedAt = excluded.startedAt, - endedAt = excluded.endedAt, - status = excluded.status - `).run(run.id, run.agentId, JSON.stringify(run), run.startedAt, run.endedAt, run.status); - this.db.bumpLastModified(); - } + await saveRunAsync(this.asyncLayer!.db, this.backendProjectId, run); + return; +} /** * Get a specific run by ID. @@ -2619,14 +2342,8 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:35: * Backend-mode: delegate to async Drizzle getRunDetail helper. */ - if (this.backendMode) { - return getRunDetailAsync(this.asyncLayer!.db, this.backendProjectId, agentId, runId); - } - const row = this.db.prepare(` - SELECT data FROM agentRuns WHERE agentId = ? AND id = ? - `).get(agentId, runId) as { data: string } | undefined; - return row ? this.parseJson(row.data, null) : null; - } + return getRunDetailAsync(this.asyncLayer!.db, this.backendProjectId, agentId, runId); +} /** * Get recent runs for an agent from structured run storage. @@ -2639,58 +2356,16 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:35: * Backend-mode: delegate to async Drizzle getRecentRuns helper. */ - if (this.backendMode) { - return getRecentRunsAsync(this.asyncLayer!.db, this.backendProjectId, agentId, limit); - } - const rows = this.db.prepare(` - SELECT data FROM agentRuns - WHERE agentId = ? - ORDER BY startedAt DESC - LIMIT ? - `).all(agentId, limit) as Array<{ data: string }>; - return rows - .map((row) => this.parseJson(row.data, null)) - .filter((run): run is AgentHeartbeatRun => run !== null); - } + return getRecentRunsAsync(this.asyncLayer!.db, this.backendProjectId, agentId, limit); +} async getRunStatusCounts(agentIds?: readonly string[]): Promise<{ completedRuns: number; failedRuns: number }> { /* * FNXC:SqliteFinalRemoval 2026-06-26-09:35: * Backend-mode: delegate to async Drizzle getRunStatusCounts helper. */ - if (this.backendMode) { - return getRunStatusCountsAsync(this.asyncLayer!.db, this.backendProjectId, agentIds); - } - let rows: Array<{ status: string; count: number }>; - - if (agentIds && agentIds.length > 0) { - const placeholders = agentIds.map(() => "?").join(","); - rows = this.db.prepare(` - SELECT status, COUNT(*) as count - FROM agentRuns - WHERE agentId IN (${placeholders}) - GROUP BY status - `).all(...agentIds) as Array<{ status: string; count: number }>; - } else { - rows = this.db.prepare(` - SELECT status, COUNT(*) as count - FROM agentRuns - GROUP BY status - `).all() as Array<{ status: string; count: number }>; - } - - let completedRuns = 0; - let failedRuns = 0; - for (const row of rows) { - if (row.status === "completed") { - completedRuns += row.count; - } else if (row.status === "failed" || row.status === "terminated") { - failedRuns += row.count; - } - } - - return { completedRuns, failedRuns }; - } + return getRunStatusCountsAsync(this.asyncLayer!.db, this.backendProjectId, agentIds); +} // ───────────────────────────────────────────────────────────────────────── // Run-scoped log storage (JSONL files alongside run JSON in agentsDir) @@ -2762,14 +2437,8 @@ export class AgentStore extends EventEmitter { */ async getLastBlockedState(agentId: string): Promise { // FNXC:PostgresCutover 2026-07-04: delegate to async Drizzle helper in backend mode. - if (this.backendMode) { - return getLastBlockedStateAsync(this.asyncLayer!.db, agentId); - } - const row = this.db.prepare("SELECT data FROM agentBlockedStates WHERE agentId = ?").get(agentId) as - | { data: string } - | undefined; - return row ? this.parseJson(row.data, null) : null; - } + return getLastBlockedStateAsync(this.asyncLayer!.db, agentId); +} /** * Persist the latest blocked-task dedup state for an agent. @@ -2777,20 +2446,9 @@ export class AgentStore extends EventEmitter { async setLastBlockedState(agentId: string, state: BlockedStateSnapshot): Promise { await this.withLock(agentId, async () => { // FNXC:PostgresCutover 2026-07-04: delegate to async Drizzle helper in backend mode. - if (this.backendMode) { - await setLastBlockedStateAsync(this.asyncLayer!.db, agentId, state); - return; - } - const updatedAt = new Date().toISOString(); - this.db.prepare(` - INSERT INTO agentBlockedStates (agentId, data, updatedAt) - VALUES (?, ?, ?) - ON CONFLICT(agentId) DO UPDATE SET - data = excluded.data, - updatedAt = excluded.updatedAt - `).run(agentId, JSON.stringify(state), updatedAt); - this.db.bumpLastModified(); - }); + await setLastBlockedStateAsync(this.asyncLayer!.db, agentId, state); + return; +}); } /** @@ -2799,19 +2457,11 @@ export class AgentStore extends EventEmitter { async clearLastBlockedState(agentId: string): Promise { await this.withLock(agentId, async () => { // FNXC:PostgresCutover 2026-07-04: delegate to async Drizzle helper in backend mode. - if (this.backendMode) { - await clearLastBlockedStateAsync(this.asyncLayer!.db, agentId); - return; - } - this.db.prepare("DELETE FROM agentBlockedStates WHERE agentId = ?").run(agentId); - this.db.bumpLastModified(); - }); + await clearLastBlockedStateAsync(this.asyncLayer!.db, agentId); + return; +}); } - - - - // ───────────────────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────────────────── @@ -2822,31 +2472,14 @@ export class AgentStore extends EventEmitter { private async appendConfigRevision(revision: AgentConfigRevision): Promise { // FNXC:SqliteFinalRemoval 2026-06-26-00:10: backend mode async delegation. - if (this.backendMode) { - await appendConfigRevisionAsync(this.asyncLayer!.db, revision); - return; - } - this.db.prepare(` - INSERT INTO agentConfigRevisions (id, agentId, data, createdAt) - VALUES (?, ?, ?, ?) - `).run(revision.id, revision.agentId, JSON.stringify(revision), revision.createdAt); - this.db.bumpLastModified(); - } + await appendConfigRevisionAsync(this.asyncLayer!.db, revision); + return; +} private async readConfigRevisions(agentId: string): Promise { // FNXC:SqliteFinalRemoval 2026-06-26-00:10: backend mode async delegation. - if (this.backendMode) { - return readConfigRevisionsAsync(this.asyncLayer!.db, agentId); - } - const rows = this.db.prepare(` - SELECT data FROM agentConfigRevisions - WHERE agentId = ? - ORDER BY createdAt ASC - `).all(agentId) as Array<{ data: string }>; - return rows - .map((row) => this.parseJson(row.data, null)) - .filter((revision): revision is AgentConfigRevision => revision !== null); - } + return readConfigRevisionsAsync(this.asyncLayer!.db, agentId); +} private createConfigRevision(params: { agentId: string; @@ -2935,14 +2568,8 @@ export class AgentStore extends EventEmitter { private async findConfigRevisionAcrossAgents(revisionId: string): Promise { // FNXC:PostgresCutover 2026-07-04: delegate to async Drizzle helper in backend mode. - if (this.backendMode) { - return findConfigRevisionByIdAsync(this.asyncLayer!.db, revisionId); - } - const row = this.db.prepare("SELECT data FROM agentConfigRevisions WHERE id = ?").get(revisionId) as - | { data: string } - | undefined; - return row ? this.parseJson(row.data, null) : null; - } + return findConfigRevisionByIdAsync(this.asyncLayer!.db, revisionId); +} private computeNextResetAt(period: AgentBudgetConfig["budgetPeriod"], resetDay?: number): string | null { if (!period || period === "lifetime") { @@ -3142,29 +2769,17 @@ export class AgentStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-09:20: * Backend-mode: delegate to async Drizzle readApiKeys helper. */ - if (this.backendMode) { - return readApiKeysAsync(this.asyncLayer!.db, agentId); - } - const rows = this.db.prepare(` - SELECT data FROM agentApiKeys WHERE agentId = ? ORDER BY createdAt ASC - `).all(agentId) as Array<{ data: string }>; - return rows - .map((row) => this.parseJson(row.data, null)) - .filter((key): key is AgentApiKey => key !== null); - } + return readApiKeysAsync(this.asyncLayer!.db, agentId); +} - private readAgent(agentId: string): Agent | null { + private readAgent(_agentId: string): Agent | null { // SQLite sync read path. In PG backend mode there is no synchronous DB // handle, so this returns null — the async path is wired via getAgent() // (which delegates to readAgentAsync). The remaining internal sync caller // (getInstructionsDir) uses it only for non-critical path computation; // resolveCompatibleBundleDir now uses getAgent() directly. - if (this.backendMode) { - return null; - } - const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(agentId) as AgentRow | undefined; - return row ? this.mapAgentRow(row) : null; - } + return null; +} private mapAgentRow(row: AgentRow): Agent { const data = this.parseJson>(row.data, {}); @@ -3208,10 +2823,8 @@ export class AgentStore extends EventEmitter { null until the first async getAgent warms the cache (heartbeat timer path uses async getAgentConfig as authority). */ - if (this.backendMode) { - return this.agentMemoryCache.get(agentId) ?? null; - } - return this.readAgent(agentId); + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: getCachedAgent is memory/PG-only; no SQLite readAgent. */ + return this.agentMemoryCache.get(agentId) ?? null; } private parseAgent(data: AgentData): Agent { @@ -3248,80 +2861,9 @@ export class AgentStore extends EventEmitter { private async writeAgent(agent: Agent): Promise { // FNXC:SqliteFinalRemoval 2026-06-25-23:40: // Backend mode: delegate to async Drizzle writeAgent helper. - if (this.backendMode) { - await writeAgentAsync(this.asyncLayer!.db, agent, this.asyncLayer!.projectId); - return; - } - const data: AgentData = { - id: agent.id, - name: agent.name, - role: agent.role, - state: agent.state, - taskId: agent.taskId, - createdAt: agent.createdAt, - updatedAt: agent.updatedAt, - lastHeartbeatAt: agent.lastHeartbeatAt, - metadata: agent.metadata, - title: agent.title, - icon: agent.icon, - imageUrl: agent.imageUrl, - reportsTo: agent.reportsTo, - runtimeConfig: agent.runtimeConfig, - pauseReason: agent.pauseReason, - permissions: agent.permissions, - permissionPolicy: agent.permissionPolicy, - totalInputTokens: agent.totalInputTokens, - totalOutputTokens: agent.totalOutputTokens, - lastError: agent.lastError, - instructionsPath: agent.instructionsPath, - instructionsText: agent.instructionsText, - soul: agent.soul, - memory: agent.memory, - bundleConfig: agent.bundleConfig, - heartbeatProcedurePath: agent.heartbeatProcedurePath, - }; - - this.db.prepare(` - INSERT INTO agents ( - id, name, role, state, taskId, createdAt, updatedAt, lastHeartbeatAt, metadata, data - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - role = excluded.role, - state = excluded.state, - taskId = excluded.taskId, - updatedAt = excluded.updatedAt, - lastHeartbeatAt = excluded.lastHeartbeatAt, - metadata = excluded.metadata, - data = excluded.data - `).run( - agent.id, - agent.name, - agent.role, - agent.state, - agent.taskId ?? null, - agent.createdAt, - agent.updatedAt, - agent.lastHeartbeatAt ?? null, - JSON.stringify(agent.metadata ?? {}), - JSON.stringify(data), - ); - this.db.bumpLastModified(); - - /* - * FNXC:AgentStore 2026-07-09-08:15: - * FN-7723 — update this instance's own change-detection snapshot cache - * synchronously with every in-process write (createAgent/updateAgent/ - * updateAgentState/etc. all funnel through writeAgent()). This is the - * self-write suppression mechanism: when this instance's own poll tick - * later diffs against the cache, its own write is already reflected, so - * it is never mistaken for an external change and never double-emitted. - * Only meaningful once startWatching() has populated the cache; a no-op - * write to a Map before watching starts is negligible cost. - */ - this.agentSnapshotCache.set(agent.id, this.watchSnapshotOf(agent)); - } + await writeAgentAsync(this.asyncLayer!.db, agent, this.asyncLayer!.projectId); + return; +} /** * FN-7723: build the change-detection snapshot for an agent. @@ -3407,12 +2949,7 @@ export class AgentStore extends EventEmitter { if (this.pollingInProgress) return; this.pollingInProgress = true; try { - if (!this.backendMode) { - // sqlite-only cheap gate; backend mode diffs every tick (see startWatching). - const currentModified = this.db.getLastModified(); - if (currentModified <= this.lastKnownModified) return; - this.lastKnownModified = currentModified; - } + const agents = await this.listAgents({ includeEphemeral: true }); const seenIds = new Set(); diff --git a/packages/core/src/approval-request-store.ts b/packages/core/src/approval-request-store.ts index ac6d813511..6ac8f812d7 100644 --- a/packages/core/src/approval-request-store.ts +++ b/packages/core/src/approval-request-store.ts @@ -1,12 +1,11 @@ import { randomUUID } from "node:crypto"; import { count, eq, desc, and } from "drizzle-orm"; import type { Database } from "./db.js"; -import { fromJson, toJsonNullable } from "./db.js"; +import { fromJson } from "./db.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; import * as asyncApprovalRequestStore from "./async-approval-request-store.js"; import * as schema from "./postgres/schema/index.js"; import { - isValidApprovalRequestTransition, normalizeApprovalRequestActionCategory, type ApprovalRequest, type ApprovalRequestActorSnapshot, @@ -87,11 +86,14 @@ export class ApprovalRequestStore { /* FNXC:ApprovalRedemption 2026-07-26-16:40: In backend (PostgreSQL) mode `targetContext` is a jsonb column that Drizzle - returns ALREADY PARSED, while the sync SQLite path stores a JSON string. + returns ALREADY PARSED, while legacy rows may still store a JSON string. Feeding the parsed object through the string-only `fromJson` made `findLatestByDedupeKey` never match in PG mode, so every gate retry minted a duplicate approval request and approved-grant reuse silently never worked in production. Normalize both shapes here. + + FNXC:SqliteDualPathCleanup 2026-07-26-15:05: + Same helper used on the PG-only runtime path after dual-path collapse. */ private static normalizeTargetContext(value: unknown): Record | undefined { if (value === null || value === undefined) return undefined; @@ -178,134 +180,42 @@ export class ApprovalRequestStore { } async create(input: ApprovalRequestCreateInput): Promise { - const now = new Date().toISOString(); - const request: ApprovalRequest = { - id: `apr-${randomUUID().slice(0, 8)}`, - status: "pending", - requester: input.requester, - targetAction: { - ...input.targetAction, - category: normalizeApprovalRequestActionCategory(input.targetAction.category), - }, - taskId: input.taskId, - runId: input.runId, - requestedAt: now, - createdAt: now, - updatedAt: now, - }; - - if (this.backendMode) { - const id = `apr-${randomUUID().slice(0, 8)}`; - return asyncApprovalRequestStore.createApprovalRequest(this.asyncLayer!, { ...input, id }); - } - - this.syncDb().transaction(() => { - this.syncDb().prepare(` - INSERT INTO approval_requests ( - id, status, - requesterActorId, requesterActorType, requesterActorName, - targetActionCategory, targetActionOperation, targetActionSummary, - targetResourceType, targetResourceId, targetContext, - taskId, runId, - requestedAt, decidedAt, completedAt, - createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - request.id, - request.status, - request.requester.actorId, - request.requester.actorType, - request.requester.actorName, - request.targetAction.category, - request.targetAction.action, - request.targetAction.summary, - request.targetAction.resourceType, - request.targetAction.resourceId, - toJsonNullable(request.targetAction.context), - request.taskId ?? null, - request.runId ?? null, - request.requestedAt, - null, - null, - request.createdAt, - request.updatedAt, - ); - this.appendAuditEvent(request.id, "created", input.requester, now); - }); - - this.syncDb().bumpLastModified(); - return request; - } + /* + FNXC:SqliteDualPathCleanup 2026-07-27-06:15: + Dual-path collapse left a local ApprovalRequest construction that was discarded + while a second random id was generated for the PG insert. Use one id and let + createApprovalRequest materialize the full row. + */ + const id = `apr-${randomUUID().slice(0, 8)}`; + return asyncApprovalRequestStore.createApprovalRequest(this.asyncLayer!, { ...input, id }); +} async get(id: string): Promise { - if (this.backendMode) { - return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id); - } - const row = this.syncDb().prepare(`SELECT * FROM approval_requests WHERE id = ?`).get(id) as ApprovalRequestRow | undefined; - return row ? this.rowToRequest(row) : null; - } + return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id); +} async list(input: ApprovalRequestListInput = {}): Promise { - if (this.backendMode) { - return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input); - } - const where: string[] = []; - const params: Array = []; - - if (input.status) { - where.push("status = ?"); - params.push(input.status); - } - if (input.requesterActorId) { - where.push("requesterActorId = ?"); - params.push(input.requesterActorId); - } - if (input.taskId) { - where.push("taskId = ?"); - params.push(input.taskId); - } - if (input.runId) { - where.push("runId = ?"); - params.push(input.runId); - } - - const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; - const limit = input.limit ?? 100; - const offset = input.offset ?? 0; - const rows = this.syncDb().prepare(` - SELECT * FROM approval_requests - ${whereSql} - ORDER BY createdAt DESC, id DESC - LIMIT ? OFFSET ? - `).all(...params, limit, offset) as ApprovalRequestRow[]; - - return rows.map((row) => this.rowToRequest(row)); - } + return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input); +} async getPendingCountsByActor(): Promise> { - if (this.backendMode) { - const table = schema.project.approvalRequests; - const rows = await this.asyncLayer!.db - .select({ - actorId: table.requesterActorId, - requestCount: count(), - }) - .from(table) - .where(eq(table.status, "pending")) - .groupBy(table.requesterActorId); - return new Map(rows.map((row) => [row.actorId, Number(row.requestCount)])); - } - const rows = this.syncDb().prepare(` - SELECT requesterActorId AS actorId, COUNT(*) AS requestCount - FROM approval_requests - WHERE status = 'pending' - GROUP BY requesterActorId - `).all() as Array<{ actorId: string; requestCount: number }>; - + const table = schema.project.approvalRequests; + const rows = await this.asyncLayer!.db + .select({ + actorId: table.requesterActorId, + requestCount: count(), + }) + .from(table) + .where(eq(table.status, "pending")) + .groupBy(table.requesterActorId); return new Map(rows.map((row) => [row.actorId, Number(row.requestCount)])); - } +} async findLatestByDedupeKey(input: { requesterActorId: string; taskId?: string; dedupeKey: string }): Promise { + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:05: + Production path is PostgreSQL-only. When asyncLayer is absent (unit tests that inject a fake prepare/all Database), fall through to the sync scan so shape-independence tests still drive the real public method. + */ if (this.backendMode) { const table = schema.project.approvalRequests; const conditions = [eq(table.requesterActorId, input.requesterActorId)]; @@ -329,7 +239,6 @@ export class ApprovalRequestStore { const where = ["requesterActorId = ?"]; const params: Array = [input.requesterActorId]; - if (input.taskId !== undefined) { where.push("taskId = ?"); params.push(input.taskId); @@ -347,80 +256,18 @@ export class ApprovalRequestStore { return this.rowToRequest(row); } } - return null; - } +} async decide(requestId: string, status: "approved" | "denied", input: ApprovalRequestDecisionInput): Promise { - if (this.backendMode) { - return asyncApprovalRequestStore.decideApprovalRequest(this.asyncLayer!, requestId, status, input); - } - const existing = await this.get(requestId); - if (!existing) { - throw new Error(`Approval request ${requestId} not found`); - } - if (!isValidApprovalRequestTransition(existing.status, status)) { - throw new Error(`Invalid approval request transition: ${existing.status} -> ${status}`); - } - - const now = new Date().toISOString(); - this.syncDb().transaction(() => { - this.syncDb().prepare(` - UPDATE approval_requests - SET status = ?, decidedAt = ?, updatedAt = ? - WHERE id = ? - `).run(status, now, now, requestId); - this.appendAuditEvent(requestId, status, input.actor, now, input.note); - }); - - this.syncDb().bumpLastModified(); - const updated = await this.get(requestId); - if (!updated) { - throw new Error(`Approval request ${requestId} not found after update`); - } - return updated; - } + return asyncApprovalRequestStore.decideApprovalRequest(this.asyncLayer!, requestId, status, input); +} async markCompleted(requestId: string, input: ApprovalRequestCompletionInput): Promise { - if (this.backendMode) { - return asyncApprovalRequestStore.markApprovalRequestCompleted(this.asyncLayer!, requestId, input); - } - const existing = await this.get(requestId); - if (!existing) { - throw new Error(`Approval request ${requestId} not found`); - } - if (!isValidApprovalRequestTransition(existing.status, "completed")) { - throw new Error(`Invalid approval request transition: ${existing.status} -> completed`); - } - - const now = new Date().toISOString(); - this.syncDb().transaction(() => { - this.syncDb().prepare(` - UPDATE approval_requests - SET status = 'completed', completedAt = ?, updatedAt = ? - WHERE id = ? - `).run(now, now, requestId); - this.appendAuditEvent(requestId, "completed", input.actor, now, input.note); - }); - - this.syncDb().bumpLastModified(); - const updated = await this.get(requestId); - if (!updated) { - throw new Error(`Approval request ${requestId} not found after completion`); - } - return updated; - } + return asyncApprovalRequestStore.markApprovalRequestCompleted(this.asyncLayer!, requestId, input); +} async getAuditHistory(requestId: string): Promise { - if (this.backendMode) { - return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId); - } - const rows = this.syncDb().prepare(` - SELECT * FROM approval_request_audit_events - WHERE requestId = ? - ORDER BY createdAt ASC, rowid ASC - `).all(requestId) as ApprovalRequestAuditEventRow[]; - - return rows.map((row) => this.rowToAuditEvent(row)); - } + return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId); +} } diff --git a/packages/core/src/automation-store.ts b/packages/core/src/automation-store.ts index d6bf989ce1..1e16ba8aee 100644 --- a/packages/core/src/automation-store.ts +++ b/packages/core/src/automation-store.ts @@ -1,5 +1,4 @@ import { EventEmitter } from "node:events"; -import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { CronExpressionParser } from "cron-parser"; import type { @@ -122,16 +121,8 @@ export class AutomationStore extends EventEmitter { * file under backend mode. */ private get db(): Database { - if (this.backendMode) { - throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); - } - if (!this._db) { - const fusionDir = join(this.rootDir, ".fusion"); - this._db = new Database(fusionDir); - this._db.init(); - } - return this._db; - } + throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); +} /** * Initialize the store. @@ -142,9 +133,7 @@ export class AutomationStore extends EventEmitter { * no SQLite file to open or one-shot migration to run. */ async init(): Promise { - if (this.backendMode) return; - // Ensure DB is initialized - const _ = this.db; + /* FNXC:SqliteDualPathCleanup 2026-07-26-13:55: PostgreSQL schema is applied at startup; no SQLite open. */ } // ── Row Conversion ───────────────────────────────────────────────── @@ -226,24 +215,13 @@ export class AutomationStore extends EventEmitter { // ── Persistence ──────────────────────────────────────────────────── private async readScheduleJson(id: string): Promise { - if (this.backendMode) { - return getScheduleAsync(this.asyncLayer!, id); - } - const row = this.db.prepare('SELECT * FROM automations WHERE id = ?').get(id) as unknown as ScheduleRow | undefined; - if (!row) { - throw Object.assign(new Error(`Schedule '${id}' not found`), { code: "ENOENT" }); - } - return this.rowToSchedule(row); - } + return getScheduleAsync(this.asyncLayer!, id); +} private async persistSchedule(schedule: ScheduledTask): Promise { - if (this.backendMode) { - await upsertScheduleAsync(this.asyncLayer!, schedule); - return; - } - this.upsertSchedule(schedule); - this.db.bumpLastModified(); - } + await upsertScheduleAsync(this.asyncLayer!, schedule); + return; +} // ── Cron Computation ─────────────────────────────────────────────── @@ -334,15 +312,12 @@ export class AutomationStore extends EventEmitter { FN-8282 treats automation definitions as versioned configuration. Create and its immutable before/after snapshot share one backend transaction. */ - if (this.backendMode) { - await this.asyncLayer!.transactionImmediate(async (tx) => { - await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); - const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before: null, after: schedule, changedBy }); - if (revision) await appendConfigurationRevision(tx, revision); - }); - } else { - await this.persistSchedule(schedule); - } + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before: null, after: schedule, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + this.emit("schedule:created", schedule); return schedule; } @@ -367,19 +342,16 @@ export class AutomationStore extends EventEmitter { } async getSchedule(id: string): Promise { - if (this.backendMode) { - return getScheduleAsync(this.asyncLayer!, id); - } - return this.readScheduleJson(id); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:05: + Schedule reads are PostgreSQL-only; the SQLite readScheduleJson arm is deleted. + */ + return getScheduleAsync(this.asyncLayer!, id); } async listSchedules(): Promise { - if (this.backendMode) { - return listSchedulesAsync(this.asyncLayer!); - } - const rows = this.db.prepare('SELECT * FROM automations ORDER BY createdAt ASC').all() as unknown as ScheduleRow[]; - return rows.map((row) => this.rowToSchedule(row)); - } + return listSchedulesAsync(this.asyncLayer!); +} async updateSchedule(id: string, updates: ScheduledTaskUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { this.requireVersionedConfigurationBackend(); @@ -450,15 +422,12 @@ export class AutomationStore extends EventEmitter { } schedule.updatedAt = new Date().toISOString(); - if (this.backendMode) { - await this.asyncLayer!.transactionImmediate(async (tx) => { - await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); - const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before, after: schedule, changedBy }); - if (revision) await appendConfigurationRevision(tx, revision); - }); - } else { - await this.persistSchedule(schedule); - } + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before, after: schedule, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + this.emit("schedule:updated", schedule); return schedule; }); @@ -494,15 +463,12 @@ export class AutomationStore extends EventEmitter { schedule.steps = reordered; schedule.updatedAt = new Date().toISOString(); - if (this.backendMode) { - await this.asyncLayer!.transactionImmediate(async (tx) => { - await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); - const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before, after: schedule, changedBy }); - if (revision) await appendConfigurationRevision(tx, revision); - }); - } else { - await this.persistSchedule(schedule); - } + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before, after: schedule, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + this.emit("schedule:updated", schedule); return schedule; }); @@ -512,17 +478,12 @@ export class AutomationStore extends EventEmitter { this.requireVersionedConfigurationBackend(); return this.withScheduleLock(id, async () => { const schedule = await this.getSchedule(id); - if (this.backendMode) { - await this.asyncLayer!.transactionImmediate(async (tx) => { - await deleteScheduleAsync({ ...this.asyncLayer!, db: tx }, id); - const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before: schedule, after: null, changedBy }); - if (revision) await appendConfigurationRevision(tx, revision); - }); - } else { - // Delete from SQLite - this.db.prepare('DELETE FROM automations WHERE id = ?').run(id); - this.db.bumpLastModified(); - } + await this.asyncLayer!.transactionImmediate(async (tx) => { + await deleteScheduleAsync({ ...this.asyncLayer!, db: tx }, id); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before: schedule, after: null, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + this.emit("schedule:deleted", schedule); return schedule; }); @@ -539,44 +500,19 @@ export class AutomationStore extends EventEmitter { */ async claimDueSchedule(id: string, expectedNextRunAt: string): Promise { return this.withScheduleLock(id, async () => { - if (this.backendMode) { - const schedule = await getScheduleAsync(this.asyncLayer!, id).catch((error: unknown) => { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - throw error; - }); - if (!schedule?.enabled || !schedule.nextRunAt) return false; - return claimDueScheduleAsync( - this.asyncLayer!, - id, - expectedNextRunAt, - this.computeNextRun(schedule.cronExpression), - new Date().toISOString(), - ); - } - - const row = this.db.prepare( - 'SELECT id, cronExpression, enabled, nextRunAt FROM automations WHERE id = ?', - ).get(id) as unknown as Pick | undefined; - - if (!row || row.enabled !== 1 || !row.nextRunAt) { - return false; - } - - const nextRunAt = this.computeNextRun(row.cronExpression); - const updatedAt = new Date().toISOString(); - const result = this.db.prepare(` - UPDATE automations - SET nextRunAt = ?, updatedAt = ? - WHERE id = ? AND enabled = 1 AND nextRunAt = ? - `).run(nextRunAt, updatedAt, id, expectedNextRunAt); - - const changes = typeof result.changes === "bigint" ? Number(result.changes) : result.changes; - if (changes === 1) { - this.db.bumpLastModified(); - return true; - } - return false; - }); + const schedule = await getScheduleAsync(this.asyncLayer!, id).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }); + if (!schedule?.enabled || !schedule.nextRunAt) return false; + return claimDueScheduleAsync( + this.asyncLayer!, + id, + expectedNextRunAt, + this.computeNextRun(schedule.cronExpression), + new Date().toISOString(), + ); +}); } /** @@ -615,14 +551,8 @@ export class AutomationStore extends EventEmitter { */ async getDueSchedules(scope: "global" | "project"): Promise { const now = new Date().toISOString(); - if (this.backendMode) { - return getDueSchedulesAsync(this.asyncLayer!, now, scope); - } - const rows = this.db.prepare( - 'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ? AND scope = ?' - ).all(now, scope) as unknown as ScheduleRow[]; - return rows.map((row) => this.rowToSchedule(row)); - } + return getDueSchedulesAsync(this.asyncLayer!, now, scope); +} /** * Get all schedules that are due to run (nextRunAt <= now and enabled) for both scopes. @@ -630,12 +560,6 @@ export class AutomationStore extends EventEmitter { */ async getDueSchedulesAllScopes(): Promise { const now = new Date().toISOString(); - if (this.backendMode) { - return getDueSchedulesAsync(this.asyncLayer!, now); - } - const rows = this.db.prepare( - 'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?' - ).all(now) as unknown as ScheduleRow[]; - return rows.map((row) => this.rowToSchedule(row)); - } + return getDueSchedulesAsync(this.asyncLayer!, now); +} } diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 0442407b6c..16a4dc847b 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -34,7 +34,7 @@ import { EventEmitter } from "node:events"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, statSync } from "node:fs"; import { mkdir } from "node:fs/promises"; -import { isAbsolute, join, basename, resolve } from "node:path"; +import { isAbsolute, basename, resolve } from "node:path"; import type { RegisteredProject, ProjectHealth, @@ -84,7 +84,7 @@ import type { } from "./types.js"; import { getAppVersion, parseSemver } from "./app-version.js"; import { validateDockerNodeConfig } from "./types.js"; -import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js"; +import { CentralDatabase, fromJson } from "./central-db.js"; import { resolveGlobalDir } from "./global-settings.js"; import { stripMovedSettingsKeys } from "./moved-settings.js"; import { NodeConnection } from "./node-connection.js"; @@ -322,11 +322,17 @@ export class CentralCore extends EventEmitter { async init(): Promise { if (this.initialized) return; - if (this.backendMode) { - await asyncCentralCore.ensureBackendBootstrap(this.asyncLayer!); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:15: + CentralCore.init is PostgreSQL-only. When no asyncLayer is attached yet, mark initialized without opening SQLite; attachBackendLayer bootstraps PG later. + */ + if (this.asyncLayer) { + await asyncCentralCore.ensureBackendBootstrap(this.asyncLayer); this.initialized = true; return; } + this.initialized = true; + return; /* * FNXC:CentralPostgresCutover 2026-07-14-17:14: @@ -419,51 +425,12 @@ export class CentralCore extends EventEmitter { } } - private insertProjectRow(project: RegisteredProject, now: string): void { - if (this.backendMode) { - // FNXC:CentralCore 2026-06-26-12:30: Backend mode delegates to the async - // helper. Callers route through the backend-mode branches of - // registerProject/reattachProject which await this via insertProjectRowAsync. - throw new Error("insertProjectRow(sync) must not be called in backend mode"); - } - this.db!.transaction(() => { - this.db!.prepare( - `INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt, lastActivityAt, nodeId, settings) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - project.id, - project.name, - project.path, - project.status, - project.isolationMode, - project.createdAt, - project.updatedAt, - project.lastActivityAt ?? null, - project.nodeId ?? null, - toJsonNullable(project.settings) - ); - - const localNode = this.db! - .prepare("SELECT id FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1") - .get() as { id: string } | undefined; - if (localNode) { - this.db! - .prepare( - `INSERT INTO projectNodePathMappings (projectId, nodeId, path, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(projectId, nodeId) DO UPDATE SET - path = excluded.path, - updatedAt = excluded.updatedAt` - ) - .run(project.id, localNode.id, project.path, now, now); - } - - this.db!.prepare( - `INSERT INTO projectHealth (projectId, status, updatedAt, totalTasksCompleted, totalTasksFailed) - VALUES (?, ?, ?, 0, 0)` - ).run(project.id, project.status, now); - }); - } + private insertProjectRow(_project: RegisteredProject, _now: string): void { + // FNXC:CentralCore 2026-06-26-12:30: Backend mode delegates to the async + // helper. Callers route through the backend-mode branches of + // registerProject/reattachProject which await this via insertProjectRowAsync. + throw new Error("insertProjectRow(sync) must not be called in backend mode"); +} /** * Register a new project in the central database. @@ -502,18 +469,11 @@ export class CentralCore extends EventEmitter { settings: input.settings, }; - if (this.backendMode) { - // FNXC:CentralCore 2026-06-26-12:30: Backend mode delegates to PostgreSQL. - await asyncCentralCore.insertProjectRow(this.asyncLayer!, project, now); - this.emit("project:registered", project); - return project; - } - - this.insertProjectRow(project, now); - this.db!.bumpLastModified(); + // FNXC:CentralCore 2026-06-26-12:30: Backend mode delegates to PostgreSQL. + await asyncCentralCore.insertProjectRow(this.asyncLayer!, project, now); this.emit("project:registered", project); return project; - } +} async reattachProject(input: { id: string; @@ -560,22 +520,14 @@ export class CentralCore extends EventEmitter { settings: input.settings, }; - if (this.backendMode) { - // FNXC:CentralCore 2026-06-26-12:30: Backend mode delegates to PostgreSQL. - await asyncCentralCore.insertProjectRow(this.asyncLayer!, project, now); - severityAuditLog.log( - `[central] reattached project ${project.id} at ${project.path} using stored identity (createdAt=${now})`, - ); - this.emit("project:reattached", project, "identity-recovered"); - return project; - } - - this.insertProjectRow(project, now); - this.db!.bumpLastModified(); - severityAuditLog.debug(`[central] local reattached project ${project.id} at ${project.path} using stored identity (createdAt=${now})`); + // FNXC:CentralCore 2026-06-26-12:30: Backend mode delegates to PostgreSQL. + await asyncCentralCore.insertProjectRow(this.asyncLayer!, project, now); + severityAuditLog.log( + `[central] reattached project ${project.id} at ${project.path} using stored identity (createdAt=${now})`, + ); this.emit("project:reattached", project, "identity-recovered"); return project; - } +} async ensureProjectForPath(input: EnsureProjectForPathInput): Promise { this.ensureInitialized(); @@ -644,18 +596,10 @@ export class CentralCore extends EventEmitter { return; // Idempotent } - if (this.backendMode) { - await asyncCentralCore.deleteProject(this.backendHandle, id); - this.emit("project:unregistered", id); - return; - } - - // Delete will cascade to health and activity log - this.db!.prepare("DELETE FROM projects WHERE id = ?").run(id); - this.db!.bumpLastModified(); - + await asyncCentralCore.deleteProject(this.backendHandle, id); this.emit("project:unregistered", id); - } + return; +} /** * Get a registered project by ID. @@ -666,31 +610,8 @@ export class CentralCore extends EventEmitter { async getProject(id: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getProject(this.backendHandle, id); - } - - if (!this.syncDbAvailable) return undefined; - - const row = this.db!.prepare("SELECT * FROM projects WHERE id = ?").get(id) as - | { - id: string; - name: string; - path: string; - status: string; - isolationMode: string; - createdAt: string; - updatedAt: string; - lastActivityAt: string | null; - nodeId: string | null; - settings: string | null; - } - | undefined; - - if (!row) return undefined; - - return this.rowToProject(row); - } + return asyncCentralCore.getProject(this.backendHandle, id); +} /** * Get a registered project by path. @@ -701,31 +622,8 @@ export class CentralCore extends EventEmitter { async getProjectByPath(path: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getProjectByPath(this.backendHandle, path); - } - - if (!this.syncDbAvailable) return undefined; - - const row = this.db!.prepare("SELECT * FROM projects WHERE path = ?").get(path) as - | { - id: string; - name: string; - path: string; - status: string; - isolationMode: string; - createdAt: string; - updatedAt: string; - lastActivityAt: string | null; - nodeId: string | null; - settings: string | null; - } - | undefined; - - if (!row) return undefined; - - return this.rowToProject(row); - } + return asyncCentralCore.getProjectByPath(this.backendHandle, path); +} /** * List all registered projects. @@ -735,27 +633,8 @@ export class CentralCore extends EventEmitter { async listProjects(): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.listProjects(this.backendHandle); - } - - if (!this.syncDbAvailable) return []; - - const rows = this.db!.prepare("SELECT * FROM projects ORDER BY name").all() as Array<{ - id: string; - name: string; - path: string; - status: string; - isolationMode: string; - createdAt: string; - updatedAt: string; - lastActivityAt: string | null; - nodeId: string | null; - settings: string | null; - }>; - - return rows.map((row) => this.rowToProject(row)); - } + return asyncCentralCore.listProjects(this.backendHandle); +} /** * Update a registered project's metadata. @@ -785,59 +664,10 @@ export class CentralCore extends EventEmitter { updatedAt: now, }; - if (this.backendMode) { - await asyncCentralCore.updateProject(this.asyncLayer!, id, updated, project.path); - this.emit("project:updated", updated); - return updated; - } - - this.db!.transaction(() => { - this.db!.prepare( - `UPDATE projects SET - name = ?, - path = ?, - status = ?, - isolationMode = ?, - updatedAt = ?, - lastActivityAt = ?, - nodeId = ?, - settings = ? - WHERE id = ?` - ).run( - updated.name, - updated.path, - updated.status, - updated.isolationMode, - updated.updatedAt, - updated.lastActivityAt ?? null, - updated.nodeId ?? null, - toJsonNullable(updated.settings), - id - ); - - if (updated.path !== project.path) { - const localNode = this.db! - .prepare("SELECT id FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1") - .get() as { id: string } | undefined; - - if (localNode) { - this.db! - .prepare( - `INSERT INTO projectNodePathMappings (projectId, nodeId, path, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(projectId, nodeId) DO UPDATE SET - path = excluded.path, - updatedAt = excluded.updatedAt` - ) - .run(id, localNode.id, updated.path, now, now); - } - } - }); - - this.db!.bumpLastModified(); + await asyncCentralCore.updateProject(this.asyncLayer!, id, updated, project.path); this.emit("project:updated", updated); return updated; - } +} async transitionProjectIsolation( projectId: string, @@ -873,7 +703,6 @@ export class CentralCore extends EventEmitter { return { ok: true }; } - /** * Reconcile stale project statuses. * @@ -891,41 +720,8 @@ export class CentralCore extends EventEmitter { async reconcileProjectStatuses(): Promise> { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.reconcileStaleProjectStatuses(this.asyncLayer!); - } - - const staleProjects = this.db!.prepare( - "SELECT id, status FROM projects WHERE status = ?" - ).all("initializing") as Array<{ id: string; status: string }>; - - if (staleProjects.length === 0) return []; - - const now = new Date().toISOString(); - const reconciled: Array<{ projectId: string; previousStatus: string }> = []; - - this.db!.transaction(() => { - for (const project of staleProjects) { - // Update projects table - this.db!.prepare( - `UPDATE projects SET status = ?, updatedAt = ? WHERE id = ?` - ).run("active", now, project.id); - - // Update projectHealth table (if row exists) - this.db!.prepare( - `UPDATE projectHealth SET status = ?, updatedAt = ? WHERE projectId = ?` - ).run("active", now, project.id); - - reconciled.push({ projectId: project.id, previousStatus: project.status }); - } - }); - - if (reconciled.length > 0) { - this.db!.bumpLastModified(); - } - - return reconciled; - } + return asyncCentralCore.reconcileStaleProjectStatuses(this.asyncLayer!); +} // ── Node Registry API ─────────────────────────────────────────────────── @@ -999,33 +795,10 @@ export class CentralCore extends EventEmitter { updatedAt: now, }; - if (this.backendMode) { - await asyncCentralCore.insertNode(this.backendHandle, node); - this.emit("node:registered", node); - return node; - } - - this.db!.prepare( - `INSERT INTO nodes (id, name, type, url, apiKey, status, capabilities, dockerConfig, maxConcurrent, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - node.id, - node.name, - node.type, - node.url ?? null, - node.apiKey ?? null, - node.status, - toJsonNullable(node.capabilities), - toJsonNullable(node.dockerConfig), - node.maxConcurrent, - node.createdAt, - node.updatedAt - ); - - this.db!.bumpLastModified(); + await asyncCentralCore.insertNode(this.backendHandle, node); this.emit("node:registered", node); return node; - } +} /** * Register a remote peer node from gossip exchange. @@ -1068,32 +841,10 @@ export class CentralCore extends EventEmitter { updatedAt: now, }; - if (this.backendMode) { - await asyncCentralCore.insertGossipPeer(this.backendHandle, node); - this.emit("node:registered", node); - return node; - } - - this.db!.prepare( - `INSERT INTO nodes (id, name, type, url, status, capabilities, systemMetrics, maxConcurrent, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - node.id, - node.name, - node.type, - node.url ?? null, - node.status, - toJsonNullable(node.capabilities), - toJsonNullable(node.systemMetrics), - node.maxConcurrent, - node.createdAt, - node.updatedAt - ); - - this.db!.bumpLastModified(); + await asyncCentralCore.insertGossipPeer(this.backendHandle, node); this.emit("node:registered", node); return node; - } +} /** * Unregister a runtime node. @@ -1112,21 +863,11 @@ export class CentralCore extends EventEmitter { const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.clearProjectNodeAssignments(this.backendHandle, id, now); - await asyncCentralCore.deleteNode(this.backendHandle, id); - this.emit("node:unregistered", id); - return; - } - - this.db!.transaction(() => { - this.db!.prepare("UPDATE projects SET nodeId = NULL, updatedAt = ? WHERE nodeId = ?").run(now, id); - this.db!.prepare("DELETE FROM nodes WHERE id = ?").run(id); - }); - - this.db!.bumpLastModified(); + await asyncCentralCore.clearProjectNodeAssignments(this.backendHandle, id, now); + await asyncCentralCore.deleteNode(this.backendHandle, id); this.emit("node:unregistered", id); - } + return; +} /** * Get a node by ID. @@ -1134,33 +875,8 @@ export class CentralCore extends EventEmitter { async getNode(id: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getNode(this.backendHandle, id); - } - - const row = this.db!.prepare("SELECT * FROM nodes WHERE id = ?").get(id) as - | { - id: string; - name: string; - type: string; - url: string | null; - apiKey: string | null; - status: string; - capabilities: string | null; - systemMetrics: string | null; - knownPeers: string | null; - versionInfo: string | null; - pluginVersions: string | null; - dockerConfig: string | null; - maxConcurrent: number; - createdAt: string; - updatedAt: string; - } - | undefined; - - if (!row) return undefined; - return this.rowToNode(row); - } + return asyncCentralCore.getNode(this.backendHandle, id); +} /** * Get a node by unique name. @@ -1168,33 +884,8 @@ export class CentralCore extends EventEmitter { async getNodeByName(name: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getNodeByName(this.backendHandle, name); - } - - const row = this.db!.prepare("SELECT * FROM nodes WHERE name = ?").get(name) as - | { - id: string; - name: string; - type: string; - url: string | null; - apiKey: string | null; - status: string; - capabilities: string | null; - systemMetrics: string | null; - knownPeers: string | null; - versionInfo: string | null; - pluginVersions: string | null; - dockerConfig: string | null; - maxConcurrent: number; - createdAt: string; - updatedAt: string; - } - | undefined; - - if (!row) return undefined; - return this.rowToNode(row); - } + return asyncCentralCore.getNodeByName(this.backendHandle, name); +} /** * List all nodes ordered by name. @@ -1202,32 +893,8 @@ export class CentralCore extends EventEmitter { async listNodes(): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.listNodes(this.backendHandle); - } - - if (!this.syncDbAvailable) return []; - - const rows = this.db!.prepare("SELECT * FROM nodes ORDER BY name").all() as Array<{ - id: string; - name: string; - type: string; - url: string | null; - apiKey: string | null; - status: string; - capabilities: string | null; - systemMetrics: string | null; - knownPeers: string | null; - versionInfo: string | null; - pluginVersions: string | null; - dockerConfig: string | null; - maxConcurrent: number; - createdAt: string; - updatedAt: string; - }>; - - return rows.map((row) => this.rowToNode(row)); - } + return asyncCentralCore.listNodes(this.backendHandle); +} /** * Update node metadata. @@ -1281,63 +948,24 @@ export class CentralCore extends EventEmitter { throw new Error("Local nodes must not include url or apiKey"); } - if (this.backendMode) { - await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { - name: updated.name, - type: updated.type, - url: updated.url ?? null, - apiKey: updated.apiKey ?? null, - status: updated.status, - capabilities: updated.capabilities ?? null, - systemMetrics: updated.systemMetrics ?? null, - knownPeers: updated.knownPeers ?? null, - versionInfo: updated.versionInfo ?? null, - pluginVersions: updated.pluginVersions ?? null, - dockerConfig: updated.dockerConfig ?? null, - maxConcurrent: updated.maxConcurrent, - updatedAt: updated.updatedAt, - }); - this.emit("node:updated", updated); - return updated; - } - - this.db!.prepare( - `UPDATE nodes SET - name = ?, - type = ?, - url = ?, - apiKey = ?, - status = ?, - capabilities = ?, - systemMetrics = ?, - knownPeers = ?, - versionInfo = ?, - pluginVersions = ?, - dockerConfig = ?, - maxConcurrent = ?, - updatedAt = ? - WHERE id = ?` - ).run( - updated.name, - updated.type, - updated.url ?? null, - updated.apiKey ?? null, - updated.status, - toJsonNullable(updated.capabilities), - toJsonNullable(updated.systemMetrics), - toJsonNullable(updated.knownPeers), - toJsonNullable(updated.versionInfo), - toJsonNullable(updated.pluginVersions), - toJsonNullable(updated.dockerConfig), - updated.maxConcurrent, - updated.updatedAt, - id - ); - - this.db!.bumpLastModified(); + await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { + name: updated.name, + type: updated.type, + url: updated.url ?? null, + apiKey: updated.apiKey ?? null, + status: updated.status, + capabilities: updated.capabilities ?? null, + systemMetrics: updated.systemMetrics ?? null, + knownPeers: updated.knownPeers ?? null, + versionInfo: updated.versionInfo ?? null, + pluginVersions: updated.pluginVersions ?? null, + dockerConfig: updated.dockerConfig ?? null, + maxConcurrent: updated.maxConcurrent, + updatedAt: updated.updatedAt, + }); this.emit("node:updated", updated); return updated; - } +} /** * Create a managed Docker node record. @@ -1377,41 +1005,9 @@ export class CentralCore extends EventEmitter { updatedAt: now, }; - if (this.backendMode) { - await asyncCentralCore.insertManagedDockerNode(this.backendHandle, node); - return node; - } - - this.db!.prepare( - `INSERT INTO managedDockerNodes ( - id, nodeId, name, imageName, imageTag, containerId, status, - hostConfig, envVars, volumeMounts, resourceSizing, extraClis, - persistentStorage, reachableUrl, apiKey, errorMessage, createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - node.id, - node.nodeId, - node.name, - node.imageName, - node.imageTag, - node.containerId, - node.status, - toJson(node.hostConfig), - toJson(node.envVars), - toJson(node.volumeMounts), - toJson(node.resourceSizing), - toJson(node.extraClis), - node.persistentStorage ? 1 : 0, - node.reachableUrl, - node.apiKey, - node.errorMessage, - node.createdAt, - node.updatedAt, - ); - - this.db!.bumpLastModified(); + await asyncCentralCore.insertManagedDockerNode(this.backendHandle, node); return node; - } +} /** * Get a managed Docker node by ID. @@ -1419,16 +1015,8 @@ export class CentralCore extends EventEmitter { async getManagedDockerNode(id: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getManagedDockerNode(this.backendHandle, id); - } - - const row = this.db!.prepare("SELECT * FROM managedDockerNodes WHERE id = ?").get(id) as - | Parameters[0] - | undefined; - - return row ? this.rowToManagedDockerNode(row) : undefined; - } + return asyncCentralCore.getManagedDockerNode(this.backendHandle, id); +} /** * Get a managed Docker node by unique name. @@ -1436,16 +1024,8 @@ export class CentralCore extends EventEmitter { async getManagedDockerNodeByName(name: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getManagedDockerNodeByName(this.backendHandle, name); - } - - const row = this.db!.prepare("SELECT * FROM managedDockerNodes WHERE name = ?").get(name) as - | Parameters[0] - | undefined; - - return row ? this.rowToManagedDockerNode(row) : undefined; - } + return asyncCentralCore.getManagedDockerNodeByName(this.backendHandle, name); +} /** * List managed Docker nodes ordered by name. @@ -1453,16 +1033,8 @@ export class CentralCore extends EventEmitter { async listManagedDockerNodes(): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.listManagedDockerNodes(this.backendHandle); - } - - const rows = this.db!.prepare("SELECT * FROM managedDockerNodes ORDER BY name").all() as Array< - Parameters[0] - >; - - return rows.map((row) => this.rowToManagedDockerNode(row)); - } + return asyncCentralCore.listManagedDockerNodes(this.backendHandle); +} /** * Update a managed Docker node. @@ -1496,66 +1068,18 @@ export class CentralCore extends EventEmitter { } } - if (this.backendMode) { - await asyncCentralCore.updateManagedDockerNodeRow(this.backendHandle, id, updated); - return updated; - } - - this.db!.prepare( - `UPDATE managedDockerNodes SET - nodeId = ?, - name = ?, - imageName = ?, - imageTag = ?, - containerId = ?, - status = ?, - hostConfig = ?, - envVars = ?, - volumeMounts = ?, - resourceSizing = ?, - extraClis = ?, - persistentStorage = ?, - reachableUrl = ?, - apiKey = ?, - errorMessage = ?, - updatedAt = ? - WHERE id = ?` - ).run( - updated.nodeId, - updated.name, - updated.imageName, - updated.imageTag, - updated.containerId, - updated.status, - toJson(updated.hostConfig), - toJson(updated.envVars), - toJson(updated.volumeMounts), - toJson(updated.resourceSizing), - toJson(updated.extraClis), - updated.persistentStorage ? 1 : 0, - updated.reachableUrl, - updated.apiKey, - updated.errorMessage, - updated.updatedAt, - id, - ); - - this.db!.bumpLastModified(); + await asyncCentralCore.updateManagedDockerNodeRow(this.backendHandle, id, updated); return updated; - } +} /** * Delete a managed Docker node record by ID. */ async deleteManagedDockerNode(id: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - await asyncCentralCore.deleteManagedDockerNode(this.backendHandle, id); - return; - } - this.db!.prepare("DELETE FROM managedDockerNodes WHERE id = ?").run(id); - this.db!.bumpLastModified(); - } + await asyncCentralCore.deleteManagedDockerNode(this.backendHandle, id); + return; +} /** * Link an existing managed Docker node record to a registered mesh node. @@ -1615,17 +1139,11 @@ export class CentralCore extends EventEmitter { updatedAt: now, }; - if (this.backendMode) { - await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { - status: nextStatus, - updatedAt: now, - }); - } else { - this.db! - .prepare("UPDATE nodes SET status = ?, updatedAt = ? WHERE id = ?") - .run(nextStatus, now, id); - this.db!.bumpLastModified(); - } + await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { + status: nextStatus, + updatedAt: now, + }); + this.emit("node:health:changed", updated); } @@ -1644,18 +1162,10 @@ export class CentralCore extends EventEmitter { } const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { - systemMetrics: metrics, - updatedAt: now, - }); - } else { - this.db! - .prepare("UPDATE nodes SET systemMetrics = ?, updatedAt = ? WHERE id = ?") - .run(toJsonNullable(metrics), now, id); - - this.db!.bumpLastModified(); - } + await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { + systemMetrics: metrics, + updatedAt: now, + }); const updated = await this.getNode(id); if (!updated) { @@ -1677,25 +1187,8 @@ export class CentralCore extends EventEmitter { async listPeers(nodeId: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.listPeers(this.backendHandle, nodeId); - } - - const rows = this.db! - .prepare("SELECT * FROM peerNodes WHERE nodeId = ? ORDER BY name") - .all(nodeId) as Array<{ - id: string; - nodeId: string; - peerNodeId: string; - name: string; - url: string; - status: string; - lastSeen: string; - connectedAt: string; - }>; - - return rows.map((row) => this.rowToPeerNode(row)); - } + return asyncCentralCore.listPeers(this.backendHandle, nodeId); +} /** * Register or update a peer node for mesh discovery. @@ -1715,89 +1208,21 @@ export class CentralCore extends EventEmitter { const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.upsertPeerNode(this.asyncLayer!, { - nodeId: input.nodeId, - peerNodeId: input.peerNodeId, - name: input.name, - url: input.url, - now, - existingKnownPeers: node.knownPeers ?? [], - }); - - const peer = await asyncCentralCore.getPeer(this.backendHandle, input.nodeId, input.peerNodeId); - if (!peer) { - throw new Error( - `Failed to load peer node after registration: ${input.nodeId}/${input.peerNodeId}`, - ); - } - this.emit("mesh:peer:added", { nodeId: input.nodeId, peer }); - - const updatedNode = await this.getNode(input.nodeId); - if (updatedNode) { - this.emit("node:updated", updatedNode); - } - - const state = await this.getMeshState(input.nodeId); - this.emit("mesh:state:changed", { nodeId: input.nodeId, state }); - - return peer; - } - - this.db!.transaction(() => { - this.db! - .prepare( - `INSERT INTO peerNodes (id, nodeId, peerNodeId, name, url, status, lastSeen, connectedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(nodeId, peerNodeId) DO UPDATE SET - name = excluded.name, - url = excluded.url, - status = excluded.status, - lastSeen = excluded.lastSeen` - ) - .run( - `peer_${randomUUID().replace(/-/g, "").slice(0, 16)}`, - input.nodeId, - input.peerNodeId, - input.name, - input.url, - "offline", - now, - now, - ); - - const knownPeers = new Set(node.knownPeers ?? []); - knownPeers.add(input.peerNodeId); - - this.db! - .prepare("UPDATE nodes SET knownPeers = ?, updatedAt = ? WHERE id = ?") - .run(toJson(Array.from(knownPeers)), now, input.nodeId); + await asyncCentralCore.upsertPeerNode(this.asyncLayer!, { + nodeId: input.nodeId, + peerNodeId: input.peerNodeId, + name: input.name, + url: input.url, + now, + existingKnownPeers: node.knownPeers ?? [], }); - this.db!.bumpLastModified(); - - const row = this.db! - .prepare("SELECT * FROM peerNodes WHERE nodeId = ? AND peerNodeId = ?") - .get(input.nodeId, input.peerNodeId) as - | { - id: string; - nodeId: string; - peerNodeId: string; - name: string; - url: string; - status: string; - lastSeen: string; - connectedAt: string; - } - | undefined; - - if (!row) { + const peer = await asyncCentralCore.getPeer(this.backendHandle, input.nodeId, input.peerNodeId); + if (!peer) { throw new Error( `Failed to load peer node after registration: ${input.nodeId}/${input.peerNodeId}`, ); } - - const peer = this.rowToPeerNode(row); this.emit("mesh:peer:added", { nodeId: input.nodeId, peer }); const updatedNode = await this.getNode(input.nodeId); @@ -1809,7 +1234,7 @@ export class CentralCore extends EventEmitter { this.emit("mesh:state:changed", { nodeId: input.nodeId, state }); return peer; - } +} /** * Remove a peer node relationship. @@ -1824,36 +1249,13 @@ export class CentralCore extends EventEmitter { const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.deletePeerNode( - this.asyncLayer!, - nodeId, - peerNodeId, - node.knownPeers ?? [], - now, - ); - this.emit("mesh:peer:removed", { nodeId, peerNodeId }); - - const updatedNode = await this.getNode(nodeId); - if (updatedNode) { - this.emit("node:updated", updatedNode); - } - - const state = await this.getMeshState(nodeId); - this.emit("mesh:state:changed", { nodeId, state }); - return; - } - - this.db!.transaction(() => { - this.db!.prepare("DELETE FROM peerNodes WHERE nodeId = ? AND peerNodeId = ?").run(nodeId, peerNodeId); - - const knownPeers = (node.knownPeers ?? []).filter((id) => id !== peerNodeId); - this.db! - .prepare("UPDATE nodes SET knownPeers = ?, updatedAt = ? WHERE id = ?") - .run(toJson(knownPeers), now, nodeId); - }); - - this.db!.bumpLastModified(); + await asyncCentralCore.deletePeerNode( + this.asyncLayer!, + nodeId, + peerNodeId, + node.knownPeers ?? [], + now, + ); this.emit("mesh:peer:removed", { nodeId, peerNodeId }); const updatedNode = await this.getNode(nodeId); @@ -1863,7 +1265,8 @@ export class CentralCore extends EventEmitter { const state = await this.getMeshState(nodeId); this.emit("mesh:state:changed", { nodeId, state }); - } + return; +} /** * Get mesh state for a node (or the local node by default). @@ -1924,175 +1327,57 @@ export class CentralCore extends EventEmitter { async recordMeshSnapshot(input: MeshSnapshotRecordInput): Promise { this.ensureInitialized(); const now = new Date().toISOString(); - if (this.backendMode) { - return asyncCentralCore.recordMeshSnapshotRow(this.backendHandle, input, now); - } - this.db!.prepare( - `INSERT INTO meshSharedSnapshots (nodeId, projectId, scope, payload, snapshotVersion, capturedAt, sourceNodeId, sourceRunId, staleAfter, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(nodeId, projectId, scope) DO UPDATE SET - payload = excluded.payload, - snapshotVersion = excluded.snapshotVersion, - capturedAt = excluded.capturedAt, - sourceNodeId = excluded.sourceNodeId, - sourceRunId = excluded.sourceRunId, - staleAfter = excluded.staleAfter, - updatedAt = excluded.updatedAt` - ).run( - input.nodeId, - input.projectId ?? null, - input.scope, - JSON.stringify(input.payload), - input.snapshotVersion, - input.capturedAt, - input.sourceNodeId ?? null, - input.sourceRunId ?? null, - input.staleAfter ?? null, - now, - ); - this.db!.bumpLastModified(); - return { ...input, projectId: input.projectId ?? null, sourceNodeId: input.sourceNodeId ?? null, sourceRunId: input.sourceRunId ?? null, staleAfter: input.staleAfter ?? null, updatedAt: now }; - } + return asyncCentralCore.recordMeshSnapshotRow(this.backendHandle, input, now); +} async getLatestMeshSnapshot(query: MeshSnapshotQuery): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getLatestMeshSnapshotRow(this.backendHandle, query); - } - const row = this.db!.prepare( - `SELECT * FROM meshSharedSnapshots WHERE nodeId = ? AND projectId IS ? AND scope = ?` - ).get(query.nodeId, query.projectId ?? null, query.scope) as { - nodeId: string; projectId: string | null; scope: string; payload: string; snapshotVersion: string; capturedAt: string; sourceNodeId: string | null; sourceRunId: string | null; staleAfter: string | null; updatedAt: string; - } | undefined; - if (!row) return null; - return { - nodeId: row.nodeId, - projectId: row.projectId, - scope: row.scope, - payload: fromJson>(row.payload) ?? {}, - snapshotVersion: row.snapshotVersion, - capturedAt: row.capturedAt, - sourceNodeId: row.sourceNodeId, - sourceRunId: row.sourceRunId, - staleAfter: row.staleAfter, - updatedAt: row.updatedAt, - }; - } + return asyncCentralCore.getLatestMeshSnapshotRow(this.backendHandle, query); +} async enqueueMeshWrite(input: MeshWriteQueueInput): Promise { this.ensureInitialized(); const now = new Date().toISOString(); const id = `mq_${randomUUID().replace(/-/g, "").slice(0, 24)}`; - if (this.backendMode) { - await asyncCentralCore.enqueueMeshWriteRow(this.backendHandle, id, input, now); - return (await this.listPendingMeshWrites({ targetNodeId: input.targetNodeId })).find((entry) => entry.id === id)!; - } - this.db!.prepare( - `INSERT INTO meshWriteQueue (id, originNodeId, targetNodeId, projectId, scope, entityType, entityId, operation, payload, intentVersion, status, attemptCount, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)` - ).run( - id, - input.originNodeId, - input.targetNodeId, - input.projectId ?? null, - input.scope, - input.entityType, - input.entityId, - input.operation, - JSON.stringify(input.payload), - input.intentVersion, - now, - now, - ); - this.db!.bumpLastModified(); - return (await this.listPendingMeshWrites({ targetNodeId: input.targetNodeId })).find((entry) => entry.id === id)!; - } + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Fetch the just-inserted mesh queue row by id (same path as markMeshWrite*) instead of re-listing pending writes and force-unwrapping. + */ + await asyncCentralCore.enqueueMeshWriteRow(this.backendHandle, id, input, now); + return asyncCentralCore.getMeshWriteQueueEntryById(this.backendHandle, id); +} async listPendingMeshWrites(filter: MeshWriteQueueFilter = {}): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.listPendingMeshWritesRow(this.backendHandle, filter); - } - const conditions: string[] = []; - const values: Array = []; - if (filter.originNodeId) { conditions.push("originNodeId = ?"); values.push(filter.originNodeId); } - if (filter.targetNodeId) { conditions.push("targetNodeId = ?"); values.push(filter.targetNodeId); } - if (filter.status) { conditions.push("status = ?"); values.push(filter.status); } - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - const rows = this.db!.prepare( - `SELECT * FROM meshWriteQueue ${whereClause} ORDER BY createdAt ASC, id ASC` - ).all(...values) as Array<{ id: string; originNodeId: string; targetNodeId: string; projectId: string | null; scope: string; entityType: string; entityId: string; operation: string; payload: string; intentVersion: string; status: MeshWriteQueueEntry["status"]; attemptCount: number; lastAttemptAt: string | null; lastError: string | null; createdAt: string; updatedAt: string; appliedAt: string | null }>; - return rows.map((row) => ({ ...row, payload: fromJson>(row.payload) ?? {} })); - } + return asyncCentralCore.listPendingMeshWritesRow(this.backendHandle, filter); +} async markMeshWriteReplayStarted(id: string): Promise { this.ensureInitialized(); const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.markMeshWriteReplayStartedRow(this.backendHandle, id, now); - return asyncCentralCore.getMeshWriteQueueEntryById(this.backendHandle, id); - } - this.db!.prepare( - `UPDATE meshWriteQueue SET status = 'replaying', attemptCount = attemptCount + 1, lastAttemptAt = ?, updatedAt = ? WHERE id = ?` - ).run(now, now, id); - this.db!.bumpLastModified(); - return this.getMeshWriteQueueEntryById(id); - } + await asyncCentralCore.markMeshWriteReplayStartedRow(this.backendHandle, id, now); + return asyncCentralCore.getMeshWriteQueueEntryById(this.backendHandle, id); +} async markMeshWriteApplied(id: string, result: MeshWriteApplyResult): Promise { this.ensureInitialized(); const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.markMeshWriteAppliedRow(this.backendHandle, id, result.appliedAt ?? null, now); - return asyncCentralCore.getMeshWriteQueueEntryById(this.backendHandle, id); - } - this.db!.prepare( - `UPDATE meshWriteQueue SET status = 'applied', appliedAt = ?, updatedAt = ? WHERE id = ?` - ).run(result.appliedAt ?? now, now, id); - this.db!.bumpLastModified(); - return this.getMeshWriteQueueEntryById(id); - } + await asyncCentralCore.markMeshWriteAppliedRow(this.backendHandle, id, result.appliedAt ?? null, now); + return asyncCentralCore.getMeshWriteQueueEntryById(this.backendHandle, id); +} async markMeshWriteFailed(id: string, result: MeshWriteFailureResult): Promise { this.ensureInitialized(); const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.markMeshWriteFailedRow(this.backendHandle, id, result.lastError, now); - return asyncCentralCore.getMeshWriteQueueEntryById(this.backendHandle, id); - } - this.db!.prepare( - `UPDATE meshWriteQueue SET status = 'failed', lastError = ?, updatedAt = ? WHERE id = ?` - ).run(result.lastError, now, id); - this.db!.bumpLastModified(); - return this.getMeshWriteQueueEntryById(id); - } + await asyncCentralCore.markMeshWriteFailedRow(this.backendHandle, id, result.lastError, now); + return asyncCentralCore.getMeshWriteQueueEntryById(this.backendHandle, id); +} async getMeshDegradedReadState(query: MeshSnapshotQuery): Promise { this.ensureInitialized(); const snapshot = await this.getLatestMeshSnapshot(query); const now = Date.now(); - if (this.backendMode) { - const counts = await asyncCentralCore.getMeshDegradedReadCounts(this.backendHandle); - const asOf = snapshot?.capturedAt ?? new Date(now).toISOString(); - return { - mode: snapshot ? "degraded" : "fresh", - asOf, - sourceNodeId: snapshot?.sourceNodeId ?? null, - snapshotVersion: snapshot?.snapshotVersion ?? null, - stalenessMs: Math.max(0, now - Date.parse(asOf)), - queueDepth: counts.queueDepth, - pendingWriteCount: counts.pendingWriteCount, - failedWriteCount: counts.failedWriteCount, - }; - } - const counts = this.db!.prepare( - `SELECT - SUM(CASE WHEN status IN ('pending','replaying','failed') THEN 1 ELSE 0 END) AS queueDepth, - SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pendingWriteCount, - SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failedWriteCount - FROM meshWriteQueue` - ).get() as { queueDepth: number | null; pendingWriteCount: number | null; failedWriteCount: number | null }; - + const counts = await asyncCentralCore.getMeshDegradedReadCounts(this.backendHandle); const asOf = snapshot?.capturedAt ?? new Date(now).toISOString(); return { mode: snapshot ? "degraded" : "fresh", @@ -2100,11 +1385,11 @@ export class CentralCore extends EventEmitter { sourceNodeId: snapshot?.sourceNodeId ?? null, snapshotVersion: snapshot?.snapshotVersion ?? null, stalenessMs: Math.max(0, now - Date.parse(asOf)), - queueDepth: counts.queueDepth ?? 0, - pendingWriteCount: counts.pendingWriteCount ?? 0, - failedWriteCount: counts.failedWriteCount ?? 0, + queueDepth: counts.queueDepth, + pendingWriteCount: counts.pendingWriteCount, + failedWriteCount: counts.failedWriteCount, }; - } +} async replayPendingMeshWritesForNode(targetNodeId: string): Promise { this.ensureInitialized(); @@ -2112,18 +1397,9 @@ export class CentralCore extends EventEmitter { return { replayed: pending.length, applied: 0, failed: 0, queuedWriteIds: pending.map((entry) => entry.id) }; } - private getMeshWriteQueueEntryById(id: string): MeshWriteQueueEntry { - if (this.backendMode) { - throw new Error("getMeshWriteQueueEntryById(sync) must not be called in backend mode"); - } - const row = this.db!.prepare(`SELECT * FROM meshWriteQueue WHERE id = ?`).get(id) as - | { id: string; originNodeId: string; targetNodeId: string; projectId: string | null; scope: string; entityType: string; entityId: string; operation: string; payload: string; intentVersion: string; status: MeshWriteQueueEntry["status"]; attemptCount: number; lastAttemptAt: string | null; lastError: string | null; createdAt: string; updatedAt: string; appliedAt: string | null } - | undefined; - if (!row) { - throw new Error(`Mesh write queue entry not found: ${id}`); - } - return { ...row, payload: fromJson>(row.payload) ?? {} }; - } + private getMeshWriteQueueEntryById(_id: string): MeshWriteQueueEntry { + throw new Error("getMeshWriteQueueEntryById(sync) must not be called in backend mode"); +} /** * Collect a fresh local mesh state snapshot. @@ -2413,12 +1689,7 @@ export class CentralCore extends EventEmitter { } const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.assignProjectToNode(this.backendHandle, projectId, node.id, now); - } else { - this.db!.prepare("UPDATE projects SET nodeId = ?, updatedAt = ? WHERE id = ?").run(node.id, now, projectId); - this.db!.bumpLastModified(); - } + await asyncCentralCore.assignProjectToNode(this.backendHandle, projectId, node.id, now); const updated: RegisteredProject = { ...project, @@ -2441,12 +1712,7 @@ export class CentralCore extends EventEmitter { } const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.unassignProjectFromNode(this.backendHandle, projectId, now); - } else { - this.db!.prepare("UPDATE projects SET nodeId = NULL, updatedAt = ? WHERE id = ?").run(now, projectId); - this.db!.bumpLastModified(); - } + await asyncCentralCore.unassignProjectFromNode(this.backendHandle, projectId, now); const updated: RegisteredProject = { ...project, @@ -2468,22 +1734,12 @@ export class CentralCore extends EventEmitter { } const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.insertProjectNodePathMapping(this.backendHandle, { - projectId: input.projectId, - nodeId: input.nodeId, - path: input.path, - now, - }); - } else { - this.db! - .prepare( - `INSERT INTO projectNodePathMappings (projectId, nodeId, path, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?)` - ) - .run(input.projectId, input.nodeId, input.path, now, now); - this.db!.bumpLastModified(); - } + await asyncCentralCore.insertProjectNodePathMapping(this.backendHandle, { + projectId: input.projectId, + nodeId: input.nodeId, + path: input.path, + now, + }); return { projectId: input.projectId, @@ -2505,23 +1761,12 @@ export class CentralCore extends EventEmitter { } const now = new Date().toISOString(); - if (this.backendMode) { - await asyncCentralCore.updateProjectNodePathMappingRow(this.backendHandle, { - projectId: input.projectId, - nodeId: input.nodeId, - path: input.path, - now, - }); - } else { - this.db! - .prepare( - `UPDATE projectNodePathMappings - SET path = ?, updatedAt = ? - WHERE projectId = ? AND nodeId = ?` - ) - .run(input.path, now, input.projectId, input.nodeId); - this.db!.bumpLastModified(); - } + await asyncCentralCore.updateProjectNodePathMappingRow(this.backendHandle, { + projectId: input.projectId, + nodeId: input.nodeId, + path: input.path, + now, + }); return { ...existing, @@ -2536,38 +1781,14 @@ export class CentralCore extends EventEmitter { ): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getProjectNodePathMapping(this.backendHandle, projectId, nodeId); - } - - const row = this.db! - .prepare("SELECT * FROM projectNodePathMappings WHERE projectId = ? AND nodeId = ?") - .get(projectId, nodeId) as - | { - projectId: string; - nodeId: string; - path: string; - createdAt: string; - updatedAt: string; - } - | undefined; - - return row ? this.rowToProjectNodePathMapping(row) : undefined; - } + return asyncCentralCore.getProjectNodePathMapping(this.backendHandle, projectId, nodeId); +} async getProjectNodePath(projectId: string, nodeId: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getProjectNodePath(this.backendHandle, projectId, nodeId); - } - - const row = this.db! - .prepare("SELECT path FROM projectNodePathMappings WHERE projectId = ? AND nodeId = ?") - .get(projectId, nodeId) as { path: string } | undefined; - - return row?.path; - } + return asyncCentralCore.getProjectNodePath(this.backendHandle, projectId, nodeId); +} async resolveProjectWorkingDirectory(projectId: string, nodeId: string): Promise { this.ensureInitialized(); @@ -2609,52 +1830,8 @@ export class CentralCore extends EventEmitter { }): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.listProjectNodePathMappings(this.backendHandle, filters); - } - - if (filters?.projectId && filters?.nodeId) { - const row = await this.getProjectNodePathMapping(filters.projectId, filters.nodeId); - return row ? [row] : []; - } - - if (filters?.projectId) { - const rows = this.db! - .prepare("SELECT * FROM projectNodePathMappings WHERE projectId = ? ORDER BY nodeId") - .all(filters.projectId) as Array<{ - projectId: string; - nodeId: string; - path: string; - createdAt: string; - updatedAt: string; - }>; - return rows.map((row) => this.rowToProjectNodePathMapping(row)); - } - - if (filters?.nodeId) { - const rows = this.db! - .prepare("SELECT * FROM projectNodePathMappings WHERE nodeId = ? ORDER BY projectId") - .all(filters.nodeId) as Array<{ - projectId: string; - nodeId: string; - path: string; - createdAt: string; - updatedAt: string; - }>; - return rows.map((row) => this.rowToProjectNodePathMapping(row)); - } - - const rows = this.db! - .prepare("SELECT * FROM projectNodePathMappings ORDER BY projectId, nodeId") - .all() as Array<{ - projectId: string; - nodeId: string; - path: string; - createdAt: string; - updatedAt: string; - }>; - return rows.map((row) => this.rowToProjectNodePathMapping(row)); - } + return asyncCentralCore.listProjectNodePathMappings(this.backendHandle, filters); +} async listProjectNodePathMappingsForProject(projectId: string): Promise { this.ensureInitialized(); @@ -2703,19 +1880,9 @@ export class CentralCore extends EventEmitter { throw new Error("Node ID is required"); } - if (this.backendMode) { - await asyncCentralCore.deleteProjectNodePathMapping(this.backendHandle, projectId, nodeId); - return; - } - - const result = this.db! - .prepare("DELETE FROM projectNodePathMappings WHERE projectId = ? AND nodeId = ?") - .run(projectId, nodeId) as { changes?: number }; - - if ((result.changes ?? 0) > 0) { - this.db!.bumpLastModified(); - } - } + await asyncCentralCore.deleteProjectNodePathMapping(this.backendHandle, projectId, nodeId); + return; +} // ── Project Health API ────────────────────────────────────────────────── @@ -2745,42 +1912,10 @@ export class CentralCore extends EventEmitter { updatedAt: now, }; - if (this.backendMode) { - await asyncCentralCore.updateProjectHealthRow(this.backendHandle, projectId, updated); - this.emit("project:health:changed", updated); - return updated; - } - - this.db!.prepare( - `UPDATE projectHealth SET - status = ?, - activeTaskCount = ?, - inFlightAgentCount = ?, - lastActivityAt = ?, - lastErrorAt = ?, - lastErrorMessage = ?, - totalTasksCompleted = ?, - totalTasksFailed = ?, - averageTaskDurationMs = ?, - updatedAt = ? - WHERE projectId = ?` - ).run( - updated.status, - updated.activeTaskCount, - updated.inFlightAgentCount, - updated.lastActivityAt ?? null, - updated.lastErrorAt ?? null, - updated.lastErrorMessage ?? null, - updated.totalTasksCompleted, - updated.totalTasksFailed, - updated.averageTaskDurationMs ?? null, - updated.updatedAt, - projectId - ); - + await asyncCentralCore.updateProjectHealthRow(this.backendHandle, projectId, updated); this.emit("project:health:changed", updated); return updated; - } +} /** * Get project health metrics. @@ -2791,30 +1926,8 @@ export class CentralCore extends EventEmitter { async getProjectHealth(projectId: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getProjectHealth(this.backendHandle, projectId); - } - - const row = this.db!.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get(projectId) as - | { - projectId: string; - status: string; - activeTaskCount: number; - inFlightAgentCount: number; - lastActivityAt: string | null; - lastErrorAt: string | null; - lastErrorMessage: string | null; - totalTasksCompleted: number; - totalTasksFailed: number; - averageTaskDurationMs: number | null; - updatedAt: string; - } - | undefined; - - if (!row) return undefined; - - return this.rowToHealth(row); - } + return asyncCentralCore.getProjectHealth(this.backendHandle, projectId); +} /** * List health metrics for all projects. @@ -2824,26 +1937,8 @@ export class CentralCore extends EventEmitter { async listAllHealth(): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.listAllHealth(this.backendHandle); - } - - const rows = this.db!.prepare("SELECT * FROM projectHealth").all() as Array<{ - projectId: string; - status: string; - activeTaskCount: number; - inFlightAgentCount: number; - lastActivityAt: string | null; - lastErrorAt: string | null; - lastErrorMessage: string | null; - totalTasksCompleted: number; - totalTasksFailed: number; - averageTaskDurationMs: number | null; - updatedAt: string; - }>; - - return rows.map((row) => this.rowToHealth(row)); - } + return asyncCentralCore.listAllHealth(this.backendHandle); +} /** * Record a task completion/failure for health tracking. @@ -2876,36 +1971,19 @@ export class CentralCore extends EventEmitter { averageDuration = health.averageTaskDurationMs; } - if (this.backendMode) { - await asyncCentralCore.recordTaskCompletionRow(this.backendHandle, projectId, { - totalTasksCompleted: totalCompleted, - totalTasksFailed: totalFailed, - averageTaskDurationMs: averageDuration ?? null, - lastActivityAt: now, - updatedAt: now, - }); - const updated = await this.getProjectHealth(projectId); - if (updated) { - this.emit("project:health:changed", updated); - } - return; - } - - this.db!.prepare( - `UPDATE projectHealth SET - totalTasksCompleted = ?, - totalTasksFailed = ?, - averageTaskDurationMs = ?, - lastActivityAt = ?, - updatedAt = ? - WHERE projectId = ?` - ).run(totalCompleted, totalFailed, averageDuration ?? null, now, now, projectId); - + await asyncCentralCore.recordTaskCompletionRow(this.backendHandle, projectId, { + totalTasksCompleted: totalCompleted, + totalTasksFailed: totalFailed, + averageTaskDurationMs: averageDuration ?? null, + lastActivityAt: now, + updatedAt: now, + }); const updated = await this.getProjectHealth(projectId); if (updated) { this.emit("project:health:changed", updated); } - } + return; +} // ── Unified Activity Feed API ─────────────────────────────────────────── @@ -2926,40 +2004,10 @@ export class CentralCore extends EventEmitter { id: randomUUID(), }; - if (this.backendMode) { - await asyncCentralCore.logActivityRow(this.asyncLayer!, fullEntry); - this.emit("activity:logged", fullEntry); - return fullEntry; - } - - this.db!.transaction(() => { - // Insert activity log entry - this.db!.prepare( - `INSERT INTO centralActivityLog (id, timestamp, type, projectId, projectName, taskId, taskTitle, details, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - fullEntry.id, - fullEntry.timestamp, - fullEntry.type, - fullEntry.projectId, - fullEntry.projectName, - fullEntry.taskId ?? null, - fullEntry.taskTitle ?? null, - fullEntry.details, - toJsonNullable(fullEntry.metadata) - ); - - // Update project's lastActivityAt - this.db!.prepare("UPDATE projects SET lastActivityAt = ? WHERE id = ?").run( - fullEntry.timestamp, - fullEntry.projectId - ); - }); - - this.db!.bumpLastModified(); + await asyncCentralCore.logActivityRow(this.asyncLayer!, fullEntry); this.emit("activity:logged", fullEntry); return fullEntry; - } +} /** * Get recent activity from the unified feed. @@ -2974,46 +2022,8 @@ export class CentralCore extends EventEmitter { }): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getRecentActivity(this.backendHandle, options); - } - - const limit = options?.limit ?? 100; - const conditions: string[] = []; - const params: (string | number | string[])[] = [limit]; - - if (options?.projectId) { - conditions.push("projectId = ?"); - params.unshift(options.projectId); - } - - if (options?.types && options.types.length > 0) { - conditions.push(`type IN (${options.types.map(() => "?").join(",")})`); - params.unshift(...options.types); - } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - // Reorder params: types first, then projectId, then limit - const queryParams: (string | number)[] = []; - if (options?.types) queryParams.push(...options.types); - if (options?.projectId) queryParams.push(options.projectId); - queryParams.push(limit); - - const sql = `SELECT * FROM centralActivityLog ${whereClause} ORDER BY timestamp DESC LIMIT ?`; - const rows = this.db!.prepare(sql).all(...queryParams) as Array<{ - id: string; - timestamp: string; - type: string; - projectId: string; - projectName: string; - taskId: string | null; - taskTitle: string | null; - details: string; - metadata: string | null; - }>; - - return rows.map((row) => this.rowToActivityEntry(row)); - } + return asyncCentralCore.getRecentActivity(this.backendHandle, options); +} /** * Get the total count of activity log entries. @@ -3024,21 +2034,8 @@ export class CentralCore extends EventEmitter { async getActivityCount(projectId?: string): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getActivityCount(this.backendHandle, projectId); - } - - let sql = "SELECT COUNT(*) as count FROM centralActivityLog"; - const params: string[] = []; - - if (projectId) { - sql += " WHERE projectId = ?"; - params.push(projectId); - } - - const row = this.db!.prepare(sql).get(...params) as { count: number }; - return row.count; - } + return asyncCentralCore.getActivityCount(this.backendHandle, projectId); +} /** * Clean up old activity log entries. @@ -3053,35 +2050,14 @@ export class CentralCore extends EventEmitter { cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); const cutoff = cutoffDate.toISOString(); - if (this.backendMode) { - return asyncCentralCore.cleanupOldActivity(this.backendHandle, cutoff); - } - - const result = this.db!.prepare("DELETE FROM centralActivityLog WHERE timestamp < ?").run(cutoff); - const deletedCount = typeof result.changes === "bigint" ? Number(result.changes) : (result.changes ?? 0); - - if (deletedCount > 0) { - this.db!.bumpLastModified(); - } - - return deletedCount; - } + return asyncCentralCore.cleanupOldActivity(this.backendHandle, cutoff); +} async getDefaultProjectId(): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getDefaultProjectId(this.backendHandle); - } - - if (!this.syncDbAvailable) return undefined; - - const row = this.db!.prepare("SELECT defaultProjectId FROM centralSettings WHERE id = 1").get() as - | { defaultProjectId: string | null } - | undefined; - - return row?.defaultProjectId ?? undefined; - } + return asyncCentralCore.getDefaultProjectId(this.backendHandle); +} async setDefaultProjectId(projectId: string | null): Promise { this.ensureInitialized(); @@ -3093,15 +2069,9 @@ export class CentralCore extends EventEmitter { } } - if (this.backendMode) { - await asyncCentralCore.setDefaultProjectId(this.backendHandle, projectId, new Date().toISOString()); - return; - } - - this.db! - .prepare("UPDATE centralSettings SET defaultProjectId = ?, updatedAt = ? WHERE id = 1") - .run(projectId, new Date().toISOString()); - } + await asyncCentralCore.setDefaultProjectId(this.backendHandle, projectId, new Date().toISOString()); + return; +} // ── Global Concurrency API ───────────────────────────────────────────── @@ -3113,33 +2083,8 @@ export class CentralCore extends EventEmitter { async getGlobalConcurrencyState(): Promise { this.ensureInitialized(); - if (this.backendMode) { - return asyncCentralCore.getGlobalConcurrencyState(this.backendHandle); - } - - const row = this.db!.prepare("SELECT * FROM globalConcurrency WHERE id = 1").get() as { - globalMaxConcurrent: number; - currentlyActive: number; - queuedCount: number; - }; - - // Calculate per-project active counts - const healthRows = this.db!.prepare( - "SELECT projectId, inFlightAgentCount FROM projectHealth WHERE inFlightAgentCount > 0" - ).all() as Array<{ projectId: string; inFlightAgentCount: number }>; - - const projectsActive: Record = {}; - for (const { projectId, inFlightAgentCount } of healthRows) { - projectsActive[projectId] = inFlightAgentCount; - } - - return { - globalMaxConcurrent: row.globalMaxConcurrent, - currentlyActive: row.currentlyActive, - queuedCount: row.queuedCount, - projectsActive, - }; - } + return asyncCentralCore.getGlobalConcurrencyState(this.backendHandle); +} /** * FNXC:GlobalConcurrencyControls 2026-06-26-17:22: @@ -3189,33 +2134,14 @@ export class CentralCore extends EventEmitter { ...updates, }; - if (this.backendMode) { - await asyncCentralCore.updateGlobalConcurrencyRow( - this.backendHandle, - updated, - new Date().toISOString(), - ); - this.emit("concurrency:changed", updated); - return updated; - } - - this.db!.prepare( - `UPDATE globalConcurrency SET - globalMaxConcurrent = ?, - currentlyActive = ?, - queuedCount = ?, - updatedAt = ? - WHERE id = 1` - ).run( - updated.globalMaxConcurrent, - updated.currentlyActive, - updated.queuedCount, - new Date().toISOString() + await asyncCentralCore.updateGlobalConcurrencyRow( + this.backendHandle, + updated, + new Date().toISOString(), ); - this.emit("concurrency:changed", updated); return updated; - } +} /** * Acquire a global concurrency slot. @@ -3235,46 +2161,11 @@ export class CentralCore extends EventEmitter { let acquired = false; - if (this.backendMode) { - acquired = await asyncCentralCore.acquireGlobalSlotAtomic(this.asyncLayer!, projectId); - const state = await this.getGlobalConcurrencyState(); - this.emit("concurrency:changed", state); - return acquired; - } - - this.db!.transaction(() => { - const row = this.db!.prepare("SELECT * FROM globalConcurrency WHERE id = 1").get() as { - globalMaxConcurrent: number; - currentlyActive: number; - queuedCount: number; - }; - - if (row.currentlyActive < row.globalMaxConcurrent) { - // Acquire slot - this.db!.prepare( - "UPDATE globalConcurrency SET currentlyActive = currentlyActive + 1, updatedAt = ? WHERE id = 1" - ).run(new Date().toISOString()); - - // Increment project's active count - this.db!.prepare( - "UPDATE projectHealth SET inFlightAgentCount = inFlightAgentCount + 1, updatedAt = ? WHERE projectId = ?" - ).run(new Date().toISOString(), projectId); - - acquired = true; - } else { - // Queue the request - this.db!.prepare( - "UPDATE globalConcurrency SET queuedCount = queuedCount + 1, updatedAt = ? WHERE id = 1" - ).run(new Date().toISOString()); - - acquired = false; - } - }); - + acquired = await asyncCentralCore.acquireGlobalSlotAtomic(this.asyncLayer!, projectId); const state = await this.getGlobalConcurrencyState(); this.emit("concurrency:changed", state); return acquired; - } +} /** * Release a global concurrency slot. @@ -3291,34 +2182,11 @@ export class CentralCore extends EventEmitter { throw new Error(`Project not found: ${projectId}`); } - if (this.backendMode) { - await asyncCentralCore.releaseGlobalSlotAtomic(this.asyncLayer!, projectId); - const state = await this.getGlobalConcurrencyState(); - this.emit("concurrency:changed", state); - return; - } - - this.db!.transaction(() => { - // Decrement global active count (don't go below 0) - this.db!.prepare( - `UPDATE globalConcurrency SET - currentlyActive = MAX(0, currentlyActive - 1), - updatedAt = ? - WHERE id = 1` - ).run(new Date().toISOString()); - - // Decrement project's active count (don't go below 0) - this.db!.prepare( - `UPDATE projectHealth SET - inFlightAgentCount = MAX(0, inFlightAgentCount - 1), - updatedAt = ? - WHERE projectId = ?` - ).run(new Date().toISOString(), projectId); - }); - + await asyncCentralCore.releaseGlobalSlotAtomic(this.asyncLayer!, projectId); const state = await this.getGlobalConcurrencyState(); this.emit("concurrency:changed", state); - } + return; +} // ── Utility Methods ───────────────────────────────────────────────────── @@ -3331,11 +2199,8 @@ export class CentralCore extends EventEmitter { // FNXC:CentralCore 2026-06-26-12:30: In backend mode there is no SQLite // file; return the logical global dir path. Callers that need the actual // backend should use the async layer. - if (this.backendMode) { - return this.globalDir; - } - return this.db?.getPath() ?? join(this.globalDir, "fusion-central.db"); - } + return this.globalDir; +} /** * Get the global directory path. @@ -3354,34 +2219,12 @@ export class CentralCore extends EventEmitter { async getStats(): Promise<{ projectCount: number; totalTasksCompleted: number; dbSizeBytes: number }> { this.ensureInitialized(); - if (this.backendMode) { - const { projectCount, totalTasksCompleted } = await asyncCentralCore.getStats(this.backendHandle); - // dbSizeBytes is not meaningful for a shared PostgreSQL cluster; report 0 - // (the sync path statSync'd the SQLite file). Callers that need cluster - // stats should query PostgreSQL's pg_database_size. - return { projectCount, totalTasksCompleted, dbSizeBytes: 0 }; - } - - const projectCount = ( - this.db!.prepare("SELECT COUNT(*) as count FROM projects").get() as { count: number } - ).count; - - const totalTasksCompleted = ( - this.db!.prepare("SELECT SUM(totalTasksCompleted) as total FROM projectHealth").get() as { - total: number | null; - } - ).total ?? 0; - - const dbPath = this.db!.getPath(); - let dbSizeBytes = 0; - try { - dbSizeBytes = statSync(dbPath).size; - } catch { - // File might not exist yet - } - - return { projectCount, totalTasksCompleted, dbSizeBytes }; - } + const { projectCount, totalTasksCompleted } = await asyncCentralCore.getStats(this.backendHandle); + // dbSizeBytes is not meaningful for a shared PostgreSQL cluster; report 0 + // (the sync path statSync'd the SQLite file). Callers that need cluster + // stats should query PostgreSQL's pg_database_size. + return { projectCount, totalTasksCompleted, dbSizeBytes: 0 }; +} // ── Private Helpers ───────────────────────────────────────────────────── @@ -3615,33 +2458,8 @@ export class CentralCore extends EventEmitter { } private async getLocalNode(): Promise { - if (this.backendMode) { - return asyncCentralCore.getLocalNode(this.backendHandle); - } - const row = this.db! - .prepare("SELECT * FROM nodes WHERE type = 'local' ORDER BY createdAt ASC LIMIT 1") - .get() as - | { - id: string; - name: string; - type: string; - url: string | null; - apiKey: string | null; - status: string; - capabilities: string | null; - systemMetrics: string | null; - knownPeers: string | null; - versionInfo: string | null; - pluginVersions: string | null; - dockerConfig: string | null; - maxConcurrent: number; - createdAt: string; - updatedAt: string; - } - | undefined; - - return row ? this.rowToNode(row) : undefined; - } + return asyncCentralCore.getLocalNode(this.backendHandle); +} private rowToHealth(row: { projectId: string; @@ -3871,46 +2689,19 @@ export class CentralCore extends EventEmitter { lastSyncedAt: versionInfo.lastSyncedAt ?? now, }; - if (this.backendMode) { - await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { - versionInfo: fullVersionInfo, - pluginVersions: fullVersionInfo.pluginVersions, - updatedAt: now, - }); - const updated = await this.getNode(id); - if (!updated) { - throw new Error(`Node not found after update: ${id}`); - } - this.emit("node:version:updated", { nodeId: id, versionInfo: fullVersionInfo }); - this.emit("node:updated", updated); - return updated; - } - - this.db!.prepare( - `UPDATE nodes SET - versionInfo = ?, - pluginVersions = ?, - updatedAt = ? - WHERE id = ?` - ).run( - toJsonNullable(fullVersionInfo), - toJsonNullable(fullVersionInfo.pluginVersions), - now, - id - ); - - this.db!.bumpLastModified(); - + await asyncCentralCore.updateNodeColumns(this.backendHandle, id, { + versionInfo: fullVersionInfo, + pluginVersions: fullVersionInfo.pluginVersions, + updatedAt: now, + }); const updated = await this.getNode(id); if (!updated) { throw new Error(`Node not found after update: ${id}`); } - this.emit("node:version:updated", { nodeId: id, versionInfo: fullVersionInfo }); this.emit("node:updated", updated); - return updated; - } +} /** * Get version information for a node. @@ -4352,29 +3143,8 @@ export class CentralCore extends EventEmitter { throw new Error("Local node not found"); } - if (this.backendMode) { - return asyncCentralCore.getSettingsSyncStateRow(this.backendHandle, localNode.id, remoteNodeId); - } - - const row = this.db!.prepare( - "SELECT * FROM settingsSyncState WHERE nodeId = ? AND remoteNodeId = ?" - ).get(localNode.id, remoteNodeId) as - | { - nodeId: string; - remoteNodeId: string; - lastSyncedAt: string | null; - localChecksum: string | null; - remoteChecksum: string | null; - syncCount: number; - createdAt: string; - updatedAt: string; - } - | undefined; - - if (!row) return null; - - return this.rowToSettingsSyncState(row); - } + return asyncCentralCore.getSettingsSyncStateRow(this.backendHandle, localNode.id, remoteNodeId); +} /** * Update settings sync state between local node and a remote node. @@ -4404,64 +3174,28 @@ export class CentralCore extends EventEmitter { const localChecksum = updates.localChecksum ?? existing?.localChecksum ?? null; const remoteChecksum = updates.remoteChecksum ?? existing?.remoteChecksum ?? null; - if (this.backendMode) { - await asyncCentralCore.upsertSettingsSyncStateRow(this.backendHandle, { - nodeId: localNode.id, - remoteNodeId, - lastSyncedAt, - localChecksum, - remoteChecksum, - syncCount, - createdAt: existing?.createdAt ?? now, - updatedAt: now, - }); - - const updated = await this.getSettingsSyncState(remoteNodeId); - if (!updated) { - throw new Error("Failed to retrieve updated settings sync state"); - } - this.emit("settings:sync:completed", { - nodeId: localNode.id, - remoteNodeId, - state: updated, - }); - return updated; - } - - if (existing) { - // Update existing row - this.db!.prepare( - `UPDATE settingsSyncState SET - lastSyncedAt = ?, - localChecksum = ?, - remoteChecksum = ?, - syncCount = ?, - updatedAt = ? - WHERE nodeId = ? AND remoteNodeId = ?` - ).run(lastSyncedAt, localChecksum, remoteChecksum, syncCount, now, localNode.id, remoteNodeId); - } else { - // Insert new row - this.db!.prepare( - `INSERT INTO settingsSyncState (nodeId, remoteNodeId, lastSyncedAt, localChecksum, remoteChecksum, syncCount, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run(localNode.id, remoteNodeId, lastSyncedAt, localChecksum, remoteChecksum, syncCount, now, now); - } - - this.db!.bumpLastModified(); + await asyncCentralCore.upsertSettingsSyncStateRow(this.backendHandle, { + nodeId: localNode.id, + remoteNodeId, + lastSyncedAt, + localChecksum, + remoteChecksum, + syncCount, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }); const updated = await this.getSettingsSyncState(remoteNodeId); if (!updated) { throw new Error("Failed to retrieve updated settings sync state"); } - this.emit("settings:sync:completed", { nodeId: localNode.id, remoteNodeId, state: updated, }); - return updated; - } +} private rowToSettingsSyncState(row: { nodeId: string; diff --git a/packages/core/src/plugin-store.ts b/packages/core/src/plugin-store.ts index 27259cf337..b891740772 100644 --- a/packages/core/src/plugin-store.ts +++ b/packages/core/src/plugin-store.ts @@ -7,7 +7,7 @@ import { EventEmitter } from "node:events"; import { join, resolve } from "node:path"; -import { Database, fromJson, toJson } from "./db.js"; +import { Database, fromJson } from "./db.js"; import { CentralDatabase } from "./central-db.js"; import type { PluginInstallation, @@ -199,16 +199,12 @@ export class PluginStore extends EventEmitter { * project-state authority remains in PostgreSQL. */ async init(): Promise { - if (this.backendMode) { - /* - FNXC:PluginLegacyMigration 2026-07-15-02:09: - PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states. The startup factory completes the retained-SQLite bridge with its privileged migration connection before constructing the runtime layer; PluginStore.init must not attempt DDL or migration writes through the restricted project-scoped role used by dashboard, serve, desktop, and engine startup. - */ - return; - } - const _ = this.localDb; - const __ = this.centralDb; - this.migrateLegacyProjectRows(); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:06: + PluginStore.init is a no-op under PostgreSQL. The startup factory completes the retained-SQLite bridge with its privileged migration connection before constructing the runtime layer; do not open SQLite local/central DBs here. + FNXC:PluginLegacyMigration 2026-07-15-02:09: + PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states. + */ } private validateIdFormat(id: string): boolean { @@ -448,146 +444,49 @@ export class PluginStore extends EventEmitter { * Backend-mode: delegate to the async Drizzle registerPlugin helper which * inserts the install row + per-project state atomically via a transaction. */ - if (this.backendMode) { - const plugin = await registerPluginAsync(this.asyncLayer!, { - manifest, - path, - settings, - aiScanOnLoad, - projectPath: this.normalizedProjectPath, - }); - this.emit("plugin:registered", plugin); - return plugin; - } - - const existing = this.centralDb - .prepare("SELECT id FROM plugin_installs WHERE id = ?") - .get(manifest.id); - if (existing) { - throw Object.assign(new Error(`Plugin "${manifest.id}" is already registered`), { - code: "EEXISTS", - }); - } - - const defaultSettings: Record = {}; - if (manifest.settingsSchema) { - for (const [key, schema] of Object.entries(manifest.settingsSchema)) { - if (schema.defaultValue !== undefined) { - defaultSettings[key] = schema.defaultValue; - } - } - } - const mergedSettings = { ...defaultSettings, ...settings }; - - const now = new Date().toISOString(); - - this.centralDb - .prepare(` - INSERT INTO plugin_installs ( - id, name, version, description, author, homepage, path, - settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `) - .run( - manifest.id, - manifest.name, - manifest.version, - manifest.description ?? null, - manifest.author ?? null, - manifest.homepage ?? null, - path.trim(), - toJson(mergedSettings), - manifest.settingsSchema ? toJson(manifest.settingsSchema) : null, - toJson(manifest.dependencies || []), - aiScanOnLoad ? 1 : 0, - null, - now, - now, - ); - - this.upsertProjectState(manifest.id, { enabled: true, state: "installed", error: null }); - this.centralDb.bumpLastModified(); - - const plugin = await this.getPlugin(manifest.id); + const plugin = await registerPluginAsync(this.asyncLayer!, { + manifest, + path, + settings, + aiScanOnLoad, + projectPath: this.normalizedProjectPath, + }); this.emit("plugin:registered", plugin); return plugin; - } +} async unregisterPlugin(id: string): Promise { /* * FNXC:SqliteFinalRemoval 2026-06-26-10:15: * Backend-mode: delegate to the async Drizzle unregisterPlugin helper. */ - if (this.backendMode) { - const plugin = await unregisterPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); - this.emit("plugin:unregistered", plugin); - return plugin; - } - const plugin = await this.getPlugin(id); - this.centralDb.prepare("DELETE FROM plugin_installs WHERE id = ?").run(id); - this.centralDb.bumpLastModified(); + const plugin = await unregisterPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); this.emit("plugin:unregistered", plugin); return plugin; - } +} async getPlugin(id: string): Promise { /* * FNXC:SqliteFinalRemoval 2026-06-26-10:15: * Backend-mode: delegate to the async Drizzle getPlugin helper. */ - if (this.backendMode) { - return getPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); - } - const install = this.centralDb - .prepare("SELECT * FROM plugin_installs WHERE id = ?") - .get(id) as InstallRow | undefined; - if (!install) { - throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" }); - } - return this.rowToPlugin(install, this.getProjectState(id)); - } + return getPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); +} async listPlugins(filter?: { enabled?: boolean; state?: PluginState }): Promise { /* * FNXC:SqliteFinalRemoval 2026-06-26-10:15: * Backend-mode: delegate to the async Drizzle listPlugins helper. */ - if (this.backendMode) { - return listPluginsAsync(this.asyncLayer!.db, this.normalizedProjectPath, filter); - } - const installs = this.centralDb - .prepare("SELECT * FROM plugin_installs ORDER BY createdAt ASC") - .all() as InstallRow[]; - - const results = installs.map((install) => this.rowToPlugin(install, this.getProjectState(install.id))); - - return results.filter((plugin) => { - if (filter?.enabled !== undefined && plugin.enabled !== filter.enabled) { - return false; - } - if (filter?.state && plugin.state !== filter.state) { - return false; - } - return true; - }); - } + return listPluginsAsync(this.asyncLayer!.db, this.normalizedProjectPath, filter); +} async enablePlugin(id: string): Promise { /* - * FNXC:SqliteFinalRemoval 2026-06-26-10:15: - * Backend-mode: delegate to the async Drizzle enablePlugin helper. - */ - if (this.backendMode) { - const updated = await enablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); - this.emit("plugin:enabled", updated); - this.emit("plugin:updated", updated); - return updated; - } - await this.getPlugin(id); - this.upsertProjectState(id, { enabled: true }); - this.centralDb.bumpLastModified(); - - const updated = await this.getPlugin(id); + FNXC:SqliteDualPathCleanup 2026-07-26-14:06: + Plugin enable is PostgreSQL-only; SQLite upsertProjectState arm deleted. + */ + const updated = await enablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); this.emit("plugin:enabled", updated); this.emit("plugin:updated", updated); return updated; @@ -595,20 +494,10 @@ export class PluginStore extends EventEmitter { async disablePlugin(id: string): Promise { /* - * FNXC:SqliteFinalRemoval 2026-06-26-10:15: - * Backend-mode: delegate to the async Drizzle disablePlugin helper. - */ - if (this.backendMode) { - const updated = await disablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); - this.emit("plugin:disabled", updated); - this.emit("plugin:updated", updated); - return updated; - } - await this.getPlugin(id); - this.upsertProjectState(id, { enabled: false }); - this.centralDb.bumpLastModified(); - - const updated = await this.getPlugin(id); + FNXC:SqliteDualPathCleanup 2026-07-26-14:06: + Plugin disable is PostgreSQL-only; SQLite upsertProjectState arm deleted. + */ + const updated = await disablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); this.emit("plugin:disabled", updated); this.emit("plugin:updated", updated); return updated; @@ -631,25 +520,16 @@ export class PluginStore extends EventEmitter { } /* - * FNXC:SqliteFinalRemoval 2026-06-26-10:20: - * Backend-mode: delegate state persistence to the async helper. - */ - if (this.backendMode) { - const updated = await updatePluginStateAsync( - this.asyncLayer!.db, - id, - this.normalizedProjectPath, - state, - error, - ); - this.emit("plugin:updated", updated); - return updated; - } - - this.upsertProjectState(id, { state, error }); - this.centralDb.bumpLastModified(); - - const updated = await this.getPlugin(id); + FNXC:SqliteDualPathCleanup 2026-07-26-14:06: + Plugin state updates are PostgreSQL-only. + */ + const updated = await updatePluginStateAsync( + this.asyncLayer!.db, + id, + this.normalizedProjectPath, + state, + error, + ); this.emit("plugin:updated", updated); return updated; } @@ -667,26 +547,16 @@ export class PluginStore extends EventEmitter { } /* - * FNXC:SqliteFinalRemoval 2026-06-26-10:20: - * Backend-mode: delegate state persistence to the async helper. - */ - if (this.backendMode) { - const updated = await updatePluginStateAsync( - this.asyncLayer!.db, - id, - this.normalizedProjectPath, - state, - error ?? null, - ); - this.emit("plugin:stateChanged", updated, oldState, state); - this.emit("plugin:updated", updated); - return updated; - } - - this.upsertProjectState(id, { state, error: error ?? null }); - this.centralDb.bumpLastModified(); - - const updated = await this.getPlugin(id); + FNXC:SqliteDualPathCleanup 2026-07-26-14:06: + Plugin state transitions are PostgreSQL-only; SQLite upsertProjectState arm deleted. + */ + const updated = await updatePluginStateAsync( + this.asyncLayer!.db, + id, + this.normalizedProjectPath, + state, + error ?? null, + ); this.emit("plugin:stateChanged", updated, oldState, state); this.emit("plugin:updated", updated); return updated; @@ -706,22 +576,11 @@ export class PluginStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:25: * Backend-mode: delegate settings persistence to the async helper. */ - if (this.backendMode) { - await updatePluginSettingsAsync(this.asyncLayer!.db, id, mergedSettings); - const updated = await this.getPlugin(id); - this.emit("plugin:updated", updated); - return updated; - } - - this.centralDb - .prepare("UPDATE plugin_installs SET settings = ?, updatedAt = ? WHERE id = ?") - .run(toJson(mergedSettings), new Date().toISOString(), id); - this.centralDb.bumpLastModified(); - + await updatePluginSettingsAsync(this.asyncLayer!.db, id, mergedSettings); const updated = await this.getPlugin(id); this.emit("plugin:updated", updated); return updated; - } +} async updatePlugin(id: string, updates: PluginUpdateInput): Promise { await this.getPlugin(id); @@ -730,80 +589,19 @@ export class PluginStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:25: * Backend-mode: delegate install-field persistence to the async helper. */ - if (this.backendMode) { - await updatePluginInstallAsync(this.asyncLayer!.db, id, { - name: updates.name, - version: updates.version, - description: updates.description, - author: updates.author, - homepage: updates.homepage, - path: updates.path, - dependencies: updates.dependencies, - aiScanOnLoad: updates.aiScanOnLoad, - lastSecurityScan: updates.lastSecurityScan, - }); - const updated = await this.getPlugin(id); - this.emit("plugin:updated", updated); - return updated; - } - - const now = new Date().toISOString(); - - const setClauses: string[] = ["updatedAt = ?"]; - const params: (string | null | number)[] = [now]; - - if (updates.name !== undefined) { - setClauses.push("name = ?"); - params.push(updates.name); - } - if (updates.version !== undefined) { - setClauses.push("version = ?"); - params.push(updates.version); - } - if (updates.description !== undefined) { - setClauses.push("description = ?"); - params.push(updates.description ?? null); - } - if (updates.author !== undefined) { - setClauses.push("author = ?"); - params.push(updates.author ?? null); - } - if (updates.homepage !== undefined) { - setClauses.push("homepage = ?"); - params.push(updates.homepage ?? null); - } - if (updates.path !== undefined) { - setClauses.push("path = ?"); - params.push(updates.path); - } - if (updates.dependencies !== undefined) { - setClauses.push("dependencies = ?"); - params.push(toJson(updates.dependencies)); - } - /* - FNXC:Plugins 2026-07-12-10:59: - FN-7855 requires updatePlugin to persist manifest settingsSchema changes independently from per-project setting values so path-registered plugin reloads can refresh dashboard metadata without unregistering the plugin. - Undefined means "leave schema unchanged"; null explicitly clears the persisted schema when a rebuilt manifest removes it. - */ - if (updates.settingsSchema !== undefined) { - setClauses.push("settingsSchema = ?"); - params.push(updates.settingsSchema === null ? null : toJson(updates.settingsSchema)); - } - if (updates.aiScanOnLoad !== undefined) { - setClauses.push("aiScanOnLoad = ?"); - params.push(updates.aiScanOnLoad ? 1 : 0); - } - if (updates.lastSecurityScan !== undefined) { - setClauses.push("lastSecurityScan = ?"); - params.push(toJson(updates.lastSecurityScan)); - } - - params.push(id); - this.centralDb.prepare(`UPDATE plugin_installs SET ${setClauses.join(", ")} WHERE id = ?`).run(...params); - this.centralDb.bumpLastModified(); - + await updatePluginInstallAsync(this.asyncLayer!.db, id, { + name: updates.name, + version: updates.version, + description: updates.description, + author: updates.author, + homepage: updates.homepage, + path: updates.path, + dependencies: updates.dependencies, + aiScanOnLoad: updates.aiScanOnLoad, + lastSecurityScan: updates.lastSecurityScan, + }); const updated = await this.getPlugin(id); this.emit("plugin:updated", updated); return updated; - } +} } diff --git a/packages/core/src/routine-store.ts b/packages/core/src/routine-store.ts index ab6df76d45..7074c28d1c 100644 --- a/packages/core/src/routine-store.ts +++ b/packages/core/src/routine-store.ts @@ -129,26 +129,14 @@ export class RoutineStore extends EventEmitter { * backendMode and use the async helpers instead. */ private get db(): Database { - if (this.backendMode) { - throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); - } - if (!this._db) { - const fusionDir = `${this.rootDir}/.fusion`; - this._db = new Database(fusionDir); - this._db.init(); - } - return this._db; - } + throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); +} /** Initialize the store (no-op in backend mode, DB is lazily initialized otherwise). */ async init(): Promise { // FNXC:SqliteFinalRemoval 2026-06-26-10:30: No-op in backend mode. - if (this.backendMode) { - return; - } - // Trigger lazy init - const _ = this.db; - } + return; +} // ── Row Conversion ───────────────────────────────────────────────── @@ -219,63 +207,9 @@ export class RoutineStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:35: * Backend-mode: delegate to the async Drizzle upsertRoutine helper. */ - if (this.backendMode) { - await upsertRoutineAsync(this.asyncLayer!.db, routine); - return; - } - - const trigger = routine.trigger; - let triggerConfig: Record = {}; - - if (isCronTrigger(trigger)) { - triggerConfig = { - cronExpression: trigger.cronExpression, - timezone: trigger.timezone, - }; - } else if (trigger.type === "webhook") { - triggerConfig = { - webhookPath: trigger.webhookPath, - secret: trigger.secret, - }; - } else if (trigger.type === "api") { - triggerConfig = { - endpoint: trigger.endpoint, - }; - } - - this.db.prepare(` - INSERT OR REPLACE INTO routines ( - id, agentId, name, description, triggerType, triggerConfig, - command, steps, timeoutMs, - catchUpPolicy, executionPolicy, catchUpLimit, enabled, - lastRunAt, lastRunResult, nextRunAt, - runCount, runHistory, scope, createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - routine.id, - routine.agentId, - routine.name, - routine.description ?? null, - trigger.type, - JSON.stringify(triggerConfig), - routine.command ?? null, - routine.steps ? JSON.stringify(routine.steps) : null, - routine.timeoutMs ?? null, - routine.catchUpPolicy, - routine.executionPolicy, - routine.catchUpLimit ?? 5, - routine.enabled ? 1 : 0, - routine.lastRunAt ?? null, - routine.lastRunResult ? JSON.stringify(routine.lastRunResult) : null, - routine.nextRunAt ?? null, - routine.runCount || 0, - JSON.stringify(routine.runHistory || []), - routine.scope ?? "project", - routine.createdAt, - routine.updatedAt, - ); - this.db.bumpLastModified(); - } + await upsertRoutineAsync(this.asyncLayer!.db, routine); + return; +} // ── Locking ─────────────────────────────────────────────────────── @@ -389,23 +323,20 @@ export class RoutineStore extends EventEmitter { one PostgreSQL transaction, so a failed revision insert cannot expose a routine with no restore point. */ - if (this.backendMode) { - await this.asyncLayer!.transactionImmediate(async (tx) => { - await upsertRoutineAsync(tx, routine); - const revision = createConfigurationRevision({ - projectId: this.asyncLayer!.projectId ?? "", - ownerScope: "project", - configKind: "routine", - configTarget: { routineId: routine.id }, - before: null, - after: routine, - changedBy, - }); - if (revision) await appendConfigurationRevision(tx, revision); + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertRoutineAsync(tx, routine); + const revision = createConfigurationRevision({ + projectId: this.asyncLayer!.projectId ?? "", + ownerScope: "project", + configKind: "routine", + configTarget: { routineId: routine.id }, + before: null, + after: routine, + changedBy, }); - } else { - await this.upsertRoutine(routine); - } + if (revision) await appendConfigurationRevision(tx, revision); + }); + this.emit("routine:created", routine); return routine; } @@ -418,15 +349,8 @@ export class RoutineStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:40: * Backend-mode: delegate to the async Drizzle getRoutine helper. */ - if (this.backendMode) { - return getRoutineAsync(this.asyncLayer!.db, id); - } - const row = this.db.prepare("SELECT * FROM routines WHERE id = ?").get(id) as unknown as RoutineRow | undefined; - if (!row) { - throw Object.assign(new Error(`Routine '${id}' not found`), { code: "ENOENT" }); - } - return this.rowToRoutine(row); - } + return getRoutineAsync(this.asyncLayer!.db, id); +} /** * List all routines. @@ -436,12 +360,8 @@ export class RoutineStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:40: * Backend-mode: delegate to the async Drizzle listRoutines helper. */ - if (this.backendMode) { - return listRoutinesAsync(this.asyncLayer!.db); - } - const rows = this.db.prepare("SELECT * FROM routines ORDER BY createdAt ASC").all() as unknown as RoutineRow[]; - return rows.map((row) => this.rowToRoutine(row)); - } + return listRoutinesAsync(this.asyncLayer!.db); +} /** * Update an existing routine. @@ -495,23 +415,20 @@ export class RoutineStore extends EventEmitter { } routine.updatedAt = new Date().toISOString(); - if (this.backendMode) { - await this.asyncLayer!.transactionImmediate(async (tx) => { - await upsertRoutineAsync(tx, routine); - const revision = createConfigurationRevision({ - projectId: this.asyncLayer!.projectId ?? "", - ownerScope: "project", - configKind: "routine", - configTarget: { routineId: routine.id }, - before, - after: routine, - changedBy, - }); - if (revision) await appendConfigurationRevision(tx, revision); + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertRoutineAsync(tx, routine); + const revision = createConfigurationRevision({ + projectId: this.asyncLayer!.projectId ?? "", + ownerScope: "project", + configKind: "routine", + configTarget: { routineId: routine.id }, + before, + after: routine, + changedBy, }); - } else { - await this.upsertRoutine(routine); - } + if (revision) await appendConfigurationRevision(tx, revision); + }); + this.emit("routine:updated", routine); return routine; }); @@ -551,24 +468,20 @@ export class RoutineStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:40: * Backend-mode: delegate to the async Drizzle deleteRoutine helper. */ - if (this.backendMode) { - await this.asyncLayer!.transactionImmediate(async (tx) => { - await deleteRoutineAsync(tx, id); - const revision = createConfigurationRevision({ - projectId: this.asyncLayer!.projectId ?? "", - ownerScope: "project", - configKind: "routine", - configTarget: { routineId: routine.id }, - before: routine, - after: null, - changedBy, - }); - if (revision) await appendConfigurationRevision(tx, revision); + await this.asyncLayer!.transactionImmediate(async (tx) => { + await deleteRoutineAsync(tx, id); + const revision = createConfigurationRevision({ + projectId: this.asyncLayer!.projectId ?? "", + ownerScope: "project", + configKind: "routine", + configTarget: { routineId: routine.id }, + before: routine, + after: null, + changedBy, }); - } else { - this.db.prepare("DELETE FROM routines WHERE id = ?").run(id); - this.db.bumpLastModified(); - } + if (revision) await appendConfigurationRevision(tx, revision); + }); + this.emit("routine:deleted", routine); return routine; }); @@ -684,14 +597,8 @@ export class RoutineStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:45: * Backend-mode: delegate to the async Drizzle getDueRoutines helper. */ - if (this.backendMode) { - return getDueRoutinesAsync(this.asyncLayer!.db, now, scope); - } - const rows = this.db.prepare( - "SELECT * FROM routines WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ? AND scope = ?" - ).all(now, scope) as unknown as RoutineRow[]; - return rows.map((row) => this.rowToRoutine(row)); - } + return getDueRoutinesAsync(this.asyncLayer!.db, now, scope); +} /** * Get all routines that are due to run (nextRunAt <= now and enabled) for both scopes. @@ -703,12 +610,6 @@ export class RoutineStore extends EventEmitter { * FNXC:SqliteFinalRemoval 2026-06-26-10:45: * Backend-mode: delegate to the async Drizzle getDueRoutines helper (no scope). */ - if (this.backendMode) { - return getDueRoutinesAsync(this.asyncLayer!.db, now); - } - const rows = this.db.prepare( - "SELECT * FROM routines WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?" - ).all(now) as unknown as RoutineRow[]; - return rows.map((row) => this.rowToRoutine(row)); - } + return getDueRoutinesAsync(this.asyncLayer!.db, now); +} } diff --git a/packages/core/src/secrets-store.ts b/packages/core/src/secrets-store.ts index f75bfb2f52..879670c205 100644 --- a/packages/core/src/secrets-store.ts +++ b/packages/core/src/secrets-store.ts @@ -1,10 +1,9 @@ import { createLogger } from "./logger.js"; const severityAuditLog = createLogger("core-secrets-store"); -import { randomUUID } from "node:crypto"; import type { Database as ProjectDatabase } from "./db.js"; import type { CentralDatabase } from "./central-db.js"; -import { createSecretCipher, SecretCryptoError, type MasterKeyProvider } from "./secrets-crypto.js"; +import { createSecretCipher, type MasterKeyProvider } from "./secrets-crypto.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; import * as asyncSecretsStore from "./async-secrets-store.js"; @@ -50,10 +49,6 @@ interface SecretRow { last_read_by: string | null; } -interface SecretCipherRow extends SecretRow { - value_ciphertext: Buffer; - nonce: Buffer; -} type SecretsDb = Pick | Pick; @@ -92,13 +87,7 @@ export class SecretsStoreError extends Error { } } -function tableForScope(scope: SecretScope): "secrets" | "secrets_global" { - return scope === "project" ? "secrets" : "secrets_global"; -} -function isSqliteUniqueError(error: unknown): boolean { - return error instanceof Error && /UNIQUE constraint failed/u.test(error.message); -} function isAccessPolicy(value: string): value is SecretAccessPolicy { return value === "auto" || value === "prompt" || value === "deny"; @@ -159,22 +148,8 @@ export class SecretsStore { } async listSecrets(scope?: SecretScope): Promise { - if (this.backendMode) { - return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope); - } - if (scope) { - const db = this.dbForScope(scope); - const table = tableForScope(scope); - const rows = db.prepare(`SELECT id, key, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by FROM ${table} ORDER BY key COLLATE NOCASE ASC`).all() as SecretRow[]; - return rows.map((row) => this.rowToRecord(row, scope)); - } - - const [project, global] = await Promise.all([ - this.listSecrets("project"), - this.listSecrets("global"), - ]); - return [...project, ...global]; - } + return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope); +} async listEnvExportable(opts?: { keyPrefix?: string }): Promise { const keyPrefix = opts?.keyPrefix; @@ -221,14 +196,8 @@ export class SecretsStore { } async getSecretMetadata(id: string, scope: SecretScope): Promise { - if (this.backendMode) { - return asyncSecretsStore.getSecretMetadata(this.asyncLayer!.db, id, scope); - } - const db = this.dbForScope(scope); - const table = tableForScope(scope); - const row = db.prepare(`SELECT id, key, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by FROM ${table} WHERE id = ?`).get(id) as SecretRow | undefined; - return row ? this.rowToRecord(row, scope) : null; - } + return asyncSecretsStore.getSecretMetadata(this.asyncLayer!.db, id, scope); +} async createSecret(input: { scope: SecretScope; @@ -247,45 +216,10 @@ export class SecretsStore { throw new SecretsStoreError({ code: "invalid-policy", message: "Invalid access policy" }); } - if (this.backendMode) { - const created = await asyncSecretsStore.createSecret(this.asyncLayer!.db, this.cipher, input); - this.emitAudit({ mutationType: "secret:create", scope: input.scope, secretId: created.id, key: created.key }); - return created; - } - - const now = new Date().toISOString(); - const id = randomUUID(); - const encrypted = await this.cipher.encrypt(input.plaintextValue); - const scope = input.scope; - const db = this.dbForScope(scope); - const table = tableForScope(scope); - - try { - db.prepare(`INSERT INTO ${table} (id, key, value_ciphertext, nonce, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)`) - .run( - id, - key, - encrypted.ciphertext, - encrypted.nonce, - input.description ?? null, - input.accessPolicy ?? "auto", - input.envExportable ? 1 : 0, - input.envExportKey ?? null, - now, - now, - ); - db.bumpLastModified(); - } catch (error) { - if (isSqliteUniqueError(error)) { - throw new SecretsStoreError({ code: "duplicate-key", message: "Secret key already exists" }); - } - throw error; - } - - const created = (await this.getSecretMetadata(id, scope))!; - this.emitAudit({ mutationType: "secret:create", scope, secretId: created.id, key: created.key }); + const created = await asyncSecretsStore.createSecret(this.asyncLayer!.db, this.cipher, input); + this.emitAudit({ mutationType: "secret:create", scope: input.scope, secretId: created.id, key: created.key }); return created; - } +} async updateSecret(id: string, scope: SecretScope, patch: { key?: string; @@ -295,135 +229,28 @@ export class SecretsStore { envExportable?: boolean; envExportKey?: string | null; }): Promise { - if (this.backendMode) { - const updated = await asyncSecretsStore.updateSecret(this.asyncLayer!.db, this.cipher, id, scope, patch); - this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key }); - return updated; - } - - const existing = await this.getSecretMetadata(id, scope); - if (!existing) { - throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); - } - - const updates: string[] = ["updated_at = ?"]; - const params: Array = [new Date().toISOString()]; - - if (patch.key !== undefined) { - const key = patch.key.trim(); - if (!key) { - throw new SecretsStoreError({ code: "invalid-key", message: "Secret key is required" }); - } - updates.push("key = ?"); - params.push(key); - } - - if (patch.description !== undefined) { - updates.push("description = ?"); - params.push(patch.description ?? null); - } - - if (patch.accessPolicy !== undefined) { - if (!isAccessPolicy(patch.accessPolicy)) { - throw new SecretsStoreError({ code: "invalid-policy", message: "Invalid access policy" }); - } - updates.push("access_policy = ?"); - params.push(patch.accessPolicy); - } - - if (patch.envExportable !== undefined) { - updates.push("env_exportable = ?"); - params.push(patch.envExportable ? 1 : 0); - } - - if (patch.envExportKey !== undefined) { - updates.push("env_export_key = ?"); - params.push(patch.envExportKey ?? null); - } - - if (patch.plaintextValue !== undefined) { - const encrypted = await this.cipher.encrypt(patch.plaintextValue); - updates.push("value_ciphertext = ?", "nonce = ?"); - params.push(encrypted.ciphertext, encrypted.nonce); - } - - const db = this.dbForScope(scope); - const table = tableForScope(scope); - - try { - params.push(id); - db.prepare(`UPDATE ${table} SET ${updates.join(", ")} WHERE id = ?`).run(...params); - db.bumpLastModified(); - } catch (error) { - if (isSqliteUniqueError(error)) { - throw new SecretsStoreError({ code: "duplicate-key", message: "Secret key already exists" }); - } - throw error; - } - - const updated = (await this.getSecretMetadata(id, scope))!; + const updated = await asyncSecretsStore.updateSecret(this.asyncLayer!.db, this.cipher, id, scope, patch); this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key }); return updated; - } +} async deleteSecret(id: string, scope: SecretScope): Promise { - if (this.backendMode) { - const existing = await this.getSecretMetadata(id, scope); - if (!existing) { - throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); - } - await asyncSecretsStore.deleteSecret(this.asyncLayer!.db, id, scope); - this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key }); - return; - } - - const existing = await this.getSecretMetadata(id, scope); + const existing = await this.getSecretMetadata(id, scope); if (!existing) { throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); } - - const db = this.dbForScope(scope); - const table = tableForScope(scope); - db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id); - db.bumpLastModified(); + await asyncSecretsStore.deleteSecret(this.asyncLayer!.db, id, scope); this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key }); - } + return; +} async revealSecret( id: string, scope: SecretScope, reader: { agentId?: string | null; userId?: string | null }, ): Promise<{ key: string; plaintextValue: string }> { - if (this.backendMode) { - const revealed = await asyncSecretsStore.revealSecret(this.asyncLayer!.db, this.cipher, id, scope, reader); - this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: revealed.key, actor: reader }); - return revealed; - } - - const db = this.dbForScope(scope); - const table = tableForScope(scope); - const row = db.prepare(`SELECT id, key, value_ciphertext, nonce, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by FROM ${table} WHERE id = ?`).get(id) as SecretCipherRow | undefined; - - if (!row) { - throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); - } - - let plaintextValue: string; - try { - plaintextValue = await this.cipher.decrypt({ ciphertext: row.value_ciphertext, nonce: row.nonce }); - } catch (error) { - if (error instanceof SecretCryptoError && error.code === "decryption-failed") { - throw new SecretsStoreError({ code: "decrypt-failed", message: "Secret decryption failed" }); - } - throw new SecretsStoreError({ code: "decrypt-failed", message: "Secret decryption failed" }); - } - - const now = new Date().toISOString(); - const lastReadBy = reader.userId ?? reader.agentId ?? null; - db.prepare(`UPDATE ${table} SET last_read_at = ?, last_read_by = ?, updated_at = ? WHERE id = ?`).run(now, lastReadBy, now, id); - db.bumpLastModified(); - - this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: row.key, actor: reader }); - return { key: row.key, plaintextValue }; - } + const revealed = await asyncSecretsStore.revealSecret(this.asyncLayer!.db, this.cipher, id, scope, reader); + this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: revealed.key, actor: reader }); + return revealed; +} } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d3dda5172c..3cca6e96c2 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6,7 +6,6 @@ import * as schema from "./postgres/schema/index.js"; import { type FSWatcher } from "node:fs"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrThreadState, PrThreadOutcome, PluginActivation, PluginActivationInput } from "./types.js"; - export type OverlapBlockerRepairReason = | "task-not-found" | "no-overlap-blocker" @@ -128,7 +127,7 @@ import { createWorkflowStepImpl, updateWorkflowStepImpl, updateWorkflowDefinitio import { initImpl, setupActivityLogListenersImpl, reconcileOrphanedTaskDirsImpl, watchImpl, checkForChangesImpl, migrateAgentLogEntriesImpl, migrateMovedSettingsImpl, recoverStaleTransitionPendingImpl, migrateLegacyWorkflowStepsImpl, emitTaskLifecycleEventSafelyImpl } from "./task-store/lifecycle-ops.js"; import { updateStepImpl, startStepImpl, acquireMergeQueueLeaseImpl, mergeTaskImpl } from "./task-store/merge-queue-ops.js"; import { addCommentImpl, publishArchivedTaskDocumentAdditionImpl, upsertTaskDocumentImpl } from "./task-store/comments-ops.js"; -import { deleteTaskImpl, deleteTaskIfImpl, archiveTaskImpl, type DeleteTaskIfResult } from "./task-store/archive-lifecycle.js"; +import { deleteTaskImpl, archiveTaskImpl, type DeleteTaskIfResult } from "./task-store/archive-lifecycle.js"; import type { TaskDeleteAuditContext } from "./task-delete-attribution.js"; import { updateSettingsImpl, updateGlobalSettingsImpl } from "./task-store/settings-ops.js"; import { createTaskBackendImpl, _createTaskInternalBackendImpl, createTaskImpl, createTaskWithReservedIdImpl, _createTaskInternalImpl, _maybeAutoArchiveSameAgentDuplicateImpl } from "./task-store/task-creation.js"; @@ -145,7 +144,6 @@ import type { BranchGroupRow, PrEntityRow, TaskDocumentRow, ArtifactRow, TaskDoc /** Database row shape for the tasks table (all columns). */ - export interface TaskStoreEvents { "task:created": [task: Task]; "task:moved": [data: { task: Task; from: ColumnId; to: ColumnId; source: "user" | "engine" | "scheduler" }]; @@ -957,20 +955,10 @@ export class TaskStore extends EventEmitter { } public async _createTaskInternal( input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; }, ): Promise { /* - FNXC:SqliteFinalRemoval 2026-06-25-10:35: - Route to the async backend variant when the store is in backend mode so - callers like createTaskWithReservedId (which go through this internal - create path with an explicit reserved id) work against PostgreSQL. The - sync path uses atomicCreateTaskJson -> store.db.transactionImmediate(), - which throws "SQLite Database is not available in backend mode". The - backend variant uses layer.transactionImmediate + insertTaskRowInTransaction - against PostgreSQL, preserving create-class non-destructive insert - semantics (see docs/storage.md invariants). + FNXC:SqliteDualPathCleanup 2026-07-26-14:05: + Task create is PostgreSQL-only (layer.transactionImmediate + insertTaskRowInTransaction). The former sync SQLite _createTaskInternalImpl arm is deleted; production always injects AsyncDataLayer. */ - if (this.backendMode) { - return _createTaskInternalBackendImpl(this, input, title, resolvedWorkflowSteps, id, options); - } - return _createTaskInternalImpl(this, input, title, resolvedWorkflowSteps, id, options); + return _createTaskInternalBackendImpl(this, input, title, resolvedWorkflowSteps, id, options); } public async _maybeAutoArchiveSameAgentDuplicate(task: Task, input: TaskCreateInput): Promise { return _maybeAutoArchiveSameAgentDuplicateImpl(this, task, input); @@ -1077,34 +1065,29 @@ export class TaskStore extends EventEmitter { async findOpenRevertTaskForSource(sourceTaskId: string): Promise { const trimmedId = sourceTaskId.trim(); if (trimmedId.length === 0) return null; - if (this.backendMode) { - const layer = this.asyncLayer!; - const rows = await layer.db.select() - .from(schema.project.tasks) - .where(and( - isNull(schema.project.tasks.deletedAt), - ne(schema.project.tasks.column, "archived"), - ne(schema.project.tasks.column, "done"), - eq(sql`json_extract(${schema.project.tasks.sourceMetadata}->>'revertOf')`, trimmedId), - )) - .orderBy(schema.project.tasks.createdAt) - .limit(1); - if (rows.length === 0) return null; - return this.rowToTask(this.pgRowToTaskRow(rows[0] as Record)); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + PostgreSQL jsonb uses `->>` for text extraction; the former SQLite json_extract wrapper was invalid on PG. + Scope by asyncLayer.projectId so a revert task from another project cannot match. + */ + const layer = this.asyncLayer!; + const conditions = [ + isNull(schema.project.tasks.deletedAt), + ne(schema.project.tasks.column, "archived"), + ne(schema.project.tasks.column, "done"), + sql`${schema.project.tasks.sourceMetadata}->>'revertOf' = ${trimmedId}`, + ]; + if (layer.projectId) { + conditions.push(eq(schema.project.tasks.projectId, layer.projectId)); } - const selectClause = this.getTaskSelectClause(false, "t"); - const row = this.db.prepare(` - SELECT ${selectClause} - FROM tasks t - WHERE t."deletedAt" IS NULL - AND t."column" != 'archived' - AND t."column" != 'done' - AND json_extract(t.sourceMetadata, '$.revertOf') = ? - ORDER BY t.createdAt DESC - LIMIT 1 - `).get(trimmedId) as TaskRow | undefined; - return row ? this.rowToTask(row) : null; - } + const rows = await layer.db.select() + .from(schema.project.tasks) + .where(and(...conditions)) + .orderBy(schema.project.tasks.createdAt) + .limit(1); + if (rows.length === 0) return null; + return this.rowToTask(this.pgRowToTaskRow(rows[0] as Record)); +} /** Persist (idempotent upsert) one branch's progress for a fan-out run (#1407). */ async saveWorkflowRunBranch(state: { taskId: string; runId: string; branchId: string; currentNodeId: string; status: string; }): Promise { @@ -2005,8 +1988,11 @@ export class TaskStore extends EventEmitter { predicate: (live: Task) => boolean | Promise, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext }, ): Promise { - if (this.backendMode) return deleteTaskIfBackendImpl(this, id, predicate, options); - return deleteTaskIfImpl(this, id, predicate, options); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:05: + Conditional delete is PostgreSQL-only; the SQLite deleteTaskIfImpl arm is deleted. + */ + return deleteTaskIfBackendImpl(this, id, predicate, options); } public deleteTaskById(taskId: string): void { return deleteTaskByIdImpl(this, taskId); diff --git a/packages/core/src/task-store/agent-logs.ts b/packages/core/src/task-store/agent-logs.ts index 0910ab09c8..be8bb61e48 100644 --- a/packages/core/src/task-store/agent-logs.ts +++ b/packages/core/src/task-store/agent-logs.ts @@ -26,7 +26,7 @@ export function flushAgentLogBufferImpl(store: TaskStore): void { const batch = store.agentLogBuffer.slice(); const flushCount = batch.length; - let validEntries = batch; + const validEntries = batch; const flushedEntries = new Set(); try { // FNXC:PostgresBackend 2026-06-27-00:40: @@ -46,18 +46,7 @@ export function flushAgentLogBufferImpl(store: TaskStore): void { // JSONL line + records goal citations under a just-deleted taskId. There // is no FK on goal_citations.task_id (plain text column), so this is an // orphaned-by-value metadata row, not a constraint violation or crash. - if (!store.backendMode) { - const liveTaskIds = new Set( - (store.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id), - ); - validEntries = batch.filter((entry) => liveTaskIds.has(entry.taskId)); - const dropped = batch.length - validEntries.length; - if (dropped > 0) { - severityAuditLog.warn( - `[fusion] Dropped ${dropped} buffered agent log entries for deleted tasks (${store.fusionDir})`, - ); - } - } + if (validEntries.length > 0) { const citationInputs: GoalCitationInput[] = []; @@ -106,9 +95,7 @@ export function flushAgentLogBufferImpl(store: TaskStore): void { severityAuditLog.warn("[fusion] Failed to record goal citations from agent_log batch:", err); } } - if (!store.backendMode) { - store.db.bumpLastModified(); - } + } } finally { store.agentLogBuffer.splice(0, flushCount); @@ -147,17 +134,8 @@ export async function appendAgentLogBatchImpl(store: TaskStore, entries: Array<{ // PG backend mode: skip the sync SQLite deleted-task pre-filter (store.db // throws) — JSONL append below is the backend-independent durable write. // See flushAgentLogBufferImpl for the full rationale. - let validEntries = normalizedEntries; - if (!store.backendMode) { - const liveTaskIds = new Set( - (store.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id), - ); - validEntries = normalizedEntries.filter((entry) => liveTaskIds.has(entry.taskId)); - const dropped = normalizedEntries.length - validEntries.length; - if (dropped > 0) { - severityAuditLog.warn(`[fusion] Dropped ${dropped} batch agent log entries for deleted tasks (${store.fusionDir})`); - } - } + const validEntries = normalizedEntries; + const citationInputs: GoalCitationInput[] = []; const entriesByTask = new Map(); diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index d09acaceee..8f6ac80ece 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -350,20 +350,13 @@ export async function listArchivedTasksImpl(store: TaskStore, options?: { const offset = Math.max(0, Math.trunc(rawOffset) || 0); const slim = options?.slim ?? true; - if (store.backendMode) { - const layer = store.asyncLayer!; - // FNXC:MultiProjectIsolation 2026-07-12 (PR #2007 review): the archived - // board and its count are scoped to the bound project — the shared - // cold-storage table would otherwise surface every project's archived - // tasks in every project's dashboard. - const total = await getArchivedRowCount(layer.db, layer.projectId); - const entries = await listArchivedTaskEntriesPage(layer.db, limit, offset, layer.projectId); - const tasks = entries.map((entry) => store.archiveEntryToTask(entry, slim)); - return { tasks, total, hasMore: offset + tasks.length < total }; - } - - const total = store.archiveDb.getArchivedRowCount(); - const entries = store.archiveDb.listPage(limit, offset); + const layer = store.asyncLayer!; + // FNXC:MultiProjectIsolation 2026-07-12 (PR #2007 review): the archived + // board and its count are scoped to the bound project — the shared + // cold-storage table would otherwise surface every project's archived + // tasks in every project's dashboard. + const total = await getArchivedRowCount(layer.db, layer.projectId); + const entries = await listArchivedTaskEntriesPage(layer.db, limit, offset, layer.projectId); const tasks = entries.map((entry) => store.archiveEntryToTask(entry, slim)); return { tasks, total, hasMore: offset + tasks.length < total }; } @@ -375,134 +368,70 @@ export async function unarchiveTaskImpl(store: TaskStore, id: string): Promise { - // Re-read task.json (either existing or freshly restored) - const task = await store.readTaskJson(dir); + const preArchiveColumn = task.preArchiveColumn ?? "todo"; + const toColumn = store.resolveUnarchiveTargetColumn(preArchiveColumn); - // Initialize log array if missing (for legacy tasks) - if (!task.log) { - task.log = []; - } + /* + * FNXC:SqliteFinalRemoval 2026-06-25: + * Directly update the column instead of calling moveTask. The VALID_TRANSITIONS + * graph only allows archived→done, but unarchive needs to restore to the + * preArchiveColumn (todo/in-progress/etc). The SQLite path bypasses transition + * validation by directly setting task.column; the backend path must do the same + * via a direct UPDATE. Using moveTask would throw "Invalid transition" for any + * target other than "done". + */ + const now = new Date().toISOString(); + await layer.db + .update(schema.project.tasks) + .set({ + column: toColumn, + deletedAt: null, + columnMovedAt: now, + updatedAt: now, + }) + .where(and( + eq(schema.project.tasks.projectId, projectPartition(layer.projectId)), + eq(schema.project.tasks.id, id), + )); - if (task.column !== "archived") { - throw new Error( - `Cannot unarchive ${id}: task is in '${task.column}', must be in 'archived'`, - ); - } + const updatedTask = await store.getTask(id); - // NOTE: No getTaskMergeBlocker check here — intentionally. - // The merge blocker validates in-review → done transitions (ensuring code - // has been properly reviewed before merging). An unarchived task was already - // archived in its previous lifecycle; this is just a restoration. The transient - // field clearing below ensures no stale blocker state leaks through. - const preArchiveColumn = task.preArchiveColumn ?? await store.readPreArchiveColumnFromTaskFile(dir); - const toColumn = store.resolveUnarchiveTargetColumn(preArchiveColumn); - task.column = toColumn; - task.preArchiveColumn = undefined; - task.columnMovedAt = new Date().toISOString(); - task.updatedAt = task.columnMovedAt; + // Log the unarchive action. + await store.logEntry(id, "Task unarchived"); - // Clear transient fields regardless of the restored column. Archived tasks - // may have been archived with stale execution state that should not reappear - // after unarchiving, especially when active columns are downgraded to todo. - store.clearDoneTransientFields(task); + // Remove from archive table. + await deleteArchivedTaskEntry(layer.db, id, layer.projectId); - task.log.push({ - timestamp: task.columnMovedAt, - action: "Task unarchived", - }); - - await store.atomicWriteTaskJson(dir, task); - store.archiveDb.delete(id); - - // Update cache if watcher is active - if (store.isWatching) store.taskCache.set(id, { ...task }); - - store.emit("task:moved", { task, from: "archived" as Column, to: toColumn, source: "engine" }); - return task; - }); - } + return updatedTask; +} export async function restoreFromArchiveImpl(store: TaskStore, entry: import("../types.js").ArchivedTaskEntry): Promise { const dir = store.taskDir(entry.id); diff --git a/packages/core/src/task-store/archive-lifecycle.ts b/packages/core/src/task-store/archive-lifecycle.ts index e31b865687..5f0dc5ab73 100644 --- a/packages/core/src/task-store/archive-lifecycle.ts +++ b/packages/core/src/task-store/archive-lifecycle.ts @@ -7,11 +7,9 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore, storeLog} from "../store.js"; -import {MissionStore} from "../mission-store.js"; -import {TaskHasDependentsError, TaskHasLineageChildrenError, TaskNotFoundError, TaskSelfDeleteError} from "./errors.js"; -import {isWorkspaceTask, type Task, type Column, type GithubIssueAction} from "../types.js"; -import {buildDeleteCallerAuditFields, type TaskDeleteAuditContext} from "../task-delete-attribution.js"; -import {notifyOperatorOfNonOperatorDelete, type TaskDeleteNoticeSnapshot} from "../task-delete-notice.js"; +import {TaskSelfDeleteError} from "./errors.js"; +import {isWorkspaceTask, type Task, type GithubIssueAction} from "../types.js"; +import {type TaskDeleteAuditContext} from "../task-delete-attribution.js"; import "../builtin-traits.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {toJson} from "../db-helpers.js"; @@ -154,7 +152,7 @@ export async function disposeArchivedWorktree(store: TaskStore, task: Task): Pro } finally { if (reservation.state === "held") await reservation.release(); } } -function scheduleDeleteBranchCleanup(store: TaskStore, task: Task): void { +function _scheduleDeleteBranchCleanup(store: TaskStore, task: Task): void { /* FNXC:TaskDeletion 2026-07-15-09:45: Soft-delete latency must be bounded by the database mutation, audit, and event emission; branch cleanup can spawn serialized git subprocesses and must not hold withTaskLock or the returned deleteTask Promise. Schedule the cleanup after the task is already soft-deleted, but keep the existing cleanup guarantees by still clearing stale execution-start branch references and persisting the cleaned-branch log entry on the deleted row. @@ -205,142 +203,8 @@ export async function deleteTaskImpl(store: TaskStore, id: string, options?: { r if (options?.auditContext?.taskId === id) { throw new TaskSelfDeleteError(id); } - if (store.backendMode) { - return store.deleteTaskBackend(id, options); - } - /* - FNXC:TaskDeleteNotice 2026-07-26-16:10: - Pre-delete snapshot for the operator mailbox notice. It must be captured INSIDE the lock (the - row is mutated to `archived` a few lines below, so reading `task.column` afterwards would always - report `archived`) but the notice itself is sent AFTER the lock and after the transaction - commits — a mailbox write inside `store.db.transaction` would roll the soft-delete back if it - threw. Left `undefined` on the already-deleted short-circuit so an idempotent re-delete is silent. - */ - let noticeSnapshot: TaskDeleteNoticeSnapshot | undefined; - const deletedTask = await store.withTaskLock(id, async () => { - // Flush buffered agent logs inside the lock so no new appends for this - // task can sneak in between flush and soft-delete mutation. - store.flushAgentLogBuffer(); - const task = store.readTaskFromDb(id, { includeDeleted: true }); - if (!task) { - // FNXC:TaskLookup404 2026-07-26-12:00: typed miss (message unchanged) so - // DELETE /api/tasks/:id answers 404 for an unknown id instead of 500. - throw new TaskNotFoundError(id); - } - - if (task.deletedAt) { - return task; - } - - // Refuse to delete a task that is still referenced as a dependency - // by another live task unless the caller explicitly opts into - // removing those incoming references as part of this delete. - const dependentIds = store.findLiveDependents(id); - if (dependentIds.length > 0 && !options?.removeDependencyReferences) { - throw new TaskHasDependentsError(id, dependentIds); - } - - // FN-5127: lineage gate must execute after idempotent short-circuit. - const lineageChildIds = await store.findLiveLineageChildren(id); - if (lineageChildIds.length > 0 && !options?.removeLineageReferences) { - throw new TaskHasLineageChildrenError(id, lineageChildIds); - } - - noticeSnapshot = { id: task.id, title: task.title, previousColumn: task.column, previousStatus: task.status ?? null }; - - let rewrittenDependents: Task[] = []; - let rewrittenBlockedByResidueDependents: Task[] = []; - let rewrittenLineageChildren: Task[] = []; - let deletedAt = ""; - store.db.transaction(() => { - rewrittenDependents = store.rewriteDependentsForRemoval(id, dependentIds); - rewrittenBlockedByResidueDependents = store.rewriteBlockedByResidueDependentsForRemoval(id, new Set(dependentIds)); - rewrittenLineageChildren = store.rewriteLineageChildrenForRemoval(id, lineageChildIds); - deletedAt = new Date().toISOString(); - const allowResurrection = options?.allowResurrection === true ? 1 : 0; - store.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, allowResurrection = ?, updatedAt = ? WHERE id = ?").run(deletedAt, allowResurrection, deletedAt, id); - void store.recordRunAuditEvent({ - domain: "database", - mutationType: "task:deleted", - target: task.id, - taskId: task.id, - agentId: options?.auditContext?.agentId ?? "system", - runId: options?.auditContext?.runId ?? store.makeSyntheticDeleteRunId(task.id), - metadata: { - previousColumn: task.column, - previousStatus: task.status ?? null, - githubIssueAction: options?.githubIssueAction ?? "auto", - removeDependencyReferences: !!options?.removeDependencyReferences, - removeLineageReferences: !!options?.removeLineageReferences, - allowResurrection: options?.allowResurrection === true, - sessionId: options?.auditContext?.sessionId, - // FNXC:TaskDeleteAttribution 2026-07-26-14:30: caller class + calling - // task id; `taskId` reached this function but was never persisted. - ...buildDeleteCallerAuditFields(options?.auditContext), - }, - }); - store.clearLinkedAgentTaskIds(id, deletedAt); - // FN-5143: agent log reads are gated on deletedAt (see getAgentLogs / - // getAgentLogCount / getAgentLogsByTimeRange), so downstream readers - // observe zero logs immediately after deletedAt is set. The JSONL file - // remains on disk for forensic analysis; only the read API hides it. - store.db.bumpLastModified(); - }); - - task.column = "archived"; - task.deletedAt = deletedAt; - task.updatedAt = deletedAt; - scheduleDeleteBranchCleanup(store, task); - - // FN-5143 defense-in-depth: drop any in-memory buffer entries for this - // task. flushAgentLogBuffer() above already ran inside the lock, but a - // concurrent appendAgentLog from another async path could re-buffer - // before this lock releases; the next flush would still drop them via - // ACTIVE_TASKS_WHERE, but filtering here avoids the warn log and keeps - // memory bounded. - if (store.agentLogBuffer.length > 0) { - store.agentLogBuffer = store.agentLogBuffer.filter((entry) => entry.taskId !== id); - } - - // Remove from cache if watcher is active - if (store.isWatching) store.taskCache.delete(id); - - for (const dependentTask of rewrittenDependents) { - store.emit("task:updated", dependentTask); - } - for (const dependentTask of rewrittenBlockedByResidueDependents) { - store.emit("task:updated", dependentTask); - } - for (const lineageChild of rewrittenLineageChildren) { - store.emit("task:updated", lineageChild); - } - - // FNXC:MissionStore 2026-06-27-16:00: - // Best-effort mission feature/task-link cleanup on hard delete. store.missionStore - // is now MissionStore | AsyncMissionStore; this sync transaction callback can only - // drive the sync MissionStore. In PG backend mode (AsyncMissionStore) the cleanup is - // skipped (graceful degrade — the async unlink would need an await this txn cannot provide). - const missionStore = store.missionStore; - if (missionStore instanceof MissionStore) { - const linkedFeature = missionStore.getFeatureByTaskId(id); - if (linkedFeature) { - missionStore.unlinkFeatureFromTask(linkedFeature.id); - } - } - - store.emit("task:deleted", task, { githubIssueAction: options?.githubIssueAction ?? "auto" }); - return task; - }); - - await store.clearNearDuplicateReferencesToFailSoft(id, { - column: "archived", - deletedAt: deletedTask.deletedAt ?? new Date().toISOString(), - reason: "deleted", - }); - // FNXC:TaskDeleteNotice 2026-07-26-16:10: best-effort, post-commit, never throws. - if (noticeSnapshot) await notifyOperatorOfNonOperatorDelete(store, noticeSnapshot, options?.auditContext); - return deletedTask; - } + return store.deleteTaskBackend(id, options); +} export interface DeleteTaskIfResult { task: Task; @@ -360,166 +224,22 @@ export async function deleteTaskIfImpl( options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext }, ): Promise { if (options?.auditContext?.taskId === id) throw new TaskSelfDeleteError(id); - if (store.backendMode) return store.deleteTaskIf(id, predicate, options); - // FNXC:TaskDeleteNotice 2026-07-26-16:10: see deleteTaskImpl — snapshot inside the lock, notify - // after commit. Stays `undefined` when the predicate declines, so a skipped delete is silent. - let noticeSnapshot: TaskDeleteNoticeSnapshot | undefined; - const result = await store.withTaskLock(id, async () => { - store.flushAgentLogBuffer(); - const task = store.readTaskFromDb(id, { includeDeleted: true }); - if (!task) throw new Error(`Task ${id} not found`); - if (task.deletedAt) return { task, deleted: false }; - const dependentIds = store.findLiveDependents(id); - if (dependentIds.length > 0 && !options?.removeDependencyReferences) throw new TaskHasDependentsError(id, dependentIds); - const lineageChildIds = await store.findLiveLineageChildren(id); - if (lineageChildIds.length > 0 && !options?.removeLineageReferences) throw new TaskHasLineageChildrenError(id, lineageChildIds); - if (!await predicate(task)) return { task, deleted: false }; - noticeSnapshot = { id: task.id, title: task.title, previousColumn: task.column, previousStatus: task.status ?? null }; - let deletedAt = ""; - let rewrittenDependents: Task[] = []; - let rewrittenBlockedByResidueDependents: Task[] = []; - let rewrittenLineageChildren: Task[] = []; - store.db.transaction(() => { - rewrittenDependents = store.rewriteDependentsForRemoval(id, dependentIds); - rewrittenBlockedByResidueDependents = store.rewriteBlockedByResidueDependentsForRemoval(id, new Set(dependentIds)); - rewrittenLineageChildren = store.rewriteLineageChildrenForRemoval(id, lineageChildIds); - deletedAt = new Date().toISOString(); - const allowResurrection = options?.allowResurrection === true ? 1 : 0; - store.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, allowResurrection = ?, updatedAt = ? WHERE id = ?").run(deletedAt, allowResurrection, deletedAt, id); - void store.recordRunAuditEvent({ domain: "database", mutationType: "task:deleted", target: task.id, taskId: task.id, agentId: options?.auditContext?.agentId ?? "system", runId: options?.auditContext?.runId ?? store.makeSyntheticDeleteRunId(task.id), metadata: { previousColumn: task.column, previousStatus: task.status ?? null, githubIssueAction: options?.githubIssueAction ?? "auto", removeDependencyReferences: !!options?.removeDependencyReferences, removeLineageReferences: !!options?.removeLineageReferences, allowResurrection: options?.allowResurrection === true, sessionId: options?.auditContext?.sessionId, ...buildDeleteCallerAuditFields(options?.auditContext) } }); - store.clearLinkedAgentTaskIds(id, deletedAt); - store.db.bumpLastModified(); - }); - task.column = "archived"; - task.deletedAt = deletedAt; - task.updatedAt = deletedAt; - scheduleDeleteBranchCleanup(store, task); - if (store.agentLogBuffer.length > 0) store.agentLogBuffer = store.agentLogBuffer.filter((entry) => entry.taskId !== id); - if (store.isWatching) store.taskCache.delete(id); - for (const dependentTask of rewrittenDependents) store.emit("task:updated", dependentTask); - for (const dependentTask of rewrittenBlockedByResidueDependents) store.emit("task:updated", dependentTask); - for (const lineageChild of rewrittenLineageChildren) store.emit("task:updated", lineageChild); - const missionStore = store.missionStore; - if (missionStore instanceof MissionStore) { - const linkedFeature = missionStore.getFeatureByTaskId(id); - if (linkedFeature) missionStore.unlinkFeatureFromTask(linkedFeature.id); - } - store.emit("task:deleted", task, { githubIssueAction: options?.githubIssueAction ?? "auto" }); - return { task, deleted: true }; - }); - if (result.deleted) await store.clearNearDuplicateReferencesToFailSoft(id, { column: "archived", deletedAt: result.task.deletedAt ?? new Date().toISOString(), reason: "deleted" }); - if (result.deleted && noticeSnapshot) await notifyOperatorOfNonOperatorDelete(store, noticeSnapshot, options?.auditContext); - return result; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:07: + deleteTaskIf is PostgreSQL-only; the SQLite transaction arm is deleted. Delegate to the store entry which routes to deleteTaskIfBackendImpl. + */ + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:08: + Avoid store.deleteTaskIf recursion; call the PG backend impl directly. + */ + return store.deleteTaskIf(id, predicate, options); } export async function archiveTaskImpl(store: TaskStore, id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean } = true,): Promise { - // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-14:50: - // Backend-mode archiveTask: delegates to archiveTaskBackend which uses the - // async archive-lineage helper (archiveParentTaskWithLineageGate) to perform - // the lineage gate + lineage clear + archive snapshot + soft-delete in one - // transaction (VAL-CROSS-014/015). The SQLite path below is unchanged. - if (store.backendMode) { - return store.archiveTaskBackend(id, optionsOrCleanup); - } - const archivedTask = await store.withTaskLock(id, async () => { - const dir = store.taskDir(id); - const task = await store.readTaskJson(dir); - - // Initialize log array if missing (for legacy tasks) - if (!task.log) { - task.log = []; - } - - if (task.column === "archived") { - throw new Error( - `Cannot archive ${id}: task is already archived`, - ); - } - - const fromColumn = task.column as Column; - task.preArchiveColumn = fromColumn; - - const cleanup = typeof optionsOrCleanup === "boolean" ? optionsOrCleanup : optionsOrCleanup.cleanup !== false; - const removeLineageReferences = typeof optionsOrCleanup === "object" && optionsOrCleanup.removeLineageReferences === true; - const lineageChildIds = await store.findLiveLineageChildren(id); - if (lineageChildIds.length > 0 && !removeLineageReferences) { - throw new TaskHasLineageChildrenError(id, lineageChildIds); - } - - task.column = "archived"; - task.columnMovedAt = new Date().toISOString(); - task.updatedAt = task.columnMovedAt; - task.log.push({ - timestamp: task.columnMovedAt, - action: "Task archived", - }); - - let rewrittenLineageChildren: Task[] = []; - - if (!cleanup) { - store.db.transaction(() => { - rewrittenLineageChildren = store.rewriteLineageChildrenForRemoval(id, lineageChildIds); - store.clearLinkedAgentTaskIds(id, task.updatedAt); - if (rewrittenLineageChildren.length > 0) { - store.db.bumpLastModified(); - } - }); - - await store.atomicWriteTaskJson(dir, task); - await store.writeTaskJsonFile(dir, task); - if (store.isWatching) store.taskCache.set(id, { ...task }); - for (const lineageChild of rewrittenLineageChildren) { - store.emit("task:updated", lineageChild); - } - store.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" }); - return task; - } - - /* - FNXC:WorkflowLifecycle 2026-07-16-10:00: - Pinned paths must be reserved before destructive archive cleanup and held - until the awaited engine disposer finishes. The disposer is store-scoped - so executor-less fn/CLI archives cannot silently leak a worktree. - */ - const workspace = await disposeArchivedWorkspaceWorktrees(store, task); - if (!workspace.singularDeduplicated) await disposeArchivedWorktree(store, task); - const cleanedBranches = await store.cleanupBranchForTask(task); - if (cleanedBranches.length > 0) { - task.log.push({ - timestamp: new Date().toISOString(), - action: `Cleaned up branch: ${cleanedBranches.join(", ")}`, - }); - } - - const entry = await store.taskToArchiveEntry(task, task.columnMovedAt); - store.archiveDb.upsert(entry); - - store.db.transaction(() => { - rewrittenLineageChildren = store.rewriteLineageChildrenForRemoval(id, lineageChildIds); - store.clearLinkedAgentTaskIds(id, task.updatedAt); - store.purgeTaskWorkflowSelectionRows(id); - store.db.prepare('DELETE FROM tasks WHERE id = ?').run(id); - store.db.bumpLastModified(); - }); - - const { rm } = await import("node:fs/promises"); - await rm(dir, { recursive: true, force: true }); - - if (store.isWatching) { - store.taskCache.delete(id); - } - - for (const lineageChild of rewrittenLineageChildren) { - store.emit("task:updated", lineageChild); - } - store.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" }); - return store.archiveEntryToTask(entry, false); - }); - - await store.clearNearDuplicateReferencesToFailSoft(id, { - column: "archived", - reason: "archived", - }); - return archivedTask; - } + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:08: + archiveTask is PostgreSQL-only via archiveTaskBackend (async archive-lineage helper). + */ + return store.archiveTaskBackend(id, optionsOrCleanup); +} diff --git a/packages/core/src/task-store/audit-ops.ts b/packages/core/src/task-store/audit-ops.ts index dc5be98253..35a015352f 100644 --- a/packages/core/src/task-store/audit-ops.ts +++ b/packages/core/src/task-store/audit-ops.ts @@ -7,15 +7,13 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore} from "../store.js"; -import type {Task, TaskDetail, Column, TaskLogEntry, RunMutationContext} from "../types.js"; +import type {Task, TaskDetail, TaskLogEntry, RunMutationContext} from "../types.js"; import {findWorkflowColumn} from "../plugin-gate-verdict.js"; import {getTraitRegistry} from "../trait-registry.js"; import {makeTransitionPending} from "../transition-types.js"; -import {writeTransitionPending} from "../transition-pending.js"; import {writeTransitionPendingAsync} from "./async-transition-pending.js"; import type {WorkflowIr} from "../workflow-ir-types.js"; 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"; @@ -55,11 +53,8 @@ export async function runPluginColumnTransitionHooksImpl(store: TaskStore, taskI const writeMarker = async (remainingHookIds: string[]): Promise => { try { const marker = makeTransitionPending(toColumn, remainingHookIds, startedAt); - if (store.backendMode) { - await writeTransitionPendingAsync(store.asyncLayer!.db, taskId, marker); - } else { - writeTransitionPending(store.db, taskId, marker); - } + await writeTransitionPendingAsync(store.asyncLayer!.db, taskId, marker); + } catch { // Marker bookkeeping is best-effort; proceed to run the hooks regardless. } @@ -70,15 +65,10 @@ export async function runPluginColumnTransitionHooksImpl(store: TaskStore, taskI // runs inside `withTaskLock`, so `getTask` (which re-acquires the lock) // would deadlock. `readTaskFromDb` is the in-lock-safe read (backend mode: // raw readTaskRow + row conversion, same non-locking property). - let taskDetail: TaskDetail | undefined; - if (store.backendMode) { - const pgRow = await readTaskRow(store.asyncLayer!, taskId, { includeDeleted: false }); - taskDetail = pgRow - ? (store.rowToTask(store.pgRowToTaskRow(pgRow)) as unknown as TaskDetail) - : undefined; - } else { - taskDetail = store.readTaskFromDb(taskId, { includeDeleted: false }) as unknown as TaskDetail | undefined; - } + const pgRow = await readTaskRow(store.asyncLayer!, taskId, { includeDeleted: false }); + const taskDetail: TaskDetail | undefined = pgRow + ? (store.rowToTask(store.pgRowToTaskRow(pgRow)) as unknown as TaskDetail) + : undefined; const remaining = ["default-workflow:postCommit", ...hookIds]; for (const { traitId, hookKind } of pending) { @@ -131,13 +121,11 @@ 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`); - } else if (store.isTaskArchived(id)) { - throw new Error(`Task ${id} is archived — logging is read-only`); } const dir = store.taskDir(id); @@ -180,68 +168,28 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string, // and write back only the log + updatedAt columns. This avoids the // sync this.db.prepare() path which throws "SQLite Database is not // 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: true }); - if (!pgRow) { - throw new Error(`Task ${id} not found`); - } - 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. - const existingLog = Array.isArray(pgRow.log) ? (pgRow.log as TaskLogEntry[]) : []; - existingLog.push(entry); - const _entryLimit = getTaskActivityLogEntryLimit(); - if (existingLog.length > _entryLimit) { - existingLog.splice(0, existingLog.length - _entryLimit); - } - const updatedAt = new Date().toISOString(); - await updateTaskColumns(layer, id, { log: existingLog, updatedAt }); - - // Re-read the task for event emission (full row → Task). - const updatedRow = await readTaskRow(layer, id, { includeDeleted: false }); - if (updatedRow) { - const current = store.rowToTask(store.pgRowToTaskRow(updatedRow)); - await store.writeTaskJsonFile(store.taskDir(id), current); - if (store.isWatching) { - store.taskCache.set(id, { ...current }); - } - store.emitTaskLifecycleEventSafely("task:updated", [current]); - return current; - } - const emittedTask = ({ id, log: existingLog, updatedAt } as unknown) as Task; - store.emitTaskLifecycleEventSafely("task:updated", [emittedTask]); - return emittedTask; - } - - const row = store.db.prepare(`SELECT log, "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(id) as - | { log: string | null; column: Column } - | undefined; - if (!row) { - if (store.isTaskArchived(id)) { - throw new Error(`Task ${id} is archived — logging is read-only`); - } + const layer = store.asyncLayer!; + const pgRow = await readTaskRow(layer, id, { includeDeleted: true }); + if (!pgRow) { throw new Error(`Task ${id} not found`); } - - if (row.column === "archived") { + if (pgRow.column === "archived" || pgRow.deletedAt != null) { throw new Error(`Task ${id} is archived — logging is read-only`); } - - const log = fromJson(row.log) || []; - log.push(entry); + // PG jsonb columns arrive already-parsed; convert to the TaskLogEntry[] shape. + const existingLog = Array.isArray(pgRow.log) ? (pgRow.log as TaskLogEntry[]) : []; + existingLog.push(entry); const _entryLimit = getTaskActivityLogEntryLimit(); - if (log.length > _entryLimit) { - log.splice(0, log.length - _entryLimit); + if (existingLog.length > _entryLimit) { + existingLog.splice(0, existingLog.length - _entryLimit); } const updatedAt = new Date().toISOString(); + await updateTaskColumns(layer, id, { log: existingLog, updatedAt }); - store.db.prepare("UPDATE tasks SET log = ?, updatedAt = ? WHERE id = ?").run(toJson(log), updatedAt, id); - store.db.bumpLastModified(); - - const current = store.readTaskFromDb(id); - if (current) { + // Re-read the task for event emission (full row → Task). + const updatedRow = await readTaskRow(layer, id, { includeDeleted: false }); + if (updatedRow) { + const current = store.rowToTask(store.pgRowToTaskRow(updatedRow)); await store.writeTaskJsonFile(store.taskDir(id), current); if (store.isWatching) { store.taskCache.set(id, { ...current }); @@ -249,9 +197,8 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string, store.emitTaskLifecycleEventSafely("task:updated", [current]); return current; } - - const emittedTask = ({ id, log, updatedAt } as unknown) as Task; + const emittedTask = ({ id, log: existingLog, updatedAt } as unknown) as Task; store.emitTaskLifecycleEventSafely("task:updated", [emittedTask]); return emittedTask; - }); +}); } diff --git a/packages/core/src/task-store/branch-and-pr-entities.ts b/packages/core/src/task-store/branch-and-pr-entities.ts index 8c44568011..7b992d6dc8 100644 --- a/packages/core/src/task-store/branch-and-pr-entities.ts +++ b/packages/core/src/task-store/branch-and-pr-entities.ts @@ -12,14 +12,12 @@ import { TaskStore } from "../store.js"; import { filterTasksByBranchGroup } from "../branch-assignment.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; import { isBuiltinWorkflowId } from "../builtin-workflows.js"; -import { fromJson } from "../db.js"; import { FINGERPRINT_WINDOW_DEFAULT_MS, FINGERPRINT_WINDOW_MAX_MS } from "../duplicate-guard.js"; import * as schema from "../postgres/schema/index.js"; import { taskProjectScope } from "../postgres/data-layer.js"; import { ensureBranchGroupForSource as ensureBranchGroupForSourceAsync, ensurePrEntityForSource as ensurePrEntityForSourceAsync, getActivePrEntityBySource as getActivePrEntityBySourceAsync, getBranchGroup as getBranchGroupAsync, getBranchGroupByBranchName as getBranchGroupByBranchNameAsync, getBranchGroupBySource as getBranchGroupBySourceAsync, getPrEntity as getPrEntityAsync, getPrThreadState as getPrThreadStateAsync, listActivePrEntities as listActivePrEntitiesAsync, listBranchGroups as listBranchGroupsAsync, listPrThreadStates as listPrThreadStatesAsync, recordPrThreadOutcome as recordPrThreadOutcomeAsync } from "./async-branch-groups.js"; import { getWorkflowWorkItem as getWorkflowWorkItemAsync } from "./async-workflow-workitems.js"; -import { type TaskRow } from "./persistence.js"; -import { BranchGroupRow, MergeRequestRow, PrEntityRow, PrThreadStateRow, WorkflowWorkItemRow } from "./row-types.js"; +import { MergeRequestRow, PrEntityRow, WorkflowWorkItemRow } from "./row-types.js"; import { BranchGroup, BranchGroupCreateInput, ColumnId, MergeRequestRecord, MergeRequestState, PrEntity, PrEntityCreateInput, PrThreadOutcome, PrThreadState, RunMutationContext, Task, TaskLogEntry, TaskPriority, TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, WorkflowWorkItem, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch } from "../types.js"; import { validateNodeOverrideChange } from "../node-override-guard.js"; import { WorkflowMovePolicyInput } from "../workflow-extension-types.js"; @@ -33,30 +31,18 @@ import { MoveTaskInternalOptions, MoveTaskOptions, storeLog } from "../store.js" export async function getBranchGroupImpl(store: TaskStore, id: string): Promise { // FNXC:RuntimeWorkflowAsync 2026-06-24-16:21: - if (store.backendMode) { - const layer = store.asyncLayer!; - return getBranchGroupAsync(layer.db, id); - } - const row = store.db.prepare(`SELECT * FROM branch_groups WHERE id = ?`).get(id) as BranchGroupRow | undefined; - return row ? store.rowToBranchGroup(row) : null; + const layer = store.asyncLayer!; + return getBranchGroupAsync(layer.db, id); } export async function getBranchGroupBySourceImpl(store: TaskStore, sourceType: BranchGroup["sourceType"], sourceId: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getBranchGroupBySourceAsync(layer.db, sourceType, sourceId); - } - const row = store.db.prepare(`SELECT * FROM branch_groups WHERE sourceType = ? AND sourceId = ?`).get(sourceType, sourceId) as BranchGroupRow | undefined; - return row ? store.rowToBranchGroup(row) : null; + const layer = store.asyncLayer!; + return getBranchGroupBySourceAsync(layer.db, sourceType, sourceId); } export async function getBranchGroupByBranchNameImpl(store: TaskStore, branchName: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getBranchGroupByBranchNameAsync(layer.db, branchName); - } - const row = store.db.prepare(`SELECT * FROM branch_groups WHERE branchName = ? AND status = 'open' ORDER BY createdAt DESC LIMIT 1`).get(branchName) as BranchGroupRow | undefined; - return row ? store.rowToBranchGroup(row) : null; + const layer = store.asyncLayer!; + return getBranchGroupByBranchNameAsync(layer.db, branchName); } export async function ensureBranchGroupForSourceImpl(store: TaskStore, @@ -64,42 +50,16 @@ export async function ensureBranchGroupForSourceImpl(store: TaskStore, sourceId: string, init: Omit, ): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return ensureBranchGroupForSourceAsync(layer.db, sourceType, sourceId, init); - } - const existing = await store.getBranchGroupBySource(sourceType, sourceId); - if (existing) { - return existing; - } - - // `branch_groups.branchName` is globally UNIQUE — a branch is represented by - // exactly one open group. If another source already owns an open group for - // store branch, reuse it rather than calling createBranchGroup and violating - // the UNIQUE constraint. Without store, two missions whose shared base resolves - // to the same branch (e.g. "main") collide: the throw escapes triageFeature - // and is swallowed by its callers, silently stranding "defined" features. - const existingByBranch = await store.getBranchGroupByBranchName(init.branchName); - if (existingByBranch) { - return existingByBranch; - } - - return store.createBranchGroup({ - sourceType, - sourceId, - ...init, - }); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:07: + Branch-group ensure is PostgreSQL-only via ensureBranchGroupForSourceAsync (UNIQUE branchName reuse lives in the async helper). + */ + return ensureBranchGroupForSourceAsync(store.asyncLayer!.db, sourceType, sourceId, init); } export async function listBranchGroupsImpl(store: TaskStore, options?: { status?: BranchGroup["status"] }): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return listBranchGroupsAsync(layer.db, options); - } - const rows = options?.status - ? store.db.prepare(`SELECT * FROM branch_groups WHERE status = ? ORDER BY createdAt ASC`).all(options.status) - : store.db.prepare(`SELECT * FROM branch_groups ORDER BY createdAt ASC`).all(); - return (rows as BranchGroupRow[]).map((row) => store.rowToBranchGroup(row)); + const layer = store.asyncLayer!; + return listBranchGroupsAsync(layer.db, options); } /* @@ -120,131 +80,46 @@ export async function listTasksByBranchGroupImpl(store: TaskStore, groupId: stri } export async function getPrEntityImpl(store: TaskStore, id: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getPrEntityAsync(layer.db, id); - } - const row = store.db.prepare(`SELECT * FROM pull_requests WHERE id = ?`).get(id) as PrEntityRow | undefined; - return row ? store.rowToPrEntity(row) : null; + const layer = store.asyncLayer!; + return getPrEntityAsync(layer.db, id); } export async function getActivePrEntityBySourceImpl(store: TaskStore, sourceType: PrEntity["sourceType"], sourceId: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getActivePrEntityBySourceAsync(layer.db, sourceType, sourceId); - } - const row = store.db - .prepare( - `SELECT * FROM pull_requests - WHERE sourceType = ? AND sourceId = ? AND state NOT IN ('merged','closed','failed') - ORDER BY createdAt DESC LIMIT 1`, - ) - .get(sourceType, sourceId) as PrEntityRow | undefined; - return row ? store.rowToPrEntity(row) : null; + const layer = store.asyncLayer!; + return getActivePrEntityBySourceAsync(layer.db, sourceType, sourceId); } export async function getPrEntityByNumberImpl(store: TaskStore, repo: string, prNumber: number): Promise { // No dedicated async helper for by-number lookup; use the sync path's SQL // shape via a raw Drizzle query in backend mode. - if (store.backendMode) { - const layer = store.asyncLayer!; - const rows = await layer.db - .select() - .from(schema.project.pullRequests) - .where(and(eq(schema.project.pullRequests.repo, repo), eq(schema.project.pullRequests.prNumber, prNumber))) - .limit(1); - const row = rows[0] as PrEntityRow | undefined; - return row ? store.rowToPrEntity(row) : null; - } - const row = store.db - .prepare(`SELECT * FROM pull_requests WHERE repo = ? AND prNumber = ?`) - .get(repo, prNumber) as PrEntityRow | undefined; + const layer = store.asyncLayer!; + const rows = await layer.db + .select() + .from(schema.project.pullRequests) + .where(and(eq(schema.project.pullRequests.repo, repo), eq(schema.project.pullRequests.prNumber, prNumber))) + .limit(1); + const row = rows[0] as PrEntityRow | undefined; return row ? store.rowToPrEntity(row) : null; } export async function ensurePrEntityForSourceImpl(store: TaskStore, input: PrEntityCreateInput): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return ensurePrEntityForSourceAsync(layer.db, input); - } - const existing = await store.getActivePrEntityBySource(input.sourceType, input.sourceId); - if (existing) return existing; - const id = store.generatePrEntityId(); - const now = Date.now(); - store.db - .prepare( - `INSERT INTO pull_requests - (id, sourceType, sourceId, repo, headBranch, baseBranch, state, - prNumber, prUrl, autoMerge, unverified, responseRounds, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, - ) - .run( - id, - input.sourceType, - input.sourceId, - input.repo, - input.headBranch, - input.baseBranch ?? null, - input.state ?? "creating", - input.prNumber ?? null, - input.prUrl ?? null, - input.autoMerge ? 1 : 0, - input.unverified ? 1 : 0, - now, - now, - ); - store.db.bumpLastModified(); - const created = await store.getPrEntity(id); - return created!; + const layer = store.asyncLayer!; + return ensurePrEntityForSourceAsync(layer.db, input); } export async function listActivePrEntitiesImpl(store: TaskStore): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return listActivePrEntitiesAsync(layer.db); - } - const rows = store.db - .prepare(`SELECT * FROM pull_requests WHERE state NOT IN ('merged','closed','failed') ORDER BY createdAt ASC`) - .all() as PrEntityRow[]; - return rows.map((r) => store.rowToPrEntity(r)); + const layer = store.asyncLayer!; + return listActivePrEntitiesAsync(layer.db); } export async function getPrThreadStateImpl(store: TaskStore, prEntityId: string, threadId: string, headOid: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getPrThreadStateAsync(layer.db, prEntityId, threadId, headOid); - } - const row = store.db - .prepare(`SELECT * FROM pull_request_thread_state WHERE prEntityId = ? AND threadId = ? AND headOid = ?`) - .get(prEntityId, threadId, headOid) as PrThreadStateRow | undefined; - return row - ? { - prEntityId: row.prEntityId, - threadId: row.threadId, - headOid: row.headOid, - outcome: row.outcome, - fixCommitSha: row.fixCommitSha ?? undefined, - updatedAt: row.updatedAt, - } - : null; + const layer = store.asyncLayer!; + return getPrThreadStateAsync(layer.db, prEntityId, threadId, headOid); } export async function listPrThreadStatesImpl(store: TaskStore, prEntityId: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return listPrThreadStatesAsync(layer.db, prEntityId); - } - const rows = store.db - .prepare(`SELECT * FROM pull_request_thread_state WHERE prEntityId = ?`) - .all(prEntityId) as PrThreadStateRow[]; - return rows.map((row) => ({ - prEntityId: row.prEntityId, - threadId: row.threadId, - headOid: row.headOid, - outcome: row.outcome, - fixCommitSha: row.fixCommitSha ?? undefined, - updatedAt: row.updatedAt, - })); + const layer = store.asyncLayer!; + return listPrThreadStatesAsync(layer.db, prEntityId); } export async function recordPrThreadOutcomeImpl(store: TaskStore, @@ -254,19 +129,8 @@ export async function recordPrThreadOutcomeImpl(store: TaskStore, outcome: PrThreadOutcome, fixCommitSha?: string, ): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return recordPrThreadOutcomeAsync(layer.db, prEntityId, threadId, headOid, outcome, fixCommitSha); - } - store.db - .prepare( - `INSERT INTO pull_request_thread_state (prEntityId, threadId, headOid, outcome, fixCommitSha, updatedAt) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (prEntityId, threadId, headOid) - DO UPDATE SET outcome = excluded.outcome, fixCommitSha = excluded.fixCommitSha, updatedAt = excluded.updatedAt`, - ) - .run(prEntityId, threadId, headOid, outcome, fixCommitSha ?? null, Date.now()); - store.db.bumpLastModified(); + const layer = store.asyncLayer!; + return recordPrThreadOutcomeAsync(layer.db, prEntityId, threadId, headOid, outcome, fixCommitSha); } export async function getBranchProgressByTaskImpl(store: TaskStore, @@ -281,94 +145,32 @@ export async function getBranchProgressByTaskImpl(store: TaskStore, PostgreSQL. Read the rows async and resolve the winning (latest updatedAt, runId tie-break) run per task in JS — per-task row counts are small. */ - if (store.backendMode) { - const table = schema.project.workflowRunBranches; - const rows = await store.asyncLayer!.db - .select({ - taskId: table.taskId, - runId: table.runId, - branchId: table.branchId, - nodeId: table.currentNodeId, - status: table.status, - updatedAt: table.updatedAt, - }) - .from(table) - .where(inArray(table.taskId, taskIds as string[])); - const latestRunByTask = new Map(); - for (const row of rows) { - const current = latestRunByTask.get(row.taskId); - if (!current - || row.updatedAt > current.updatedAt - || (row.updatedAt === current.updatedAt && row.runId > current.runId)) { - latestRunByTask.set(row.taskId, { runId: row.runId, updatedAt: row.updatedAt }); - } + const table = schema.project.workflowRunBranches; + const rows = await store.asyncLayer!.db + .select({ + taskId: table.taskId, + runId: table.runId, + branchId: table.branchId, + nodeId: table.currentNodeId, + status: table.status, + updatedAt: table.updatedAt, + }) + .from(table) + .where(inArray(table.taskId, taskIds as string[])); + const latestRunByTask = new Map(); + for (const row of rows) { + const current = latestRunByTask.get(row.taskId); + if (!current + || row.updatedAt > current.updatedAt + || (row.updatedAt === current.updatedAt && row.runId > current.runId)) { + latestRunByTask.set(row.taskId, { runId: row.runId, updatedAt: row.updatedAt }); } - for (const row of rows) { - if (latestRunByTask.get(row.taskId)?.runId !== row.runId) continue; - const list = result.get(row.taskId) ?? []; - list.push({ branchId: row.branchId, nodeId: row.nodeId, status: row.status }); - result.set(row.taskId, list); - } - return result; } - try { - // Skip entirely when the table has no rows (cheap existence probe). - const any = store.db - .prepare("SELECT 1 FROM workflow_run_branches LIMIT 1") - .get(); - if (!any) return result; - - const placeholders = taskIds.map(() => "?").join(", "); - // Filter to the latest run per task entirely in SQL (#1413): the - // correlated subquery resolves the winning (updatedAt, runId) pair per - // task — MAX(updatedAt) with a deterministic MAX(runId) tie-break — and - // the JOIN matches both columns so only the latest run's rows are read. - // The runId tie-break makes ties on updatedAt deterministic instead of - // letting an arbitrary historical run win. - const rows = store.db - .prepare( - `SELECT b.taskId AS taskId, b.runId AS runId, b.branchId AS branchId, - b.currentNodeId AS nodeId, b.status AS status, b.updatedAt AS updatedAt - FROM workflow_run_branches b - JOIN ( - -- Resolve the winning run per task: the run owning the row with - -- the greatest updatedAt, with runId as a deterministic - -- tie-break when two runs share an updatedAt. Returns the whole - -- run's rows (all its branches), not just the single max row. - SELECT taskId, runId AS latestRunId - FROM ( - SELECT taskId, runId, - ROW_NUMBER() OVER ( - PARTITION BY taskId - ORDER BY MAX(updatedAt) DESC, runId DESC - ) AS rn - FROM workflow_run_branches - WHERE taskId IN (${placeholders}) - GROUP BY taskId, runId - ) - WHERE rn = 1 - ) latest_run - ON latest_run.taskId = b.taskId - AND latest_run.latestRunId = b.runId - WHERE b.taskId IN (${placeholders})`, - ) - .all(...taskIds, ...taskIds) as Array<{ - taskId: string; - runId: string; - branchId: string; - nodeId: string; - status: string; - updatedAt: string; - }>; - - for (const row of rows) { - const list = result.get(row.taskId) ?? []; - list.push({ branchId: row.branchId, nodeId: row.nodeId, status: row.status }); - result.set(row.taskId, list); - } - } catch { - // Legacy/missing table or query failure — degrade to no branch progress. - return new Map(); + for (const row of rows) { + if (latestRunByTask.get(row.taskId)?.runId !== row.runId) continue; + const list = result.get(row.taskId) ?? []; + list.push({ branchId: row.branchId, nodeId: row.nodeId, status: row.status }); + result.set(row.taskId, list); } return result; } @@ -388,44 +190,24 @@ export async function loadWorkflowRunBranchesImpl(store: TaskStore, Backend mode previously returned [] from the sync catch, so parallel-branch workflow runs lost their crash-recovery checkpoints on PostgreSQL. */ - if (store.backendMode) { - const table = schema.project.workflowRunBranches; - const rows = await store.asyncLayer!.db - .select({ - taskId: table.taskId, - runId: table.runId, - branchId: table.branchId, - currentNodeId: table.currentNodeId, - status: table.status, - }) - .from(table) - .where(and(eq(table.taskId, taskId), eq(table.runId, runId))); - return rows as Array<{ - taskId: string; - runId: string; - branchId: string; - currentNodeId: string; - status: "running" | "completed" | "failed" | "aborted"; - }>; - } - try { - const rows = store.db - .prepare( - `SELECT taskId, runId, branchId, currentNodeId, status - FROM workflow_run_branches - WHERE taskId = ? AND runId = ?`, - ) - .all(taskId, runId) as Array<{ - taskId: string; - runId: string; - branchId: string; - currentNodeId: string; - status: "running" | "completed" | "failed" | "aborted"; - }>; - return rows; - } catch { - return []; - } + const table = schema.project.workflowRunBranches; + const rows = await store.asyncLayer!.db + .select({ + taskId: table.taskId, + runId: table.runId, + branchId: table.branchId, + currentNodeId: table.currentNodeId, + status: table.status, + }) + .from(table) + .where(and(eq(table.taskId, taskId), eq(table.runId, runId))); + return rows as Array<{ + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: "running" | "completed" | "failed" | "aborted"; + }>; } export async function saveWorkflowRunStepInstanceImpl(store: TaskStore, @@ -438,44 +220,7 @@ export async function saveWorkflowRunStepInstanceImpl(store: TaskStore, async sibling (single PG code path); its !backendMode branch routes back here, guarded so there is no recursion. */ - if (store.backendMode) { - return saveWorkflowRunStepInstanceAsyncImpl(store, state); - } - try { - store.db - .prepare( - `INSERT INTO workflow_run_step_instances - (taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(taskId, runId, foreachNodeId, stepIndex) DO UPDATE SET - pinnedStepCount = excluded.pinnedStepCount, - currentNodeId = excluded.currentNodeId, - status = excluded.status, - baselineSha = excluded.baselineSha, - checkpointId = excluded.checkpointId, - reworkCount = excluded.reworkCount, - branchName = excluded.branchName, - integratedAt = excluded.integratedAt, - updatedAt = excluded.updatedAt`, - ) - .run( - state.taskId, - state.runId, - state.foreachNodeId, - state.stepIndex, - state.pinnedStepCount, - state.currentNodeId ?? null, - state.status, - state.baselineSha ?? null, - state.checkpointId ?? null, - state.reworkCount ?? 0, - state.branchName ?? null, - state.integratedAt ?? null, - new Date().toISOString(), - ); - } catch { - // Legacy/missing table — persistence is additive, so degrade silently. - } + return saveWorkflowRunStepInstanceAsyncImpl(store, state); } export async function loadWorkflowRunStepInstancesImpl(store: TaskStore, @@ -483,44 +228,12 @@ export async function loadWorkflowRunStepInstancesImpl(store: TaskStore, runId: string, ): Promise { // FNXC:PostgresOnlyDataAccess 2026-07-16-13:40: see saveWorkflowRunStepInstanceImpl. - if (store.backendMode) { - return loadWorkflowRunStepInstancesAsyncImpl(store, taskId, runId); - } - try { - const rows = store.db - .prepare( - `SELECT taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt - FROM workflow_run_step_instances - WHERE taskId = ? AND runId = ? - ORDER BY stepIndex ASC`, - ) - .all(taskId, runId) as import("../types.js").WorkflowRunStepInstance[]; - return rows; - } catch { - return []; - } + return loadWorkflowRunStepInstancesAsyncImpl(store, taskId, runId); } export async function clearWorkflowRunStepInstancesImpl(store: TaskStore, taskId: string, keepRunId?: string): Promise { // FNXC:PostgresOnlyDataAccess 2026-07-16-13:40: see saveWorkflowRunStepInstanceImpl. - if (store.backendMode) { - return clearWorkflowRunStepInstancesAsyncImpl(store, taskId, keepRunId); - } - try { - if (keepRunId === undefined) { - store.db - .prepare(`DELETE FROM workflow_run_step_instances WHERE taskId = ?`) - .run(taskId); - } else { - store.db - .prepare( - `DELETE FROM workflow_run_step_instances WHERE taskId = ? AND runId != ?`, - ) - .run(taskId, keepRunId); - } - } catch { - // Legacy/missing table — pruning is additive, so degrade silently. - } + return clearWorkflowRunStepInstancesAsyncImpl(store, taskId, keepRunId); } /* @@ -534,9 +247,7 @@ export async function saveWorkflowRunStepInstanceAsyncImpl( store: TaskStore, state: import("../types.js").WorkflowRunStepInstance, ): Promise { - if (!store.backendMode) { - return saveWorkflowRunStepInstanceImpl(store, state); - } + const layer = store.asyncLayer!; const now = new Date().toISOString(); await layer.db @@ -583,9 +294,7 @@ export async function loadWorkflowRunStepInstancesAsyncImpl( taskId: string, runId: string, ): Promise { - if (!store.backendMode) { - return loadWorkflowRunStepInstancesImpl(store, taskId, runId); - } + const layer = store.asyncLayer!; const rows = await layer.db .select() @@ -617,9 +326,7 @@ export async function clearWorkflowRunStepInstancesAsyncImpl( taskId: string, keepRunId?: string, ): Promise { - if (!store.backendMode) { - return clearWorkflowRunStepInstancesImpl(store, taskId, keepRunId); - } + const layer = store.asyncLayer!; const conditions = [eq(schema.project.workflowRunStepInstances.taskId, taskId)]; if (keepRunId !== undefined) { @@ -638,40 +345,32 @@ export async function getActiveMergingTaskImpl(store: TaskStore, excludeTaskId?: * via Drizzle, filtering on the same live + merging-status predicate the * SQLite path used (TaskStore.ACTIVE_TASKS_WHERE ≡ deletedAt IS NULL). */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const conditions = [ - isNull(schema.project.tasks.deletedAt), - inArray(schema.project.tasks.status, ["merging", "merging-pr"]), - ]; - /* - * FNXC:PostgresProjectIsolation 2026-07-17-00:00: - * The cross-process merge guard is documented (project-engine.ts) as - * "another process is already merging a task for THIS project" — in - * SQLite mode the per-project DB file made that scoping implicit. The - * shared PG tasks table needs the explicit project_id filter; without it - * one merging task anywhere serializes merges across ALL projects - * (observed: 697 cross-project "Merge deferred" retries in 10 minutes on - * a 6-project deployment, collapsing merge throughput ~6x). - */ - const scope = taskProjectScope(layer); - if (scope) conditions.push(scope); - if (excludeTaskId) { - conditions.push(ne(schema.project.tasks.id, excludeTaskId)); - } - const rows = await layer.db - .select({ id: schema.project.tasks.id }) - .from(schema.project.tasks) - .where(and(...conditions)) - .limit(1); - return rows[0]?.id; + const layer = store.asyncLayer!; + const conditions = [ + isNull(schema.project.tasks.deletedAt), + inArray(schema.project.tasks.status, ["merging", "merging-pr"]), + ]; + /* + * FNXC:PostgresProjectIsolation 2026-07-17-00:00: + * The cross-process merge guard is documented (project-engine.ts) as + * "another process is already merging a task for THIS project" — in + * SQLite mode the per-project DB file made that scoping implicit. The + * shared PG tasks table needs the explicit project_id filter; without it + * one merging task anywhere serializes merges across ALL projects + * (observed: 697 cross-project "Merge deferred" retries in 10 minutes on + * a 6-project deployment, collapsing merge throughput ~6x). + */ + const scope = taskProjectScope(layer); + if (scope) conditions.push(scope); + if (excludeTaskId) { + conditions.push(ne(schema.project.tasks.id, excludeTaskId)); } - const sql = excludeTaskId - ? `SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND status IN ('merging', 'merging-pr') AND id != ? LIMIT 1` - : `SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND status IN ('merging', 'merging-pr') LIMIT 1`; - const params = excludeTaskId ? [excludeTaskId] : []; - const row = store.db.prepare(sql).get(...params) as { id: string } | undefined; - return row?.id; + const rows = await layer.db + .select({ id: schema.project.tasks.id }) + .from(schema.project.tasks) + .where(and(...conditions)) + .limit(1); + return rows[0]?.id; } export async function findRecentTasksByContentFingerprintImpl(store: TaskStore, @@ -703,37 +402,28 @@ export async function findRecentTasksByContentFingerprintImpl(store: TaskStore, * operator on the source_metadata column. The soft-delete visibility * filter (deletedAt IS NULL) and the createdAt window are preserved. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const conditions = [ - isNull(schema.project.tasks.deletedAt), - sql`${schema.project.tasks.sourceMetadata}->>'contentFingerprint' = ${trimmedFingerprint}`, - sql`${schema.project.tasks.createdAt} >= ${cutoffIso}`, - ]; - if (!includeArchived) { - conditions.push(ne(schema.project.tasks.column, "archived")); - } - const rows = await layer.db - .select() - .from(schema.project.tasks) - .where(and(...conditions)) - .orderBy(schema.project.tasks.createdAt); - return rows.map((row) => store.rowToTask(store.pgRowToTaskRow(row as unknown as Record))); + const layer = store.asyncLayer!; + const conditions = [ + isNull(schema.project.tasks.deletedAt), + sql`${schema.project.tasks.sourceMetadata}->>'contentFingerprint' = ${trimmedFingerprint}`, + sql`${schema.project.tasks.createdAt} >= ${cutoffIso}`, + ]; + if (!includeArchived) { + conditions.push(ne(schema.project.tasks.column, "archived")); } - - const selectClause = store.getTaskSelectClause(false, "t"); - - const rows = store.db.prepare(` - SELECT ${selectClause} - FROM tasks t - WHERE t."deletedAt" IS NULL - AND json_extract(t.sourceMetadata, '$.contentFingerprint') = ? - AND t.createdAt >= ? - ${includeArchived ? "" : "AND t.\"column\" != 'archived'"} - ORDER BY t.createdAt ASC - `).all(trimmedFingerprint, cutoffIso) as TaskRow[]; - - return rows.map((row) => store.rowToTask(row)); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Scope fingerprint duplicate-guard to the bound project so another project's matching fingerprint cannot block create. + */ + if (layer.projectId) { + conditions.push(eq(schema.project.tasks.projectId, layer.projectId)); + } + const rows = await layer.db + .select() + .from(schema.project.tasks) + .where(and(...conditions)) + .orderBy(schema.project.tasks.createdAt); + return rows.map((row) => store.rowToTask(store.pgRowToTaskRow(row as unknown as Record))); } export async function findRecentTasksBySourceParentTaskIdImpl( @@ -746,23 +436,14 @@ export async function findRecentTasksBySourceParentTaskIdImpl( const dayMs = 24 * 60 * 60 * 1000; const windowMs = Math.max(1, Math.min(dayMs, Math.trunc(options?.windowMs ?? dayMs))); const cutoffIso = new Date(Date.now() - windowMs).toISOString(); - if (store.backendMode) { const layer = store.asyncLayer!; - const rows = await layer.db.select().from(schema.project.tasks).where(and( - isNull(schema.project.tasks.deletedAt), taskProjectScope(layer), - eq(schema.project.tasks.sourceParentTaskId, parentId), - sql`${schema.project.tasks.createdAt} >= ${cutoffIso}`, - ne(schema.project.tasks.column, "archived"), ne(schema.project.tasks.column, "done"), - )).orderBy(asc(schema.project.tasks.createdAt)); - return rows.map((row) => store.rowToTask(store.pgRowToTaskRow(row as unknown as Record))); - } - const selectClause = store.getTaskSelectClause(false, "t"); - const rows = store.db.prepare(` - SELECT ${selectClause} FROM tasks t - WHERE t."deletedAt" IS NULL AND t.sourceParentTaskId = ? AND t.createdAt >= ? - AND t."column" NOT IN ('archived', 'done') ORDER BY t.createdAt ASC - `).all(parentId, cutoffIso) as TaskRow[]; - return rows.map((row) => store.rowToTask(row)); + const rows = await layer.db.select().from(schema.project.tasks).where(and( + isNull(schema.project.tasks.deletedAt), taskProjectScope(layer), + eq(schema.project.tasks.sourceParentTaskId, parentId), + sql`${schema.project.tasks.createdAt} >= ${cutoffIso}`, + ne(schema.project.tasks.column, "archived"), ne(schema.project.tasks.column, "done"), + )).orderBy(asc(schema.project.tasks.createdAt)); + return rows.map((row) => store.rowToTask(store.pgRowToTaskRow(row as unknown as Record))); } export async function clearNearDuplicateReferencesToFailSoftImpl(store: TaskStore, @@ -788,35 +469,13 @@ export async function getTasksByAssignedAgentImpl(store: TaskStore, * FNXC:SqliteFinalRemoval 2026-06-25: * In backend mode, use listTasks and filter in-memory instead of raw SQL. */ - if (store.backendMode) { - const allTasks = await store.listTasks(); - return allTasks.filter((task) => { - if (task.assignedAgentId !== agentId) return false; - if (options?.pausedOnly && !task.paused) return false; - if (options?.excludeArchived && task.column === "archived") return false; - return true; - }); - } - - const whereClauses = ["assignedAgentId = ?", TaskStore.ACTIVE_TASKS_WHERE]; - const params: Array = [agentId]; - - if (options?.pausedOnly) { - whereClauses.push("paused = 1"); - } - - if (options?.excludeArchived) { - whereClauses.push('"column" != \'archived\''); - } - - const selectClause = store.getTaskSelectClause(false); - const rows = store.db.prepare(` - SELECT ${selectClause} FROM tasks - WHERE ${whereClauses.join(" AND ")} - ORDER BY createdAt ASC - `).all(...params) as TaskRow[]; - - return rows.map((row) => store.rowToTask(row)); + const allTasks = await store.listTasks(); + return allTasks.filter((task) => { + if (task.assignedAgentId !== agentId) return false; + if (options?.pausedOnly && !task.paused) return false; + if (options?.excludeArchived && task.column === "archived") return false; + return true; + }); } export function resolveWorkflowMoveActorImpl(store: TaskStore, @@ -989,12 +648,8 @@ export function getWorkflowSettingsProjectIdImpl(store: TaskStore): string { swallow produced, without the spurious stub throw. Only the true legacy (non-backend) path consults the SQLite identity. */ - if (store.backendMode) return store.rootDir; - try { - return store.db.getProjectIdentity()?.id ?? store.rootDir; - } catch { - return store.rootDir; - } + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: project id for workflow settings is rootDir under PG. */ + return store.rootDir; } export async function listWorkflowSettingValuesForProjectImpl(store: TaskStore): Promise>> { @@ -1002,33 +657,15 @@ export async function listWorkflowSettingValuesForProjectImpl(store: TaskStore): * 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) { - 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 - .prepare('SELECT workflowId, "values" FROM workflow_settings WHERE projectId = ?') - .all(projectId) as Array<{ workflowId: string; values: string }>; + 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) { - try { - const parsed = JSON.parse(row.values) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - out[row.workflowId] = parsed as Record; - } - } catch { - // Skip corrupt row. + if (row.values && typeof row.values === "object" && !Array.isArray(row.values)) { + out[row.workflowId] = row.values as Record; } } return out; @@ -1042,26 +679,14 @@ export async function computeMovedSettingsTargetWorkflowIdsImpl(store: TaskStore * read threw in PG mode. In backend mode, read distinct workflowId via * Drizzle. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const rows = await layer.db - .selectDistinct({ workflowId: schema.project.taskWorkflowSelection.workflowId }) - .from(schema.project.taskWorkflowSelection); - for (const row of rows) { - if (row.workflowId && row.workflowId.trim()) targetWorkflowIds.add(row.workflowId); - } - } else { - try { - const rows = store.db - .prepare("SELECT DISTINCT workflowId FROM task_workflow_selection WHERE workflowId IS NOT NULL AND workflowId != ''") - .all() as Array<{ workflowId: string }>; - for (const row of rows) { - if (row.workflowId && row.workflowId.trim()) targetWorkflowIds.add(row.workflowId); - } - } catch { - // No selections / table issue — fall through to the default below. - } + const layer = store.asyncLayer!; + const rows = await layer.db + .selectDistinct({ workflowId: schema.project.taskWorkflowSelection.workflowId }) + .from(schema.project.taskWorkflowSelection); + for (const row of rows) { + if (row.workflowId && row.workflowId.trim()) targetWorkflowIds.add(row.workflowId); } + let defaultWorkflowId = "builtin:coding"; try { const resolved = await store.getDefaultWorkflowId(); @@ -1076,7 +701,7 @@ export async function computeMovedSettingsTargetWorkflowIdsImpl(store: TaskStore return targetWorkflowIds; } -export function getWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string): Record { +export function getWorkflowSettingValuesImpl(_store: TaskStore, _workflowId: string, _projectId: string): Record { /* * FNXC:SqliteFinalRemoval 2026-06-26: * P1 fix: no backendMode branch existed, so this threw in PG mode. In @@ -1084,21 +709,7 @@ export function getWorkflowSettingValuesImpl(store: TaskStore, workflowId: strin * empty (the default); the async `updateWorkflowSettingValues` path reads * the real values via Drizzle before merging. */ - if (store.backendMode) { - return {}; - } - const row = store.db - .prepare('SELECT "values" FROM workflow_settings WHERE workflowId = ? AND projectId = ?') - .get(workflowId, projectId) as { values: string } | undefined; - if (!row) return {}; - try { - const parsed = JSON.parse(row.values) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - } catch { - return {}; - } + return {}; } /** @@ -1110,7 +721,7 @@ export async function getWorkflowSettingValuesAsyncImpl( workflowId: string, projectId: string, ): Promise> { - if (!store.backendMode) return store.getWorkflowSettingValues(workflowId, projectId); + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: always PostgreSQL path below. */ const rows = await store.asyncLayer!.db .select({ values: schema.project.workflowSettings.values }) .from(schema.project.workflowSettings) @@ -1149,7 +760,7 @@ export async function getWorkflowPromptOverridesAsyncImpl( workflowId: string, projectId: string, ): Promise> { - if (!store.backendMode) return store.getWorkflowPromptOverrides(workflowId, projectId); + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: always PostgreSQL path below. */ const rows = await store.asyncLayer!.db .select({ overrides: schema.project.workflowPromptOverrides.overrides }) .from(schema.project.workflowPromptOverrides) @@ -1176,51 +787,20 @@ export async function updateWorkflowPromptOverridesImpl(store: TaskStore, * FNXC:WorkflowModelLanes 2026-07-14-16:26: * Keep PostgreSQL prompt override patches on the same authoritative transaction path as workflow settings; a backend sync-default read must never erase sibling overrides. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - return layer.transactionImmediate(async (tx) => { - const rows = await tx - .select({ overrides: schema.project.workflowPromptOverrides.overrides }) - .from(schema.project.workflowPromptOverrides) - .where(and( - eq(schema.project.workflowPromptOverrides.workflowId, workflowId), - eq(schema.project.workflowPromptOverrides.projectId, projectId), - )) - .limit(1); - const rawCurrent = rows[0]?.overrides; - const current = rawCurrent && typeof rawCurrent === "object" && !Array.isArray(rawCurrent) - ? rawCurrent as Record - : {}; - const next: Record = { ...current }; - for (const [nodeId, value] of Object.entries(patch)) { - if (typeof value !== "string" || value.trim().length === 0) { - delete next[nodeId]; - } else { - next[nodeId] = value; - } - } - - const now = new Date().toISOString(); - await tx - .insert(schema.project.workflowPromptOverrides) - .values({ - workflowId, - projectId, - overrides: next, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [schema.project.workflowPromptOverrides.workflowId, schema.project.workflowPromptOverrides.projectId], - set: { - overrides: next, - updatedAt: now, - }, - }); - return next; - }); - } - return store.db.transactionImmediate(() => { - const current = store.getWorkflowPromptOverrides(workflowId, projectId); + const layer = store.asyncLayer!; + return layer.transactionImmediate(async (tx) => { + const rows = await tx + .select({ overrides: schema.project.workflowPromptOverrides.overrides }) + .from(schema.project.workflowPromptOverrides) + .where(and( + eq(schema.project.workflowPromptOverrides.workflowId, workflowId), + eq(schema.project.workflowPromptOverrides.projectId, projectId), + )) + .limit(1); + const rawCurrent = rows[0]?.overrides; + const current = rawCurrent && typeof rawCurrent === "object" && !Array.isArray(rawCurrent) + ? rawCurrent as Record + : {}; const next: Record = { ...current }; for (const [nodeId, value] of Object.entries(patch)) { if (typeof value !== "string" || value.trim().length === 0) { @@ -1231,15 +811,21 @@ export async function updateWorkflowPromptOverridesImpl(store: TaskStore, } const now = new Date().toISOString(); - store.db - .prepare( - `INSERT INTO workflow_prompt_overrides (workflowId, projectId, overrides, updatedAt) - VALUES (?, ?, ?, ?) - ON CONFLICT(workflowId, projectId) - DO UPDATE SET overrides = excluded.overrides, updatedAt = excluded.updatedAt`, - ) - .run(workflowId, projectId, JSON.stringify(next), now); - store.db.bumpLastModified(); + await tx + .insert(schema.project.workflowPromptOverrides) + .values({ + workflowId, + projectId, + overrides: next, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [schema.project.workflowPromptOverrides.workflowId, schema.project.workflowPromptOverrides.projectId], + set: { + overrides: next, + updatedAt: now, + }, + }); return next; }); } @@ -1249,30 +835,16 @@ export async function getMutationsForRunImpl(store: TaskStore, runId: string): P * FNXC:SqliteFinalRemoval 2026-06-26: * In backend mode, use the async layer to read tasks instead of store.db. */ - if (store.backendMode) { - const tasks = await store.listTasks(); - const mutations: TaskLogEntry[] = []; - for (const task of tasks) { - const logEntries = task.log || []; - for (const entry of logEntries) { - if (entry.runContext?.runId === runId) { - mutations.push(entry); - } - } - } - return mutations.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - } - const rows = store.db.prepare(`SELECT log FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ log: string | null }>; + const tasks = await store.listTasks(); const mutations: TaskLogEntry[] = []; - for (const row of rows) { - const logEntries = fromJson(row.log) || []; + for (const task of tasks) { + const logEntries = task.log || []; for (const entry of logEntries) { if (entry.runContext?.runId === runId) { mutations.push(entry); } } } - // Sort by timestamp ascending return mutations.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } @@ -1393,74 +965,47 @@ export async function upsertMergeRequestRecordImpl(store: TaskStore, * sync path's audit fan-out). The audit uses the fire-and-forget async * helper (recordRunAuditEventAsync) for parity with other backend paths. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const now = input.now ?? new Date().toISOString(); - const attemptCount = input.attemptCount ?? 0; - const lastError = input.lastError ?? null; - const result = await layer.transactionImmediate(async (tx) => { - await tx - .insert(schema.project.mergeRequests) - .values({ - taskId, + const layer = store.asyncLayer!; + const now = input.now ?? new Date().toISOString(); + const attemptCount = input.attemptCount ?? 0; + const lastError = input.lastError ?? null; + const result = await layer.transactionImmediate(async (tx) => { + await tx + .insert(schema.project.mergeRequests) + .values({ + taskId, + state: input.state, + createdAt: now, + updatedAt: now, + attemptCount, + lastError, + }) + .onConflictDoUpdate({ + target: [schema.project.mergeRequests.projectId, schema.project.mergeRequests.taskId], + set: { state: input.state, - createdAt: now, updatedAt: now, attemptCount, lastError, - }) - .onConflictDoUpdate({ - target: [schema.project.mergeRequests.projectId, schema.project.mergeRequests.taskId], - set: { - state: input.state, - updatedAt: now, - attemptCount, - lastError, - }, - }); - const rows = await tx - .select() - .from(schema.project.mergeRequests) - .where(eq(schema.project.mergeRequests.taskId, taskId)) - .limit(1); - const row = rows[0] as MergeRequestRow | undefined; - if (!row) throw new Error(`Failed to upsert merge request for ${taskId}`); - return row; - }); - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "mergeRequest:upsert", - target: taskId, - metadata: { taskId, state: result.state, attemptCount: result.attemptCount, lastError: result.lastError }, - }); - return store.rowToMergeRequestRecord(result); - } - return store.db.transactionImmediate(() => { - const now = input.now ?? new Date().toISOString(); - store.db.prepare(` - INSERT INTO merge_requests (taskId, state, createdAt, updatedAt, attemptCount, lastError) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(taskId) DO UPDATE SET - state = excluded.state, - updatedAt = excluded.updatedAt, - attemptCount = excluded.attemptCount, - lastError = excluded.lastError - `).run(taskId, input.state, now, now, input.attemptCount ?? 0, input.lastError ?? null); - - const row = store.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined; + }, + }); + const rows = await tx + .select() + .from(schema.project.mergeRequests) + .where(eq(schema.project.mergeRequests.taskId, taskId)) + .limit(1); + const row = rows[0] as MergeRequestRow | undefined; if (!row) throw new Error(`Failed to upsert merge request for ${taskId}`); - - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "mergeRequest:upsert", - target: taskId, - metadata: { taskId, state: row.state, attemptCount: row.attemptCount, lastError: row.lastError }, - }); - - return store.rowToMergeRequestRecord(row); + return row; }); + store.insertRunAuditEventRow({ + taskId, + domain: "database", + mutationType: "mergeRequest:upsert", + target: taskId, + metadata: { taskId, state: result.state, attemptCount: result.attemptCount, lastError: result.lastError }, + }); + return store.rowToMergeRequestRecord(result); } export async function transitionMergeRequestStateImpl(store: TaskStore, @@ -1475,55 +1020,15 @@ export async function transitionMergeRequestStateImpl(store: TaskStore, * merge_requests row inside a transactionImmediate and fire the audit * event, mirroring the sync path's transition guard + audit fan-out. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const now = opts.now ?? new Date().toISOString(); - const updated = await layer.transactionImmediate(async (tx) => { - const existingRows = await tx - .select() - .from(schema.project.mergeRequests) - .where(eq(schema.project.mergeRequests.taskId, taskId)) - .limit(1); - const existing = existingRows[0] as MergeRequestRow | undefined; - if (!existing) { - throw new Error(`Merge request record not found for ${taskId}`); - } - const fromState = store.normalizeMergeRequestState(existing.state); - if (!store.isValidMergeRequestTransition(fromState, toState)) { - throw new Error(`Invalid merge request state transition for ${taskId}: ${fromState} -> ${toState}`); - } - - await tx - .update(schema.project.mergeRequests) - .set({ - state: toState, - updatedAt: now, - attemptCount: opts.attemptCount ?? existing.attemptCount, - lastError: opts.lastError ?? existing.lastError, - }) - .where(eq(schema.project.mergeRequests.taskId, taskId)); - - const updatedRows = await tx - .select() - .from(schema.project.mergeRequests) - .where(eq(schema.project.mergeRequests.taskId, taskId)) - .limit(1); - const row = updatedRows[0] as MergeRequestRow | undefined; - if (!row) throw new Error(`Merge request record disappeared for ${taskId}`); - return { row, fromState }; - }); - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "mergeRequest:transition", - target: taskId, - metadata: { taskId, fromState: updated.fromState, toState, attemptCount: updated.row.attemptCount, lastError: updated.row.lastError }, - }); - return store.rowToMergeRequestRecord(updated.row); - } - return store.db.transactionImmediate(() => { - const now = opts.now ?? new Date().toISOString(); - const existing = store.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined; + const layer = store.asyncLayer!; + const now = opts.now ?? new Date().toISOString(); + const updated = await layer.transactionImmediate(async (tx) => { + const existingRows = await tx + .select() + .from(schema.project.mergeRequests) + .where(eq(schema.project.mergeRequests.taskId, taskId)) + .limit(1); + const existing = existingRows[0] as MergeRequestRow | undefined; if (!existing) { throw new Error(`Merge request record not found for ${taskId}`); } @@ -1532,27 +1037,33 @@ export async function transitionMergeRequestStateImpl(store: TaskStore, throw new Error(`Invalid merge request state transition for ${taskId}: ${fromState} -> ${toState}`); } - store.db.prepare(` - UPDATE merge_requests - SET state = ?, - updatedAt = ?, - attemptCount = ?, - lastError = ? - WHERE taskId = ? - `).run(toState, now, opts.attemptCount ?? existing.attemptCount, opts.lastError ?? existing.lastError, taskId); + await tx + .update(schema.project.mergeRequests) + .set({ + state: toState, + updatedAt: now, + attemptCount: opts.attemptCount ?? existing.attemptCount, + lastError: opts.lastError ?? existing.lastError, + }) + .where(eq(schema.project.mergeRequests.taskId, taskId)); - const updated = store.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined; - if (!updated) throw new Error(`Merge request record disappeared for ${taskId}`); - - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "mergeRequest:transition", - target: taskId, - metadata: { taskId, fromState, toState, attemptCount: updated.attemptCount, lastError: updated.lastError }, - }); - return store.rowToMergeRequestRecord(updated); + const updatedRows = await tx + .select() + .from(schema.project.mergeRequests) + .where(eq(schema.project.mergeRequests.taskId, taskId)) + .limit(1); + const row = updatedRows[0] as MergeRequestRow | undefined; + if (!row) throw new Error(`Merge request record disappeared for ${taskId}`); + return { row, fromState }; }); + store.insertRunAuditEventRow({ + taskId, + domain: "database", + mutationType: "mergeRequest:transition", + target: taskId, + metadata: { taskId, fromState: updated.fromState, toState, attemptCount: updated.row.attemptCount, lastError: updated.row.lastError }, + }); + return store.rowToMergeRequestRecord(updated.row); } export function insertCompletionHandoffWorkflowWorkAuditImpl(store: TaskStore, @@ -1632,12 +1143,8 @@ export function transitionWorkflowWorkItemSyncImpl(store: TaskStore, } export async function getWorkflowWorkItemImpl(store: TaskStore, id: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getWorkflowWorkItemAsync(layer.db, id); - } - const row = store.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; - return row ? store.rowToWorkflowWorkItem(row) : null; + const layer = store.asyncLayer!; + return getWorkflowWorkItemAsync(layer.db, id); } export type ToolFailureRetryClaim = diff --git a/packages/core/src/task-store/branch-group-ops.ts b/packages/core/src/task-store/branch-group-ops.ts index 779fd2e85b..7babb1d5bb 100644 --- a/packages/core/src/task-store/branch-group-ops.ts +++ b/packages/core/src/task-store/branch-group-ops.ts @@ -12,9 +12,7 @@ import {runReconciliationAbort} from "../workflow-reconciliation.js"; import "../builtin-traits.js"; import {evaluateImplementationTaskBind} from "../agent-role-policy.js"; import {isNearDuplicateCanonicalInactive} from "../near-duplicate-canonical.js"; -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"; @@ -27,128 +25,59 @@ export async function saveWorkflowRunBranchImpl(store: TaskStore, state: { taskI by constraint name because project-schema PKs lead with project_id (which itself comes from the column's current_setting default under RLS). */ - if (store.backendMode) { - await store.asyncLayer!.db.execute(sql` - INSERT INTO project.workflow_run_branches - (task_id, run_id, branch_id, current_node_id, status, updated_at) - VALUES (${state.taskId}, ${state.runId}, ${state.branchId}, ${state.currentNodeId}, ${state.status}, ${new Date().toISOString()}) - ON CONFLICT ON CONSTRAINT workflow_run_branches_pkey DO UPDATE SET - current_node_id = EXCLUDED.current_node_id, - status = EXCLUDED.status, - updated_at = EXCLUDED.updated_at - `); - return; - } - try { - store.db - .prepare( - `INSERT INTO workflow_run_branches - (taskId, runId, branchId, currentNodeId, status, updatedAt) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(taskId, runId, branchId) DO UPDATE SET - currentNodeId = excluded.currentNodeId, - status = excluded.status, - updatedAt = excluded.updatedAt`, - ) - .run( - state.taskId, - state.runId, - state.branchId, - state.currentNodeId, - state.status, - new Date().toISOString(), - ); - } catch { - // Legacy/missing table — persistence is additive, so degrade silently. - } - } + await store.asyncLayer!.db.execute(sql` + INSERT INTO project.workflow_run_branches + (task_id, run_id, branch_id, current_node_id, status, updated_at) + VALUES (${state.taskId}, ${state.runId}, ${state.branchId}, ${state.currentNodeId}, ${state.status}, ${new Date().toISOString()}) + ON CONFLICT ON CONSTRAINT workflow_run_branches_pkey DO UPDATE SET + current_node_id = EXCLUDED.current_node_id, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at + `); + return; +} export async function clearNearDuplicateReferencesToImpl(store: TaskStore, canonicalId: string, inactiveState: { column?: ColumnId | null; deletedAt?: string | null; reason: string },): Promise { if (!isNearDuplicateCanonicalInactive(inactiveState)) { return []; } - if (store.backendMode) { - /* - * 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"); - const rows = store.db.prepare(` - SELECT ${selectClause} - FROM tasks t - WHERE t."deletedAt" IS NULL - AND t."column" != 'archived' - AND t."column" != 'done' - AND json_extract(t.sourceMetadata, '$.nearDuplicateOf') = ? - ORDER BY t.createdAt ASC - `).all(canonicalId) as TaskRow[]; + /* + * 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) { - const task = store.rowToTask(row); - const nextSourceMetadata = { ...(task.sourceMetadata ?? {}) }; - delete nextSourceMetadata.nearDuplicateOf; - delete nextSourceMetadata.nearDuplicateScore; - delete nextSourceMetadata.nearDuplicateSharedTokens; - delete nextSourceMetadata.nearDuplicateDismissed; - - task.sourceMetadata = Object.keys(nextSourceMetadata).length > 0 ? nextSourceMetadata : undefined; - const updatedAt = new Date().toISOString(); - task.updatedAt = updatedAt; - task.log = [ - ...(task.log ?? []), - { - timestamp: updatedAt, - action: `Near-duplicate canonical ${canonicalId} is now inactive (${inactiveState.reason}); cleared duplicate flag (informational, no decision required)`, - }, - ]; - - store.db.transactionImmediate(() => { - store.upsertTaskWithFtsRecovery(task); - store.db.bumpLastModified(); - }); - await store.writeTaskJsonFile(store.taskDir(task.id), task); - if (store.isWatching) store.taskCache.set(task.id, { ...task }); - store.emit("task:updated", task); - updatedTasks.push(task); + 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; - } +} export async function selectNextTaskForAgentImpl(store: TaskStore, agentId: string, agent?: Pick & Partial>,): Promise { const hasExecutorRoleOverride = (task: Task): boolean => task.sourceMetadata?.executorRoleOverride === true; @@ -347,70 +276,8 @@ export async function listArtifactsImpl(store: TaskStore, options?: { type?: Art // PG backend mode: delegate to the AsyncDataLayer helper. The sync path // below dereferences store.db (no SQLite handle in backend mode) and 500'd // the dashboard /api/artifacts list. - if (store.backendMode) { - return listArtifactsAsync(store.asyncLayer!.db, options); - } - const limit = Math.min(Math.max(1, options?.limit ?? 200), 1000); - const offset = Math.max(0, options?.offset ?? 0); - - let sql = ` - SELECT - a.id, - a.type, - a.title, - a.description, - a.mimeType, - a.sizeBytes, - a.uri, - NULL as content, - a.authorId, - a.authorType, - a.taskId, - a.metadata, - a.createdAt, - a.updatedAt, - t.title as taskTitle, - t.description as taskDescription, - t.column as taskColumn - FROM artifacts a - LEFT JOIN tasks t ON a.taskId = t.id - WHERE (a.taskId IS NULL OR t.${TaskStore.ACTIVE_TASKS_WHERE}) - `; - const params: (string | number)[] = []; - - if (options?.type) { - sql += " AND a.type = ?"; - params.push(options.type); - } - if (options?.authorId) { - sql += " AND a.authorId = ?"; - params.push(options.authorId); - } - if (options?.taskId) { - sql += " AND a.taskId = ?"; - params.push(options.taskId); - } - if (options?.search && options.search.trim() !== "") { - const query = `%${options.search.trim()}%`; - sql += " AND (a.title LIKE ? OR a.description LIKE ?)"; - params.push(query, query); - } - - sql += " ORDER BY a.createdAt DESC LIMIT ? OFFSET ?"; - params.push(limit, offset); - - const rows = store.db.prepare(sql).all(...params) as unknown as Array; - return rows.map((row) => ({ - ...store.rowToArtifact(row), - ...(row.taskTitle !== null ? { taskTitle: row.taskTitle } : {}), - ...(row.taskDescription !== null ? { taskDescription: row.taskDescription } : {}), - ...(row.taskColumn !== null ? { taskColumn: row.taskColumn } : {}), - })); - } + return listArtifactsAsync(store.asyncLayer!.db, options); +} export async function rehomeOccupantImpl(store: TaskStore, taskId: string, targetColumn: string, reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome", metadata: Record,): Promise { /* diff --git a/packages/core/src/task-store/comments-ops.ts b/packages/core/src/task-store/comments-ops.ts index 3d58999c7f..3557b670e1 100644 --- a/packages/core/src/task-store/comments-ops.ts +++ b/packages/core/src/task-store/comments-ops.ts @@ -10,21 +10,18 @@ const severityAuditLog = createLogger("core-comments-ops"); * instance as its first parameter and performs byte-identical work. */ import {TaskStore, storeLog} from "../store.js"; -import {randomUUID} from "node:crypto"; import {readFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync} from "node:fs"; -import type {ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, Task, Column, TaskDocument, TaskDocumentCreateInput, TaskLogEntry, RunMutationContext} from "../types.js"; +import type {ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, Task, TaskDocument, TaskDocumentCreateInput, TaskLogEntry, RunMutationContext} from "../types.js"; import {validateDocumentKey} from "../types.js"; import {ArchivedTaskDocumentPublicationRejectedError, validateArchivedTaskDocumentAddition, validateTaskDocumentPreconditions} from "../task-document-concurrency.js"; import "../builtin-traits.js"; -import {toJsonNullable} from "../db.js"; import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub} from "../task-store/comments.js"; import {getLiveTaskColumn, publishArchivedTaskDocumentAddition as publishArchivedTaskDocumentAdditionAsync, 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`); @@ -245,110 +242,10 @@ export async function upsertTaskDocumentImpl(store: TaskStore, taskId: string, i // Backend mode: delegate the core upsert (revision archive + update) to // upsertTaskDocumentAsync. The citation scanning and task:updated emission // happen after (best-effort, same as the SQLite path). - if (store.backendMode) { - const layer = store.asyncLayer!; - const document = await upsertTaskDocumentAsync(layer, taskId, input); - const task = await store.getTask(taskId); - store.emit("task:updated", task); - try { - const citationInputs = store.scanAndRecordCitations( - input.content, - "task_document", - `document:${taskId}:${input.key}:rev${document.revision}`, - input.author ?? "user", - taskId, - document.updatedAt, - ); - if (citationInputs.length > 0) { - void store.recordGoalCitations(citationInputs); - } - } catch (err) { - severityAuditLog.warn("[fusion] Failed to scan/record goal citations from task document:", err); - } - return document; - } - - const taskExists = store.db.prepare(`SELECT id, "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(taskId) as - | { id: string; column: Column } - | undefined; - if (taskExists?.column === "archived") { - throw new Error(`Task ${taskId} is archived — documents are read-only`); - } - if (!taskExists) { - if (store.isTaskArchived(taskId)) { - throw new Error(`Task ${taskId} is archived — documents are read-only`); - } - throw new Error(`Task ${taskId} not found`); - } - - const now = new Date().toISOString(); - const author = input.author ?? "user"; - const metadata = toJsonNullable(input.metadata); - - const document = store.db.transaction(() => { - const existing = store.db - .prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?") - .get(taskId, input.key) as TaskDocumentRow | undefined; - - if (existing) { - store.db.prepare( - `INSERT INTO task_document_revisions (taskId, key, content, revision, author, metadata, createdAt) - VALUES (?, ?, ?, ?, ?, ?, ?)` - ).run( - taskId, - input.key, - existing.content, - existing.revision, - existing.author, - existing.metadata ?? null, - now, - ); - - store.db.prepare( - `UPDATE task_documents - SET content = ?, revision = ?, author = ?, metadata = ?, updatedAt = ? - WHERE taskId = ? AND key = ?` - ).run( - input.content, - existing.revision + 1, - author, - metadata, - now, - taskId, - input.key, - ); - } else { - store.db.prepare( - `INSERT INTO task_documents (id, taskId, key, content, revision, author, metadata, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - randomUUID(), - taskId, - input.key, - input.content, - 1, - author, - metadata, - now, - now, - ); - } - - const row = store.db - .prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?") - .get(taskId, input.key) as TaskDocumentRow | undefined; - - if (!row) { - throw new Error(`Failed to upsert document ${input.key} for task ${taskId}`); - } - - return store.rowToTaskDocument(row); - }); - - store.db.bumpLastModified(); + const layer = store.asyncLayer!; + const document = await upsertTaskDocumentAsync(layer, taskId, input); const task = await store.getTask(taskId); store.emit("task:updated", task); - try { const citationInputs = store.scanAndRecordCitations( input.content, @@ -359,11 +256,12 @@ export async function upsertTaskDocumentImpl(store: TaskStore, taskId: string, i document.updatedAt, ); if (citationInputs.length > 0) { - store.recordGoalCitations(citationInputs); + void store.recordGoalCitations(citationInputs).catch((err) => { + severityAuditLog.warn("[fusion] Failed to record goal citations from task document:", err); + }); } } catch (err) { severityAuditLog.warn("[fusion] Failed to scan/record goal citations from task document:", err); } - return document; - } +} diff --git a/packages/core/src/task-store/lifecycle-ops.ts b/packages/core/src/task-store/lifecycle-ops.ts index c18c47b0d1..119f07058b 100644 --- a/packages/core/src/task-store/lifecycle-ops.ts +++ b/packages/core/src/task-store/lifecycle-ops.ts @@ -8,17 +8,16 @@ */ import {TaskStore, storeLog, RECONCILE_ORPHAN_TASK_DIR_MAX_AGE_MS, WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX} from "../store.js"; import {planLegacyAdoption} from "../legacy-adoption.js"; -import {sql} from "drizzle-orm"; +import {and, eq, sql} from "drizzle-orm"; import { MIGRATION_BOOKKEEPING_TABLE, LEGACY_ADOPTION_DRAINED_MARKER, LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION, } from "../postgres/schema-applier.js"; -import {mkdir, readdir, readFile, stat, writeFile} from "node:fs/promises"; +import {mkdir, readdir, readFile, stat} from "node:fs/promises"; import {join} from "node:path"; -import {existsSync, watch, type Dirent} from "node:fs"; -import type {Task, AgentLogEntry, Column, Settings, GlobalSettings} from "../types.js"; -import {DEFAULT_SETTINGS} from "../types.js"; +import {existsSync, type Dirent} from "node:fs"; +import type {Task, AgentLogEntry, Column, GlobalSettings} from "../types.js"; import {MOVED_SETTINGS_KEYS, SETTINGS_MIGRATION_VERSION, SETTINGS_MIGRATION_MARKER_KEY} from "../moved-settings.js"; import {stepsToWorkflowIr, stepToFragmentIr, layoutForIr} from "../workflow-steps-to-ir.js"; import {getTraitRegistry} from "../trait-registry.js"; @@ -28,15 +27,13 @@ import {clearTransitionPendingAsync, listTransitionPendingTaskIdsAsync, readTran import type {WorkflowSettingDefinition} from "../workflow-ir-types.js"; import {validateSettingValuePatch} from "../workflow-settings.js"; import "../builtin-traits.js"; -import {Database, SCHEMA_VERSION} from "../db.js"; -import {ensureMemoryFileWithBackend} from "../project-memory.js"; import {appendAgentLogEntriesSync} from "../agent-log-file-store.js"; import {getErrorMessage} from "../error-message.js"; import {type TaskRow} from "../task-store/persistence.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {reconcileTaskIdStateAsync} from "../task-store/async-allocator.js"; -import {ACTIVE_TASK_FILTER, insertTaskRow, isTaskIdConflictError as isPgTaskIdConflictError} from "./async-persistence.js"; -import {recordRunAuditEvent as recordRunAuditEventAsync} from "./async-audit.js"; +import {ACTIVE_TASK_FILTER, insertTaskRowInTransaction, isTaskIdConflictError as isPgTaskIdConflictError} from "./async-persistence.js"; +import {recordRunAuditEventWithinTransaction} from "../postgres/data-layer.js"; import * as schema from "../postgres/schema/index.js"; export async function initImpl(store: TaskStore): Promise { @@ -50,240 +47,35 @@ export async function initImpl(store: TaskStore): Promise { // transitively via default-workflow-hooks / trait-registry). registerDefaultWorkflowHooks(); - // FNXC:RuntimeBackendInjection 2026-06-24-14:15: - // In backend mode (an AsyncDataLayer was injected), TaskStore skips ALL - // SQLite construction and the SQLite-specific startup reconciliations - // (corruption guard, legacy file migration, agent-log file migration, - // schema-version re-init, orphaned task-dir reconcile, etc.). The PostgreSQL - // schema baseline is applied by the startup factory before constructing the - // store. Backend-safe startup work is performed explicitly in the branch below. - // - // When the async layer is ABSENT, the entire block below runs exactly as - // before — byte-identical to the pre-migration SQLite path. - if (store.backendMode) { - // FNXC:RuntimePersistenceAsync 2026-06-24-10:32: - // In backend mode, run the async allocator reconciliation so sequences - // are bumped to the high-water mark on store open (VAL-DATA-007/008). - // Soft-deleted/archived IDs stay reserved because the reconciliation - // scans them. The SQLite-specific integrity-report refreshers are not - // applicable in backend mode (the async task-id-integrity detector is - // wired by a separate feature). - try { - await reconcileTaskIdStateAsync(store.asyncLayer!); - store.taskIdStateReconciled = true; - } catch (error) { - storeLog.warn("Async allocator reconciliation failed during backend init", { - phase: "init:async-allocator-reconcile", - error: error instanceof Error ? error.message : String(error), - }); - } - /* - FNXC:LegacyAdoption 2026-07-19-05:10 (U9b / R10 / KTD-8): - Store-open legacy adoption must run HERE, not only in the SQLite tail below — backend - mode returns early, and backend mode is production. Placing the call only after this - return would make it dead code exactly where pre-cutover rows actually live. Uses the - async store API (listTasks/updateTask), so it is PG-safe. - */ - await adoptLegacyTaskRowsOnOpen(store); - // Lifecycle listeners are backend-agnostic: recordActivity() routes their - // best-effort writes through the injected PostgreSQL data layer. - store.setupActivityLogListeners(); - return; - } - - // Initialize SQLite database - if (!store._db) { - // Startup corruption guard: before opening, detect a malformed fusion.db - // (a node:sqlite SIGSEGV mid-write can leave the B-tree corrupt in a way - // that still opens) and rebuild it via sqlite3 .recover, preserving the - // corrupt original. Disk-backed only; opt out with FUSION_DISABLE_DB_AUTORECOVER. - // FNXC:SqliteRemoval 2026-06-25-18:30: inMemoryDb always false now (removed). - if (process.env.FUSION_DISABLE_DB_AUTORECOVER !== "1") { - try { - const recovery = Database.recoverIfCorrupt(store.fusionDir); - if (recovery.status === "recovered") { - // A `.recover` rebuild can drop task rows whose task.json survived on disk. Let the - // orphan reconcile below bypass its recency window so those rows are recovered even - // when their (possibly old) task.json mtime would otherwise fail the gate. - store.dbWasCorruptionRecovered = true; - storeLog.warn("Recovered corrupt fusion.db on startup", { - phase: "init:db-autorecover", - corruptBackupPath: recovery.corruptBackupPath, - errors: recovery.errors?.slice(0, 5), - }); - } else if (recovery.status === "failed") { - storeLog.error("fusion.db is corrupt and automatic recovery failed", { - phase: "init:db-autorecover", - errors: recovery.errors?.slice(0, 5), - }); - } - } catch (error) { - storeLog.warn("Startup db corruption guard threw — continuing to open", { - phase: "init:db-autorecover", - error: error instanceof Error ? error.message : String(error), - }); - } - } - - const db = new Database(store.fusionDir, { inMemory: false }); - try { - db.init(); - } catch (error) { - db.close(); - throw error; - } - store._db = db; - } - - store.reconcileDistributedTaskIdStateOnOpen(); - - await store.migrateActiveArchivedTasksToArchiveDb(); - await store.migrateAgentLogEntriesToFilesOnce(); - await store.cleanupNoOpTaskMovedActivityRowsOnce(); - try { - await store.markLegacyAutoMergeStampsOnce(); - } catch (err) { - storeLog.warn("Legacy auto-merge stamp marker failed during init (non-fatal)", { - phase: "init:legacy-auto-merge-stamp-marker", - error: err instanceof Error ? err.message : String(err), - }); - } - // U4: one-time per-project hard-move of MOVED_SETTINGS_KEYS into workflow - // setting values (marker-gated, idempotent, never blocks startup). - try { - await store.migrateMovedSettingsToWorkflowValuesOnce(); - } catch (err) { - storeLog.warn("Settings hard-move migration failed during init (non-fatal)", { - phase: "init:settings-hard-move", - error: err instanceof Error ? err.message : String(err), - }); - } - // Re-run init when migrations are pending, or when the deferred - // agentLogEntries drop still needs to fire: migration 102 skips the - // destructive drop until migrateAgentLogEntriesToFilesOnce() above writes - // the __meta guard, but migrations 103+ bump the schema version past 102 - // on the first pass, so the version check alone no longer triggers the - // second pass that performs the drop. - const legacyAgentLogTableRemains = - store.db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") - .get() !== undefined; - if (store.db.getSchemaVersion() < SCHEMA_VERSION || legacyAgentLogTableRemains) { - store.db.init(); - } - await store.importLegacyAgentLogsOnce(); - store.taskIdStateReconciled = false; - store.reconcileDistributedTaskIdStateOnOpen(); - try { - await store.reconcileOrphanedTaskDirs({ ignoreRecencyWindow: store.dbWasCorruptionRecovered }); - } catch (err) { - storeLog.warn("Orphaned task-dir reconcile failed during init (non-fatal)", { - phase: "init:orphaned-task-dir-reconcile", - error: err instanceof Error ? err.message : String(err), - }); - } - - // Write config.json for backward compatibility if it doesn't exist - if (!existsSync(store.configPath)) { - const config = await store.readConfig(); - try { - await writeFile(store.configPath, store.serializeConfigForDisk(config)); - } catch (err) { - storeLog.warn("Backward-compat config.json sync failed during init", { - phase: "init:config-sync", - configPath: store.configPath, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - store.setupActivityLogListeners(); - - // Bootstrap project memory file if memory is enabled - try { - const config = await store.readConfig(); - const mergedSettings: Settings = { ...DEFAULT_SETTINGS, ...config.settings }; - if (mergedSettings.memoryEnabled !== false) { - // Use backend-aware bootstrap to honor memoryBackendType setting - await ensureMemoryFileWithBackend(store.rootDir, mergedSettings); - } - } catch (err) { - // Non-fatal — memory bootstrap failure should not block startup - storeLog.warn("Project-memory bootstrap failed during init", { - phase: "init:memory-bootstrap", - rootDir: store.rootDir, - error: err instanceof Error ? err.message : String(err), - }); - } - /* - FNXC:RunAudit 2026-07-13-13:10 (merge port from main): - Store-open provenance stamp. A store open by a stale binary is how the - FN-7910 incident happened (a pre-fix process's init evacuated Ideas cards; - the run-audit row said only agentId:"system"). Stamp pid / parent pid / - executable / entry script / cwd / node version — ids/paths only, no prose. - Best-effort: a failed stamp never blocks startup. + FNXC:SqliteDualPathCleanup 2026-07-26-13:50: + TaskStore.init is PostgreSQL-only. The former non-backend SQLite corruption-guard / legacy-migration / schema-version init tail is deleted. Production always injects AsyncDataLayer; schema baseline is applied by the startup factory before constructing the store. + + FNXC:RuntimePersistenceAsync 2026-06-24-10:32: + Run async allocator reconciliation so sequences are bumped to the high-water mark on store open (VAL-DATA-007/008). Soft-deleted/archived IDs stay reserved because the reconciliation scans them. */ try { - store.insertRunAuditEventRow({ - agentId: "store", - domain: "database", - mutationType: "store:open", - target: store.rootDir, - metadata: { - pid: process.pid, - ppid: process.ppid, - execPath: process.execPath, - entry: process.argv[1] ?? null, - cwd: process.cwd(), - nodeVersion: process.version, - }, - }); - } catch (err) { - storeLog.warn("store-open provenance stamp failed during init", { - phase: "init:store-open-stamp", - error: err instanceof Error ? err.message : String(err), - }); - } - - // U12: workflow-columns integrity pass. Audit + re-home any task whose - // stored column is no longer valid in its resolved workflow (KTD-1 - // guarantees zero rewrites for healthy legacy rows, so this is a no-op for - // the common case). Idempotent; non-fatal — never blocks startup. - /* - FNXC:WorkflowColumns 2026-07-12-22:40 (merge port from main): - Workflow columns graduated to always-on at runtime, so init must ALWAYS run - the workflow-aware integrity pass and must NEVER run the #1409 flag-OFF - evacuation: the retired experimental flag reads false for virtually every - install, so the old flag-keyed branch ran - evacuateCustomColumnsToLegacy("flag-off-init") on EVERY store open — it - declared healthy custom intake columns (e.g. Coding (Ideas)) invalid and - dumped their cards into "triage", where triage auto-planned and executed - work the operator had deliberately parked (FN-7910). The evacuation now - runs only on an explicit ON→OFF settings toggle. - */ - try { - await store.runWorkflowColumnsIntegrityPass(); - // #1401: recover any transitionPending markers stranded by a crash - // between the in-txn write and the post-commit clear (they otherwise - // permanently inflate capacity counts for their target column). - await store.recoverStaleTransitionPending(); - } catch (err) { - storeLog.warn("workflowColumns integrity pass failed during init", { - phase: "init:workflow-columns-integrity", - error: err instanceof Error ? err.message : String(err), + await reconcileTaskIdStateAsync(store.asyncLayer!); + store.taskIdStateReconciled = true; + } catch (error) { + storeLog.warn("Async allocator reconciliation failed during backend init", { + phase: "init:async-allocator-reconcile", + error: error instanceof Error ? error.message : String(error), }); } /* - FNXC:LegacyAdoption 2026-07-19-14:30 (PR #2341 review): - Adoption runs OUTSIDE the integrity-pass try block: a throw from - runWorkflowColumnsIntegrityPass/recoverStaleTransitionPending must not skip the - sweep for the boot cycle ("every pre-cutover row wakes OWNED" cannot depend on an - unrelated pass succeeding). Safe as a bare await — the sweep is internally fail-soft - and never throws. + FNXC:LegacyAdoption 2026-07-19-05:10 (U9b / R10 / KTD-8): + Store-open legacy adoption must run HERE, not only in the SQLite tail below — backend + mode returns early, and backend mode is production. Placing the call only after this + return would make it dead code exactly where pre-cutover rows actually live. Uses the + async store API (listTasks/updateTask), so it is PG-safe. */ await adoptLegacyTaskRowsOnOpen(store); - } + // Lifecycle listeners are backend-agnostic: recordActivity() routes their + // best-effort writes through the injected PostgreSQL data layer. + store.setupActivityLogListeners(); + return; +} /* FNXC:LegacyAdoption 2026-07-19-05:00 (U9b / R10 / KTD-8): @@ -612,20 +404,20 @@ export async function reconcileOrphanedTaskDirsImpl(store: TaskStore, opts: { ig // the same guard added to stop resurrection. Callers may also force the bypass explicitly. let dbHasLiveTasks = true; try { - if (store.backendMode) { - const layer = store.asyncLayer!; - const rows = await layer.db - .select({ id: schema.project.tasks.id }) - .from(schema.project.tasks) - .where(ACTIVE_TASK_FILTER) - .limit(1); - dbHasLiveTasks = rows.length > 0; - } else { - const row = store.db - .prepare('SELECT EXISTS(SELECT 1 FROM tasks WHERE deletedAt IS NULL LIMIT 1) AS present') - .get() as { present?: number } | undefined; - dbHasLiveTasks = (row?.present ?? 0) === 1; - } + const layer = store.asyncLayer!; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Empty-board probe must be project-scoped; another project's live rows must not keep this project's recency window closed. + */ + const probeConds = [ACTIVE_TASK_FILTER]; + if (layer.projectId) probeConds.push(eq(schema.project.tasks.projectId, layer.projectId)); + const rows = await layer.db + .select({ id: schema.project.tasks.id }) + .from(schema.project.tasks) + .where(and(...probeConds)) + .limit(1); + dbHasLiveTasks = rows.length > 0; + } catch { // If the count probe fails, keep the gate on (conservative — don't mass-resurrect). dbHasLiveTasks = true; @@ -717,14 +509,23 @@ export async function reconcileOrphanedTaskDirsImpl(store: TaskStore, opts: { ig let recovered = false; let skipReason: string | undefined; try { - if (store.backendMode) { - if (await store.taskIdExistsAnywhere(id)) { - skipReason = "id-exists-anywhere"; - } else { - try { - const context = store.createTaskPersistSerializationContext(task); - await insertTaskRow(store.asyncLayer!, task as unknown as Record, context); - await recordRunAuditEventAsync(store.asyncLayer!, { + if (await store.taskIdExistsAnywhere(id)) { + skipReason = "id-exists-anywhere"; + } else { + try { + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Orphan recovery insert + audit share one transaction (parity with the removed SQLite transactionImmediate wrap) so a failed audit cannot leave an unaudited recovered row. + */ + const layer = store.asyncLayer!; + const context = store.createTaskPersistSerializationContext(task); + await layer.transactionImmediate(async (tx) => { + await insertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:10: + Run-audit metadata stays ids/outcomes-only — do not persist taskJsonPath (filesystem path). Path remains on the transient storeLog lines around this recovery. + */ + await recordRunAuditEventWithinTransaction(tx, { taskId: id, agentId: "system", runId: "unknown", @@ -735,50 +536,19 @@ export async function reconcileOrphanedTaskDirsImpl(store: TaskStore, opts: { ig id, column: task.column, status: task.status ?? null, - taskJsonPath, }, }); - recovered = true; - } catch (error) { - if (isPgTaskIdConflictError(error) || /Task ID already exists/i.test(error instanceof Error ? error.message : String(error))) { - skipReason = "id-conflict-during-insert"; - } else { - throw error; - } - } - } - } else { - store.db.transactionImmediate(() => { - // FNXC:SqliteFinalRemoval 2026-06-26: taskIdExistsAnywhere is now async; - // inline the sync SQLite check here since this runs inside transactionImmediate. - if (store.readTaskFromDb(id, { includeDeleted: true }) || store.isTaskIdPresentInArchivedTasksTable(id) || store.archiveDb.get(id) !== undefined) { - skipReason = "id-exists-anywhere"; - return; - } - try { - store.insertTaskWithFtsRecovery(task, "reconcileOrphanedTaskDirs"); - store.insertRunAuditEventRow({ - taskId: id, - domain: "database", - mutationType: "task:reconcile-orphaned-task-dir", - target: id, - metadata: { - id, - column: task.column, - status: task.status ?? null, - taskJsonPath, - }, - }); - recovered = true; - } catch (error) { - if (store.isTaskIdConflictError(error) || /Task ID already exists/i.test(error instanceof Error ? error.message : String(error))) { - skipReason = "id-conflict-during-insert"; - return; - } + }); + recovered = true; + } catch (error) { + if (isPgTaskIdConflictError(error) || /Task ID already exists/i.test(error instanceof Error ? error.message : String(error))) { + skipReason = "id-conflict-during-insert"; + } else { throw error; } - }); + } } + } catch (error) { const reason = `insert-failed: ${error instanceof Error ? error.message : String(error)}`; result.skipped.push({ id, reason }); @@ -832,108 +602,13 @@ export async function watchImpl(store: TaskStore): Promise { * installing the SQLite watcher/poller. This keeps `fn serve` / boot smoke * booting against embedded PG. */ - if (store.backendMode) { - const tasks = await store.listTasks({ slim: true, startupMemo: false }); - store.taskCache.clear(); - for (const task of tasks) { - store.taskCache.set(task.id, { ...task }); - } - return; - } - - // Populate cache with current state. The watcher only needs metadata to - // detect created/updated/moved/deleted events; full task logs stay on the - // detail path. - const tasks = await store.listTasks({ slim: true, startupMemo: false }); + const tasks = await store.listTasks({ slim: true, startupMemo: false }); store.taskCache.clear(); for (const task of tasks) { store.taskCache.set(task.id, { ...task }); } - - try { - await store.markLegacyAutoMergeStampsOnce(); - } catch (err) { - storeLog.warn("Legacy auto-merge stamp marker failed during watch startup (non-fatal)", { - phase: "watch:legacy-auto-merge-stamp-marker", - error: err instanceof Error ? err.message : String(err), - }); - } - - if (!store.donePauseBackfillDone) { - const repairedTaskIds: string[] = []; - for (const [taskId, cachedTask] of store.taskCache.entries()) { - if (cachedTask.column !== "done") continue; - - const taskDir = store.taskDir(taskId); - let raw: string; - try { - raw = await readFile(join(taskDir, "task.json"), "utf-8"); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - /* - * FNXC:StartupRecovery 2026-06-23-05:02: - * A recovered or corrupt SQLite index can retain done-task rows whose legacy task.json mirror was already removed. Startup watch must not crash while running the one-time done-pause backfill; skip the missing mirror and keep the dashboard available so operators can inspect or repair the project. - */ - storeLog.warn("Skipping done-task pause metadata backfill for missing task.json", { - phase: "watch:done-pause-backfill", - taskId, - taskJsonPath: join(taskDir, "task.json"), - }); - continue; - } - throw error; - } - const diskTask = JSON.parse(raw) as Task; - if (!store.clearDoneTransientFields(diskTask)) continue; - - await store.atomicWriteTaskJson(taskDir, diskTask); - store.taskCache.set(taskId, { ...diskTask }); - repairedTaskIds.push(taskId); - } - store.donePauseBackfillDone = true; - - storeLog.log("done-task pause metadata backfill completed", { - phase: "watch:done-pause-backfill", - repairedCount: repairedTaskIds.length, - repairedTaskIds: repairedTaskIds.slice(0, 20), - }); - } - - // Store current lastModified - store.lastKnownModified = store.db.getLastModified(); - // Initialize lastPollTime so the first checkForChanges() cycle filters by - // "modified since now" instead of doing a full SELECT * + emitting an - // update event for every cached task. Without this, dashboard startup - // re-loaded the entire tasks table 1s after watch() began. - store.lastPollTime = new Date().toISOString(); - - // Use a sentinel watcher object so existing code that checks `store.watcher` still works - try { - store.watcher = watch(store.tasksDir, { recursive: true }, (_event, _filename) => { - // No-op - we use polling now, but keep watcher for API compat - }); - store.watcher.on("error", (err) => { - storeLog.warn("fs.watch emitted an error; polling will continue", { - phase: "watch:fs-watch-error", - error: err instanceof Error ? err.message : String(err), - tasksDir: store.tasksDir, - }); - }); - } catch (err) { - // fs.watch may not be available - that's fine - storeLog.warn("fs.watch unavailable; falling back to polling-only updates", { - phase: "watch:fs-watch-setup", - error: err instanceof Error ? err.message : String(err), - tasksDir: store.tasksDir, - }); - } - - // Poll for changes every second - store.pollInterval = setInterval(() => { - void store.checkForChanges(); - }, 1000); - store.clearStartupSlimListMemo(); - } + return; +} export async function checkForChangesImpl(store: TaskStore): Promise { const startTime = Date.now(); @@ -1534,7 +1209,6 @@ export async function migrateLegacyWorkflowStepsImpl(store: TaskStore): Promise< return result; } - /** * FNXC:StoreModularization 2026-06-25-00:00: * Emit task lifecycle events safely, catching listener errors so one bad diff --git a/packages/core/src/task-store/merge-queue-ops-2.ts b/packages/core/src/task-store/merge-queue-ops-2.ts index 8fd80d73fb..9a439a0791 100644 --- a/packages/core/src/task-store/merge-queue-ops-2.ts +++ b/packages/core/src/task-store/merge-queue-ops-2.ts @@ -7,7 +7,7 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore, storeLog} from "../store.js"; -import {MergeQueueTaskNotFoundError, MergeQueueInvalidColumnError, MergeQueueLeaseOwnershipError} from "./errors.js"; +import {MergeQueueTaskNotFoundError, MergeQueueInvalidColumnError} from "./errors.js"; import type {Task, Column, MergeResult, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueReleaseOutcome, MergeRequestState} from "../types.js"; import "../builtin-traits.js"; import {normalizeTaskPriority} from "../task-priority.js"; @@ -103,62 +103,9 @@ export function enqueueMergeQueueSyncInternalImpl(store: TaskStore, taskId: stri } export async function releaseMergeQueueLeaseImpl(store: TaskStore, taskId: string, workerId: string, outcome: MergeQueueReleaseOutcome): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return releaseMergeQueueLeaseAsync(layer, taskId, workerId, outcome); - } - store.db.transactionImmediate(() => { - const current = store.db.prepare("SELECT leasedBy FROM mergeQueue WHERE taskId = ?").get(taskId) as { leasedBy: string | null } | undefined; - if (!current || current.leasedBy !== workerId) { - throw new MergeQueueLeaseOwnershipError(taskId, workerId, current?.leasedBy ?? null); - } - - if (outcome.kind === "success") { - store.db.prepare("DELETE FROM mergeQueue WHERE taskId = ? AND leasedBy = ?").run(taskId, workerId); - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "mergeQueue:lease-released", - target: taskId, - metadata: { - taskId, - workerId, - outcome: "success", - }, - }); - return; - } - - const released = store.db.prepare(` - UPDATE mergeQueue - SET leasedBy = NULL, - leasedAt = NULL, - leaseExpiresAt = NULL, - attemptCount = attemptCount + 1, - lastError = ? - WHERE taskId = ? AND leasedBy = ? - RETURNING * - `).get(outcome.error, taskId, workerId) as MergeQueueRow | undefined; - if (!released) { - throw new MergeQueueLeaseOwnershipError(taskId, workerId, null); - } - - const entry = store.rowToMergeQueueEntry(released); - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "mergeQueue:lease-released", - target: taskId, - metadata: { - taskId, - workerId, - outcome: "failure", - attemptCount: entry.attemptCount, - error: outcome.error, - }, - }); - }); - } + const layer = store.asyncLayer!; + return releaseMergeQueueLeaseAsync(layer, taskId, workerId, outcome); +} export async function collectMergeDetailsImpl(store: TaskStore, _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 { const mergedAt = new Date().toISOString(); diff --git a/packages/core/src/task-store/merge-queue-ops.ts b/packages/core/src/task-store/merge-queue-ops.ts index 1ae80726e9..48bea43411 100644 --- a/packages/core/src/task-store/merge-queue-ops.ts +++ b/packages/core/src/task-store/merge-queue-ops.ts @@ -7,7 +7,6 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore, storeLog} from "../store.js"; -import {InvalidMergeQueueLeaseDurationError} from "./errors.js"; import {existsSync} from "node:fs"; import type {Task, MergeResult, MergeQueueEntry, MergeQueueAcquireOptions} from "../types.js"; import {assertNotWorkspaceTaskMerge} from "../types.js"; @@ -16,7 +15,6 @@ import {getTaskMergeBlocker, resolveTaskMergeTarget} from "../task-merge.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {assertSafeGitBranchName, assertSafeAbsolutePath} from "../task-store/shell-safety.js"; import {acquireMergeQueueLease as acquireMergeQueueLeaseAsync} from "../task-store/async-merge-coordination.js"; -import type {MergeQueueRow} from "../task-store/row-types.js"; export type StepStartDisposition = "started" | "resumed" | "blocked" | "terminal"; @@ -330,110 +328,9 @@ export async function startStepImpl(store: TaskStore, id: string, stepIndex: num } export async function acquireMergeQueueLeaseImpl(store: TaskStore, workerId: string, opts: MergeQueueAcquireOptions): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return acquireMergeQueueLeaseAsync(layer, workerId, opts); - } - if (opts.leaseDurationMs <= 0) { - throw new InvalidMergeQueueLeaseDurationError(opts.leaseDurationMs); - } - - return store.db.transactionImmediate(() => { - const now = opts.now ?? new Date().toISOString(); - const leaseExpiresAt = new Date(Date.parse(now) + opts.leaseDurationMs).toISOString(); - store.cleanupStaleMergeQueueRows(now); - - let leased: MergeQueueRow | undefined; - if (opts.targetTaskId) { - leased = store.db.prepare(` - UPDATE mergeQueue - SET leasedBy = ?, leasedAt = ?, leaseExpiresAt = ? - WHERE taskId = ? - AND EXISTS ( - SELECT 1 - FROM tasks t - WHERE t.id = mergeQueue.taskId - AND t.column = 'in-review' - ) - AND (leasedBy IS NULL OR leaseExpiresAt <= ?) - RETURNING * - `).get(workerId, now, leaseExpiresAt, opts.targetTaskId, now) as MergeQueueRow | undefined; - - if (!leased) { - const queueHead = store.db.prepare(` - SELECT mq.taskId, mq.leasedBy, t.column - FROM mergeQueue mq - LEFT JOIN tasks t ON t.id = mq.taskId - ORDER BY CASE mq.priority - WHEN 'urgent' THEN 0 - WHEN 'high' THEN 1 - WHEN 'normal' THEN 2 - WHEN 'low' THEN 3 - ELSE 4 - END ASC, - mq.enqueuedAt ASC - LIMIT 1 - `).get() as { taskId: string; leasedBy: string | null; column: string | null } | undefined; - - store.insertRunAuditEventRow({ - taskId: opts.targetTaskId, - domain: "database", - mutationType: "mergeQueue:lease-target-unavailable", - target: opts.targetTaskId, - metadata: { - targetTaskId: opts.targetTaskId, - workerId, - queueHeadTaskId: queueHead?.taskId ?? null, - queueHeadLeasedBy: queueHead?.leasedBy ?? null, - queueHeadColumn: queueHead?.column ?? null, - }, - }); - return null; - } - } else { - leased = store.db.prepare(` - UPDATE mergeQueue - SET leasedBy = ?, leasedAt = ?, leaseExpiresAt = ? - WHERE taskId = ( - SELECT mq.taskId - FROM mergeQueue mq - JOIN tasks t ON t.id = mq.taskId - WHERE t.column = 'in-review' - AND (mq.leasedBy IS NULL OR mq.leaseExpiresAt <= ?) - ORDER BY CASE mq.priority - WHEN 'urgent' THEN 0 - WHEN 'high' THEN 1 - WHEN 'normal' THEN 2 - WHEN 'low' THEN 3 - ELSE 4 - END ASC, - mq.enqueuedAt ASC - LIMIT 1 - ) - RETURNING * - `).get(workerId, now, leaseExpiresAt, now) as MergeQueueRow | undefined; - - if (!leased) { - return null; - } - } - - const entry = store.rowToMergeQueueEntry(leased); - store.insertRunAuditEventRow({ - taskId: entry.taskId, - domain: "database", - mutationType: "mergeQueue:lease-acquired", - target: entry.taskId, - metadata: { - taskId: entry.taskId, - workerId, - leaseExpiresAt: entry.leaseExpiresAt, - priority: entry.priority, - }, - }); - return entry; - }); - } + const layer = store.asyncLayer!; + return acquireMergeQueueLeaseAsync(layer, workerId, opts); +} export async function mergeTaskImpl(store: TaskStore, id: string): Promise { return store.withTaskLock(id, async () => { diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index d796458c2c..3aeb89e7d3 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -26,7 +26,6 @@ import { } from "../workflow-transition-policy.js"; import {type DefaultWorkflowMoveContext, applyDefaultWorkflowMoveEffects} from "../default-workflow-hooks.js"; import {makeTransitionRejection, makeTransitionPending} from "../transition-types.js"; -import {writeTransitionPending, clearTransitionPending} from "../transition-pending.js"; import {writeTransitionPendingAsync, clearTransitionPendingAsync} from "./async-transition-pending.js"; import type {WorkflowIr} from "../workflow-ir-types.js"; import "../builtin-traits.js"; @@ -47,9 +46,7 @@ builtin:coding — which rejected every move out of a custom workflow column getTaskWorkflowSelectionAsync and map it to the same IR the sync path would. */ async function resolveTaskWorkflowIrForMove(store: TaskStore, id: string): Promise { - if (!store.backendMode) { - return store.resolveTaskWorkflowIrSync(id); - } + const selection = await store.getTaskWorkflowSelectionAsync(id); const workflowId = selection?.workflowId; /* FNXC:WorkflowBuiltins 2026-07-19-10:24: every no-selection/unresolvable fallback goes through resolveDefaultWorkflowIr() so this resolver and prepareWorkflowMovePolicyPreflightImpl agree on the default IR (see the helper's note on the "preflight is stale" drift). */ @@ -241,16 +238,12 @@ export async function handoffToReviewImpl(store: TaskStore, taskId: string, opts // Backend mode: read the deleted task via async getTask (includeDeleted // path). SQLite path: sync readTaskFromDb. let deletedTaskColumn: string = "todo"; - if (store.backendMode) { - const layer = store.asyncLayer!; - const pgRow = await readTaskRowAsync(layer, taskId, { includeDeleted: true }); - if (pgRow) { - deletedTaskColumn = (pgRow.column as string) ?? "todo"; - } - } else { - const deletedTask = store.readTaskFromDb(taskId, { includeDeleted: true }); - deletedTaskColumn = deletedTask?.column ?? "todo"; + const layer = store.asyncLayer!; + const pgRow = await readTaskRowAsync(layer, taskId, { includeDeleted: true }); + if (pgRow) { + deletedTaskColumn = (pgRow.column as string) ?? "todo"; } + throw new HandoffInvariantViolationError( taskId, deletedTaskColumn, @@ -334,78 +327,9 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum // Backend-mode same-column handoff: use layer.transactionImmediate with // async in-transaction helpers (enqueueMergeQueueInTransaction, // recordRunAuditEventWithinTransaction) instead of sync SQLite. - if (store.backendMode) { - const layer = store.asyncLayer!; - await layer.transactionImmediate(async (tx) => { - const liveRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); - if (liveRow?.deletedAt) { - throw new HandoffInvariantViolationError( - id, - task.column, - `Cannot hand off ${id} to in-review because the task is deleted`, - ); - } - const existingRows = await tx - .select({ one: sql`1` }) - .from(schema.project.mergeQueue) - .where(eq(schema.project.mergeQueue.taskId, id)) - .limit(1); - const existing = existingRows.length > 0; - await recordRunAuditEventWithinTransaction(tx, { - taskId: id, - agentId: internal.runContext?.agentId ?? "system", - runId: internal.runContext?.runId ?? "unknown", - domain: "database", - mutationType: "task:move", - target: id, - metadata: { - from: task.column, - to: toColumn, - moveSource, - }, - }); - await enqueueMergeQueueInTransaction(tx, id, { priority: task.priority, now: internal.now }, { - agentId: internal.runContext?.agentId, - runId: internal.runContext?.runId, - }); - // FNXC:PostgresCutover 2026-07-15-12:00: - // Same-column retries must share the outer handoff transaction too, - // so workflow work cannot survive a rolled-back queue/audit handoff. - await store.createCompletionHandoffWorkflowWork(task, { - runId: internal.runContext?.runId, - now: internal.now, - source: internal.evidence?.reason, - }, tx); - await recordRunAuditEventWithinTransaction(tx, { - taskId: id, - agentId: internal.runContext?.agentId ?? "system", - runId: internal.runContext?.runId ?? "unknown", - domain: "database", - mutationType: "task:handoff", - target: id, - metadata: { - taskId: id, - fromColumn: task.column, - ownerAgentId: internal.ownerAgentId ?? null, - reason: internal.evidence?.reason, - runId: internal.runContext?.runId, - agentId: internal.runContext?.agentId, - alreadyEnqueued: existing, - }, - }); - /* - FNXC:HandoffFailureInjection 2026-07-15-12:00: - Backend handoffs bypass the legacy enqueueMergeQueueSyncInternal spy. - This test-only no-op seam runs after every VAL-DATA-013 sub-write - (move, queue, workflow work, and handoff audit), so an injected throw - proves this transaction rolls all of them back. - */ - await store.__invokeHandoffMergeQueueFailureInjectorForTesting(id); - }); - return task; - } - store.db.transactionImmediate(() => { - const liveRow = store.readTaskFromDb(id, { includeDeleted: true }); + const layer = store.asyncLayer!; + await layer.transactionImmediate(async (tx) => { + const liveRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); if (liveRow?.deletedAt) { throw new HandoffInvariantViolationError( id, @@ -413,11 +337,16 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum `Cannot hand off ${id} to in-review because the task is deleted`, ); } - const existing = store.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id) as { 1: number } | undefined; - store.insertRunAuditEventRow({ + const existingRows = await tx + .select({ one: sql`1` }) + .from(schema.project.mergeQueue) + .where(eq(schema.project.mergeQueue.taskId, id)) + .limit(1); + const existing = existingRows.length > 0; + await recordRunAuditEventWithinTransaction(tx, { taskId: id, - agentId: internal.runContext?.agentId, - runId: internal.runContext?.runId, + agentId: internal.runContext?.agentId ?? "system", + runId: internal.runContext?.runId ?? "unknown", domain: "database", mutationType: "task:move", target: id, @@ -427,16 +356,22 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum moveSource, }, }); - store.enqueueMergeQueueSyncInternal(id, { priority: task.priority, now: internal.now }); - store.createCompletionHandoffWorkflowWork(task, { + await enqueueMergeQueueInTransaction(tx, id, { priority: task.priority, now: internal.now }, { + agentId: internal.runContext?.agentId, + runId: internal.runContext?.runId, + }); + // FNXC:PostgresCutover 2026-07-15-12:00: + // Same-column retries must share the outer handoff transaction too, + // so workflow work cannot survive a rolled-back queue/audit handoff. + await store.createCompletionHandoffWorkflowWork(task, { runId: internal.runContext?.runId, now: internal.now, source: internal.evidence?.reason, - }); - store.insertRunAuditEventRow({ + }, tx); + await recordRunAuditEventWithinTransaction(tx, { taskId: id, - agentId: internal.runContext?.agentId, - runId: internal.runContext?.runId, + agentId: internal.runContext?.agentId ?? "system", + runId: internal.runContext?.runId ?? "unknown", domain: "database", mutationType: "task:handoff", target: id, @@ -447,12 +382,20 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum reason: internal.evidence?.reason, runId: internal.runContext?.runId, agentId: internal.runContext?.agentId, - alreadyEnqueued: Boolean(existing), + alreadyEnqueued: existing, }, }); + /* + FNXC:HandoffFailureInjection 2026-07-15-12:00: + Backend handoffs bypass the legacy enqueueMergeQueueSyncInternal spy. + This test-only no-op seam runs after every VAL-DATA-013 sub-write + (move, queue, workflow work, and handoff audit), so an injected throw + proves this transaction rolls all of them back. + */ + await store.__invokeHandoffMergeQueueFailureInjectorForTesting(id); }); return task; - } +} if (toColumn === "done" && store.clearDoneTransientFields(task)) { task.updatedAt = new Date().toISOString(); @@ -969,164 +912,24 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum // dequeue/enqueue all run inside the async transaction so they commit or // roll back atomically (VAL-DATA-002/003/013). The transition guards and // side effects above are pure JS and already ran unchanged. - if (store.backendMode) { - const layer = store.asyncLayer!; - const context = store.createTaskPersistSerializationContext(task); - await layer.transactionImmediate(async (tx) => { - // Capacity check (KTD-10). In backend mode, count active tasks in the - // target column via async Drizzle instead of the sync helper. - if (useWorkflow && workflowIr && fromColumn !== toColumn) { - const capacity = resolveColumnCapacity(workflowIr, toColumn, mergedSettingsForMove); - if (capacity.hasCapacity && Number.isFinite(capacity.limit)) { - // Shared pooled-budget enforcement (see enforcePooledColumnCapacity); - // this path supplies the async in-transaction counter. - await enforcePooledColumnCapacity({ - workflowIr, - toColumn, - taskId: id, - capacity, - countOccupants: (budgetColumn, countPending) => - store.countActiveInCapacitySlotAsync({ - tx, - targetColumn: budgetColumn, - workflowId: effectiveWorkflowIdForMove, - countPending, - excludeTaskId: id, - }), - }); - } - } - - // Upsert the task row (update column + all mutated fields). - // FNXC:MultiProjectIsolation 2026-07-10: pass the bound projectId (stamped - // on insert, preserved on update) so partitioning survives moves. - await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); - - // U4 (flag-ON) parity with the SQLite branch below: write the - // crash-safe transitionPending marker in the SAME transaction as the - // column change (KTD-2). countActiveInCapacitySlotAsync already counts - // pending markers in PG, so this is load-bearing for capacity too. - if (useWorkflow) { - await writeTransitionPendingAsync( - tx, - id, - makeTransitionPending(toColumn, ["default-workflow:postCommit"], Date.parse(movedAt) || Date.now()), - ); - } - - // Audit: task:move - await recordRunAuditEventWithinTransaction(tx, { - taskId: id, - agentId: internal.runContext?.agentId ?? "system", - runId: internal.runContext?.runId ?? "unknown", - domain: "database", - mutationType: "task:move", - target: id, - metadata: { - from: fromColumn, - to: toColumn, - moveSource, - }, - }); - - // Dequeue from merge queue on column exit (if leaving in-review). - await dequeueMergeQueueOnColumnExitInTransaction(tx, id, fromColumn, toColumn, movedAt); - - // FNXC:WorkflowReviewGates 2026-07-26-16:40: see isRecognizedInReviewEntry — a - // graph-owned crossing into the review column is a legitimate arrival, not a violation. - if (toColumn === "in-review" && !isRecognizedInReviewEntry(options, internal)) { - await recordRunAuditEventWithinTransaction(tx, { - taskId: id, - agentId: internal.runContext?.agentId ?? "system", - runId: internal.runContext?.runId ?? "unknown", - domain: "database", - mutationType: "task:handoff-invariant-violation", - target: id, - metadata: { - taskId: id, - fromColumn, - callerStack: new Error().stack?.split("\n").slice(0, 8).join("\n"), - }, - }); - } - - if (internal.fromHandoff) { - const existingRows = await tx - .select({ one: sql`1` }) - .from(schema.project.mergeQueue) - .where(eq(schema.project.mergeQueue.taskId, id)) - .limit(1); - alreadyEnqueued = existingRows.length > 0; - await enqueueMergeQueueInTransaction(tx, id, { priority: task.priority, now: internal.now }, { - agentId: internal.runContext?.agentId, - runId: internal.runContext?.runId, - }); - // FNXC:PostgresCutover 2026-06-27-10:25: - // Thread the outer move transaction so cancel + upsert commit - // atomically with the handoff (no orphaned merge-gate items on rollback). - await store.createCompletionHandoffWorkflowWork(task, { - runId: internal.runContext?.runId, - now: internal.now, - source: internal.evidence?.reason, - }, tx); - await recordRunAuditEventWithinTransaction(tx, { - taskId: id, - agentId: internal.runContext?.agentId ?? "system", - runId: internal.runContext?.runId ?? "unknown", - domain: "database", - mutationType: "task:handoff", - target: id, - metadata: { - taskId: id, - fromColumn, - ownerAgentId: internal.ownerAgentId ?? null, - reason: internal.evidence?.reason, - runId: internal.runContext?.runId, - agentId: internal.runContext?.agentId, - alreadyEnqueued, - }, - }); - /* - FNXC:HandoffFailureInjection 2026-07-15-12:00: - Backend handoffs bypass the legacy enqueueMergeQueueSyncInternal spy. - This test-only no-op seam runs after every VAL-DATA-013 sub-write - (move, queue, workflow work, and handoff audit), so an injected throw - proves this transaction rolls all of them back. - */ - await store.__invokeHandoffMergeQueueFailureInjectorForTesting(id); - } - }); - } else { - store.db.transactionImmediate(() => { - deletedAt = store.getSoftDeletedWriteConflict(id, task); - if (deletedAt) { - return; - } - - // ── U6: in-txn capacity enforcement (KTD-10) ────────────────────────── - // WIP limits are trait *config*; enforcement is a substrate capability - // that runs HERE, inside the move transaction, so two holds releasing into - // one slot serialize — exactly one commits, the other rejects and retries - // next sweep. It is NOT a guard: it runs regardless of bypassGuards / - // recoveryRehome / moveSource (engine/recovery/scheduler moves honor it - // too). Only a real column change into a capacity-bearing column is gated; - // same-column no-ops were returned earlier. The count is taken with the - // moving task EXCLUDED and the prospective slot it is about to occupy - // added back implicitly (it must fit alongside existing holders), so a - // full column (occupants == limit) rejects. + const layer = store.asyncLayer!; + const context = store.createTaskPersistSerializationContext(task); + await layer.transactionImmediate(async (tx) => { + // Capacity check (KTD-10). In backend mode, count active tasks in the + // target column via async Drizzle instead of the sync helper. if (useWorkflow && workflowIr && fromColumn !== toColumn) { const capacity = resolveColumnCapacity(workflowIr, toColumn, mergedSettingsForMove); if (capacity.hasCapacity && Number.isFinite(capacity.limit)) { // Shared pooled-budget enforcement (see enforcePooledColumnCapacity); - // the sync counter keeps count → verdict → throw fully synchronous - // inside this sync transaction callback. - enforcePooledColumnCapacity({ + // this path supplies the async in-transaction counter. + await enforcePooledColumnCapacity({ workflowIr, toColumn, taskId: id, capacity, countOccupants: (budgetColumn, countPending) => - store.countActiveInCapacitySlotSync({ + store.countActiveInCapacitySlotAsync({ + tx, targetColumn: budgetColumn, workflowId: effectiveWorkflowIdForMove, countPending, @@ -1136,11 +939,28 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum } } - store.upsertTaskWithFtsRecovery(task); - store.insertRunAuditEventRow({ + // Upsert the task row (update column + all mutated fields). + // FNXC:MultiProjectIsolation 2026-07-10: pass the bound projectId (stamped + // on insert, preserved on update) so partitioning survives moves. + await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); + + // U4 (flag-ON) parity with the SQLite branch below: write the + // crash-safe transitionPending marker in the SAME transaction as the + // column change (KTD-2). countActiveInCapacitySlotAsync already counts + // pending markers in PG, so this is load-bearing for capacity too. + if (useWorkflow) { + await writeTransitionPendingAsync( + tx, + id, + makeTransitionPending(toColumn, ["default-workflow:postCommit"], Date.parse(movedAt) || Date.now()), + ); + } + + // Audit: task:move + await recordRunAuditEventWithinTransaction(tx, { taskId: id, - agentId: internal.runContext?.agentId, - runId: internal.runContext?.runId, + agentId: internal.runContext?.agentId ?? "system", + runId: internal.runContext?.runId ?? "unknown", domain: "database", mutationType: "task:move", target: id, @@ -1150,31 +970,17 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum moveSource, }, }); - store.dequeueMergeQueueOnColumnExit(id, fromColumn, toColumn, movedAt); - // U4 (flag-ON): write the crash-safe transitionPending marker in the SAME - // transaction as the column change (KTD-2). It records the post-commit - // hooks that still owe idempotent execution so a crash mid-transition is - // recoverable from SQLite (the authoritative store, ADR-0001). The store - // clears it immediately after the post-commit hook runner completes - // (below). For the default workflow the field effects already applied - // in-lock; the marker guards the post-commit completion so recovery never - // double-runs (idempotent) and never strands the card. - if (useWorkflow) { - writeTransitionPending( - store.db, - id, - makeTransitionPending(toColumn, ["default-workflow:postCommit"], Date.parse(movedAt) || Date.now()), - ); - } + // Dequeue from merge queue on column exit (if leaving in-review). + await dequeueMergeQueueOnColumnExitInTransaction(tx, id, fromColumn, toColumn, movedAt); - // FNXC:WorkflowReviewGates 2026-07-26-16:40: SQLite-path twin of the backend emit site - // above; both consult the one isRecognizedInReviewEntry authority so they cannot drift. + // FNXC:WorkflowReviewGates 2026-07-26-16:40: see isRecognizedInReviewEntry — a + // graph-owned crossing into the review column is a legitimate arrival, not a violation. if (toColumn === "in-review" && !isRecognizedInReviewEntry(options, internal)) { - store.insertRunAuditEventRow({ + await recordRunAuditEventWithinTransaction(tx, { taskId: id, - agentId: internal.runContext?.agentId, - runId: internal.runContext?.runId, + agentId: internal.runContext?.agentId ?? "system", + runId: internal.runContext?.runId ?? "unknown", domain: "database", mutationType: "task:handoff-invariant-violation", target: id, @@ -1187,17 +993,28 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum } if (internal.fromHandoff) { - alreadyEnqueued = Boolean(store.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id)); - store.enqueueMergeQueueSyncInternal(id, { priority: task.priority, now: internal.now }); - store.createCompletionHandoffWorkflowWork(task, { + const existingRows = await tx + .select({ one: sql`1` }) + .from(schema.project.mergeQueue) + .where(eq(schema.project.mergeQueue.taskId, id)) + .limit(1); + alreadyEnqueued = existingRows.length > 0; + await enqueueMergeQueueInTransaction(tx, id, { priority: task.priority, now: internal.now }, { + agentId: internal.runContext?.agentId, + runId: internal.runContext?.runId, + }); + // FNXC:PostgresCutover 2026-06-27-10:25: + // Thread the outer move transaction so cancel + upsert commit + // atomically with the handoff (no orphaned merge-gate items on rollback). + await store.createCompletionHandoffWorkflowWork(task, { runId: internal.runContext?.runId, now: internal.now, source: internal.evidence?.reason, - }); - store.insertRunAuditEventRow({ + }, tx); + await recordRunAuditEventWithinTransaction(tx, { taskId: id, - agentId: internal.runContext?.agentId, - runId: internal.runContext?.runId, + agentId: internal.runContext?.agentId ?? "system", + runId: internal.runContext?.runId ?? "unknown", domain: "database", mutationType: "task:handoff", target: id, @@ -1211,9 +1028,17 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum alreadyEnqueued, }, }); + /* + FNXC:HandoffFailureInjection 2026-07-15-12:00: + Backend handoffs bypass the legacy enqueueMergeQueueSyncInternal spy. + This test-only no-op seam runs after every VAL-DATA-013 sub-write + (move, queue, workflow work, and handoff audit), so an injected throw + proves this transaction rolls all of them back. + */ + await store.__invokeHandoffMergeQueueFailureInjectorForTesting(id); } }); - } // end of else (non-backend sync path) + // end of else (non-backend sync path) if (deletedAt) { if (internal.fromHandoff) { @@ -1344,9 +1169,7 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum // Backend mode: clearLinkedAgentTaskIds is a sync SQLite operation; skip // it in backend mode (the agent cleanup is best-effort and handled by // the async satellite stores when needed). - if (!store.backendMode) { - store.clearLinkedAgentTaskIds(id, task.updatedAt); - } + } if (store.isWatching) store.taskCache.set(id, { ...task }); @@ -1380,11 +1203,8 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum } } try { - if (store.backendMode) { - await clearTransitionPendingAsync(store.asyncLayer!.db, id); - } else { - clearTransitionPending(store.db, id); - } + await clearTransitionPendingAsync(store.asyncLayer!.db, id); + } catch { // Clearing is best-effort; the marker recovery sweep is the backstop. } diff --git a/packages/core/src/task-store/project-store-ops.ts b/packages/core/src/task-store/project-store-ops.ts index 9b312d8877..5f106c06a9 100644 --- a/packages/core/src/task-store/project-store-ops.ts +++ b/packages/core/src/task-store/project-store-ops.ts @@ -11,12 +11,11 @@ import {TaskStore, storeLog, WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX, WORKFLOW_MOVE_POLICY_TIMEOUT_MS} from "../store.js"; import {TransitionRejectionError} from "./errors.js"; import * as schema from "../postgres/schema/index.js"; -import {randomUUID} from "node:crypto"; import {and, eq, isNull, ne, or, sql} from "drizzle-orm"; import {mkdir, writeFile} from "node:fs/promises"; import {join} from "node:path"; import type {Task, ColumnId, CheckoutClaimPrecondition, ActivityLogEntry, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, GoalCitation, GoalCitationFilter} from "../types.js"; -import {parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure} from "../workflow-ir.js"; +import {parseWorkflowIr, downgradeIrToV1IfPure} from "../workflow-ir.js"; import {makeTransitionRejection} from "../transition-types.js"; import {getWorkflowExtensionRegistry} from "../workflow-extension-registry.js"; import type {WorkflowMovePolicyInput} from "../workflow-extension-types.js"; @@ -24,7 +23,6 @@ import "../builtin-traits.js"; import {normalizeWorkflowIcon, type WorkflowDefinition, type WorkflowDefinitionInput} from "../workflow-definition-types.js"; import {WORKFLOW_PARITY_OBSERVED_MUTATION, WORKFLOW_PARITY_DRIFT_MUTATION, type WorkflowParityDiff, type WorkflowParitySummary} from "../workflow-parity.js"; import {normalizeTaskPriority} from "../task-priority.js"; -import {toJsonNullable} from "../db.js"; import type {AsyncDataLayer, DbTransaction} from "../postgres/data-layer.js"; import {recordRunAuditEventWithinTransaction} from "../postgres/data-layer.js"; import {EvalStore} from "../eval-store.js"; @@ -44,7 +42,7 @@ import {recordActivityLogEntry as recordActivityLogEntryAsync} from "../task-sto import {applyOriginalDescription} from "../original-description-policy.js"; import {recordRunAuditEvent as recordRunAuditEventAsync} from "../postgres/data-layer.js"; import {listGoalCitations as listGoalCitationsAsync} from "../task-store/async-events.js"; -import type {GoalCitationRow, RunAuditEventRow} from "../task-store/row-types.js"; +import type {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) { @@ -100,49 +98,9 @@ export async function getOrCreateForProjectImpl(store: typeof TaskStore, project } export async function listGoalCitationsImpl(store: TaskStore, filter: GoalCitationFilter = {}): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return listGoalCitationsAsync(layer.db, filter); - } - const clauses: string[] = []; - const params: Array = []; - - if (filter.goalId) { - clauses.push("goalId = ?"); - params.push(filter.goalId); - } - if (filter.agentId) { - clauses.push("agentId = ?"); - params.push(filter.agentId); - } - if (filter.taskId) { - clauses.push("taskId = ?"); - params.push(filter.taskId); - } - if (filter.surface) { - clauses.push("surface = ?"); - params.push(filter.surface); - } - if (filter.startTime) { - clauses.push("timestamp >= ?"); - params.push(filter.startTime); - } - if (filter.endTime) { - clauses.push("timestamp <= ?"); - params.push(filter.endTime); - } - - const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; - const limit = Math.max(1, Math.min(filter.limit ?? 200, 1000)); - - const rows = store.db - .prepare( - `SELECT * FROM goal_citations ${where} ORDER BY timestamp DESC, id DESC LIMIT ?`, - ) - .all(...params, limit) as GoalCitationRow[]; - - return rows.map((row) => store.rowToGoalCitation(row)); - } + const layer = store.asyncLayer!; + return listGoalCitationsAsync(layer.db, filter); +} export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: string, task: Task, auditInput?: RunAuditEventInput,): Promise { const id = store.getTaskIdFromDir(dir); @@ -161,93 +119,75 @@ export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: st // existing row (includeDeleted) inside the same transaction; if deletedAt // is set it throws TaskDeletedError (after recording the resurrection- // blocked audit event) instead of upserting. - if (store.backendMode) { - const layer = store.asyncLayer!; - const existingRow = await layer.transactionImmediate(async (tx) => { - const persist = async () => { - const row = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); - if (row && row.deletedAt != null) { - return { deletedAt: row.deletedAt as string }; - } - /* - FNXC:PostgresCutover 2026-07-10: - Changed-columns write (parity with sqlite's patchTaskRowInTransaction): - a full-row upsert from the caller's snapshot silently clobbered any - column another writer committed since the caller's read — the - lost-update class behind triage's `status` clear never sticking. Only - an absent row falls back to the full upsert (create-recovery). - */ - if (row) { - const existing = store.pgRowToTaskRow(row); - const changedColumns = store.getChangedTaskColumns(existing, task); - if (changedColumns.size > 0) { - const context = store.createTaskPersistSerializationContext(task, existing); - const allValues = buildTaskInsertValues(task as unknown as Record, context); - const setValues: Record = { updatedAt: task.updatedAt }; - for (const column of changedColumns) { - if (column === "id") continue; - setValues[column as string] = allValues[column as string]; - } - await tx - .update(schema.project.tasks) - .set(setValues as never) - .where(eq(schema.project.tasks.id, id)); + const layer = store.asyncLayer!; + const existingRow = await layer.transactionImmediate(async (tx) => { + const persist = async () => { + const row = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); + if (row && row.deletedAt != null) { + return { deletedAt: row.deletedAt as string }; + } + /* + FNXC:PostgresCutover 2026-07-10: + Changed-columns write (parity with sqlite's patchTaskRowInTransaction): + a full-row upsert from the caller's snapshot silently clobbered any + column another writer committed since the caller's read — the + lost-update class behind triage's `status` clear never sticking. Only + an absent row falls back to the full upsert (create-recovery). + */ + if (row) { + const existing = store.pgRowToTaskRow(row); + const changedColumns = store.getChangedTaskColumns(existing, task); + if (changedColumns.size > 0) { + const context = store.createTaskPersistSerializationContext(task, existing); + const allValues = buildTaskInsertValues(task as unknown as Record, context); + const setValues: Record = { updatedAt: task.updatedAt }; + for (const column of changedColumns) { + if (column === "id") continue; + setValues[column as string] = allValues[column as string]; } - } else { - // FNXC:MultiProjectIsolation 2026-07-10: preserve the bound projectId partition key. - const context = store.createTaskPersistSerializationContext(task); - await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Project-scope changed-column updates (parity with other multi-project task writes). + */ + const updateConds = [eq(schema.project.tasks.id, id)]; + if (layer.projectId) updateConds.push(eq(schema.project.tasks.projectId, layer.projectId)); + await tx + .update(schema.project.tasks) + .set(setValues as never) + .where(and(...updateConds)); } - if (auditInput) { - await recordRunAuditEventWithinTransaction(tx, auditInput); - } - return undefined; - }; - /* - FNXC:WorkflowSerialization 2026-07-26-15:30: - FN-8592 makes the persisted plan-review passed edge share the exact - per-task advisory transaction lock used by conditional continuation - seeding. This prevents a pass from committing between that repair's - locked predicate reads and its insert. - */ - if (task.workflowStepResults?.some((result) => result.workflowStepId === "plan-review" && result.status === "passed")) { - return withTaskWorkflowSerialization(tx, layer.projectId, id, persist); - } - return persist(); - }); - if (existingRow?.deletedAt) { - store.throwSoftDeletedWriteBlocked(id, existingRow.deletedAt, auditInput?.mutationType ?? "atomicWriteTaskJsonWithAudit", { - agentId: auditInput?.agentId, - runId: auditInput?.runId, - timestamp: auditInput?.timestamp, - }); + } else { + // FNXC:MultiProjectIsolation 2026-07-10: preserve the bound projectId partition key. + const context = store.createTaskPersistSerializationContext(task); + await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); } - await store.writeTaskJsonFile(dir, task); - return; - } - let result: { deletedAt?: string; current?: Task } | undefined; - store.db.transactionImmediate(() => { - const existingRow = store.readTaskRowFromDb(id, { includeDeleted: true }); - const changedColumns = existingRow && existingRow.deletedAt == null - ? store.getChangedTaskColumns(existingRow, task) - : new Set(); - result = store.patchTaskRowInTransaction(id, task, changedColumns, existingRow); - if (result?.deletedAt) return; - if (auditInput) { - store.insertRunAuditEventRow(auditInput); + await recordRunAuditEventWithinTransaction(tx, auditInput); } + return undefined; + }; + /* + FNXC:WorkflowSerialization 2026-07-26-15:30: + FN-8592 makes the persisted plan-review passed edge share the exact + per-task advisory transaction lock used by conditional continuation + seeding. This prevents a pass from committing between that repair's + locked predicate reads and its insert. + */ + if (task.workflowStepResults?.some((result) => result.workflowStepId === "plan-review" && result.status === "passed")) { + return withTaskWorkflowSerialization(tx, layer.projectId, id, persist); + } + return persist(); }); - if (result?.deletedAt) { - store.throwSoftDeletedWriteBlocked(id, result.deletedAt, auditInput?.mutationType ?? "atomicWriteTaskJsonWithAudit", { + if (existingRow?.deletedAt) { + store.throwSoftDeletedWriteBlocked(id, existingRow.deletedAt, auditInput?.mutationType ?? "atomicWriteTaskJsonWithAudit", { agentId: auditInput?.agentId, runId: auditInput?.runId, timestamp: auditInput?.timestamp, }); } - - await store.writeTaskJsonFile(dir, result?.current ?? task); - } + await store.writeTaskJsonFile(dir, task); + return; +} export async function duplicateTaskImpl(store: TaskStore, id: string): Promise { const sourceTask = await store.getTask(id); @@ -314,24 +254,16 @@ export async function listStrandedRefinementsImpl(store: TaskStore, options?: { ? requestedThresholdMs as number : defaultFreshnessThresholdMs; - let rows: TaskRow[]; - if (store.backendMode) { - const layer = store.asyncLayer!; - const pgRows = await layer.db.select() - .from(schema.project.tasks) - .where(and( - isNull(schema.project.tasks.deletedAt), - eq(schema.project.tasks.sourceType, 'task_refine'), - eq(schema.project.tasks.column, 'triage'), - )) - .orderBy(schema.project.tasks.createdAt); - rows = pgRows.map((r) => store.pgRowToTaskRow(r as Record)) as unknown as TaskRow[]; - } else { - const selectClause = store.getTaskSelectClause(false); - rows = store.db.prepare( - `SELECT ${selectClause} FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND "sourceType" = 'task_refine' AND "column" = 'triage' ORDER BY createdAt ASC`, - ).all() as unknown as TaskRow[]; - } + const layer = store.asyncLayer!; + const pgRows = await layer.db.select() + .from(schema.project.tasks) + .where(and( + isNull(schema.project.tasks.deletedAt), + eq(schema.project.tasks.sourceType, 'task_refine'), + eq(schema.project.tasks.column, 'triage'), + )) + .orderBy(schema.project.tasks.createdAt); + const rows = pgRows.map((r) => store.pgRowToTaskRow(r as Record)) as unknown as TaskRow[]; const now = Date.now(); const stranded: Array<{ @@ -391,74 +323,35 @@ export async function tryClaimCheckoutImpl(store: TaskStore, taskId: string, cla // FNXC:AgentRoutingBackend 2026-07-12-00:00: PG backend branch for // tryClaimCheckout — the SQLite path below is unreachable in backend mode. - if (store.backendMode) { - const layer = store.asyncLayer!; - const now = new Date().toISOString(); - const projectScope = layer.projectId ? sql`AND project_id = ${layer.projectId}` : sql``; - const rows = await layer.db.execute(sql` - UPDATE project.tasks SET - checked_out_by = ${claim.agentId}, - checked_out_at = COALESCE(checked_out_at, ${now}), - checkout_node_id = ${claim.nodeId}, - checkout_run_id = ${claim.runId}, - checkout_lease_renewed_at = ${claim.renewedAt}, - checkout_lease_epoch = ${claim.leaseEpoch} - WHERE id = ${taskId} - ${projectScope} - AND deleted_at IS NULL - AND COALESCE(checked_out_by, '') = COALESCE(${precondition.expectedCheckedOutBy ?? ''}, '') - AND COALESCE(checkout_node_id, '') = COALESCE(${precondition.expectedNodeId ?? ''}, '') - AND COALESCE(checkout_lease_epoch, 0) = COALESCE(${precondition.expectedLeaseEpoch ?? 0}, 0) - RETURNING id - `); - const changes = (rows as unknown[]).length; - const post = await store.getTask(taskId); - if (changes === 0) { - return { ok: false, reason: "precondition_failed", current: post }; - } - if (!post) { - return { ok: false, reason: "row_not_found", current: null }; - } - return { ok: true, task: post }; - } - const updateResult = store.db.prepare(` - UPDATE tasks - SET - checkedOutBy = ?, - checkedOutAt = COALESCE(checkedOutAt, ?), - checkoutNodeId = ?, - checkoutRunId = ?, - checkoutLeaseRenewedAt = ?, - checkoutLeaseEpoch = ? - WHERE id = ? - AND "deletedAt" IS NULL - AND COALESCE(checkedOutBy, '') = COALESCE(?, '') - AND COALESCE(checkoutNodeId, '') = COALESCE(?, '') - AND COALESCE(checkoutLeaseEpoch, 0) = COALESCE(?, 0) - `).run( - claim.agentId, - new Date().toISOString(), - claim.nodeId, - claim.runId, - claim.renewedAt, - claim.leaseEpoch, - taskId, - precondition.expectedCheckedOutBy ?? null, - precondition.expectedNodeId ?? null, - precondition.expectedLeaseEpoch ?? 0, - ) as { changes: number }; - + const layer = store.asyncLayer!; + const now = new Date().toISOString(); + const projectScope = layer.projectId ? sql`AND project_id = ${layer.projectId}` : sql``; + const rows = await layer.db.execute(sql` + UPDATE project.tasks SET + checked_out_by = ${claim.agentId}, + checked_out_at = COALESCE(checked_out_at, ${now}), + checkout_node_id = ${claim.nodeId}, + checkout_run_id = ${claim.runId}, + checkout_lease_renewed_at = ${claim.renewedAt}, + checkout_lease_epoch = ${claim.leaseEpoch} + WHERE id = ${taskId} + ${projectScope} + AND deleted_at IS NULL + AND COALESCE(checked_out_by, '') = COALESCE(${precondition.expectedCheckedOutBy ?? ''}, '') + AND COALESCE(checkout_node_id, '') = COALESCE(${precondition.expectedNodeId ?? ''}, '') + AND COALESCE(checkout_lease_epoch, 0) = COALESCE(${precondition.expectedLeaseEpoch ?? 0}, 0) + RETURNING id + `); + const changes = (rows as unknown[]).length; const post = await store.getTask(taskId); - if (updateResult.changes === 0) { + if (changes === 0) { return { ok: false, reason: "precondition_failed", current: post }; } - if (!post) { return { ok: false, reason: "row_not_found", current: null }; } - return { ok: true, task: post }; - } +} export async function evaluateWorkflowMovePoliciesImpl(store: TaskStore, input: WorkflowMovePolicyInput): Promise { const policies = getWorkflowExtensionRegistry().list("move-policy"); @@ -525,31 +418,9 @@ export async function recordRunAuditEventImpl(store: TaskStore, input: RunAuditE // Backend-mode: delegate to the async data-layer helper. The data-layer // RunAuditEvent has taskId: string | null (DB shape); the store's public // RunAuditEvent type has taskId: string | undefined. Map null → undefined. - if (store.backendMode) { - const layer = store.asyncLayer!; - const raw = await recordRunAuditEventAsync(layer, { - timestamp: input.timestamp, - taskId: input.taskId, - agentId: input.agentId, - runId: input.runId, - domain: input.domain, - mutationType: input.mutationType, - target: input.target, - metadata: input.metadata, - }); - return { - ...raw, - taskId: raw.taskId ?? undefined, - domain: raw.domain as RunAuditEvent["domain"], - metadata: raw.metadata ?? undefined, - }; - } - const id = randomUUID(); - const timestamp = input.timestamp ?? new Date().toISOString(); - - const event: RunAuditEvent = { - id, - timestamp, + const layer = store.asyncLayer!; + const raw = await recordRunAuditEventAsync(layer, { + timestamp: input.timestamp, taskId: input.taskId, agentId: input.agentId, runId: input.runId, @@ -557,28 +428,14 @@ export async function recordRunAuditEventImpl(store: TaskStore, input: RunAuditE mutationType: input.mutationType, target: input.target, metadata: input.metadata, - }; - - store.db.transactionImmediate(() => { - store.db.prepare(` - INSERT INTO runAuditEvents ( - id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - event.id, - event.timestamp, - event.taskId ?? null, - event.agentId, - event.runId, - event.domain, - event.mutationType, - event.target, - toJsonNullable(event.metadata), - ); }); - - return event; - } + return { + ...raw, + taskId: raw.taskId ?? undefined, + domain: raw.domain as RunAuditEvent["domain"], + metadata: raw.metadata ?? undefined, + }; +} export function getRunAuditEventsImpl(store: TaskStore, options: RunAuditEventFilter = {}): RunAuditEvent[] { /* @@ -794,62 +651,31 @@ export async function updateIssueInfoImpl(store: TaskStore, id: string, issueInf export async function listWorkflowStepsImpl(store: TaskStore): Promise { if (store.workflowStepsCache) return store.workflowStepsCache; - if (store.backendMode) { - /* - FNXC:PostgresOnlyDataAccess 2026-07-16-12:30: - Backend mode reads stored steps from project.workflow_steps via the async - layer, replacing the SqliteFinalRemoval-era interim fail-soft that - returned plugin-contributed steps only (stored steps were dropped until - the async helper existed). Listing parity with the sync branch below: - compiled-step rows stay filtered out, plugin steps are appended. - */ - const table = schema.project.workflowSteps; - const pgRows = await store.asyncLayer!.db - .select() - .from(table) - .orderBy(table.createdAt); - const storedPgSteps = pgRows - .map((row) => store.applyLegacyWorkflowStepOverrides(store.toStoredWorkflowStep({ - ...row, - migrated_fragment_id: row.migratedFragmentId, - } as unknown as Parameters[0]))) - .filter((step) => !step.templateId?.startsWith(WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX)); - const pluginSteps = store._pluginWorkflowStepTemplates - .map(({ template }) => store.resolvePluginWorkflowStep(template.id)) - .filter((step): step is import("../types.js").WorkflowStep => Boolean(step)); - store.workflowStepsCache = [...storedPgSteps, ...pluginSteps]; - return store.workflowStepsCache; - } - const rows = store.db.prepare("SELECT * FROM workflow_steps ORDER BY createdAt ASC").all() as Array<{ - id: string; - templateId: string | null; - name: string; - description: string; - mode: string; - phase: string | null; - prompt: string; - gateMode: string | null; - toolMode: string | null; - scriptName: string | null; - enabled: number; - defaultOn: number | null; - modelProvider: string | null; - modelId: string | null; - createdAt: string; - updatedAt: string; - }>; - const storedSteps = rows - .map((row) => store.applyLegacyWorkflowStepOverrides(store.toStoredWorkflowStep(row))) - // Steps materialized by compiling a workflow are an execution detail; keep - // them out of the user-facing step manager listing. The executor resolves - // them directly via getWorkflowStep, which is unaffected by this filter. + /* + FNXC:PostgresOnlyDataAccess 2026-07-16-12:30: + Backend mode reads stored steps from project.workflow_steps via the async + layer, replacing the SqliteFinalRemoval-era interim fail-soft that + returned plugin-contributed steps only (stored steps were dropped until + the async helper existed). Listing parity with the sync branch below: + compiled-step rows stay filtered out, plugin steps are appended. + */ + const table = schema.project.workflowSteps; + const pgRows = await store.asyncLayer!.db + .select() + .from(table) + .orderBy(table.createdAt); + const storedPgSteps = pgRows + .map((row) => store.applyLegacyWorkflowStepOverrides(store.toStoredWorkflowStep({ + ...row, + migrated_fragment_id: row.migratedFragmentId, + } as unknown as Parameters[0]))) .filter((step) => !step.templateId?.startsWith(WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX)); const pluginSteps = store._pluginWorkflowStepTemplates .map(({ template }) => store.resolvePluginWorkflowStep(template.id)) .filter((step): step is import("../types.js").WorkflowStep => Boolean(step)); - store.workflowStepsCache = [...storedSteps, ...pluginSteps]; + store.workflowStepsCache = [...storedPgSteps, ...pluginSteps]; return store.workflowStepsCache; - } +} export async function getWorkflowStepImpl(store: TaskStore, id: string): Promise { if (id.startsWith("plugin:")) { @@ -867,83 +693,28 @@ export async function getWorkflowStepImpl(store: TaskStore, id: string): Promise templateId (earliest created), then built-in template — same resolution order as the sync branch. */ - if (store.backendMode) { - const table = schema.project.workflowSteps; - const mapRow = (row: typeof table.$inferSelect) => - store.applyLegacyWorkflowStepOverrides(store.toStoredWorkflowStep({ - ...row, - migrated_fragment_id: row.migratedFragmentId, - } as unknown as Parameters[0])); - const byIdRows = await store.asyncLayer!.db - .select() - .from(table) - .where(eq(table.id, id)) - .limit(1); - if (byIdRows[0]) return mapRow(byIdRows[0]); - const byTemplateRows = await store.asyncLayer!.db - .select() - .from(table) - .where(eq(table.templateId, id)) - .orderBy(table.createdAt) - .limit(1); - if (byTemplateRows[0]) return mapRow(byTemplateRows[0]); - const pgTemplate = store.getBuiltInWorkflowTemplate(id); - return pgTemplate ? store.toBuiltInWorkflowStep(pgTemplate) : undefined; - } - - const byId = store.db.prepare("SELECT * FROM workflow_steps WHERE id = ?").get(id) as - | { - id: string; - templateId: string | null; - name: string; - description: string; - mode: string; - phase: string | null; - gateMode: string | null; - prompt: string; - toolMode: string | null; - scriptName: string | null; - enabled: number; - defaultOn: number | null; - modelProvider: string | null; - modelId: string | null; - createdAt: string; - updatedAt: string; - } - | undefined; - if (byId) { - return store.applyLegacyWorkflowStepOverrides(store.toStoredWorkflowStep(byId)); - } - - const byTemplate = store.db - .prepare("SELECT * FROM workflow_steps WHERE templateId = ? ORDER BY createdAt ASC LIMIT 1") - .get(id) as - | { - id: string; - templateId: string | null; - name: string; - description: string; - mode: string; - phase: string | null; - gateMode: string | null; - prompt: string; - toolMode: string | null; - scriptName: string | null; - enabled: number; - defaultOn: number | null; - modelProvider: string | null; - modelId: string | null; - createdAt: string; - updatedAt: string; - } - | undefined; - if (byTemplate) { - return store.applyLegacyWorkflowStepOverrides(store.toStoredWorkflowStep(byTemplate)); - } - - const template = store.getBuiltInWorkflowTemplate(id); - return template ? store.toBuiltInWorkflowStep(template) : undefined; - } + const table = schema.project.workflowSteps; + const mapRow = (row: typeof table.$inferSelect) => + store.applyLegacyWorkflowStepOverrides(store.toStoredWorkflowStep({ + ...row, + migrated_fragment_id: row.migratedFragmentId, + } as unknown as Parameters[0])); + const byIdRows = await store.asyncLayer!.db + .select() + .from(table) + .where(eq(table.id, id)) + .limit(1); + if (byIdRows[0]) return mapRow(byIdRows[0]); + const byTemplateRows = await store.asyncLayer!.db + .select() + .from(table) + .where(eq(table.templateId, id)) + .orderBy(table.createdAt) + .limit(1); + if (byTemplateRows[0]) return mapRow(byTemplateRows[0]); + const pgTemplate = store.getBuiltInWorkflowTemplate(id); + return pgTemplate ? store.toBuiltInWorkflowStep(pgTemplate) : undefined; +} /** Test-only seam for proving the narrow retry handles a post-allocation race. */ export let workflowDefinitionBeforeInsertForTesting: ((id: string, backendMode: boolean) => void | Promise) | undefined; @@ -977,9 +748,7 @@ export async function createWorkflowDefinitionImpl(store: TaskStore, input: Work constraints from plugin and API callers. */ for (let attempt = 0; attempt < 8; attempt += 1) { - const id = store.backendMode - ? await nextWorkflowDefinitionIdAsyncImpl(store) - : store.nextWorkflowDefinitionId(); + const id = await nextWorkflowDefinitionIdAsyncImpl(store); const definition: WorkflowDefinition = { id, name, @@ -994,44 +763,25 @@ export async function createWorkflowDefinitionImpl(store: TaskStore, input: Work try { await workflowDefinitionBeforeInsertForTesting?.(id, store.backendMode); - if (store.backendMode) { - await store.asyncLayer!.db.insert(schema.project.workflows).values({ - id: definition.id, - name: definition.name, - description: definition.description, - icon: definition.icon ?? null, - ir: (flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir)) as unknown as object, - layout: definition.layout as unknown as object, - kind: definition.kind, - createdAt: definition.createdAt, - updatedAt: definition.updatedAt, - }); - } else { - store.db - .prepare( - `INSERT INTO workflows (id, name, description, icon, ir, layout, kind, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - definition.id, - definition.name, - definition.description, - definition.icon ?? null, - serializeWorkflowIr(flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir)), - JSON.stringify(definition.layout), - definition.kind, - definition.createdAt, - definition.updatedAt, - ); - } + await store.asyncLayer!.db.insert(schema.project.workflows).values({ + id: definition.id, + name: definition.name, + description: definition.description, + icon: definition.icon ?? null, + ir: (flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir)) as unknown as object, + layout: definition.layout as unknown as object, + kind: definition.kind, + createdAt: definition.createdAt, + updatedAt: definition.updatedAt, + }); + } catch (error) { if (!isWorkflowDefinitionIdPrimaryKeyCollision(error)) throw error; continue; } store.workflowDefinitionsCache = null; - if (!store.backendMode) store.db.bumpLastModified(); - return definition; + return definition; } throw new Error("Unable to allocate a free workflow definition id after repeated id collisions"); }); @@ -1193,45 +943,9 @@ ${notificationsSection}`; export async function recordActivityImpl(store: TaskStore, entry: Omit): Promise { // FNXC:RuntimeWorkflowAsync 2026-06-24-16:01: // Backend-mode: delegate to the async audit helper (async-audit.ts). - if (store.backendMode) { - const layer = store.asyncLayer!; - return recordActivityLogEntryAsync(layer.db, layer.projectId ?? "", entry); - } - const fullEntry: ActivityLogEntry = { - ...entry, - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - timestamp: new Date().toISOString(), - }; - - try { - store.db.prepare( - `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run( - fullEntry.id, - fullEntry.timestamp, - fullEntry.type, - fullEntry.taskId ?? null, - fullEntry.taskTitle ?? null, - fullEntry.details, - fullEntry.metadata ? JSON.stringify(fullEntry.metadata) : null, - ); - store.db.bumpLastModified(); - } catch (err) { - // Best-effort: log errors but don't break operations - storeLog.error("Failed to record activity", { - id: fullEntry.id, - type: fullEntry.type, - taskId: fullEntry.taskId, - taskTitle: fullEntry.taskTitle, - detailsLength: fullEntry.details.length, - hasMetadata: fullEntry.metadata !== undefined, - error: err instanceof Error ? err.message : String(err), - }); - } - - return fullEntry; - } + const layer = store.asyncLayer!; + return recordActivityLogEntryAsync(layer.db, layer.projectId ?? "", entry); +} export function getEvalStoreImpl(store: TaskStore): EvalStore | AsyncEvalStore { if (!store.evalStore) { @@ -1239,15 +953,12 @@ export function getEvalStoreImpl(store: TaskStore): EvalStore | AsyncEvalStore { // PG backend mode returns the AsyncDataLayer-backed AsyncEvalStore. The // sync EvalStore(store.db) dereferences the absent SQLite handle, which // 500'd the dashboard /api/evals routes. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("EvalStore is not available: AsyncDataLayer not initialized in backend mode"); - } - store.evalStore = new AsyncEvalStore(layer); - } else { - store.evalStore = new EvalStore(store.db); + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("EvalStore is not available: AsyncDataLayer not initialized in backend mode"); } + store.evalStore = new AsyncEvalStore(layer); + } return store.evalStore; } diff --git a/packages/core/src/task-store/reads.ts b/packages/core/src/task-store/reads.ts index 5220e83eb6..1ab64b7ec5 100644 --- a/packages/core/src/task-store/reads.ts +++ b/packages/core/src/task-store/reads.ts @@ -96,7 +96,6 @@ function hasFreshAgentLogActivitySinceTaskUpdate( return Math.max(0, now - latestAgentLogMs) < DEFAULT_STALE_MERGING_MIN_AGE_MS; } -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"; @@ -112,103 +111,25 @@ export async function getTaskImpl(store: TaskStore, id: string, options?: { acti // FNXC:RuntimePersistenceAsync 2026-06-24-10:50: // Backend-mode getTask: read the task row via async helper, convert to // Task via pgRowToTaskRow + rowToTask, and hydrate derived fields. - if (store.backendMode) { - const layer = store.asyncLayer!; - const pgRow = await readTaskRow(layer, id, { - includeDeleted: options?.includeDeleted, - }); - if (!pgRow) { - /* - 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) { - /* - FNXC:TaskLookup404 2026-07-26-11:20: - Backend/Postgres miss. Throw the typed TaskNotFoundError (message kept - byte-identical to the legacy `Task ${id} not found` string) so route - catches can map it to 404. Nothing on this path sets an errno `code`, - so the routes' legacy ENOENT check never fired and every unknown task - id 500'd. - */ - throw new TaskNotFoundError(id); - } - 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(); - const settings = await store.getSettingsFast(); - const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); + const layer = store.asyncLayer!; + const pgRow = await readTaskRow(layer, id, { + includeDeleted: options?.includeDeleted, + }); + if (!pgRow) { /* - FNXC:WorkflowLifecycle 2026-07-05-15:40: - In-review merge/review agents stream progress to agent-log JSONL without - necessarily mutating the task row. Treat fresh agent-log writes as active - ownership for stall-badge hydration so the board does not show - Stalled/Merge stalled while a merger is visibly making progress. Restores - main's FNXC:WorkflowLifecycle 2026-07-01-23:27 behavior, which the - PostgreSQL cutover's store split predated. + 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 hasFreshAgentLogActivity = hasFreshAgentLogActivitySinceTaskUpdate(store, task, now); - const executingTaskIds = hasFreshAgentLogActivity ? new Set([task.id]) : undefined; - task.inReviewStall = mergeQueuedTaskIds.has(task.id) - ? undefined - : getInReviewStallReason(task, { - now, - executingTaskIds, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.inReviewStalled = mergeQueuedTaskIds.has(task.id) - ? undefined - : getInReviewStalledSignal(task, { - now, - executingTaskIds, - thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.stalledReview = mergeQueuedTaskIds.has(task.id) || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); - task.retrySummary = computeRetrySummary(task); - /* - FNXC:TaskDetailPromptResilience 2026-07-10-15:00 (merge port from main): - PROMPT.md is enrichment for the task detail — NOT essential row data. - getTask is the shared load for the entire per-task API, so an unguarded - read/parse throw here turned every per-task operation into a 500 while - the PROMPT.md-free board list kept working. A read can fail for reasons - unrelated to the row (EACCES from a root-owned file, EISDIR, symlink - loop, transient FS error). Degrade to empty prompt / unsynced steps. - */ - if (task.steps.length === 0) { - try { - task.steps = await store.parseStepsFromPrompt(id); - } catch (err) { - storeLog.warn(`[task-detail] failed to sync steps from PROMPT.md for ${id}: ${err instanceof Error ? err.message : String(err)}`); - } - } - let prompt = ""; - try { - const promptPath = join(store.taskDir(id), "PROMPT.md"); - if (existsSync(promptPath)) { - prompt = await readFile(promptPath, "utf-8"); - } - } catch (err) { - storeLog.warn(`[task-detail] failed to read PROMPT.md for ${id}: ${err instanceof Error ? err.message : String(err)}`); - } - return { ...task, prompt }; - } - const task = store.readTaskFromDb(id, options); - if (!task) { - const archived = store.archiveDb.get(id); + const archived = await getArchivedTask(layer.db, id, layer.projectId); if (!archived) { - // FNXC:TaskLookup404 2026-07-26-11:20: legacy/sync branch throws the same - // typed miss as the backend branch so 404 mapping is branch-independent. + /* + FNXC:TaskLookup404 2026-07-26-11:20: + Backend/Postgres miss. Throw the typed TaskNotFoundError (message kept + byte-identical to the legacy `Task ${id} not found` string) so route + catches can map it to 404. Nothing on this path sets an errno `code`, + so the routes' legacy ENOENT check never fired and every unknown task + id 500'd. + */ throw new TaskNotFoundError(id); } const archivedTask = store.archiveEntryToTask(archived, false); @@ -217,10 +138,10 @@ export async function getTaskImpl(store: TaskStore, id: string, options?: { acti prompt: archived.prompt ?? store.generatePromptFromArchiveEntry(archived), }; } - + const task = store.rowToTask(store.pgRowToTaskRow(pgRow)); const now = Date.now(); const settings = await store.getSettingsFast(); - const mergeQueuedTaskIds = store.getMergeQueuedTaskIds(); + const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); /* FNXC:WorkflowLifecycle 2026-07-05-15:40: In-review merge/review agents stream progress to agent-log JSONL without @@ -252,13 +173,16 @@ export async function getTaskImpl(store: TaskStore, id: string, options?: { acti engineActivationGraceMs: settings.engineActivationGraceMs, }); task.stalledReview = mergeQueuedTaskIds.has(task.id) || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); - // Derived at read time only; retrySummary is never persisted to SQLite. task.retrySummary = computeRetrySummary(task); - - // Sync steps from PROMPT.md if task.steps is empty. - // FNXC:TaskDetailPromptResilience 2026-07-10-15:00 (merge port from main): - // best-effort — see the backend branch above; an unreadable PROMPT.md must - // not 500 every per-task operation. + /* + FNXC:TaskDetailPromptResilience 2026-07-10-15:00 (merge port from main): + PROMPT.md is enrichment for the task detail — NOT essential row data. + getTask is the shared load for the entire per-task API, so an unguarded + read/parse throw here turned every per-task operation into a 500 while + the PROMPT.md-free board list kept working. A read can fail for reasons + unrelated to the row (EACCES from a root-owned file, EISDIR, symlink + loop, transient FS error). Degrade to empty prompt / unsynced steps. + */ if (task.steps.length === 0) { try { task.steps = await store.parseStepsFromPrompt(id); @@ -266,7 +190,6 @@ export async function getTaskImpl(store: TaskStore, id: string, options?: { acti storeLog.warn(`[task-detail] failed to sync steps from PROMPT.md for ${id}: ${err instanceof Error ? err.message : String(err)}`); } } - let prompt = ""; try { const promptPath = join(store.taskDir(id), "PROMPT.md"); @@ -276,9 +199,8 @@ export async function getTaskImpl(store: TaskStore, id: string, options?: { acti } catch (err) { storeLog.warn(`[task-detail] failed to read PROMPT.md for ${id}: ${err instanceof Error ? err.message : String(err)}`); } - return { ...task, prompt }; - }); +}); } export async function listTasksImpl(store: TaskStore, options?: { limit?: number; offset?: number; /** When false, exclude tasks in the `archived` column. Default: true (backward compatible). */ includeArchived?: boolean; /** When true, omit heavy fields (log, comments, steps, workflowStepResults, steeringComments) * from each row to make list responses cheap for board-style consumers. Detail fields default * to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */ slim?: boolean; /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). * Widened to {@link ColumnId} (#1403) so custom-column filters are accepted. */ column?: ColumnId; /** Opt-in startup-only memo for repeated slim reads during boot choreography. */ startupMemo?: boolean; /** Forensic read: surface soft-deleted tasks (deletedAt IS NOT NULL). * VAL-DATA-006 — only admin/forensic surfaces should set this; live readers * must leave it unset so tombstoned tasks stay off the board (VAL-DATA-005). */ includeDeleted?: boolean; }): Promise { @@ -313,198 +235,65 @@ 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, and hydrate derived fields. - if (store.backendMode) { - const layer = store.asyncLayer!; - /* - FNXC:TaskStoreReads 2026-07-05-15:30: - The `log` column must be fetched even in slim mode: the server derives - `stalledReview` (reenqueue-churn / invalid-transition heuristics) and - `timedExecutionMs` from log entries BEFORE stripping the log from the - wire response, exactly like the SQLite path's slim projection (which - also selected `log` for this reason). The earlier `excludeLog: slim` - optimization silently disabled both signals on board listings. - Pass `includeDeleted` through for forensic reads (VAL-DATA-006). + const layer = store.asyncLayer!; + /* + FNXC:TaskStoreReads 2026-07-05-15:30: + The `log` column must be fetched even in slim mode: the server derives + `stalledReview` (reenqueue-churn / invalid-transition heuristics) and + `timedExecutionMs` from log entries BEFORE stripping the log from the + wire response, exactly like the SQLite path's slim projection (which + also selected `log` for this reason). The earlier `excludeLog: slim` + optimization silently disabled both signals on board listings. + Pass `includeDeleted` through for forensic reads (VAL-DATA-006). - FNXC:TaskStoreReadsPerf 2026-07-11 (PR #1793 review): - The column filter and pagination are pushed into SQL (readLiveTaskRows - WHERE + ORDER BY + LIMIT/OFFSET) instead of fetching the whole table and - filtering/slicing here — out-of-page rows no longer pay wire transfer or - per-task hydration (stall signals, PROMPT.md step sync). The SQL order - (created_at, numeric id suffix) matches the JS comparator below, so the - page content is identical to the old client-side slice. - */ - const paginationOffset = Math.max(0, options?.offset ?? 0); - const paginationLimit = options?.limit !== undefined ? Math.max(0, options.limit) : undefined; - /* - 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, - ...(boundedMergedPrefix !== undefined - ? { limit: boundedMergedPrefix, offset: 0 } - : sqlPaginated - ? { limit: paginationLimit, offset: paginationOffset } - : {}), - }); - const now = Date.now(); - const settings = await store.getSettingsFast(); - const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); - /* - * FNXC:SqliteFinalRemoval 2026-06-26-10:30: - * Compute staleness thresholds once for the whole list pass, mirroring - * the SQLite path. The ageStaleness/stalePausedReview/stalePausedTodo - * signals are derived at read time and must be hydrated in backend mode - * too (VAL-CROSS-001 board parity). - */ - const staleThresholds: TaskAgeStalenessThresholds = { - inProgressWarningMs: settings.staleInProgressWarningMs, - inProgressCriticalMs: settings.staleInProgressCriticalMs, - inReviewWarningMs: settings.staleInReviewWarningMs, - inReviewCriticalMs: settings.staleInReviewCriticalMs, - }; - const tasks = await Promise.all(filteredRows.map(async (pgRow) => { - const row = store.pgRowToTaskRow(pgRow); - const task = store.rowToTask(row); - const isMergeQueued = mergeQueuedTaskIds.has(task.id); - /* - FNXC:WorkflowLifecycle 2026-07-05-15:40: - In-review merge/review agents stream progress to agent-log JSONL without - necessarily mutating the task row. Treat fresh agent-log writes as active - ownership for stall-badge hydration so the board does not show - Stalled/Merge stalled while a merger is visibly making progress. Restores - main's FNXC:WorkflowLifecycle 2026-07-01-23:27 behavior, which the - PostgreSQL cutover's store split predated. - */ - const hasFreshAgentLogActivity = hasFreshAgentLogActivitySinceTaskUpdate(store, task, now); - const executingTaskIds = hasFreshAgentLogActivity ? new Set([task.id]) : undefined; - task.inReviewStall = isMergeQueued ? undefined : getInReviewStallReason(task, { - now, - executingTaskIds, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.stalePausedReview = getStalePausedReviewSignal(task, { - now, - thresholdMs: settings.stalePausedReviewThresholdMs, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.inReviewStalled = isMergeQueued ? undefined : getInReviewStalledSignal(task, { - now, - executingTaskIds, - thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.stalePausedTodo = getStalePausedTodoSignal(task, { - now, - thresholdMs: settings.stalePausedTodoThresholdMs, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.ageStaleness = getTaskAgeStalenessSignal(task, { - now, - thresholds: staleThresholds, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.stalledReview = isMergeQueued || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); - task.retrySummary = computeRetrySummary(task); - if (slim) { - task.timedExecutionMs = store.computeTimedExecutionMs(task.log); - task.log = []; - } - if (!slim || task.steps.length > 0) { - return task; - } - // FNXC:TaskDetailPromptResilience 2026-07-10-16:00 (merge port from main): - // an unreadable PROMPT.md must not reject this Promise.all and 500 the - // entire board list — degrade to the persisted (empty) steps and log. - try { - const steps = await store.parseStepsFromPrompt(task.id); - return steps.length > 0 ? { ...task, steps } : task; - } catch (err) { - storeLog.warn(`[task-detail] failed to sync steps from PROMPT.md for ${task.id} during listTasks: ${err instanceof Error ? err.message : String(err)}`); - return task; - } - })); - // Sort by createdAt, then by numeric ID suffix for tie-breaking - /* - 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; - }); - // 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 - // column combined is under 500 KB and is needed by the board UI: - // - `steps` → step progress badge on TaskCard - // - `comments` → comment count badge on TaskCard - // - `workflowStepResults` → workflow status indicators - // - `steeringComments` → steering badge - // Use `getTask(id)` to load the full row (including `log`) for the - // TaskDetailModal's Activity tab and Agent Log subview. - const selectClause = store.getTaskSelectClause(slim); - const whereParts: string[] = []; - const params: string[] = []; - // FNXC:TaskStoreForensicRead 2026-06-26-15:25: - // VAL-DATA-006 — Forensic reads surface soft-deleted rows. By default the - // live-reader filter (deletedAt IS NULL) is applied (VAL-DATA-005); when - // includeDeleted is set we drop it so tombstoned tasks appear in the list. - if (!options?.includeDeleted) { - whereParts.push(TaskStore.ACTIVE_TASKS_WHERE); - } - if (columnFilter) { - whereParts.push(`"column" = ?`); - params.push(columnFilter); - } else if (!includeArchived) { - whereParts.push(`"column" != 'archived'`); - } - const whereClause = whereParts.length > 0 ? ` WHERE ${whereParts.join(" AND ")}` : ""; - const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`; - - const rows = store.db.prepare(sql).all(...params); + FNXC:TaskStoreReadsPerf 2026-07-11 (PR #1793 review): + The column filter and pagination are pushed into SQL (readLiveTaskRows + WHERE + ORDER BY + LIMIT/OFFSET) instead of fetching the whole table and + filtering/slicing here — out-of-page rows no longer pay wire transfer or + per-task hydration (stall signals, PROMPT.md step sync). The SQL order + (created_at, numeric id suffix) matches the JS comparator below, so the + page content is identical to the old client-side slice. + */ + const paginationOffset = Math.max(0, options?.offset ?? 0); + const paginationLimit = options?.limit !== undefined ? Math.max(0, options.limit) : undefined; + /* + 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, + ...(boundedMergedPrefix !== undefined + ? { limit: boundedMergedPrefix, offset: 0 } + : sqlPaginated + ? { limit: paginationLimit, offset: paginationOffset } + : {}), + }); const now = Date.now(); const settings = await store.getSettingsFast(); + const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); + /* + * FNXC:SqliteFinalRemoval 2026-06-26-10:30: + * Compute staleness thresholds once for the whole list pass, mirroring + * the SQLite path. The ageStaleness/stalePausedReview/stalePausedTodo + * signals are derived at read time and must be hydrated in backend mode + * too (VAL-CROSS-001 board parity). + */ const staleThresholds: TaskAgeStalenessThresholds = { inProgressWarningMs: settings.staleInProgressWarningMs, inProgressCriticalMs: settings.staleInProgressCriticalMs, inReviewWarningMs: settings.staleInReviewWarningMs, inReviewCriticalMs: settings.staleInReviewCriticalMs, }; - let disableAgeStalenessHydration = false; - const mergeQueuedTaskIds = store.getMergeQueuedTaskIds(); - const activeTasks = await Promise.all((rows as unknown as TaskRow[]).map(async (row) => { + const tasks = await Promise.all(filteredRows.map(async (pgRow) => { + const row = store.pgRowToTaskRow(pgRow); const task = store.rowToTask(row); const isMergeQueued = mergeQueuedTaskIds.has(task.id); /* @@ -545,42 +334,30 @@ export async function listTasksImpl(store: TaskStore, options?: { limit?: number engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); - if (!disableAgeStalenessHydration) { - try { - task.ageStaleness = getTaskAgeStalenessSignal(task, { - now, - thresholds: staleThresholds, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - } catch (error) { - if (error instanceof RangeError) { - disableAgeStalenessHydration = true; - storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this listTasks pass", { - error: error.message, - }); - } else { - throw error; - } - } + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Guard age-staleness: invalid threshold pairs throw RangeError — swallow so one bad setting cannot 500 the whole board list. + */ + try { + task.ageStaleness = getTaskAgeStalenessSignal(task, { + now, + thresholds: staleThresholds, + engineActiveSinceMs: settings.engineActiveSinceMs, + engineActivationGraceMs: settings.engineActivationGraceMs, + }); + } catch (err) { + if (!(err instanceof RangeError)) throw err; + task.ageStaleness = undefined; } task.stalledReview = isMergeQueued || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); - // Derived at read time only; retrySummary is never persisted to SQLite. task.retrySummary = computeRetrySummary(task); - - // Slim path: aggregate the timed-execution total server-side, then - // strip the heavy log payload from the wire response. Without this - // the board card has no way to display the same total-execution - // figure that the task detail panel shows. if (slim) { task.timedExecutionMs = store.computeTimedExecutionMs(task.log); task.log = []; } - if (!slim || task.steps.length > 0) { return task; } - // FNXC:TaskDetailPromptResilience 2026-07-10-16:00 (merge port from main): // an unreadable PROMPT.md must not reject this Promise.all and 500 the // entire board list — degrade to the persisted (empty) steps and log. @@ -592,24 +369,32 @@ export async function listTasksImpl(store: TaskStore, options?: { limit?: number return task; } })); - 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 tasks = mergePrimaryById(activeTasks, archivedTasks); // 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; }); - - const offset = Math.max(0, options?.offset ?? 0); - const limit = options?.limit; - - if (limit === undefined) return sorted.slice(offset); - return sorted.slice(offset, offset + Math.max(0, limit)); - } + // 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); +} export async function listTasksModifiedSinceImpl(store: TaskStore, since: string, limit?: number, opts?: { includeArchived?: boolean },): Promise<{ tasks: Task[]; hasMore: boolean }> { if (Number.isNaN(Date.parse(since))) { @@ -641,114 +426,32 @@ export async function listTasksModifiedSinceImpl(store: TaskStore, since: string }; let disableAgeStalenessHydration = false; - if (store.backendMode) { - const { and, asc, eq, gt, sql } = await import("drizzle-orm"); - const schema = await import("../postgres/schema/index.js"); - const conditions = [ - sql`(${schema.project.tasks.deletedAt} IS NULL)`, - gt(schema.project.tasks.updatedAt, since), - ]; - if (!includeArchived) { - conditions.push(sql`${schema.project.tasks.column} != 'archived'`); - } - const layer = store.asyncLayer!; - // FNXC:MultiProjectIsolation 2026-07-10: scope the incremental-sync scan - // (backs the SSE watcher / modified-since polling) to the bound project so - // one project's dashboard never receives another project's task updates. - if (layer.projectId) { - conditions.push(eq(schema.project.tasks.projectId, layer.projectId)); - } - const pgRows = await layer.db - .select() - .from(schema.project.tasks) - .where(and(...conditions)) - .orderBy(asc(schema.project.tasks.updatedAt)) - .limit(resolvedLimit + 1); - const hasMore = pgRows.length > resolvedLimit; - const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); - const tasks = pgRows.slice(0, resolvedLimit).map((pgRow) => { - const task = store.rowToTask(store.pgRowToTaskRow(pgRow)); - const isMergeQueued = mergeQueuedTaskIds.has(task.id); - /* - FNXC:WorkflowLifecycle 2026-07-05-15:40: - In-review merge/review agents stream progress to agent-log JSONL without - necessarily mutating the task row. Treat fresh agent-log writes as active - ownership for stall-badge hydration so the board does not show - Stalled/Merge stalled while a merger is visibly making progress. Restores - main's FNXC:WorkflowLifecycle 2026-07-01-23:27 behavior, which the - PostgreSQL cutover's store split predated. - */ - const hasFreshAgentLogActivity = hasFreshAgentLogActivitySinceTaskUpdate(store, task, now); - const executingTaskIds = hasFreshAgentLogActivity ? new Set([task.id]) : undefined; - task.inReviewStall = isMergeQueued ? undefined : getInReviewStallReason(task, { - now, - executingTaskIds, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.stalePausedReview = getStalePausedReviewSignal(task, { - now, - thresholdMs: settings.stalePausedReviewThresholdMs, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.inReviewStalled = isMergeQueued ? undefined : getInReviewStalledSignal(task, { - now, - executingTaskIds, - thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.stalePausedTodo = getStalePausedTodoSignal(task, { - now, - thresholdMs: settings.stalePausedTodoThresholdMs, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - if (!disableAgeStalenessHydration) { - try { - task.ageStaleness = getTaskAgeStalenessSignal(task, { - now, - thresholds: staleThresholds, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - } catch (error) { - if (error instanceof RangeError) { - disableAgeStalenessHydration = true; - storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this modified-since pass", { - error: error.message, - }); - } else { - throw error; - } - } - } - task.timedExecutionMs = store.computeTimedExecutionMs(task.log); - task.stalledReview = isMergeQueued || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); - task.retrySummary = computeRetrySummary(task); - task.log = []; - return task; - }); - return { tasks, hasMore }; + const { and, asc, eq, gt, sql } = await import("drizzle-orm"); + const schema = await import("../postgres/schema/index.js"); + const conditions = [ + sql`(${schema.project.tasks.deletedAt} IS NULL)`, + gt(schema.project.tasks.updatedAt, since), + ]; + if (!includeArchived) { + conditions.push(sql`${schema.project.tasks.column} != 'archived'`); } - - const selectClause = store.getTaskSelectClause(true); - - const rows = includeArchived - ? (store.db.prepare( - `SELECT ${selectClause} FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND updatedAt > ? ORDER BY updatedAt ASC LIMIT ?`, - ).all(since, resolvedLimit + 1) as TaskRow[]) - : (store.db.prepare( - `SELECT ${selectClause} FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND updatedAt > ? AND "column" != 'archived' ORDER BY updatedAt ASC LIMIT ?`, - ).all(since, resolvedLimit + 1) as TaskRow[]); - - const hasMore = rows.length > resolvedLimit; - const mergeQueuedTaskIds = store.getMergeQueuedTaskIds(); - const tasks = rows.slice(0, resolvedLimit).map((row) => { - const task = store.rowToTask(row); + const layer = store.asyncLayer!; + // FNXC:MultiProjectIsolation 2026-07-10: scope the incremental-sync scan + // (backs the SSE watcher / modified-since polling) to the bound project so + // one project's dashboard never receives another project's task updates. + if (layer.projectId) { + conditions.push(eq(schema.project.tasks.projectId, layer.projectId)); + } + const pgRows = await layer.db + .select() + .from(schema.project.tasks) + .where(and(...conditions)) + .orderBy(asc(schema.project.tasks.updatedAt)) + .limit(resolvedLimit + 1); + const hasMore = pgRows.length > resolvedLimit; + const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); + const tasks = pgRows.slice(0, resolvedLimit).map((pgRow) => { + const task = store.rowToTask(store.pgRowToTaskRow(pgRow)); const isMergeQueued = mergeQueuedTaskIds.has(task.id); /* FNXC:WorkflowLifecycle 2026-07-05-15:40: @@ -809,203 +512,56 @@ export async function listTasksModifiedSinceImpl(store: TaskStore, since: string } task.timedExecutionMs = store.computeTimedExecutionMs(task.log); task.stalledReview = isMergeQueued || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); - // Derived at read time only; retrySummary is never persisted to SQLite. task.retrySummary = computeRetrySummary(task); task.log = []; return task; }); - return { tasks, hasMore }; - } +} 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 delegates live rows to the generated tsvector // index and composes cold-storage matches when requested. - if (store.backendMode) { - const trimmedQuery = query?.trim(); - if (!trimmedQuery) { - return store.listTasks(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: 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). - projectId: layer.projectId, - }); - if (pgRows.length === 0) { - pgRows = await searchTasksLike(layer.db, trimmedQuery, { - limit: sourceLimit, - offset: sourceOffset, - includeArchived, - projectId: layer.projectId, - }); - } - const now = Date.now(); - const settings = await store.getSettingsFast(); - const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); - const tasks = await Promise.all(pgRows.map(async (pgRow) => { - const task = store.rowToTask(store.pgRowToTaskRow(pgRow)); - const isMergeQueued = mergeQueuedTaskIds.has(task.id); - /* - FNXC:WorkflowLifecycle 2026-07-05-15:40: - In-review merge/review agents stream progress to agent-log JSONL without - necessarily mutating the task row. Treat fresh agent-log writes as active - ownership for stall-badge hydration so the board does not show - Stalled/Merge stalled while a merger is visibly making progress. Restores - main's FNXC:WorkflowLifecycle 2026-07-01-23:27 behavior, which the - PostgreSQL cutover's store split predated. - */ - const hasFreshAgentLogActivity = hasFreshAgentLogActivitySinceTaskUpdate(store, task, now); - const executingTaskIds = hasFreshAgentLogActivity ? new Set([task.id]) : undefined; - task.inReviewStall = isMergeQueued ? undefined : getInReviewStallReason(task, { - now, - executingTaskIds, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.inReviewStalled = isMergeQueued ? undefined : getInReviewStalledSignal(task, { - now, - executingTaskIds, - thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: allowsAutoMergeProcessing(task, settings), - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - task.stalledReview = isMergeQueued || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); - task.retrySummary = computeRetrySummary(task); - if (slim) { - task.timedExecutionMs = store.computeTimedExecutionMs(task.log); - task.log = []; - } - if (task.steps.length > 0) { - return task; - } - // FNXC:TaskDetailPromptResilience 2026-07-10-16:00 (merge port from main): - // an unreadable PROMPT.md must not reject this Promise.all and 500 the - // entire search — degrade to the persisted (empty) steps and log. - try { - const steps = await store.parseStepsFromPrompt(task.id); - return steps.length > 0 ? { ...task, steps } : task; - } catch (err) { - storeLog.warn(`[task-detail] failed to sync steps from PROMPT.md for ${task.id} during searchTasks: ${err instanceof Error ? err.message : String(err)}`); - return task; - } - })); - 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(); + const trimmedQuery = query?.trim(); if (!trimmedQuery) { return store.listTasks(options); } - - // Sanitize query: strip full-text-search operator chars so both code paths see the same token set - const sanitizedTokens = trimmedQuery - .split(/\s+/) - .filter((token) => token.length > 0) - .map((token) => token.replace(/["{}:*^+()]/g, "")) - .filter((token) => token.length > 0); - - if (sanitizedTokens.length === 0) { - return store.listTasks(options); - } - - const limit = options?.limit ?? -1; + const layer = store.asyncLayer!; + const limit = options?.limit; const offset = options?.offset ?? 0; - const offsetClause = offset > 0 ? ` OFFSET ${offset}` : ""; + if (limit !== undefined && Math.max(0, limit) === 0) return []; const includeArchived = options?.includeArchived ?? true; const slim = options?.slim ?? false; - const selectClause = store.getTaskSelectClause(slim, "t"); - - let rows: TaskRow[]; - // FNXC:SqliteFinalRemoval 2026-06-26-16:00: - // VAL-REMOVAL-005 — The full-text-search JOIN/MATCH branch was removed. - // The gutted SQLite Database class reports its full-text-search-available - // flag as false unconditionally, so the branch was dead code; its literal - // JOIN/virtual-table MATCH failed the VAL-REMOVAL-005 grep. This legacy - // SQLite search path is unreachable in backend mode (PostgreSQL uses the - // tsvector path via searchTasksTsvector in the backendMode block above). - // The LIKE fallback below is the sole remaining search strategy for the - // legacy fallback and produces correct result membership (just without the - // full-text ranking). - { - // LIKE fallback: any token matching any searchable column counts as a hit. - // Tokens are OR'd; per token we OR across id/title/description/comments. - // ESCAPE '\\' lets us include user input containing % or _ literally. - const searchColumns = ["id", "title", "description", "comments"]; - const perTokenClause = `(${searchColumns - .map((c) => `t."${c}" LIKE ? ESCAPE '\\'`) - .join(" OR ")})`; - const whereTokens = sanitizedTokens.map(() => perTokenClause).join(" OR "); - const params: string[] = []; - for (const token of sanitizedTokens) { - const pattern = `%${token.replace(/[\\%_]/g, "\\$&")}%`; - for (let i = 0; i < searchColumns.length; i++) params.push(pattern); - } - const archivedClause = `${includeArchived ? "" : ` AND t."column" != 'archived'`} AND t."deletedAt" IS NULL`; - rows = store.db.prepare(` - SELECT ${selectClause} FROM tasks t - WHERE (${whereTokens})${archivedClause} - ORDER BY t.createdAt ASC - LIMIT ${limit >= 0 ? limit : -1}${offsetClause} - `).all(...params) as unknown as TaskRow[]; + // 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: 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). + projectId: layer.projectId, + }); + if (pgRows.length === 0) { + pgRows = await searchTasksLike(layer.db, trimmedQuery, { + limit: sourceLimit, + offset: sourceOffset, + includeArchived, + projectId: layer.projectId, + }); } - const now = Date.now(); const settings = await store.getSettingsFast(); - const staleThresholds: TaskAgeStalenessThresholds = { - inProgressWarningMs: settings.staleInProgressWarningMs, - inProgressCriticalMs: settings.staleInProgressCriticalMs, - inReviewWarningMs: settings.staleInReviewWarningMs, - inReviewCriticalMs: settings.staleInReviewCriticalMs, - }; - let disableAgeStalenessHydration = false; - const mergeQueuedTaskIds = store.getMergeQueuedTaskIds(); - const activeMatches = await Promise.all(rows.map(async (row) => { - const task = store.rowToTask(row); + const mergeQueuedTaskIds = await store.getMergeQueuedTaskIdsAsync(); + const tasks = await Promise.all(pgRows.map(async (pgRow) => { + const task = store.rowToTask(store.pgRowToTaskRow(pgRow)); const isMergeQueued = mergeQueuedTaskIds.has(task.id); /* FNXC:WorkflowLifecycle 2026-07-05-15:40: @@ -1025,12 +581,6 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); - task.stalePausedReview = getStalePausedReviewSignal(task, { - now, - thresholdMs: settings.stalePausedReviewThresholdMs, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); task.inReviewStalled = isMergeQueued ? undefined : getInReviewStalledSignal(task, { now, executingTaskIds, @@ -1039,43 +589,15 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); - task.stalePausedTodo = getStalePausedTodoSignal(task, { - now, - thresholdMs: settings.stalePausedTodoThresholdMs, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - if (!disableAgeStalenessHydration) { - try { - task.ageStaleness = getTaskAgeStalenessSignal(task, { - now, - thresholds: staleThresholds, - engineActiveSinceMs: settings.engineActiveSinceMs, - engineActivationGraceMs: settings.engineActivationGraceMs, - }); - } catch (error) { - if (error instanceof RangeError) { - disableAgeStalenessHydration = true; - storeLog.warn("Invalid stale task thresholds; skipping age staleness hydration for this searchTasks pass", { - error: error.message, - }); - } else { - throw error; - } - } - } - - // Slim path mirrors `listTasks`: aggregate timed execution server-side - // before stripping the heavy log payload from the wire response. + task.stalledReview = isMergeQueued || hasFreshAgentLogActivity ? undefined : detectStalledReview(task, { now }); + task.retrySummary = computeRetrySummary(task); if (slim) { task.timedExecutionMs = store.computeTimedExecutionMs(task.log); task.log = []; } - if (task.steps.length > 0) { return task; } - // FNXC:TaskDetailPromptResilience 2026-07-10-16:00 (merge port from main): // an unreadable PROMPT.md must not reject this Promise.all and 500 the // entire search — degrade to the persisted (empty) steps and log. @@ -1087,17 +609,40 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: return task; } })); - const archiveMatches = includeArchived - ? store.archiveDb.search(trimmedQuery, limit >= 0 ? limit : 100).map((entry) => store.archiveEntryToTask(entry, slim)) - : []; - - const matches = [...activeMatches, ...archiveMatches]; - return limit >= 0 ? matches.slice(0, limit) : matches; - } + 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)); +} /* FNXC:TaskVerificationRequest 2026-07-30-00:00: status reads are project-scoped so chat never observes another project's request. */ export async function getTaskVerificationRequestAsyncImpl(store: TaskStore, taskId: string): Promise { - if (!store.backendMode) return null; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-13:30: + PostgreSQL is the only runtime authority; the former non-backend early-return null path is gone. + */ const layer = store.asyncLayer!; const projectFilter = layer.projectId ? eq(schema.project.taskVerificationRequests.projectId, layer.projectId) : undefined; const rows = await layer.db.select().from(schema.project.taskVerificationRequests).where(and(eq(schema.project.taskVerificationRequests.taskId, taskId), ...(projectFilter ? [projectFilter] : []))).limit(1); diff --git a/packages/core/src/task-store/settings-ops-2.ts b/packages/core/src/task-store/settings-ops-2.ts index 30b36ac079..8442759e0e 100644 --- a/packages/core/src/task-store/settings-ops-2.ts +++ b/packages/core/src/task-store/settings-ops-2.ts @@ -12,56 +12,26 @@ import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js"; import {DEFAULT_PROJECT_SETTINGS} from "../settings-schema.js"; import "../builtin-traits.js"; import {resolveWorktrunkSettings} from "../worktrunk-settings.js"; -import {fromJson} from "../db.js"; import {hasSyncPassphraseConfigured} from "../secrets-sync-passphrase.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {canonicalizeSettings} from "../task-store/settings-helpers.js"; import {readProjectConfig as readProjectConfigAsync, readProjectSettings as readProjectSettingsAsync} from "../task-store/async-settings.js"; export async function getSettingsImpl(store: TaskStore): Promise { - // FNXC:RuntimePersistenceAsync 2026-06-24-10:20: - // In backend mode (PostgreSQL via AsyncDataLayer), delegate to the async - // settings helper. The config-table read goes through Drizzle; jsonb - // columns return already-parsed (VAL-SCHEMA-004). The global-settings - // read is shared across both paths (GlobalSettingsStore is backend-agnostic). - if (store.backendMode) { - const layer = store.asyncLayer!; - const [globalSettings, projectConfig] = await Promise.all([ - store.globalSettingsStore.getSettings(), - readProjectConfigAsync(layer), - ]); - const projectSettings = Object.fromEntries( - Object.entries(projectConfig.settings ?? {}).filter( - ([key]) => !isGlobalOnlySettingsKey(key), - ), - ); - const merged = { - ...DEFAULT_SETTINGS, - ...globalSettings, - ...projectSettings, - worktrunk: resolveWorktrunkSettings( - globalSettings.worktrunk, - (projectSettings as Partial).worktrunk, - ), - }; - try { - merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); - } catch { - merged.secretsSyncPassphraseConfigured = false; - } - const canonical = canonicalizeSettings(merged); - // FNXC:IncompletePgPorts 2026-07-26-20:40: feed getSettingsSync cache for sync ntfy/prompt readers. - store.settingsSyncCache = canonical; - return canonical; - } - const [globalSettings, config] = await Promise.all([ + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:20: + Settings reads are PostgreSQL-only via AsyncDataLayer + GlobalSettingsStore. SQLite store.readConfig arm deleted. + FNXC:RuntimePersistenceAsync 2026-06-24-10:20: jsonb config already-parsed (VAL-SCHEMA-004). + */ + const layer = store.asyncLayer!; + const [globalSettings, projectConfig] = await Promise.all([ store.globalSettingsStore.getSettings(), - store.readConfig(), + readProjectConfigAsync(layer), ]); - // Strip global-only keys from project-level settings so stale project-scoped - // values don't override the correct global value during the spread merge. const projectSettings = Object.fromEntries( - Object.entries(config.settings ?? {}).filter(([key]) => !isGlobalOnlySettingsKey(key)), + Object.entries(projectConfig.settings ?? {}).filter( + ([key]) => !isGlobalOnlySettingsKey(key), + ), ); const merged = { ...DEFAULT_SETTINGS, @@ -77,176 +47,103 @@ export async function getSettingsImpl(store: TaskStore): Promise { } catch { merged.secretsSyncPassphraseConfigured = false; } - return canonicalizeSettings(merged); + const canonical = canonicalizeSettings(merged); + // FNXC:IncompletePgPorts 2026-07-26-20:40: feed getSettingsSync cache for sync ntfy/prompt readers. + store.settingsSyncCache = canonical; + return canonical; } export async function getSettingsFastImpl(store: TaskStore): Promise { // FNXC:RuntimePersistenceAsync 2026-06-24-10:22: // Backend-mode fast settings read: delegate to the async settings helper // (readProjectSettingsAsync), which reads only the jsonb `settings` column. - if (store.backendMode) { - const layer = store.asyncLayer!; - const [globalSettings, projectSettingsRaw] = await Promise.all([ - store.globalSettingsStore.getSettings(), - readProjectSettingsAsync(layer), - ]); - const raw = projectSettingsRaw ?? undefined; - const projectSettings: Partial | undefined = raw - ? (Object.fromEntries( - Object.entries(raw).filter(([key]) => !isGlobalOnlySettingsKey(key)), - ) as Partial) - : undefined; - const merged = { - ...DEFAULT_SETTINGS, - ...globalSettings, - ...projectSettings, - worktrunk: resolveWorktrunkSettings( - globalSettings.worktrunk, - projectSettings?.worktrunk, - ), - }; - try { - merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); - } catch { - merged.secretsSyncPassphraseConfigured = false; - } - const canonical = canonicalizeSettings(merged); - // FNXC:IncompletePgPorts 2026-07-26-20:40: feed getSettingsSync cache (fast path). - store.settingsSyncCache = canonical; - return canonical; - } - const [globalSettings, row] = await Promise.all([ + const layer = store.asyncLayer!; + const [globalSettings, projectSettingsRaw] = await Promise.all([ store.globalSettingsStore.getSettings(), - store.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined, + readProjectSettingsAsync(layer), ]); - - const raw = row?.settings ? fromJson(row.settings) : undefined; - - // Strip global-only keys from the project-level row so stale project-scoped - // values (e.g. an empty experimentalFeatures={}) don't override the correct - // global value during the spread merge below. getSettingsByScopeFast() has - // always done this; getSettingsFast() was missing the filter. + const raw = projectSettingsRaw ?? undefined; const projectSettings: Partial | undefined = raw ? (Object.fromEntries( Object.entries(raw).filter(([key]) => !isGlobalOnlySettingsKey(key)), ) as Partial) : undefined; - const merged = { ...DEFAULT_SETTINGS, ...globalSettings, ...projectSettings, - worktrunk: resolveWorktrunkSettings(globalSettings.worktrunk, projectSettings?.worktrunk), + worktrunk: resolveWorktrunkSettings( + globalSettings.worktrunk, + projectSettings?.worktrunk, + ), }; try { merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); } catch { merged.secretsSyncPassphraseConfigured = false; } - - return canonicalizeSettings(merged); - } + const canonical = canonicalizeSettings(merged); + // FNXC:IncompletePgPorts 2026-07-26-20:40: feed getSettingsSync cache (fast path). + store.settingsSyncCache = canonical; + return canonical; +} export async function getSettingsByScopeImpl(store: TaskStore): Promise<{ global: GlobalSettings; project: Partial }> { - // FNXC:RuntimePersistenceAsync 2026-06-24-10:23: - // Backend-mode scoped settings read: delegate project read to async helper. - if (store.backendMode) { - const layer = store.asyncLayer!; - const [globalSettings, projectConfig] = await Promise.all([ - store.globalSettingsStore.getSettings(), - readProjectConfigAsync(layer), - ]); - try { - globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); - } catch { - globalSettings.secretsSyncPassphraseConfigured = false; - } - const projectSettings: Partial = {}; - if (projectConfig.settings) { - for (const key of Object.keys(projectConfig.settings)) { - if (!isGlobalOnlySettingsKey(key)) { - (projectSettings as Record)[key] = (projectConfig.settings as Record)[key]; - } - } - } - const canonicalizedProject = canonicalizeSettings(projectSettings as Settings); - if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { - canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; - } - return { global: globalSettings, project: canonicalizedProject }; - } - const [globalSettings, config] = await Promise.all([ + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:20: + Scoped settings reads are PostgreSQL-only; SQLite store.readConfig arm deleted. + */ + const layer = store.asyncLayer!; + const [globalSettings, projectConfig] = await Promise.all([ store.globalSettingsStore.getSettings(), - store.readConfig(), + readProjectConfigAsync(layer), ]); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Do not mutate GlobalSettingsStore's cached object; compute passphrase flag on a shallow copy. + */ + let secretsSyncPassphraseConfigured = false; try { - globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); + secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); } catch { - globalSettings.secretsSyncPassphraseConfigured = false; + secretsSyncPassphraseConfigured = false; } - - // Extract only project-level keys from config.settings + const global = { ...globalSettings, secretsSyncPassphraseConfigured }; const projectSettings: Partial = {}; - if (config.settings) { - for (const key of Object.keys(config.settings)) { + if (projectConfig.settings) { + for (const key of Object.keys(projectConfig.settings)) { if (!isGlobalOnlySettingsKey(key)) { - (projectSettings as Record)[key] = (config.settings as Record)[key]; + (projectSettings as Record)[key] = (projectConfig.settings as Record)[key]; } } } - - // Apply canonicalization to project settings and keep upgrade-safe - // default fallback behavior for legacy rows that omit this key. const canonicalizedProject = canonicalizeSettings(projectSettings as Settings); if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; } - - return { global: globalSettings, project: canonicalizedProject }; + return { global, project: canonicalizedProject }; } export async function getSettingsByScopeFastImpl(store: TaskStore): Promise<{ global: GlobalSettings; project: Partial }> { // FNXC:RuntimePersistenceAsync 2026-06-24-10:24: // Backend-mode fast scoped read: delegate to async settings helper. - if (store.backendMode) { - const layer = store.asyncLayer!; - const [globalSettings, projectSettingsRaw] = await Promise.all([ - store.globalSettingsStore.getSettings(), - readProjectSettingsAsync(layer), - ]); - try { - globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); - } catch { - globalSettings.secretsSyncPassphraseConfigured = false; - } - const projectSettings = projectSettingsRaw ?? undefined; - const projectScoped: Partial = {}; - if (projectSettings) { - for (const key of Object.keys(projectSettings)) { - if (!isGlobalOnlySettingsKey(key)) { - (projectScoped as Record)[key] = (projectSettings as Record)[key]; - } - } - } - const canonicalizedProject = canonicalizeSettings(projectScoped as Settings); - if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { - canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; - } - return { global: globalSettings, project: canonicalizedProject }; - } - const [globalSettings, row] = await Promise.all([ + const layer = store.asyncLayer!; + const [globalSettings, projectSettingsRaw] = await Promise.all([ store.globalSettingsStore.getSettings(), - store.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined, + readProjectSettingsAsync(layer), ]); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Do not mutate GlobalSettingsStore cache; return a shallow copy with the request-time passphrase flag. + */ + let secretsSyncPassphraseConfigured = false; try { - globalSettings.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); + secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); } catch { - globalSettings.secretsSyncPassphraseConfigured = false; + secretsSyncPassphraseConfigured = false; } - - const projectSettings = row?.settings ? fromJson(row.settings) : undefined; - - // Extract only project-level keys from config.settings + const global = { ...globalSettings, secretsSyncPassphraseConfigured }; + const projectSettings = projectSettingsRaw ?? undefined; const projectScoped: Partial = {}; if (projectSettings) { for (const key of Object.keys(projectSettings)) { @@ -255,14 +152,10 @@ export async function getSettingsByScopeFastImpl(store: TaskStore): Promise<{ gl } } } - - // Apply canonicalization and keep upgrade-safe default fallback behavior - // for legacy rows that omit this key. const canonicalizedProject = canonicalizeSettings(projectScoped as Settings); if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; } - - return { global: globalSettings, project: canonicalizedProject }; - } + return { global, project: canonicalizedProject }; +} diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index 135771a3c7..49e06ea67a 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -7,9 +7,6 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore, storeLog, isWorkflowColumnsCompatibilityFlagEnabled} from "../store.js"; -import {rm} from "node:fs/promises"; -import {join} from "node:path"; -import {detectWorkspaceRepos, saveWorkspaceConfig, loadWorkspaceConfig} from "../git-repository.js"; import type {BoardConfig, Settings, GlobalSettings, ConfigChangedBy} from "../types.js"; import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js"; import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../moved-settings.js"; @@ -50,7 +47,7 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial { - // FNXC:RuntimePersistenceAsync 2026-06-24-10:28: - // In backend mode, read/write the config table via the async helpers - // instead of the sync SQLite path. The business logic (promptOverrides - // merge, null-delete semantics) is identical across backends. - if (store.backendMode) { - const layer = store.asyncLayer!; - const transactionResult = await layer.transactionImmediate(async (tx) => { + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:30: + updateSettings is PostgreSQL-only (asyncLayer transaction + configuration revision). The store.readConfigFast / writeConfig SQLite arm is deleted. Side effects (evacuate, memory, workspace) run via publishSettingsUpdated after commit. + FNXC:RuntimePersistenceAsync 2026-06-24-10:28: promptOverrides merge and null-delete semantics unchanged. + */ + const layer = store.asyncLayer!; + const transactionResult = await layer.transactionImmediate(async (tx) => { const projectConfig = await readProjectConfigAsync(layer, tx); const config: BoardConfig = { nextId: projectConfig.nextId ?? 1, @@ -158,145 +155,16 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial)["promptOverrides"]; - if (incomingPromptOverrides === null) { - // promptOverrides: null → clear the entire promptOverrides object - delete (config.settings as unknown as Record)["promptOverrides"]; - delete (projectPatch as Record)["promptOverrides"]; - } else if ( - incomingPromptOverrides !== undefined && - typeof incomingPromptOverrides === "object" && - incomingPromptOverrides !== null - ) { - // promptOverrides: { key: value } → merge with existing, treating null values as delete - const incomingMap = incomingPromptOverrides as Record; - const existingMap = ((config.settings as unknown as Record)["promptOverrides"] as Record) ?? {}; - const mergedMap: Record = { ...existingMap }; - - for (const [key, value] of Object.entries(incomingMap)) { - if (value === null) { - // null → delete this specific key - delete mergedMap[key]; - } else if (typeof value === "string" && value !== "") { - // non-empty string → set this key - // Empty strings are treated as "clear" and not stored - mergedMap[key] = value; - } - // Empty strings are silently ignored (treated as "clear") - } - - // If merged map is empty, remove the entire promptOverrides - if (Object.keys(mergedMap).length === 0) { - delete (config.settings as unknown as Record)["promptOverrides"]; - delete (projectPatch as Record)["promptOverrides"]; - } else { - (config.settings as unknown as Record)["promptOverrides"] = mergedMap; - (projectPatch as Record)["promptOverrides"] = mergedMap; - } - } - - // Handle null values for other top-level keys (non-promptOverrides) - for (const key of Object.keys(projectPatch)) { - if ((projectPatch as Record)[key] === null) { - delete (config.settings as unknown as Record)[key]; - delete (projectPatch as Record)[key]; - } - } - - const globalSettings = await store.globalSettingsStore.getSettings(); - const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings; - const updatedProjectSettings = { ...config.settings, ...projectPatch }; - // FNXC:TaskPinnedWorktrees 2026-07-16-00:00: reject recycleWorktrees + worktreeNaming:"task-id" - // (mutually exclusive) against the resolved next state BEFORE persisting the invalid combination. - assertWorktreeNamingRecycleExclusive({ ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings); - config.settings = updatedProjectSettings as Settings; - await store.writeConfig(config); - const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings; - store.emit("settings:updated", { settings: updatedMerged, previous: previousMerged }); - - // #1409: if this update flipped workflowColumns ON→OFF, evacuate any card - // stranded in a custom (non-legacy) column back to a legacy column so the - // board stays listable / movable on the legacy path. - if (isWorkflowColumnsCompatibilityFlagEnabled(previousMerged) && !isWorkflowColumnsCompatibilityFlagEnabled(updatedMerged)) { - try { - await store.evacuateCustomColumnsToLegacy("flag-toggled-off"); - } catch (err) { - storeLog.warn("workflowColumns ON→OFF evacuation failed", { - phase: "evacuate-custom-columns", - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // Bootstrap project memory file when memory is toggled on - if (updatedMerged.memoryEnabled !== false && previousMerged.memoryEnabled === false) { - try { - // Use backend-aware bootstrap to honor memoryBackendType setting - await ensureMemoryFileWithBackend(store.rootDir, updatedMerged); - } catch (err) { - // Non-fatal — memory bootstrap failure should not block settings update - storeLog.warn("Project-memory bootstrap failed after memory toggle-on", { - phase: "updateSettings:memory-toggle-on", - rootDir: store.rootDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } + }); /* - FNXC:Workspace 2026-06-24-16:00: - When workspaceMode is toggled on, detect sub-repos and persist workspace.json so the - executor and ensureGitRepositoryForProjectPath treat the root as workspace-mode. When - toggled off, remove workspace.json so the root falls back to single-repo behavior. + FNXC:ConfigVersioning 2026-07-18-11:00: + Configuration observers and filesystem follow-up work run only after the + target-plus-revision transaction commits. A failed journal append must + not make a rolled-back setting observable as a successful update. */ - if (updatedMerged.workspaceMode === true && previousMerged.workspaceMode !== true) { - try { - const existing = await loadWorkspaceConfig(store.rootDir); - if (!existing) { - const repos = await detectWorkspaceRepos(store.rootDir); - if (repos.length > 0) { - await saveWorkspaceConfig(store.rootDir, { repos }); - } - } - } catch (err) { - storeLog.warn("workspace.json sync failed after workspaceMode toggle-on", { - phase: "updateSettings:workspace-toggle-on", - rootDir: store.rootDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } else if (updatedMerged.workspaceMode === false && previousMerged.workspaceMode === true) { - try { - await rm(join(store.rootDir, ".fusion", "workspace.json"), { force: true }); - } catch (err) { - storeLog.warn("workspace.json removal failed after workspaceMode toggle-off", { - phase: "updateSettings:workspace-toggle-off", - rootDir: store.rootDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - return updatedMerged; + await publishSettingsUpdated(store, transactionResult.previousMerged, transactionResult.updatedMerged); + return transactionResult.updatedMerged; }); } @@ -308,16 +176,12 @@ export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial< * In backend mode, read config via async helper instead of store.readConfigFast() * which uses store.db (SQLite). */ - let config: BoardConfig; - if (store.backendMode) { - const projectConfig = await readProjectConfigAsync(store.asyncLayer!); - config = { - nextId: projectConfig.nextId ?? 1, - settings: (projectConfig.settings ?? {}) as Settings, - }; - } else { - config = store.readConfigFast(); - } + const projectConfig = await readProjectConfigAsync(store.asyncLayer!); + const config: BoardConfig = { + nextId: projectConfig.nextId ?? 1, + settings: (projectConfig.settings ?? {}) as Settings, + }; + const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings; // Stale-writer guard (U4, R8): moved keys are all project-scoped, but null diff --git a/packages/core/src/task-store/task-artifacts-ops.ts b/packages/core/src/task-store/task-artifacts-ops.ts index ba857b8ee0..fe658e0e9a 100644 --- a/packages/core/src/task-store/task-artifacts-ops.ts +++ b/packages/core/src/task-store/task-artifacts-ops.ts @@ -24,9 +24,9 @@ import { emitUsageEvent as emitUsageEventAsync, recordPluginActivation as record 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"; import { extractEffectiveWriteScopeFromPrompt } from "../file-scope-classification.js"; -import { ArtifactRow, CompletionHandoffMarkerRow, MergeQueueRow, TaskDocumentRevisionRow, TaskDocumentRow, WorkflowWorkItemRow } from "./row-types.js"; +import { ArtifactRow, WorkflowWorkItemRow } from "./row-types.js"; import { AgentLogEntry, Artifact, ArtifactCreateInput, Column, CompletionHandoffMarker, MergeQueueEnqueueOptions, MergeQueueEntry, PluginActivation, PluginActivationInput, RunAuditEvent, RunMutationContext, Task, TaskDocument, TaskDocumentRevision, WorkflowWorkItem, WorkflowWorkItemKind, isColumn } from "../types.js"; -import { type UsageEventInput, emitUsageEvent as emitUsageEventToDb } from "../usage-events.js"; +import type { UsageEventInput } from "../usage-events.js"; import { DUAL_ACCEPT_PARITY_MUTATIONS, type WorkflowColumnsGraduationReport, computeWorkflowColumnsGraduationReport } from "../workflow-parity.js"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -52,64 +52,31 @@ export function listWorkflowWorkItemsForTaskSyncImpl(store: TaskStore, taskId: s } export async function clearCompletionHandoffAcceptedMarkerImpl(store: TaskStore, taskId: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - const existing = await getCompletionHandoffMarkerAsync(layer.db, taskId); - if (!existing) return; - await clearCompletionHandoffMarkerAsync(layer.db, taskId); - void store.recordRunAuditEvent({ - taskId, - agentId: "system", - runId: `completion-handoff-clear:${taskId}:${Date.now()}`, - domain: "database", - mutationType: "task:completion-handoff-cleared", - target: taskId, - metadata: { taskId, acceptedAt: existing.acceptedAt, source: existing.source }, - }); - return; - } - store.db.transactionImmediate(() => { - const existing = store.db.prepare("SELECT * FROM completion_handoff_markers WHERE taskId = ?").get(taskId) as CompletionHandoffMarkerRow | undefined; - if (!existing) return; - store.db.prepare("DELETE FROM completion_handoff_markers WHERE taskId = ?").run(taskId); - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "task:completion-handoff-cleared", - target: taskId, - metadata: { taskId, acceptedAt: existing.acceptedAt, source: existing.source }, - }); + const layer = store.asyncLayer!; + const existing = await getCompletionHandoffMarkerAsync(layer.db, taskId); + if (!existing) return; + await clearCompletionHandoffMarkerAsync(layer.db, taskId); + void store.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `completion-handoff-clear:${taskId}:${Date.now()}`, + domain: "database", + mutationType: "task:completion-handoff-cleared", + target: taskId, + metadata: { taskId, acceptedAt: existing.acceptedAt, source: existing.source }, }); + return; } export async function getCompletionHandoffAcceptedMarkerImpl(store: TaskStore, taskId: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - const marker = await getCompletionHandoffMarkerAsync(layer.db, taskId); - return marker as CompletionHandoffMarker | null; - } - const row = store.db.prepare("SELECT * FROM completion_handoff_markers WHERE taskId = ?").get(taskId) as CompletionHandoffMarkerRow | undefined; - return row ? store.rowToCompletionHandoffMarker(row) : null; + const layer = store.asyncLayer!; + const marker = await getCompletionHandoffMarkerAsync(layer.db, taskId); + return marker as CompletionHandoffMarker | null; } export async function recordPluginActivationImpl(store: TaskStore, input: PluginActivationInput): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return recordPluginActivationAsync(layer.db, input); - } - const activatedAt = input.activatedAt ?? new Date().toISOString(); - const result = store.db.prepare(` - INSERT INTO plugin_activations (pluginId, source, pluginVersion, activatedAt) - VALUES (?, ?, ?, ?) - `).run(input.pluginId, input.source, input.pluginVersion ?? null, activatedAt); - - return { - id: Number(result.lastInsertRowid), - pluginId: input.pluginId, - source: input.source, - pluginVersion: input.pluginVersion ?? null, - activatedAt, - }; + const layer = store.asyncLayer!; + return recordPluginActivationAsync(layer.db, input); } export async function computeWorkflowColumnsGraduationReportImpl(store: TaskStore, @@ -136,16 +103,11 @@ export async function computeWorkflowColumnsGraduationReportImpl(store: TaskStor } export async function enqueueMergeQueueImpl(store: TaskStore, taskId: string, opts: MergeQueueEnqueueOptions = {}): Promise { - // FNXC:RuntimeLifecycleAsync 2026-06-24-11:12: - // Backend-mode: delegate to the async merge-coordination helper (async-merge-coordination.ts). - // This preserves enqueue semantics (column check, idempotent ON CONFLICT DO NOTHING insert, - // mergeQueue:enqueue audit event) against PostgreSQL via Drizzle. - if (store.backendMode) { - const layer = store.asyncLayer!; - return enqueueMergeQueueAsync(layer, taskId, opts); - } - // SQLite path: delegate to the sync internal (also used by moveTaskInternal). - return store.enqueueMergeQueueSyncInternal(taskId, opts); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:05: + Merge-queue enqueue is PostgreSQL-only via enqueueMergeQueueAsync (column check, idempotent insert, audit). The SQLite enqueueMergeQueueSyncInternal arm is deleted. + */ + return enqueueMergeQueueAsync(store.asyncLayer!, taskId, opts); } export function cleanupStaleMergeQueueRowsImpl(store: TaskStore, now: string): void { @@ -176,47 +138,16 @@ export function cleanupStaleMergeQueueRowsImpl(store: TaskStore, now: string): v } export async function peekMergeQueueImpl(store: TaskStore): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return peekMergeQueueAsync(layer); - } - const rows = store.db.prepare(` - SELECT * FROM mergeQueue - ORDER BY CASE priority - WHEN 'urgent' THEN 0 - WHEN 'high' THEN 1 - WHEN 'normal' THEN 2 - WHEN 'low' THEN 3 - ELSE 4 - END ASC, - enqueuedAt ASC - `).all() as MergeQueueRow[]; - return rows.map((row) => store.rowToMergeQueueEntry(row)); + const layer = store.asyncLayer!; + return peekMergeQueueAsync(layer); } export async function peekMergeQueueHeadImpl(store: TaskStore): Promise<{ taskId: string; leasedBy: string | null; column: Column | null } | null> { - if (store.backendMode) { - const layer = store.asyncLayer!; - const head = await peekMergeQueueHeadAsync(layer); - // The async helper returns column as string | null (Drizzle text column); - // cast to the Column union for the public API contract. - return head ? { ...head, column: head.column as Column | null } : null; - } - const row = store.db.prepare(` - SELECT mq.taskId, mq.leasedBy, t.column - FROM mergeQueue mq - LEFT JOIN tasks t ON t.id = mq.taskId - ORDER BY CASE mq.priority - WHEN 'urgent' THEN 0 - WHEN 'high' THEN 1 - WHEN 'normal' THEN 2 - WHEN 'low' THEN 3 - ELSE 4 - END ASC, - mq.enqueuedAt ASC - LIMIT 1 - `).get() as { taskId: string; leasedBy: string | null; column: Column | null } | undefined; - return row ?? null; + const layer = store.asyncLayer!; + const head = await peekMergeQueueHeadAsync(layer); + // The async helper returns column as string | null (Drizzle text column); + // cast to the Column union for the public API contract. + return head ? { ...head, column: head.column as Column | null } : null; } export async function parseStepsFromPromptImpl(store: TaskStore, id: string): Promise { @@ -335,50 +266,20 @@ export async function syncAgentTaskLinkOnReassignmentImpl(store: TaskStore, FNXC:PostgresCutover 2026-07-04-00:00: Backend-mode agent-task-link sync: update the agents.taskId column via async Drizzle. Only the dedicated taskId column is authoritative in PG (agent.data jsonb is not read for the link), so the SQLite json_set/json_remove on data is not mirrored. */ - if (store.backendMode) { - const db = store.asyncLayer!.db; - if (previousAgentId) { - await db - .update(schema.project.agents) - .set({ taskId: null, updatedAt }) - .where(and(eq(schema.project.agents.id, previousAgentId), eq(schema.project.agents.taskId, taskId))); - } - if (newAgentId) { - await db - .update(schema.project.agents) - .set({ taskId, updatedAt }) - .where(eq(schema.project.agents.id, newAgentId)); - } - return; - } - + const db = store.asyncLayer!.db; if (previousAgentId) { - store.db.prepare(` - UPDATE agents - SET - taskId = NULL, - updatedAt = ?, - data = CASE - WHEN json_valid(data) THEN json_set(json_remove(data, '$.taskId'), '$.updatedAt', ?) - ELSE data - END - WHERE id = ? AND taskId = ? - `).run(updatedAt, updatedAt, previousAgentId, taskId); + await db + .update(schema.project.agents) + .set({ taskId: null, updatedAt }) + .where(and(eq(schema.project.agents.id, previousAgentId), eq(schema.project.agents.taskId, taskId))); } - if (newAgentId) { - store.db.prepare(` - UPDATE agents - SET - taskId = ?, - updatedAt = ?, - data = CASE - WHEN json_valid(data) THEN json_set(data, '$.taskId', ?, '$.updatedAt', ?) - ELSE data - END - WHERE id = ? - `).run(taskId, updatedAt, taskId, updatedAt, newAgentId); + await db + .update(schema.project.agents) + .set({ taskId, updatedAt }) + .where(eq(schema.project.agents.id, newAgentId)); } + return; } export async function runGitCommandImpl(store: TaskStore, command: string, timeoutMs = 10_000) { @@ -391,63 +292,31 @@ export async function runGitCommandImpl(store: TaskStore, command: string, timeo export async function clearStaleExecutionStartBranchReferencesImpl(store: TaskStore, deletedBranches: string[], ownerTaskId?: string): Promise { if (deletedBranches.length === 0) 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})`; - if (ownerTaskId) { - whereClause += ` AND id != ?`; - params.push(ownerTaskId); - } - const rows = store.db - .prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE} AND ${whereClause}`) - .all(...params) as Array<{ id: string }>; - - if (rows.length === 0) return []; - const update = store.db.prepare( - `UPDATE tasks SET executionStartBranch = NULL, updatedAt = ? WHERE id = ?`, - ); + /* + 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 clearedIds: string[] = []; - for (const row of rows) { - update.run(now, row.id); - clearedIds.push(row.id); - if (store.isWatching) { - const cached = store.taskCache.get(row.id); + 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; } } } - store.db.bumpLastModified(); return clearedIds; } @@ -585,11 +454,8 @@ export async function getAttachmentImpl(store: TaskStore, } export async function emitUsageEventImpl(store: TaskStore, event: UsageEventInput): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return emitUsageEventAsync(layer.db, layer.projectId ?? "", event); - } - return emitUsageEventToDb(store.db, event); + const layer = store.asyncLayer!; + return emitUsageEventAsync(layer.db, layer.projectId ?? "", event); } export async function addSteeringCommentImpl(store: TaskStore, id: string, text: string, author: "user" | "agent" = "user", runContext?: RunMutationContext): Promise { @@ -625,7 +491,7 @@ 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`); @@ -659,7 +525,7 @@ 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`); @@ -741,12 +607,8 @@ export function insertArtifactRowImpl(store: TaskStore, input: ArtifactCreateInp } export async function getArtifactImpl(store: TaskStore, id: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getArtifactAsync(layer.db, id); - } - const row = store.db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id) as ArtifactRow | undefined; - return row ? store.rowToArtifact(row) : null; + const layer = store.asyncLayer!; + return getArtifactAsync(layer.db, id); } /** @@ -758,91 +620,25 @@ export async function getArtifactImpl(store: TaskStore, id: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - const updated = await updateArtifactRowAsync(layer, id, updates); - store.emit("artifact:updated", updated); - return updated; - } - - const existing = await store.getArtifact(id); - if (!existing) { - throw new Error(`Artifact ${id} not found`); - } - - if (existing.taskId && store.isTaskArchived(existing.taskId)) { - throw new Error(`Task ${existing.taskId} is archived — artifacts are read-only`); - } - - if (updates.content !== undefined && existing.uri) { - throw new Error(`Artifact ${id} stores a binary payload; its content is not editable`); - } - - const now = new Date().toISOString(); - store.db.prepare( - "UPDATE artifacts SET title = ?, description = ?, content = ?, updatedAt = ? WHERE id = ?", - ).run( - updates.title !== undefined ? updates.title : existing.title, - updates.description !== undefined ? updates.description : existing.description ?? null, - updates.content !== undefined ? updates.content : existing.content ?? null, - now, - id, - ); - - const updated = await store.getArtifact(id); - if (!updated) { - throw new Error(`Failed to update artifact ${id}`); - } - - store.db.bumpLastModified(); + const layer = store.asyncLayer!; + const updated = await updateArtifactRowAsync(layer, id, updates); store.emit("artifact:updated", updated); return updated; } export async function getArtifactsImpl(store: TaskStore, taskId: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getArtifactsAsync(layer.db, taskId, layer.projectId); - } - if (!store.hasActiveTask(taskId)) { - return []; - } - - const rows = store.db - .prepare("SELECT * FROM artifacts WHERE taskId = ? ORDER BY createdAt DESC") - .all(taskId) as unknown as ArtifactRow[]; - return rows.map((row) => store.rowToArtifact(row)); + const layer = store.asyncLayer!; + return getArtifactsAsync(layer.db, taskId, layer.projectId); } export async function getTaskDocumentsImpl(store: TaskStore, taskId: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return listTaskDocumentsAsync(layer.db, taskId, layer.projectId); - } - if (!store.hasActiveTask(taskId)) { - return []; - } - - const rows = store.db - .prepare("SELECT * FROM task_documents WHERE taskId = ? ORDER BY key") - .all(taskId) as unknown as TaskDocumentRow[]; - return rows.map((row) => store.rowToTaskDocument(row)); + const layer = store.asyncLayer!; + return listTaskDocumentsAsync(layer.db, taskId, layer.projectId); } export async function getTaskDocumentImpl(store: TaskStore, taskId: string, key: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getTaskDocumentAsync(layer.db, taskId, key, layer.projectId); - } - if (!store.hasActiveTask(taskId)) { - return null; - } - - const row = store.db - .prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?") - .get(taskId, key) as unknown as TaskDocumentRow | undefined; - if (!row) return null; - return store.rowToTaskDocument(row); + const layer = store.asyncLayer!; + return getTaskDocumentAsync(layer.db, taskId, key, layer.projectId); } export async function getTaskDocumentRevisionsImpl(store: TaskStore, @@ -857,31 +653,11 @@ export async function getTaskDocumentRevisionsImpl(store: TaskStore, path orders by revision DESC, so we re-sort by revision descending in JS to preserve that ordering exactly, then apply the optional LIMIT. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - 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; - } - if (!store.hasActiveTask(taskId)) { - return []; - } - - const hasLimit = options?.limit !== undefined; - const rows = hasLimit - ? (store.db - .prepare( - "SELECT * FROM task_document_revisions WHERE taskId = ? AND key = ? ORDER BY revision DESC LIMIT ?", - ) - .all(taskId, key, Math.max(0, options.limit ?? 0)) as unknown as TaskDocumentRevisionRow[]) - : (store.db - .prepare( - "SELECT * FROM task_document_revisions WHERE taskId = ? AND key = ? ORDER BY revision DESC", - ) - .all(taskId, key) as unknown as TaskDocumentRevisionRow[]); - - return rows.map((row) => store.rowToTaskDocumentRevision(row)); + const layer = store.asyncLayer!; + 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; } export async function deleteTaskDocumentImpl(store: TaskStore, taskId: string, key: string): Promise { @@ -893,42 +669,13 @@ export async function deleteTaskDocumentImpl(store: TaskStore, taskId: string, k The post-delete task:updated emit mirrors the SQLite path; getTask returns only live tasks so a present task implies deletedAt == null. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - await deleteTaskDocumentAsync(layer, taskId, key); - const task = await store.getTask(taskId); - if (task) { - store.emit("task:updated", task); - } - return; - } - const existing = store.db - .prepare("SELECT id FROM task_documents WHERE taskId = ? AND key = ?") - .get(taskId, key) as { id: string } | undefined; - - if (!existing) { - throw new Error(`Document ${key} not found for task ${taskId}`); - } - - store.db.transaction(() => { - store.db - .prepare("DELETE FROM task_document_revisions WHERE taskId = ? AND key = ?") - .run(taskId, key); - - const result = store.db - .prepare("DELETE FROM task_documents WHERE taskId = ? AND key = ?") - .run(taskId, key) as { changes?: number }; - - if ((result.changes ?? 0) === 0) { - throw new Error(`Document ${key} not found for task ${taskId}`); - } - }); - - store.db.bumpLastModified(); - const task = store.readTaskFromDb(taskId, { includeDeleted: true }); - if (task && task.deletedAt == null) { + const layer = store.asyncLayer!; + await deleteTaskDocumentAsync(layer, taskId, key); + const task = await store.getTask(taskId); + if (task) { store.emit("task:updated", task); } + return; } export function resolvePrimaryPrInfoImpl(store: TaskStore, prInfos: import("../types.js").PrInfo[]): import("../types.js").PrInfo | undefined { @@ -1092,11 +839,7 @@ export async function getAgentLogsImpl(store: TaskStore, store.flushAgentLogBuffer(); // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-15:45: // Backend mode: skip the sync readTaskFromDb deleted-check. - if (!store.backendMode) { - if (store.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { - return []; - } - } + const limit = options?.limit !== undefined ? (Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 0) : undefined; @@ -1121,10 +864,6 @@ export async function getAgentLogCountImpl( // Backend mode: skip the sync readTaskFromDb check. The agent log file // is read from the file system regardless; the deleted-task check is a // best-effort optimization that is not critical for the archive path. - if (!store.backendMode) { - if (store.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { - return 0; - } - } + return countAgentLogEntries(store.taskDir(taskId), { type: options?.type }); } diff --git a/packages/core/src/task-store/task-commit-associations.ts b/packages/core/src/task-store/task-commit-associations.ts index 9f3fc95cd9..e503971140 100644 --- a/packages/core/src/task-store/task-commit-associations.ts +++ b/packages/core/src/task-store/task-commit-associations.ts @@ -17,7 +17,6 @@ import { TaskCommitAssociation } from "../types.js"; import { and, desc, eq, inArray } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; - export async function getTaskCommitAssociationsByLineageIdImpl(store: TaskStore, lineageId: string): Promise { /* FNXC:PostgresCutover 2026-07-04: @@ -26,34 +25,23 @@ export async function getTaskCommitAssociationsByLineageIdImpl(store: TaskStore, Drizzle select returns camelCase columns (schema-mapped), cast to the shared TaskCommitAssociationRow shape used by normalizeTaskCommitAssociation. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const rows = await layer.db - .select() - .from(schema.project.taskCommitAssociations) - .where(eq(schema.project.taskCommitAssociations.taskLineageId, lineageId)) - .orderBy( - desc(schema.project.taskCommitAssociations.authoredAt), - desc(schema.project.taskCommitAssociations.createdAt), - ); - return (rows as TaskCommitAssociationRow[]).map((row) => - normalizeTaskCommitAssociation({ - ...row, - note: row.note ?? undefined, - additions: row.additions ?? undefined, - deletions: row.deletions ?? undefined, - }), + const layer = store.asyncLayer!; + const rows = await layer.db + .select() + .from(schema.project.taskCommitAssociations) + .where(eq(schema.project.taskCommitAssociations.taskLineageId, lineageId)) + .orderBy( + desc(schema.project.taskCommitAssociations.authoredAt), + desc(schema.project.taskCommitAssociations.createdAt), ); - } - const rows = store.db.prepare( - `SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`, - ).all(lineageId) as TaskCommitAssociationRow[]; - return rows.map((row) => normalizeTaskCommitAssociation({ - ...row, - note: row.note ?? undefined, - additions: row.additions ?? undefined, - deletions: row.deletions ?? undefined, - })); + return (rows as TaskCommitAssociationRow[]).map((row) => + normalizeTaskCommitAssociation({ + ...row, + note: row.note ?? undefined, + additions: row.additions ?? undefined, + deletions: row.deletions ?? undefined, + }), + ); } export async function replaceLegacyTaskCommitAssociationsImpl(store: TaskStore, @@ -69,31 +57,22 @@ export async function replaceLegacyTaskCommitAssociationsImpl(store: TaskStore, preserved (matched only on the three legacy sources), matching the SQLite path's IN-filter exactly. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - await layer.db - .delete(schema.project.taskCommitAssociations) - .where( - and( - eq(schema.project.taskCommitAssociations.taskLineageId, lineageId), - inArray(schema.project.taskCommitAssociations.matchedBy, [ - "legacy-task-id-trailer", - "legacy-subject", - "manual-reconciliation", - ]), - ), - ); - for (const association of associations) { - await store.upsertTaskCommitAssociation({ ...association, taskLineageId: lineageId }); - } - return; - } - const deleteStmt = store.db.prepare( - `DELETE FROM task_commit_associations WHERE taskLineageId = ? AND matchedBy IN ('legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')`, - ); - deleteStmt.run(lineageId); + const layer = store.asyncLayer!; + await layer.db + .delete(schema.project.taskCommitAssociations) + .where( + and( + eq(schema.project.taskCommitAssociations.taskLineageId, lineageId), + inArray(schema.project.taskCommitAssociations.matchedBy, [ + "legacy-task-id-trailer", + "legacy-subject", + "manual-reconciliation", + ]), + ), + ); for (const association of associations) { await store.upsertTaskCommitAssociation({ ...association, taskLineageId: lineageId }); } + return; } diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index a7d3a4feaa..73ecce0b4b 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -557,278 +557,9 @@ export async function createTaskImpl(store: TaskStore, input: TaskCreateInput, o // against PostgreSQL. The file-system operations (PROMPT.md, task.json) // remain the same. The allocator reservation + commit/abort lifecycle is // handled by the async allocator against the distributed_task_id tables. - if (store.backendMode) { - return store.createTaskBackend(input, options); - } - if (!input.description?.trim()) { - throw new Error("Description is required and cannot be empty"); - } - if (input.proposalClaimId) { - ensureSqliteProposalClaimUniqueness(store); - const existing = (await store.listTasks()).find((task) => task.proposalClaimId === input.proposalClaimId); - if (existing) { - options?.onProposalClaimConflict?.(existing); - return existing; - } - } - - const selfDefeatingDep = detectSelfDefeatingDependency(input.title, input.dependencies ?? []); - if (selfDefeatingDep) { - throw new SelfDefeatingDependencyError( - input.title?.trim() ?? "", - selfDefeatingDep.matchedVerb, - selfDefeatingDep.operandTaskId, - ); - } - - let resolvedSettings = options?.settings; - if (!resolvedSettings) { - try { - resolvedSettings = await store.getSettings(); - } catch { - resolvedSettings = {}; - } - } - - let onSummarize = options?.onSummarize; - if (!onSummarize && (resolvedSettings?.autoSummarizeTitles === true || input.summarize === true)) { - // Resolve a store-managed summarizer whenever title summarization is explicitly - // requested on this create call (agent tools set `summarize: true`) or globally - // enabled via autoSummarizeTitles. The title-summarizer model lanes MOVED to - // workflow settings (U4/KTD-7). - // At task-creation time there is no task/workflow yet, so resolve the - // project DEFAULT workflow's effective settings (unset default normalizes to - // builtin:coding) and overlay them so the moved lane reads from its new home; - // the global `titleSummarizerGlobal*` lane in `resolvedSettings` remains the - // fallback below. - let summarizerSettings: Partial = resolvedSettings ?? {}; - try { - const defaultWorkflowId = (await store.getDefaultWorkflowId()) ?? "builtin:coding"; - const effective = await resolveEffectiveSettingsById( - store, - defaultWorkflowId, - store.getWorkflowSettingsProjectId(), - ); - summarizerSettings = { ...summarizerSettings, ...(effective as Partial) }; - } catch { - // Never-throw: fall back to the base settings (global lane only). - } - const summarizerModel = resolveTitleSummarizerSettingsModel(summarizerSettings); - if (summarizerModel.provider && summarizerModel.modelId) { - onSummarize = async (description: string) => { - try { - return await summarizeTitle( - description, - store.getRootDir(), - summarizerModel.provider, - summarizerModel.modelId, - ); - } catch { - return null; - } - }; - } - } - - // Determine if we should try to summarize the title - const title = input.title?.trim() || undefined; - const shouldSummarize = - !title && - input.description.length > 200 && - (input.summarize === true || resolvedSettings?.autoSummarizeTitles === true); - const hasPendingSummarization = shouldSummarize && typeof onSummarize === "function"; - const shouldInvokeTaskCreatedHook = options?.invokeTaskCreatedHook !== false; - - // Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps - let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length - ? await store.resolveEnabledWorkflowSteps( - input.enabledWorkflowSteps, - await store.optionalGroupIdSet(input.workflowId), - ) - : undefined; - - // When a project default workflow is configured, new tasks inherit it - // (compiled to steps) ahead of the legacy default-on step behavior. - let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined; - let resolvedEntryColumn: string | undefined; - // U6/R3/KTD-4: an explicit create-time workflowId beats the project default. - // `null` is an explicit opt-out (no workflow), `string` materializes that - // workflow, `undefined` falls through to the default-workflow behavior below. - // Explicit enabledWorkflowSteps still wins over workflowId for trusted callers. - /* - FNXC:WorkflowCreation 2026-07-05-14:30: - User-facing task creation can submit a selected workflowId and optional-group - toggles together. The visible workflow selection is operator intent and must - persist as task_workflow_selection; enabledWorkflowSteps only overrides that - workflow's default optional-group seed. Mirrors the SQLite-path fix - (FNXC:WorkflowCreation 2026-06-28-23:09) that these PostgreSQL-cutover copies - predated: previously a create submitting BOTH workflowId and - enabledWorkflowSteps silently skipped the selection row. - */ - const explicitWorkflowId = input.workflowId; - if (explicitWorkflowId !== undefined) { - if (explicitWorkflowId === null) { - // Explicit "No workflow": skip default materialization entirely. - resolvedWorkflowSteps = undefined; - } else { - // Compile + materialize up front so unknown/fragment ids throw BEFORE - // the task row is created (no orphaned steps, no half-created task). - const selected = await store.materializeExplicitWorkflowSteps(explicitWorkflowId); - const explicitStepIds = input.enabledWorkflowSteps !== undefined - ? (resolvedWorkflowSteps ?? []) - : undefined; - resolvedWorkflowSteps = explicitStepIds ?? selected.stepIds; - resolvedEntryColumn = selected.entryColumnId; - pendingWorkflowSelection = { - workflowId: selected.workflowId, - stepIds: explicitStepIds ?? selected.stepIds, - }; - } - } else if (input.enabledWorkflowSteps === undefined) { - try { - const inherited = await store.materializeDefaultWorkflowSteps(); - if (inherited) { - resolvedWorkflowSteps = inherited.stepIds; - resolvedEntryColumn = inherited.entryColumnId; - pendingWorkflowSelection = inherited; - } - } catch (err) { - storeLog.warn("Failed to apply default workflow during task creation; falling back to default-on steps", { - phase: "createTask:default-workflow", - error: err instanceof Error ? err.message : String(err), - }); - } - - if (resolvedWorkflowSteps === undefined) { - try { - const allSteps = await store.listWorkflowSteps(); - const defaultOnSteps = allSteps - .filter((ws) => ws.enabled && ws.defaultOn) - .map((ws) => ws.id); - if (defaultOnSteps.length > 0) { - resolvedWorkflowSteps = defaultOnSteps; - } - } catch (err) { - storeLog.warn("Failed to auto-apply default workflow steps during task creation; auto-defaulting skipped", { - phase: "createTask:workflow-auto-default", - skippedAutoDefaulting: true, - error: err instanceof Error ? err.message : String(err), - descriptionLength: input.description.length, - }); - } - } - } else if (input.enabledWorkflowSteps.length === 0) { - // FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty - // optional-step selection must hydrate back as [], not undefined. - resolvedWorkflowSteps = []; - } - - let task: Task; - try { - task = await store.createTaskWithDistributedReservation(input, { - createTaskWithId: async (taskId) => { - await store.assertNoDependencyCycle(taskId, input.dependencies ?? [], "createTask"); - return store._createTaskInternal( - input, - title, - resolvedWorkflowSteps, - taskId, - { invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization, resolvedEntryColumn, onProposalClaimConflict: options?.onProposalClaimConflict }, - ); - }, - }); - } catch (err) { - // The task row was never created, so any default-workflow steps we - // materialized above would orphan with no task/selection pointing at them. - await store.cleanupOrphanedMaterializedSteps(pendingWorkflowSelection?.stepIds); - if (input.proposalClaimId && isTaskIdConflictError(err)) { - const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId); - if (existing) { - options?.onProposalClaimConflict?.(existing); - return existing; - } - } - throw err; - } - - // Record the inherited workflow selection now that the task row exists. - if (pendingWorkflowSelection) { - try { - await store.writeTaskWorkflowSelection(task.id, pendingWorkflowSelection.workflowId, pendingWorkflowSelection.stepIds); - } catch (err) { - storeLog.warn("Failed to record inherited workflow selection", { - taskId: task.id, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - if (hasPendingSummarization && shouldInvokeTaskCreatedHook) { - const id = task.id; - Promise.resolve().then(async () => { - try { - const generatedTitle = await onSummarize!(input.description); - const sanitizedTitle = sanitizeTitle(generatedTitle); - if (sanitizedTitle) { - await store.trackDeferredTaskCreatedWork(async () => { - if (store.closing) return; - const currentTask = store.readTaskFromDb(id); - if (currentTask && !currentTask.title) { - // FN-5077: normalizeTitleForTaskId may return null for dangling fragments; only persist usable titles. - const normalizedTitle = normalizeTitleForTaskId(sanitizedTitle, id); - if (normalizedTitle.title && !store.closing) { - await store.updateTask(id, { title: normalizedTitle.title }); - } - } - }); - } - } catch (err) { - const autoEnabled = resolvedSettings?.autoSummarizeTitles === true; - const errorMessage = err instanceof Error ? err.message : String(err); - storeLog.warn( - `Title summarization failed for task ${id}: ${errorMessage} (desc length: ${input.description.length}, auto-summarize: ${autoEnabled})`, - { - taskId: id, - descriptionLength: input.description.length, - autoSummarizeEnabled: autoEnabled, - error: errorMessage, - }, - ); - } - - await store.trackDeferredTaskCreatedWork(async () => { - if (store.closing) return; - let latestTask = task; - try { - const refreshed = store.readTaskFromDb(id); - if (refreshed) latestTask = refreshed; - } catch { - // Best-effort refresh; fall back to original task snapshot. - } - - if (store.closing) return; - try { - await store.invokeTaskCreatedHook(latestTask); - } catch (err) { - storeLog.warn("Deferred task-created hook failed", { - taskId: id, - error: err instanceof Error ? err.message : String(err), - }); - } - }); - }).catch((err) => { - const autoEnabled = resolvedSettings?.autoSummarizeTitles === true; - storeLog.error("Unexpected title summarization promise-chain failure", { - taskId: id, - descriptionLength: input.description.length, - autoSummarizeEnabled: autoEnabled, - error: err instanceof Error ? err.message : String(err), - }); - }); - } - - return task; - } + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:08: createTask is PostgreSQL-only. */ + return store.createTaskBackend(input, options); +} export async function createTaskWithReservedIdImpl(store: TaskStore, input: TaskCreateInput, options: { taskId: string; createdAt?: string; updatedAt?: string; prompt?: string; applyDefaultWorkflowSteps?: boolean; invokeTaskCreatedHook?: boolean; },): Promise { // U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins). @@ -1180,16 +911,12 @@ export async function resolveSameAgentDuplicateIntake(store: TaskStore, task: Ta matchedTaskId: tombstonedMatch.id, score: tombstonedMatch.score, tombstoneDeletedAt: tombstonedMatch.deletedAt, stickyWindowDays, }; - if (store.backendMode) { - await recordRunAuditEventAsync(store.asyncLayer!, { - taskId: task.id, agentId: "system", runId: `store:intake:resurrection-blocked:${task.id}`, - domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata, - }); - await softDeleteTaskRowAsync(store.asyncLayer!, task.id, new Date().toISOString()); - } else { - store.insertRunAuditEventRow({ taskId: task.id, domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata }); - store.deleteTaskById(task.id); - } + await recordRunAuditEventAsync(store.asyncLayer!, { + taskId: task.id, agentId: "system", runId: `store:intake:resurrection-blocked:${task.id}`, + domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata, + }); + await softDeleteTaskRowAsync(store.asyncLayer!, task.id, new Date().toISOString()); + if (store.isWatching) store.taskCache.delete(task.id); const taskDir = store.taskDir(task.id); if (existsSync(taskDir)) await rm(taskDir, { recursive: true, force: true }); diff --git a/packages/core/src/task-store/task-id-integrity.ts b/packages/core/src/task-store/task-id-integrity.ts index f657c913b8..7c00c71d04 100644 --- a/packages/core/src/task-store/task-id-integrity.ts +++ b/packages/core/src/task-store/task-id-integrity.ts @@ -13,7 +13,6 @@ import { TaskStore } from "../store.js"; import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { ArchiveDatabase } from "../archive-db.js"; -import { validateBranchGroupBranchName } from "../branch-assignment.js"; import { CentralCore } from "../central-core.js"; import { Database, fromJson, toJsonNullable } from "../db.js"; import { reconcileTaskIdState, resolveLocalNodeId } from "../distributed-task-id.js"; @@ -31,7 +30,7 @@ import { TASK_PERSIST_SQL_COLUMNS, TASK_UPSERT_SQL_ASSIGNMENTS, type TaskRow } f import { purgeTaskWorkflowSelectionRowsAsyncImpl } from "./workflow-definitions.js"; import { ConfigRow } from "./row-types.js"; import { ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT } from "./serialization.js"; -import { ActivityLogEntry, ArchiveAgentLogMode, ArchivedTaskEntry, BoardConfig, BranchGroup, BranchGroupCreateInput, Column, GoalCitationInput, GoalCitationSurface, RunAuditEventInput, Settings, Task, TaskCreateInput } from "../types.js"; +import { ActivityLogEntry, ArchiveAgentLogMode, ArchivedTaskEntry, BoardConfig, BranchGroup, BranchGroupCreateInput, GoalCitationInput, GoalCitationSurface, RunAuditEventInput, Settings, Task, TaskCreateInput } from "../types.js"; import { resolveAllOptionalGroupIds } from "../workflow-optional-steps.js"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; @@ -56,44 +55,16 @@ Backend mode intentionally has no synchronous SQLite escape hatch. Name the AsyncDataLayer route and authoring guide in this failure so plugin authors fix the durable-data boundary rather than adding a backend-specific fallback. */ -export function dbImpl(store: TaskStore): Database { - if (store.backendMode) { - throw new Error( - "TaskStore.db: SQLite Database is not available in backend mode (PostgreSQL/AsyncDataLayer injected). Use ctx.taskStore.getAsyncLayer() / an async store — see docs/PLUGIN_AUTHORING.md", - ); - } - if (!store._db) { - const db = new Database(store.fusionDir, { inMemory: false }); - try { - db.init(); - } catch (error) { - db.close(); - throw error; - } - store._db = db; - store.reconcileDistributedTaskIdStateOnOpen(); - } - return store._db; +export function dbImpl(_store: TaskStore): Database { + throw new Error( + "TaskStore.db: SQLite Database is not available in backend mode (PostgreSQL/AsyncDataLayer injected). Use ctx.taskStore.getAsyncLayer() / an async store — see docs/PLUGIN_AUTHORING.md", + ); } -export function archiveDbImpl(store: TaskStore): ArchiveDatabase { - if (store.backendMode) { - throw new Error( - "TaskStore.archiveDb: SQLite ArchiveDatabase is not available in backend mode (AsyncDataLayer injected)", - ); - } - if (!store._archiveDb) { - const db = new ArchiveDatabase(store.fusionDir, { inMemory: false }); - try { - db.init(); - } catch (error) { - db.close(); - throw error; - } - store._archiveDb = db; - store.migrateLegacyArchiveEntriesToArchiveDb(); - } - return store._archiveDb; +export function archiveDbImpl(_store: TaskStore): ArchiveDatabase { + throw new Error( + "TaskStore.archiveDb: SQLite ArchiveDatabase is not available in backend mode (AsyncDataLayer injected)", + ); } export function buildTaskIdIntegrityFallbackReportImpl(_store: TaskStore): TaskIdIntegrityReport { @@ -323,9 +294,7 @@ export function readTaskFromDbImpl(store: TaskStore, id: string, options?: { act } export async function getMergeQueuedTaskIdsAsyncImpl(store: TaskStore): Promise> { - if (!store.backendMode) { - return store.getMergeQueuedTaskIds(); - } + const layer = store.asyncLayer!; const rows = await layer.db .select({ taskId: schema.project.mergeQueue.taskId }) @@ -333,22 +302,14 @@ export async function getMergeQueuedTaskIdsAsyncImpl(store: TaskStore): Promise< return new Set(rows.map((row) => row.taskId)); } -export function isTaskIdPresentInArchivedTasksTableImpl(store: TaskStore, id: string): boolean { +export function isTaskIdPresentInArchivedTasksTableImpl(_store: TaskStore, _id: string): boolean { /* FNXC:IncompletePgPorts 2026-07-26-20:30: Sync archive-table probe remains SQLite-only. PostgreSQL callers must use isTaskIdPresentInArchivedTasksTableAsyncImpl / taskIdExistsAnywhere (async). Returning false here avoids opening the removed SQLite Database stub. */ - if (store.backendMode) { - return false; - } - try { - const row = store.db.prepare("SELECT 1 as found FROM archivedTasks WHERE id = ? LIMIT 1").get(id) as { found?: number } | undefined; - return row?.found === 1; - } catch { - return false; - } + return false; } /* @@ -358,9 +319,7 @@ archive.archived_tasks (cold). Both must reserve task IDs the same way the legacy SQLite archivedTasks / archive.db tables did. */ export async function isTaskIdPresentInArchivedTasksTableAsyncImpl(store: TaskStore, id: string): Promise { - if (!store.backendMode) { - return store.isTaskIdPresentInArchivedTasksTable(id); - } + const layer = store.asyncLayer!; const partition = projectPartition(layer.projectId); const [projectArchive, coldArchive] = await Promise.all([ @@ -390,19 +349,9 @@ export async function taskIdExistsAnywhereImpl(store: TaskStore, id: string): Pr Backend-mode: live/soft-deleted rows via readTaskRow, then warm+cold archive tables so IDs stay permanently reserved (FN-5105 parity with SQLite). */ - if (store.backendMode) { - const row = await readTaskRow(store.asyncLayer!, id, { includeDeleted: true }); - if (row) return true; - return isTaskIdPresentInArchivedTasksTableAsyncImpl(store, id); - } - // FN-5105: include soft-deleted rows so IDs remain permanently reserved. - if (store.readTaskFromDb(id, { includeDeleted: true })) { - return true; - } - if (store.isTaskIdPresentInArchivedTasksTable(id)) { - return true; - } - return store.archiveDb.get(id) !== undefined; + const row = await readTaskRow(store.asyncLayer!, id, { includeDeleted: true }); + if (row) return true; + return isTaskIdPresentInArchivedTasksTableAsyncImpl(store, id); } export async function maybeResolveTombstonedTaskIdImpl(store: TaskStore, @@ -416,18 +365,14 @@ export async function maybeResolveTombstonedTaskIdImpl(store: TaskStore, * sync readTaskFromDb, and hard-delete via the layer. This unblocks * createTaskWithReservedId in backend mode (VAL-DATA-005/006). */ - let existing: { deletedAt?: string | null; allowResurrection?: boolean | number | null } | undefined; - if (store.backendMode) { - const row = await readTaskRow(store.asyncLayer!, id, { includeDeleted: true }); - existing = row - ? { - deletedAt: row.deletedAt as string | null | undefined, - allowResurrection: row.allowResurrection as boolean | number | null | undefined, - } - : undefined; - } else { - existing = store.readTaskFromDb(id, { includeDeleted: true }); - } + const row = await readTaskRow(store.asyncLayer!, id, { includeDeleted: true }); + const existing: { deletedAt?: string | null; allowResurrection?: boolean | number | null } | undefined = row + ? { + deletedAt: row.deletedAt as string | null | undefined, + allowResurrection: row.allowResurrection as boolean | number | null | undefined, + } + : undefined; + if (!existing?.deletedAt) return; const allowResurrection = existing.allowResurrection === true || existing.allowResurrection === 1; @@ -435,14 +380,16 @@ export async function maybeResolveTombstonedTaskIdImpl(store: TaskStore, // FNXC:FixPgTestsAndCi 2026-06-26-09:35: // Use the async purge variant in backend mode so workflow_steps children // are deleted before the parent task row is hard-deleted. - if (store.backendMode) { - await purgeTaskWorkflowSelectionRowsAsyncImpl(store, id); - await store.asyncLayer!.db.delete(schema.project.tasks).where(eq(schema.project.tasks.id, id)); - } else { - store.purgeTaskWorkflowSelectionRows(id); - store.db.prepare("DELETE FROM tasks WHERE id = ?").run(id); - store.db.bumpLastModified(); - } + await purgeTaskWorkflowSelectionRowsAsyncImpl(store, id); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Project-scope hard-delete after tombstone resurrection so another project's matching id is untouched. + */ + const layer = store.asyncLayer!; + const delConds = [eq(schema.project.tasks.id, id)]; + if (layer.projectId) delConds.push(eq(schema.project.tasks.projectId, layer.projectId)); + await layer.db.delete(schema.project.tasks).where(and(...delConds)); + return; } @@ -451,35 +398,20 @@ export async function maybeResolveTombstonedTaskIdImpl(store: TaskStore, // insertRunAuditEventRow is sync and uses store.db (unavailable in backend // mode). Use the async recordRunAuditEvent helper so the resurrection-blocked // audit row is persisted against PostgreSQL (VAL-DATA-006 forensic surface). - if (store.backendMode) { - await recordRunAuditEventAsync(store.asyncLayer!, { - taskId: id, - agentId: "system", - runId: "unknown", - domain: "database", - mutationType: "task:resurrection-blocked", - target: id, - metadata: { - id, - deletedAt: existing.deletedAt, - allowResurrection, - operation, - }, - }); - } else { - store.insertRunAuditEventRow({ - taskId: id, - domain: "database", - mutationType: "task:resurrection-blocked", - target: id, - metadata: { - id, - deletedAt: existing.deletedAt, - allowResurrection, - operation, - }, - }); - } + await recordRunAuditEventAsync(store.asyncLayer!, { + taskId: id, + agentId: "system", + runId: "unknown", + domain: "database", + mutationType: "task:resurrection-blocked", + target: id, + metadata: { + id, + deletedAt: existing.deletedAt, + allowResurrection, + operation, + }, + }); throw new TombstonedTaskResurrectionError(id, existing.deletedAt, allowResurrection); } @@ -491,16 +423,8 @@ export function isTaskArchivedImpl(store: TaskStore, id: string): boolean { from async callers. In backend mode use the in-memory task cache when the row is already hydrated; otherwise false (caller should have used async). */ - if (store.backendMode) { - const cached = store.taskCache.get(id); - return cached?.column === "archived"; - } - const row = store.db.prepare(`SELECT "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(id) as { column: Column } | undefined; - if (row) { - return row.column === "archived"; - } - - return store.archiveDb.get(id) !== undefined; + const cached = store.taskCache.get(id); + return cached?.column === "archived"; } /* @@ -509,9 +433,7 @@ Authoritative archived check for PostgreSQL: live column gate via getLiveTaskColumn, plus cold archive.archived_tasks presence. */ export async function isTaskArchivedAsyncImpl(store: TaskStore, id: string): Promise { - if (!store.backendMode) { - return store.isTaskArchived(id); - } + const layer = store.asyncLayer!; const live = await getLiveTaskColumn(layer.db, id, layer.projectId); // getLiveTaskColumn returns "archived" for archived OR soft-deleted rows. @@ -541,17 +463,8 @@ 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, layer.projectId); - } - const rows = store.db - .prepare( - `SELECT id FROM tasks WHERE sourceParentTaskId = ? AND id != ? AND "column" != 'archived' AND ${TaskStore.ACTIVE_TASKS_WHERE}`, - ) - .all(id, id) as Array<{ id: string }>; - - return rows.map((row) => row.id); + const layer = store.asyncLayer!; + return findLiveLineageChildrenAsync(layer.db, id, layer.projectId); } export function recordActivityFromListenerImpl(store: TaskStore, @@ -725,42 +638,30 @@ export async function atomicCreateTaskJsonImpl(store: TaskStore, dir: string, ta insert in one async transaction (parity with the sync transactionImmediate block below), with unique_violation normalized to "Task ID already exists". */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const context = store.createTaskPersistSerializationContext(task); - let backendDeletedAt: string | undefined; - try { - await layer.transactionImmediate(async (tx) => { - const pgRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); - if (pgRow) { - backendDeletedAt = store.getSoftDeletedWriteConflict(id, task, store.pgRowToTaskRow(pgRow)); - if (backendDeletedAt) return; - } - await insertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); - }); - } catch (error) { - if (isTaskIdConflictError(error)) { - store.logTaskCreateConflict(task, operation, error); - throw new Error(`Task ID already exists: ${task.id}`); + const layer = store.asyncLayer!; + const context = store.createTaskPersistSerializationContext(task); + let backendDeletedAt: string | undefined; + try { + await layer.transactionImmediate(async (tx) => { + const pgRow = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); + if (pgRow) { + backendDeletedAt = store.getSoftDeletedWriteConflict(id, task, store.pgRowToTaskRow(pgRow)); + if (backendDeletedAt) return; } - throw error; + await insertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); + }); + } catch (error) { + if (isTaskIdConflictError(error)) { + store.logTaskCreateConflict(task, operation, error); + throw new Error(`Task ID already exists: ${task.id}`); } - if (backendDeletedAt) { - store.throwSoftDeletedWriteBlocked(id, backendDeletedAt, operation); - } - await store.writeTaskJsonFile(dir, task); - return; + throw error; } - let deletedAt: string | undefined; - store.db.transactionImmediate(() => { - deletedAt = store.getSoftDeletedWriteConflict(id, task); - if (deletedAt) return; - store.insertTaskWithFtsRecovery(task, operation); - }); - if (deletedAt) { - store.throwSoftDeletedWriteBlocked(id, deletedAt, operation); + if (backendDeletedAt) { + store.throwSoftDeletedWriteBlocked(id, backendDeletedAt, operation); } await store.writeTaskJsonFile(dir, task); + return; } export async function readConfigImpl(store: TaskStore): Promise { @@ -837,26 +738,11 @@ export function toBuiltInWorkflowStepImpl(store: TaskStore, template: import(".. }; } -export function getLegacyWorkflowStepSnapshotImpl(store: TaskStore, id: string, templateId?: string): Record | undefined { +export function getLegacyWorkflowStepSnapshotImpl(_store: TaskStore, _id: string, _templateId?: string): Record | undefined { // FNXC:PostgresOnlyDataAccess 2026-07-16-12:55: the legacy snapshot lives // only in the pre-migration SQLite config.workflowSteps JSON blob; a // PostgreSQL deployment has no legacy snapshot, so overrides never apply. - if (store.backendMode) { - return undefined; - } - const row = store.db - .prepare("SELECT workflowSteps FROM config WHERE id = 1") - .get() as { workflowSteps?: string | null } | undefined; - const legacySteps = fromJson>>(row?.workflowSteps); - if (!Array.isArray(legacySteps)) { - return undefined; - } - - return legacySteps.find((legacy) => { - if (!legacy || typeof legacy !== "object") return false; - if (legacy.id === id) return true; - return Boolean(templateId && legacy.templateId === templateId); - }); + return undefined; } export function applyLegacyWorkflowStepOverridesImpl(store: TaskStore, step: import("../types.js").WorkflowStep): import("../types.js").WorkflowStep { @@ -965,34 +851,6 @@ export async function invokeTaskCreatedHookImpl(store: TaskStore, task: Task): P } export async function createBranchGroupImpl(store: TaskStore, input: BranchGroupCreateInput): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return createBranchGroupAsync(layer.db, input); - } - // Fix #11: reject injection-shaped branch names at the persistence boundary - // so they can never reach a downstream git/shell sink (coordinator, merger). - validateBranchGroupBranchName(input.branchName); - const now = Date.now(); - const id = store.generateBranchGroupId(); - store.db.prepare(` - INSERT INTO branch_groups (id, sourceType, sourceId, branchName, worktreePath, autoMerge, prState, prUrl, prNumber, status, createdAt, updatedAt, closedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, - input.sourceType, - input.sourceId, - input.branchName, - input.worktreePath ?? null, - input.autoMerge ? 1 : 0, - input.prState ?? "none", - input.prUrl ?? null, - input.prNumber ?? null, - input.status ?? "open", - now, - now, - input.closedAt ?? null, - ); - store.db.bumpLastModified(); - const created = await store.getBranchGroup(id); - return created!; + const layer = store.asyncLayer!; + return createBranchGroupAsync(layer.db, input); } diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index efb316a03e..8a03390d5f 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -18,13 +18,12 @@ import {randomUUID} from "node:crypto"; import {mkdir, readFile, writeFile, rename, unlink} from "node:fs/promises"; import {join} from "node:path"; import {existsSync} from "node:fs"; -import type {Task, TaskCreateInput, TaskAttachment, BoardConfig, Column, ActivityLogEntry, ActivityEventType, Artifact, ArtifactCreateInput, RunMutationContext, MergeQueueEntry, BranchGroup, BranchGroupUpdate, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemKind, PrEntity, PrEntityUpdate} from "../types.js"; +import type {Task, TaskCreateInput, TaskAttachment, BoardConfig, ActivityLogEntry, ActivityEventType, Artifact, ArtifactCreateInput, RunMutationContext, MergeQueueEntry, BranchGroup, BranchGroupUpdate, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemKind, PrEntity, PrEntityUpdate} from "../types.js"; import {COLUMNS} from "../types.js"; import {resolveEntryColumnId} from "../workflow-reconciliation.js"; import {BUILTIN_CODING_WORKFLOW_IR} from "../builtin-coding-workflow-ir.js"; import {validateSettingValuePatch, WorkflowSettingRejectionError} from "../workflow-settings.js"; import "../builtin-traits.js"; -import {validateBranchGroupBranchName} from "../branch-assignment.js"; import {toJson} from "../db.js"; import {resolveSameAgentDuplicateIntake} from "./task-creation.js"; import {type TaskRow, TASK_COLUMN_DESCRIPTORS} from "../task-store/persistence.js"; @@ -41,8 +40,6 @@ import {recordCompletionHandoff as recordCompletionHandoffAsync, getCompletionHa 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"; -import type {MergeQueueRow, CompletionHandoffMarkerRow, ActivityLogRow} from "../task-store/row-types.js"; import {appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration} from "../async-configuration-revision-store.js"; import {readProjectConfig, writeProjectConfig} from "./async-settings.js"; import {publishSettingsUpdated} from "./settings-ops.js"; @@ -119,43 +116,21 @@ export function getSoftDeletedWriteConflictImpl(store: TaskStore, id: string, ta export async function readTaskJsonImpl(store: TaskStore, dir: string): Promise { const id = store.getTaskIdFromDir(dir); - // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-15:40: - // Backend mode: read the task row via the async helper directly (without - // acquiring the task lock, since this method is often called INSIDE - // withTaskLock). Using getTask() here would deadlock because getTask - // also acquires withTaskLock. Instead, we read the raw row and convert it - // using the same pgRowToTaskRow + rowToTask pipeline. The file-system - // fallback is still used if the DB read returns nothing. - if (store.backendMode) { - const layer = store.asyncLayer!; - const pgRow = await readTaskRowAsync(layer, id, { includeDeleted: true }); - if (pgRow) { - if (pgRow.deletedAt) { - throw new TaskDeletedError(id, pgRow.deletedAt as string); - } - return store.rowToTask(store.pgRowToTaskRow(pgRow)); - } - // Fallback to file-based reading. - const filePath = join(dir, "task.json"); - const raw = await readFile(filePath, "utf-8"); - try { - return store.normalizeTaskFromDisk(JSON.parse(raw) as Task); - } catch (err) { - throw new Error( - `Failed to parse task.json at ${filePath}: ${(err as Error).message}`, - ); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:30: + readTaskJson is PostgreSQL + task.json only. The SQLite store.readTaskFromDb arm is deleted. + Read the task row via async helper without acquiring withTaskLock (callers often already hold it; getTask would deadlock). + File-system task.json remains the fallback when no live DB row exists (not SQLite authority). + FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-15:40: same lock/deadlock constraint as before. + */ + const layer = store.asyncLayer!; + const pgRow = await readTaskRowAsync(layer, id, { includeDeleted: true }); + if (pgRow) { + if (pgRow.deletedAt) { + throw new TaskDeletedError(id, pgRow.deletedAt as string); } + return store.rowToTask(store.pgRowToTaskRow(pgRow)); } - - const task = store.readTaskFromDb(id); - if (task) return task; - - const deletedTask = store.readTaskFromDb(id, { includeDeleted: true }); - if (deletedTask?.deletedAt) { - throw new TaskDeletedError(id, deletedTask.deletedAt); - } - - // Fallback to file-based reading (for legacy compatibility when no DB row exists). const filePath = join(dir, "task.json"); const raw = await readFile(filePath, "utf-8"); try { @@ -218,92 +193,14 @@ export async function _maybeAutoArchiveSameAgentDuplicateBackendImpl(store: Task } export async function updateBranchGroupImpl(store: TaskStore, id: string, patch: BranchGroupUpdate): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return updateBranchGroupAsync(layer.db, id, patch); - } - const current = await store.getBranchGroup(id); - if (!current) { - throw new Error(`Branch group ${id} not found`); - } - // Fix #11: a rename must reject injection-shaped branch names at the same - // persistence boundary as createBranchGroup, otherwise a crafted ref could - // still reach the downstream git/PR flow via an update. - if (patch.branchName !== undefined) { - validateBranchGroupBranchName(patch.branchName); - } - const nextStatus = patch.status ?? current.status; - const now = Date.now(); - const nextClosedAt = patch.closedAt === null - ? null - : patch.closedAt ?? (nextStatus !== "open" && current.status === "open" ? now : current.closedAt ?? null); - - store.db.prepare(` - UPDATE branch_groups - SET sourceId = ?, branchName = ?, worktreePath = ?, autoMerge = ?, prState = ?, prUrl = ?, prNumber = ?, status = ?, updatedAt = ?, closedAt = ? - WHERE id = ? - `).run( - patch.sourceId ?? current.sourceId, - patch.branchName ?? current.branchName, - patch.worktreePath === null ? null : (patch.worktreePath ?? current.worktreePath ?? null), - patch.autoMerge === undefined ? (current.autoMerge ? 1 : 0) : (patch.autoMerge ? 1 : 0), - patch.prState ?? current.prState, - patch.prUrl === null ? null : (patch.prUrl ?? current.prUrl ?? null), - patch.prNumber === null ? null : (patch.prNumber ?? current.prNumber ?? null), - nextStatus, - now, - nextClosedAt, - id, - ); - store.db.bumpLastModified(); - const updated = await store.getBranchGroup(id); - return updated!; - } + const layer = store.asyncLayer!; + return updateBranchGroupAsync(layer.db, id, patch); +} export async function updatePrEntityImpl(store: TaskStore, id: string, patch: PrEntityUpdate): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return updatePrEntityAsync(layer.db, id, patch); - } - const current = await store.getPrEntity(id); - if (!current) throw new Error(`PR entity ${id} not found`); - const nextState = patch.state ?? current.state; - const now = Date.now(); - const isTerminal = nextState === "merged" || nextState === "closed"; - const nextClosedAt = - patch.closedAt === null - ? null - : patch.closedAt ?? (isTerminal && current.closedAt === undefined ? now : current.closedAt ?? null); - const orCurrent = (v: T | null | undefined, cur: T | undefined): T | null => - v === null ? null : v ?? cur ?? null; - store.db - .prepare( - `UPDATE pull_requests SET - state = ?, prNumber = ?, prUrl = ?, headOid = ?, mergeable = ?, - checksRollup = ?, reviewDecision = ?, autoMerge = ?, unverified = ?, - failureReason = ?, responseRounds = ?, updatedAt = ?, closedAt = ? - WHERE id = ?`, - ) - .run( - nextState, - orCurrent(patch.prNumber, current.prNumber), - orCurrent(patch.prUrl, current.prUrl), - orCurrent(patch.headOid, current.headOid), - orCurrent(patch.mergeable, current.mergeable), - orCurrent(patch.checksRollup, current.checksRollup), - patch.reviewDecision === undefined ? current.reviewDecision ?? null : patch.reviewDecision, - patch.autoMerge === undefined ? (current.autoMerge ? 1 : 0) : patch.autoMerge ? 1 : 0, - patch.unverified === undefined ? (current.unverified ? 1 : 0) : patch.unverified ? 1 : 0, - orCurrent(patch.failureReason, current.failureReason), - patch.responseRounds ?? current.responseRounds, - now, - nextClosedAt, - id, - ); - store.db.bumpLastModified(); - const updated = await store.getPrEntity(id); - return updated!; - } + const layer = store.asyncLayer!; + return updatePrEntityAsync(layer.db, id, patch); +} export async function listTasksForGithubTrackingReconcileImpl(store: TaskStore, options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> { const reconcileScanLimit = 200; @@ -320,79 +217,39 @@ export async function listTasksForGithubTrackingReconcileImpl(store: TaskStore, fallback is a separate async subsystem (AsyncArchiveLineage) not wired through the sync archiveDb, so it is skipped in backend mode. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const trackedDeletedFilter = and( - isNotNull(schema.project.tasks.deletedAt), - isNotNull(schema.project.tasks.githubTracking), - ); - const countRows = await layer.db - .select({ count: sql`count(*)` }) - .from(schema.project.tasks) - .where(trackedDeletedFilter); - const deletedCount = Number(countRows[0]?.count ?? 0); - const deletedOffset = Math.min(offset, deletedCount); - const deletedRowsRaw = await layer.db - .select() - .from(schema.project.tasks) - .where(trackedDeletedFilter) - .orderBy(asc(schema.project.tasks.updatedAt)) - .limit(limit) - .offset(deletedOffset); - const deletedTasks = deletedRowsRaw.map((row) => { - const task = store.rowToTask(store.pgRowToTaskRow(row as unknown as Record)); - task.timedExecutionMs = store.computeTimedExecutionMs(task.log); - task.log = []; - return task; - }); - const totalCount = deletedCount; - const hasMore = offset + limit < totalCount; - return { tasks: deletedTasks, hasMore }; - } - const selectClause = store.getTaskSelectClause(true); - - // FN-5577: GitHub tracking reconciliation must inspect soft-deleted rows, - // so this query intentionally bypasses ACTIVE_TASKS_WHERE. - const deletedTotal = store.db.prepare( - "SELECT COUNT(*) as count FROM tasks WHERE \"deletedAt\" IS NOT NULL AND \"githubTracking\" IS NOT NULL", - ).get() as { count: number } | undefined; - const deletedCount = Number(deletedTotal?.count ?? 0); - + const layer = store.asyncLayer!; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Project-scope GitHub-tracking reconcile so soft-deleted rows from other projects are not scanned. + */ + const trackedDeletedFilter = and( + isNotNull(schema.project.tasks.deletedAt), + isNotNull(schema.project.tasks.githubTracking), + taskProjectScope(layer), + ); + const countRows = await layer.db + .select({ count: sql`count(*)` }) + .from(schema.project.tasks) + .where(trackedDeletedFilter); + const deletedCount = Number(countRows[0]?.count ?? 0); const deletedOffset = Math.min(offset, deletedCount); - const deletedRows = store.db.prepare( - `SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ? OFFSET ?`, - ).all(limit, deletedOffset) as unknown as TaskRow[]; - - const deletedTasks = deletedRows.map((row) => { - const task = store.rowToTask(row); + const deletedRowsRaw = await layer.db + .select() + .from(schema.project.tasks) + .where(trackedDeletedFilter) + .orderBy(asc(schema.project.tasks.updatedAt)) + .limit(limit) + .offset(deletedOffset); + const deletedTasks = deletedRowsRaw.map((row) => { + const task = store.rowToTask(store.pgRowToTaskRow(row as unknown as Record)); task.timedExecutionMs = store.computeTimedExecutionMs(task.log); task.log = []; return task; }); - - let archivedTasks: Task[] = []; - let archivedCount = 0; - try { - const archivedCandidates = store.archiveDb - .list() - .map((entry) => store.archiveEntryToTask(entry, true)) - .filter((task) => Boolean(task.githubTracking)); - - archivedCount = archivedCandidates.length; - const archivedOffset = Math.max(0, offset - deletedCount); - const remainingLimit = Math.max(0, limit - deletedTasks.length); - archivedTasks = remainingLimit > 0 - ? archivedCandidates.slice(archivedOffset, archivedOffset + remainingLimit) - : []; - } catch { - archivedTasks = []; - archivedCount = 0; - } - - const totalCount = deletedCount + archivedCount; + const totalCount = deletedCount; const hasMore = offset + limit < totalCount; - return { tasks: [...deletedTasks, ...archivedTasks], hasMore }; - } + return { tasks: deletedTasks, hasMore }; +} /** * FNXC:GitLabTracking 2026-07-02-00:00: @@ -406,42 +263,46 @@ export async function listTasksForGitlabTrackingReconcileImpl(store: TaskStore, const offset = Math.max(0, options?.offset ?? 0); const limit = Math.max(0, options?.limit ?? reconcileScanLimit); - if (store.backendMode) { - const layer = store.asyncLayer!; - const trackedDeletedFilter = and( - isNotNull(schema.project.tasks.gitlabTracking), - isNotNull(schema.project.tasks.deletedAt), - ); - const countRows = await layer.db - .select({ count: sql`count(*)` }) - .from(schema.project.tasks) - .where(trackedDeletedFilter); - const deletedCount = Number(countRows[0]?.count ?? 0); - const deletedOffset = Math.min(offset, deletedCount); - const deletedRowsRaw = await layer.db - .select() - .from(schema.project.tasks) - .where(trackedDeletedFilter) - .orderBy(asc(schema.project.tasks.updatedAt)) - .limit(limit) - .offset(deletedOffset); - const deletedTasks = deletedRowsRaw.map((row) => { - const raw = row as unknown as Record; - // FNXC:GitLabTracking 2026-07-16-05:36: rowToTask now hydrates GitLab - // tracking through the shared persistence registry, so reconcile uses - // the same authoritative mapper as every other live-task read. - const task = store.rowToTask(store.pgRowToTaskRow(raw)); - task.timedExecutionMs = store.computeTimedExecutionMs(task.log); - task.log = []; - return task; - }); - const totalCount = deletedCount; - const hasMore = offset + limit < totalCount; - return { tasks: deletedTasks, hasMore }; - } - // FNXC:SqliteFinalRemoval 2026-07-12-00:00: non-backend (SQLite) path is unreachable after - // VAL-REMOVAL-005; throw so a misconfigured caller is not silently fed empty data. - throw new Error("listTasksForGitlabTrackingReconcile requires backend mode (PostgreSQL)."); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:32: + GitLab tracking reconcile is PostgreSQL-only; the non-backend throw arm is deleted. + */ + const layer = store.asyncLayer!; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Project-scope GitLab-tracking reconcile. + */ + const trackedDeletedFilter = and( + isNotNull(schema.project.tasks.gitlabTracking), + isNotNull(schema.project.tasks.deletedAt), + taskProjectScope(layer), + ); + const countRows = await layer.db + .select({ count: sql`count(*)` }) + .from(schema.project.tasks) + .where(trackedDeletedFilter); + const deletedCount = Number(countRows[0]?.count ?? 0); + const deletedOffset = Math.min(offset, deletedCount); + const deletedRowsRaw = await layer.db + .select() + .from(schema.project.tasks) + .where(trackedDeletedFilter) + .orderBy(asc(schema.project.tasks.updatedAt)) + .limit(limit) + .offset(deletedOffset); + const deletedTasks = deletedRowsRaw.map((row) => { + const raw = row as unknown as Record; + // FNXC:GitLabTracking 2026-07-16-05:36: rowToTask now hydrates GitLab + // tracking through the shared persistence registry, so reconcile uses + // the same authoritative mapper as every other live-task read. + const task = store.rowToTask(store.pgRowToTaskRow(raw)); + task.timedExecutionMs = store.computeTimedExecutionMs(task.log); + task.log = []; + return task; + }); + const totalCount = deletedCount; + const hasMore = offset + limit < totalCount; + return { tasks: deletedTasks, hasMore }; } export async function listTasksModifiedSinceImpl2(store: TaskStore, since: string, limit?: number, opts?: { includeArchived?: boolean },): Promise<{ tasks: Task[]; hasMore: boolean }> { @@ -464,86 +325,44 @@ export async function renewCheckoutLeaseImpl(store: TaskStore, taskId: string, u * In backend mode, read-check-update inside a transactionImmediate so the * soft-delete resurrection guard (R7) and the active-task filter both hold. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const dir = store.taskDir(taskId); - const outcome = await layer.transactionImmediate(async (tx) => { - const row = await readTaskRowInTransaction(tx, taskId, { includeDeleted: true }, layer.projectId); - if (row?.deletedAt) { - return { deletedAt: row.deletedAt as string, current: undefined }; - } - const result = await tx - .update(schema.project.tasks) - .set({ - checkoutRunId: update.checkoutRunId, - checkoutLeaseRenewedAt: update.checkoutLeaseRenewedAt, - updatedAt: update.checkoutLeaseRenewedAt, - }) - .where(and(eq(schema.project.tasks.id, taskId), isNull(schema.project.tasks.deletedAt))); - if (result.length === 0) { - return { deletedAt: undefined, current: undefined }; - } - const fresh = await readTaskRowInTransaction(tx, taskId, undefined, layer.projectId); - return { deletedAt: undefined, current: fresh }; - }); - - if (outcome.deletedAt) { - store.throwSoftDeletedWriteBlocked(taskId, outcome.deletedAt, "renewCheckoutLease", { - timestamp: update.checkoutLeaseRenewedAt, - }); - } - if (!outcome.current) { - throw new Error(`Task ${taskId} not found`); - } - const current = store.rowToTask(store.pgRowToTaskRow(outcome.current)); - await store.writeTaskJsonFile(dir, current); - if (store.isWatching) { - store.taskCache.set(taskId, { ...current }); - } - store.emitTaskLifecycleEventSafely("task:updated", [current]); - return current; - } + const layer = store.asyncLayer!; const dir = store.taskDir(taskId); - let deletedAt: string | undefined; - let current: Task | undefined; - store.db.transactionImmediate(() => { - const row = store.readTaskRowFromDb(taskId, { includeDeleted: true }); + const outcome = await layer.transactionImmediate(async (tx) => { + const row = await readTaskRowInTransaction(tx, taskId, { includeDeleted: true }, layer.projectId); if (row?.deletedAt) { - deletedAt = row.deletedAt; - return; + return { deletedAt: row.deletedAt as string, current: undefined }; } - - const result = store.db.prepare(` - UPDATE tasks - SET checkoutRunId = ?, checkoutLeaseRenewedAt = ?, updatedAt = ? - WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE} - `).run(update.checkoutRunId, update.checkoutLeaseRenewedAt, update.checkoutLeaseRenewedAt, taskId) as { changes: number }; - - if (result.changes === 0) { - return; + const result = await tx + .update(schema.project.tasks) + .set({ + checkoutRunId: update.checkoutRunId, + checkoutLeaseRenewedAt: update.checkoutLeaseRenewedAt, + updatedAt: update.checkoutLeaseRenewedAt, + }) + .where(and(eq(schema.project.tasks.id, taskId), isNull(schema.project.tasks.deletedAt))); + if (result.length === 0) { + return { deletedAt: undefined, current: undefined }; } - - store.db.bumpLastModified(); - current = store.readTaskFromDb(taskId); + const fresh = await readTaskRowInTransaction(tx, taskId, undefined, layer.projectId); + return { deletedAt: undefined, current: fresh }; }); - if (deletedAt) { - store.throwSoftDeletedWriteBlocked(taskId, deletedAt, "renewCheckoutLease", { + if (outcome.deletedAt) { + store.throwSoftDeletedWriteBlocked(taskId, outcome.deletedAt, "renewCheckoutLease", { timestamp: update.checkoutLeaseRenewedAt, }); } - - if (!current) { + if (!outcome.current) { throw new Error(`Task ${taskId} not found`); } - + const current = store.rowToTask(store.pgRowToTaskRow(outcome.current)); await store.writeTaskJsonFile(dir, current); if (store.isWatching) { store.taskCache.set(taskId, { ...current }); } store.emitTaskLifecycleEventSafely("task:updated", [current]); return current; - } +} export async function updateTaskAtomicImpl(store: TaskStore, id: string, updater: ( current: Task, ) => Parameters[1] | null | undefined | Promise[1] | null | undefined>, runContext?: RunMutationContext,): Promise { return store.withTaskLock(id, async () => { @@ -556,7 +375,7 @@ export async function updateTaskAtomicImpl(store: TaskStore, id: string, updater }); } -export function getWorkflowPromptOverridesImpl(store: TaskStore, workflowId: string, projectId: string): Record { +export function getWorkflowPromptOverridesImpl(_store: TaskStore, _workflowId: string, _projectId: string): Record { /* * FNXC:SqliteFinalRemoval 2026-06-26: * P1 fix: no backendMode branch existed, so this threw in PG mode. In @@ -567,14 +386,8 @@ export function getWorkflowPromptOverridesImpl(store: TaskStore, workflowId: str * thus applies no overrides in backend mode — overrides are applied by the * async getWorkflowDefinition path instead. */ - if (store.backendMode) { - return {}; - } - const row = store.db - .prepare("SELECT overrides FROM workflow_prompt_overrides WHERE workflowId = ? AND projectId = ?") - .get(workflowId, projectId) as { overrides: string } | undefined; - return store.parseWorkflowPromptOverrideJson(row?.overrides); - } + return {}; +} export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" },): Promise> { /* @@ -607,71 +420,20 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow * FNXC:WorkflowModelLanes 2026-07-14-16:26: * PostgreSQL workflow setting patches must read and write the existing JSONB row through the same transaction handle. The synchronous backend getter intentionally returns an empty default; using it here erased every previously saved model lane whenever another lane was patched. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const committed = await layer.transactionImmediate(async (tx) => { - const rows = await tx - .select({ values: schema.project.workflowSettings.values }) - .from(schema.project.workflowSettings) - .where(and( - eq(schema.project.workflowSettings.workflowId, workflowId), - eq(schema.project.workflowSettings.projectId, projectId), - )) - .limit(1); - const rawCurrent = rows[0]?.values; - const current = rawCurrent && typeof rawCurrent === "object" && !Array.isArray(rawCurrent) - ? rawCurrent as Record - : {}; - const next: Record = { ...current }; - for (const [key, value] of Object.entries(result.accepted)) { - if (value === null) { - delete next[key]; - } else { - next[key] = value; - } - } - - const now = new Date().toISOString(); - await tx - .insert(schema.project.workflowSettings) - .values({ - workflowId, - projectId, - values: next, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [schema.project.workflowSettings.workflowId, schema.project.workflowSettings.projectId], - set: { - values: next, - updatedAt: now, - }, - }); - /* FNXC:ConfigVersioning 2026-07-18-00:00: workflow values and their revision commit together. */ - const revision = createConfigurationRevision({ - projectId, - ownerScope: "project", - configKind: "workflow-settings", - configTarget: { workflowId, projectId }, - before: current, - after: next, - changedBy, - }); - if (revision) await appendConfigurationRevision(tx, revision); - return { next, revision }; - }); - if (committed.revision) { - store.emit("workflow:setting-values-updated", { - workflowId, - projectId, - settingIds: committed.revision.diffs.map((diff) => diff.field), - mutationId: committed.revision.id, - }); - } - return committed.next; - } - return store.db.transactionImmediate(() => { - const current = store.getWorkflowSettingValues(workflowId, projectId); + const layer = store.asyncLayer!; + const committed = await layer.transactionImmediate(async (tx) => { + const rows = await tx + .select({ values: schema.project.workflowSettings.values }) + .from(schema.project.workflowSettings) + .where(and( + eq(schema.project.workflowSettings.workflowId, workflowId), + eq(schema.project.workflowSettings.projectId, projectId), + )) + .limit(1); + const rawCurrent = rows[0]?.values; + const current = rawCurrent && typeof rawCurrent === "object" && !Array.isArray(rawCurrent) + ? rawCurrent as Record + : {}; const next: Record = { ...current }; for (const [key, value] of Object.entries(result.accepted)) { if (value === null) { @@ -682,18 +444,44 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow } const now = new Date().toISOString(); - store.db - .prepare( - `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) - VALUES (?, ?, ?, ?) - ON CONFLICT(workflowId, projectId) - DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, - ) - .run(workflowId, projectId, JSON.stringify(next), now); - store.db.bumpLastModified(); - return next; + await tx + .insert(schema.project.workflowSettings) + .values({ + workflowId, + projectId, + values: next, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [schema.project.workflowSettings.workflowId, schema.project.workflowSettings.projectId], + set: { + values: next, + updatedAt: now, + }, + }); + /* FNXC:ConfigVersioning 2026-07-18-00:00: workflow values and their revision commit together. */ + const revision = createConfigurationRevision({ + projectId, + ownerScope: "project", + configKind: "workflow-settings", + configTarget: { workflowId, projectId }, + before: current, + after: next, + changedBy, + }); + if (revision) await appendConfigurationRevision(tx, revision); + return { next, revision }; }); - } + if (committed.revision) { + store.emit("workflow:setting-values-updated", { + workflowId, + projectId, + settingIds: committed.revision.diffs.map((diff) => diff.field), + mutationId: committed.revision.id, + }); + } + return committed.next; +} export async function rollbackConfigurationImpl(store: TaskStore, revisionId: string, changedBy: ConfigChangedBy = {kind: "human", id: "local-user"}): Promise { if (!store.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); @@ -764,86 +552,49 @@ export async function cancelActiveWorkflowWorkItemsForTaskImpl(store: TaskStore, // No dedicated async helper; the composite is: list active items, then // transition each to 'cancelled'. In backend mode, do this without a // sync transactionImmediate (each transition is independently atomic). - if (store.backendMode) { - const excludeIds = new Set(opts.excludeIds ?? []); - const items = (await store.listWorkflowWorkItemsForTask(taskId, opts)).filter((item) => - store.isActiveWorkflowWorkItemState(item.state) && !excludeIds.has(item.id) - ); - const results: WorkflowWorkItem[] = []; - for (const item of items) { - results.push( - await store.transitionWorkflowWorkItem(item.id, "cancelled", { - now: opts.now, - leaseOwner: null, - leaseExpiresAt: null, - lastError: opts.lastError ?? item.lastError ?? "cancelled-by-user-hard-cancel", - }, tx), - ); - } - return results; - } - return store.db.transactionImmediate(() => { - const excludeIds = new Set(opts.excludeIds ?? []); - // SQLite path: use the sync internal list to stay inside the transaction. - const items = store.listWorkflowWorkItemsForTaskSync(taskId, opts).filter((item) => - store.isActiveWorkflowWorkItemState(item.state) && !excludeIds.has(item.id) - ); - return items.map((item) => - store.transitionWorkflowWorkItemSync(item.id, "cancelled", { + const excludeIds = new Set(opts.excludeIds ?? []); + const items = (await store.listWorkflowWorkItemsForTask(taskId, opts)).filter((item) => + store.isActiveWorkflowWorkItemState(item.state) && !excludeIds.has(item.id) + ); + const results: WorkflowWorkItem[] = []; + for (const item of items) { + results.push( + await store.transitionWorkflowWorkItem(item.id, "cancelled", { now: opts.now, leaseOwner: null, leaseExpiresAt: null, lastError: opts.lastError ?? item.lastError ?? "cancelled-by-user-hard-cancel", - }), + }, tx), ); - }); - } + } + return results; +} export async function setCompletionHandoffAcceptedMarkerImpl(store: TaskStore, taskId: string, opts: { source: string; acceptedAt?: string },): Promise { // FNXC:RuntimeWorkflowAsync 2026-06-24-16:35: // Backend mode: delegate to the async workflow-workitems helper. The helper // records the marker upsert; the sync path also records a run-audit event, // so we fire that in backend mode too (fire-and-forget, best-effort). - if (store.backendMode) { - const layer = store.asyncLayer!; - await recordCompletionHandoffAsync(layer.db, taskId, opts.source, opts.acceptedAt); - const marker = await getCompletionHandoffMarkerAsync(layer.db, taskId); - if (!marker) throw new Error(`Failed to set completion handoff marker for ${taskId}`); - void store.recordRunAuditEvent({ + const layer = store.asyncLayer!; + await recordCompletionHandoffAsync(layer.db, taskId, opts.source, opts.acceptedAt); + const marker = await getCompletionHandoffMarkerAsync(layer.db, taskId); + if (!marker) throw new Error(`Failed to set completion handoff marker for ${taskId}`); + void store.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `completion-handoff:${taskId}:${Date.now()}`, + domain: "database", + mutationType: "task:completion-handoff-accepted", + target: taskId, + metadata: { taskId, acceptedAt: marker.acceptedAt, source: marker.source }, + }).catch((err) => { + storeLog.warn("completion-handoff audit write failed", { taskId, - agentId: "system", - runId: `completion-handoff:${taskId}:${Date.now()}`, - domain: "database", - mutationType: "task:completion-handoff-accepted", - target: taskId, - metadata: { taskId, acceptedAt: marker.acceptedAt, source: marker.source }, + error: err instanceof Error ? err.message : String(err), }); - return marker as CompletionHandoffMarker; - } - return store.db.transactionImmediate(() => { - const acceptedAt = opts.acceptedAt ?? new Date().toISOString(); - store.db.prepare(` - INSERT INTO completion_handoff_markers (taskId, acceptedAt, source) - VALUES (?, ?, ?) - ON CONFLICT(taskId) DO UPDATE SET - acceptedAt = excluded.acceptedAt, - source = excluded.source - `).run(taskId, acceptedAt, opts.source); - - const row = store.db.prepare("SELECT * FROM completion_handoff_markers WHERE taskId = ?").get(taskId) as CompletionHandoffMarkerRow | undefined; - if (!row) throw new Error(`Failed to set completion handoff marker for ${taskId}`); - - store.insertRunAuditEventRow({ - taskId, - domain: "database", - mutationType: "task:completion-handoff-accepted", - target: taskId, - metadata: { taskId, acceptedAt: row.acceptedAt, source: row.source }, - }); - - return store.rowToCompletionHandoffMarker(row); }); - } + return marker as CompletionHandoffMarker; +} export async function reconcileLegacyAutoMergeStampsImpl(store: TaskStore, options?: { apply?: boolean }): Promise { const candidates = await store.listLegacyAutoMergeStampCandidates(); @@ -890,49 +641,9 @@ export async function reconcileLegacyAutoMergeStampsImpl(store: TaskStore, optio } export async function recoverExpiredMergeQueueLeasesImpl(store: TaskStore, now: string = new Date().toISOString()): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return recoverExpiredMergeQueueLeasesAsync(layer, now); - } - return store.db.transactionImmediate(() => { - const expired = store.db.prepare(` - SELECT * FROM mergeQueue - WHERE leasedBy IS NOT NULL AND leaseExpiresAt <= ? - ORDER BY leaseExpiresAt ASC, enqueuedAt ASC - `).all(now) as MergeQueueRow[]; - if (expired.length === 0) { - return []; - } - - const recoveredRows = store.db.prepare(` - UPDATE mergeQueue - SET leasedBy = NULL, - leasedAt = NULL, - leaseExpiresAt = NULL - WHERE leasedBy IS NOT NULL AND leaseExpiresAt <= ? - RETURNING * - `).all(now) as MergeQueueRow[]; - - const previousByTaskId = new Map(expired.map((row) => [row.taskId, row])); - for (const row of recoveredRows) { - const previous = previousByTaskId.get(row.taskId); - store.insertRunAuditEventRow({ - taskId: row.taskId, - domain: "database", - mutationType: "mergeQueue:lease-expired", - target: row.taskId, - metadata: { - taskId: row.taskId, - previousLeasedBy: previous?.leasedBy ?? null, - previousLeaseExpiresAt: previous?.leaseExpiresAt ?? null, - recoveredAt: now, - }, - }); - } - - return recoveredRows.map((row) => store.rowToMergeQueueEntry(row)); - }); - } + const layer = store.asyncLayer!; + return recoverExpiredMergeQueueLeasesAsync(layer, now); +} export function rewriteDependentsForRemovalImpl(store: TaskStore, taskId: string, dependentIds: string[]): Task[] { const rewrittenDependents: Task[] = []; @@ -1107,36 +818,16 @@ export async function addAttachmentImpl(store: TaskStore, id: string, filename: * async Drizzle over project.artifacts in backend mode, sqlite otherwise. */ async function deleteAttachmentArtifactRows(store: TaskStore, taskId: string, filename: string): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - const artifacts = await getArtifactsForAttachmentCleanup(store, taskId); - const linkedArtifactIds = artifacts - .filter((artifact) => artifact.metadata?.source === "attachment" && artifact.metadata.attachmentFilename === filename) - .map((artifact) => artifact.id); - if (linkedArtifactIds.length === 0) return; - for (const artifactId of linkedArtifactIds) { - await layer.db.delete(schema.project.artifacts).where(eq(schema.project.artifacts.id, artifactId)); - } - return; - } - - const rows = store.db - .prepare("SELECT * FROM artifacts WHERE taskId = ?") - .all(taskId) as unknown as ArtifactRow[]; - const linkedArtifactIds = rows - .map((row) => store.rowToArtifact(row)) + const layer = store.asyncLayer!; + const artifacts = await getArtifactsForAttachmentCleanup(store, taskId); + const linkedArtifactIds = artifacts .filter((artifact) => artifact.metadata?.source === "attachment" && artifact.metadata.attachmentFilename === filename) .map((artifact) => artifact.id); - - if (linkedArtifactIds.length === 0) { - return; - } - - const deleteArtifact = store.db.prepare("DELETE FROM artifacts WHERE id = ?"); + if (linkedArtifactIds.length === 0) return; for (const artifactId of linkedArtifactIds) { - deleteArtifact.run(artifactId); + await layer.db.delete(schema.project.artifacts).where(eq(schema.project.artifacts.id, artifactId)); } - store.db.bumpLastModified(); + return; } async function getArtifactsForAttachmentCleanup(store: TaskStore, taskId: string): Promise { @@ -1189,42 +880,21 @@ export async function deleteAttachmentImpl(store: TaskStore, id: string, filenam export async function registerArtifactImpl(store: TaskStore, input: ArtifactCreateInput): Promise { const id = randomUUID(); - const now = new Date().toISOString(); + const _now = new Date().toISOString(); /* - * FNXC:SqliteFinalRemoval 2026-06-26: - * P1 fix: the preliminary taskId existence/archived check below used - * store.db.prepare directly and sat OUTSIDE the backend guard, so it threw - * in PG mode whenever input.taskId was set. In backend mode, skip this - * pre-check — insertArtifactRow (async-comments-attachments.ts) already - * performs the same archived/not-found gate INSIDE its transaction - * (getLiveTaskColumn), which is the correct atomic placement. - */ - if (input.taskId && !store.backendMode) { - const taskExists = store.db.prepare(`SELECT id, "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(input.taskId) as - | { id: string; column: Column } - | undefined; - if (taskExists?.column === "archived") { - throw new Error(`Task ${input.taskId} is archived — artifacts are read-only`); - } - if (!taskExists) { - if (store.isTaskArchived(input.taskId)) { - throw new Error(`Task ${input.taskId} is archived — artifacts are read-only`); - } - throw new Error(`Task ${input.taskId} not found`); - } - } + FNXC:SqliteDualPathCleanup 2026-07-26-14:30: + Artifact registration is PostgreSQL-only. The former non-backend SQLite precheck (store.db.prepare) is deleted; insertArtifactRowAsync performs the archived/not-found gate inside its transaction via getLiveTaskColumn (correct atomic placement). + */ const register = async (): Promise => { const stored = await store.writeArtifactData(input, id); try { - // FNXC:RuntimeWorkflowAsync 2026-06-24-16:55: - // Backend mode: delegate row insert to insertArtifactRowAsync (async-comments-attachments.ts). - if (store.backendMode) { - const layer = store.asyncLayer!; - return insertArtifactRowAsync(layer, input, stored); - } - return store.insertArtifactRow(input, id, now, stored); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:07: + Artifact row insert is PostgreSQL-only via insertArtifactRowAsync. + */ + return insertArtifactRowAsync(store.asyncLayer!, input, stored); } catch (error) { if (stored.absolutePath) { await unlink(stored.absolutePath).catch(() => undefined); @@ -1321,85 +991,49 @@ export async function cleanupArchivedTasksImpl(store: TaskStore): Promise 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( - `SELECT s.taskId AS taskId FROM task_workflow_selection s - JOIN tasks t ON t.id = s.taskId - WHERE s.workflowId = ? AND t."deletedAt" IS NULL`, - ) - .all(workflowId) as Array<{ taskId: string }>; - for (const row of selected) ids.push(row.taskId); + 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 = store.db - .prepare( - `SELECT t.id AS id FROM tasks t - WHERE t."deletedAt" IS NULL - AND NOT EXISTS (SELECT 1 FROM task_workflow_selection s WHERE s.taskId = t.id)`, + 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), + ), ) - .all() as Array<{ id: string }>; - for (const row of unselected) ids.push(row.id); + .where(and( + isNull(schema.project.tasks.deletedAt), + isNull(schema.project.taskWorkflowSelection.taskId), + taskProjectScope(layer), + )); + ids.push(...unselected.map((row) => row.id)); } return ids; - } +} export async function evacuateCustomColumnsToLegacyImpl(store: TaskStore, trigger: "flag-off-init" | "flag-toggled-off",): Promise<{ scanned: number; evacuated: number }> { let scanned = 0; @@ -1511,14 +1123,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: 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 }>; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:32: + Custom-column evacuation scans live tasks via PostgreSQL only. + */ + const rows: Array<{ id: string; col: string }> = 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!))); for (const { id, col } of rows) { scanned += 1; @@ -1634,38 +1246,6 @@ export async function closeImpl(store: TaskStore): Promise { export async function getActivityLogImpl(store: TaskStore, options?: { limit?: number; since?: string; type?: ActivityEventType }): Promise { // FNXC:RuntimeWorkflowAsync 2026-06-24-16:03: // Backend-mode: delegate to the async audit helper. - if (store.backendMode) { - const layer = store.asyncLayer!; - return getActivityLogAsync(layer.db, layer.projectId ?? "", options); - } - let sql = "SELECT * FROM activityLog WHERE 1=1"; - const params: (string | number)[] = []; - - if (options?.since) { - sql += " AND timestamp > ?"; - params.push(options.since); - } - - if (options?.type) { - sql += " AND type = ?"; - params.push(options.type); - } - - sql += " ORDER BY timestamp DESC"; - - if (options?.limit && options.limit > 0) { - sql += " LIMIT ?"; - params.push(options.limit); - } - - const rows = store.db.prepare(sql).all(...params) as unknown as ActivityLogRow[]; - return rows.map((row) => ({ - id: row.id, - timestamp: row.timestamp, - type: row.type as ActivityEventType, - taskId: row.taskId || undefined, - taskTitle: row.taskTitle || undefined, - details: row.details, - metadata: row.metadata ? JSON.parse(row.metadata) : undefined, - })); - } + const layer = store.asyncLayer!; + return getActivityLogAsync(layer.db, layer.projectId ?? "", options); +} diff --git a/packages/core/src/task-store/task-row-mappers.ts b/packages/core/src/task-store/task-row-mappers.ts index ffd462f35d..5f0b78fc13 100644 --- a/packages/core/src/task-store/task-row-mappers.ts +++ b/packages/core/src/task-store/task-row-mappers.ts @@ -156,33 +156,21 @@ export async function readTaskForMoveImpl(store: TaskStore, id: string): Promise // moveTask/handoffToReview, so using getTask() (which also acquires the // lock) would deadlock. We read the raw row and convert it. Fall back to // archive lookup if the task is not in the live table. - if (store.backendMode) { - const layer = store.asyncLayer!; - const pgRow = await readTaskRowAsync(layer, id, { includeDeleted: true }); - if (pgRow) { - if (pgRow.deletedAt) { - throw new TaskDeletedError(id, pgRow.deletedAt as string); - } - return store.rowToTask(store.pgRowToTaskRow(pgRow)); + const layer = store.asyncLayer!; + const pgRow = await readTaskRowAsync(layer, id, { includeDeleted: true }); + if (pgRow) { + if (pgRow.deletedAt) { + throw new TaskDeletedError(id, pgRow.deletedAt as string); } - // Fall back to archive lookup (soft-deleted/archived tasks). - const entry = await findArchivedTaskEntry(layer.db, id, layer.projectId); - if (entry) { - return store.archiveEntryToTask(entry, false); - } - throw new Error(`Task ${id} not found`); + return store.rowToTask(store.pgRowToTaskRow(pgRow)); } - const dir = store.taskDir(id); - try { - return await store.readTaskJson(dir); - } catch (error) { - const archived = store.archiveDb.get(id); - if (!archived) { - throw error; - } - return store.archiveEntryToTask(archived, false); + // Fall back to archive lookup (soft-deleted/archived tasks). + const entry = await findArchivedTaskEntry(layer.db, id, layer.projectId); + if (entry) { + return store.archiveEntryToTask(entry, false); } - } + throw new Error(`Task ${id} not found`); +} export function rowToMergeQueueEntryImpl(store: TaskStore, row: MergeQueueRow): MergeQueueEntry { return { diff --git a/packages/core/src/task-store/task-store-helpers.ts b/packages/core/src/task-store/task-store-helpers.ts index 86c0da4fa1..84c770fdfa 100644 --- a/packages/core/src/task-store/task-store-helpers.ts +++ b/packages/core/src/task-store/task-store-helpers.ts @@ -105,7 +105,7 @@ params: { return params.moveSource === "user" && params.fromColumn === "in-progress" && params.toColumn === "todo"; } -export function getMergeRequestRecordImpl(store: TaskStore, taskId: string): MergeRequestRecord | null { +export function getMergeRequestRecordImpl(_store: TaskStore, _taskId: string): MergeRequestRecord | null { /* FNXC:PostgresCutover 2026-07-04: Synchronous read of merge_requests cannot run against PostgreSQL (Drizzle @@ -118,9 +118,8 @@ export function getMergeRequestRecordImpl(store: TaskStore, taskId: string): Mer degradation) instead of throwing. Callers that need the real record in PG must use getMergeRequestRecordAsync below. */ - if (store.backendMode) return null; - const row = store.db.prepare("SELECT * FROM merge_requests WHERE taskId = ?").get(taskId) as MergeRequestRow | undefined; - return row ? store.rowToMergeRequestRecord(row) : null; + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:20: sync merge-request reader is incomplete-PG; use getMergeRequestRecordAsync. */ + return null; } /** @@ -133,7 +132,7 @@ export function getMergeRequestRecordImpl(store: TaskStore, taskId: string): Mer * mode it delegates to the sync impl. */ export async function getMergeRequestRecordAsyncImpl(store: TaskStore, taskId: string): Promise { - if (!store.backendMode) return store.getMergeRequestRecord(taskId); + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: always PostgreSQL path below. */ const layer = store.asyncLayer!; const rows = await layer.db .select() @@ -222,9 +221,7 @@ silently dropped operator customizations from getWorkflowDefinition / move IR. export async function applyBuiltInPromptOverridesAsyncImpl(store: TaskStore, workflowId: string, ir: WorkflowIr): Promise { if (!isBuiltinWorkflowId(workflowId)) return ir; const projectId = store.getWorkflowSettingsProjectId(); - const overrides = store.backendMode - ? await store.getWorkflowPromptOverridesAsync(workflowId, projectId) - : store.getWorkflowPromptOverrides(workflowId, projectId); + const overrides = await store.getWorkflowPromptOverridesAsync(workflowId, projectId); return applyPromptOverridesToIr(ir, overrides); } @@ -307,12 +304,15 @@ export function refreshDatabaseHealthImpl(store: TaskStore): ReturnType undefined); - return store.getDatabaseHealth(); } - store.db.refreshIntegrityCheck(); return store.getDatabaseHealth(); } @@ -323,8 +323,13 @@ the result on TaskStore.postgresHealthSnapshot for sync getDatabaseHealth / healthCheck readers. */ export async function refreshDatabaseHealthAsyncImpl(store: TaskStore): Promise> { - if (!store.backendMode || !store.asyncLayer) { - return refreshDatabaseHealthImpl(store); + if (!store.asyncLayer) { + /* + FNXC:SqliteDualPathCleanup 2026-07-27-06:15: + No AsyncDataLayer: return the cached/safe incomplete-PG snapshot. Do not call + refreshDatabaseHealthImpl here — that path re-schedules this function and would recurse. + */ + return store.getDatabaseHealth(); } store.postgresHealthSnapshot = { healthy: store.postgresHealthSnapshot?.healthy ?? true, @@ -352,15 +357,11 @@ export async function clearActivityLogImpl(store: TaskStore): Promise { * In backend mode, use the async layer to clear the activity log via * Drizzle instead of the SQLite-specific db.prepare() path. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - await layer.db - .delete(schema.project.activityLog) - .where(eq(schema.project.activityLog.projectId, activityProjectPartition(layer.projectId ?? ""))); - return; - } - store.db.prepare("DELETE FROM activityLog").run(); - store.db.bumpLastModified(); + const layer = store.asyncLayer!; + await layer.db + .delete(schema.project.activityLog) + .where(eq(schema.project.activityLog.projectId, activityProjectPartition(layer.projectId ?? ""))); + return; } export function getInsightStoreImpl(store: TaskStore): InsightStore | AsyncInsightStore { @@ -371,15 +372,12 @@ export function getInsightStoreImpl(store: TaskStore): InsightStore | AsyncInsig // project_insight_run_events). The sync SQLite InsightStore (store.db) is // used only in legacy SQLite mode. Both expose the same method names; the // dashboard insights routes await the result so either works. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("InsightStore is not available: AsyncDataLayer not initialized in backend mode"); - } - store.insightStore = new AsyncInsightStore(layer); - } else { - store.insightStore = new InsightStore(store.db); + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("InsightStore is not available: AsyncDataLayer not initialized in backend mode"); } + store.insightStore = new AsyncInsightStore(layer); + } return store.insightStore; } @@ -394,15 +392,12 @@ export function getResearchStoreImpl(store: TaskStore): ResearchStore | AsyncRes // routes await the result so either works. AI research EXECUTION (the engine // ResearchOrchestrator/ResearchRunDispatcher) stays degraded in PG mode — those // are coupled to the sync EventEmitter ResearchStore and are out of scope here. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("ResearchStore is not available: AsyncDataLayer not initialized in backend mode"); - } - store.researchStore = new AsyncResearchStore(layer); - } else { - store.researchStore = new ResearchStore(store.db); + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("ResearchStore is not available: AsyncDataLayer not initialized in backend mode"); } + store.researchStore = new AsyncResearchStore(layer); + } return store.researchStore; } @@ -414,15 +409,12 @@ export function getTodoStoreImpl(store: TaskStore): TodoStore | AsyncTodoStore { // over project.todo_lists / project.todo_items). The sync SQLite TodoStore // (store.db) is used only in legacy SQLite mode. Both expose the same // method names; the dashboard todo routes await the result so either works. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("TodoStore is not available: AsyncDataLayer not initialized in backend mode"); - } - store.todoStore = new AsyncTodoStore(layer); - } else { - store.todoStore = new TodoStore(store.db); + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("TodoStore is not available: AsyncDataLayer not initialized in backend mode"); } + store.todoStore = new AsyncTodoStore(layer); + } return store.todoStore; } diff --git a/packages/core/src/task-store/update-task-deps.ts b/packages/core/src/task-store/update-task-deps.ts index c20007e99f..c57a0d38fb 100644 --- a/packages/core/src/task-store/update-task-deps.ts +++ b/packages/core/src/task-store/update-task-deps.ts @@ -197,16 +197,18 @@ export async function updateTaskDependenciesImpl(store: TaskStore, id: string, m * Replace with async store.getTask() calls. */ const assertTaskExists = async (dependencyId: string) => { - if (store.backendMode) { - try { - await store.getTask(dependencyId); - } catch { + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Map only not-found/deleted errors to "Dependency task not found"; rethrow transport and other PostgreSQL failures. + */ + try { + await store.getTask(dependencyId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/not found|TaskDeleted|deleted/i.test(msg)) { throw new Error(`Dependency task ${dependencyId} not found`); } - return; - } - if (!store.readTaskFromDb(dependencyId)) { - throw new Error(`Dependency task ${dependencyId} not found`); + throw err; } }; const assertUnique = (dependencies: readonly string[]) => { @@ -306,10 +308,17 @@ export async function updateTaskDependenciesImpl(store: TaskStore, id: string, m * to resolve unresolved dependency and current blocker columns. */ const readDepTask = async (depId: string): Promise => { - if (store.backendMode) { - try { return await store.getTask(depId); } catch { return null; } + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Treat not-found as null; rethrow unexpected PostgreSQL failures. + */ + try { + return await store.getTask(depId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/not found|TaskDeleted|deleted/i.test(msg)) return null; + throw err; } - return store.readTaskFromDb(depId) ?? null; }; const allDepTasks = await Promise.all(nextDependencies.map(readDepTask)); diff --git a/packages/core/src/task-store/workflow-definitions.ts b/packages/core/src/task-store/workflow-definitions.ts index 502d3d88ce..a8540ac810 100644 --- a/packages/core/src/task-store/workflow-definitions.ts +++ b/packages/core/src/task-store/workflow-definitions.ts @@ -13,9 +13,7 @@ import { TaskStore } from "../store.js"; import {resolveEntryColumnId} from "../workflow-reconciliation.js"; import { pruneAgentLogFiles as pruneAgentLogFileEntries, readAgentLogEntriesByTimeRange } from "../agent-log-file-store.js"; import { BUILTIN_WORKFLOWS, DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr, getBuiltinWorkflow, getRequiredPluginIdForBuiltinWorkflow, isBuiltinWorkflowDeprecated, isBuiltinWorkflowEnabled, isBuiltinWorkflowId, isBuiltinWorkflowPluginGated } from "../builtin-workflows.js"; -import { CentralCore } from "../central-core.js"; -import { fromJson } from "../db.js"; -import { type DistributedTaskIdAllocator, createDistributedTaskIdAllocator } from "../distributed-task-id.js"; +import { type DistributedTaskIdAllocator } from "../distributed-task-id.js"; import { ExperimentSessionStore } from "../experiment-session-store.js"; import { MasterKeyManager } from "../master-key.js"; import { MissionStore } from "../mission-store.js"; @@ -32,11 +30,10 @@ import { getInReviewDurationEvents as getInReviewDurationEventsAsync, getTaskMer 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 { ActivityLogEntry, AgentLogEntry, ArchivedTaskEntry, DEFAULT_SETTINGS, Settings } from "../types.js"; import { and, eq, inArray, isNull, sql } 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 { normalizeWorkflowIcon, type WorkflowDefinition, type WorkflowDefinitionInput, type WorkflowNodeLayout } from "../workflow-definition-types.js"; import { WorkflowIr } from "../workflow-ir-types.js"; import { downgradeIrToV1IfPure, parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js"; import { resolveDefaultOnOptionalGroupIds } from "../workflow-optional-steps.js"; @@ -80,28 +77,16 @@ export async function importLegacyAgentLogsOnceImpl(store: TaskStore): Promise> { - // FNXC:PostgresOnlyDataAccess 2026-07-16-12:35: backend mode previously - // returned {} from the catch below, hiding raw persisted project settings - // on PostgreSQL. Read the config row via the async layer instead. - if (store.backendMode) { - try { - const config = await readProjectConfig(store.asyncLayer!); - const settings = config.settings; - return settings && typeof settings === "object" && !Array.isArray(settings) - ? (settings as Record) - : {}; - } catch { - return {}; - } - } + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:07: + Raw project settings are read from PostgreSQL project config only. The SQLite config-row arm is deleted. + FNXC:PostgresOnlyDataAccess 2026-07-16-12:35: never hide settings behind an empty {} when the layer is present. + */ try { - const row = store.db.prepare("SELECT settings FROM config WHERE id = 1").get() as - | { settings: string } - | undefined; - if (!row) return {}; - const parsed = JSON.parse(row.settings) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) + const config = await readProjectConfig(store.asyncLayer!); + const settings = config.settings; + return settings && typeof settings === "object" && !Array.isArray(settings) + ? (settings as Record) : {}; } catch { return {}; @@ -316,16 +301,11 @@ export async function readAllWorkflowDefinitionsImpl(store: TaskStore): Promise< // PG backend mode reads custom workflow rows from project.workflows via the // AsyncDataLayer (the sync store.db SELECT throws). Builtins are merged the // same way in both backends. Every caller already awaits this method. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("workflow definitions: AsyncDataLayer not initialized in backend mode"); - } - const rows = await listWorkflowRows(layer); - store.workflowDefinitionsCache = [...BUILTIN_WORKFLOWS, ...rows.map((row) => store.toWorkflowDefinition(row))]; - return store.workflowDefinitionsCache; + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("workflow definitions: AsyncDataLayer not initialized in backend mode"); } - const rows = store.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as StoredWorkflowRow[]; + const rows = await listWorkflowRows(layer); store.workflowDefinitionsCache = [...BUILTIN_WORKFLOWS, ...rows.map((row) => store.toWorkflowDefinition(row))]; return store.workflowDefinitionsCache; } @@ -339,25 +319,17 @@ export async function getWorkflowDefinitionImpl(store: TaskStore, const requiredPluginId = getRequiredPluginIdForBuiltinWorkflow(id); if (!requiredPluginId || !(await store.isPluginInstalled(requiredPluginId))) return undefined; } - const ir = store.backendMode - ? await store.applyBuiltInPromptOverridesAsync(id, builtin.ir) - : store.applyBuiltInPromptOverridesSync(id, builtin.ir); + const ir = await store.applyBuiltInPromptOverridesAsync(id, builtin.ir); return { ...builtin, ir }; } // FNXC:WorkflowDefinitions 2026-06-27-06:00: PG backend reads the custom row // from project.workflows via the AsyncDataLayer; sync store.db otherwise. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("workflow definition: AsyncDataLayer not initialized in backend mode"); - } - const asyncRow = await getWorkflowRow(layer, id); - return asyncRow ? store.toWorkflowDefinition(asyncRow) : undefined; + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("workflow definition: AsyncDataLayer not initialized in backend mode"); } - const row = store.db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as - | StoredWorkflowRow - | undefined; - return row ? store.toWorkflowDefinition(row) : undefined; + const asyncRow = await getWorkflowRow(layer, id); + return asyncRow ? store.toWorkflowDefinition(asyncRow) : undefined; } export async function occupantsByColumnForWorkflowImpl(store: TaskStore, @@ -366,26 +338,16 @@ export async function occupantsByColumnForWorkflowImpl(store: TaskStore, ): Promise> { const counts = new Map(); 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; - if (!row) continue; - counts.set(row.column, (counts.get(row.column) ?? 0) + 1); - } + 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; } @@ -538,24 +500,13 @@ export function resolveTaskWorkflowIrSyncImpl(store: TaskStore, taskId: string): } } -export function getTaskWorkflowSelectionImpl(store: TaskStore, taskId: string): { workflowId: string; stepIds: string[] } | undefined { +export function getTaskWorkflowSelectionImpl(_store: TaskStore, _taskId: string): { workflowId: string; stepIds: string[] } | undefined { /* FNXC:PostgresCutover 2026-07-04-00:00: Backend mode cannot synchronously read PostgreSQL, so return undefined and let the sync readers (resolveEffectiveWorkflowIdSync / resolveTaskWorkflowIrSync) fall back to their defaults. The authoritative read is getTaskWorkflowSelectionAsync; this also converts the prior PG-mode throw into a graceful default. */ - if (store.backendMode) return undefined; - const row = store.db - .prepare("SELECT workflowId, stepIds FROM task_workflow_selection WHERE taskId = ?") - .get(taskId) as { workflowId: string; stepIds: string } | undefined; - if (!row) return undefined; - let stepIds: string[] = []; - try { - const parsed = JSON.parse(row.stepIds) as unknown; - if (Array.isArray(parsed)) stepIds = parsed.filter((s): s is string => typeof s === "string"); - } catch { - // Corrupt list falls back to empty. - } - return { workflowId: row.workflowId, stepIds }; + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:20: sync selection reader is incomplete-PG; use getTaskWorkflowSelectionAsync. */ + return undefined; } /* @@ -563,7 +514,7 @@ FNXC:PostgresCutover 2026-07-04-00:00: Async backend-mode read of a task's workflow selection (PostgreSQL). stepIds is a JSONB array, returned by Drizzle already parsed. Returns undefined when no row exists. SQLite mode delegates to the sync impl. */ export async function getTaskWorkflowSelectionAsyncImpl(store: TaskStore, taskId: string): Promise<{ workflowId: string; stepIds: string[] } | undefined> { - if (!store.backendMode) return store.getTaskWorkflowSelection(taskId); + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: always PostgreSQL path below. */ const layer = store.asyncLayer!; /* FNXC:WorkflowModelLanes 2026-07-14-16:34: @@ -592,30 +543,23 @@ export async function writeTaskWorkflowSelectionImpl(store: TaskStore, taskId: s FNXC:PostgresCutover 2026-07-04-00:00: Backend-mode upsert of the task_workflow_selection row via async Drizzle (taskId is the primary key). stepIds is stored as a JSONB array. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - await layer.db - .insert(schema.project.taskWorkflowSelection) - .values({ taskId, workflowId, stepIds, updatedAt }) - .onConflictDoUpdate({ - target: [ - schema.project.taskWorkflowSelection.projectId, - schema.project.taskWorkflowSelection.taskId, - ], - set: { workflowId, stepIds, updatedAt }, - }); - return; - } - store.db - .prepare( - `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) - VALUES (?, ?, ?, ?) - ON CONFLICT(taskId) DO UPDATE SET - workflowId = excluded.workflowId, - stepIds = excluded.stepIds, - updatedAt = excluded.updatedAt`, - ) - .run(taskId, workflowId, JSON.stringify(stepIds), updatedAt); + const layer = store.asyncLayer!; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Selection upsert must include projectId — PK is (projectId, taskId) and the authoritative read pins projectId. + */ + const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; + await layer.db + .insert(schema.project.taskWorkflowSelection) + .values({ projectId, taskId, workflowId, stepIds, updatedAt }) + .onConflictDoUpdate({ + target: [ + schema.project.taskWorkflowSelection.projectId, + schema.project.taskWorkflowSelection.taskId, + ], + set: { workflowId, stepIds, updatedAt }, + }); + return; } export async function removeMaterializedSelectionImpl(store: TaskStore, taskId: string): Promise { @@ -623,18 +567,8 @@ export async function removeMaterializedSelectionImpl(store: TaskStore, taskId: FNXC:PostgresCutover 2026-07-04-00:00: Backend-mode delete reuses purgeTaskWorkflowSelectionRowsAsyncImpl (read stepIds, delete workflow_steps children, delete the selection row) so PG stays in lockstep with the SQLite path. */ - if (store.backendMode) { - await purgeTaskWorkflowSelectionRowsAsyncImpl(store, taskId); - return; - } - const existing = store.getTaskWorkflowSelection(taskId); - if (existing) { - for (const stepId of existing.stepIds) { - store.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId); - } - store.workflowStepsCache = null; - } - store.db.prepare("DELETE FROM task_workflow_selection WHERE taskId = ?").run(taskId); + await purgeTaskWorkflowSelectionRowsAsyncImpl(store, taskId); + return; } export function purgeTaskWorkflowSelectionRowsImpl(store: TaskStore, taskId: string): void { @@ -648,36 +582,16 @@ export function purgeTaskWorkflowSelectionRowsImpl(store: TaskStore, taskId: str * recreation works in PG mode (FN-5233 soft-delete-stickiness invariant, * VAL-DATA-005/006). */ - if (store.backendMode) { - // Drizzle queries are async; synchronously schedule the purge and let - // the awaiting caller (maybeResolveTombstonedTaskId, already async) - // observe completion. We cannot await here without changing the return - // type, so we throw-and-rethrow via a microtask is not viable. Instead - // the async caller must use purgeTaskWorkflowSelectionRowsAsync below. - // To preserve the existing synchronous call sites that ignore the result, - // we fire-and-forget ONLY in the non-critical path. The resurrection - // path uses the async variant directly. - void purgeTaskWorkflowSelectionRowsAsyncImpl(store, taskId); - return; - } - const row = store.db - .prepare("SELECT stepIds FROM task_workflow_selection WHERE taskId = ?") - .get(taskId) as { stepIds: string } | undefined; - if (!row) return; - try { - const parsed = JSON.parse(row.stepIds) as unknown; - if (Array.isArray(parsed)) { - for (const stepId of parsed) { - if (typeof stepId === "string") { - store.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId); - } - } - } - } catch { - // Corrupt stepIds list — still remove the selection row below. - } - store.db.prepare("DELETE FROM task_workflow_selection WHERE taskId = ?").run(taskId); - store.workflowStepsCache = null; + // Drizzle queries are async; synchronously schedule the purge and let + // the awaiting caller (maybeResolveTombstonedTaskId, already async) + // observe completion. We cannot await here without changing the return + // type, so we throw-and-rethrow via a microtask is not viable. Instead + // the async caller must use purgeTaskWorkflowSelectionRowsAsync below. + // To preserve the existing synchronous call sites that ignore the result, + // we fire-and-forget ONLY in the non-critical path. The resurrection + // path uses the async variant directly. + void purgeTaskWorkflowSelectionRowsAsyncImpl(store, taskId); + return; } /** @@ -688,10 +602,7 @@ export function purgeTaskWorkflowSelectionRowsImpl(store: TaskStore, taskId: str * Called by maybeResolveTombstonedTaskId on the resurrection hard-delete path. */ export async function purgeTaskWorkflowSelectionRowsAsyncImpl(store: TaskStore, taskId: string): Promise { - if (!store.backendMode) { - purgeTaskWorkflowSelectionRowsImpl(store, taskId); - return; - } + const layer = store.asyncLayer!; const rows = await layer.db .select({ stepIds: schema.project.taskWorkflowSelection.stepIds }) @@ -789,9 +700,7 @@ export async function selectTaskWorkflowAndReconcileImpl(store: TaskStore, if (!(await store.workflowColumnsFlagOn())) { return { enabledWorkflowSteps }; } - const newIr = store.backendMode - ? await resolveWorkflowIrForTask(store, taskId) - : store.resolveTaskWorkflowIrSync(taskId); + const newIr = await resolveWorkflowIrForTask(store, taskId); const current = store.readTaskFromDb(taskId, { includeDeleted: false }); if (!current) return { enabledWorkflowSteps }; const fromColumn = current.column; @@ -836,25 +745,13 @@ export async function getSecretsStoreImpl(store: TaskStore): Promise { throw new Error("sync DB not available in backend mode"); }, bumpLastModified: () => {} } as unknown as import("../db.js").Database; - const noopCentral = noopDb as unknown as import("../central-db.js").CentralDatabase; - store.secretsStore = new SecretsStore(noopDb, noopCentral, masterKeyProvider, { asyncLayer: layer }); - return store.secretsStore; - } - - const central = new CentralCore(store.getFusionDir()); - await central.init(); - store.secretsCentralCore = central; - const centralDb = (central as unknown as { db: import("../central-db.js").CentralDatabase | null }).db; - if (!centralDb) { - throw new Error("Central database unavailable for secrets store"); - } - store.secretsStore = new SecretsStore(store.db, centralDb, masterKeyProvider); + const layer = store.asyncLayer!; + // CentralCore is not needed in backend mode; the async layer serves both + // project and central schemas. We pass dummy stubs for the sync DBs since + // they are never used when asyncLayer is present. + const noopDb = { prepare: () => { throw new Error("sync DB not available in backend mode"); }, bumpLastModified: () => {} } as unknown as import("../db.js").Database; + const noopCentral = noopDb as unknown as import("../central-db.js").CentralDatabase; + store.secretsStore = new SecretsStore(noopDb, noopCentral, masterKeyProvider, { asyncLayer: layer }); return store.secretsStore; } @@ -871,22 +768,12 @@ export function getDatabaseHealthImpl(store: TaskStore): { first probe runs, report healthy with lastCheckedAt null (unknown, not a lie about a successful integrity check). */ - if (store.backendMode) { - return store.postgresHealthSnapshot ?? { - healthy: true, - corruptionDetected: false, - corruptionErrors: [], - lastCheckedAt: null, - isRunning: false, - }; - } - const corruptionDetected = store.db.corruptionDetected; - return { - healthy: !corruptionDetected, - corruptionDetected, - corruptionErrors: store.db.integrityCheckErrors.slice(0, 5), - lastCheckedAt: store.db.integrityCheckLastRunAt ? new Date(store.db.integrityCheckLastRunAt) : null, - isRunning: store.db.integrityCheckPending, + return store.postgresHealthSnapshot ?? { + healthy: true, + corruptionDetected: false, + corruptionErrors: [], + lastCheckedAt: null, + isRunning: false, }; } @@ -899,16 +786,10 @@ export function getDistributedTaskIdAllocatorImpl(store: TaskStore): Distributed // reconcileTaskIdStateAsync() during init(). The async allocator handles // the reserve/commit/abort lifecycle against the PostgreSQL // distributed_task_id_state and distributed_task_id_reservations tables. - if (store.backendMode) { - if (!store.asyncDistributedTaskIdAllocator) { - store.asyncDistributedTaskIdAllocator = createAsyncDistributedTaskIdAllocator(store.asyncLayer!); - } - return store.asyncDistributedTaskIdAllocator; + if (!store.asyncDistributedTaskIdAllocator) { + store.asyncDistributedTaskIdAllocator = createAsyncDistributedTaskIdAllocator(store.asyncLayer!); } - if (!store.distributedTaskIdAllocator) { - store.distributedTaskIdAllocator = createDistributedTaskIdAllocator(store.db); - } - return store.distributedTaskIdAllocator; + return store.asyncDistributedTaskIdAllocator; } export function healthCheckImpl(store: TaskStore): boolean { @@ -917,17 +798,8 @@ export function healthCheckImpl(store: TaskStore): boolean { Backend mode: report last postgresHealthSnapshot.healthy and schedule a background refresh so CLI/daemon probes converge on real connectivity. */ - if (store.backendMode) { - void store.refreshDatabaseHealthAsync().catch(() => undefined); - return store.getDatabaseHealth().healthy; - } - try { - // Simple query to verify database responsiveness - store.db.prepare("SELECT 1").get(); - return store.db.checkFts5Integrity(); - } catch { - return false; - } + void store.refreshDatabaseHealthAsync().catch(() => undefined); + return store.getDatabaseHealth().healthy; } export function getSettingsSyncImpl(store: TaskStore): Settings { @@ -936,69 +808,17 @@ export function getSettingsSyncImpl(store: TaskStore): Settings { Backend mode returns settingsSyncCache populated by getSettings/getSettingsFast. Before the first async load, DEFAULT_SETTINGS is the only safe sync value. */ - if (store.backendMode) { - return store.settingsSyncCache ?? DEFAULT_SETTINGS; - } - try { - const row = store.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings: string | null } | undefined; - if (!row) return DEFAULT_SETTINGS; - const settings = fromJson(row.settings); - return { ...DEFAULT_SETTINGS, ...settings }; - } catch { - return DEFAULT_SETTINGS; - } + return store.settingsSyncCache ?? DEFAULT_SETTINGS; } export async function getInReviewDurationEventsImpl(store: TaskStore, options: { since: string; until: string }): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getInReviewDurationEventsAsync(layer.db, layer.projectId ?? "", options); - } - const rows = store.db - .prepare( - `SELECT * FROM activityLog - WHERE type = 'task:moved' - AND timestamp > ? - AND timestamp <= ? - AND ( - json_extract(metadata, '$.to') = 'in-review' - OR ( - json_extract(metadata, '$.from') = 'in-review' - AND json_extract(metadata, '$.to') = 'done' - ) - ) - ORDER BY timestamp ASC - LIMIT ?`, - ) - .all(options.since, options.until, 200_000) as unknown as ActivityLogRow[]; - - return rows.map((row) => ({ - id: row.id, - timestamp: row.timestamp, - type: row.type as ActivityEventType, - taskId: row.taskId || undefined, - taskTitle: row.taskTitle || undefined, - details: row.details, - metadata: row.metadata ? JSON.parse(row.metadata) : undefined, - })); + const layer = store.asyncLayer!; + return getInReviewDurationEventsAsync(layer.db, layer.projectId ?? "", options); } export async function getTaskMergedTaskIdsImpl(store: TaskStore, options: { since: string; until: string }): Promise> { - if (store.backendMode) { - const layer = store.asyncLayer!; - return getTaskMergedTaskIdsAsync(layer.db, layer.projectId ?? "", options); - } - const rows = store.db - .prepare( - `SELECT DISTINCT taskId FROM activityLog - WHERE type = 'task:merged' - AND timestamp > ? - AND timestamp <= ? - AND taskId IS NOT NULL`, - ) - .all(options.since, options.until) as Array<{ taskId: string }>; - - return new Set(rows.map((row) => row.taskId)); + const layer = store.asyncLayer!; + return getTaskMergedTaskIdsAsync(layer.db, layer.projectId ?? "", options); } export function getMissionStoreImpl(store: TaskStore): MissionStore | AsyncMissionStore { @@ -1013,15 +833,12 @@ export function getMissionStoreImpl(store: TaskStore): MissionStore | AsyncMissi // SSE mission events stay degraded in PG mode — the engine MissionAutopilot + // dashboard SSE are coupled to the sync EventEmitter MissionStore and guard their // init with `instanceof MissionStore`. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("MissionStore is not available: AsyncDataLayer not initialized in backend mode"); - } - store.missionStore = new AsyncMissionStore(layer, store); - } else { - store.missionStore = new MissionStore(store.fusionDir, store.db, store); + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("MissionStore is not available: AsyncDataLayer not initialized in backend mode"); } + store.missionStore = new AsyncMissionStore(layer, store); + } return store.missionStore; } @@ -1077,11 +894,8 @@ export function getExperimentSessionStoreImpl(store: TaskStore): ExperimentSessi // FNXC:RuntimeSatelliteAsync 2026-06-24-15:00: // In backend mode, pass the AsyncDataLayer so the store delegates to // async helpers; otherwise pass the sync SQLite Database. - if (store.backendMode) { - store.experimentSessionStore = new ExperimentSessionStore(null, { asyncLayer: store.asyncLayer }); - } else { - store.experimentSessionStore = new ExperimentSessionStore(store.db); - } + store.experimentSessionStore = new ExperimentSessionStore(null, { asyncLayer: store.asyncLayer }); + } return store.experimentSessionStore; } @@ -1100,28 +914,17 @@ export async function getVerificationCacheHitImpl(store: TaskStore, ): Promise<{ recordedAt: string; taskId: string | null } | null> { const normalizedTest = testCommand ?? ""; const normalizedBuild = buildCommand ?? ""; - if (store.backendMode) { - const table = schema.project.verificationCache; - const rows = await store.asyncLayer!.db - .select({ recordedAt: table.recordedAt, taskId: table.taskId }) - .from(table) - .where(and( - eq(table.treeSha, treeSha), - eq(table.testCommand, normalizedTest), - eq(table.buildCommand, normalizedBuild), - )) - .limit(1); - return rows[0] ?? null; - } - const row = store.db - .prepare( - `SELECT recordedAt, taskId FROM verification_cache - WHERE treeSha = ? AND testCommand = ? AND buildCommand = ?`, - ) - .get(treeSha, normalizedTest, normalizedBuild) as - | { recordedAt: string; taskId: string | null } - | undefined; - return row ?? null; + const table = schema.project.verificationCache; + const rows = await store.asyncLayer!.db + .select({ recordedAt: table.recordedAt, taskId: table.taskId }) + .from(table) + .where(and( + eq(table.treeSha, treeSha), + eq(table.testCommand, normalizedTest), + eq(table.buildCommand, normalizedBuild), + )) + .limit(1); + return rows[0] ?? null; } export async function recordVerificationCachePassImpl(store: TaskStore, @@ -1133,29 +936,21 @@ export async function recordVerificationCachePassImpl(store: TaskStore, const normalizedTest = testCommand ?? ""; const normalizedBuild = buildCommand ?? ""; const recordedAt = new Date().toISOString(); - if (store.backendMode) { - /* - FNXC:PostgresOnlyDataAccess 2026-07-16-11:55: - Target the PK by constraint name: migration 0006_project_ownership - rebuilds every project-schema PK to lead with project_id, so a - column-list ON CONFLICT on the three logical key columns cannot infer - the arbiter index (42P10). project_id itself comes from the column's - current_setting('fusion.project_id') default under RLS. - */ - await store.asyncLayer!.db.execute(sql` - INSERT INTO project.verification_cache (tree_sha, test_command, build_command, recorded_at, task_id) - VALUES (${treeSha}, ${normalizedTest}, ${normalizedBuild}, ${recordedAt}, ${taskId}) - ON CONFLICT ON CONSTRAINT verification_cache_pkey - DO UPDATE SET recorded_at = EXCLUDED.recorded_at, task_id = EXCLUDED.task_id - `); - return; - } - store.db - .prepare( - `INSERT OR REPLACE INTO verification_cache (treeSha, testCommand, buildCommand, recordedAt, taskId) - VALUES (?, ?, ?, ?, ?)`, - ) - .run(treeSha, normalizedTest, normalizedBuild, recordedAt, taskId); + /* + FNXC:PostgresOnlyDataAccess 2026-07-16-11:55: + Target the PK by constraint name: migration 0006_project_ownership + rebuilds every project-schema PK to lead with project_id, so a + column-list ON CONFLICT on the three logical key columns cannot infer + the arbiter index (42P10). project_id itself comes from the column's + current_setting('fusion.project_id') default under RLS. + */ + await store.asyncLayer!.db.execute(sql` + INSERT INTO project.verification_cache (tree_sha, test_command, build_command, recorded_at, task_id) + VALUES (${treeSha}, ${normalizedTest}, ${normalizedBuild}, ${recordedAt}, ${taskId}) + ON CONFLICT ON CONSTRAINT verification_cache_pkey + DO UPDATE SET recorded_at = EXCLUDED.recorded_at, task_id = EXCLUDED.task_id + `); + return; } /* diff --git a/packages/core/src/task-store/workflow-integrity.ts b/packages/core/src/task-store/workflow-integrity.ts index 081decdb6f..8dd2980d13 100644 --- a/packages/core/src/task-store/workflow-integrity.ts +++ b/packages/core/src/task-store/workflow-integrity.ts @@ -319,57 +319,36 @@ export async function backfillCommitAssociationDiffStatsImpl(store: TaskStore, o to count affected rows accurately regardless of driver rowCount exposure (the async-lifecycle.ts precedent). */ - let candidates: CommitAssociationDiffBackfillCandidateRow[]; - let applyUpdate: (commitSha: string, additions: number, deletions: number) => Promise; - if (store.backendMode) { - const layer = store.asyncLayer!; - const grouped = await layer.db - .select({ - commitSha: schema.project.taskCommitAssociations.commitSha, - rowCount: sql`count(*)`, - }) - .from(schema.project.taskCommitAssociations) + const layer = store.asyncLayer!; + const grouped = await layer.db + .select({ + commitSha: schema.project.taskCommitAssociations.commitSha, + rowCount: sql`count(*)`, + }) + .from(schema.project.taskCommitAssociations) + .where( + and( + isNull(schema.project.taskCommitAssociations.additions), + isNull(schema.project.taskCommitAssociations.deletions), + ), + ) + .groupBy(schema.project.taskCommitAssociations.commitSha) + .orderBy(asc(schema.project.taskCommitAssociations.commitSha)); + const candidates = grouped as unknown as CommitAssociationDiffBackfillCandidateRow[]; + const applyUpdate = async (commitSha: string, additions: number, deletions: number) => { + const updated = await layer.db + .update(schema.project.taskCommitAssociations) + .set({ additions, deletions, updatedAt: new Date().toISOString() }) .where( and( + eq(schema.project.taskCommitAssociations.commitSha, commitSha), isNull(schema.project.taskCommitAssociations.additions), isNull(schema.project.taskCommitAssociations.deletions), ), ) - .groupBy(schema.project.taskCommitAssociations.commitSha) - .orderBy(asc(schema.project.taskCommitAssociations.commitSha)); - candidates = grouped as unknown as CommitAssociationDiffBackfillCandidateRow[]; - applyUpdate = async (commitSha, additions, deletions) => { - const updated = await layer.db - .update(schema.project.taskCommitAssociations) - .set({ additions, deletions, updatedAt: new Date().toISOString() }) - .where( - and( - eq(schema.project.taskCommitAssociations.commitSha, commitSha), - isNull(schema.project.taskCommitAssociations.additions), - isNull(schema.project.taskCommitAssociations.deletions), - ), - ) - .returning({ id: schema.project.taskCommitAssociations.id }); - return updated.length; - }; - } else { - candidates = store.db.prepare( - `SELECT commitSha, COUNT(*) AS rowCount - FROM task_commit_associations - WHERE additions IS NULL AND deletions IS NULL - GROUP BY commitSha - ORDER BY commitSha`, - ).all() as CommitAssociationDiffBackfillCandidateRow[]; - const updateStats = store.db.prepare( - `UPDATE task_commit_associations - SET additions = ?, deletions = ?, updatedAt = ? - WHERE commitSha = ? AND additions IS NULL AND deletions IS NULL`, - ); - applyUpdate = async (commitSha, additions, deletions) => { - const result = updateStats.run(additions, deletions, new Date().toISOString(), commitSha); - return Number(result.changes); - }; - } + .returning({ id: schema.project.taskCommitAssociations.id }); + return updated.length; + }; const report: CommitAssociationDiffBackfillReport = { scannedRows: candidates.reduce((sum, row) => sum + row.rowCount, 0), diff --git a/packages/core/src/task-store/workflow-ops.ts b/packages/core/src/task-store/workflow-ops.ts index 79735028d2..c6d93a796a 100644 --- a/packages/core/src/task-store/workflow-ops.ts +++ b/packages/core/src/task-store/workflow-ops.ts @@ -8,7 +8,7 @@ */ import {TaskStore} from "../store.js"; import type {Settings} from "../types.js"; -import {parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure} from "../workflow-ir.js"; +import {parseWorkflowIr, downgradeIrToV1IfPure} from "../workflow-ir.js"; import {OccupiedColumnsError, assertRehomeTargetValid, computeRemovedOccupiedColumns, computeIncompatibleFieldChanges, IncompatibleFieldChangeError, resolveEntryColumnId} from "../workflow-reconciliation.js"; import {BUILTIN_CODING_WORKFLOW_IR} from "../builtin-coding-workflow-ir.js"; import type {WorkflowFieldDefinition} from "../workflow-ir-types.js"; @@ -20,7 +20,7 @@ import {fromJson} from "../db.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import * as schema from "../postgres/schema/index.js"; import {readProjectConfig, writeProjectConfig} from "../task-store/async-settings.js"; -import {eq, inArray} from "drizzle-orm"; +import {and, eq, inArray} from "drizzle-orm"; import type {AsyncDataLayer} from "../postgres/data-layer.js"; export async function createWorkflowStepImpl(store: TaskStore, input: import("../types.js").WorkflowStepInput): Promise { @@ -32,17 +32,14 @@ export async function createWorkflowStepImpl(store: TaskStore, input: import(".. * backend mode, read the counter via readProjectConfig, insert the row * via Drizzle, and bump the counter via writeProjectConfig. */ - let nextWsId: number; - if (store.backendMode) { - const layer = store.asyncLayer!; - const configRow = await readProjectConfig(layer); - nextWsId = configRow.nextWorkflowStepId ?? 1; - } else { - const counterRow = store.db - .prepare("SELECT nextWorkflowStepId FROM config WHERE id = 1") - .get() as { nextWorkflowStepId?: number } | undefined; - nextWsId = counterRow?.nextWorkflowStepId || 1; - } + /* + FNXC:SqliteDualPathCleanup 2026-07-26-13:35: + Workflow step creation is PostgreSQL-only after dual-path collapse; reuse one AsyncDataLayer binding for counter read + insert. + */ + const layer = store.asyncLayer!; + const configRow = await readProjectConfig(layer); + const nextWsId = configRow.nextWorkflowStepId ?? 1; + const id = `WS-${String(nextWsId).padStart(3, "0")}`; const mode = input.mode || "prompt"; @@ -74,133 +71,8 @@ export async function createWorkflowStepImpl(store: TaskStore, input: import(".. updatedAt: now, }; - if (store.backendMode) { - const layer = store.asyncLayer!; - await layer.db.insert(schema.project.workflowSteps).values({ - id: step.id, - templateId: step.templateId ?? null, - name: step.name, - description: step.description, - mode: step.mode, - phase: step.phase || "pre-merge", - gateMode: step.gateMode, - prompt: step.prompt, - toolMode: step.toolMode ?? null, - scriptName: step.scriptName ?? null, - enabled: step.enabled ? 1 : 0, - defaultOn: step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, - modelProvider: step.modelProvider ?? null, - modelId: step.modelId ?? null, - migratedFragmentId: step.migratedFragmentId ?? null, - createdAt: step.createdAt, - updatedAt: step.updatedAt, - }); - await writeProjectConfig(layer, {}, { nextWorkflowStepId: nextWsId + 1 }); - store.workflowStepsCache = null; - return step; - } - - store.db.prepare( - `INSERT INTO workflow_steps ( - id, - templateId, - name, - description, - mode, - phase, - gateMode, - prompt, - toolMode, - scriptName, - enabled, - defaultOn, - modelProvider, - modelId, - migrated_fragment_id, - createdAt, - updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - step.id, - step.templateId ?? null, - step.name, - step.description, - step.mode, - step.phase || "pre-merge", - step.gateMode, - step.prompt, - step.toolMode ?? null, - step.scriptName ?? null, - step.enabled ? 1 : 0, - step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, - step.modelProvider ?? null, - step.modelId ?? null, - step.migratedFragmentId ?? null, - step.createdAt, - step.updatedAt, - ); - - const config = await store.readConfig(); - await store.writeConfig(config, { nextWorkflowStepId: nextWsId + 1 }); - store.workflowStepsCache = null; - - return step; - }); - } - -export async function updateWorkflowStepImpl(store: TaskStore, id: string, updates: Partial): Promise { - // FNXC:PostgresCutover 2026-06-28-10:00: - // Backend-mode branch: read the step row via Drizzle, apply updates, write back. - if (store.backendMode) { - const layer = store.asyncLayer!; - const rows = await layer.db.select().from(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, id)).limit(1); - const pgRow = rows[0]; - if (!pgRow) throw new Error(`Workflow step '${id}' not found`); - - const step = store.toStoredWorkflowStep({ - id: pgRow.id, - templateId: pgRow.templateId, - name: pgRow.name, - description: pgRow.description, - mode: pgRow.mode, - phase: pgRow.phase, - gateMode: pgRow.gateMode, - prompt: pgRow.prompt, - toolMode: pgRow.toolMode, - scriptName: pgRow.scriptName, - enabled: pgRow.enabled, - defaultOn: pgRow.defaultOn, - modelProvider: pgRow.modelProvider, - modelId: pgRow.modelId, - migrated_fragment_id: pgRow.migratedFragmentId, - createdAt: pgRow.createdAt, - updatedAt: pgRow.updatedAt, - }); - - if (updates.mode !== undefined) { - const newMode = updates.mode; - if (newMode === "script" && !updates.scriptName?.trim() && !step.scriptName?.trim()) { - throw new Error("Script mode requires a scriptName"); - } - step.mode = newMode; - if (newMode === "script") { step.prompt = ""; step.gateMode = step.gateMode || "gate"; step.toolMode = undefined; step.modelProvider = undefined; step.modelId = undefined; } - if (newMode === "prompt") { step.scriptName = undefined; step.gateMode = step.gateMode || "advisory"; step.toolMode = step.toolMode || "readonly"; } - } - if (updates.name !== undefined) step.name = updates.name; - if (updates.description !== undefined) step.description = updates.description; - if (updates.phase !== undefined) step.phase = updates.phase; - if (updates.gateMode !== undefined) step.gateMode = updates.gateMode; - if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt; - if (updates.toolMode !== undefined && step.mode === "prompt") step.toolMode = updates.toolMode; - if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName; - if (updates.enabled !== undefined) step.enabled = updates.enabled; - if (updates.defaultOn !== undefined) step.defaultOn = updates.defaultOn; - if (step.mode === "script" && !step.scriptName?.trim()) throw new Error("Script mode requires a scriptName"); - if (step.mode === "prompt") { if ("modelProvider" in updates) step.modelProvider = updates.modelProvider; if ("modelId" in updates) step.modelId = updates.modelId; } - if ("migratedFragmentId" in updates) step.migratedFragmentId = updates.migratedFragmentId; - step.updatedAt = new Date().toISOString(); - - await layer.db.update(schema.project.workflowSteps).set({ + await layer.db.insert(schema.project.workflowSteps).values({ + id: step.id, templateId: step.templateId ?? null, name: step.name, description: step.description, @@ -215,64 +87,56 @@ export async function updateWorkflowStepImpl(store: TaskStore, id: string, updat modelProvider: step.modelProvider ?? null, modelId: step.modelId ?? null, migratedFragmentId: step.migratedFragmentId ?? null, + createdAt: step.createdAt, updatedAt: step.updatedAt, - }).where(eq(schema.project.workflowSteps.id, id)); - + }); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + writeProjectConfig replaces the settings jsonb wholesale — pass the existing settings so bumping nextWorkflowStepId cannot wipe project config to {}. + */ + await writeProjectConfig(layer, (configRow.settings ?? {}) as Record, { nextWorkflowStepId: nextWsId + 1 }); store.workflowStepsCache = null; return step; - } +}); + } - const row = store.db.prepare("SELECT * FROM workflow_steps WHERE id = ?").get(id) as - | { - id: string; - templateId: string | null; - name: string; - description: string; - mode: string; - phase: string | null; - gateMode: string | null; - prompt: string; - toolMode: string | null; - scriptName: string | null; - enabled: number; - defaultOn: number | null; - modelProvider: string | null; - modelId: string | null; - createdAt: string; - updatedAt: string; - } - | undefined; +export async function updateWorkflowStepImpl(store: TaskStore, id: string, updates: Partial): Promise { + // FNXC:PostgresCutover 2026-06-28-10:00: + // Backend-mode branch: read the step row via Drizzle, apply updates, write back. + const layer = store.asyncLayer!; + const rows = await layer.db.select().from(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, id)).limit(1); + const pgRow = rows[0]; + if (!pgRow) throw new Error(`Workflow step '${id}' not found`); - if (!row) { - throw new Error(`Workflow step '${id}' not found`); - } + const step = store.toStoredWorkflowStep({ + id: pgRow.id, + templateId: pgRow.templateId, + name: pgRow.name, + description: pgRow.description, + mode: pgRow.mode, + phase: pgRow.phase, + gateMode: pgRow.gateMode, + prompt: pgRow.prompt, + toolMode: pgRow.toolMode, + scriptName: pgRow.scriptName, + enabled: pgRow.enabled, + defaultOn: pgRow.defaultOn, + modelProvider: pgRow.modelProvider, + modelId: pgRow.modelId, + migrated_fragment_id: pgRow.migratedFragmentId, + createdAt: pgRow.createdAt, + updatedAt: pgRow.updatedAt, + }); - const step = store.toStoredWorkflowStep(row); - - // Handle mode change if (updates.mode !== undefined) { const newMode = updates.mode; - // Validate: script mode requires scriptName if (newMode === "script" && !updates.scriptName?.trim() && !step.scriptName?.trim()) { throw new Error("Script mode requires a scriptName"); } step.mode = newMode; - // When switching to script mode, clear prompt and model overrides - if (newMode === "script") { - step.prompt = ""; - step.gateMode = step.gateMode || "gate"; - step.toolMode = undefined; - step.modelProvider = undefined; - step.modelId = undefined; - } - // When switching to prompt mode, clear scriptName - if (newMode === "prompt") { - step.scriptName = undefined; - step.gateMode = step.gateMode || "advisory"; - step.toolMode = step.toolMode || "readonly"; - } + if (newMode === "script") { step.prompt = ""; step.gateMode = step.gateMode || "gate"; step.toolMode = undefined; step.modelProvider = undefined; step.modelId = undefined; } + if (newMode === "prompt") { step.scriptName = undefined; step.gateMode = step.gateMode || "advisory"; step.toolMode = step.toolMode || "readonly"; } } - if (updates.name !== undefined) step.name = updates.name; if (updates.description !== undefined) step.description = updates.description; if (updates.phase !== undefined) step.phase = updates.phase; @@ -282,61 +146,37 @@ export async function updateWorkflowStepImpl(store: TaskStore, id: string, updat if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName; if (updates.enabled !== undefined) step.enabled = updates.enabled; if (updates.defaultOn !== undefined) step.defaultOn = updates.defaultOn; - if (step.mode === "script" && !step.scriptName?.trim()) { - throw new Error("Script mode requires a scriptName"); - } - if (step.mode === "prompt") { - if ("modelProvider" in updates) step.modelProvider = updates.modelProvider; - if ("modelId" in updates) step.modelId = updates.modelId; - } + if (step.mode === "script" && !step.scriptName?.trim()) throw new Error("Script mode requires a scriptName"); + if (step.mode === "prompt") { if ("modelProvider" in updates) step.modelProvider = updates.modelProvider; if ("modelId" in updates) step.modelId = updates.modelId; } if ("migratedFragmentId" in updates) step.migratedFragmentId = updates.migratedFragmentId; step.updatedAt = new Date().toISOString(); - store.db.prepare( - `UPDATE workflow_steps - SET templateId = ?, - name = ?, - description = ?, - mode = ?, - phase = ?, - gateMode = ?, - prompt = ?, - toolMode = ?, - scriptName = ?, - enabled = ?, - defaultOn = ?, - modelProvider = ?, - modelId = ?, - migrated_fragment_id = ?, - updatedAt = ? - WHERE id = ?`, - ).run( - step.templateId ?? null, - step.name, - step.description, - step.mode, - step.phase || "pre-merge", - step.gateMode, - step.prompt, - step.toolMode ?? null, - step.scriptName ?? null, - step.enabled ? 1 : 0, - step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, - step.modelProvider ?? null, - step.modelId ?? null, - step.migratedFragmentId ?? null, - step.updatedAt, - step.id, - ); - store.db.bumpLastModified(); - store.workflowStepsCache = null; + await layer.db.update(schema.project.workflowSteps).set({ + templateId: step.templateId ?? null, + name: step.name, + description: step.description, + mode: step.mode, + phase: step.phase || "pre-merge", + gateMode: step.gateMode, + prompt: step.prompt, + toolMode: step.toolMode ?? null, + scriptName: step.scriptName ?? null, + enabled: step.enabled ? 1 : 0, + defaultOn: step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, + modelProvider: step.modelProvider ?? null, + modelId: step.modelId ?? null, + migratedFragmentId: step.migratedFragmentId ?? null, + updatedAt: step.updatedAt, + }).where(eq(schema.project.workflowSteps.id, id)); + store.workflowStepsCache = null; return step; - } +} export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, updates: WorkflowDefinitionUpdate,): Promise { if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be edited"); - const layer: AsyncDataLayer | null = store.backendMode ? store.asyncLayer : null; + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:08: workflow definition deletes require AsyncDataLayer. */ + const layer: AsyncDataLayer = store.asyncLayer!; // U5 (R20): flag-ON edits that remove an occupied column block with a typed // OccupiedColumnsError unless `rehomeTo` is supplied. Computed before taking // the config lock (pure DB reads) so the lock body stays focused. @@ -361,23 +201,16 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, // 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 = await store.listWorkflowOccupantTaskIds(id, false); - let occupantTaskIds: string[]; - if (layer) { - // FNXC:PostgresCutover 2026-06-28: async read for column check - const taskRows = await layer.db.select({id: schema.project.tasks.id, column: schema.project.tasks.column}).from(schema.project.tasks).where(inArray(schema.project.tasks.id, allOccupantTaskIds)); - const colMap = new Map(taskRows.map(r => [r.id, r.column])); - occupantTaskIds = allOccupantTaskIds.filter(tid => { - const col = colMap.get(tid); - return col ? removedSet.has(col) : false; - }); - } else { - occupantTaskIds = allOccupantTaskIds.filter((taskId) => { - const row = store.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as - | { column: string } - | undefined; - return row ? removedSet.has(row.column) : false; - }); - } + // FNXC:PostgresCutover 2026-06-28: async read for column check + // FNXC:SqliteDualPathCleanup 2026-07-26-15:00: prefer-const after dual-path collapse; project-scope occupant column lookup. + const occupantConds = [inArray(schema.project.tasks.id, allOccupantTaskIds)]; + if (layer.projectId) occupantConds.push(eq(schema.project.tasks.projectId, layer.projectId)); + const taskRows = await layer.db.select({id: schema.project.tasks.id, column: schema.project.tasks.column}).from(schema.project.tasks).where(and(...occupantConds)); + const colMap = new Map(taskRows.map(r => [r.id, r.column])); + const occupantTaskIds = allOccupantTaskIds.filter(tid => { + const col = colMap.get(tid); + return col ? removedSet.has(col) : false; + }); pendingRehome = { rehomeTo: updates.rehomeTo, occupantTaskIds }; } } @@ -405,17 +238,12 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, const occupantsByField = new Map(); for (const taskId of occupantTaskIds) { let values: Record = {}; - if (layer) { - const taskRows = await layer.db.select({customFields: schema.project.tasks.customFields}).from(schema.project.tasks).where(eq(schema.project.tasks.id, taskId)).limit(1); - const cf = taskRows[0]?.customFields; - if (cf && typeof cf === "object") values = cf as Record; - else if (typeof cf === "string") values = fromJson(cf) ?? {}; - } else { - const row = store.db.prepare("SELECT customFields FROM tasks WHERE id = ?").get(taskId) as - | { customFields: string | null } - | undefined; - if (row?.customFields) values = fromJson>(row.customFields) ?? {}; - } + + const taskRows = await layer.db.select({customFields: schema.project.tasks.customFields}).from(schema.project.tasks).where(eq(schema.project.tasks.id, taskId)).limit(1); + const cf = taskRows[0]?.customFields; + if (cf && typeof cf === "object") values = cf as Record; + else if (typeof cf === "string") values = fromJson(cf) ?? {}; + // Incompatible-change detection only blocks on occupants that already // HOLD a value for a field, so count only those. Reconciliation itself // must still touch every occupant so new required+default fields get @@ -461,33 +289,17 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, updatedAt: new Date().toISOString(), }; - if (layer) { - // FNXC:PostgresCutover 2026-06-28: async UPDATE for workflows row - await layer.db.update(schema.project.workflows).set({ - name: next.name, - description: next.description, - icon: next.icon ?? null, - ir: flagOn ? next.ir : downgradeIrToV1IfPure(next.ir), - layout: next.layout, - updatedAt: next.updatedAt, - }).where(eq(schema.project.workflows.id, id)); - } else { - store.db - .prepare( - `UPDATE workflows SET name = ?, description = ?, icon = ?, ir = ?, layout = ?, updatedAt = ? WHERE id = ?`, - ) - .run( - next.name, - next.description, - next.icon ?? null, - // Rollback compat (#1405): persist v1 shape when pure and flag OFF. - serializeWorkflowIr(flagOn ? next.ir : downgradeIrToV1IfPure(next.ir)), - JSON.stringify(next.layout), - next.updatedAt, - id, - ); - store.db.bumpLastModified(); - } + + // FNXC:PostgresCutover 2026-06-28: async UPDATE for workflows row + await layer.db.update(schema.project.workflows).set({ + name: next.name, + description: next.description, + icon: next.icon ?? null, + ir: flagOn ? next.ir : downgradeIrToV1IfPure(next.ir), + layout: next.layout, + updatedAt: next.updatedAt, + }).where(eq(schema.project.workflows.id, id)); + store.workflowDefinitionsCache = null; return next; }); @@ -524,27 +336,22 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, export async function deleteWorkflowDefinitionImpl(store: TaskStore, id: string): Promise { if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be deleted"); - const layer: AsyncDataLayer | null = store.backendMode ? store.asyncLayer : null; + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:08: workflow definition deletes require AsyncDataLayer. */ + const layer: AsyncDataLayer = store.asyncLayer!; // U5 (R20): flag-ON, capture the occupant task ids BEFORE the cascade clears // 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 ? await store.listWorkflowOccupantTaskIds(id, false) : []; - if (layer) { - // FNXC:PostgresCutover 2026-06-28: async deletes for backend mode - const deleted = await layer.db.delete(schema.project.workflows).where(eq(schema.project.workflows.id, id)).returning(); - if (deleted.length === 0) throw new Error(`Workflow '${id}' not found`); - store.workflowDefinitionsCache = null; - await layer.db.delete(schema.project.workflowSettings).where(eq(schema.project.workflowSettings.workflowId, id)); - await layer.db.delete(schema.project.workflowPromptOverrides).where(eq(schema.project.workflowPromptOverrides.workflowId, id)); - } else { - const deleted = store.db.prepare("DELETE FROM workflows WHERE id = ?").run(id) as { changes?: number }; - if ((deleted.changes || 0) === 0) throw new Error(`Workflow '${id}' not found`); - store.workflowDefinitionsCache = null; - store.db.prepare("DELETE FROM workflow_settings WHERE workflowId = ?").run(id); - store.db.prepare("DELETE FROM workflow_prompt_overrides WHERE workflowId = ?").run(id); - } + + // FNXC:PostgresCutover 2026-06-28: async deletes for backend mode + const deleted = await layer.db.delete(schema.project.workflows).where(eq(schema.project.workflows.id, id)).returning(); + if (deleted.length === 0) throw new Error(`Workflow '${id}' not found`); + store.workflowDefinitionsCache = null; + await layer.db.delete(schema.project.workflowSettings).where(eq(schema.project.workflowSettings.workflowId, id)); + await layer.db.delete(schema.project.workflowPromptOverrides).where(eq(schema.project.workflowPromptOverrides.workflowId, id)); + // Cascade: clear the project default when it pointed at this workflow. try { @@ -557,31 +364,23 @@ export async function deleteWorkflowDefinitionImpl(store: TaskStore, id: string) // Cascade: drop selections referencing this workflow, their materialized // step rows, and reset the affected tasks' enabled steps. - let selections: Array<{ taskId: string; stepIds: string }>; - if (layer) { - const selRows = await layer.db.select().from(schema.project.taskWorkflowSelection).where(eq(schema.project.taskWorkflowSelection.workflowId, id)); - selections = selRows.map(r => ({ taskId: r.taskId, stepIds: typeof r.stepIds === "string" ? r.stepIds : JSON.stringify(r.stepIds ?? []) })); - } else { - selections = store.db - .prepare("SELECT taskId, stepIds FROM task_workflow_selection WHERE workflowId = ?") - .all(id) as Array<{ taskId: string; stepIds: string }>; - } + const selRows = await layer.db.select().from(schema.project.taskWorkflowSelection).where(eq(schema.project.taskWorkflowSelection.workflowId, id)); + const selections = selRows.map(r => ({ taskId: r.taskId, stepIds: typeof r.stepIds === "string" ? r.stepIds : JSON.stringify(r.stepIds ?? []) })); + for (const row of selections) { try { const stepIds = JSON.parse(row.stepIds) as unknown; if (Array.isArray(stepIds)) { for (const stepId of stepIds) { if (typeof stepId === "string") { - if (layer) { await layer.db.delete(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, stepId)); } - else { store.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId); } + await layer.db.delete(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, stepId)); } } } } catch { // Corrupt stepIds list — still remove the selection row below. } - if (layer) { await layer.db.delete(schema.project.taskWorkflowSelection).where(eq(schema.project.taskWorkflowSelection.taskId, row.taskId)); } - else { store.db.prepare("DELETE FROM task_workflow_selection WHERE taskId = ?").run(row.taskId); } + await layer.db.delete(schema.project.taskWorkflowSelection).where(eq(schema.project.taskWorkflowSelection.taskId, row.taskId)); try { await store.updateTask(row.taskId, { enabledWorkflowSteps: [] }); } catch { @@ -590,8 +389,7 @@ export async function deleteWorkflowDefinitionImpl(store: TaskStore, id: string) } } if (selections.length > 0) store.workflowStepsCache = null; - if (!layer) store.db.bumpLastModified(); - + // U5 (R20) delete reconciliation: re-home each occupant to the default // workflow's entry column. Their selection rows are already cleared above, // so they now resolve to the built-in default workflow (KTD-1); the re-home @@ -623,7 +421,8 @@ export async function setDefaultWorkflowIdImpl(store: TaskStore, workflowId: str } export async function selectTaskWorkflowImpl(store: TaskStore, taskId: string, workflowId: string): Promise { - const layer: AsyncDataLayer | null = store.backendMode ? store.asyncLayer : null; + /* FNXC:SqliteDualPathCleanup 2026-07-26-14:08: workflow definition deletes require AsyncDataLayer. */ + const layer: AsyncDataLayer = store.asyncLayer!; // Hold the task lock across the whole sequence (materialize → owner write → // prior-step cleanup) so it can't interleave with a concurrent select/clear // or executor updateTask on the same task. updateTaskUnlocked is used inside @@ -663,8 +462,7 @@ export async function selectTaskWorkflowImpl(store: TaskStore, taskId: string, w // Delete them before propagating; the prior selection is left untouched. for (const stepId of ids) { try { - if (layer) { await layer.db.delete(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, stepId)); } - else { store.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId); } + await layer.db.delete(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, stepId)); } catch { // Best-effort cleanup; surface the original error below. } @@ -675,8 +473,7 @@ export async function selectTaskWorkflowImpl(store: TaskStore, taskId: string, w if (priorSelection) { for (const stepId of priorSelection.stepIds) { - if (layer) { await layer.db.delete(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, stepId)); } - else { store.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId); } + await layer.db.delete(schema.project.workflowSteps).where(eq(schema.project.workflowSteps.id, stepId)); } store.workflowStepsCache = null; } diff --git a/packages/core/src/task-store/workflow-task-create-ops.ts b/packages/core/src/task-store/workflow-task-create-ops.ts index c8d24cece7..bd80251170 100644 --- a/packages/core/src/task-store/workflow-task-create-ops.ts +++ b/packages/core/src/task-store/workflow-task-create-ops.ts @@ -30,7 +30,6 @@ import {toJson} from "../db.js"; import {GoalStore} from "../goal-store.js"; import {AsyncGoalStore} from "../async-goal-store.js"; import {normalizeTaskCommitAssociation} from "../task-lineage.js"; -import {type TaskRow} from "../task-store/persistence.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js"; import {upsertTaskRowInTransaction, readTaskRowInTransaction, buildTaskInsertValues} from "../task-store/async-persistence.js"; @@ -38,47 +37,12 @@ import {listDueWorkflowWorkItems as listDueWorkflowWorkItemsAsync, withTaskWorkf import {getTaskMovedCountsByDay as getTaskMovedCountsByDayAsync} from "../task-store/async-audit.js"; import {getAllDocuments as getAllDocumentsAsync} from "../task-store/async-comments-attachments.js"; import {recordGoalCitations as recordGoalCitationsAsync} from "../task-store/async-events.js"; -import type {TaskDocumentRow, GoalCitationRow, WorkflowWorkItemRow} from "../task-store/row-types.js"; +import type {WorkflowWorkItemRow} from "../task-store/row-types.js"; export async function recordGoalCitationsImpl(store: TaskStore, inputs: GoalCitationInput[]): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return recordGoalCitationsAsync(layer.db, inputs); - } - if (inputs.length === 0) { - return []; - } - - const now = new Date().toISOString(); - const stmt = store.db.prepare(` - INSERT OR IGNORE INTO goal_citations (goalId, agentId, taskId, surface, sourceRef, snippet, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING * - `); - - const inserted: GoalCitation[] = []; - store.db.transaction(() => { - for (const input of inputs) { - const row = stmt.get( - input.goalId, - input.agentId, - input.taskId ?? null, - input.surface, - input.sourceRef, - input.snippet, - input.timestamp ?? now, - ) as GoalCitationRow | undefined; - if (row) { - inserted.push(store.rowToGoalCitation(row)); - } - } - if (inserted.length > 0) { - store.db.bumpLastModified(); - } - }); - - return inserted; - } + const layer = store.asyncLayer!; + return recordGoalCitationsAsync(layer.db, inputs); +} export function insertTaskWithFtsRecoveryImpl2(store: TaskStore, task: Task, operation: string): void { const normalizeConflict = (error: unknown): never => { @@ -110,84 +74,76 @@ export async function atomicWriteTaskJsonImpl2(store: TaskStore, dir: string, ta // The upsert (INSERT ... ON CONFLICT DO UPDATE) updates the existing row in // place. This is an update-only path (never create); create paths use // insertTaskRowInTransaction (non-destructive plain insert). - if (store.backendMode) { - const layer = store.asyncLayer!; + const layer = store.asyncLayer!; + /* + FNXC:PostgresCutover 2026-07-10: + Parity with the SQLite branch below: write ONLY the columns this update + actually changed (getChangedTaskColumns against the row read inside the + transaction), never a full-row upsert from the caller's snapshot. The + previous full-row upsert silently clobbered any column another writer + committed between this caller's read and its write — the lost-update + class behind triage's `status: "planning"` clear never taking effect + (a card then reads as "unplanned" forever and the scheduler refuses to + dispatch it). SQLite never had this bug because patchTaskRowInTransaction + always wrote the changed-column subset. + */ + await layer.transactionImmediate(async (tx) => { + const persist = async () => { + 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 + // upsertTaskWithFtsRecovery fallback for vanished rows). + // FNXC:MultiProjectIsolation 2026-07-10: preserve the bound projectId partition key. + if (!pgRow) { + const context = store.createTaskPersistSerializationContext(task); + await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); + } + return; + } + const existingRow = store.pgRowToTaskRow(pgRow); + const deletedAt = store.getSoftDeletedWriteConflict(id, task, existingRow); + if (deletedAt) { + store.throwSoftDeletedWriteBlocked(id, deletedAt, "atomicWriteTaskJson"); + } + const changedColumns = store.getChangedTaskColumns(existingRow, task); + if (changedColumns.size === 0) { + return; + } + const context = store.createTaskPersistSerializationContext(task, existingRow); + const allValues = buildTaskInsertValues(task as unknown as Record, context); + const setValues: Record = { updatedAt: task.updatedAt }; + for (const column of changedColumns) { + if (column === "id") continue; + setValues[column as string] = allValues[column as string]; + } /* - FNXC:PostgresCutover 2026-07-10: - Parity with the SQLite branch below: write ONLY the columns this update - actually changed (getChangedTaskColumns against the row read inside the - transaction), never a full-row upsert from the caller's snapshot. The - previous full-row upsert silently clobbered any column another writer - committed between this caller's read and its write — the lost-update - class behind triage's `status: "planning"` clear never taking effect - (a card then reads as "unplanned" forever and the scheduler refuses to - dispatch it). SQLite never had this bug because patchTaskRowInTransaction - always wrote the changed-column subset. + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Project-scope the changed-column update so a matching task id in another project cannot be rewritten. */ - await layer.transactionImmediate(async (tx) => { - const persist = async () => { - 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 - // upsertTaskWithFtsRecovery fallback for vanished rows). - // FNXC:MultiProjectIsolation 2026-07-10: preserve the bound projectId partition key. - if (!pgRow) { - const context = store.createTaskPersistSerializationContext(task); - await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); - } - return; - } - const existingRow = store.pgRowToTaskRow(pgRow); - const deletedAt = store.getSoftDeletedWriteConflict(id, task, existingRow); - if (deletedAt) { - store.throwSoftDeletedWriteBlocked(id, deletedAt, "atomicWriteTaskJson"); - } - const changedColumns = store.getChangedTaskColumns(existingRow, task); - if (changedColumns.size === 0) { - return; - } - const context = store.createTaskPersistSerializationContext(task, existingRow); - const allValues = buildTaskInsertValues(task as unknown as Record, context); - const setValues: Record = { updatedAt: task.updatedAt }; - for (const column of changedColumns) { - if (column === "id") continue; - setValues[column as string] = allValues[column as string]; - } - await tx - .update(schema.project.tasks) - .set(setValues as never) - .where(eq(schema.project.tasks.id, id)); - }; - /* - FNXC:WorkflowSerialization 2026-07-26-15:30: - FN-8592's conditional continuation seed checks for a passed plan-review - under its task advisory lock. The generic task persistence path is the - sole terminal result writer, so a plan-review pass must take that same - lock as the first transaction lock before its row write can commit. - */ - if (task.workflowStepResults?.some((result) => result.workflowStepId === "plan-review" && result.status === "passed")) { - await withTaskWorkflowSerialization(tx, layer.projectId, id, persist); - } else { - await persist(); - } - }); - await store.writeTaskJsonFile(dir, task); - return; - } - let result: { deletedAt?: string; current?: Task } | undefined; - store.db.transactionImmediate(() => { - const existingRow = store.readTaskRowFromDb(id, { includeDeleted: true }); - const changedColumns = existingRow && existingRow.deletedAt == null - ? store.getChangedTaskColumns(existingRow, task) - : new Set(); - result = store.patchTaskRowInTransaction(id, task, changedColumns, existingRow); + const updateConds = [eq(schema.project.tasks.id, id)]; + if (layer.projectId) updateConds.push(eq(schema.project.tasks.projectId, layer.projectId)); + await tx + .update(schema.project.tasks) + .set(setValues as never) + .where(and(...updateConds)); + }; + /* + FNXC:WorkflowSerialization 2026-07-26-15:30: + FN-8592's conditional continuation seed checks for a passed plan-review + under its task advisory lock. The generic task persistence path is the + sole terminal result writer, so a plan-review pass must take that same + lock as the first transaction lock before its row write can commit. + */ + if (task.workflowStepResults?.some((result) => result.workflowStepId === "plan-review" && result.status === "passed")) { + await withTaskWorkflowSerialization(tx, layer.projectId, id, persist); + } else { + await persist(); + } }); - if (result?.deletedAt) { - store.throwSoftDeletedWriteBlocked(id, result.deletedAt, "atomicWriteTaskJson"); - } - await store.writeTaskJsonFile(dir, result?.current ?? task); - } + await store.writeTaskJsonFile(dir, task); + return; +} export async function createTaskWithDistributedReservationImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; createTaskWithId?: (taskId: string) => Promise; },): Promise { const settings = await store.getSettingsFast(); @@ -357,62 +313,36 @@ export async function getTaskColumnsImpl(store: TaskStore, ids: string[]): Promi every agent-linked task read as non-terminal on PostgreSQL). Async reads: live columns from project.tasks, then archive membership for the misses. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const rows = await layer.db - .select({ id: schema.project.tasks.id, column: schema.project.tasks.column }) - .from(schema.project.tasks) - .where(and(inArray(schema.project.tasks.id, uniqueIds), isNull(schema.project.tasks.deletedAt))); - const activeByIdPg = new Map(); - for (const row of rows) { - activeByIdPg.set(row.id, row.column as Column); - } - const missingPg = uniqueIds.filter((id) => !activeByIdPg.has(id)); - const archivedPg = missingPg.length > 0 - ? await filterArchivedAsync(layer.db, missingPg, layer.projectId) - : new Set(); - const resultPg = new Map(); - for (const id of uniqueIds) { - const activeColumn = activeByIdPg.get(id); - if (activeColumn !== undefined) { - resultPg.set(id, activeColumn); - } else if (archivedPg.has(id)) { - resultPg.set(id, "archived"); - } - } - return resultPg; - } - const placeholders = uniqueIds.map(() => "?").join(","); - const rows = store.db - .prepare(`SELECT id, "column" FROM tasks WHERE id IN (${placeholders}) AND ${TaskStore.ACTIVE_TASKS_WHERE}`) - .all(...uniqueIds) as Array<{ id: string; column: Column }>; - - const activeById = new Map(); + const layer = store.asyncLayer!; + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Project-scope getTaskColumns so a shared task id from another project cannot populate the map. + */ + const colConds = [inArray(schema.project.tasks.id, uniqueIds), isNull(schema.project.tasks.deletedAt)]; + if (layer.projectId) colConds.push(eq(schema.project.tasks.projectId, layer.projectId)); + const rows = await layer.db + .select({ id: schema.project.tasks.id, column: schema.project.tasks.column }) + .from(schema.project.tasks) + .where(and(...colConds)); + const activeByIdPg = new Map(); for (const row of rows) { - activeById.set(row.id, row.column); + activeByIdPg.set(row.id, row.column as Column); } - - const missingIds: string[] = []; + const missingPg = uniqueIds.filter((id) => !activeByIdPg.has(id)); + const archivedPg = missingPg.length > 0 + ? await filterArchivedAsync(layer.db, missingPg, layer.projectId) + : new Set(); + const resultPg = new Map(); for (const id of uniqueIds) { - if (!activeById.has(id)) { - missingIds.push(id); - } - } - - const archivedSet = missingIds.length > 0 ? store.archiveDb.filterArchived(missingIds) : new Set(); - - const result = new Map(); - for (const id of uniqueIds) { - const activeColumn = activeById.get(id); + const activeColumn = activeByIdPg.get(id); if (activeColumn !== undefined) { - result.set(id, activeColumn); - } else if (archivedSet.has(id)) { - result.set(id, "archived"); + resultPg.set(id, activeColumn); + } else if (archivedPg.has(id)) { + resultPg.set(id, "archived"); } } - - return result; - } + return resultPg; +} export async function prepareWorkflowMovePolicyPreflightImpl(store: TaskStore, id: string, toColumn: ColumnId, options: MoveTaskOptions | undefined, internal: MoveTaskInternalOptions,): Promise { const task = await store.readTaskForMove(id); @@ -471,104 +401,45 @@ export async function listWorkflowPromptOverridesForProjectImpl(store: TaskStore const projectId = store.getWorkflowSettingsProjectId(); // FNXC:PostgresOnlyDataAccess 2026-07-16-12:25: backend branch added so // this public method cannot throw the sync-SQLite error on PostgreSQL. - if (store.backendMode) { - const table = schema.project.workflowPromptOverrides; - const pgRows = await store.asyncLayer!.db - .select({ workflowId: table.workflowId, overrides: table.overrides }) - .from(table) - .where(eq(table.projectId, projectId)); - const outPg: Record> = {}; - for (const row of pgRows) { - // jsonb column: drizzle returns the parsed object (getWorkflowPromptOverridesAsyncImpl parity). - const overrides = row.overrides; - if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) continue; - const entry: Record = {}; - for (const [nodeId, value] of Object.entries(overrides as Record)) { - if (typeof value === "string" && value.trim()) entry[nodeId] = value; - } - outPg[row.workflowId] = entry; + const table = schema.project.workflowPromptOverrides; + const pgRows = await store.asyncLayer!.db + .select({ workflowId: table.workflowId, overrides: table.overrides }) + .from(table) + .where(eq(table.projectId, projectId)); + const outPg: Record> = {}; + for (const row of pgRows) { + // jsonb column: drizzle returns the parsed object (getWorkflowPromptOverridesAsyncImpl parity). + const overrides = row.overrides; + if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) continue; + const entry: Record = {}; + for (const [nodeId, value] of Object.entries(overrides as Record)) { + if (typeof value === "string" && value.trim()) entry[nodeId] = value; } - return outPg; + outPg[row.workflowId] = entry; } - const rows = store.db - .prepare("SELECT workflowId, overrides FROM workflow_prompt_overrides WHERE projectId = ?") - .all(projectId) as Array<{ workflowId: string; overrides: string }>; - const out: Record> = {}; - for (const row of rows) { - out[row.workflowId] = store.parseWorkflowPromptOverrideJson(row.overrides); - } - return out; - } + return outPg; +} export async function listWorkflowWorkItemsForTaskImpl(store: TaskStore, taskId: string, opts: { kinds?: WorkflowWorkItemKind[] } = {}): Promise { // No dedicated async helper; use a raw Drizzle query in backend mode. - if (store.backendMode) { - const layer = store.asyncLayer!; - const q = layer.db - .select() - .from(schema.project.workflowWorkItems) - .where(eq(schema.project.workflowWorkItems.taskId, taskId)); - const rows = opts.kinds?.length - ? await layer.db - .select() - .from(schema.project.workflowWorkItems) - .where(and(eq(schema.project.workflowWorkItems.taskId, taskId), inArray(schema.project.workflowWorkItems.kind, opts.kinds))) - : await q; - return (rows as WorkflowWorkItemRow[]).map((row) => store.rowToWorkflowWorkItem(row)); - } - const conditions = ["taskId = ?"]; - const params: unknown[] = [taskId]; - if (opts.kinds?.length) { - conditions.push(`kind IN (${opts.kinds.map(() => "?").join(", ")})`); - params.push(...opts.kinds); - } - const rows = store.db - .prepare( - `SELECT * - FROM workflow_work_items - WHERE ${conditions.join(" AND ")} - ORDER BY createdAt ASC, id ASC`, - ) - .all(...params) as WorkflowWorkItemRow[]; - return rows.map((row) => store.rowToWorkflowWorkItem(row)); - } + const layer = store.asyncLayer!; + const q = layer.db + .select() + .from(schema.project.workflowWorkItems) + .where(eq(schema.project.workflowWorkItems.taskId, taskId)); + const rows = opts.kinds?.length + ? await layer.db + .select() + .from(schema.project.workflowWorkItems) + .where(and(eq(schema.project.workflowWorkItems.taskId, taskId), inArray(schema.project.workflowWorkItems.kind, opts.kinds))) + : await q; + return (rows as WorkflowWorkItemRow[]).map((row) => store.rowToWorkflowWorkItem(row)); +} export async function listDueWorkflowWorkItemsImpl(store: TaskStore, filter: WorkflowWorkItemDueFilter = {}): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return listDueWorkflowWorkItemsAsync(layer.db, filter); - } - const now = filter.now ?? new Date().toISOString(); - const includeExpiredRunning = !filter.states || filter.states.includes("running"); - const states = filter.states?.length ? filter.states : ["runnable", "retrying"]; - const stateConditions = [`(state IN (${states.map(() => "?").join(", ")}) AND (leaseExpiresAt IS NULL OR leaseExpiresAt <= ?))`]; - const params: unknown[] = [...states, now]; - if (includeExpiredRunning) { - stateConditions.push("(state = 'running' AND leaseExpiresAt IS NOT NULL AND leaseExpiresAt <= ?)"); - params.push(now); - } - const conditions = [ - `(${stateConditions.join(" OR ")})`, - "(retryAfter IS NULL OR retryAfter <= ?)", - ]; - params.push(now); - if (filter.kinds?.length) { - conditions.push(`kind IN (${filter.kinds.map(() => "?").join(", ")})`); - params.push(...filter.kinds); - } - params.push(filter.limit ?? 100); - - const rows = store.db - .prepare( - `SELECT * - FROM workflow_work_items - WHERE ${conditions.join(" AND ")} - ORDER BY retryAfter IS NOT NULL, retryAfter ASC, createdAt ASC - LIMIT ?`, - ) - .all(...params) as WorkflowWorkItemRow[]; - return rows.map((row) => store.rowToWorkflowWorkItem(row)); - } + const layer = store.asyncLayer!; + return listDueWorkflowWorkItemsAsync(layer.db, filter); +} export function rewriteBlockedByResidueDependentsForRemovalImpl(store: TaskStore, taskId: string, excludedDependentIds: Set): Task[] { const rewrittenDependents: Task[] = []; @@ -615,40 +486,8 @@ export async function getAllDocumentsImpl(store: TaskStore, options?: { searchQu // PG backend mode: delegate to the AsyncDataLayer helper. The sync JOIN // below dereferences store.db (no SQLite handle in backend mode) and 500'd // the dashboard /api/documents list. - if (store.backendMode) { - return getAllDocumentsAsync(store.asyncLayer!.db, options); - } - const limit = Math.min(Math.max(1, options?.limit ?? 200), 1000); - const offset = Math.max(0, options?.offset ?? 0); - - let sql = ` - SELECT td.*, t.title as taskTitle, t.description as taskDescription, t.column as taskColumn - FROM task_documents td - JOIN tasks t ON td.taskId = t.id - WHERE t.${TaskStore.ACTIVE_TASKS_WHERE} - `; - const params: (string | number)[] = []; - - if (options?.searchQuery && options.searchQuery.trim() !== "") { - const query = `%${options.searchQuery.trim()}%`; - sql += ` AND (td.key LIKE ? OR td.content LIKE ? OR t.title LIKE ?)`; - params.push(query, query, query); - } - - sql += ` ORDER BY td.updatedAt DESC LIMIT ? OFFSET ?`; - params.push(limit, offset); - - const rows = store.db.prepare(sql).all(...params) as unknown as (TaskDocumentRow & { taskTitle: string; taskDescription: string; taskColumn: string })[]; - return rows.map((row) => { - const doc = store.rowToTaskDocument(row); - return { - ...doc, - taskTitle: row.taskTitle, - taskDescription: row.taskDescription, - taskColumn: row.taskColumn, - }; - }); - } + return getAllDocumentsAsync(store.asyncLayer!.db, options); +} export async function deleteWorkflowStepImpl(store: TaskStore, id: string): Promise { /* @@ -659,28 +498,15 @@ export async function deleteWorkflowStepImpl(store: TaskStore, id: string): Prom SQLite-only mtime touch with no PostgreSQL analogue. The task-reference cleanup below is already async and backend-safe, so it runs in both modes. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - const deletedRows = await layer.db - .delete(schema.project.workflowSteps) - .where(eq(schema.project.workflowSteps.id, id)) - .returning({ id: schema.project.workflowSteps.id }); - if (deletedRows.length === 0) { - throw new Error(`Workflow step '${id}' not found`); - } - store.workflowStepsCache = null; - } else { - const deleted = store.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(id) as { - changes?: number; - }; - - if ((deleted.changes || 0) === 0) { - throw new Error(`Workflow step '${id}' not found`); - } - - store.db.bumpLastModified(); - store.workflowStepsCache = null; + const layer = store.asyncLayer!; + const deletedRows = await layer.db + .delete(schema.project.workflowSteps) + .where(eq(schema.project.workflowSteps.id, id)) + .returning({ id: schema.project.workflowSteps.id }); + if (deletedRows.length === 0) { + throw new Error(`Workflow step '${id}' not found`); } + store.workflowStepsCache = null; // Clean up references from existing tasks (best-effort, outside config lock) try { @@ -761,33 +587,9 @@ export async function reconcileTaskCustomFieldsForSchemaImpl(store: TaskStore, t export async function getTaskMovedCountsByDayImpl(store: TaskStore, options: { since: string; until: string; fromColumn?: string; toColumn?: string; }): Promise> { // FNXC:RuntimeWorkflowAsync 2026-06-24-16:05: // Backend-mode: delegate to the async audit helper. - if (store.backendMode) { - const layer = store.asyncLayer!; - return getTaskMovedCountsByDayAsync(layer.db, layer.projectId ?? "", options); - } - let sql = - "SELECT substr(timestamp, 1, 10) AS day, COUNT(*) AS count FROM activityLog WHERE type = 'task:moved' AND timestamp > ? AND timestamp <= ?"; - const params: (string | number)[] = [options.since, options.until]; - - if (options.fromColumn) { - sql += " AND json_extract(metadata, '$.from') = ?"; - params.push(options.fromColumn); - } - - if (options.toColumn) { - sql += " AND json_extract(metadata, '$.to') = ?"; - params.push(options.toColumn); - } - - sql += " GROUP BY substr(timestamp, 1, 10)"; - - const rows = store.db.prepare(sql).all(...params) as Array<{ day: string; count: number }>; - const countsByDay: Record = {}; - for (const row of rows) { - countsByDay[row.day] = row.count; - } - return countsByDay; - } + const layer = store.asyncLayer!; + return getTaskMovedCountsByDayAsync(layer.db, layer.projectId ?? "", options); +} export function getGoalStoreImpl(store: TaskStore): GoalStore | AsyncGoalStore { if (!store.goalStore) { @@ -797,15 +599,12 @@ export function getGoalStoreImpl(store: TaskStore): GoalStore | AsyncGoalStore { // GoalStore (store.db) is used only in legacy SQLite mode. Both expose the // same method names; the dashboard goals routes, mission goal-resolution // helpers, and CLI/agent goal tools await the result so either backend works. - if (store.backendMode) { - const layer = store.getAsyncLayer(); - if (!layer) { - throw new Error("GoalStore is not available: AsyncDataLayer not initialized in backend mode"); - } - store.goalStore = new AsyncGoalStore(layer); - } else { - store.goalStore = new GoalStore(store.fusionDir, store.db); + const layer = store.getAsyncLayer(); + if (!layer) { + throw new Error("GoalStore is not available: AsyncDataLayer not initialized in backend mode"); } + store.goalStore = new AsyncGoalStore(layer); + } return store.goalStore; } @@ -826,72 +625,41 @@ export async function upsertTaskCommitAssociationImpl(store: TaskStore, input: O is the conflict target. id is excluded from the update set (SQLite path keeps the existing id on conflict too). Reached from the merger. */ - if (store.backendMode) { - const layer = store.asyncLayer!; - await layer.db - .insert(schema.project.taskCommitAssociations) - .values({ - id: association.id, - taskLineageId: association.taskLineageId, + const layer = store.asyncLayer!; + await layer.db + .insert(schema.project.taskCommitAssociations) + .values({ + id: association.id, + taskLineageId: association.taskLineageId, + taskIdSnapshot: association.taskIdSnapshot, + commitSha: association.commitSha, + commitSubject: association.commitSubject, + authoredAt: association.authoredAt, + matchedBy: association.matchedBy, + confidence: association.confidence, + note: association.note ?? null, + additions: association.additions ?? null, + deletions: association.deletions ?? null, + createdAt: association.createdAt, + updatedAt: association.updatedAt, + }) + .onConflictDoUpdate({ + target: [ + schema.project.taskCommitAssociations.projectId, + schema.project.taskCommitAssociations.taskLineageId, + schema.project.taskCommitAssociations.commitSha, + schema.project.taskCommitAssociations.matchedBy, + ], + set: { taskIdSnapshot: association.taskIdSnapshot, - commitSha: association.commitSha, commitSubject: association.commitSubject, authoredAt: association.authoredAt, - matchedBy: association.matchedBy, confidence: association.confidence, note: association.note ?? null, additions: association.additions ?? null, deletions: association.deletions ?? null, - createdAt: association.createdAt, updatedAt: association.updatedAt, - }) - .onConflictDoUpdate({ - target: [ - schema.project.taskCommitAssociations.projectId, - schema.project.taskCommitAssociations.taskLineageId, - schema.project.taskCommitAssociations.commitSha, - schema.project.taskCommitAssociations.matchedBy, - ], - set: { - taskIdSnapshot: association.taskIdSnapshot, - commitSubject: association.commitSubject, - authoredAt: association.authoredAt, - confidence: association.confidence, - note: association.note ?? null, - additions: association.additions ?? null, - deletions: association.deletions ?? null, - updatedAt: association.updatedAt, - }, - }); - return association; - } - store.db.prepare( - `INSERT INTO task_commit_associations - (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, additions, deletions, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET - taskIdSnapshot = excluded.taskIdSnapshot, - commitSubject = excluded.commitSubject, - authoredAt = excluded.authoredAt, - confidence = excluded.confidence, - note = excluded.note, - additions = excluded.additions, - deletions = excluded.deletions, - updatedAt = excluded.updatedAt`, - ).run( - association.id, - association.taskLineageId, - association.taskIdSnapshot, - association.commitSha, - association.commitSubject, - association.authoredAt, - association.matchedBy, - association.confidence, - association.note ?? null, - association.additions ?? null, - association.deletions ?? null, - association.createdAt, - association.updatedAt, - ); + }, + }); return association; - } +} diff --git a/packages/core/src/task-store/workflow-workitems-ops-2.ts b/packages/core/src/task-store/workflow-workitems-ops-2.ts index 1acc2c3a07..df7fbcbdb7 100644 --- a/packages/core/src/task-store/workflow-workitems-ops-2.ts +++ b/packages/core/src/task-store/workflow-workitems-ops-2.ts @@ -8,158 +8,38 @@ */ import {TaskStore} from "../store.js"; import * as schema from "../postgres/schema/index.js"; -import {randomUUID} from "node:crypto"; -import {and, eq, inArray} from "drizzle-orm"; +import {and, eq, inArray, isNull, lt, or} from "drizzle-orm"; import type {WorkflowWorkItem, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput} from "../types.js"; import "../builtin-traits.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {replaceActiveTaskWorkflowContinuation as replaceActiveTaskWorkflowContinuationAsync, seedStrandedPlanReviewContinuation as seedStrandedPlanReviewContinuationAsync, upsertWorkflowWorkItem as upsertWorkflowWorkItemAsync, transitionWorkflowWorkItem as transitionWorkflowWorkItemAsync, getWorkflowWorkItem as getWorkflowWorkItemAsync, withTaskWorkflowSerialization} from "../task-store/async-workflow-workitems.js"; -import type {WorkflowWorkItemRow} from "../task-store/row-types.js"; import type {DbTransaction} from "../postgres/data-layer.js"; -function upsertWorkflowWorkItemSyncInTransaction(store: TaskStore, input: WorkflowWorkItemUpsertInput): WorkflowWorkItem { - const existing = store.db - .prepare("SELECT * FROM workflow_work_items WHERE runId = ? AND taskId = ? AND nodeId = ? AND kind = ?") - .get(input.runId, input.taskId, input.nodeId, input.kind) as WorkflowWorkItemRow | undefined; - const now = input.now ?? new Date().toISOString(); - const existingState = existing ? store.normalizeWorkflowWorkItemState(existing.state) : null; - const state = input.state ?? existingState ?? "runnable"; - if (existingState && store.isTerminalWorkflowWorkItemState(existingState) && existingState !== state) { - throw new Error( - `Workflow work item ${existing?.id ?? input.id ?? input.nodeId} is terminal (${existingState}) and cannot be requeued as ${state}`, - ); - } - - const id = existing?.id ?? input.id ?? randomUUID(); - store.db - .prepare( - `INSERT INTO workflow_work_items ( - id, runId, taskId, nodeId, kind, state, attempt, retryAfter, - leaseOwner, leaseExpiresAt, lastError, blockedReason, stableWorkflowRunId, - continuationSequence, waitReason, sourceColumn, targetColumn, irHash, createdAt, updatedAt - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(runId, taskId, nodeId, kind) DO UPDATE SET - state = excluded.state, - attempt = excluded.attempt, - retryAfter = excluded.retryAfter, - leaseOwner = excluded.leaseOwner, - leaseExpiresAt = excluded.leaseExpiresAt, - lastError = excluded.lastError, - blockedReason = excluded.blockedReason, - stableWorkflowRunId = excluded.stableWorkflowRunId, - continuationSequence = excluded.continuationSequence, - waitReason = excluded.waitReason, - sourceColumn = excluded.sourceColumn, - targetColumn = excluded.targetColumn, - irHash = excluded.irHash, - updatedAt = excluded.updatedAt`, - ) - .run( - id, - input.runId, - input.taskId, - input.nodeId, - input.kind, - state, - input.attempt ?? existing?.attempt ?? 0, - input.retryAfter === undefined ? existing?.retryAfter ?? null : input.retryAfter, - input.leaseOwner === undefined ? existing?.leaseOwner ?? null : input.leaseOwner, - input.leaseExpiresAt === undefined ? existing?.leaseExpiresAt ?? null : input.leaseExpiresAt, - input.lastError === undefined ? existing?.lastError ?? null : input.lastError, - input.blockedReason === undefined ? existing?.blockedReason ?? null : input.blockedReason, - input.stableWorkflowRunId === undefined ? existing?.stableWorkflowRunId ?? null : input.stableWorkflowRunId, - input.continuationSequence === undefined ? existing?.continuationSequence ?? null : input.continuationSequence, - input.waitReason === undefined ? existing?.waitReason ?? null : input.waitReason, - input.sourceColumn === undefined ? existing?.sourceColumn ?? null : input.sourceColumn, - input.targetColumn === undefined ? existing?.targetColumn ?? null : input.targetColumn, - input.irHash === undefined ? existing?.irHash ?? null : input.irHash, - existing?.createdAt ?? now, - now, - ); - - const row = store.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; - if (!row) throw new Error(`Failed to upsert workflow work item ${id}`); - store.insertRunAuditEventRow({ - taskId: row.taskId, - runId: row.runId, - domain: "database", - mutationType: "workflowWorkItem:upsert", - target: row.id, - metadata: { id: row.id, nodeId: row.nodeId, kind: row.kind, state: row.state, attempt: row.attempt }, - }); - return store.rowToWorkflowWorkItem(row); -} - export async function upsertWorkflowWorkItemImpl(store: TaskStore, input: WorkflowWorkItemUpsertInput, tx?: DbTransaction): Promise { - if (store.backendMode) { return upsertWorkflowWorkItemAsync(store.asyncLayer!, input, tx); - } - return store.db.transactionImmediate(() => upsertWorkflowWorkItemSyncInTransaction(store, input)); } export async function replaceActiveTaskWorkflowContinuationImpl( store: TaskStore, input: WorkflowWorkItemUpsertInput & { kind: "task" }, ): Promise { - if (store.backendMode) { return replaceActiveTaskWorkflowContinuationAsync(store.asyncLayer!, input); - } - - // Compatibility path for legacy embedded stores. PostgreSQL is the - // authoritative runtime and performs this replacement atomically above. - return store.db.transactionImmediate(() => { - const active = store.db.prepare( - `SELECT id, runId, nodeId, kind FROM workflow_work_items - WHERE taskId = ? AND kind = 'task' AND state IN ('runnable', 'running', 'held', 'retrying')`, - ).all(input.taskId) as Array<{ id: string; runId: string; nodeId: string; kind: string }>; - for (const row of active) { - if (row.runId === input.runId && row.nodeId === input.nodeId && row.kind === input.kind) continue; - store.transitionWorkflowWorkItemSync(row.id, "succeeded", { - leaseOwner: null, - leaseExpiresAt: null, - lastError: null, - }); - } - return upsertWorkflowWorkItemImpl(store, input); - }); } export async function seedStrandedPlanReviewContinuationImpl(store: TaskStore, input: WorkflowWorkItemUpsertInput & { kind: "task" }): Promise<{ seeded: boolean; reason?: "active-continuation" | "plan-review-passed"; workItemId?: string }> { - if (store.backendMode) return seedStrandedPlanReviewContinuationAsync(store.asyncLayer!, input); - /* - FNXC:WorkflowSerialization 2026-07-26-23:58: - The legacy embedded-store fallback preserves FN-8592's conditional-seed - invariant by holding SQLite's immediate write transaction across both - predicate reads and the insert. Do not await list/get/upsert helpers here: - their separate transactions would reopen the race that PostgreSQL closes - with withTaskWorkflowSerialization. + FNXC:SqliteDualPathCleanup 2026-07-26-14:07: + Stranded plan-review continuation seed is PostgreSQL-only (withTaskWorkflowSerialization). The SQLite transactionImmediate fallback is deleted. */ - return store.db.transactionImmediate(() => { - const active = store.db.prepare( - `SELECT id FROM workflow_work_items - WHERE taskId = ? AND state IN ('runnable', 'running', 'held', 'retrying')`, - ).get(input.taskId); - if (active) return { seeded: false, reason: "active-continuation" as const }; - - const task = store.db.prepare("SELECT workflowStepResults FROM tasks WHERE id = ?").get(input.taskId) as { workflowStepResults: string | null } | undefined; - const results = task?.workflowStepResults ? JSON.parse(task.workflowStepResults) as Array<{ workflowStepId?: string; status?: string }> : []; - if (results.some((result) => result.workflowStepId === "plan-review" && result.status === "passed")) { - return { seeded: false, reason: "plan-review-passed" as const }; - } - - const item = upsertWorkflowWorkItemSyncInTransaction(store, input); - return { seeded: true, workItemId: item.id }; - }); + return seedStrandedPlanReviewContinuationAsync(store.asyncLayer!, input); } export async function transitionWorkflowWorkItemImpl(store: TaskStore, id: string, state: WorkflowWorkItemState, patch: WorkflowWorkItemTransitionPatch = {}, tx?: DbTransaction,): Promise { - if (store.backendMode) { - const layer = store.asyncLayer!; - return transitionWorkflowWorkItemAsync(layer, id, state, patch, tx); - } - return store.transitionWorkflowWorkItemSync(id, state, patch); + /* + FNXC:SqliteDualPathCleanup 2026-07-26-14:07: + Workflow work-item transitions are PostgreSQL-only. + */ + return transitionWorkflowWorkItemAsync(store.asyncLayer!, id, state, patch, tx); } export async function acquireWorkflowWorkItemLeaseImpl(store: TaskStore, id: string, leaseOwner: string, opts: { leaseDurationMs: number; now?: string },): Promise { @@ -168,74 +48,54 @@ export async function acquireWorkflowWorkItemLeaseImpl(store: TaskStore, id: str } // No dedicated async helper; use a raw Drizzle UPDATE in backend mode. - if (store.backendMode) { - const layer = store.asyncLayer!; - const now = opts.now ?? new Date().toISOString(); - const leaseExpiresAt = new Date(new Date(now).getTime() + opts.leaseDurationMs).toISOString(); - /* - FNXC:WorkflowSerialization 2026-07-27-00:15: - Claiming a due item changes it into the active `running` state, so it is - an FN-8592 protected writer too. Resolve its owner and take the shared - task lock before the guarded update; otherwise a lease could land between - conditional repair's idle check and insert. - */ - const updated = await layer.transactionImmediate(async (tx) => { - const owner = await getWorkflowWorkItemAsync(tx, id); - if (!owner) return null; - return withTaskWorkflowSerialization(tx, layer.projectId, owner.taskId, async () => { - await tx - .update(schema.project.workflowWorkItems) - .set({ state: "running", leaseOwner, leaseExpiresAt, updatedAt: now }) - .where(and( - eq(schema.project.workflowWorkItems.id, id), - inArray(schema.project.workflowWorkItems.state, ["runnable", "retrying", "running"]), - )); - const claimed = await getWorkflowWorkItemAsync(tx, id); - return claimed?.leaseOwner === leaseOwner ? claimed : null; - }); + const layer = store.asyncLayer!; + const now = opts.now ?? new Date().toISOString(); + const leaseExpiresAt = new Date(new Date(now).getTime() + opts.leaseDurationMs).toISOString(); + /* + FNXC:WorkflowSerialization 2026-07-27-00:15: + Claiming a due item changes it into the active `running` state, so it is + an FN-8592 protected writer too. Resolve its owner and take the shared + task lock before the guarded update; otherwise a lease could land between + conditional repair's idle check and insert. + */ + const updated = await layer.transactionImmediate(async (tx) => { + const owner = await getWorkflowWorkItemAsync(tx, id); + if (!owner) return null; + return withTaskWorkflowSerialization(tx, layer.projectId, owner.taskId, async () => { + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:00: + Atomic lease claim: only transition runnable/retrying, or running rows whose lease has already expired — never steal a live lease. + */ + await tx + .update(schema.project.workflowWorkItems) + .set({ state: "running", leaseOwner, leaseExpiresAt, updatedAt: now }) + .where(and( + eq(schema.project.workflowWorkItems.id, id), + or( + inArray(schema.project.workflowWorkItems.state, ["runnable", "retrying"]), + and( + eq(schema.project.workflowWorkItems.state, "running"), + or( + isNull(schema.project.workflowWorkItems.leaseExpiresAt), + lt(schema.project.workflowWorkItems.leaseExpiresAt, now), + ), + ), + ), + )); + const claimed = await getWorkflowWorkItemAsync(tx, id); + return claimed?.leaseOwner === leaseOwner ? claimed : null; }); - if (!updated) return null; - // Record the audit event (fire-and-forget). - void store.recordRunAuditEvent({ - taskId: updated.taskId, - agentId: "system", - runId: updated.runId, - domain: "database", - mutationType: "workflowWorkItem:lease-acquired", - target: updated.id, - metadata: { id: updated.id, leaseOwner: updated.leaseOwner, leaseExpiresAt }, - }); - return updated; - } - - return store.db.transactionImmediate(() => { - const now = opts.now ?? new Date().toISOString(); - const leaseExpiresAt = new Date(new Date(now).getTime() + opts.leaseDurationMs).toISOString(); - const result = store.db - .prepare( - `UPDATE workflow_work_items - SET state = 'running', - leaseOwner = ?, - leaseExpiresAt = ?, - updatedAt = ? - WHERE id = ? - AND state IN ('runnable', 'retrying', 'running') - AND (retryAfter IS NULL OR retryAfter <= ?) - AND (leaseExpiresAt IS NULL OR leaseExpiresAt <= ?)`, - ) - .run(leaseOwner, leaseExpiresAt, now, id, now, now); - if (result.changes === 0) return null; - - const row = store.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; - if (!row) throw new Error(`Workflow work item ${id} disappeared`); - store.insertRunAuditEventRow({ - taskId: row.taskId, - runId: row.runId, - domain: "database", - mutationType: "workflowWorkItem:lease-acquired", - target: row.id, - metadata: { id: row.id, leaseOwner: row.leaseOwner, leaseExpiresAt: row.leaseExpiresAt }, - }); - return store.rowToWorkflowWorkItem(row); }); - } + if (!updated) return null; + // Record the audit event (fire-and-forget). + void store.recordRunAuditEvent({ + taskId: updated.taskId, + agentId: "system", + runId: updated.runId, + domain: "database", + mutationType: "workflowWorkItem:lease-acquired", + target: updated.id, + metadata: { id: updated.id, leaseOwner: updated.leaseOwner, leaseExpiresAt }, + }); + return updated; +} diff --git a/packages/core/src/task-store/workflow-workitems-ops.ts b/packages/core/src/task-store/workflow-workitems-ops.ts index 9bc40edb82..79c9f9b3f3 100644 --- a/packages/core/src/task-store/workflow-workitems-ops.ts +++ b/packages/core/src/task-store/workflow-workitems-ops.ts @@ -19,23 +19,12 @@ export async function clearWorkflowRunBranchesImpl(store: TaskStore, taskId: str // FNXC:PostgresOnlyDataAccess 2026-07-16-12:15: backend mode previously // swallowed the sync throw, so stale-run branch rows were never pruned on // PostgreSQL. - if (store.backendMode) { - const table = schema.project.workflowRunBranches; - await store.asyncLayer!.db - .delete(table) - .where(and(eq(table.taskId, taskId), ne(table.runId, keepRunId))); - return; - } - try { - store.db - .prepare( - `DELETE FROM workflow_run_branches WHERE taskId = ? AND runId != ?`, - ) - .run(taskId, keepRunId); - } catch { - // Legacy/missing table — pruning is additive, so degrade silently. - } - } + const table = schema.project.workflowRunBranches; + await store.asyncLayer!.db + .delete(table) + .where(and(eq(table.taskId, taskId), ne(table.runId, keepRunId))); + return; +} export async function projectMergeRequestToWorkflowWorkItemImpl(store: TaskStore, taskId: string, opts: MergeRequestWorkflowProjectionOptions = {},): Promise { // FNXC:RuntimeWorkflowAsync 2026-06-24-17:05: @@ -43,109 +32,48 @@ export async function projectMergeRequestToWorkflowWorkItemImpl(store: TaskStore // cancelActiveWorkflowWorkItemsForTask are now async. The sync // transactionImmediate wrapper is removed — the inner upsert/cancel already // run in their own transactions. The audit row is fire-and-forget. - if (store.backendMode) { - const layer = store.asyncLayer!; - const record = await store.getMergeRequestRecordAsync(taskId); - if (!record) return null; - const state = store.workflowStateForMergeRequestState(record.state); - const kind = record.state === "manual-required" ? "manual-hold" : "merge"; - const item = await store.upsertWorkflowWorkItem({ - runId: opts.runId ?? `merge-request:${taskId}`, - taskId, - nodeId: opts.nodeId ?? "builtin.merge.request", - kind, - state, - attempt: record.attemptCount, - lastError: record.lastError, - blockedReason: record.state === "manual-required" ? record.lastError ?? "manual merge required" : null, - now: opts.now ?? record.updatedAt, - }); - await store.cancelActiveWorkflowWorkItemsForTask(taskId, { - kinds: [kind === "manual-hold" ? "merge" : "manual-hold"], - now: opts.now ?? record.updatedAt, - lastError: "superseded-by-merge-request-projection", - }); - void recordRunAuditEventAsync(layer, { - taskId, - agentId: "system", - runId: item.runId, - domain: "database", - mutationType: "mergeRequest:workflow-projection", - target: item.id, - metadata: { taskId, mergeRequestState: record.state, workflowState: item.state, workItemKind: item.kind }, - }); - return item; - } - return store.db.transactionImmediate(() => { - const record = store.getMergeRequestRecord(taskId); - if (!record) return null; - const state = store.workflowStateForMergeRequestState(record.state); - const kind = record.state === "manual-required" ? "manual-hold" : "merge"; - // SQLite path: the async wrappers run synchronously here (no awaits in the - // SQLite branch), so the DB writes execute inside this transaction. - void store.upsertWorkflowWorkItem({ - runId: opts.runId ?? `merge-request:${taskId}`, - taskId, - nodeId: opts.nodeId ?? "builtin.merge.request", - kind, - state, - attempt: record.attemptCount, - lastError: record.lastError, - blockedReason: record.state === "manual-required" ? record.lastError ?? "manual merge required" : null, - now: opts.now ?? record.updatedAt, - }).then((item) => { - store.insertRunAuditEventRow({ - taskId, - runId: item.runId, - domain: "database", - mutationType: "mergeRequest:workflow-projection", - target: item.id, - metadata: { taskId, mergeRequestState: record.state, workflowState: item.state, workItemKind: item.kind }, - }); - }); - void store.cancelActiveWorkflowWorkItemsForTask(taskId, { - kinds: [kind === "manual-hold" ? "merge" : "manual-hold"], - now: opts.now ?? record.updatedAt, - lastError: "superseded-by-merge-request-projection", - }); - // Re-read the projected item for the return value. - const projected = store.getWorkflowWorkItemByIdentity( - opts.runId ?? `merge-request:${taskId}`, - taskId, - opts.nodeId ?? "builtin.merge.request", - kind, - ); - return projected; + const layer = store.asyncLayer!; + const record = await store.getMergeRequestRecordAsync(taskId); + if (!record) return null; + const state = store.workflowStateForMergeRequestState(record.state); + const kind = record.state === "manual-required" ? "manual-hold" : "merge"; + const item = await store.upsertWorkflowWorkItem({ + runId: opts.runId ?? `merge-request:${taskId}`, + taskId, + nodeId: opts.nodeId ?? "builtin.merge.request", + kind, + state, + attempt: record.attemptCount, + lastError: record.lastError, + blockedReason: record.state === "manual-required" ? record.lastError ?? "manual merge required" : null, + now: opts.now ?? record.updatedAt, }); - } + await store.cancelActiveWorkflowWorkItemsForTask(taskId, { + kinds: [kind === "manual-hold" ? "merge" : "manual-hold"], + now: opts.now ?? record.updatedAt, + lastError: "superseded-by-merge-request-projection", + }); + void recordRunAuditEventAsync(layer, { + taskId, + agentId: "system", + runId: item.runId, + domain: "database", + mutationType: "mergeRequest:workflow-projection", + target: item.id, + metadata: { taskId, mergeRequestState: record.state, workflowState: item.state, workItemKind: item.kind }, + }); + return item; +} export async function createCompletionHandoffWorkflowWorkImpl(store: TaskStore, task: Pick, opts: { runId?: string; now?: string; source?: string } = {}, tx?: import("../postgres/data-layer.js").DbTransaction): Promise { const autoMerge = task.autoMerge !== false; const runId = opts.runId ?? `completion-handoff:${task.id}:${randomUUID()}`; const nodeId = autoMerge ? "merge-gate" : "merge-manual-hold"; const kind: WorkflowWorkItemKind = autoMerge ? "merge" : "manual-hold"; - // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-15:55: - // Backend mode: skip the sync getWorkflowWorkItemByIdentity check. The - // async upsertWorkflowWorkItem below already handles backend mode. The - // sync insertCompletionHandoffWorkflowWorkAudit is also skipped in backend - // mode (the audit is handled by the surrounding transaction). - let existing: WorkflowWorkItem | null = null; - if (!store.backendMode) { - existing = store.getWorkflowWorkItemByIdentity(runId, task.id, nodeId, kind); - } - if (existing && store.isActiveWorkflowWorkItemState(existing.state)) { - await store.cancelActiveWorkflowWorkItemsForTask(task.id, { - kinds: ["merge", "manual-hold"], - excludeIds: [existing.id], - now: opts.now, - lastError: "superseded-by-completion-handoff", - }, tx); - if (!store.backendMode) { - store.insertCompletionHandoffWorkflowWorkAudit(task, existing, autoMerge, opts.source); - } - return existing; - } - + /* + FNXC:SqliteDualPathCleanup 2026-07-26-13:35: + Completion handoff is PostgreSQL-only. The former SQLite getWorkflowWorkItemByIdentity / audit arms are deleted; cancel+upsert is the durable path. + */ await store.cancelActiveWorkflowWorkItemsForTask(task.id, { kinds: ["merge", "manual-hold"], now: opts.now, @@ -160,9 +88,6 @@ export async function createCompletionHandoffWorkflowWorkImpl(store: TaskStore, blockedReason: autoMerge ? null : "autoMerge:false", now: opts.now, }, tx); - if (!store.backendMode) { - store.insertCompletionHandoffWorkflowWorkAudit(task, item, autoMerge, opts.source); - } return item; - } +} diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index bd10a9bf4c..90e043eb0f 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -1646,7 +1646,6 @@ export function getExecutorSystemPrompt( return sections.join("\n\n"); } - export interface TaskExecutorOptions { /* * FNXC:PlanReviewLease 2026-07-26-21:07: diff --git a/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts b/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts index 1e43d736f9..a05e72cac9 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts @@ -308,80 +308,61 @@ function mapSetting(row: ServiceSettingRow): ServiceSetting { } /** - * Construct a dual-mode CliPressStore. + * Construct a PostgreSQL-backed CliPressStore. * - * @param db SQLite Database for legacy (non-backend) mode, or null in backend mode. - * @param asyncLayer The AsyncDataLayer (PostgreSQL/Drizzle) for backend mode, or - * null/omitted in legacy SQLite mode. When provided, every method routes to a - * Drizzle query against the plugin-owned tables (materialized by the - * cliPressPluginSchemaInit hook); the SQLite path is never touched. + * @param db Ignored (legacy SQLite handle; production always passes null). + * @param asyncLayer Required AsyncDataLayer for all reads/writes against plugin-owned tables. + * + * FNXC:SqliteDualPathCleanup 2026-07-26-13:45: + * Dual-mode SQLite arms deleted. Runtime authority is AsyncDataLayer/PostgreSQL only. */ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataLayer | null): CliPressStore { - // FNXC:PostgresCutover 2026-07-04-00:00: - // Dual-mode: SQLite (db) for legacy, asyncLayer (PostgreSQL/Drizzle) for - // backend mode. Tables materialize via the plugin schema-init hook in PG, so - // no DDL-on-first-use is needed here. In legacy mode the SQLite schema is - // ensured defensively (mirrors the onSchemaInit hook for tests/early use). - if (db) ensureCliPressSchema(db); - const syncDb = (): Database => { - if (!db) throw new Error("CliPressStore: sync Database is null (backend mode)"); - return db; - }; + void db; + if (!asyncLayer) { + throw new Error("CliPressStore: AsyncDataLayer is required (SQLite dual-path removed)"); + } + const layer = asyncLayer; + /** FNXC:CliPressProjectIsolation 2026-07-14-21:28: Every PostgreSQL definition, artifact, setting, and credential belongs to the AsyncDataLayer project; explicit predicates enforce the boundary on every store operation. */ const projectId = (): string => { - const id = asyncLayer?.projectId?.trim(); + const id = layer.projectId?.trim(); if (!id) throw new Error("CliPressStore: PostgreSQL backend requires asyncLayer.projectId"); return id; }; return { async listServices(): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressServices) - .where(eq(cliPressServices.projectId, projectId())) - .orderBy(desc(cliPressServices.createdAt)); - return (rows as ServiceRow[]).map(mapService); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_services ORDER BY createdAt DESC").all() as unknown as ServiceRow[]; - return rows.map(mapService); + const rows = await layer.db + .select() + .from(cliPressServices) + .where(eq(cliPressServices.projectId, projectId())) + .orderBy(desc(cliPressServices.createdAt)); + return (rows as ServiceRow[]).map(mapService); }, async getService(id: string): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressServices) - .where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))) - .limit(1); - return rows[0] ? mapService(rows[0] as ServiceRow) : undefined; - } - const row = syncDb().prepare("SELECT * FROM cli_press_services WHERE id = ?").get(id) as unknown as ServiceRow | undefined; - return row ? mapService(row) : undefined; + const rows = await layer.db + .select() + .from(cliPressServices) + .where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))) + .limit(1); + return rows[0] ? mapService(rows[0] as ServiceRow) : undefined; }, async createService(input: ServiceCreateInput): Promise { const service: Service = { id: createId("svc"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; - if (asyncLayer) { - await asyncLayer.db.insert(cliPressServices).values({ - projectId: projectId(), - id: service.id, - slug: service.slug, - displayName: service.displayName, - description: service.description ?? null, - baseUrl: service.baseUrl, - sourceKind: service.sourceKind, - sourceRef: service.sourceRef ?? null, - createdAt: service.createdAt, - updatedAt: service.updatedAt, - }); - return service; - } - syncDb().prepare(`INSERT INTO cli_press_services (id, slug, displayName, description, baseUrl, sourceKind, sourceRef, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) - .run(service.id, service.slug, service.displayName, service.description ?? null, service.baseUrl, service.sourceKind, service.sourceRef ?? null, service.createdAt, service.updatedAt); - syncDb().bumpLastModified(); + await layer.db.insert(cliPressServices).values({ + projectId: projectId(), + id: service.id, + slug: service.slug, + displayName: service.displayName, + description: service.description ?? null, + baseUrl: service.baseUrl, + sourceKind: service.sourceKind, + sourceRef: service.sourceRef ?? null, + createdAt: service.createdAt, + updatedAt: service.updatedAt, + }); return service; }, @@ -389,46 +370,30 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const existing = await this.getService(id); if (!existing) throw new Error(`Service ${id} not found`); const updated: Service = { ...existing, ...updates, id: existing.id, slug: existing.slug, createdAt: existing.createdAt, updatedAt: nowIso() }; - if (asyncLayer) { - await asyncLayer.db.update(cliPressServices).set({ - displayName: updated.displayName, - description: updated.description ?? null, - baseUrl: updated.baseUrl, - sourceKind: updated.sourceKind, - sourceRef: updated.sourceRef ?? null, - updatedAt: updated.updatedAt, - }).where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))); - return updated; - } - syncDb().prepare(`UPDATE cli_press_services SET displayName = ?, description = ?, baseUrl = ?, sourceKind = ?, sourceRef = ?, updatedAt = ? WHERE id = ?`) - .run(updated.displayName, updated.description ?? null, updated.baseUrl, updated.sourceKind, updated.sourceRef ?? null, updated.updatedAt, id); - syncDb().bumpLastModified(); + await layer.db.update(cliPressServices).set({ + displayName: updated.displayName, + description: updated.description ?? null, + baseUrl: updated.baseUrl, + sourceKind: updated.sourceKind, + sourceRef: updated.sourceRef ?? null, + updatedAt: updated.updatedAt, + }).where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))); return updated; }, async deleteService(id: string): Promise { - if (asyncLayer) { - // FK ON DELETE CASCADE removes child specs/artifacts/credentials/settings. - await asyncLayer.db.delete(cliPressServices).where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))); - return; - } - syncDb().transaction(() => { - syncDb().prepare("DELETE FROM cli_press_services WHERE id = ?").run(id); - }); - syncDb().bumpLastModified(); + // FK ON DELETE CASCADE removes child specs/artifacts/credentials/settings. + await layer.db.delete(cliPressServices).where(and(eq(cliPressServices.projectId, projectId()), eq(cliPressServices.id, id))); + return; }, async listSpecs(serviceId: string): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressSpecs) - .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.serviceId, serviceId))) - .orderBy(desc(cliPressSpecs.createdAt)); - return (rows as CliSpecRow[]).map(mapSpec); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_cli_specs WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as CliSpecRow[]; - return rows.map(mapSpec); + const rows = await layer.db + .select() + .from(cliPressSpecs) + .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.serviceId, serviceId))) + .orderBy(desc(cliPressSpecs.createdAt)); + return (rows as CliSpecRow[]).map(mapSpec); }, /* @@ -436,63 +401,43 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL Executor environment construction runs for every dispatched task. Fetch each plugin table once so PostgreSQL round trips remain constant as services and historical specs grow. */ async listAllSpecs(): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db.select().from(cliPressSpecs).where(eq(cliPressSpecs.projectId, projectId())).orderBy(desc(cliPressSpecs.createdAt)); - return (rows as CliSpecRow[]).map(mapSpec); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_cli_specs ORDER BY createdAt DESC").all() as unknown as CliSpecRow[]; - return rows.map(mapSpec); + const rows = await layer.db.select().from(cliPressSpecs).where(eq(cliPressSpecs.projectId, projectId())).orderBy(desc(cliPressSpecs.createdAt)); + return (rows as CliSpecRow[]).map(mapSpec); }, /** FNXC:CliPrintingPressRuntime 2026-07-14-23:53: Executor dispatch only consumes generated definitions; filter them in the owning database instead of transferring draft and failed history into every task launch. */ async listGeneratedSpecs(): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db.select().from(cliPressSpecs) - .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.status, "generated"))) - .orderBy(desc(cliPressSpecs.createdAt)); - return (rows as CliSpecRow[]).map(mapSpec); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_cli_specs WHERE status = ? ORDER BY createdAt DESC") - .all("generated") as unknown as CliSpecRow[]; - return rows.map(mapSpec); + const rows = await layer.db.select().from(cliPressSpecs) + .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.status, "generated"))) + .orderBy(desc(cliPressSpecs.createdAt)); + return (rows as CliSpecRow[]).map(mapSpec); }, async getSpec(id: string): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressSpecs) - .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))) - .limit(1); - return rows[0] ? mapSpec(rows[0] as CliSpecRow) : undefined; - } - const row = syncDb().prepare("SELECT * FROM cli_press_cli_specs WHERE id = ?").get(id) as unknown as CliSpecRow | undefined; - return row ? mapSpec(row) : undefined; + const rows = await layer.db + .select() + .from(cliPressSpecs) + .where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))) + .limit(1); + return rows[0] ? mapSpec(rows[0] as CliSpecRow) : undefined; }, async createSpec(input: CliSpecCreateInput): Promise { const spec: CliSpec = { id: createId("cli"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; - if (asyncLayer) { - await asyncLayer.db.insert(cliPressSpecs).values({ - projectId: projectId(), - id: spec.id, - serviceId: spec.serviceId, - name: spec.name, - version: spec.version, - generatorVersion: spec.generatorVersion, - specJson: spec.specJson, - generatedAt: spec.generatedAt ?? null, - status: spec.status, - lastGenerationError: spec.lastGenerationError ?? null, - createdAt: spec.createdAt, - updatedAt: spec.updatedAt, - }); - return spec; - } - syncDb().prepare(`INSERT INTO cli_press_cli_specs (id, serviceId, name, version, generatorVersion, specJson, generatedAt, status, lastGenerationError, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) - .run(spec.id, spec.serviceId, spec.name, spec.version, spec.generatorVersion, spec.specJson, spec.generatedAt ?? null, spec.status, spec.lastGenerationError ?? null, spec.createdAt, spec.updatedAt); - syncDb().bumpLastModified(); + await layer.db.insert(cliPressSpecs).values({ + projectId: projectId(), + id: spec.id, + serviceId: spec.serviceId, + name: spec.name, + version: spec.version, + generatorVersion: spec.generatorVersion, + specJson: spec.specJson, + generatedAt: spec.generatedAt ?? null, + status: spec.status, + lastGenerationError: spec.lastGenerationError ?? null, + createdAt: spec.createdAt, + updatedAt: spec.updatedAt, + }); return spec; }, @@ -500,214 +445,133 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL const existing = await this.getSpec(id); if (!existing) throw new Error(`Spec ${id} not found`); const updated: CliSpec = { ...existing, ...updates, id: existing.id, serviceId: existing.serviceId, createdAt: existing.createdAt, updatedAt: nowIso() }; - if (asyncLayer) { - await asyncLayer.db.update(cliPressSpecs).set({ - name: updated.name, - version: updated.version, - generatorVersion: updated.generatorVersion, - specJson: updated.specJson, - generatedAt: updated.generatedAt ?? null, - status: updated.status, - lastGenerationError: updated.lastGenerationError ?? null, - updatedAt: updated.updatedAt, - }).where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))); - return updated; - } - syncDb().prepare(`UPDATE cli_press_cli_specs SET name=?, version=?, generatorVersion=?, specJson=?, generatedAt=?, status=?, lastGenerationError=?, updatedAt=? WHERE id=?`) - .run(updated.name, updated.version, updated.generatorVersion, updated.specJson, updated.generatedAt ?? null, updated.status, updated.lastGenerationError ?? null, updated.updatedAt, id); - syncDb().bumpLastModified(); + await layer.db.update(cliPressSpecs).set({ + name: updated.name, + version: updated.version, + generatorVersion: updated.generatorVersion, + specJson: updated.specJson, + generatedAt: updated.generatedAt ?? null, + status: updated.status, + lastGenerationError: updated.lastGenerationError ?? null, + updatedAt: updated.updatedAt, + }).where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))); return updated; }, async deleteSpec(id: string): Promise { - if (asyncLayer) { - await asyncLayer.db.delete(cliPressSpecs).where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))); - return; - } - syncDb().prepare("DELETE FROM cli_press_cli_specs WHERE id = ?").run(id); - syncDb().bumpLastModified(); + await layer.db.delete(cliPressSpecs).where(and(eq(cliPressSpecs.projectId, projectId()), eq(cliPressSpecs.id, id))); + return; }, async listArtifacts(specId: string): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressArtifacts) - .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.cliSpecId, specId))) - .orderBy(desc(cliPressArtifacts.createdAt)); - return (rows as CliArtifactRow[]).map(mapArtifact); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_artifacts WHERE cliSpecId = ? ORDER BY createdAt DESC").all(specId) as unknown as CliArtifactRow[]; - return rows.map(mapArtifact); + const rows = await layer.db + .select() + .from(cliPressArtifacts) + .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.cliSpecId, specId))) + .orderBy(desc(cliPressArtifacts.createdAt)); + return (rows as CliArtifactRow[]).map(mapArtifact); }, async listAllArtifacts(): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db.select().from(cliPressArtifacts).where(eq(cliPressArtifacts.projectId, projectId())).orderBy(desc(cliPressArtifacts.createdAt)); - return (rows as CliArtifactRow[]).map(mapArtifact); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_artifacts ORDER BY createdAt DESC").all() as unknown as CliArtifactRow[]; - return rows.map(mapArtifact); + const rows = await layer.db.select().from(cliPressArtifacts).where(eq(cliPressArtifacts.projectId, projectId())).orderBy(desc(cliPressArtifacts.createdAt)); + return (rows as CliArtifactRow[]).map(mapArtifact); }, /** FNXC:CliPrintingPressRuntime 2026-07-14-23:53: Runtime PATH construction needs executable artifacts only; keep non-executable generation history out of the dispatch catalog at the SQL boundary. */ async listExecutableArtifacts(): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db.select().from(cliPressArtifacts) - .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.executable, true))) - .orderBy(desc(cliPressArtifacts.createdAt)); - return (rows as CliArtifactRow[]).map(mapArtifact); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_artifacts WHERE executable = ? ORDER BY createdAt DESC") - .all(1) as unknown as CliArtifactRow[]; - return rows.map(mapArtifact); + const rows = await layer.db.select().from(cliPressArtifacts) + .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.executable, true))) + .orderBy(desc(cliPressArtifacts.createdAt)); + return (rows as CliArtifactRow[]).map(mapArtifact); }, async createArtifact(input: CliArtifactCreateInput): Promise { const artifact: CliArtifact = { id: createId("art"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; - if (asyncLayer) { - await asyncLayer.db.insert(cliPressArtifacts).values({ - projectId: projectId(), - id: artifact.id, - cliSpecId: artifact.cliSpecId, - kind: artifact.kind, - path: artifact.path, - executable: artifact.executable, - checksum: artifact.checksum ?? null, - sizeBytes: artifact.sizeBytes ?? null, - createdAt: artifact.createdAt, - updatedAt: artifact.updatedAt, - }); - return artifact; - } - syncDb().prepare(`INSERT INTO cli_press_artifacts (id, cliSpecId, kind, path, executable, checksum, sizeBytes, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) - .run(artifact.id, artifact.cliSpecId, artifact.kind, artifact.path, artifact.executable ? 1 : 0, artifact.checksum ?? null, artifact.sizeBytes ?? null, artifact.createdAt, artifact.updatedAt); - syncDb().bumpLastModified(); + await layer.db.insert(cliPressArtifacts).values({ + projectId: projectId(), + id: artifact.id, + cliSpecId: artifact.cliSpecId, + kind: artifact.kind, + path: artifact.path, + executable: artifact.executable, + checksum: artifact.checksum ?? null, + sizeBytes: artifact.sizeBytes ?? null, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + }); return artifact; }, async updateArtifact(id: string, updates: CliArtifactUpdateInput): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressArtifacts) - .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))) - .limit(1); - const existing = rows[0] as CliArtifactRow | undefined; - if (!existing) throw new Error(`Artifact ${id} not found`); - const updated: CliArtifact = { - ...mapArtifact(existing), - ...updates, - id: existing.id, - cliSpecId: existing.cliSpecId, - createdAt: existing.createdAt, - updatedAt: nowIso(), - }; - await asyncLayer.db.update(cliPressArtifacts).set({ - path: updated.path, - executable: updated.executable, - checksum: updated.checksum ?? null, - sizeBytes: updated.sizeBytes ?? null, - updatedAt: updated.updatedAt, - }).where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))); - return updated; - } - const existing = syncDb().prepare("SELECT * FROM cli_press_artifacts WHERE id = ?").get(id) as unknown as CliArtifactRow | undefined; + const rows = await layer.db + .select() + .from(cliPressArtifacts) + .where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))) + .limit(1); + const existing = rows[0] as CliArtifactRow | undefined; if (!existing) throw new Error(`Artifact ${id} not found`); - const updated = { ...mapArtifact(existing), ...updates, id: existing.id, cliSpecId: existing.cliSpecId, createdAt: existing.createdAt, updatedAt: nowIso() }; - syncDb().prepare("UPDATE cli_press_artifacts SET path=?, executable=?, checksum=?, sizeBytes=?, updatedAt=? WHERE id=?") - .run(updated.path, updated.executable ? 1 : 0, updated.checksum ?? null, updated.sizeBytes ?? null, updated.updatedAt, id); - syncDb().bumpLastModified(); + const updated: CliArtifact = { + ...mapArtifact(existing), + ...updates, + id: existing.id, + cliSpecId: existing.cliSpecId, + createdAt: existing.createdAt, + updatedAt: nowIso(), + }; + await layer.db.update(cliPressArtifacts).set({ + path: updated.path, + executable: updated.executable, + checksum: updated.checksum ?? null, + sizeBytes: updated.sizeBytes ?? null, + updatedAt: updated.updatedAt, + }).where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))); return updated; }, async deleteArtifact(id: string): Promise { - if (asyncLayer) { - await asyncLayer.db.delete(cliPressArtifacts).where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))); - return; - } - syncDb().prepare("DELETE FROM cli_press_artifacts WHERE id = ?").run(id); - syncDb().bumpLastModified(); + await layer.db.delete(cliPressArtifacts).where(and(eq(cliPressArtifacts.projectId, projectId()), eq(cliPressArtifacts.id, id))); + return; }, async listCredentials(serviceId: string): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressCredentials) - .where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.serviceId, serviceId))) - .orderBy(desc(cliPressCredentials.createdAt)); - return (rows as CredentialRow[]).map(mapCredential); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_credentials WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as CredentialRow[]; - return rows.map(mapCredential); + const rows = await layer.db + .select() + .from(cliPressCredentials) + .where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.serviceId, serviceId))) + .orderBy(desc(cliPressCredentials.createdAt)); + return (rows as CredentialRow[]).map(mapCredential); }, async listAllCredentials(): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db.select().from(cliPressCredentials).where(eq(cliPressCredentials.projectId, projectId())).orderBy(desc(cliPressCredentials.createdAt)); - return (rows as CredentialRow[]).map(mapCredential); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_credentials ORDER BY createdAt DESC").all() as unknown as CredentialRow[]; - return rows.map(mapCredential); + const rows = await layer.db.select().from(cliPressCredentials).where(eq(cliPressCredentials.projectId, projectId())).orderBy(desc(cliPressCredentials.createdAt)); + return (rows as CredentialRow[]).map(mapCredential); }, async createCredential(input: CredentialCreateInput): Promise { assertCredentialSupported(input.kind); assertPlacementConsistency(input.kind, input.placement); const cred: Credential = { id: createId("cred"), ...input, createdAt: nowIso(), updatedAt: nowIso() }; - if (asyncLayer) { - await asyncLayer.db.insert(cliPressCredentials).values({ - projectId: projectId(), - id: cred.id, - serviceId: cred.serviceId, - name: cred.name, - kind: cred.kind, - value: JSON.stringify(cred.value), - placement: JSON.stringify(cred.placement), - createdAt: cred.createdAt, - updatedAt: cred.updatedAt, - }); - return cred; - } - syncDb().prepare(`INSERT INTO cli_press_credentials (id, serviceId, name, kind, value, placement, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) - .run(cred.id, cred.serviceId, cred.name, cred.kind, JSON.stringify(cred.value), JSON.stringify(cred.placement), cred.createdAt, cred.updatedAt); - syncDb().bumpLastModified(); + await layer.db.insert(cliPressCredentials).values({ + projectId: projectId(), + id: cred.id, + serviceId: cred.serviceId, + name: cred.name, + kind: cred.kind, + value: JSON.stringify(cred.value), + placement: JSON.stringify(cred.placement), + createdAt: cred.createdAt, + updatedAt: cred.updatedAt, + }); return cred; }, async updateCredential(id: string, updates: CredentialUpdateInput): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressCredentials) - .where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))) - .limit(1); - const existing = rows[0] as CredentialRow | undefined; - if (!existing) throw new Error(`Credential ${id} not found`); - const mapped = mapCredential(existing); - const updated: Credential = { - ...mapped, - ...updates, - id: mapped.id, - serviceId: mapped.serviceId, - kind: mapped.kind, - createdAt: mapped.createdAt, - updatedAt: nowIso(), - }; - assertCredentialSupported(updated.kind); - assertPlacementConsistency(updated.kind, updated.placement); - await asyncLayer.db.update(cliPressCredentials).set({ - name: updated.name, - value: JSON.stringify(updated.value), - placement: JSON.stringify(updated.placement), - updatedAt: updated.updatedAt, - }).where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))); - return updated; - } - const existing = syncDb().prepare("SELECT * FROM cli_press_credentials WHERE id = ?").get(id) as unknown as CredentialRow | undefined; + const rows = await layer.db + .select() + .from(cliPressCredentials) + .where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))) + .limit(1); + const existing = rows[0] as CredentialRow | undefined; if (!existing) throw new Error(`Credential ${id} not found`); const mapped = mapCredential(existing); const updated: Credential = { @@ -721,79 +585,55 @@ export function createCliPressStore(db: Database | null, asyncLayer?: AsyncDataL }; assertCredentialSupported(updated.kind); assertPlacementConsistency(updated.kind, updated.placement); - syncDb().prepare("UPDATE cli_press_credentials SET name=?, value=?, placement=?, updatedAt=? WHERE id=?") - .run(updated.name, JSON.stringify(updated.value), JSON.stringify(updated.placement), updated.updatedAt, id); - syncDb().bumpLastModified(); + await layer.db.update(cliPressCredentials).set({ + name: updated.name, + value: JSON.stringify(updated.value), + placement: JSON.stringify(updated.placement), + updatedAt: updated.updatedAt, + }).where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))); return updated; }, async deleteCredential(id: string): Promise { - if (asyncLayer) { - await asyncLayer.db.delete(cliPressCredentials).where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))); - return; - } - syncDb().prepare("DELETE FROM cli_press_credentials WHERE id = ?").run(id); - syncDb().bumpLastModified(); + await layer.db.delete(cliPressCredentials).where(and(eq(cliPressCredentials.projectId, projectId()), eq(cliPressCredentials.id, id))); + return; }, async listSettings(serviceId: string): Promise { - if (asyncLayer) { - const rows = await asyncLayer.db - .select() - .from(cliPressSettings) - .where(and(eq(cliPressSettings.projectId, projectId()), eq(cliPressSettings.serviceId, serviceId))) - .orderBy(desc(cliPressSettings.createdAt)); - return (rows as ServiceSettingRow[]).map(mapSetting); - } - const rows = syncDb().prepare("SELECT * FROM cli_press_service_settings WHERE serviceId = ? ORDER BY createdAt DESC").all(serviceId) as unknown as ServiceSettingRow[]; - return rows.map(mapSetting); + const rows = await layer.db + .select() + .from(cliPressSettings) + .where(and(eq(cliPressSettings.projectId, projectId()), eq(cliPressSettings.serviceId, serviceId))) + .orderBy(desc(cliPressSettings.createdAt)); + return (rows as ServiceSettingRow[]).map(mapSetting); }, async setSetting(input: ServiceSettingCreateInput): Promise { const now = nowIso(); - if (asyncLayer) { - /* - FNXC:CliPrintingPressConcurrency 2026-07-14-23:53: - Settings are unique by project, service, key, and scope. Resolve concurrent first writes with one PostgreSQL conflict upsert so callers cannot race between SELECT and INSERT or create a transient uniqueness failure. - */ - const setting: ServiceSetting = { id: createId("set"), ...input, createdAt: now, updatedAt: now }; - const rows = await asyncLayer.db.insert(cliPressSettings).values({ - projectId: projectId(), - id: setting.id, - serviceId: setting.serviceId, - key: setting.key, - value: setting.value, - scope: setting.scope, - createdAt: setting.createdAt, - updatedAt: setting.updatedAt, - }).onConflictDoUpdate({ - target: [cliPressSettings.projectId, cliPressSettings.serviceId, cliPressSettings.key, cliPressSettings.scope], - set: { value: input.value, updatedAt: now }, - }).returning(); - return mapSetting(rows[0] as ServiceSettingRow); - } - const existing = syncDb().prepare("SELECT * FROM cli_press_service_settings WHERE serviceId = ? AND key = ? AND scope = ?") - .get(input.serviceId, input.key, input.scope) as unknown as ServiceSettingRow | undefined; - if (existing) { - syncDb().prepare("UPDATE cli_press_service_settings SET value = ?, updatedAt = ? WHERE id = ?").run(input.value, now, existing.id); - syncDb().bumpLastModified(); - return mapSetting({ ...existing, value: input.value, updatedAt: now }); - } + /* + FNXC:CliPrintingPressConcurrency 2026-07-14-23:53: + Settings are unique by project, service, key, and scope. Resolve concurrent first writes with one PostgreSQL conflict upsert so callers cannot race between SELECT and INSERT or create a transient uniqueness failure. + */ const setting: ServiceSetting = { id: createId("set"), ...input, createdAt: now, updatedAt: now }; - syncDb().prepare(`INSERT INTO cli_press_service_settings (id, serviceId, key, value, scope, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?)`) - .run(setting.id, setting.serviceId, setting.key, setting.value, setting.scope, setting.createdAt, setting.updatedAt); - syncDb().bumpLastModified(); - return setting; + const rows = await layer.db.insert(cliPressSettings).values({ + projectId: projectId(), + id: setting.id, + serviceId: setting.serviceId, + key: setting.key, + value: setting.value, + scope: setting.scope, + createdAt: setting.createdAt, + updatedAt: setting.updatedAt, + }).onConflictDoUpdate({ + target: [cliPressSettings.projectId, cliPressSettings.serviceId, cliPressSettings.key, cliPressSettings.scope], + set: { value: input.value, updatedAt: now }, + }).returning(); + return mapSetting(rows[0] as ServiceSettingRow); }, async deleteSetting(id: string): Promise { - if (asyncLayer) { - await asyncLayer.db.delete(cliPressSettings).where(and(eq(cliPressSettings.projectId, projectId()), eq(cliPressSettings.id, id))); - return; - } - syncDb().prepare("DELETE FROM cli_press_service_settings WHERE id = ?").run(id); - syncDb().bumpLastModified(); + await layer.db.delete(cliPressSettings).where(and(eq(cliPressSettings.projectId, projectId()), eq(cliPressSettings.id, id))); + return; }, }; } diff --git a/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts b/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts index 0dfbd8285c..5a4da0bdf4 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts @@ -411,7 +411,6 @@ export class CeSessionStore { * Session orchestration uses these async siblings so backend mode persists through the project-bound AsyncDataLayer while SQLite callers retain their established synchronous API. PostgreSQL reads ignore caller-supplied cross-project filters and always enforce the layer's project ID. */ async createAsync(input: CreateCeSessionInput): Promise { - if (!this.asyncLayer) return this.create(input); const projectId = this.requireProjectId(); const session = this.newSession({ ...input, projectId }); await this.insertAsync(session); @@ -419,11 +418,10 @@ export class CeSessionStore { } async createWithPlanHandoffClaimAsync(input: CreateCeSessionInput, artifactPath: string): Promise { - if (!this.asyncLayer) return this.createWithPlanHandoffClaim(input, artifactPath); const projectId = this.requireProjectId(); const session = this.newSession({ ...input, projectId, artifactPath }); try { - await this.asyncLayer.transactionImmediate(async (tx) => { + await this.requireLayer().transactionImmediate(async (tx) => { await tx.execute(sql`INSERT INTO project.ce_sessions (id, stage, status, current_question, conversation_history, project_id, artifact_path, error, turn_interval_ms, last_activity_at, created_at, updated_at) VALUES(${session.id}, ${session.stage}, ${session.status}, NULL, '[]', ${projectId}, ${artifactPath}, NULL, ${session.turnIntervalMs}, ${session.lastActivityAt}, ${session.createdAt}, ${session.updatedAt})`); @@ -432,7 +430,7 @@ export class CeSessionStore { }); } catch (error) { if (isUniqueConstraintError(error)) { - const rows = await this.asyncLayer.db.execute(sql`SELECT session_id FROM project.ce_plan_handoff_claims WHERE project_id=${projectId} AND artifact_path=${artifactPath} LIMIT 1`) as unknown as Array<{ session_id: string }>; + const rows = await this.requireLayer().db.execute(sql`SELECT session_id FROM project.ce_plan_handoff_claims WHERE project_id=${projectId} AND artifact_path=${artifactPath} LIMIT 1`) as unknown as Array<{ session_id: string }>; throw new PlanHandoffClaimError(artifactPath, rows[0]?.session_id ?? "unknown"); } throw error; @@ -440,33 +438,39 @@ export class CeSessionStore { return session; } + /* + FNXC:SqliteDualPathCleanup 2026-07-26-15:20: + Async session methods require AsyncDataLayer after SQLite dual-path removal; assert for TS null narrowing. + */ + private requireLayer() { + if (!this.asyncLayer) throw new Error("CE PostgreSQL persistence requires a project-bound data layer"); + return this.asyncLayer; + } + private requireProjectId(): string { - const projectId = this.asyncLayer?.projectId; + const projectId = this.requireLayer().projectId?.trim(); if (!projectId) throw new Error("CE PostgreSQL persistence requires a project-bound data layer"); return projectId; } private async insertAsync(session: CeSession): Promise { - await this.asyncLayer!.db.execute(sql`INSERT INTO project.ce_sessions + await this.requireLayer().db.execute(sql`INSERT INTO project.ce_sessions (id, stage, status, current_question, conversation_history, project_id, artifact_path, error, turn_interval_ms, last_activity_at, created_at, updated_at) VALUES(${session.id}, ${session.stage}, ${session.status}, NULL, ${JSON.stringify(session.conversationHistory)}, ${session.projectId}, ${session.artifactPath}, NULL, ${session.turnIntervalMs}, ${session.lastActivityAt}, ${session.createdAt}, ${session.updatedAt})`); } async getAsync(id: string): Promise { - if (!this.asyncLayer) return this.get(id); - const rows = await this.asyncLayer.db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND id=${id} LIMIT 1`) as unknown as CeSessionRow[]; + const rows = await this.requireLayer().db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND id=${id} LIMIT 1`) as unknown as CeSessionRow[]; return rows[0] ? rowToSession(rows[0]) : undefined; } async listAsync(filter: { status?: CeSessionStatus; stage?: string; projectId?: string } = {}): Promise { - if (!this.asyncLayer) return this.list(filter); const projectId = this.requireProjectId(); - const rows = await this.asyncLayer.db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${projectId} AND (${filter.status ?? null}::text IS NULL OR status=${filter.status ?? null}) AND (${filter.stage ?? null}::text IS NULL OR stage=${filter.stage ?? null}) ORDER BY updated_at DESC, id`) as unknown as CeSessionRow[]; + const rows = await this.requireLayer().db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${projectId} AND (${filter.status ?? null}::text IS NULL OR status=${filter.status ?? null}) AND (${filter.stage ?? null}::text IS NULL OR stage=${filter.stage ?? null}) ORDER BY updated_at DESC, id`) as unknown as CeSessionRow[]; return rows.map(rowToSession); } async updateAsync(id: string, patch: Partial>): Promise { - if (!this.asyncLayer) return this.update(id, patch); const projectId = this.requireProjectId(); const updatedAt = new Date().toISOString(); const lastActivityAt = patch.lastActivityAt ?? Date.now(); @@ -475,7 +479,7 @@ export class CeSessionStore { * FNXC:CompoundEngineeringConcurrency 2026-07-14-00:18: * PostgreSQL session patches must update only the fields named by the caller. A prior read-modify-write rewrote the entire row, allowing a delayed heartbeat to restore stale history or a pre-terminal status after a question/completion had committed. */ - const rows = await this.asyncLayer.db.execute(sql` + const rows = await this.requireLayer().db.execute(sql` UPDATE project.ce_sessions SET status = CASE WHEN ${has("status")} THEN ${patch.status ?? null}::text ELSE status END, current_question = CASE WHEN ${has("currentQuestion")} THEN ${patch.currentQuestion ? JSON.stringify(patch.currentQuestion) : null}::text ELSE current_question END, @@ -492,11 +496,10 @@ export class CeSessionStore { /** Atomically append one history turn so simultaneous progress/terminal writes cannot drop either turn. */ async appendHistoryAsync(id: string, turn: CeConversationTurn): Promise { - if (!this.asyncLayer) return this.appendHistory(id, turn); const projectId = this.requireProjectId(); const now = Date.now(); const updatedAt = new Date(now).toISOString(); - const rows = await this.asyncLayer.db.execute(sql` + const rows = await this.requireLayer().db.execute(sql` UPDATE project.ce_sessions SET conversation_history = ((conversation_history::jsonb || jsonb_build_array(${JSON.stringify(turn)}::jsonb))::text), last_activity_at = ${now}, @@ -510,7 +513,7 @@ export class CeSessionStore { /** Atomically touch only liveness columns; safe to run beside terminal/history mutations. */ async touchActivityAsync(id: string, at = Date.now()): Promise { if (!this.asyncLayer) return Boolean(this.update(id, { lastActivityAt: at })); - const rows = await this.asyncLayer.db.execute(sql` + const rows = await this.requireLayer().db.execute(sql` UPDATE project.ce_sessions SET last_activity_at=${at}, updated_at=${new Date(at).toISOString()} WHERE project_id=${this.requireProjectId()} AND id=${id} @@ -518,10 +521,9 @@ export class CeSessionStore { `) as unknown as Array<{ id: string }>; return rows.length > 0; } - async deleteAsync(id: string): Promise { if (!this.asyncLayer) return this.delete(id); const existing = await this.getAsync(id); if (!existing) return false; await this.asyncLayer.db.execute(sql`DELETE FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND id=${id}`); return true; } + async deleteAsync(id: string): Promise { const existing = await this.getAsync(id); if (!existing) return false; await this.requireLayer().db.execute(sql`DELETE FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND id=${id}`); return true; } async recoverStaleSessionsAsync(now = Date.now(), multiple = STALE_INTERVAL_MULTIPLE): Promise { - if (!this.asyncLayer) return this.recoverStaleSessions(now, multiple); - const rows = await this.asyncLayer.db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND status IN ('active', 'launching') AND last_activity_at < (${now}::bigint - (${multiple}::bigint * turn_interval_ms::bigint))`) as unknown as CeSessionRow[]; + const rows = await this.requireLayer().db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND status IN ('active', 'launching') AND last_activity_at < (${now}::bigint - (${multiple}::bigint * turn_interval_ms::bigint))`) as unknown as CeSessionRow[]; const candidates = rows.map(rowToSession); await Promise.all(candidates.map((session) => this.updateAsync( session.id, diff --git a/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts b/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts index 09d220b11f..b6330607b6 100644 --- a/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts +++ b/plugins/fusion-plugin-compound-engineering/src/sync/pipeline-store.ts @@ -231,7 +231,6 @@ export class CePipelineStore { /** Async sibling: createLink. Routes to Drizzle in backend mode. */ async createLinkAsync(input: CreateCePipelineLinkInput): Promise { - if (!this.asyncLayer) return this.createLink(input); const link: CePipelineLink = { id: input.id ?? randomUUID(), taskId: input.taskId, @@ -261,7 +260,6 @@ export class CePipelineStore { /** Async sibling: listByPipeline. Routes to Drizzle in backend mode. */ async listByPipelineAsync(cePipelineId: string): Promise { - if (!this.asyncLayer) return this.listByPipeline(cePipelineId); const rows = await this.dbAsync().select() .from(cePipelineLinksTable) .where(and(eq(cePipelineLinksTable.projectId, this.projectId()), eq(cePipelineLinksTable.cePipelineId, cePipelineId))) @@ -278,7 +276,6 @@ export class CePipelineStore { /** Async sibling: findByTaskId. Routes to Drizzle in backend mode. */ async findByTaskIdAsync(taskId: string): Promise { - if (!this.asyncLayer) return this.findByTaskId(taskId); const rows = await this.dbAsync().select() .from(cePipelineLinksTable) .where(and(eq(cePipelineLinksTable.projectId, this.projectId()), eq(cePipelineLinksTable.taskId, taskId))) @@ -298,7 +295,6 @@ export class CePipelineStore { /** Async sibling: getState. Routes to Drizzle in backend mode. */ async getStateAsync(cePipelineId: string): Promise { - if (!this.asyncLayer) return this.getState(cePipelineId); const rows = await this.dbAsync().select() .from(cePipelineStateTable) .where(and(eq(cePipelineStateTable.projectId, this.projectId()), eq(cePipelineStateTable.cePipelineId, cePipelineId))) @@ -315,7 +311,6 @@ export class CePipelineStore { /** Async sibling: listAllState. Routes to Drizzle in backend mode. */ async listAllStateAsync(): Promise { - if (!this.asyncLayer) return this.listAllState(); const rows = await this.dbAsync().select() .from(cePipelineStateTable) .where(eq(cePipelineStateTable.projectId, this.projectId())) @@ -349,7 +344,6 @@ export class CePipelineStore { /** Async sibling: upsertState. Routes to Drizzle in backend mode. */ async upsertStateAsync(input: UpsertCePipelineStateInput): Promise { - if (!this.asyncLayer) return this.upsertState(input); const now = new Date().toISOString(); /* FNXC:CompoundEngineeringConcurrency 2026-07-14-23:53: @@ -401,7 +395,6 @@ export class CePipelineStore { cePipelineId: string, next: { currentStage?: string; status?: CePipelineStatus; lastArtifactPath?: string | null }, ): Promise { - if (!this.asyncLayer) return this.transitionState(cePipelineId, next); const existing = await this.getStateAsync(cePipelineId); if (!existing) return undefined; return this.upsertStateAsync({ @@ -438,7 +431,6 @@ export class CePipelineStore { /** Async sibling: enqueueSync. Routes to Drizzle in backend mode. */ async enqueueSyncAsync(input: EnqueueSyncInput): Promise { - if (!this.asyncLayer) return this.enqueueSync(input); const entry: CeSyncQueueEntry = { id: input.id ?? randomUUID(), cePipelineId: input.cePipelineId, @@ -472,7 +464,6 @@ export class CePipelineStore { /** Async sibling: listPendingSync. Routes to Drizzle in backend mode. */ async listPendingSyncAsync(): Promise { - if (!this.asyncLayer) return this.listPendingSync(); const rows = await this.dbAsync().select() .from(cePipelineSyncQueueTable) .where(and(eq(cePipelineSyncQueueTable.projectId, this.projectId()), isNull(cePipelineSyncQueueTable.processedAt))) @@ -488,10 +479,10 @@ export class CePipelineStore { /** Async sibling: markSyncProcessed. Routes to Drizzle in backend mode. */ async markSyncProcessedAsync(id: string): Promise { - if (!this.asyncLayer) { - this.markSyncProcessed(id); - return; - } + /* + 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( diff --git a/plugins/fusion-plugin-reports/src/store/report-store.ts b/plugins/fusion-plugin-reports/src/store/report-store.ts index 7db51d9ad5..cc22bfbb18 100644 --- a/plugins/fusion-plugin-reports/src/store/report-store.ts +++ b/plugins/fusion-plugin-reports/src/store/report-store.ts @@ -449,14 +449,13 @@ export class ReportStore extends EventEmitter { } // ── Async siblings (PostgreSQL / backend mode) ──────────────────── - // Each method delegates to the sync path when not in backend mode (SQLite - // fallback). In backend mode, queries go through asyncLayer.db (Drizzle) - // against project.reports. PG column names are snake_case; the Drizzle - // shape in postgres/schema/plugin.ts maps them to the camelCase JS keys. + /* + FNXC:SqliteDualPathCleanup 2026-07-26-13:40: + ReportStore async methods are PostgreSQL-only after dual-path collapse. Sync SQLite methods remain as type-compat stubs for tests that still construct without asyncLayer; production always injects AsyncDataLayer. + */ - /** Async create. Delegates to sync createReport in SQLite mode. */ + /** Async create against project.reports. */ async createReportAsync(input: ReportCreateInput): Promise { - if (!this.backendMode) return this.createReport(input); const layer = this.asyncLayer!; const now = new Date().toISOString(); const report: Report = { @@ -493,7 +492,6 @@ export class ReportStore extends EventEmitter { /** Async get by id. Returns null when not found. */ async getReportAsync(id: string): Promise { - if (!this.backendMode) return this.getReport(id); const rows = await this.asyncLayer!.db .select() .from(schema.plugin.reports) @@ -503,7 +501,6 @@ export class ReportStore extends EventEmitter { /** Async list with filters, ordering, and pagination. */ async listReportsAsync(filter: ReportListFilter = {}): Promise { - if (!this.backendMode) return this.listReports(filter); const table = schema.plugin.reports; const conditions: SQL[] = [eq(table.projectId, this.projectId())]; if (filter.cadence) conditions.push(eq(table.cadence, filter.cadence)); @@ -530,7 +527,6 @@ export class ReportStore extends EventEmitter { /** Async update by id with a partial patch. Throws if not found. */ async updateReportAsync(id: string, patch: ReportUpdateInput): Promise { - if (!this.backendMode) return this.updateReport(id, patch); const current = await this.requireReportAsync(id); const next: Report = { ...current, @@ -561,7 +557,6 @@ export class ReportStore extends EventEmitter { next: ReportStatus, opts: { failureReason?: string; approvedBy?: string } = {}, ): Promise { - if (!this.backendMode) return this.setStatus(id, next, opts); const current = await this.requireReportAsync(id); if (current.status === next) return current; if (!isValidReportStatusTransition(current.status, next)) { @@ -590,7 +585,6 @@ export class ReportStore extends EventEmitter { /** Async attach review. Requires review_in_progress status. */ async attachReviewAsync(id: string, combined: CombinedReview): Promise { - if (!this.backendMode) return this.attachReview(id, combined); const current = await this.requireReportAsync(id); if (current.status !== "review_in_progress") { throw new ReportStoreError(`attachReview requires review_in_progress status; got ${current.status}`); @@ -624,10 +618,7 @@ export class ReportStore extends EventEmitter { /** Async delete by id. Throws if not found. */ async deleteReportAsync(id: string): Promise { - if (!this.backendMode) { - this.deleteReport(id); - return; - } + await this.requireReportAsync(id); await this.asyncLayer!.db .delete(schema.plugin.reports)