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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 11:34:42 -07:00
parent f218645911
commit bcb1256d6b
20 changed files with 928 additions and 74 deletions

View File

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

View File

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

View File

@@ -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 }]);
});
});

View File

@@ -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 () => {

View File

@@ -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<string, unknown> : { value: before };
const afterObject = after && typeof after === "object" && !Array.isArray(after) ? after as Record<string, unknown> : { 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<void> {
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<void> {
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<ConfigurationRevision | null> {
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<ConfigurationRevision[]> {
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<ConfigurationRevision[]> {
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<ConfigurationRevision | null> {
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<unknown>; replace(before: unknown): Promise<void> },
): Promise<ConfigurationRevision> {
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;
}

View File

@@ -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<AutomationStoreEvents> {
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<AutomationStoreEvents> {
// ── CRUD ───────────────────────────────────────────────────────────
async createSchedule(input: ScheduledTaskCreateInput): Promise<ScheduledTask> {
/*
* 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<ScheduledTask> {
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<AutomationStoreEvents> {
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<ConfigurationRevision> {
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<ScheduledTask> {
if (this.backendMode) {
return getScheduleAsync(this.asyncLayer!, id);
@@ -334,9 +381,11 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
return rows.map((row) => this.rowToSchedule(row));
}
async updateSchedule(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
async updateSchedule(id: string, updates: ScheduledTaskUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ScheduledTask> {
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<AutomationStoreEvents> {
}
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<AutomationStoreEvents> {
* 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<ScheduledTask> {
async reorderSteps(scheduleId: string, stepIds: string[], changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ScheduledTask> {
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<AutomationStoreEvents> {
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<ScheduledTask> {
async deleteSchedule(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ScheduledTask> {
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);

View File

@@ -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<Parameters<typeof createConfigurationRevision>[0], "projectId">): Promise<ConfigurationRevision | null> {
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<ConfigurationRevision[]> {
return listConfigurationRevisions(this.layer.db, { projectId: this.ownerProjectId, configKind, configTarget, limit });
}
get(id: string): Promise<ConfigurationRevision | null> {
return getConfigurationRevision(this.layer.db, this.ownerProjectId, id);
}
}
export { GLOBAL_CONFIGURATION_OWNER_ID } from "./async-configuration-revision-store.js";

View File

@@ -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<string, Promise<AsyncDataLayer>>();
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<AsyncDataLayer | undefined> {
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<GlobalSettings> & Record<string, unknown>): Promise<GlobalSettings> {
async updateSettings(
patch: Partial<GlobalSettings> & Record<string, unknown>,
changedBy: ConfigChangedBy = { kind: "human", id: "local-user" },
): Promise<GlobalSettings> {
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<string, unknown>);
} 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<ConfigurationRevision[]> {
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<ConfigurationRevision> {
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<string, unknown>;
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<string, unknown>,
after: Record<string, unknown>,
): Promise<void> {
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<void> {
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<void> {
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).

View File

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

View File

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

View File

@@ -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();

View File

@@ -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));

View File

@@ -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)`),

View File

@@ -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<RoutineStoreEvents> {
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<RoutineStoreEvents> {
// ── 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<Routine> {
async createRoutine(input: RoutineCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> {
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<RoutineStoreEvents> {
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<RoutineStoreEvents> {
/**
* Update an existing routine.
*/
async updateRoutine(id: string, updates: RoutineUpdateInput): Promise<Routine> {
async updateRoutine(id: string, updates: RoutineUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> {
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<RoutineStoreEvents> {
}
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<ConfigurationRevision> {
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<Routine> {
async deleteRoutine(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> {
this.requireVersionedConfigurationBackend();
return this.withRoutineLock(id, async () => {
const routine = await this.getRoutine(id);
/*
@@ -473,7 +552,19 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
* 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();

View File

@@ -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<TaskStoreEvents> {
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<TaskStoreEvents> {
async getSettingsByScopeFast(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
return getSettingsByScopeFastImpl(this);
}
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
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<Settings>, changedBy?: import("./types.js").ConfigChangedBy): Promise<Settings> {
return updateSettingsImpl(this, patch, changedBy);
}
async updateGlobalSettings(patch: Partial<GlobalSettings>): Promise<Settings> {
return updateGlobalSettingsImpl(this, patch);
async updateGlobalSettings(patch: Partial<GlobalSettings>, changedBy?: import("./types.js").ConfigChangedBy): Promise<Settings> {
return updateGlobalSettingsImpl(this, patch, changedBy);
}
/** Get the GlobalSettingsStore instance (used by API routes). */
@@ -1246,8 +1247,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateWorkflowPromptOverrides( workflowId: string, projectId: string, patch: Record<string, string | null | undefined>, ): Promise<Record<string, string>> {
return updateWorkflowPromptOverridesImpl(this, workflowId, projectId, patch);
}
async updateWorkflowSettingValues( workflowId: string, projectId: string, patch: Record<string, unknown>, ): Promise<Record<string, unknown>> {
return updateWorkflowSettingValuesImpl(this, workflowId, projectId, patch);
async updateWorkflowSettingValues( workflowId: string, projectId: string, patch: Record<string, unknown>, changedBy?: import("./types.js").ConfigChangedBy, ): Promise<Record<string, unknown>> {
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<import("./types.js").ConfigurationRevision> {
return rollbackConfigurationImpl(this, revisionId, changedBy);
}
public async updateTaskUnlocked( id: string, updates: Parameters<TaskStore["updateTask"]>[1], runContext?: RunMutationContext, ): Promise<Task> {
return updateTaskUnlockedImpl(this, id, updates, runContext);

View File

@@ -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<AsyncDataLayer, "projectId">): SQL {
*/
export async function readProjectConfig(
layer: AsyncDataLayer,
handle: AsyncDataLayer["db"] | DbTransaction = layer.db,
): Promise<ProjectConfigRow> {
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<Record<string, unknown> | 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<string, unknown>,
options?: { nextWorkflowStepId?: number; nextWorkflowDefinitionId?: number },
handle: AsyncDataLayer["db"] | DbTransaction = layer.db,
): Promise<void> {
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,

View File

@@ -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<string, unknown>,): Promise<Record<string, unknown>> {
export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record<string, unknown>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" },): Promise<Record<string, unknown>> {
/*
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<ConfigurationRevision> {
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<string, unknown>, 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<string, unknown>, updatedAt: new Date().toISOString()}).onConflictDoUpdate({target: [schema.project.workflowSettings.workflowId, schema.project.workflowSettings.projectId], set: {values: snapshot as Record<string, unknown>, 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<WorkflowWorkItem[]> {
// FNXC:PostgresCutover 2026-06-27-10:20:
// Accept an optional outer transaction so handoff-to-review can thread the

View File

@@ -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<void> {
/* 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<Settings>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Settings> {
/*
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<Settings>): Promise<Settings> {
// 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<Settin
// merge, null-delete semantics) is identical across backends.
if (store.backendMode) {
const layer = store.asyncLayer!;
const projectConfig = await readProjectConfigAsync(layer);
const transactionResult = await layer.transactionImmediate(async (tx) => {
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<string, unknown>)["promptOverrides"];
if (incomingPromptOverrides === null) {
@@ -99,35 +137,37 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settin
// FNXC:TaskPinnedWorktrees 2026-07-16-00:00: reject recycleWorktrees + worktreeNaming:"task-id"
// (mutually exclusive) against the resolved next state BEFORE persisting the invalid combination.
assertWorktreeNamingRecycleExclusive({ ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings);
// Write the full updated settings object back via the async helper.
await writeProjectConfigAsync(layer, updatedProjectSettings as Record<string, unknown>);
/*
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<string, unknown>, 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<Settin
});
}
export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial<GlobalSettings>): Promise<Settings> {
export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial<GlobalSettings>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Settings> {
// 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());

View File

@@ -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<Record<string, string>>;
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 */

View File

@@ -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<typeof workflowSettingsParams>) => {
execute: async (_id: string, params: Static<typeof workflowSettingsParams>, _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);