FN-7452: preserve workflow settings after identity assignment

Preserve rootDir-migrated workflow settings once a durable project identity is assigned.

- Store the Fusion directory on Database instances so identity writes can detect rootDir-keyed workflow settings.
- Reconcile fallback workflow_settings rows into the identity-keyed row without overwriting existing identity values.
- Cover rootDir backfill, conflict merging, and corrupt or empty row handling in settings migration tests.
- Add a patch changeset for the published Fusion CLI package.

Files changed:
 .changeset/fn-7452-workflow-settings-identity.md   |  7 ++
 .../core/src/__tests__/settings-migration.test.ts  | 89 ++++++++++++++++++++++
 packages/core/src/db.ts                            | 84 ++++++++++++++++----
 3 files changed, 166 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-7452

Fusion-Task-Lineage: 0a49a969-fbce-45bb-adda-95b67e5c680d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 14:24:09 -07:00
parent 2050323d16
commit 3fac409cf9
3 changed files with 166 additions and 14 deletions

View File

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

View File

@@ -17,6 +17,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { TaskStore } from "../store.js"; import type { TaskStore } from "../store.js";
import { writeProjectIdentity, type ProjectIdentity } from "../db.js";
import { import {
MOVED_SETTINGS_KEYS, MOVED_SETTINGS_KEYS,
SETTINGS_MIGRATION_VERSION, SETTINGS_MIGRATION_VERSION,
@@ -105,6 +106,29 @@ function seedSelection(store: TaskStore, taskId: string, workflowId: string): vo
.run(taskId, workflowId, new Date().toISOString()); .run(taskId, workflowId, new Date().toISOString());
} }
function seedWorkflowSettingsRow(
store: TaskStore,
workflowId: string,
projectId: string,
values: Record<string, unknown> | 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. */ /** Run the (private) migration directly. */
async function runMigration(store: TaskStore): Promise<void> { async function runMigration(store: TaskStore): Promise<void> {
await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).migrateMovedSettingsToWorkflowValuesOnce(); await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).migrateMovedSettingsToWorkflowValuesOnce();
@@ -381,4 +405,69 @@ describe("settings hard-move migration (U4)", () => {
expect(globalRaw.requirePrApproval).toBeUndefined(); expect(globalRaw.requirePrApproval).toBeUndefined();
expect(globalRaw.themeMode).toBe("dark"); 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);
});
}); });

View File

@@ -9,7 +9,7 @@
*/ */
import { DatabaseSync } from "./sqlite-adapter.js"; 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 { mkdirSync, existsSync, statSync, renameSync, rmSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
@@ -1888,6 +1888,7 @@ export class Database {
private db: DatabaseSync; private db: DatabaseSync;
private readonly dbPath: string; private readonly dbPath: string;
private readonly fusionDir: string;
private readonly inMemory: boolean; private readonly inMemory: boolean;
/** Returns the database file path (or ":memory:" for in-memory databases). */ /** Returns the database file path (or ":memory:" for in-memory databases). */
get path(): string { return this.dbPath; } get path(): string { return this.dbPath; }
@@ -1916,6 +1917,7 @@ export class Database {
// don't need cross-instance persistence. // don't need cross-instance persistence.
const inMemory = options?.inMemory === true; const inMemory = options?.inMemory === true;
this.inMemory = inMemory; this.inMemory = inMemory;
this.fusionDir = fusionDir;
this.dbPath = inMemory ? ":memory:" : join(fusionDir, "fusion.db"); this.dbPath = inMemory ? ":memory:" : join(fusionDir, "fusion.db");
this.busyTimeoutMs = Math.max(0, options?.busyTimeoutMs ?? DEFAULT_SQLITE_BUSY_TIMEOUT_MS); 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); this.lockRecoveryWindowMs = Math.max(0, options?.lockRecoveryWindowMs ?? DEFAULT_SQLITE_LOCK_RECOVERY_WINDOW_MS);
@@ -5972,25 +5974,79 @@ export class Database {
return fromJson<ProjectIdentity>(value); return fromJson<ProjectIdentity>(value);
} }
private parseWorkflowSettingsJson(raw: string | null | undefined): Record<string, unknown> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} 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 { setProjectIdentity(identity: ProjectIdentity, options?: { force?: boolean }): void {
const stored = this.getProjectIdentity(); const stored = this.getProjectIdentity();
const force = options?.force === true; const force = options?.force === true;
if (stored) { if (stored && stored.id !== identity.id && !force) {
if (stored.id === identity.id) { throw new ProjectIdentityConflictError({
return; storedId: stored.id,
} storedPath: stored.firstSeenPath,
if (!force) { incomingId: identity.id,
throw new ProjectIdentityConflictError({ incomingPath: identity.firstSeenPath,
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 { clearProjectIdentity(): void {