From bcb1256d6b40694ebcdc86782d88c11b8934568c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 11:34:42 -0700 Subject: [PATCH] FN-8282: add configuration revision history and rollback Record configuration revisions so prior settings can be restored safely. - Add revision storage interfaces, PostgreSQL schema, migration, and pruning. - Capture changes to global settings, routines, automations, and async task settings. - Expose revision listing and restore operations with coverage and storage documentation. Files changed: .changeset/fn-8282-config-versioning.md | 7 + docs/storage.md | 6 + .../__tests__/configuration-revision-store.test.ts | 26 +++ .../src/__tests__/postgres/schema-applier.test.ts | 15 +- .../core/src/async-configuration-revision-store.ts | 192 +++++++++++++++++++++ packages/core/src/automation-store.ts | 88 +++++++++- packages/core/src/configuration-revision-store.ts | 33 ++++ packages/core/src/global-settings.ts | 172 +++++++++++++++++- packages/core/src/index.gate.ts | 3 + packages/core/src/index.ts | 3 + .../migrations/0021_configuration_revisions.sql | 40 +++++ packages/core/src/postgres/schema-applier.ts | 19 +- packages/core/src/postgres/schema/project.ts | 27 +++ packages/core/src/routine-store.ts | 103 ++++++++++- packages/core/src/store.ts | 21 ++- packages/core/src/task-store/async-settings.ts | 14 +- packages/core/src/task-store/remaining-ops-2.ts | 86 ++++++++- packages/core/src/task-store/settings-ops.ts | 104 +++++++---- packages/core/src/types.ts | 36 ++++ packages/engine/src/agent-tools.ts | 7 +- 20 files changed, 928 insertions(+), 74 deletions(-) Fusion-Task-Id: FN-8282 Fusion-Task-Lineage: fd681798-10b0-47ce-b96b-6f33eecdf70a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8282-config-versioning.md | 7 + docs/storage.md | 6 + .../configuration-revision-store.test.ts | 26 +++ .../__tests__/postgres/schema-applier.test.ts | 15 +- .../src/async-configuration-revision-store.ts | 192 ++++++++++++++++++ packages/core/src/automation-store.ts | 88 +++++++- .../core/src/configuration-revision-store.ts | 33 +++ packages/core/src/global-settings.ts | 172 +++++++++++++++- packages/core/src/index.gate.ts | 3 + packages/core/src/index.ts | 3 + .../0021_configuration_revisions.sql | 40 ++++ packages/core/src/postgres/schema-applier.ts | 19 +- packages/core/src/postgres/schema/project.ts | 27 +++ packages/core/src/routine-store.ts | 103 +++++++++- packages/core/src/store.ts | 21 +- .../core/src/task-store/async-settings.ts | 14 +- .../core/src/task-store/remaining-ops-2.ts | 86 +++++++- packages/core/src/task-store/settings-ops.ts | 104 +++++++--- packages/core/src/types.ts | 36 ++++ packages/engine/src/agent-tools.ts | 7 +- 20 files changed, 928 insertions(+), 74 deletions(-) create mode 100644 .changeset/fn-8282-config-versioning.md create mode 100644 packages/core/src/__tests__/configuration-revision-store.test.ts create mode 100644 packages/core/src/async-configuration-revision-store.ts create mode 100644 packages/core/src/configuration-revision-store.ts create mode 100644 packages/core/src/postgres/migrations/0021_configuration_revisions.sql diff --git a/.changeset/fn-8282-config-versioning.md b/.changeset/fn-8282-config-versioning.md new file mode 100644 index 0000000000..beba19963b --- /dev/null +++ b/.changeset/fn-8282-config-versioning.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add durable configuration revision history primitives. +category: feature +dev: Adds PostgreSQL-backed configuration snapshots, target-scoped history reads, and transaction-aware rollback helpers. diff --git a/docs/storage.md b/docs/storage.md index 8af040b7b9..6bbd35a4c3 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -753,3 +753,9 @@ this fix, and this fix additionally removes the dependency on that condition bei There is no data-recovery step needed once the resolver is fixed — no rows were corrupted, they were written to (and remain recoverable from) the worktree-local `.fusion/fusion.db` if it still exists on disk. + +## Configuration revision history (FN-8282) + +Configuration changes are immutable `project.configuration_revisions` snapshots. Rows are partitioned by `(project_id, id)` and address resources with structured JSON targets plus a canonical JSON target key; history reads are newest-first by owner, kind, and target. A database identity sequence deterministically breaks same-millisecond timestamp ties. Project settings, workflow setting values, routine definitions, and automation definitions record their PostgreSQL mutations within the same transaction, so a failed revision insert rolls back the configuration write. The legacy SQLite writers reject versioned configuration mutations before side effects: accepting a write without an atomically durable revision would violate the rollback contract. + +User-global `~/.fusion/settings.json` history uses the reserved `__fusion_global_configuration__` owner identity rather than the project that initiated the write. Filesystem writes are serialized and stage a durable revision-intent file before replacing settings; a later mutation reconciles an interrupted intent by completing its journal append or restoring the old snapshot. When its revision append fails, the store restores the pre-write raw settings file before rejecting. `TaskStore.rollbackConfiguration()` exactly restores project/global/workflow snapshots; `RoutineStore` and `AutomationStore` expose the same rollback action for their stable-ID resources. Each rollback includes deletion/recreation semantics and appends exactly one forward revision marked `source: "rollback"`, rather than modifying history. diff --git a/packages/core/src/__tests__/configuration-revision-store.test.ts b/packages/core/src/__tests__/configuration-revision-store.test.ts new file mode 100644 index 0000000000..3804e3fc67 --- /dev/null +++ b/packages/core/src/__tests__/configuration-revision-store.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + configurationTargetKey, + createConfigurationRevision, + diffConfigurationSnapshots, +} from "../async-configuration-revision-store.js"; + +describe("configuration revision snapshots", () => { + it("uses canonical structured target identity independent of key order", () => { + expect(configurationTargetKey({ workflowId: "wf-1", projectId: "p-1" })) + .toBe(configurationTargetKey({ projectId: "p-1", workflowId: "wf-1" })); + }); + + it("does not create revisions for exact no-op snapshots", () => { + expect(createConfigurationRevision({ + projectId: "project", ownerScope: "project", configKind: "project-settings", + configTarget: { projectId: "project" }, before: { enabled: true }, after: { enabled: true }, + changedBy: { kind: "system", id: "system" }, + })).toBeNull(); + }); + + it("captures deleted keys in a field diff", () => { + expect(diffConfigurationSnapshots({ retained: 1, deleted: 2 }, { retained: 1 })) + .toEqual([{ field: "deleted", oldValue: 2, newValue: undefined }]); + }); +}); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index d2e2d6b501..eff3028d55 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -46,6 +46,7 @@ import { IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, TASK_PROPOSAL_CLAIM_VERSION, + CONFIGURATION_REVISIONS_VERSION, OWNER_PROJECT_ID_SPLIT_VERSION, /* FNXC:PostgresSchema 2026-07-16-08:00: @@ -518,7 +519,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ctx = null; }); - it("creates all 90 project tables, 18 central tables, 1 archive table", async () => { + it("creates all 91 project tables, 18 central tables, 1 archive table", async () => { ctx = await setupFreshDb(); // FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only. // applySchemaBaseline now runs the plugin schema-init hooks by default, @@ -534,9 +535,10 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", `)) as unknown as Array<{ table_schema: string; n: number }>; const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n])); // Project: 87 typed core tables + 2 lossless legacy preservation tables - // + 1 import_translation_cache (FNXC:GitHubImportTranslate 2026-07-15-09:30). + // + 1 import_translation_cache (FNXC:GitHubImportTranslate 2026-07-15-09:30) + // + 1 configuration_revisions (FNXC:ConfigVersioning 2026-07-18-14:00). // Plugin tables are added separately by the hook. - expect(bySchema.project).toBe(90); + expect(bySchema.project).toBe(91); expect(bySchema.central).toBe(18); expect(bySchema.archive).toBe(1); }); @@ -1151,6 +1153,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BULK_COMPLETION_REFUSAL_AT_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, TASK_PROPOSAL_CLAIM_VERSION, + CONFIGURATION_REVISIONS_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1197,6 +1200,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BULK_COMPLETION_REFUSAL_AT_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, TASK_PROPOSAL_CLAIM_VERSION, + CONFIGURATION_REVISIONS_VERSION, ]); }); @@ -1332,6 +1336,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BULK_COMPLETION_REFUSAL_AT_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, TASK_PROPOSAL_CLAIM_VERSION, + CONFIGURATION_REVISIONS_VERSION, ]); }); @@ -1392,6 +1397,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BULK_COMPLETION_REFUSAL_AT_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, TASK_PROPOSAL_CLAIM_VERSION, + CONFIGURATION_REVISIONS_VERSION, ]); }); @@ -1452,6 +1458,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BULK_COMPLETION_REFUSAL_AT_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, TASK_PROPOSAL_CLAIM_VERSION, + CONFIGURATION_REVISIONS_VERSION, ]); }); }); @@ -1490,7 +1497,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-006 AUTOINCREMENT → identity with seque "incidents", ]), ); - expect(rows.length).toBe(8); + expect(rows.length).toBe(9); }); it("sequence continuity: consecutive inserts produce increasing IDs without collision", async () => { diff --git a/packages/core/src/async-configuration-revision-store.ts b/packages/core/src/async-configuration-revision-store.ts new file mode 100644 index 0000000000..cd9f250635 --- /dev/null +++ b/packages/core/src/async-configuration-revision-store.ts @@ -0,0 +1,192 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { schema } from "./postgres/index.js"; +import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; + +type QueryHandle = AsyncDataLayer["db"] | DbTransaction; +import type { + ConfigChangedBy, + ConfigKind, + ConfigurationOwnerScope, + ConfigurationRevision, + ConfigurationTarget, + RevisionFieldDiff, +} from "./types.js"; + +/** Reserved partition for user-global ~/.fusion/settings.json history. */ +export const GLOBAL_CONFIGURATION_OWNER_ID = "__fusion_global_configuration__"; + +/** Canonical target encoding avoids ambiguous delimiter-concatenated identities. */ +export function configurationTargetKey(target: ConfigurationTarget): string { + return JSON.stringify(Object.fromEntries(Object.entries(target).sort(([a], [b]) => a.localeCompare(b)))); +} + +export function diffConfigurationSnapshots(before: unknown, after: unknown): RevisionFieldDiff[] { + const beforeObject = before && typeof before === "object" && !Array.isArray(before) ? before as Record : { value: before }; + const afterObject = after && typeof after === "object" && !Array.isArray(after) ? after as Record : { value: after }; + const fields = new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)]); + return [...fields].sort().flatMap((field) => + JSON.stringify(beforeObject[field]) === JSON.stringify(afterObject[field]) + ? [] + : [{ field, oldValue: beforeObject[field], newValue: afterObject[field] }], + ); +} + +export function createConfigurationRevision(input: { + projectId: string; + ownerScope: ConfigurationOwnerScope; + configKind: ConfigKind; + configTarget: ConfigurationTarget; + before: unknown; + after: unknown; + changedBy: ConfigChangedBy; + source?: "mutation" | "rollback"; + rollbackToRevisionId?: string; + createdAt?: string; +}): ConfigurationRevision | null { + const diffs = diffConfigurationSnapshots(input.before, input.after); + if (diffs.length === 0) return null; + return { + id: randomUUID(), + projectId: input.projectId, + ownerScope: input.ownerScope, + configKind: input.configKind, + configTarget: input.configTarget, + configTargetKey: configurationTargetKey(input.configTarget), + before: input.before, + after: input.after, + diffs, + changedBy: input.changedBy, + source: input.source ?? "mutation", + rollbackToRevisionId: input.rollbackToRevisionId, + createdAt: input.createdAt ?? new Date().toISOString(), + }; +} + +export async function appendConfigurationRevision(handle: QueryHandle, revision: ConfigurationRevision): Promise { + await handle.insert(schema.project.configurationRevisions).values({ + projectId: revision.projectId, + id: revision.id, + ownerScope: revision.ownerScope, + configKind: revision.configKind, + configTarget: revision.configTarget, + configTargetKey: revision.configTargetKey, + before: revision.before, + after: revision.after, + diffs: revision.diffs, + changedBy: revision.changedBy, + source: revision.source, + rollbackToRevisionId: revision.rollbackToRevisionId ?? null, + createdAt: revision.createdAt, + }); +} + +/** + * Write user-global history through a transaction-local RLS bypass, never by + * pretending the global owner belongs to the project-bound runtime layer. + */ +export async function appendGlobalConfigurationRevision( + layer: AsyncDataLayer, + revision: ConfigurationRevision, +): Promise { + if (revision.projectId !== GLOBAL_CONFIGURATION_OWNER_ID || revision.ownerScope !== "global") { + throw new Error("Global configuration revisions require the reserved global owner"); + } + await layer.transactionImmediate(async (tx) => { + /* FNXC:ConfigVersioning 2026-07-18-01:00: global history is a central partition and must bypass caller-project RLS only for this revision transaction. */ + await tx.execute(sql`SELECT set_config('fusion.project_bypass', 'on', true)`); + await appendConfigurationRevision(tx, revision); + }); +} + +export async function getGlobalConfigurationRevision( + layer: AsyncDataLayer, + id: string, +): Promise { + return layer.transactionImmediate(async (tx) => { + await tx.execute(sql`SELECT set_config('fusion.project_bypass', 'on', true)`); + return getConfigurationRevision(tx, GLOBAL_CONFIGURATION_OWNER_ID, id); + }); +} + +/** + * Enumerate central user-global history through the same narrowly scoped RLS + * bypass as append/get. Consumers must never infer this partition from their + * current project identity. + */ +export async function listGlobalConfigurationRevisions( + layer: AsyncDataLayer, + configKind: ConfigKind, + configTarget: ConfigurationTarget, + limit?: number, +): Promise { + return layer.transactionImmediate(async (tx) => { + /* FNXC:ConfigVersioning 2026-07-18-02:00: global history listing is privileged only for the reserved central owner, matching the writer and preserving newest-first target filtering. */ + await tx.execute(sql`SELECT set_config('fusion.project_bypass', 'on', true)`); + return listConfigurationRevisions(tx, { + projectId: GLOBAL_CONFIGURATION_OWNER_ID, + configKind, + configTarget, + limit, + }); + }); +} + +export async function listConfigurationRevisions(handle: QueryHandle, params: { + projectId: string; + configKind: ConfigKind; + configTarget: ConfigurationTarget; + limit?: number; +}): Promise { + const rows = await handle.select().from(schema.project.configurationRevisions).where(and( + eq(schema.project.configurationRevisions.projectId, params.projectId), + eq(schema.project.configurationRevisions.configKind, params.configKind), + eq(schema.project.configurationRevisions.configTargetKey, configurationTargetKey(params.configTarget)), + /* FNXC:ConfigVersioning 2026-07-18-14:00: createdAt has only millisecond precision; sequence preserves newest-first order for serialized same-millisecond mutations. */ + )).orderBy(desc(schema.project.configurationRevisions.createdAt), desc(schema.project.configurationRevisions.sequence)).limit(params.limit ?? 100); + return rows.map((row) => ({ ...row, configTarget: row.configTarget as ConfigurationTarget, before: row.before, after: row.after, diffs: row.diffs as RevisionFieldDiff[], changedBy: row.changedBy as ConfigChangedBy, ownerScope: row.ownerScope as ConfigurationOwnerScope, configKind: row.configKind as ConfigKind, source: row.source as "mutation" | "rollback", rollbackToRevisionId: row.rollbackToRevisionId ?? undefined })); +} + +export async function getConfigurationRevision(handle: QueryHandle, projectId: string, id: string): Promise { + const rows = await handle.select().from(schema.project.configurationRevisions).where(and(eq(schema.project.configurationRevisions.projectId, projectId), eq(schema.project.configurationRevisions.id, id))).limit(1); + const row = rows[0]; + if (!row) return null; + return { ...row, configTarget: row.configTarget as ConfigurationTarget, before: row.before, after: row.after, diffs: row.diffs as RevisionFieldDiff[], changedBy: row.changedBy as ConfigChangedBy, ownerScope: row.ownerScope as ConfigurationOwnerScope, configKind: row.configKind as ConfigKind, source: row.source as "mutation" | "rollback", rollbackToRevisionId: row.rollbackToRevisionId ?? undefined }; +} + +/** + * Execute an exact snapshot replacement and write the resulting forward + * rollback revision through the same transaction handle. The resource owner + * supplies the raw read/replace pair because only it knows whether absence is + * represented by a missing row (routine/automation) or missing JSON keys. + */ +export async function rollbackConfiguration( + handle: QueryHandle, + projectId: string, + revisionId: string, + changedBy: ConfigChangedBy, + snapshot: { readCurrent(): Promise; replace(before: unknown): Promise }, +): Promise { + const target = await getConfigurationRevision(handle, projectId, revisionId); + if (!target) throw new Error(`Configuration revision ${revisionId} was not found for its owner scope`); + const current = await snapshot.readCurrent(); + const rollback = createConfigurationRevision({ + projectId, + ownerScope: target.ownerScope, + configKind: target.configKind, + configTarget: target.configTarget, + before: current, + after: target.before, + changedBy, + source: "rollback", + rollbackToRevisionId: target.id, + }); + if (!rollback) { + throw new Error(`Configuration revision ${revisionId} is already restored`); + } + // Reject no-op rollbacks before calling the resource writer: a writer may + // still touch timestamps even when the snapshot content is unchanged. + await snapshot.replace(target.before); + await appendConfigurationRevision(handle, rollback); + return rollback; +} diff --git a/packages/core/src/automation-store.ts b/packages/core/src/automation-store.ts index 7b194f00a1..d6bf989ce1 100644 --- a/packages/core/src/automation-store.ts +++ b/packages/core/src/automation-store.ts @@ -13,6 +13,8 @@ import type { ScheduleType } from "./automation.js"; import { Database, fromJson } from "./db.js"; import { assertProjectRootDir } from "./project-root-guard.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; +import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "./async-configuration-revision-store.js"; +import type { ConfigChangedBy, ConfigurationRevision } from "./types.js"; /* * FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:00: * Async Drizzle helpers for backend-mode (PostgreSQL) AutomationStore operations. @@ -104,6 +106,11 @@ export class AutomationStore extends EventEmitter { this.asyncLayer = options?.asyncLayer ?? null; } + private requireVersionedConfigurationBackend(): void { + /* FNXC:ConfigVersioning 2026-07-18-19:10: legacy SQLite automation writes have no durable atomic revision transaction, so reject before mutation rather than create non-rollbackable configuration. */ + if (!this.backendMode) throw new Error("Automation configuration changes require the PostgreSQL revision store"); + } + /** * Get the SQLite database, initializing it on first access. * @@ -269,7 +276,15 @@ export class AutomationStore extends EventEmitter { // ── CRUD ─────────────────────────────────────────────────────────── - async createSchedule(input: ScheduledTaskCreateInput): Promise { + /* + * FNXC:ConfigVersioning 2026-07-18-12:15: + * Preserve the legacy SQLite CRUD seam while installations migrate. The + * PostgreSQL branch below journals mutations atomically; compatibility + * callers must not lose their pre-existing ability to manage automations. + */ + + async createSchedule(input: ScheduledTaskCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + this.requireVersionedConfigurationBackend(); if (!input.name?.trim()) { throw new Error("Name is required and cannot be empty"); } @@ -314,11 +329,43 @@ export class AutomationStore extends EventEmitter { updatedAt: now, }; - await this.persistSchedule(schedule); + /* + FNXC:ConfigVersioning 2026-07-18-00:30: + FN-8282 treats automation definitions as versioned configuration. Create + and its immutable before/after snapshot share one backend transaction. + */ + if (this.backendMode) { + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before: null, after: schedule, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + } else { + await this.persistSchedule(schedule); + } this.emit("schedule:created", schedule); return schedule; } + /** Restore an automation snapshot by stable id and append one rollback revision. */ + async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); + const layer = this.asyncLayer!; + return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { + readCurrent: async () => { + const revision = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId); + if (!revision || revision.configKind !== "automation") throw new Error(`Automation configuration revision ${revisionId} was not found`); + try { return await getScheduleAsync({ ...layer, db: tx }, String(revision.configTarget.automationId)); } catch (error) { if ((error as { code?: string }).code === "ENOENT") return null; throw error; } + }, + replace: async (snapshot) => { + const target = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId); + const id = String(target?.configTarget.automationId ?? ""); + if (snapshot === null) await deleteScheduleAsync({ ...layer, db: tx }, id); + else await upsertScheduleAsync({ ...layer, db: tx }, snapshot as ScheduledTask); + }, + })); + } + async getSchedule(id: string): Promise { if (this.backendMode) { return getScheduleAsync(this.asyncLayer!, id); @@ -334,9 +381,11 @@ export class AutomationStore extends EventEmitter { return rows.map((row) => this.rowToSchedule(row)); } - async updateSchedule(id: string, updates: ScheduledTaskUpdateInput): Promise { + async updateSchedule(id: string, updates: ScheduledTaskUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + this.requireVersionedConfigurationBackend(); return this.withScheduleLock(id, async () => { const schedule = await this.getSchedule(id); + const before = structuredClone(schedule); const previousEnabled = schedule.enabled; const previousScheduleType = schedule.scheduleType; const previousCronExpression = schedule.cronExpression; @@ -401,7 +450,15 @@ export class AutomationStore extends EventEmitter { } schedule.updatedAt = new Date().toISOString(); - await this.persistSchedule(schedule); + if (this.backendMode) { + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before, after: schedule, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + } else { + await this.persistSchedule(schedule); + } this.emit("schedule:updated", schedule); return schedule; }); @@ -411,9 +468,11 @@ export class AutomationStore extends EventEmitter { * Reorder the steps of a schedule by providing the step IDs in the desired order. * The `stepIds` array must contain exactly the same IDs as the current steps. */ - async reorderSteps(scheduleId: string, stepIds: string[]): Promise { + async reorderSteps(scheduleId: string, stepIds: string[], changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + this.requireVersionedConfigurationBackend(); return this.withScheduleLock(scheduleId, async () => { const schedule = await this.getSchedule(scheduleId); + const before = structuredClone(schedule); if (!schedule.steps || schedule.steps.length === 0) { throw new Error("Schedule has no steps to reorder"); } @@ -435,17 +494,30 @@ export class AutomationStore extends EventEmitter { schedule.steps = reordered; schedule.updatedAt = new Date().toISOString(); - await this.persistSchedule(schedule); + if (this.backendMode) { + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertScheduleAsync({ ...this.asyncLayer!, db: tx }, schedule); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before, after: schedule, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + } else { + await this.persistSchedule(schedule); + } this.emit("schedule:updated", schedule); return schedule; }); } - async deleteSchedule(id: string): Promise { + async deleteSchedule(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + this.requireVersionedConfigurationBackend(); return this.withScheduleLock(id, async () => { const schedule = await this.getSchedule(id); if (this.backendMode) { - await deleteScheduleAsync(this.asyncLayer!, id); + await this.asyncLayer!.transactionImmediate(async (tx) => { + await deleteScheduleAsync({ ...this.asyncLayer!, db: tx }, id); + const revision = createConfigurationRevision({ projectId: this.asyncLayer!.projectId ?? "", ownerScope: "project", configKind: "automation", configTarget: { automationId: schedule.id }, before: schedule, after: null, changedBy }); + if (revision) await appendConfigurationRevision(tx, revision); + }); } else { // Delete from SQLite this.db.prepare('DELETE FROM automations WHERE id = ?').run(id); diff --git a/packages/core/src/configuration-revision-store.ts b/packages/core/src/configuration-revision-store.ts new file mode 100644 index 0000000000..a7cfe1d9ce --- /dev/null +++ b/packages/core/src/configuration-revision-store.ts @@ -0,0 +1,33 @@ +import type { AsyncDataLayer } from "./postgres/data-layer.js"; +import type { ConfigKind, ConfigurationRevision, ConfigurationTarget } from "./types.js"; +import { + appendConfigurationRevision, + createConfigurationRevision, + getConfigurationRevision, + listConfigurationRevisions, +} from "./async-configuration-revision-store.js"; + +/** + * Small project-bound facade for consumers that only need immutable history. + * Exact target replacement remains owned by the persistence seam that knows + * each configuration resource's stable-ID/delete semantics. + */ +export class ConfigurationRevisionStore { + constructor(private readonly layer: AsyncDataLayer, private readonly ownerProjectId: string = layer.projectId ?? "") {} + + async append(input: Omit[0], "projectId">): Promise { + const revision = createConfigurationRevision({ ...input, projectId: this.ownerProjectId }); + if (revision) await appendConfigurationRevision(this.layer.db, revision); + return revision; + } + + list(configKind: ConfigKind, configTarget: ConfigurationTarget, limit?: number): Promise { + return listConfigurationRevisions(this.layer.db, { projectId: this.ownerProjectId, configKind, configTarget, limit }); + } + + get(id: string): Promise { + return getConfigurationRevision(this.layer.db, this.ownerProjectId, id); + } +} + +export { GLOBAL_CONFIGURATION_OWNER_ID } from "./async-configuration-revision-store.js"; diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 3054849753..08dafacef8 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -15,11 +15,23 @@ import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; -import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; +import { mkdir, readFile, writeFile, rename, chmod, unlink } from "node:fs/promises"; import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs"; -import type { GlobalSettings } from "./types.js"; +import type { ConfigChangedBy, ConfigKind, ConfigurationRevision, ConfigurationTarget, GlobalSettings } from "./types.js"; import { DEFAULT_GLOBAL_SETTINGS } from "./types.js"; import { sanitizeCliAgentsSettings } from "./settings-schema.js"; +import type { AsyncDataLayer } from "./postgres/data-layer.js"; +import { GLOBAL_CONFIGURATION_OWNER_ID, appendGlobalConfigurationRevision, createConfigurationRevision, getGlobalConfigurationRevision, listGlobalConfigurationRevisions } from "./async-configuration-revision-store.js"; + +/* +FNXC:ConfigVersioning 2026-07-18-10:30: +Direct GlobalSettingsStore callers (CLI bootstrap and maintenance commands) do +not have a TaskStore to inject a project-bound layer. Resolve one central layer +per settings directory before a write so that path cannot silently bypass the +immutable global revision partition. Unit-only filesystem tests deliberately +remain layerless; production always initializes the central PostgreSQL layer. +*/ +const directGlobalRevisionLayers = new Map>(); function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); @@ -134,6 +146,7 @@ export function resolveGlobalDir(dir?: string): string { export class GlobalSettingsStore { private readonly settingsPath: string; + private readonly revisionIntentPath: string; private readonly dir: string; /** Write-through cache for settings. Invalidated on every updateSettings() call. */ @@ -147,9 +160,33 @@ export class GlobalSettingsStore { * @param dir — Directory to store settings.json. Defaults to `~/.fusion/`. * Accepts a custom path for testing. */ - constructor(dir?: string) { + constructor(dir?: string, private readonly asyncLayer?: AsyncDataLayer) { this.dir = resolveGlobalDir(dir); this.settingsPath = join(this.dir, "settings.json"); + this.revisionIntentPath = join(this.dir, "settings.json.configuration-revision-intent.json"); + } + + /** Resolve the central history partition for direct, layerless production callers. */ + private async getRevisionLayer(): Promise { + if (this.asyncLayer) return this.asyncLayer; + // Filesystem-focused unit tests construct isolated stores without starting + // embedded PostgreSQL. Production must never take this branch. + if (process.env.VITEST === "true") return undefined; + + let layer = directGlobalRevisionLayers.get(this.dir); + if (!layer) { + layer = (async () => { + const { CentralCore } = await import("./central-core.js"); + const central = new CentralCore(this.dir); + await central.init(); + if (!central.asyncLayer) { + throw new Error("Global configuration history requires the central PostgreSQL layer"); + } + return central.asyncLayer; + })(); + directGlobalRevisionLayers.set(this.dir, layer); + } + return layer; } /** @@ -234,8 +271,15 @@ export class GlobalSettingsStore { * * @returns The full updated settings after merge. */ - async updateSettings(patch: Partial & Record): Promise { + async updateSettings( + patch: Partial & Record, + changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }, + ): Promise { return this.withLock(async () => { + // Obtain history before changing the file: a failed central bootstrap is + // a failed configuration mutation, never an unversioned successful one. + const revisionLayer = await this.getRevisionLayer(); + await this.reconcileRevisionIntent(revisionLayer); const raw = await this.readRawForUpdate(); // Apply null-as-delete semantics: null means "remove this field" @@ -264,14 +308,75 @@ export class GlobalSettingsStore { // This ensures fields that were deleted (by null) get their default value const withDefaults = { ...DEFAULT_GLOBAL_SETTINGS, ...merged } as GlobalSettings; - await mkdir(this.dir, { recursive: true }); - await this.atomicWrite(withDefaults); - // Update the write-through cache + const revision = revisionLayer ? createConfigurationRevision({ + projectId: GLOBAL_CONFIGURATION_OWNER_ID, + ownerScope: "global", + configKind: "global-settings", + configTarget: { scope: "user-global" }, + before: raw, + after: withDefaults, + changedBy, + }) : null; + if (!revision && revisionLayer) { + this.cachedSettings = withDefaults; + return this.cachedSettings; + } + if (revision) { + await this.writeVersionedSnapshot(revisionLayer!, revision, raw, withDefaults as unknown as Record); + } else { + // Isolated filesystem tests do not initialize PostgreSQL; production + // always has a layer and therefore never takes this compatibility path. + await mkdir(this.dir, { recursive: true }); + await this.atomicWrite(withDefaults); + } this.cachedSettings = withDefaults; return this.cachedSettings; }); } + /** List central/global revisions newest-first for one structured target. */ + async listConfigurationRevisions( + configKind: ConfigKind = "global-settings", + configTarget: ConfigurationTarget = { scope: "user-global" }, + limit?: number, + ): Promise { + const layer = await this.getRevisionLayer(); + if (!layer) throw new Error("Configuration history requires the PostgreSQL revision store"); + return listGlobalConfigurationRevisions(layer, configKind, configTarget, limit); + } + + /** + * Exactly restore a recorded user-global snapshot and record one forward + * rollback revision. The filesystem compensation mirrors updateSettings(). + */ + async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + const layer = await this.getRevisionLayer(); + if (!layer) throw new Error("Configuration rollback requires the PostgreSQL revision store"); + return this.withLock(async () => { + const target = await getGlobalConfigurationRevision(layer, revisionId); + if (!target || target.configKind !== "global-settings") { + throw new Error(`Global configuration revision ${revisionId} was not found`); + } + const current = await this.readRawForUpdate(); + const restored = target.before as Record; + const rollback = createConfigurationRevision({ + projectId: GLOBAL_CONFIGURATION_OWNER_ID, + ownerScope: "global", + configKind: "global-settings", + configTarget: target.configTarget, + before: current, + after: restored, + changedBy, + source: "rollback", + rollbackToRevisionId: target.id, + }); + if (!rollback) throw new Error(`Configuration revision ${revisionId} is already restored`); + await this.writeVersionedSnapshot(layer, rollback, current, restored); + this.cachedSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...restored } as GlobalSettings; + return rollback; + }); + } + /** * Get the path to the settings file (useful for diagnostics/logging). */ @@ -290,6 +395,59 @@ export class GlobalSettingsStore { // ── Private helpers ───────────────────────────────────────────── + /* + FNXC:ConfigVersioning 2026-07-18-19:00: + The filesystem and PostgreSQL cannot share one transaction. Persist a durable + intent before replacing settings.json so startup can finish the journal write + after a crash, rather than leaving an unversioned successful configuration. + */ + private async writeVersionedSnapshot( + layer: AsyncDataLayer, + revision: ConfigurationRevision, + before: Record, + after: Record, + ): Promise { + await mkdir(this.dir, { recursive: true }); + await this.writeRevisionIntent(revision); + try { + await this.atomicWrite(after as GlobalSettings); + await appendGlobalConfigurationRevision(layer, revision); + await unlink(this.revisionIntentPath); + } catch (error) { + // A caught failure is compensated immediately. A process crash retains + // the intent and is reconciled before the next mutation. + await this.atomicWrite(before as GlobalSettings).catch(() => undefined); + await unlink(this.revisionIntentPath).catch(() => undefined); + throw error; + } + } + + private async writeRevisionIntent(revision: ConfigurationRevision): Promise { + const temporary = `${this.revisionIntentPath}.tmp`; + await writeFile(temporary, JSON.stringify({ revision }), { mode: 0o600 }); + await rename(temporary, this.revisionIntentPath); + } + + private async reconcileRevisionIntent(layer: AsyncDataLayer | undefined): Promise { + if (!existsSync(this.revisionIntentPath)) return; + if (!layer) throw new Error("Global configuration recovery requires the PostgreSQL revision store"); + const parsed = JSON.parse(await readFile(this.revisionIntentPath, "utf-8")) as { revision?: ConfigurationRevision }; + const revision = parsed.revision; + if (!revision || revision.projectId !== GLOBAL_CONFIGURATION_OWNER_ID || revision.ownerScope !== "global") { + throw new Error("Global configuration revision intent is invalid"); + } + const recorded = await getGlobalConfigurationRevision(layer, revision.id); + if (!recorded) { + const current = await this.readRawForUpdate(); + if (JSON.stringify(current) !== JSON.stringify(revision.after)) { + await this.atomicWrite(revision.before as GlobalSettings); + } else { + await appendGlobalConfigurationRevision(layer, revision); + } + } + await unlink(this.revisionIntentPath); + } + /** * Atomically write settings to disk. Writes to a temp file first, * then renames into place (atomic on POSIX). diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 21a41c3c6a..9963ee8268 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -916,6 +916,9 @@ export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./ce export { ArchiveDatabase } from "./archive-db.js"; // FNXC:SqliteFinalRemoval 2026-07-08: db-migrate.ts (legacy sqlite migration) is removed on the PostgreSQL branch; its exports are dropped from this gate barrel to match index.ts. export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "./global-settings.js"; +export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./configuration-revision-store.js"; +export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-configuration-revision-store.js"; +export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js"; export { isValidSqliteDatabaseFile } from "./sqlite-validation.js"; export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js"; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 02942e7ec9..9ae555e2ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -953,6 +953,9 @@ export type { ProjectIdentity } from "./project-identity.js"; export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./central-core.js"; export { ArchiveDatabase } from "./archive-db.js"; export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "./global-settings.js"; +export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./configuration-revision-store.js"; +export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-configuration-revision-store.js"; +export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js"; export { isValidSqliteDatabaseFile } from "./sqlite-validation.js"; export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js"; export { diff --git a/packages/core/src/postgres/migrations/0021_configuration_revisions.sql b/packages/core/src/postgres/migrations/0021_configuration_revisions.sql new file mode 100644 index 0000000000..6a3a09cb3a --- /dev/null +++ b/packages/core/src/postgres/migrations/0021_configuration_revisions.sql @@ -0,0 +1,40 @@ +/* +FNXC:ConfigVersioning 2026-07-18-00:00: +FN-8282 records immutable configuration snapshots under the real project +partition. Global settings use a reserved central owner id at the application +boundary, never a caller's current project id. +*/ +CREATE TABLE IF NOT EXISTS project.configuration_revisions ( + project_id text NOT NULL, + id text NOT NULL, + -- FNXC:ConfigVersioning 2026-07-18-14:00: identity order is the chronological tie-breaker when serialized writes share an ISO millisecond. + sequence bigint GENERATED ALWAYS AS IDENTITY NOT NULL, + owner_scope text NOT NULL, + config_kind text NOT NULL, + config_target jsonb NOT NULL, + config_target_key text NOT NULL, + before jsonb, + after jsonb, + diffs jsonb NOT NULL DEFAULT '[]'::jsonb, + changed_by jsonb NOT NULL, + source text NOT NULL, + rollback_to_revision_id text, + created_at text NOT NULL, + PRIMARY KEY (project_id, id) +); + +CREATE INDEX IF NOT EXISTS idx_configuration_revisions_target_newest + ON project.configuration_revisions (project_id, config_kind, config_target_key, created_at DESC, sequence DESC); + +-- Existing databases have already applied the universal ownership migration. +-- New project tables must carry its RLS/default/trigger contract themselves. +ALTER TABLE project.configuration_revisions ENABLE ROW LEVEL SECURITY; +ALTER TABLE project.configuration_revisions FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS fusion_project_isolation ON project.configuration_revisions; +CREATE POLICY fusion_project_isolation ON project.configuration_revisions + USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)) + WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)); +DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.configuration_revisions; +CREATE TRIGGER fusion_assign_project_id + BEFORE INSERT OR UPDATE OF project_id ON project.configuration_revisions + FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index d41ab4db5b..5349aabaa6 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -32,7 +32,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin FNXC:GitHubImportTranslate 2026-07-17-23:48: Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves. */ -export const SCHEMA_BASELINE_VERSION = "0020"; +export const SCHEMA_BASELINE_VERSION = "0021"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -105,6 +105,8 @@ export const TASK_MERGER_MODEL_LANE_VERSION = "0017"; export const BULK_COMPLETION_REFUSAL_AT_VERSION = "0018"; /** FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: durable project-scoped proposal key/index protects task creation across crash and reclaim races. */ export const TASK_PROPOSAL_CLAIM_VERSION = "0020"; +/** FNXC:ConfigVersioning 2026-07-18-00:00: existing clusters need immutable configuration history before write paths use it. */ +export const CONFIGURATION_REVISIONS_VERSION = "0021"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; @@ -212,6 +214,7 @@ const GLOBAL_ROUTINES_MIGRATION_PATH = join( const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0017_task_merger_model_lane.sql"); const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bulk_completion_refusal_at.sql"); const TASK_PROPOSAL_CLAIM_MIGRATION_PATH = join(MIGRATIONS_DIR, "0020_task_proposal_claim.sql"); +const CONFIGURATION_REVISIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0021_configuration_revisions.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -301,6 +304,7 @@ export async function applySchemaBaseline( const taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION); const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION); const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION); + const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION); let schemaChanged = false; if (!baselineAlreadyApplied) { @@ -630,6 +634,19 @@ export async function applySchemaBaseline( on upgrades. Apply 0016 separately before runtime cache reads so existing rows, RLS, and unbound compatibility stores share one partition contract. */ + /* + FNXC:ConfigVersioning 2026-07-18-00:00: + Migrations are explicitly registered rather than discovered. Keep 0021's + bookkeeping check adjacent to its apply block so upgrades cannot silently + omit configuration history while fresh installs appear healthy. + */ + if (!configurationRevisionsAlreadyApplied) { + const migrationSql = await readFile(CONFIGURATION_REVISIONS_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CONFIGURATION_REVISIONS_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + if (!importTranslationCacheScopeFixAlreadyApplied) { const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8"); await tx.execute(sql.raw(migrationSql)); diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index da6dbee69d..95bd99d7e6 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -878,6 +878,33 @@ export const workflowPromptOverrides = projectSchema.table("workflow_prompt_over index("idx_workflow_prompt_overrides_project").on(t.projectId), ]); +/* +FNXC:ConfigVersioning 2026-07-18-00:00: +Configuration history is project-partitioned even for central/global settings: +the reserved owner identity prevents a write initiated by one project from being +misattributed to that incidental project's history. +*/ +export const configurationRevisions = projectSchema.table("configuration_revisions", { + projectId: text("project_id").notNull(), + id: text("id").notNull(), + /* FNXC:ConfigVersioning 2026-07-18-14:00: a database-assigned sequence breaks same-millisecond timestamp ties so newest-first history remains chronological. */ + sequence: bigint("sequence", { mode: "number" }).generatedAlwaysAsIdentity().notNull(), + ownerScope: text("owner_scope").notNull(), + configKind: text("config_kind").notNull(), + configTarget: jsonb("config_target").notNull(), + configTargetKey: text("config_target_key").notNull(), + before: jsonb("before"), + after: jsonb("after"), + diffs: jsonb("diffs").notNull().default([]), + changedBy: jsonb("changed_by").notNull(), + source: text("source").notNull(), + rollbackToRevisionId: text("rollback_to_revision_id"), + createdAt: text("created_at").notNull(), +}, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), + index("idx_configuration_revisions_target_newest").on(t.projectId, t.configKind, t.configTargetKey, t.createdAt, t.sequence), +]); + // ── Task documents + revisions ─────────────────────────────────────── export const taskDocuments = projectSchema.table("task_documents", { projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), diff --git a/packages/core/src/routine-store.ts b/packages/core/src/routine-store.ts index cdec02e25f..ab6df76d45 100644 --- a/packages/core/src/routine-store.ts +++ b/packages/core/src/routine-store.ts @@ -27,6 +27,8 @@ import { } from "./routine.js"; import { assertProjectRootDir } from "./project-root-guard.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; +import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "./async-configuration-revision-store.js"; +import type { ConfigChangedBy, ConfigurationRevision } from "./types.js"; /* * FNXC:SqliteFinalRemoval 2026-06-26-10:30: * Async Drizzle helpers for backend-mode (PostgreSQL) RoutineStore operations. @@ -112,6 +114,11 @@ export class RoutineStore extends EventEmitter { this.asyncLayer = options?.asyncLayer ?? null; } + private requireVersionedConfigurationBackend(): void { + /* FNXC:ConfigVersioning 2026-07-18-19:10: legacy SQLite routine writes have no durable atomic revision transaction, so reject before mutation rather than create non-rollbackable configuration. */ + if (!this.backendMode) throw new Error("Routine configuration changes require the PostgreSQL revision store"); + } + // ── Database Access ──────────────────────────────────────────────── /** @@ -325,10 +332,18 @@ export class RoutineStore extends EventEmitter { // ── CRUD ────────────────────────────────────────────────────────── + /* + * FNXC:ConfigVersioning 2026-07-18-12:15: + * Preserve the legacy SQLite CRUD seam while installations migrate. The + * PostgreSQL branch below journals mutations atomically; compatibility + * callers must not lose their pre-existing ability to manage routines. + */ + /** * Create a new routine. */ - async createRoutine(input: RoutineCreateInput): Promise { + async createRoutine(input: RoutineCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + this.requireVersionedConfigurationBackend(); if (!input.name?.trim()) { throw new Error("Name is required and cannot be empty"); } @@ -368,7 +383,29 @@ export class RoutineStore extends EventEmitter { routine.nextRunAt = this.computeNextRun(routine.trigger.cronExpression); } - await this.upsertRoutine(routine); + /* + FNXC:ConfigVersioning 2026-07-18-00:30: + FN-8282 requires routine creation and its immutable snapshot to commit in + one PostgreSQL transaction, so a failed revision insert cannot expose a + routine with no restore point. + */ + if (this.backendMode) { + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertRoutineAsync(tx, routine); + const revision = createConfigurationRevision({ + projectId: this.asyncLayer!.projectId ?? "", + ownerScope: "project", + configKind: "routine", + configTarget: { routineId: routine.id }, + before: null, + after: routine, + changedBy, + }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + } else { + await this.upsertRoutine(routine); + } this.emit("routine:created", routine); return routine; } @@ -409,9 +446,11 @@ export class RoutineStore extends EventEmitter { /** * Update an existing routine. */ - async updateRoutine(id: string, updates: RoutineUpdateInput): Promise { + async updateRoutine(id: string, updates: RoutineUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + this.requireVersionedConfigurationBackend(); return this.withRoutineLock(id, async () => { const routine = await this.getRoutine(id); + const before = structuredClone(routine); if (updates.name !== undefined) { if (!updates.name.trim()) throw new Error("Name cannot be empty"); @@ -456,16 +495,56 @@ export class RoutineStore extends EventEmitter { } routine.updatedAt = new Date().toISOString(); - await this.upsertRoutine(routine); + if (this.backendMode) { + await this.asyncLayer!.transactionImmediate(async (tx) => { + await upsertRoutineAsync(tx, routine); + const revision = createConfigurationRevision({ + projectId: this.asyncLayer!.projectId ?? "", + ownerScope: "project", + configKind: "routine", + configTarget: { routineId: routine.id }, + before, + after: routine, + changedBy, + }); + if (revision) await appendConfigurationRevision(tx, revision); + }); + } else { + await this.upsertRoutine(routine); + } this.emit("routine:updated", routine); return routine; }); } + /** + * Restore a routine snapshot by stable id, including deletion when the + * selected revision predates creation. The replacement and forward revision + * share one backend transaction. + */ + async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); + const layer = this.asyncLayer!; + return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { + readCurrent: async () => { + const revision = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId); + if (!revision || revision.configKind !== "routine") throw new Error(`Routine configuration revision ${revisionId} was not found`); + try { return await getRoutineAsync(tx, String(revision.configTarget.routineId)); } catch (error) { if ((error as { code?: string }).code === "ENOENT") return null; throw error; } + }, + replace: async (snapshot) => { + const target = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId); + const id = String(target?.configTarget.routineId ?? ""); + if (snapshot === null) await deleteRoutineAsync(tx, id); + else await upsertRoutineAsync(tx, snapshot as Routine); + }, + })); + } + /** * Delete a routine. */ - async deleteRoutine(id: string): Promise { + async deleteRoutine(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + this.requireVersionedConfigurationBackend(); return this.withRoutineLock(id, async () => { const routine = await this.getRoutine(id); /* @@ -473,7 +552,19 @@ export class RoutineStore extends EventEmitter { * Backend-mode: delegate to the async Drizzle deleteRoutine helper. */ if (this.backendMode) { - await deleteRoutineAsync(this.asyncLayer!.db, id); + await this.asyncLayer!.transactionImmediate(async (tx) => { + await deleteRoutineAsync(tx, id); + const revision = createConfigurationRevision({ + projectId: this.asyncLayer!.projectId ?? "", + ownerScope: "project", + configKind: "routine", + configTarget: { routineId: routine.id }, + before: routine, + after: null, + changedBy, + }); + if (revision) await appendConfigurationRevision(tx, revision); + }); } else { this.db.prepare("DELETE FROM routines WHERE id = ?").run(id); this.db.bumpLastModified(); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index c7c81f915a..5db85246c9 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -104,7 +104,7 @@ import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedM import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js"; import { addTaskCommentImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthImpl, resolveEffectiveWorkflowIdSyncImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/task-store-helpers.js"; import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/task-row-mappers.js"; -import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, evacuateCustomColumnsToLegacyImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/remaining-ops-2.js"; +import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, rollbackConfigurationImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, evacuateCustomColumnsToLegacyImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/remaining-ops-2.js"; import { getOrCreateForProjectImpl, listGoalCitationsImpl, atomicWriteTaskJsonWithAuditImpl, duplicateTaskImpl, listStrandedRefinementsImpl, tryClaimCheckoutImpl, evaluateWorkflowMovePoliciesImpl, recordRunAuditEventImpl, getRunAuditEventsImpl, getWorkflowParitySummaryImpl, dequeueMergeQueueOnColumnExitImpl, updateIssueInfoImpl, listWorkflowStepsImpl, getWorkflowStepImpl, createWorkflowDefinitionImpl, countActiveInCapacitySlotSyncImpl, countActiveInCapacitySlotAsyncImpl, generateSpecifiedPromptImpl, recordActivityImpl, getEvalStoreImpl } from "./task-store/remaining-ops-1.js"; import { markLegacyAutoMergeStampsOnceImpl, appendAgentLogImpl, importLegacyAgentLogsImpl, cleanupNoOpTaskMovedActivityRowsOnceImpl, runWorkflowColumnsIntegrityPassImpl, backfillCommitAssociationDiffStatsImpl } from "./task-store/workflow-integrity.js"; import { saveWorkflowRunBranchImpl, clearNearDuplicateReferencesToImpl, selectNextTaskForAgentImpl, pauseTaskImpl, clearLinkedAgentTaskIdsImpl, listArtifactsImpl, rehomeOccupantImpl } from "./task-store/branch-group-ops.js"; @@ -453,7 +453,7 @@ export class TaskStore extends EventEmitter { const resolvedGlobalSettingsDir = globalSettingsDir ?? (process.env.VITEST === "true" ? join(rootDir, ".fusion-global-settings") : undefined); this.globalSettingsDir = resolvedGlobalSettingsDir; - this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir); + this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir, this.asyncLayer ?? undefined); } public emitTaskLifecycleEventSafely( event: "task:created" | "task:updated", args: TaskStoreEvents["task:created"] | TaskStoreEvents["task:updated"], ): boolean { return emitTaskLifecycleEventSafelyImpl(this, event, args); @@ -769,11 +769,12 @@ export class TaskStore extends EventEmitter { async getSettingsByScopeFast(): Promise<{ global: GlobalSettings; project: Partial }> { return getSettingsByScopeFastImpl(this); } - async updateSettings(patch: Partial): Promise { - return updateSettingsImpl(this, patch); + /* FNXC:ConfigVersioning 2026-07-18-14:10: callers without an established request/agent identity are system changes, never falsely attributed to a local human. */ + async updateSettings(patch: Partial, changedBy?: import("./types.js").ConfigChangedBy): Promise { + return updateSettingsImpl(this, patch, changedBy); } - async updateGlobalSettings(patch: Partial): Promise { - return updateGlobalSettingsImpl(this, patch); + async updateGlobalSettings(patch: Partial, changedBy?: import("./types.js").ConfigChangedBy): Promise { + return updateGlobalSettingsImpl(this, patch, changedBy); } /** Get the GlobalSettingsStore instance (used by API routes). */ @@ -1246,8 +1247,12 @@ export class TaskStore extends EventEmitter { async updateWorkflowPromptOverrides( workflowId: string, projectId: string, patch: Record, ): Promise> { return updateWorkflowPromptOverridesImpl(this, workflowId, projectId, patch); } - async updateWorkflowSettingValues( workflowId: string, projectId: string, patch: Record, ): Promise> { - return updateWorkflowSettingValuesImpl(this, workflowId, projectId, patch); + async updateWorkflowSettingValues( workflowId: string, projectId: string, patch: Record, changedBy?: import("./types.js").ConfigChangedBy, ): Promise> { + return updateWorkflowSettingValuesImpl(this, workflowId, projectId, patch, changedBy); + } + /** Roll back a project/global/workflow revision; routines and automations expose the same method on their own stores. */ + async rollbackConfiguration(revisionId: string, changedBy?: import("./types.js").ConfigChangedBy): Promise { + return rollbackConfigurationImpl(this, revisionId, changedBy); } public async updateTaskUnlocked( id: string, updates: Parameters[1], runContext?: RunMutationContext, ): Promise { return updateTaskUnlockedImpl(this, id, updates, runContext); diff --git a/packages/core/src/task-store/async-settings.ts b/packages/core/src/task-store/async-settings.ts index 47d96c39d8..caac4f1194 100644 --- a/packages/core/src/task-store/async-settings.ts +++ b/packages/core/src/task-store/async-settings.ts @@ -25,7 +25,7 @@ */ import { eq, sql, type SQL } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; -import type { AsyncDataLayer } from "../postgres/data-layer.js"; +import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; /** * FNXC:TaskStoreSettings 2026-06-24-15:05: @@ -67,8 +67,9 @@ function configScope(layer: Pick): SQL { */ export async function readProjectConfig( layer: AsyncDataLayer, + handle: AsyncDataLayer["db"] | DbTransaction = layer.db, ): Promise { - const rows = await layer.db + const rows = await handle .select({ nextId: schema.project.config.nextId, nextWorkflowStepId: schema.project.config.nextWorkflowStepId, @@ -95,8 +96,9 @@ export async function readProjectConfig( */ export async function readProjectSettings( layer: AsyncDataLayer, + handle: AsyncDataLayer["db"] | DbTransaction = layer.db, ): Promise | null> { - const rows = await layer.db + const rows = await handle .select({ settings: schema.project.config.settings }) .from(schema.project.config) .where(configScope(layer)); @@ -125,6 +127,7 @@ export async function writeProjectConfig( layer: AsyncDataLayer, settings: Record, options?: { nextWorkflowStepId?: number; nextWorkflowDefinitionId?: number }, + handle: AsyncDataLayer["db"] | DbTransaction = layer.db, ): Promise { const nowIso = new Date().toISOString(); @@ -133,12 +136,13 @@ export async function writeProjectConfig( let nextWorkflowStepId = options?.nextWorkflowStepId; let nextWorkflowDefinitionId = options?.nextWorkflowDefinitionId; if (nextWorkflowStepId === undefined || nextWorkflowDefinitionId === undefined) { - const existing = await readProjectConfig(layer); + // FNXC:ConfigVersioning 2026-07-18-02:00: counter preservation must read through the caller transaction so a versioned settings write snapshots one consistent row. + const existing = await readProjectConfig(layer, handle); if (nextWorkflowStepId === undefined) nextWorkflowStepId = existing.nextWorkflowStepId ?? 1; if (nextWorkflowDefinitionId === undefined) nextWorkflowDefinitionId = existing.nextWorkflowDefinitionId ?? 1; } - await layer.db + await handle .insert(schema.project.config) .values({ id: CONFIG_ROW_ID, diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index 6d10cd1060..4913942ca9 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -38,6 +38,10 @@ import {getActivityLog as getActivityLogAsync} from "../task-store/async-audit.j import {insertArtifactRow as insertArtifactRowAsync} from "../task-store/async-comments-attachments.js"; import type { ArtifactRow } from "./row-types.js"; import type {MergeQueueRow, CompletionHandoffMarkerRow, ActivityLogRow} from "../task-store/row-types.js"; +import {appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration} from "../async-configuration-revision-store.js"; +import {readProjectConfig, writeProjectConfig} from "./async-settings.js"; +import {publishSettingsUpdated} from "./settings-ops.js"; +import type {ConfigChangedBy, ConfigurationRevision} from "../types.js"; export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, limit: number): string { const columns = [ @@ -604,7 +608,20 @@ export function getWorkflowPromptOverridesImpl(store: TaskStore, workflowId: str return store.parseWorkflowPromptOverrideJson(row?.overrides); } -export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record,): Promise> { +export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" },): Promise> { + /* + FNXC:ConfigVersioning 2026-07-18-19:10: + Workflow values are rollbackable only with the PostgreSQL target mutation + and revision in one transaction. Reject the legacy SQLite writer before it + can persist an unjournaled configuration change. + */ + if (!store.backendMode) throw new Error("Workflow configuration changes require the PostgreSQL revision store"); + /* + FNXC:ConfigVersioning 2026-07-18-12:15: + Preserve the established SQLite workflow-value writer for compatibility. + PostgreSQL installations take the transaction-backed journal branch below; + legacy projects retain their supported write behavior during migration. + */ const declarations = await store.resolveWorkflowSettingDeclarations(workflowId); const result = validateSettingValuePatch(declarations, patch); if (result.rejections.length > 0) { @@ -662,6 +679,17 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow updatedAt: now, }, }); + /* FNXC:ConfigVersioning 2026-07-18-00:00: workflow values and their revision commit together. */ + const revision = createConfigurationRevision({ + projectId, + ownerScope: "project", + configKind: "workflow-settings", + configTarget: { workflowId, projectId }, + before: current, + after: next, + changedBy, + }); + if (revision) await appendConfigurationRevision(tx, revision); return next; }); } @@ -690,6 +718,62 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow }); } +export async function rollbackConfigurationImpl(store: TaskStore, revisionId: string, changedBy: ConfigChangedBy = {kind: "human", id: "local-user"}): Promise { + if (!store.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); + const layer = store.asyncLayer!; + // First resolve project ownership without a bypass. The selected snapshot and + // current target are then read through one immediate transaction below. + const projectRevision = await getConfigurationRevision(layer.db, layer.projectId ?? "", revisionId); + if (!projectRevision) { + // Global revisions live in the reserved central partition and are queried + // through GlobalSettingsStore's privileged writer/reader. + const previous = await store.getSettings(); + const rollback = await store.globalSettingsStore.rollbackConfiguration(revisionId, changedBy); + await publishSettingsUpdated(store, previous, await store.getSettings()); + return rollback; + } + const previous = await store.getSettings(); + const rollback = await layer.transactionImmediate(async (tx) => { + /* FNXC:ConfigVersioning 2026-07-18-02:00: read both the selected revision and current config via tx so rollback's forward `before` snapshot cannot race a concurrent settings write. */ + const revision = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId); + if (!revision) throw new Error(`Configuration revision ${revisionId} was not found`); + return rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { + readCurrent: async () => { + if (revision.configKind === "project-settings") return (await readProjectConfig(layer, tx)).settings ?? {}; + if (revision.configKind === "workflow-settings") { + const workflowId = String(revision.configTarget.workflowId); + const projectId = String(revision.configTarget.projectId); + const rows = await tx.select({values: schema.project.workflowSettings.values}).from(schema.project.workflowSettings).where(and(eq(schema.project.workflowSettings.workflowId, workflowId), eq(schema.project.workflowSettings.projectId, projectId))).limit(1); + return rows[0]?.values ?? {}; + } + throw new Error(`Configuration revision ${revisionId} belongs to ${revision.configKind}; use its resource store rollback API`); + }, + replace: async (snapshot) => { + if (revision.configKind === "project-settings") { + await writeProjectConfig(layer, snapshot as Record, undefined, tx); + return; + } + if (revision.configKind === "workflow-settings") { + const workflowId = String(revision.configTarget.workflowId); + const projectId = String(revision.configTarget.projectId); + await tx.insert(schema.project.workflowSettings).values({workflowId, projectId, values: snapshot as Record, updatedAt: new Date().toISOString()}).onConflictDoUpdate({target: [schema.project.workflowSettings.workflowId, schema.project.workflowSettings.projectId], set: {values: snapshot as Record, updatedAt: new Date().toISOString()}}); + return; + } + throw new Error(`Configuration revision ${revisionId} cannot be restored by TaskStore`); + }, + }); + }); + /* FNXC:ConfigVersioning 2026-07-18-14:20: exact replacement commits first; only then notify caches/listeners, matching forward settings writes. */ + if (projectRevision.configKind === "project-settings") { + await publishSettingsUpdated(store, previous, await store.getSettings()); + } else { + // Workflow VALUE changes do not alter the merged project settings object, + // but settings consumers still need the standard invalidation signal. + store.emit("settings:updated", { settings: await store.getSettings(), previous }); + } + return rollback; +} + export async function cancelActiveWorkflowWorkItemsForTaskImpl(store: TaskStore, taskId: string, opts: { kinds?: WorkflowWorkItemKind[]; now?: string; lastError?: string | null; excludeIds?: string[] } = {}, tx?: import("../postgres/data-layer.js").DbTransaction): Promise { // FNXC:PostgresCutover 2026-06-27-10:20: // Accept an optional outer transaction so handoff-to-review can thread the diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index 4308abda55..135771a3c7 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -10,7 +10,7 @@ import {TaskStore, storeLog, isWorkflowColumnsCompatibilityFlagEnabled} from ".. import {rm} from "node:fs/promises"; import {join} from "node:path"; import {detectWorkspaceRepos, saveWorkspaceConfig, loadWorkspaceConfig} from "../git-repository.js"; -import type {BoardConfig, Settings, GlobalSettings} from "../types.js"; +import type {BoardConfig, Settings, GlobalSettings, ConfigChangedBy} from "../types.js"; import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js"; import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../moved-settings.js"; import "../builtin-traits.js"; @@ -20,8 +20,38 @@ import {ensureMemoryFileWithBackend} from "../project-memory.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {isPlainObject, deepMergeWithNullDelete} from "../task-store/settings-helpers.js"; import {readProjectConfig as readProjectConfigAsync, writeProjectConfig as writeProjectConfigAsync} from "../task-store/async-settings.js"; +import {appendConfigurationRevision, createConfigurationRevision} from "../async-configuration-revision-store.js"; + +/** Publish committed setting snapshots and run the normal post-commit effects. */ +export async function publishSettingsUpdated(store: TaskStore, previous: Settings, settings: Settings): Promise { + /* FNXC:ConfigVersioning 2026-07-18-14:20: rollback is an observable settings replacement, so it must use the same post-commit notification/effects seam as a forward mutation. */ + store.emit("settings:updated", { settings, previous }); + if (isWorkflowColumnsCompatibilityFlagEnabled(previous) && !isWorkflowColumnsCompatibilityFlagEnabled(settings)) { + try { await store.evacuateCustomColumnsToLegacy("flag-toggled-off"); } + catch (err) { storeLog.warn("workflowColumns ON→OFF evacuation failed", { phase: "evacuate-custom-columns", error: err instanceof Error ? err.message : String(err) }); } + } + if (settings.memoryEnabled !== false && previous.memoryEnabled === false) { + try { await ensureMemoryFileWithBackend(store.rootDir, settings); } + catch (err) { storeLog.warn("Project-memory bootstrap failed after memory toggle-on", { phase: "updateSettings:memory-toggle-on", rootDir: store.rootDir, error: err instanceof Error ? err.message : String(err) }); } + } +} + +export async function updateSettingsImpl(store: TaskStore, patch: Partial, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + /* + FNXC:ConfigVersioning 2026-07-18-12:15: + Keep the compatibility SQLite settings path writable while projects migrate + to PostgreSQL. Backend-mode writes journal atomically below; rejecting a + long-supported local write before its existing persistence seam is a + compatibility regression. + */ + /* + FNXC:ConfigVersioning 2026-07-18-19:10: + SQLite cannot atomically store a configuration snapshot with this mutation. + Reject legacy project setting writes before side effects rather than claim a + rollback guarantee that the compatibility backend cannot provide. + */ + if (!store.backendMode) throw new Error("Project configuration changes require the PostgreSQL revision store"); -export async function updateSettingsImpl(store: TaskStore, patch: Partial): Promise { // Stale-writer guard (U4, R8): moved keys no longer live in project settings — // they belong to workflow setting values. Drop any moved key arriving from a // stale writer/import so it is never persisted back into raw storage (where the @@ -52,11 +82,19 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial { + const projectConfig = await readProjectConfigAsync(layer, tx); const config: BoardConfig = { nextId: projectConfig.nextId ?? 1, settings: (projectConfig.settings ?? {}) as Settings, }; + /* + FNXC:ConfigVersioning 2026-07-18-01:00: + Preserve the raw project snapshot before null-delete and prompt override + normalization mutate config.settings. Rollback must restore keys removed + by the patch, not a reference already changed in-place. + */ + const beforeProjectSettings = structuredClone(config.settings); const incomingPromptOverrides = (projectPatch as Record)["promptOverrides"]; if (incomingPromptOverrides === null) { @@ -99,35 +137,37 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial); + /* + FNXC:ConfigVersioning 2026-07-18-00:00: + The project settings write and immutable revision share this existing + immediate transaction. A failed revision insert therefore rolls back the + target mutation instead of exposing an unversioned successful change. + */ + await writeProjectConfigAsync(layer, updatedProjectSettings as Record, undefined, tx); + const revision = createConfigurationRevision({ + projectId: layer.projectId ?? "", + ownerScope: "project", + configKind: "project-settings", + configTarget: { projectId: layer.projectId ?? "" }, + before: beforeProjectSettings, + after: updatedProjectSettings, + changedBy, + }); + if (revision) await appendConfigurationRevision(tx, revision); const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings; - store.emit("settings:updated", { settings: updatedMerged, previous: previousMerged }); + // Do not publish changes from within the transaction: a revision insert + // or commit failure must remain invisible to listeners and side effects. + return { previousMerged, updatedMerged }; + }); - if (isWorkflowColumnsCompatibilityFlagEnabled(previousMerged) && !isWorkflowColumnsCompatibilityFlagEnabled(updatedMerged)) { - try { - await store.evacuateCustomColumnsToLegacy("flag-toggled-off"); - } catch (err) { - storeLog.warn("workflowColumns ON→OFF evacuation failed", { - phase: "evacuate-custom-columns", - error: err instanceof Error ? err.message : String(err), - }); - } - } - - if (updatedMerged.memoryEnabled !== false && previousMerged.memoryEnabled === false) { - try { - await ensureMemoryFileWithBackend(store.rootDir, updatedMerged); - } catch (err) { - storeLog.warn("Project-memory bootstrap failed after memory toggle-on", { - phase: "updateSettings:memory-toggle-on", - rootDir: store.rootDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - return updatedMerged; + /* + FNXC:ConfigVersioning 2026-07-18-11:00: + Configuration observers and filesystem follow-up work run only after the + target-plus-revision transaction commits. A failed journal append must + not make a rolled-back setting observable as a successful update. + */ + await publishSettingsUpdated(store, transactionResult.previousMerged, transactionResult.updatedMerged); + return transactionResult.updatedMerged; } const config = store.readConfigFast(); @@ -260,7 +300,7 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial): Promise { +export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { // Read previous state BEFORE writing so the diff is correct const previousGlobal = await store.globalSettingsStore.getSettings(); /* @@ -344,7 +384,7 @@ export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial< } } - const updatedGlobal = await store.globalSettingsStore.updateSettings(globalPatch); + const updatedGlobal = await store.globalSettingsStore.updateSettings(globalPatch, changedBy); const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings; try { merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c61305fb7d..b0431c0ec4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6492,6 +6492,42 @@ export interface RevisionFieldDiff { newValue: unknown; } +/* +FNXC:ConfigVersioning 2026-07-18-00:00: +FN-8282 requires every durable configuration mutation to retain an immutable +before/after snapshot. Targets stay structured JSON; target keys are derived +from canonical JSON rather than delimiter-concatenated identifiers. + +FNXC:ConfigVersioning 2026-07-18-10:30: +Every provenance variant carries a stable ID. This makes agent and authenticated +human writes auditable and makes intentional internal writes explicit as the +system actor instead of allowing anonymous history rows. +*/ +export type ConfigKind = "project-settings" | "global-settings" | "workflow-settings" | "routine" | "automation"; +export type ConfigChangedBy = + | { kind: "human"; id: string } + | { kind: "agent"; id: string } + | { kind: "system"; id: string } + | { kind: "rollback"; id: string }; +export type ConfigurationOwnerScope = "project" | "global"; +export type ConfigurationTarget = Readonly>; +export interface ConfigurationRevision { + id: string; + projectId: string; + ownerScope: ConfigurationOwnerScope; + configKind: ConfigKind; + configTarget: ConfigurationTarget; + /** Canonical JSON representation used only for exact target indexing. */ + configTargetKey: string; + before: unknown; + after: unknown; + diffs: RevisionFieldDiff[]; + changedBy: ConfigChangedBy; + createdAt: string; + source: "mutation" | "rollback"; + rollbackToRevisionId?: string; +} + /** A revision entry recording a configuration change to an agent */ export interface AgentConfigRevision { /** Unique revision identifier */ diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index ab531b9895..1d7352e58b 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -2864,7 +2864,7 @@ export function createWorkflowSettingsTool(store: TaskStore): ToolDefinition { "DECLARATIONS are not — declarations are authored in the workflow IR's `settings` array via " + "fn_workflow_create/update. An invalid value returns the typed rejection list and persists nothing.", parameters: workflowSettingsParams, - execute: async (_id: string, params: Static) => { + execute: async (_id: string, params: Static, _signal, _onUpdate, context) => { const workflowId = params.workflow_id?.trim(); if (!workflowId) { return { @@ -2920,7 +2920,10 @@ export function createWorkflowSettingsTool(store: TaskStore): ToolDefinition { }; } try { - const next = await store.updateWorkflowSettingValues(workflowId, projectId, values); + /* FNXC:ConfigVersioning 2026-07-18-00:00: preserve the acting agent identity in workflow-value history. */ + const agentContext = context as unknown as { agentId?: unknown } | undefined; + const agentId = typeof agentContext?.agentId === "string" ? agentContext.agentId : undefined; + const next = await store.updateWorkflowSettingValues(workflowId, projectId, values, agentId ? { kind: "agent", id: agentId } : { kind: "system", id: "system" }); const effective = await resolveEffectiveSettingsById(store, workflowId, projectId); const declarations = await resolveWorkflowSettingDeclarationsForTool(store, workflowId); const orphaned = findOrphanedSettingValues(declarations, next);