feat(FN-1637): merge fusion/fn-1637

This commit is contained in:
gsxdsm
2026-04-12 15:58:10 -07:00
parent e8d0740d0d
commit 50e5f36909
4 changed files with 176 additions and 6 deletions

View File

@@ -387,6 +387,55 @@ describe("createBackupManager", () => {
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
it("should canonicalize legacy .kb/backups to .fusion/backups in settings", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const kbDir = join(tempDir, ".fusion");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: ".kb/backups", // Legacy value
};
const manager = createBackupManager(kbDir, settings);
// Use fake timers and create a backup
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const backup = await manager.createBackup();
// Verify the backup was created in the canonical .fusion/backups directory
expect(backup.path).toContain(".fusion/backups");
expect(backup.path).not.toContain(".kb/backups");
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
it("should preserve non-legacy custom .kb/* directories", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const kbDir = join(tempDir, ".fusion");
await mkdir(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: ".kb/my-custom-backups", // Custom path, not the legacy default
};
const manager = createBackupManager(kbDir, settings);
// Use fake timers and create a backup
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const backup = await manager.createBackup();
// Verify the backup was created in the custom .kb/my-custom-backups directory
expect(backup.path).toContain(".kb/my-custom-backups");
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
});
describe("runBackupCommand", () => {

View File

@@ -4,6 +4,25 @@ import { join } from "node:path";
import { CronExpressionParser } from "cron-parser";
import type { ProjectSettings } from "./types.js";
/**
* Legacy backup directory default value from the old .kb storage structure.
* Projects that were created before the .fusion rename may still have this
* value persisted in their config. It is canonicalized to the new default
* so that all backup operations use a consistent directory.
*/
const LEGACY_BACKUP_DIR = ".kb/backups";
/**
* Canonicalizes the backup directory from legacy defaults.
* Only the exact legacy default alias is transformed — custom paths are preserved.
*/
function canonicalizeBackupDir(dir: string | undefined): string | undefined {
if (dir === LEGACY_BACKUP_DIR) {
return ".fusion/backups";
}
return dir;
}
/**
* Metadata for a database backup file.
*/
@@ -285,6 +304,7 @@ export function validateBackupDir(dir: string): boolean {
/**
* Factory function to create a BackupManager with project settings.
* Applies defensive canonicalization for the legacy backup directory default.
* @param kbDir - Absolute path to the .fusion directory
* @param settings - Project settings containing backup configuration
* @returns Configured BackupManager instance
@@ -294,7 +314,7 @@ export function createBackupManager(
settings?: Partial<ProjectSettings>
): BackupManager {
return new BackupManager(kbDir, {
backupDir: settings?.autoBackupDir,
backupDir: canonicalizeBackupDir(settings?.autoBackupDir),
retention: settings?.autoBackupRetention,
});
}

View File

@@ -938,6 +938,70 @@ describe("TaskStore", () => {
});
});
// ── Backup Directory Canonicalization ─────────────────────────────
describe("autoBackupDir canonicalization", () => {
it("getSettings returns .fusion/backups when persisted config contains legacy .kb/backups", async () => {
// Directly set the legacy backup dir in the SQLite config to simulate legacy projects
const db = (store as any).db;
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
existingSettings.autoBackupDir = ".kb/backups";
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
const settings = await store.getSettings();
expect(settings.autoBackupDir).toBe(".fusion/backups");
});
it("getSettingsFast returns .fusion/backups when persisted config contains legacy .kb/backups", async () => {
const db = (store as any).db;
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
existingSettings.autoBackupDir = ".kb/backups";
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
const settings = await store.getSettingsFast();
expect(settings.autoBackupDir).toBe(".fusion/backups");
});
it("getSettingsByScope returns .fusion/backups in project when persisted config contains legacy .kb/backups", async () => {
const db = (store as any).db;
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
existingSettings.autoBackupDir = ".kb/backups";
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
const { project } = await store.getSettingsByScope();
expect(project.autoBackupDir).toBe(".fusion/backups");
});
it("autoBackupDir: null removes the override and falls back to default .fusion/backups", async () => {
// First set a custom backup dir
await store.updateSettings({ autoBackupDir: "custom/backups" });
let settings = await store.getSettings();
expect(settings.autoBackupDir).toBe("custom/backups");
// Then clear it with null (null-as-delete semantics)
await store.updateSettings({ autoBackupDir: null as unknown as undefined });
settings = await store.getSettings();
// Should fall back to the default .fusion/backups (which is the canonical form)
expect(settings.autoBackupDir).toBe(".fusion/backups");
});
it("non-legacy custom .kb/* directories are preserved (not canonicalized)", async () => {
// Custom path like ".kb/my-custom-backups" should NOT be canonicalized
await store.updateSettings({ autoBackupDir: ".kb/my-custom-backups" });
const settings = await store.getSettings();
expect(settings.autoBackupDir).toBe(".kb/my-custom-backups");
});
it("getSettings preserves explicit .fusion/backups setting", async () => {
await store.updateSettings({ autoBackupDir: ".fusion/backups" });
const settings = await store.getSettings();
expect(settings.autoBackupDir).toBe(".fusion/backups");
});
});
// ── Prompt Overrides Tests ─────────────────────────────────────────
describe("promptOverrides settings", () => {

View File

@@ -16,6 +16,34 @@ import { getTaskMergeBlocker } from "./task-merge.js";
import { ensureMemoryFile } from "./project-memory.js";
import { runCommandAsync } from "./run-command.js";
/**
* Legacy backup directory default value from the old .kb storage structure.
* Projects that were created before the .fusion rename may still have this
* value persisted in their config. It is canonicalized to the new default
* so that all backup operations use a consistent directory.
*/
const LEGACY_BACKUP_DIR = ".kb/backups";
/**
* Canonicalizes a settings object by resolving legacy defaults.
* Currently handles the .kb/backups → .fusion/backups migration.
*
* This function applies only the exact-match legacy alias transformation.
* Other custom .kb/* paths are preserved as-is.
*/
function canonicalizeSettings(settings: Settings): Settings {
// Canonicalize the legacy backup directory default to the new location.
// Only the exact legacy default value is transformed — custom paths like
// ".kb/my-custom-backups" are preserved unchanged.
if ((settings as Partial<ProjectSettings>).autoBackupDir === LEGACY_BACKUP_DIR) {
return {
...settings,
autoBackupDir: ".fusion/backups",
};
}
return settings;
}
export interface TaskStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
@@ -621,17 +649,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
*
* Returns the combined view that most consumers should use. Project-level
* values in `.fusion/config.json` override global values from `~/.pi/fusion/settings.json`.
*
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
*/
async getSettings(): Promise<Settings> {
const [globalSettings, config] = await Promise.all([
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
return {
return canonicalizeSettings({
...DEFAULT_SETTINGS,
...globalSettings,
...config.settings,
};
});
}
/**
@@ -643,6 +673,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* read-heavy paths like the settings page that don't need workflow steps.
*
* Note: Do NOT use this method when you need workflow steps — use `getSettings()` instead.
*
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
*/
async getSettingsFast(): Promise<Settings> {
const [globalSettings, row] = await Promise.all([
@@ -652,17 +684,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const projectSettings = row?.settings ? fromJson<Settings>(row.settings) : undefined;
return {
return canonicalizeSettings({
...DEFAULT_SETTINGS,
...globalSettings,
...projectSettings,
};
});
}
/**
* Get settings separated by scope. Returns both the global and
* project-level settings independently (useful for the UI to show
* which scope a value comes from).
*
* Settings are canonicalized to resolve legacy defaults (e.g., `.kb/backups` → `.fusion/backups`).
*/
async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
const [globalSettings, config] = await Promise.all([
@@ -680,7 +714,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
return { global: globalSettings, project: projectSettings };
// Apply canonicalization to both the project settings and the merged result
const canonicalizedProject = canonicalizeSettings(projectSettings as Settings);
return { global: globalSettings, project: canonicalizedProject };
}
/**