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:
7
.changeset/fn-7452-workflow-settings-identity.md
Normal file
7
.changeset/fn-7452-workflow-settings-identity.md
Normal 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.
|
||||
@@ -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, 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. */
|
||||
async function runMigration(store: TaskStore): Promise<void> {
|
||||
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.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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user