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:
7
.changeset/fn-db-migrate-stamp-rows.md
Normal file
7
.changeset/fn-db-migrate-stamp-rows.md
Normal 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.
|
||||
7
.changeset/workflow-settings-central-identity.md
Normal file
7
.changeset/workflow-settings-central-identity.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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()`
|
||||
|
||||
183
packages/core/src/postgres/migration-stamping.ts
Normal file
183
packages/core/src/postgres/migration-stamping.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user