fix: key workflow settings by the central project id and stamp all partitioned tables on both migration paths

Closes the remaining PG-cutover partitioning gaps:

- getWorkflowSettingsProjectId resolves the bound AsyncDataLayer's central-
  registry id first. In backend mode the SQLite stub's getProjectIdentity()
  throws, so the old fallback ALWAYS keyed workflow_settings /
  workflow_prompt_overrides by the rootDir path string — a namespace nothing
  else reads, making workflow settings appear reset after cutover.
- Stamping is extracted into core stampMigratedProjectRows (tasks/archived
  NULL->id, config ''->id, workflow_settings + workflow_prompt_overrides
  rootDir-key->id, all guarded against clobbering per-project rows), shared by
  startup-factory Step 5.5 and 'fn db migrate', which now resolves the
  registered project by path after the copy and warns when unregistered.
- The task-id allocator and merge_queue are verified safe WITHOUT project
  partitioning: task ids are a global PK, the per-prefix sequence scans are
  intentionally global (only the per-project config floor can raise them), so
  two projects sharing a prefix cannot mint duplicate ids. FNXC comments lock
  the invariant; a cross-project PG regression test proves it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-13 21:49:33 -07:00
parent 3ccc9f96e6
commit 8e4514e585
13 changed files with 935 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: fn db migrate now stamps migrated rows so tasks, config, and workflow settings stay visible after a cutover.
category: fix
dev: Extracts the first-boot stamping into core `stampMigratedProjectRows` (project.tasks/archived_tasks/archive.archived_tasks NULL→id, project.config ''→id, and the new project.workflow_settings/workflow_prompt_overrides rootDir-key→id re-key, all NOT_EXISTS-guarded). Shared by startup-factory Step 5.5 and `fn db migrate`, which resolves the registered project id via `lookupRegisteredProjectIdByPath(central.projects.path)` after the copy and warns when the project is unregistered.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix workflow settings and prompt overrides appearing reset after the PostgreSQL migration.
category: fix
dev: getWorkflowSettingsProjectId now resolves the central-registry id from the bound AsyncDataLayer first. In PG mode the SQLite stub's getProjectIdentity() throws, so the old code always fell through to the rootDir path string — workflow_settings/workflow_prompt_overrides rows were keyed by an absolute path nothing else could find. Legacy path-keyed rows are re-keyed by migration stamping.

View File

@@ -5,6 +5,8 @@ import {
resolveBackend,
migrateSqliteToPostgres,
defaultMigrationSources,
stampMigratedProjectRows,
lookupRegisteredProjectIdByPath,
resolveGlobalDir,
type MigrationReport,
} from "@fusion/core";
@@ -240,6 +242,50 @@ export async function runDbMigrate(
return;
}
/*
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
* The migrator (migrateSqliteToPostgres) is partition-unaware: it copies
* legacy rows verbatim, so migrated rows land with NULL project_id, a '' config
* key, and rootDir-path-keyed workflow settings — all invisible to bound
* readers (engine, dashboard project-store-resolver, configScope,
* workflow-settings resolver). The first-boot auto-migration stamps these; the
* manual `fn db migrate` path stamped NOTHING, so an operator cutover left the
* board/settings empty. Resolve the registered project id for this cwd by
* matching central.projects.path (the migration just populated central.projects,
* so query AFTER the copy) and re-key the migrated rows. If the project was
* never registered centrally, leave rows unstamped and tell the operator how to
* fix it (unregistered single-project setups use an unbound, unfiltered layer).
*/
if (!dryRun) {
try {
const registeredProjectId = await lookupRegisteredProjectIdByPath(
connections.migration,
projectRoot,
);
if (registeredProjectId) {
await stampMigratedProjectRows(connections.migration, {
projectId: registeredProjectId,
rootDir: projectRoot,
});
console.log(
`fn db migrate: stamped migrated rows with central-registry project id "${registeredProjectId}" (tasks, archived tasks, config, workflow settings).`,
);
} else {
console.warn(
`fn db migrate: WARNING — no registered project matches path "${projectRoot}" in central.projects; ` +
`migrated rows were left UNSTAMPED (NULL project_id / '' config key / rootDir-keyed workflow settings) ` +
`and will be invisible to project-bound readers. To fix: register the project (e.g. open it once via the ` +
`dashboard/CLI so it is added to central.projects), then re-run \`fn db migrate\` to stamp the rows.`,
);
}
} catch (error) {
console.warn(
`fn db migrate: WARNING — post-migration row stamping failed: ${(error as Error).message}. ` +
`Migrated rows may be invisible to project-bound readers; re-run \`fn db migrate\` after confirming the project is registered.`,
);
}
}
await connections.close().catch(() => undefined);
// 6. Report.

View File

@@ -0,0 +1,254 @@
/**
* Cross-project distributed-task-id allocator PostgreSQL integration test.
*
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
* Locks in the global-task-id invariant on the shared embedded-PG cluster: two
* per-project TaskStores (bound to different projectIds, "proj_a" / "proj_b")
* over ONE database + ONE `project` schema, both configured with the SAME task
* prefix, MUST draw from a single shared per-prefix sequence and never mint a
* duplicate task id.
*
* Why this matters (see async-allocator.ts computeNextSequenceFloor and the
* schema note on distributed_task_id_state): `tasks.id` is a global PRIMARY KEY
* shared by every project, so the per-prefix sequence in
* `distributed_task_id_state` (keyed on prefix only, no project_id) is what
* guarantees two projects using the same prefix never collide. The allocator's
* high-water scans are unscoped (prefix only) so the shared sequence advances
* past every project's max suffix. This test proves:
* 1. Interleaved reservations across the two project-bound layers yield ids
* that are all UNIQUE and STRICTLY INCREASING per the shared sequence.
* 2. Inserting a task under each project (project_id stamped respectively)
* with its minted id causes NO tasks.id primary-key violation.
* 3. reserve → commit works for both layers against the shared state row.
*
* Skipped when PostgreSQL is unreachable (FUSION_PG_TEST_SKIP=1) so the merge
* gate stays green without a running server.
*/
import { describe, it, expect, afterEach } from "vitest";
import { eq } from "drizzle-orm";
import { execSync } from "node:child_process";
import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js";
import { createConnectionSetFromUrl, type PostgresConnections } from "../../postgres/connection.js";
import type { ResolvedBackend } from "../../postgres/backend-resolver.js";
import { applySchemaBaseline } from "../../postgres/schema-applier.js";
import * as schema from "../../postgres/schema/index.js";
import { insertTaskRow } from "../../task-store/async-persistence.js";
import {
createAsyncDistributedTaskIdAllocator,
reconcileTaskIdStateAsync,
} from "../../task-store/async-allocator.js";
import type { DistributedTaskIdAllocator } from "../../distributed-task-id.js";
const PG_TEST_URL_BASE =
process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432";
const PG_AVAILABLE =
process.env.FUSION_PG_TEST_SKIP !== "1" && Boolean(PG_TEST_URL_BASE);
const pgDescribe = PG_AVAILABLE ? describe : describe.skip;
const SHARED_PREFIX = "KB";
function uniqueDbName(): string {
return `fusion_allocxp_test_${process.pid}_${Math.random().toString(36).slice(2, 8)}`;
}
function adminExec(statement: string): void {
execSync(
`psql -h localhost -p 5432 -U ${process.env.USER ?? "postgres"} -d postgres -v ON_ERROR_STOP=1 -c "${statement.replace(/"/g, '\\"')}"`,
{ stdio: "pipe", env: process.env },
);
}
interface TestCtx {
dbName: string;
connections: PostgresConnections;
/** One raw connection set; two logical layers differ only by bound projectId. */
layerA: AsyncDataLayer;
layerB: AsyncDataLayer;
allocatorA: DistributedTaskIdAllocator;
allocatorB: DistributedTaskIdAllocator;
}
async function setupCtx(): Promise<TestCtx> {
const dbName = uniqueDbName();
try {
adminExec(`DROP DATABASE IF EXISTS "${dbName}"`);
} catch {
// may not exist
}
adminExec(`CREATE DATABASE "${dbName}"`);
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
const backend: ResolvedBackend = {
mode: "external",
runtimeUrl: testUrl,
migrationUrl: testUrl,
migrationUrlOverridden: false,
};
const schemaConnections = await createConnectionSetFromUrl(backend, {
poolMax: 1,
connectTimeoutSeconds: 5,
});
await applySchemaBaseline(schemaConnections.migration);
await schemaConnections.close();
const connections = await createConnectionSetFromUrl(backend, {
poolMax: 5,
connectTimeoutSeconds: 5,
});
// Two project-bound layers over the SAME shared database + `project` schema.
const layerA = createAsyncDataLayer(connections, { projectId: "proj_a" });
const layerB = createAsyncDataLayer(connections, { projectId: "proj_b" });
const allocatorA = createAsyncDistributedTaskIdAllocator(layerA);
const allocatorB = createAsyncDistributedTaskIdAllocator(layerB);
return { dbName, connections, layerA, layerB, allocatorA, allocatorB };
}
async function teardownCtx(ctx: TestCtx | null): Promise<void> {
if (!ctx) return;
try {
await ctx.connections.close();
} catch {
// best-effort
}
try {
adminExec(`DROP DATABASE IF EXISTS "${ctx.dbName}"`);
} catch {
// best-effort
}
}
/** Insert a task row with the minted id under the given layer (project_id stamped). */
async function insertMintedTask(layer: AsyncDataLayer, id: string): Promise<void> {
const now = new Date().toISOString();
await insertTaskRow(
layer,
{
id,
description: "cross-project allocator test task",
column: "todo",
currentStep: 0,
createdAt: now,
updatedAt: now,
},
{ lineageId: null },
);
}
function suffix(taskId: string): number {
return Number.parseInt(taskId.split("-")[1] ?? "", 10);
}
pgDescribe("cross-project distributed-task-id allocator (PostgreSQL)", () => {
let ctx: TestCtx | null = null;
afterEach(async () => {
await teardownCtx(ctx);
ctx = null;
});
it("two projects sharing a prefix draw unique, strictly-increasing ids from ONE shared sequence", async () => {
ctx = await setupCtx();
const { allocatorA, allocatorB, layerA, layerB } = ctx;
// Reconcile both on open (mirrors store-open). Both key on the same shared
// prefix row, so this is idempotent.
await reconcileTaskIdStateAsync(layerA);
await reconcileTaskIdStateAsync(layerB);
const minted: { taskId: string; project: "a" | "b" }[] = [];
// Interleave a realistic number of reserve→commit allocations, alternating
// between the two project-bound allocators. Each uses the REAL allocator
// entry points (reserve + commit).
const ROUNDS = 12;
for (let i = 0; i < ROUNDS; i++) {
const useA = i % 2 === 0;
const allocator = useA ? allocatorA : allocatorB;
const nodeId = useA ? "node-a" : "node-b";
const reserved = await allocator.reserveDistributedTaskId({
prefix: SHARED_PREFIX,
nodeId,
});
const committed = await allocator.commitDistributedTaskIdReservation({
reservationId: reserved.reservationId,
nodeId,
});
expect(committed.taskId).toBe(reserved.taskId);
// Insert the task under the respective project so project_id is stamped.
await insertMintedTask(useA ? layerA : layerB, committed.taskId);
minted.push({ taskId: committed.taskId, project: useA ? "a" : "b" });
}
// 1. All minted ids are unique (no cross-project duplicate).
const ids = minted.map((m) => m.taskId);
expect(new Set(ids).size).toBe(ids.length);
// 2. Suffixes are strictly increasing per the shared sequence (interleaving
// the two projects does not reset or fork the counter).
const suffixes = ids.map(suffix);
for (let i = 1; i < suffixes.length; i++) {
expect(suffixes[i]).toBeGreaterThan(suffixes[i - 1]!);
}
// 3. Both projects contributed ids (the interleave actually alternated).
expect(minted.some((m) => m.project === "a")).toBe(true);
expect(minted.some((m) => m.project === "b")).toBe(true);
// 4. Exactly one shared state row for the prefix; next_sequence is past the
// global max suffix.
const stateRows = await layerA.db
.select()
.from(schema.project.distributedTaskIdState)
.where(eq(schema.project.distributedTaskIdState.prefix, SHARED_PREFIX));
expect(stateRows).toHaveLength(1);
expect(stateRows[0]!.nextSequence).toBeGreaterThan(Math.max(...suffixes));
// 5. Tasks landed under BOTH project_ids with NO tasks.id PK violation
// (proven by the inserts above succeeding). Verify the stamping.
const allTasks = await layerA.db
.select({ id: schema.project.tasks.id, projectId: schema.project.tasks.projectId })
.from(schema.project.tasks);
expect(allTasks).toHaveLength(ROUNDS);
const byProject = new Map(allTasks.map((t) => [t.id, t.projectId]));
for (const m of minted) {
expect(byProject.get(m.taskId)).toBe(m.project === "a" ? "proj_a" : "proj_b");
}
});
it("a project's floor cannot mint an id below a sibling project's existing max suffix", async () => {
ctx = await setupCtx();
const { allocatorA, allocatorB, layerA, layerB } = ctx;
// Project B pre-populates a HIGH task id under the shared prefix, simulating
// a sibling project that already advanced the id namespace far ahead.
const highId = `${SHARED_PREFIX}-500`;
await insertMintedTask(layerB, highId);
// Project A opens/reconciles and reserves. Its floor scan is GLOBAL, so it
// must jump PAST B's max (500), never reuse an id <= 500.
await reconcileTaskIdStateAsync(layerA);
const reserved = await allocatorA.reserveDistributedTaskId({
prefix: SHARED_PREFIX,
nodeId: "node-a",
});
expect(suffix(reserved.taskId)).toBeGreaterThan(500);
await allocatorA.commitDistributedTaskIdReservation({
reservationId: reserved.reservationId,
nodeId: "node-a",
});
// Inserting under project A with the minted id does not collide with B's row.
await insertMintedTask(layerA, reserved.taskId);
// And B, allocating next, continues strictly above A's id (shared counter).
const reservedB = await allocatorB.reserveDistributedTaskId({
prefix: SHARED_PREFIX,
nodeId: "node-b",
});
expect(suffix(reservedB.taskId)).toBeGreaterThan(suffix(reserved.taskId));
});
});

View File

@@ -221,6 +221,21 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
JSON.stringify({ taskPrefix: "ST", merger: { mode: "ai" } }),
"2026-06-01T00:00:00Z",
);
// Legacy workflow_settings keyed by the pre-isolation rootDir path string
// (real SQLite schema: workflowId, projectId, "values", updatedAt). Must
// be re-keyed from the rootDir path to the registered project id so the
// bound workflow-settings resolver still sees the migrated VALUES
// (FNXC:CentralProjectIdentity 2026-07-13-23:10).
legacy.exec(`CREATE TABLE IF NOT EXISTS workflow_settings (
workflowId TEXT NOT NULL,
projectId TEXT NOT NULL,
"values" TEXT DEFAULT '{}',
updatedAt TEXT NOT NULL,
PRIMARY KEY (workflowId, projectId)
);`);
legacy.prepare(
`INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) VALUES (?, ?, ?, ?)`,
).run("wf_default", rootDir, JSON.stringify({ maxWorktrees: 3 }), "2026-06-01T00:00:00Z");
} finally {
legacy.close();
}
@@ -274,8 +289,129 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
expect(projectConfig, "migrated config row must be re-keyed to the project").toBeDefined();
expect(projectConfig!.settings?.taskPrefix).toBe("ST");
expect(configRows.some((r) => r.project_id === ""), "no orphaned '' config row").toBe(false);
/*
FNXC:CentralProjectIdentity 2026-07-13-23:10:
The migrated workflow_settings row, keyed by the pre-isolation rootDir
path string, must be re-keyed to the registered project id so a bound
workflow-settings resolver still sees the migrated VALUES. Before the
stamping re-key, this row stayed rootDir-keyed and vanished from every
project-bound read.
*/
const wfRows = (await layer.db.execute(
`SELECT workflow_id, project_id, "values" FROM project.workflow_settings ORDER BY workflow_id`,
)) as unknown as Array<{ workflow_id: string; project_id: string; values: { maxWorktrees?: number } | null }>;
const wfRow = wfRows.find((r) => r.workflow_id === "wf_default");
expect(wfRow, "migrated workflow_settings row must survive the migration").toBeDefined();
expect(
wfRow!.project_id,
"workflow_settings row must be re-keyed from the rootDir path to the registered project id",
).toBe("proj_stamp_test");
expect(wfRow!.values?.maxWorktrees).toBe(3);
expect(
wfRows.some((r) => r.project_id === rootDir),
"no workflow_settings row may remain keyed by the rootDir path",
).toBe(false);
} finally {
await boot!.shutdown();
}
});
/*
FNXC:CentralProjectIdentity 2026-07-13-23:10:
Direct unit-ish coverage of the shared stampMigratedProjectRows helper: seed a
freshly-baselined PG schema with unstamped rows (NULL project_id tasks, ''
config, rootDir-keyed workflow settings/prompt overrides), run the helper, and
assert every table is re-keyed to the supplied project id — including the
NOT_EXISTS guard that refuses to clobber a pre-existing per-project row.
*/
it("stampMigratedProjectRows re-keys all partitioned tables to the project id", async () => {
rootDir = await mkdtemp(join(tmpdir(), "stamp-helper-"));
dbName = uniqueDbName();
adminExec(`CREATE DATABASE "${dbName}"`);
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
const fakeRootDir = "/legacy/path/to/project";
const { createConnectionSetFromUrl } = await import("../../postgres/connection.js");
const { applySchemaBaseline } = await import("../../postgres/schema-applier.js");
const { stampMigratedProjectRows } = await import("../../postgres/migration-stamping.js");
const { resolveBackendWithOptions } = await import("../../postgres/backend-resolver.js");
const connections = await createConnectionSetFromUrl(
resolveBackendWithOptions({ databaseUrl: testUrl }),
{ poolMax: 1, connectTimeoutSeconds: 30 },
);
try {
await applySchemaBaseline(connections.migration);
const db = connections.migration;
// Seed unstamped rows the migrator would have produced.
await db.execute(
`INSERT INTO project.tasks (id, description, "column", created_at, updated_at)
VALUES ('FN-HELP-1', 'd', 'todo', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z')`,
);
await db.execute(
`INSERT INTO project.config (project_id, settings, updated_at)
VALUES ('', '{"taskPrefix":"HL"}'::jsonb, '2026-06-01T00:00:00Z')`,
);
await db.execute(
`INSERT INTO project.workflow_settings (workflow_id, project_id, "values", updated_at)
VALUES ('wf_a', '${fakeRootDir}', '{"maxWorktrees":5}'::jsonb, '2026-06-01T00:00:00Z')`,
);
// A pre-existing per-project row for wf_b: the rootDir-keyed migrated copy
// must NOT clobber it (NOT_EXISTS guard).
await db.execute(
`INSERT INTO project.workflow_settings (workflow_id, project_id, "values", updated_at)
VALUES ('wf_b', 'proj_help', '{"maxWorktrees":9}'::jsonb, '2026-06-01T00:00:00Z')`,
);
await db.execute(
`INSERT INTO project.workflow_settings (workflow_id, project_id, "values", updated_at)
VALUES ('wf_b', '${fakeRootDir}', '{"maxWorktrees":1}'::jsonb, '2026-06-01T00:00:00Z')`,
);
await db.execute(
`INSERT INTO project.workflow_prompt_overrides (workflow_id, project_id, overrides, updated_at)
VALUES ('wf_a', '${fakeRootDir}', '{"executor":"x"}'::jsonb, '2026-06-01T00:00:00Z')`,
);
const result = await stampMigratedProjectRows(db, { projectId: "proj_help", rootDir: fakeRootDir });
expect(result.stamped).toBe(true);
const tasks = (await db.execute(
`SELECT project_id FROM project.tasks WHERE id = 'FN-HELP-1'`,
)) as unknown as Array<{ project_id: string | null }>;
expect(tasks[0]?.project_id).toBe("proj_help");
const cfg = (await db.execute(
`SELECT project_id FROM project.config ORDER BY project_id`,
)) as unknown as Array<{ project_id: string }>;
expect(cfg.some((r) => r.project_id === "proj_help")).toBe(true);
expect(cfg.some((r) => r.project_id === "")).toBe(false);
const wf = (await db.execute(
`SELECT workflow_id, project_id, "values" FROM project.workflow_settings ORDER BY workflow_id, project_id`,
)) as unknown as Array<{ workflow_id: string; project_id: string; values: { maxWorktrees?: number } | null }>;
// wf_a re-keyed to proj_help.
const wfA = wf.find((r) => r.workflow_id === "wf_a");
expect(wfA?.project_id).toBe("proj_help");
expect(wfA?.values?.maxWorktrees).toBe(5);
// wf_b keeps its pre-existing per-project row (value 9); the rootDir copy
// was NOT re-keyed (guard) so it remains keyed by the fake rootDir.
const wfBProject = wf.find((r) => r.workflow_id === "wf_b" && r.project_id === "proj_help");
expect(wfBProject?.values?.maxWorktrees, "pre-existing per-project row must not be clobbered").toBe(9);
const wfBLegacy = wf.find((r) => r.workflow_id === "wf_b" && r.project_id === fakeRootDir);
expect(wfBLegacy, "guarded rootDir row is left in place for manual reconciliation").toBeDefined();
// No wf_a row remains keyed by the fake rootDir path.
expect(wf.some((r) => r.workflow_id === "wf_a" && r.project_id === fakeRootDir)).toBe(false);
const overrides = (await db.execute(
`SELECT project_id FROM project.workflow_prompt_overrides WHERE workflow_id = 'wf_a'`,
)) as unknown as Array<{ project_id: string }>;
expect(overrides[0]?.project_id).toBe("proj_help");
// No-op when projectId is empty.
const noop = await stampMigratedProjectRows(db, { projectId: "", rootDir: fakeRootDir });
expect(noop.stamped).toBe(false);
} finally {
await connections.close().catch(() => undefined);
}
});
});

View File

@@ -0,0 +1,171 @@
/**
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
* Regression coverage for the workflow-settings project-identity namespace bug.
*
* In backend (PostgreSQL) mode `store.db` is a SQLite stub whose
* `getProjectIdentity()` throws, so the OLD `getWorkflowSettingsProjectId`
* always fell through its catch to `store.rootDir` — an absolute filesystem
* path. Every other backend-mode read/write partitions by the central-registry
* project id (`asyncLayer.projectId`), so workflow settings landed under a
* rootDir key nothing else could find (settings appeared "reset").
*
* These tests pin the invariant: when the async layer is BOUND to a central
* project id, workflow settings + prompt overrides must be keyed by that id
* (the `project_id` column), NOT by the rootDir path. An UNBOUND layer keeps
* the legacy rootDir fallback.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { sql } from "drizzle-orm";
import {
pgDescribe,
createSharedPgTaskStoreTestHarness,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
import { getWorkflowSettingsProjectIdImpl } from "../../task-store/remaining-ops-6.js";
import type { TaskStore } from "../../store.js";
const pgTest = pgDescribe;
/** Stand-in central-registry project id, matching the "proj_" shape used in prod. */
const BOUND_PROJECT_ID = "proj_wfsettings_identity_test";
pgTest("workflow-settings project identity keys by the central-registry id (PostgreSQL)", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_wfsettings_identity",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
/**
* Build a project-bound clone of the shared harness layer. `createAsyncDataLayer`
* returns an object literal whose transaction methods close over the shared
* `db`, so spreading it and overriding `projectId` yields a layer that shares
* the same PostgreSQL connection but reports a bound central id.
*/
function boundLayer(): AsyncDataLayer {
return { ...h.layer(), projectId: BOUND_PROJECT_ID };
}
async function boundStore(): Promise<TaskStore> {
const { TaskStore: TaskStoreCtor } = await import("../../store.js");
return new TaskStoreCtor(h.rootDir(), undefined, { asyncLayer: boundLayer() });
}
it("a projectId-BOUND backend store writes workflow_prompt_overrides under the central id, not rootDir", async () => {
const store = await boundStore();
const workflowId = "builtin:coding";
const projectId = store.getWorkflowSettingsProjectId();
expect(projectId).toBe(BOUND_PROJECT_ID);
// Explicitly prove it is NOT the rootDir path the old code returned.
expect(projectId).not.toBe(h.rootDir());
await store.updateWorkflowPromptOverrides(workflowId, projectId, {
"node-a": "override prose for node a",
});
const rows = (await h
.adminDb()
.execute(
sql`SELECT project_id, workflow_id FROM project.workflow_prompt_overrides WHERE workflow_id = ${workflowId}`,
)) as unknown as Array<{ project_id: string; workflow_id: string }>;
expect(rows.length).toBe(1);
expect(rows[0].project_id).toBe(BOUND_PROJECT_ID);
});
it("a projectId-BOUND backend store writes workflow_settings under the central id, not rootDir", async () => {
const store = await boundStore();
const workflowId = "builtin:coding";
const projectId = store.getWorkflowSettingsProjectId();
expect(projectId).toBe(BOUND_PROJECT_ID);
// `workflowStepTimeoutMs` is a declared builtin workflow setting, so this
// write passes declaration validation and persists a real row.
await store.updateWorkflowSettingValues(workflowId, projectId, {
workflowStepTimeoutMs: 600_000,
});
const rows = (await h
.adminDb()
.execute(
sql`SELECT project_id FROM project.workflow_settings WHERE workflow_id = ${workflowId}`,
)) as unknown as Array<{ project_id: string }>;
expect(rows.length).toBe(1);
expect(rows[0].project_id).toBe(BOUND_PROJECT_ID);
expect(rows[0].project_id).not.toBe(h.rootDir());
});
it("an UNBOUND backend layer falls back to rootDir (legacy key), proving the bound path is what changed", () => {
// The shared harness store uses an unbound layer (projectId undefined). The
// SQLite stub throws in getProjectIdentity, so resolution falls to rootDir.
const unboundStore = h.store();
expect(unboundStore.asyncLayer?.projectId).toBeUndefined();
expect(unboundStore.getWorkflowSettingsProjectId()).toBe(h.rootDir());
});
});
/**
* Focused unit coverage of the resolution order, independent of a live PG
* connection. Runs unconditionally (not gated on PG availability).
*/
describe("getWorkflowSettingsProjectIdImpl resolution order (unit)", () => {
it("prefers asyncLayer.projectId when the layer is bound", () => {
const store = {
asyncLayer: { projectId: "proj_central_id" },
rootDir: "/tmp/root",
db: {
getProjectIdentity() {
throw new Error("SQLite removed in backend mode");
},
},
} as unknown as TaskStore;
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("proj_central_id");
});
it("falls back to the legacy SQLite identity id when no layer is bound", () => {
const store = {
asyncLayer: null,
rootDir: "/tmp/root",
db: {
getProjectIdentity() {
return { id: "legacy_identity_id" };
},
},
} as unknown as TaskStore;
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("legacy_identity_id");
});
it("falls back to rootDir when the SQLite stub throws and no layer is bound (old backend behavior)", () => {
const store = {
asyncLayer: null,
rootDir: "/tmp/root",
db: {
getProjectIdentity() {
throw new Error("SQLite removed in backend mode");
},
},
} as unknown as TaskStore;
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("/tmp/root");
});
it("an unbound layer object (projectId undefined) does not short-circuit the legacy path", () => {
const store = {
asyncLayer: { projectId: undefined },
rootDir: "/tmp/root",
db: {
getProjectIdentity() {
return { id: "legacy_identity_id" };
},
},
} as unknown as TaskStore;
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("legacy_identity_id");
});
});

View File

@@ -2226,6 +2226,12 @@ export {
CENTRAL_BACKUP_SCHEMAS,
migrateSqliteToPostgres,
defaultMigrationSources,
// FNXC:CentralProjectIdentity 2026-07-13-23:10:
// Post-migration project-partition stamping, shared by the startup-factory
// first-boot auto-migration and `fn db migrate` so migrated rows are re-keyed
// to the central-registry project id on BOTH cutover paths.
stampMigratedProjectRows,
lookupRegisteredProjectIdByPath,
applySchemaBaseline,
getAppliedMigrations,
SCHEMA_BASELINE_VERSION,
@@ -2263,6 +2269,8 @@ export type {
SchemaName,
MigrationReport,
TableMigrationResult,
StampMigratedProjectRowsInput,
StampMigratedProjectRowsResult,
BackendBootResult,
CreateTaskStoreForBackendOptions,
} from "./postgres/index.js";

View File

@@ -164,6 +164,20 @@ export {
type TableMigrationResult,
} from "./sqlite-migrator.js";
/**
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
* Post-migration project-partition stamping, shared by the startup-factory
* first-boot auto-migration and the manual `fn db migrate` cutover command so
* migrated rows (tasks/archived_tasks/config/workflow settings) are re-keyed to
* the central-registry project id on BOTH paths.
*/
export {
stampMigratedProjectRows,
lookupRegisteredProjectIdByPath,
type StampMigratedProjectRowsInput,
type StampMigratedProjectRowsResult,
} from "./migration-stamping.js";
/**
* FNXC:BackendFlip 2026-06-26-14:30:
* Runtime startup factory (cutover milestone). `createTaskStoreForBackend()`

View File

@@ -0,0 +1,183 @@
/**
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
* Post-migration project-partition stamping, extracted from the startup-factory
* first-boot auto-migration (Step 5.5) so it can be shared with the manual
* `fn db migrate` cutover command.
*
* The SQLite→PostgreSQL migrator (sqlite-migrator.ts) is partition-unaware: it
* copies legacy rows verbatim, so migrated rows land with NULL project_id
* (tasks/archived_tasks), a legacy singleton config key ('' — SQLite-parity
* DEFAULT), and workflow-settings/prompt-override rows keyed by the legacy
* rootDir path string (or a pre-isolation identity id) instead of the
* central-registry project id the runtime now scopes every read/write by. Every
* project-bound reader (engine InProcessRuntime, dashboard
* project-store-resolver, configScope, workflow-settings resolver) filters those
* rows out, so the board/settings/workflow surfaces show empty right after a
* "successful" migration. This helper re-keys the just-migrated rows to the
* booting project's central-registry id, closing that silent-invisible-data gap
* on BOTH cutover paths.
*/
import { sql } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
/** The Drizzle instance type startup-factory uses for its `connections.migration`. */
type MigrationDb = PostgresJsDatabase<Record<string, never>>;
/** Inputs for stamping migrated rows with a project partition key. */
export interface StampMigratedProjectRowsInput {
/**
* The central-registry project id every migrated row must be re-keyed to.
* Resolved by the caller (options.projectId, or a path lookup against
* central.projects).
*/
readonly projectId: string;
/**
* The project rootDir path. Legacy/migrated workflow_settings and
* workflow_prompt_overrides rows are keyed by this absolute path string (the
* pre-isolation key), so re-keying them requires the rootDir as the match
* predicate.
*/
readonly rootDir: string;
}
/** Result of a stamping pass. */
export interface StampMigratedProjectRowsResult {
/** True when the pass ran (a non-empty projectId was supplied). */
readonly stamped: boolean;
}
/**
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
* Re-key just-migrated rows to the booting project's central-registry id.
*
* Covers, idempotently:
* - project.tasks NULL project_id → projectId
* - project.archived_tasks NULL project_id → projectId
* - archive.archived_tasks NULL project_id → projectId (cold-storage snapshots)
* - project.config '' key → projectId (guarded: never clobbers a
* pre-existing per-project row)
* - project.workflow_settings rootDir-path key → projectId (guarded)
* - project.workflow_prompt_overrides rootDir-path key → projectId (guarded)
*
* Callers must guarantee the NULL-project_id rows in tasks/archived_tasks were
* written by THIS migration pass (the scoped emptiness check in startup-factory
* Step 5.5, or the empty-target contract of `fn db migrate`). The config /
* workflow re-keys are NOT_EXISTS-guarded so a pre-existing per-project row is
* never destroyed.
*
* @param db A Drizzle instance connected to the target cluster (the same type
* startup-factory uses for `connections.migration`). Must run DML.
*/
export async function stampMigratedProjectRows(
db: MigrationDb,
{ projectId, rootDir }: StampMigratedProjectRowsInput,
): Promise<StampMigratedProjectRowsResult> {
if (!projectId) {
// No registry identity — leave rows unstamped (unregistered single-project
// setups use an unbound layer with no scope filter).
return { stamped: false };
}
/*
FNXC:MultiProjectIsolation 2026-07-11:
The SQLite migrator predates partitioning and leaves project_id NULL — rows
the strict taskProjectScope filter (project_id = $bound) would never surface,
so the scheduler/board would show an empty project right after a "successful"
migration. Stamp the just-migrated rows with the booting project's id.
FNXC:MultiProjectIsolation 2026-07-13-21:20:
The stamping id must also be derivable WITHOUT options.projectId — the main
cutover path (`fn dashboard` in the project directory) boots with rootDir
only, so the previous `if (options.projectId)` guard skipped stamping on
exactly the boot that performs most real-world migrations. The resolution now
falls back to a central-registry path lookup (done by the caller).
*/
await db.execute(
sql`UPDATE project.tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
);
await db.execute(
sql`UPDATE project.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
);
// The cold-storage archive is also partitioned (PR #2007 review P1); migrated
// snapshots must be owned by this project too.
await db.execute(
sql`UPDATE archive.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
);
/*
FNXC:CentralProjectIdentity 2026-07-13-22:00:
project.config is keyed by project_id (DEFAULT '' — the legacy SQLite-parity
row). The migrator copies the legacy singleton config into the '' row, but
configScope() has NO bound→'' fallback, so a bound reader silently lost the
migrated project settings, workflowSteps, taskPrefix, and nextId floor
(defaults returned right after a "successful" migration). Re-key the migrated
row to this project. Guarded so a pre-existing per-project row is never
clobbered (then the '' row is left for manual reconciliation rather than
destroying either copy).
*/
await db.execute(
sql`UPDATE project.config SET project_id = ${projectId}
WHERE project_id = ''
AND NOT EXISTS (SELECT 1 FROM project.config WHERE project_id = ${projectId})`,
);
/*
FNXC:CentralProjectIdentity 2026-07-13-23:10:
project.workflow_settings and project.workflow_prompt_overrides are keyed
(workflow_id, project_id). The runtime now keys them by the central-registry
project id (asyncLayer.projectId), but legacy/migrated rows carry the
pre-isolation key — the absolute rootDir path string (e.g.
'/Users/eclipxe/Projects/kb') or a legacy identity id. A bound
workflow-settings resolver filters those out, so per-workflow setting VALUES
and prompt overrides vanish right after a "successful" migration (defaults
returned, custom prompts lost). Re-key the rootDir-path rows to this project.
Guarded per-row with NOT EXISTS on the target (workflow_id, projectId) PK so a
unique violation never clobbers a pre-existing per-project row (the outer
table alias in the correlated subquery references the row being updated).
*/
await db.execute(
sql`UPDATE project.workflow_settings SET project_id = ${projectId}
WHERE project_id = ${rootDir}
AND NOT EXISTS (
SELECT 1 FROM project.workflow_settings w2
WHERE w2.workflow_id = project.workflow_settings.workflow_id
AND w2.project_id = ${projectId}
)`,
);
await db.execute(
sql`UPDATE project.workflow_prompt_overrides SET project_id = ${projectId}
WHERE project_id = ${rootDir}
AND NOT EXISTS (
SELECT 1 FROM project.workflow_prompt_overrides w2
WHERE w2.workflow_id = project.workflow_prompt_overrides.workflow_id
AND w2.project_id = ${projectId}
)`,
);
return { stamped: true };
}
/**
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
* Resolve the central-registry project id for a filesystem path by matching
* central.projects.path. Shared so both startup-factory (rootDir-only boot) and
* `fn db migrate` (post-migration, once central.projects is populated) derive
* the same stamping id. Returns undefined when the path is not registered
* (legacy/unregistered single-project setups stay unbound, matching their
* unfiltered readers). Never throws — a lookup failure yields undefined.
*/
export async function lookupRegisteredProjectIdByPath(
db: MigrationDb,
path: string,
): Promise<string | undefined> {
if (!path) return undefined;
try {
const rows = (await db.execute(
sql`SELECT id FROM central.projects WHERE path = ${path} LIMIT 1`,
)) as Array<{ id: string }>;
return rows[0]?.id;
} catch {
return undefined;
}
}

View File

@@ -324,6 +324,34 @@ export const config = projectSchema.table("config", {
});
// ── Distributed task ID allocator ────────────────────────────────────
/*
FNXC:CentralProjectIdentity 2026-07-13-22:40:
distributed_task_id_state / distributed_task_id_reservations / merge_queue are
INTENTIONALLY NOT project-partitioned (no project_id column), unlike `tasks` and
`archived_tasks`. This is load-bearing, not an oversight:
- Task ids are a GLOBALLY-UNIQUE namespace across the entire embedded-PG
cluster: `tasks.id` is a global PRIMARY KEY shared by every project in the
one `project` schema. Two ids like "KB-123" must never coexist even across
different projects.
- The mechanism that guarantees this is the SHARED per-prefix sequence keyed
on `distributed_task_id_state.prefix` (PK = prefix, no project_id). Projects
that share a prefix share one monotonic counter, so they can never mint the
same id. Adding a project_id here would split the counter per project and
let two projects using the same prefix collide on `tasks.id`.
- `distributed_task_id_reservations` (unique on prefix+sequence and prefix+
task_id) is the reservation ledger for that shared counter; scoping it per
project would break the uniqueness backstop for the same reason.
- `merge_queue` (PK = task_id → FK tasks.id) needs no project_id BECAUSE
task_id is globally unique: its PK can never collide across projects, and its
rows are scoped to a project transitively through the joined task's
project_id (see async-merge-coordination taskStillInReview / taskProjectScope).
Projects that share a prefix therefore share id numbering by design. The
allocator enforces the global floor by scanning tasks/archived_tasks WITHOUT a
project_id filter (see async-allocator computeNextSequenceFloor /
getMaxTaskSequenceFromTable / taskIdExists).
*/
export const distributedTaskIdState = projectSchema.table("distributed_task_id_state", {
prefix: text("prefix").primaryKey(),
nextSequence: integer("next_sequence").notNull(),
@@ -564,6 +592,13 @@ export const agentBlockedStates = projectSchema.table("agent_blocked_states", {
}, (t) => [foreignKey({ columns: [t.agentId], foreignColumns: [agents.id] }).onDelete("cascade")]);
// ── Merge queue / merge requests / handoff ───────────────────────────
/*
FNXC:CentralProjectIdentity 2026-07-13-22:40:
merge_queue has NO project_id and is safe without one: its PK is task_id, which
is globally unique across the cluster (tasks.id global PK — see the allocator
note above), so the PK cannot collide across projects. Per-project scoping is
applied transitively via the joined task's project_id in lease/cleanup queries.
*/
export const mergeQueue = projectSchema.table("merge_queue", {
taskId: text("task_id").primaryKey(),
enqueuedAt: text("enqueued_at").notNull(),

View File

@@ -472,34 +472,19 @@ export async function createTaskStoreForBackend(
*/
const stampProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath());
if (stampProjectId) {
await connections.migration.execute(
drizzleSql`UPDATE project.tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
);
await connections.migration.execute(
drizzleSql`UPDATE project.archived_tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
);
// The cold-storage archive is also partitioned (PR #2007 review
// P1); migrated snapshots must be owned by this project too.
await connections.migration.execute(
drizzleSql`UPDATE archive.archived_tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
);
/*
FNXC:CentralProjectIdentity 2026-07-13-22:00:
project.config is keyed by project_id (DEFAULT '' — the legacy
SQLite-parity row). The migrator copies the legacy singleton
config into the '' row, but configScope() has NO bound→''
fallback, so a bound reader silently lost the migrated project
settings, workflowSteps, taskPrefix, and nextId floor (defaults
returned right after a "successful" migration). Re-key the
migrated row to this project. Guarded so a pre-existing
per-project row is never clobbered (then the '' row is left for
manual reconciliation rather than destroying either copy).
FNXC:CentralProjectIdentity 2026-07-13-23:10:
The stamping DML (tasks/archived_tasks NULL→id, config ''→id, and
the workflow_settings/workflow_prompt_overrides rootDir-key→id
re-key) is shared with `fn db migrate` via
stampMigratedProjectRows. rootDir is the pre-isolation key for the
workflow tables, so it is passed alongside the stamp id.
*/
await connections.migration.execute(
drizzleSql`UPDATE project.config SET project_id = ${stampProjectId}
WHERE project_id = ''
AND NOT EXISTS (SELECT 1 FROM project.config WHERE project_id = ${stampProjectId})`,
);
const { stampMigratedProjectRows } = await import("./migration-stamping.js");
await stampMigratedProjectRows(connections.migration, {
projectId: stampProjectId,
rootDir,
});
}
/*
FNXC:PostgresMigrationBanner 2026-07-12:

View File

@@ -108,6 +108,20 @@ export async function getConfiguredPrefixAndLegacyNextId(
* The table is scanned in application code (not SQL) because the prefix/sequence
* are embedded in the string id column, not a separate numeric column. This
* mirrors the sync `getMaxTaskSequenceFromTable()` exactly.
*
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
* This scan is deliberately GLOBAL — filtered by prefix only, NEVER by
* project_id — because task ids are a globally-unique namespace across the whole
* embedded-PG cluster (`project.tasks.id` is a global PRIMARY KEY, shared by all
* projects in the one `project` schema). Two projects that share a prefix (e.g.
* both "KB") draw from ONE per-prefix sequence, so the high-water mark that
* advances that shared sequence MUST observe every project's tasks. Adding a
* `project_id` predicate here would compute a per-project floor that ignores a
* sibling project's higher max suffix, letting the allocator mint an id another
* project already owns — a tasks.id PK collision on insert and a merge_queue
* (task_id PK) collision downstream. Do NOT scope this scan to a project to
* "align" it with MultiProjectIsolation's per-project task reads; per-project
* scoping belongs only on reporting/board reads, never on id-sequence advancement.
*/
async function getMaxTaskSequenceFromTable(
db: AsyncDataLayer["db"] | DbTransaction,
@@ -167,6 +181,20 @@ async function getMaxReservationSequence(
* This is the core of VAL-DATA-007. Every known prefix gets bumped to at least
* one past the highest in-use suffix across tasks, archived tasks, and
* reservations so a newly-allocated id never collides with an existing one.
*
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
* `projectId` is threaded here for ONE purpose only: scoping the config-row read
* (`getConfiguredPrefixAndLegacyNextId`) so a per-project `config.next_id` legacy
* floor is read from the bound project's row. That legacy value can only RAISE
* the floor (via `Math.max`), never lower the shared sequence. The three
* high-water scans below (tasks / archived_tasks / reservations) stay GLOBAL —
* they take NO projectId — because the id namespace is global across the cluster
* (see getMaxTaskSequenceFromTable). Consequently, with two projects sharing a
* prefix, the returned floor is the max in-use suffix across BOTH projects, and
* `ensureStateRow`'s `GREATEST(current, floor)` update never moves the shared
* `distributed_task_id_state.next_sequence` backward. Net: a per-project floor
* can never lower the shared sequence or emit an id below another project's max,
* so no cross-project duplicate id is possible.
*/
export async function computeNextSequenceFloor(
db: AsyncDataLayer["db"] | DbTransaction,
@@ -347,6 +375,13 @@ function formatDistributedTaskId(prefix: string, sequence: number): string {
* FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-12:35:
* Check whether a task ID already exists in the tasks or archived_tasks table.
* Used by the async allocator reservation loop to skip past existing IDs.
*
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
* Defense-in-depth existence probe: matched by exact task id ONLY, never scoped
* by project_id, so it detects an id owned by ANY project on the shared cluster.
* This backstops the global sequence floor — even if the shared sequence somehow
* pointed at a taken id, the reserve loop skips forward until it finds one no
* project holds, keeping the global tasks.id namespace collision-free.
*/
async function taskIdExists(
tx: DbTransaction,

View File

@@ -710,6 +710,34 @@ export async function resolveWorkflowSettingDeclarationsImpl(store: TaskStore,
}
export function getWorkflowSettingsProjectIdImpl(store: TaskStore): string {
/*
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
* This is the SINGLE seam that produces the `project_id` key for the
* `workflow_settings` / `workflow_prompt_overrides` tables (keyed by
* (workflow_id, project_id)). Project identity ALWAYS comes from the
* central-registry id when available; rootDir is only a filesystem root /
* last-resort legacy key.
*
* Resolution order:
* (a) `store.asyncLayer?.projectId` — backend (PostgreSQL) mode bound to a
* central-registry project (e.g. "proj_2f4be0f31a404d2c"). This is the
* id the rest of the system partitions by, so workflow settings MUST
* key by it too.
* (b) `store.db.getProjectIdentity()?.id` — legacy SQLite identity id.
* (c) `store.rootDir` — absolute filesystem path, last-resort legacy key.
*
* BUG this fixes: the old code went straight to (b). In backend mode
* `store.db` is a SQLite stub whose `getProjectIdentity()` THROWS
* (throwSqliteRemoved), so the catch ALWAYS returned `store.rootDir` — an
* absolute path like "/Users/…/kb". Meanwhile every other backend-mode read/
* write partitions by the central-registry id, so workflow settings landed
* under a rootDir key that nothing else could find (settings looked "reset").
*
* Legacy rows still keyed by rootDir / the old identity id are re-keyed by
* migration stamping (owned elsewhere — see the PG startup/migration path).
*/
const boundProjectId = store.asyncLayer?.projectId;
if (boundProjectId) return boundProjectId;
try {
return store.db.getProjectIdentity()?.id ?? store.rootDir;
} catch {