From 8e4514e5850aff524b04f039f78e8d96e29c05e5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 13 Jul 2026 21:49:33 -0700 Subject: [PATCH] fix: key workflow settings by the central project id and stamp all partitioned tables on both migration paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/fn-db-migrate-stamp-rows.md | 7 + .../workflow-settings-central-identity.md | 7 + packages/cli/src/commands/db.ts | 46 ++++ .../postgres/allocator-cross-project.test.ts | 254 ++++++++++++++++++ .../startup-factory-integration.test.ts | 136 ++++++++++ ...kflow-settings-project-identity.pg.test.ts | 171 ++++++++++++ packages/core/src/index.ts | 8 + packages/core/src/postgres/index.ts | 14 + .../core/src/postgres/migration-stamping.ts | 183 +++++++++++++ packages/core/src/postgres/schema/project.ts | 35 +++ packages/core/src/postgres/startup-factory.ts | 37 +-- .../core/src/task-store/async-allocator.ts | 35 +++ .../core/src/task-store/remaining-ops-6.ts | 28 ++ 13 files changed, 935 insertions(+), 26 deletions(-) create mode 100644 .changeset/fn-db-migrate-stamp-rows.md create mode 100644 .changeset/workflow-settings-central-identity.md create mode 100644 packages/core/src/__tests__/postgres/allocator-cross-project.test.ts create mode 100644 packages/core/src/__tests__/postgres/workflow-settings-project-identity.pg.test.ts create mode 100644 packages/core/src/postgres/migration-stamping.ts diff --git a/.changeset/fn-db-migrate-stamp-rows.md b/.changeset/fn-db-migrate-stamp-rows.md new file mode 100644 index 0000000000..3af3ef578c --- /dev/null +++ b/.changeset/fn-db-migrate-stamp-rows.md @@ -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. diff --git a/.changeset/workflow-settings-central-identity.md b/.changeset/workflow-settings-central-identity.md new file mode 100644 index 0000000000..2311f38bcf --- /dev/null +++ b/.changeset/workflow-settings-central-identity.md @@ -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. diff --git a/packages/cli/src/commands/db.ts b/packages/cli/src/commands/db.ts index 229801b5bf..bd502d1c28 100644 --- a/packages/cli/src/commands/db.ts +++ b/packages/cli/src/commands/db.ts @@ -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. diff --git a/packages/core/src/__tests__/postgres/allocator-cross-project.test.ts b/packages/core/src/__tests__/postgres/allocator-cross-project.test.ts new file mode 100644 index 0000000000..b5a39e496e --- /dev/null +++ b/packages/core/src/__tests__/postgres/allocator-cross-project.test.ts @@ -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 { + 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 { + 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 { + 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)); + }); +}); diff --git a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts index 94c4a8d6d4..166c4104d3 100644 --- a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts @@ -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); + } + }); }); diff --git a/packages/core/src/__tests__/postgres/workflow-settings-project-identity.pg.test.ts b/packages/core/src/__tests__/postgres/workflow-settings-project-identity.pg.test.ts new file mode 100644 index 0000000000..2ee6bd5618 --- /dev/null +++ b/packages/core/src/__tests__/postgres/workflow-settings-project-identity.pg.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a4b8e5dfc7..1f9efeb7f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/postgres/index.ts b/packages/core/src/postgres/index.ts index a3fc016823..28121b25f7 100644 --- a/packages/core/src/postgres/index.ts +++ b/packages/core/src/postgres/index.ts @@ -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()` diff --git a/packages/core/src/postgres/migration-stamping.ts b/packages/core/src/postgres/migration-stamping.ts new file mode 100644 index 0000000000..0809162509 --- /dev/null +++ b/packages/core/src/postgres/migration-stamping.ts @@ -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>; + +/** 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 { + 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 { + 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; + } +} diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 36db8cfc77..8ebea57482 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -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(), diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 850fc21594..789ae68331 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -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: diff --git a/packages/core/src/task-store/async-allocator.ts b/packages/core/src/task-store/async-allocator.ts index 5c6b7fbc8d..386b763895 100644 --- a/packages/core/src/task-store/async-allocator.ts +++ b/packages/core/src/task-store/async-allocator.ts @@ -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, diff --git a/packages/core/src/task-store/remaining-ops-6.ts b/packages/core/src/task-store/remaining-ops-6.ts index c7a0d8522b..e9faf6e099 100644 --- a/packages/core/src/task-store/remaining-ops-6.ts +++ b/packages/core/src/task-store/remaining-ops-6.ts @@ -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 {