refactor: collapse dead SQLite dual-path code; keep migration-only readers (#2454)

# Remove dead SQLite dual-path code; keep migration-only readers

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

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

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

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

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

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

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

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

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

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

View File

@@ -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<void> {
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<AgentHeartbeatRun>;
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<void> {
@@ -699,28 +612,14 @@ export class AgentStore extends EventEmitter {
async findAgentByName(name: string): Promise<Agent | null> {
// 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<boolean> {
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<Agent | null> {
// 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<AgentRating[]> {
/*
* 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<string | number> = [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<AgentRatingSummary> {
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<Agent[]> {
// 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<AgentHeartbeatEvent[]> {
// 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<AgentHeartbeatRun>(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<AgentHeartbeatRun | null>(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<AgentTaskSession | null>(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<AgentHeartbeatRun | null>(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<AgentHeartbeatRun | null>(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<BlockedStateSnapshot | null> {
// 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<BlockedStateSnapshot | null>(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<void> {
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<void> {
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<void> {
// 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<AgentConfigRevision[]> {
// 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<AgentConfigRevision | null>(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<AgentConfigRevision | null> {
// 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<AgentConfigRevision | null>(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<AgentApiKey | null>(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<Partial<AgentData>>(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<void> {
// 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<string>();

View File

@@ -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<string, unknown> | undefined {
if (value === null || value === undefined) return undefined;
@@ -178,134 +180,42 @@ export class ApprovalRequestStore {
}
async create(input: ApprovalRequestCreateInput): Promise<ApprovalRequest> {
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<ApprovalRequest | null> {
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<ApprovalRequest[]> {
if (this.backendMode) {
return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input);
}
const where: string[] = [];
const params: Array<string | number> = [];
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<Map<string, number>> {
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<ApprovalRequest | null> {
/*
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<string> = [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<ApprovalRequest> {
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<ApprovalRequest> {
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<ApprovalRequestAuditEvent[]> {
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);
}
}

View File

@@ -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<AutomationStoreEvents> {
* 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<AutomationStoreEvents> {
* no SQLite file to open or one-shot migration to run.
*/
async init(): Promise<void> {
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<AutomationStoreEvents> {
// ── Persistence ────────────────────────────────────────────────────
private async readScheduleJson(id: string): Promise<ScheduledTask> {
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<void> {
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<AutomationStoreEvents> {
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<AutomationStoreEvents> {
}
async getSchedule(id: string): Promise<ScheduledTask> {
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<ScheduledTask[]> {
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<ScheduledTask> {
this.requireVersionedConfigurationBackend();
@@ -450,15 +422,12 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
}
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<AutomationStoreEvents> {
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<AutomationStoreEvents> {
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<AutomationStoreEvents> {
*/
async claimDueSchedule(id: string, expectedNextRunAt: string): Promise<boolean> {
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<ScheduleRow, "id" | "cronExpression" | "enabled" | "nextRunAt"> | 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<AutomationStoreEvents> {
*/
async getDueSchedules(scope: "global" | "project"): Promise<ScheduledTask[]> {
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<AutomationStoreEvents> {
*/
async getDueSchedulesAllScopes(): Promise<ScheduledTask[]> {
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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<PluginStoreEvents> {
* project-state authority remains in PostgreSQL.
*/
async init(): Promise<void> {
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<PluginStoreEvents> {
* 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<string, unknown> = {};
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<PluginInstallation> {
/*
* 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<PluginInstallation> {
/*
* 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<PluginInstallation[]> {
/*
* 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<PluginInstallation> {
/*
* 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<PluginStoreEvents> {
async disablePlugin(id: string): Promise<PluginInstallation> {
/*
* 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<PluginStoreEvents> {
}
/*
* 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<PluginStoreEvents> {
}
/*
* 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<PluginStoreEvents> {
* 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<PluginInstallation> {
await this.getPlugin(id);
@@ -730,80 +589,19 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
* 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;
}
}
}

View File

@@ -129,26 +129,14 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
* 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<void> {
// 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<RoutineStoreEvents> {
* 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<string, unknown> = {};
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<RoutineStoreEvents> {
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<RoutineStoreEvents> {
* 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<RoutineStoreEvents> {
* 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<RoutineStoreEvents> {
}
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<RoutineStoreEvents> {
* 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<RoutineStoreEvents> {
* 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<RoutineStoreEvents> {
* 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);
}
}

View File

@@ -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<ProjectDatabase, "prepare" | "bumpLastModified"> | Pick<CentralDatabase, "prepare" | "bumpLastModified">;
@@ -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<SecretRecord[]> {
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<EnvExportableSecret[]> {
const keyPrefix = opts?.keyPrefix;
@@ -221,14 +196,8 @@ export class SecretsStore {
}
async getSecretMetadata(id: string, scope: SecretScope): Promise<SecretRecord | null> {
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<SecretRecord> {
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<string | number | Buffer | null> = [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<void> {
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;
}
}

View File

@@ -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<TaskStoreEvents> {
}
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<Task> {
/*
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<void> {
return _maybeAutoArchiveSameAgentDuplicateImpl(this, task, input);
@@ -1077,34 +1065,29 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async findOpenRevertTaskForSource(sourceTaskId: string): Promise<Task | null> {
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<string, unknown>));
/*
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<string, unknown>));
}
/** 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<void> {
@@ -2005,8 +1988,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
predicate: (live: Task) => boolean | Promise<boolean>,
options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; allowResurrection?: boolean; githubIssueAction?: GithubIssueAction; auditContext?: TaskDeleteAuditContext },
): Promise<DeleteTaskIfResult> {
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);

View File

@@ -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<typeof batch[number]>();
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<string, typeof validEntries>();

View File

@@ -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<T
* archive table, restore the task to active storage, and delete the archive
* entry — all without touching store.db or store.archiveDb (SQLite).
*/
if (store.backendMode) {
const layer = store.asyncLayer!;
const layer = store.asyncLayer!;
/*
FNXC:ArchiveRestore 2026-07-14-18:48:
Public getTask deliberately falls back to cold storage when the live row is tombstoned. Unarchive must inspect the live table directly so that fallback cannot masquerade as an already-restored row and leave deleted_at set after deleting the only cold snapshot.
*/
const liveRow = await readTaskRowAsync(layer, id, { includeDeleted: true });
const entry = await findArchivedTaskEntry(layer.db, id, layer.projectId);
let task: Task;
if (entry) {
/*
FNXC:ArchiveRestore 2026-07-14-18:48:
Public getTask deliberately falls back to cold storage when the live row is tombstoned. Unarchive must inspect the live table directly so that fallback cannot masquerade as an already-restored row and leave deleted_at set after deleting the only cold snapshot.
FNXC:ArchiveRestore 2026-07-14-21:48:
A cold snapshot may outlive a missing project.tasks row after cleanup or partial legacy archival. Rebuild that row through the canonical snapshot restoration path before restoreTaskFromArchive consumes the snapshot; an existing live or tombstoned row keeps the established in-place restore path.
*/
const liveRow = await readTaskRowAsync(layer, id, { includeDeleted: true });
const entry = await findArchivedTaskEntry(layer.db, id, layer.projectId);
let task: Task;
if (entry) {
/*
FNXC:ArchiveRestore 2026-07-14-21:48:
A cold snapshot may outlive a missing project.tasks row after cleanup or partial legacy archival. Rebuild that row through the canonical snapshot restoration path before restoreTaskFromArchive consumes the snapshot; an existing live or tombstoned row keeps the established in-place restore path.
*/
if (!liveRow) {
await store.restoreFromArchive(entry);
}
await restoreTaskFromArchive(layer, entry);
task = await store.getTask(id);
} else if (liveRow && liveRow.deletedAt == null) {
task = await store.getTask(id);
} else {
throw new Error(`Cannot unarchive ${id}: task is missing from active storage and not found in archive`);
if (!liveRow) {
await store.restoreFromArchive(entry);
}
if (task.column !== "archived") {
throw new Error(`Cannot unarchive ${id}: task is in '${task.column}', must be in 'archived'`);
}
const preArchiveColumn = task.preArchiveColumn ?? "todo";
const toColumn = store.resolveUnarchiveTargetColumn(preArchiveColumn);
/*
* 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),
));
const updatedTask = await store.getTask(id);
// Log the unarchive action.
await store.logEntry(id, "Task unarchived");
// Remove from archive table.
await deleteArchivedTaskEntry(layer.db, id, layer.projectId);
return updatedTask;
await restoreTaskFromArchive(layer, entry);
task = await store.getTask(id);
} else if (liveRow && liveRow.deletedAt == null) {
task = await store.getTask(id);
} else {
throw new Error(`Cannot unarchive ${id}: task is missing from active storage and not found in archive`);
}
const dir = store.taskDir(id);
// If the active row is gone, restore from cold archive storage before
// taking the task lock. A stale directory may still exist after manual
// filesystem edits, so database presence is the source of truth.
if (!store.readTaskFromDb(id)) {
const entry = await store.findInArchive(id);
if (!entry) {
throw new Error(
`Cannot unarchive ${id}: task is missing from active storage and not found in archive`,
);
}
await store.restoreFromArchive(entry);
if (task.column !== "archived") {
throw new Error(`Cannot unarchive ${id}: task is in '${task.column}', must be in 'archived'`);
}
return store.withTaskLock(id, async () => {
// 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<Task> {
const dir = store.taskDir(entry.id);

View File

@@ -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<DeleteTaskIfResult> {
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<Task> {
// 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);
}

View File

@@ -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<void> => {
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<TaskLogEntry[]>(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;
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<Task[]> {
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<Agent, "id" | "role"> & Partial<Pick<Agent, "runtimeConfig">>,): Promise<InboxTask | null> {
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<ArtifactRow & {
taskTitle: string | null;
taskDescription: string | null;
taskColumn: string | null;
}>;
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<string, unknown>,): Promise<void> {
/*

View File

@@ -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<Task> {
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;
}
}

View File

@@ -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<void> {
@@ -50,240 +47,35 @@ export async function initImpl(store: TaskStore): Promise<void> {
// 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<string, unknown>, 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<string, unknown>, 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<void> {
* 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<void> {
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

View File

@@ -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<void> {
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<import("../types.js").MergeDetails> {
const mergedAt = new Date().toISOString();

View File

@@ -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<MergeQueueEntry | null> {
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<MergeResult> {
return store.withTaskLock(id, async () => {

View File

@@ -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<WorkflowIr> {
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<string, unknown>, 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<string, unknown>, 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.
}

View File

@@ -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<TaskStore> {
if (!asyncLayer) {
@@ -100,49 +98,9 @@ export async function getOrCreateForProjectImpl(store: typeof TaskStore, project
}
export async function listGoalCitationsImpl(store: TaskStore, filter: GoalCitationFilter = {}): Promise<GoalCitation[]> {
if (store.backendMode) {
const layer = store.asyncLayer!;
return listGoalCitationsAsync(layer.db, filter);
}
const clauses: string[] = [];
const params: Array<string | number> = [];
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<void> {
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<string, unknown>, context);
const setValues: Record<string, unknown> = { 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<string, unknown>, context);
const setValues: Record<string, unknown> = { 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<string, unknown>, 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<string, unknown>, 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<keyof TaskRow>();
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<Task> {
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<string, unknown>)) 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<string, unknown>)) 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<void> {
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<import("../types.js").WorkflowStep[]> {
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<typeof store.toStoredWorkflowStep>[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<typeof store.toStoredWorkflowStep>[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<import("../types.js").WorkflowStep | undefined> {
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<typeof store.toStoredWorkflowStep>[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<typeof store.toStoredWorkflowStep>[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<void>) | 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<ActivityLogEntry, "id" | "timestamp">): Promise<ActivityLogEntry> {
// 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<Settings> {
// 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<Settings>).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<Settings> {
} 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<Settings> {
// 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<Settings> | undefined = raw
? (Object.fromEntries(
Object.entries(raw).filter(([key]) => !isGlobalOnlySettingsKey(key)),
) as Partial<Settings>)
: 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<Settings>(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<Settings> | undefined = raw
? (Object.fromEntries(
Object.entries(raw).filter(([key]) => !isGlobalOnlySettingsKey(key)),
) as Partial<Settings>)
: 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<ProjectSettings> }> {
// 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<ProjectSettings> = {};
if (projectConfig.settings) {
for (const key of Object.keys(projectConfig.settings)) {
if (!isGlobalOnlySettingsKey(key)) {
(projectSettings as Record<string, unknown>)[key] = (projectConfig.settings as Record<string, unknown>)[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<ProjectSettings> = {};
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<string, unknown>)[key] = (config.settings as Record<string, unknown>)[key];
(projectSettings as Record<string, unknown>)[key] = (projectConfig.settings as Record<string, unknown>)[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<ProjectSettings> }> {
// 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<ProjectSettings> = {};
if (projectSettings) {
for (const key of Object.keys(projectSettings)) {
if (!isGlobalOnlySettingsKey(key)) {
(projectScoped as Record<string, unknown>)[key] = (projectSettings as Record<string, unknown>)[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<Settings>(row.settings) : undefined;
// Extract only project-level keys from config.settings
const global = { ...globalSettings, secretsSyncPassphraseConfigured };
const projectSettings = projectSettingsRaw ?? undefined;
const projectScoped: Partial<ProjectSettings> = {};
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 };
}

View File

@@ -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<Settin
Reject legacy project setting writes before side effects rather than claim a
rollback guarantee that the compatibility backend cannot provide.
*/
if (!store.backendMode) throw new Error("Project configuration changes require the PostgreSQL revision store");
/* FNXC:SqliteDualPathCleanup 2026-07-26-14:15: project configuration changes always use PostgreSQL revision store. */
// Stale-writer guard (U4, R8): moved keys no longer live in project settings —
// they belong to workflow setting values. Drop any moved key arriving from a
@@ -76,13 +73,13 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settin
}
return store.withConfigLock(async () => {
// 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<Settin
// Do not publish changes from within the transaction: a revision insert
// or commit failure must remain invisible to listeners and side effects.
return { previousMerged, updatedMerged };
});
/*
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.
*/
await publishSettingsUpdated(store, transactionResult.previousMerged, transactionResult.updatedMerged);
return transactionResult.updatedMerged;
}
const config = store.readConfigFast();
// Handle null values as "delete this key from settings"
// This allows the frontend to explicitly clear a setting by sending null
// (since JSON.stringify drops undefined keys, we use null as a sentinel)
// Handle special null-as-delete semantics for promptOverrides
const incomingPromptOverrides = (projectPatch as Record<string, unknown>)["promptOverrides"];
if (incomingPromptOverrides === null) {
// promptOverrides: null → clear the entire promptOverrides object
delete (config.settings as unknown as Record<string, unknown>)["promptOverrides"];
delete (projectPatch as Record<string, unknown>)["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<string, unknown>;
const existingMap = ((config.settings as unknown as Record<string, unknown>)["promptOverrides"] as Record<string, string>) ?? {};
const mergedMap: Record<string, string> = { ...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<string, unknown>)["promptOverrides"];
delete (projectPatch as Record<string, unknown>)["promptOverrides"];
} else {
(config.settings as unknown as Record<string, unknown>)["promptOverrides"] = mergedMap;
(projectPatch as Record<string, unknown>)["promptOverrides"] = mergedMap;
}
}
// Handle null values for other top-level keys (non-promptOverrides)
for (const key of Object.keys(projectPatch)) {
if ((projectPatch as Record<string, unknown>)[key] === null) {
delete (config.settings as unknown as Record<string, unknown>)[key];
delete (projectPatch as Record<string, unknown>)[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

View File

@@ -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<void> {
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<CompletionHandoffMarker | null> {
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<PluginActivation> {
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<MergeQueueEntry> {
// 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<MergeQueueEntry[]> {
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<import("../types.js").TaskStep[]> {
@@ -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<string[]> {
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<boolean> {
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<Task> {
@@ -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<Task> {
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<Task> {
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<Artifact | null> {
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<Art
* artifact lists live-refresh.
*/
export async function updateArtifactImpl(store: TaskStore, id: string, updates: { title?: string; description?: string; content?: string }): Promise<Artifact> {
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<Artifact[]> {
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<TaskDocument[]> {
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<TaskDocument | null> {
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<void> {
@@ -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 });
}

View File

@@ -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<TaskCommitAssociation[]> {
/*
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;
}

View File

@@ -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<Settings> = resolvedSettings ?? {};
try {
const defaultWorkflowId = (await store.getDefaultWorkflowId()) ?? "builtin:coding";
const effective = await resolveEffectiveSettingsById(
store,
defaultWorkflowId,
store.getWorkflowSettingsProjectId(),
);
summarizerSettings = { ...summarizerSettings, ...(effective as Partial<Settings>) };
} 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<Task> {
// 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 });

View File

@@ -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<Set<string>> {
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<boolean> {
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<boolean> {
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<string[]> {
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<string, unknown>, 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<string, unknown>, 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<BoardConfig> {
@@ -837,26 +738,11 @@ export function toBuiltInWorkflowStepImpl(store: TaskStore, template: import("..
};
}
export function getLegacyWorkflowStepSnapshotImpl(store: TaskStore, id: string, templateId?: string): Record<string, unknown> | undefined {
export function getLegacyWorkflowStepSnapshotImpl(_store: TaskStore, _id: string, _templateId?: string): Record<string, unknown> | 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<Array<Record<string, unknown>>>(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<BranchGroup> {
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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

@@ -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<MergeRequestRecord | null> {
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<WorkflowIr> {
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<TaskStor
a layer is present, then return the last snapshot (or optimistic healthy
until the first probe completes). Prefer refreshDatabaseHealthAsync from
async callers (self-healing surfaceDbCorruption).
FNXC:SqliteDualPathCleanup 2026-07-27-06:15:
Only schedule the async probe when asyncLayer exists. Without a layer,
refreshDatabaseHealthAsyncImpl used to call back into this sync entry and
re-schedule forever (unbounded health-refresh microtask recursion).
*/
if (store.backendMode) {
if (store.asyncLayer) {
void refreshDatabaseHealthAsyncImpl(store).catch(() => 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<ReturnType<TaskStore["getDatabaseHealth"]>> {
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<void> {
* 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;
}

View File

@@ -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<Task | null> => {
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));

View File

@@ -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<v
}
export async function readRawProjectSettingsImpl(store: TaskStore): Promise<Record<string, unknown>> {
// 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<string, unknown>)
: {};
} 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<string, unknown>)
const config = await readProjectConfig(store.asyncLayer!);
const settings = config.settings;
return settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as Record<string, unknown>)
: {};
} 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<Map<string, number>> {
const counts = new Map<string, number>();
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<void> {
@@ -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<void> {
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<SecretsStor
// In backend mode, pass the AsyncDataLayer so SecretsStore delegates to
// the async helpers. The sync projectDb/centralDb are still required by
// the constructor signature but are unused when asyncLayer is set.
if (store.backendMode) {
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;
}
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<Settings>(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<ActivityLogEntry[]> {
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<Set<string>> {
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;
}
/*

View File

@@ -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<number>;
if (store.backendMode) {
const layer = store.asyncLayer!;
const grouped = await layer.db
.select({
commitSha: schema.project.taskCommitAssociations.commitSha,
rowCount: sql<number>`count(*)`,
})
.from(schema.project.taskCommitAssociations)
const layer = store.asyncLayer!;
const grouped = await layer.db
.select({
commitSha: schema.project.taskCommitAssociations.commitSha,
rowCount: sql<number>`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),

View File

@@ -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<import("../types.js").WorkflowStep> {
@@ -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<import("../types.js").WorkflowStepInput>): Promise<import("../types.js").WorkflowStep> {
// 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<string, unknown>, { 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<import("../types.js").WorkflowStepInput>): Promise<import("../types.js").WorkflowStep> {
// 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<WorkflowDefinition> {
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<string, number>();
for (const taskId of occupantTaskIds) {
let values: Record<string, unknown> = {};
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<string, unknown>;
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<Record<string, unknown>>(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<string, unknown>;
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<void> {
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<string[]> {
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;
}

View File

@@ -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<GoalCitation[]> {
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<string, unknown>, 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<string, unknown>, context);
const setValues: Record<string, unknown> = { 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<string, unknown>, 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<string, unknown>, context);
const setValues: Record<string, unknown> = { 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<keyof TaskRow>();
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<string | null>; settings?: { autoSummarizeTitles?: boolean }; createTaskWithId?: (taskId: string) => Promise<Task>; },): Promise<Task> {
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<string, Column>();
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<string>();
const resultPg = new Map<string, Column>();
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<string, Column>();
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<string, Column>();
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<string>();
const resultPg = new Map<string, Column>();
for (const id of uniqueIds) {
if (!activeById.has(id)) {
missingIds.push(id);
}
}
const archivedSet = missingIds.length > 0 ? store.archiveDb.filterArchived(missingIds) : new Set<string>();
const result = new Map<string, Column>();
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<MoveTaskInternalOptions["movePolicyPreflight"]> {
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<string, Record<string, string>> = {};
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<string, string> = {};
for (const [nodeId, value] of Object.entries(overrides as Record<string, unknown>)) {
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<string, Record<string, string>> = {};
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<string, string> = {};
for (const [nodeId, value] of Object.entries(overrides as Record<string, unknown>)) {
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<string, Record<string, string>> = {};
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<WorkflowWorkItem[]> {
// 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<WorkflowWorkItem[]> {
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<string>): 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<void> {
/*
@@ -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<Record<string, number>> {
// 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<string, number> = {};
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;
}
}

View File

@@ -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<WorkflowWorkItem> {
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<WorkflowWorkItem> {
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<WorkflowWorkItem> {
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<WorkflowWorkItem | null> {
@@ -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;
}

View File

@@ -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<WorkflowWorkItem | null> {
// 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<Task, "id" | "autoMerge" | "priority">, opts: { runId?: string; now?: string; source?: string } = {}, tx?: import("../postgres/data-layer.js").DbTransaction): Promise<WorkflowWorkItem> {
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;
}
}

View File

@@ -1646,7 +1646,6 @@ export function getExecutorSystemPrompt(
return sections.join("\n\n");
}
export interface TaskExecutorOptions {
/*
* FNXC:PlanReviewLease 2026-07-26-21:07:

View File

@@ -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<Service[]> {
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<Service | undefined> {
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<Service> {
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<void> {
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<CliSpec[]> {
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<CliSpec[]> {
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<CliSpec[]> {
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<CliSpec | undefined> {
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<CliSpec> {
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<void> {
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<CliArtifact[]> {
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<CliArtifact[]> {
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<CliArtifact[]> {
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<CliArtifact> {
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<CliArtifact> {
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<void> {
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<Credential[]> {
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<Credential[]> {
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<Credential> {
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<Credential> {
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<void> {
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<ServiceSetting[]> {
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<ServiceSetting> {
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<void> {
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;
},
};
}

View File

@@ -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<CeSession> {
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<CeSession> {
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<void> {
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<CeSession | undefined> {
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<CeSession[]> {
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<Pick<CeSession, "status" | "currentQuestion" | "conversationHistory" | "artifactPath" | "error" | "lastActivityAt" | "projectId">>): Promise<CeSession | undefined> {
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<CeSession | undefined> {
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<boolean> {
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<boolean> { 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<boolean> { 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<string[]> {
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,

View File

@@ -231,7 +231,6 @@ export class CePipelineStore {
/** Async sibling: createLink. Routes to Drizzle in backend mode. */
async createLinkAsync(input: CreateCePipelineLinkInput): Promise<CePipelineLink> {
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<CePipelineLink[]> {
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<CePipelineLink | undefined> {
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<CePipelineState | undefined> {
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<CePipelineState[]> {
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<CePipelineState> {
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<CePipelineState | undefined> {
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<CeSyncQueueEntry> {
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<CeSyncQueueEntry[]> {
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<void> {
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(

View File

@@ -449,14 +449,13 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
}
// ── 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<Report> {
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<ReportStoreEvents> {
/** Async get by id. Returns null when not found. */
async getReportAsync(id: string): Promise<Report | null> {
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<ReportStoreEvents> {
/** Async list with filters, ordering, and pagination. */
async listReportsAsync(filter: ReportListFilter = {}): Promise<Report[]> {
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<ReportStoreEvents> {
/** Async update by id with a partial patch. Throws if not found. */
async updateReportAsync(id: string, patch: ReportUpdateInput): Promise<Report> {
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<ReportStoreEvents> {
next: ReportStatus,
opts: { failureReason?: string; approvedBy?: string } = {},
): Promise<Report> {
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<ReportStoreEvents> {
/** Async attach review. Requires review_in_progress status. */
async attachReviewAsync(id: string, combined: CombinedReview): Promise<Report> {
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<ReportStoreEvents> {
/** Async delete by id. Throws if not found. */
async deleteReportAsync(id: string): Promise<void> {
if (!this.backendMode) {
this.deleteReport(id);
return;
}
await this.requireReportAsync(id);
await this.asyncLayer!.db
.delete(schema.plugin.reports)