diff --git a/.changeset/fn-7452-workflow-settings-identity.md b/.changeset/fn-7452-workflow-settings-identity.md new file mode 100644 index 0000000000..51ac694ce9 --- /dev/null +++ b/.changeset/fn-7452-workflow-settings-identity.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve migrated workflow settings when project identity is assigned later. +category: fix +dev: Backfills rootDir-keyed workflow_settings rows into the durable project identity row, keeping identity values on conflicts. diff --git a/packages/core/src/__tests__/settings-migration.test.ts b/packages/core/src/__tests__/settings-migration.test.ts index 57b1926019..587cbcf7f4 100644 --- a/packages/core/src/__tests__/settings-migration.test.ts +++ b/packages/core/src/__tests__/settings-migration.test.ts @@ -17,6 +17,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync import { tmpdir } from "node:os"; import { join } from "node:path"; import type { TaskStore } from "../store.js"; +import { writeProjectIdentity, type ProjectIdentity } from "../db.js"; import { MOVED_SETTINGS_KEYS, SETTINGS_MIGRATION_VERSION, @@ -105,6 +106,29 @@ function seedSelection(store: TaskStore, taskId: string, workflowId: string): vo .run(taskId, workflowId, new Date().toISOString()); } +function seedWorkflowSettingsRow( + store: TaskStore, + workflowId: string, + projectId: string, + values: Record | string, +): void { + rawDb(store) + .prepare( + `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflowId, projectId) DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, + ) + .run(workflowId, projectId, typeof values === "string" ? values : JSON.stringify(values), new Date().toISOString()); +} + +function durableIdentity(): ProjectIdentity { + return { + id: "proj_0123456789abcdef", + createdAt: "2026-07-02T00:00:00.000Z", + firstSeenPath: "/central/projects/deft-ember", + }; +} + /** Run the (private) migration directly. */ async function runMigration(store: TaskStore): Promise { await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise }).migrateMovedSettingsToWorkflowValuesOnce(); @@ -381,4 +405,69 @@ describe("settings hard-move migration (U4)", () => { expect(globalRaw.requirePrApproval).toBeUndefined(); expect(globalRaw.themeMode).toBe("dark"); }); + + it("backfills rootDir-keyed workflow settings when durable project identity is assigned", async () => { + seedRawProjectSettings(store, { workflowStepTimeoutMs: 120_000, requirePrApproval: true }); + clearMarker(store); + + await runMigration(store); + + const rootDirProjectId = env.tempDir; + expect(store.getWorkflowSettingsProjectId()).toBe(rootDirProjectId); + expect(store.getWorkflowSettingValues("builtin:coding", rootDirProjectId).workflowStepTimeoutMs).toBe(120_000); + + const identity = durableIdentity(); + writeProjectIdentity(env.fusionDir, identity); + + expect(store.getWorkflowSettingsProjectId()).toBe(identity.id); + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", store.getWorkflowSettingsProjectId()); + expect(effective.workflowStepTimeoutMs).toBe(120_000); + expect(effective.requirePrApproval).toBe(true); + expect(store.getWorkflowSettingValues("builtin:coding", identity.id).workflowStepTimeoutMs).toBe(120_000); + expect(store.listWorkflowSettingValuesForProject()["builtin:coding"]?.workflowStepTimeoutMs).toBe(120_000); + }); + + it("merges duplicate rootDir and identity workflow settings without overwriting identity values", async () => { + seedRawProjectSettings(store, { + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + executionProvider: "anthropic", + }); + clearMarker(store); + await runMigration(store); + + const identity = durableIdentity(); + seedWorkflowSettingsRow(store, "builtin:coding", identity.id, { + workflowStepTimeoutMs: 333_000, + requirePrApproval: false, + }); + + writeProjectIdentity(env.fusionDir, identity); + + expect(store.getWorkflowSettingsProjectId()).toBe(identity.id); + const values = store.getWorkflowSettingValues("builtin:coding", identity.id); + expect(values.workflowStepTimeoutMs).toBe(333_000); + expect(values.requirePrApproval).toBe(false); + expect(values.executionProvider).toBe("anthropic"); + + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", identity.id); + expect(effective.workflowStepTimeoutMs).toBe(333_000); + expect(effective.requirePrApproval).toBe(false); + expect(effective.executionProvider).toBe("anthropic"); + expect(store.listWorkflowSettingValuesForProject()["builtin:coding"]).toEqual(values); + }); + + it("identity assignment ignores absent, empty, and corrupt rootDir workflow setting rows", async () => { + const identity = durableIdentity(); + seedWorkflowSettingsRow(store, "builtin:coding", env.tempDir, "not-json"); + seedWorkflowSettingsRow(store, "builtin:spec", env.tempDir, {}); + + expect(() => writeProjectIdentity(env.fusionDir, identity)).not.toThrow(); + + expect(store.getWorkflowSettingsProjectId()).toBe(identity.id); + expect(store.getWorkflowSettingValues("builtin:coding", identity.id)).toEqual({}); + expect(store.getWorkflowSettingValues("builtin:spec", identity.id)).toEqual({}); + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", identity.id); + expect(effective.workflowStepTimeoutMs).toBe(900_000); + }); }); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 1ddd53eb57..d1afb3c6ae 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -9,7 +9,7 @@ */ import { DatabaseSync } from "./sqlite-adapter.js"; -import { basename, isAbsolute, join } from "node:path"; +import { basename, dirname, isAbsolute, join } from "node:path"; import { mkdirSync, existsSync, statSync, renameSync, rmSync } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; @@ -1888,6 +1888,7 @@ export class Database { private db: DatabaseSync; private readonly dbPath: string; + private readonly fusionDir: string; private readonly inMemory: boolean; /** Returns the database file path (or ":memory:" for in-memory databases). */ get path(): string { return this.dbPath; } @@ -1916,6 +1917,7 @@ export class Database { // don't need cross-instance persistence. const inMemory = options?.inMemory === true; this.inMemory = inMemory; + this.fusionDir = fusionDir; this.dbPath = inMemory ? ":memory:" : join(fusionDir, "fusion.db"); this.busyTimeoutMs = Math.max(0, options?.busyTimeoutMs ?? DEFAULT_SQLITE_BUSY_TIMEOUT_MS); this.lockRecoveryWindowMs = Math.max(0, options?.lockRecoveryWindowMs ?? DEFAULT_SQLITE_LOCK_RECOVERY_WINDOW_MS); @@ -5972,25 +5974,79 @@ export class Database { return fromJson(value); } + private parseWorkflowSettingsJson(raw: string | null | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + + private reconcileWorkflowSettingsRootDirProjectId(identityId: string): boolean { + if (this.inMemory) return false; + const rootDirProjectId = basename(this.fusionDir) === ".fusion" ? dirname(this.fusionDir) : this.fusionDir; + if (!rootDirProjectId || rootDirProjectId === identityId) return false; + + const rows = this.db + .prepare('SELECT workflowId, "values" FROM workflow_settings WHERE projectId = ?') + .all(rootDirProjectId) as Array<{ workflowId: string; values: string }>; + if (rows.length === 0) return false; + + let changed = false; + const now = new Date().toISOString(); + for (const row of rows) { + const rootValues = this.parseWorkflowSettingsJson(row.values); + if (Object.keys(rootValues).length === 0) continue; + const identityRow = this.db + .prepare('SELECT "values" FROM workflow_settings WHERE workflowId = ? AND projectId = ?') + .get(row.workflowId, identityId) as { values: string } | undefined; + const identityValues = this.parseWorkflowSettingsJson(identityRow?.values); + const carriesEveryRootKey = Object.keys(rootValues).every((key) => Object.prototype.hasOwnProperty.call(identityValues, key)); + if (carriesEveryRootKey) continue; + const next = { ...rootValues, ...identityValues }; + this.db + .prepare( + `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflowId, projectId) + DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, + ) + .run(row.workflowId, identityId, JSON.stringify(next), now); + changed = true; + } + return changed; + } + setProjectIdentity(identity: ProjectIdentity, options?: { force?: boolean }): void { const stored = this.getProjectIdentity(); const force = options?.force === true; - if (stored) { - if (stored.id === identity.id) { - return; - } - if (!force) { - throw new ProjectIdentityConflictError({ - storedId: stored.id, - storedPath: stored.firstSeenPath, - incomingId: identity.id, - incomingPath: identity.firstSeenPath, - }); - } + if (stored && stored.id !== identity.id && !force) { + throw new ProjectIdentityConflictError({ + storedId: stored.id, + storedPath: stored.firstSeenPath, + incomingId: identity.id, + incomingPath: identity.firstSeenPath, + }); } - this.setMetaValue(Database.PROJECT_IDENTITY_META_KEY, JSON.stringify(identity)); + this.transactionImmediate(() => { + let changed = false; + if (!stored || stored.id !== identity.id || force) { + this.setMetaValue(Database.PROJECT_IDENTITY_META_KEY, JSON.stringify(identity)); + changed = true; + } + /* + FNXC:WorkflowSettingsIdentity 2026-07-02-13:18: + Workflow setting hard-move migration can run before a durable project identity exists, so rows may be keyed by the TaskStore rootDir fallback. When identity is assigned, reconcile those fallback rows into the identity-keyed table entry and keep existing identity values on conflicts so operator-tuned settings stay visible without stale rootDir values overwriting newer writes. + */ + changed = this.reconcileWorkflowSettingsRootDirProjectId(identity.id) || changed; + if (changed) this.bumpLastModified(); + }); } clearProjectIdentity(): void {