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 { 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user