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 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 were written to (and remain recoverable from) the worktree-local `.fusion/fusion.db` if it still
exists on disk. 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_SCOPE_FIX_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
TASK_PROPOSAL_CLAIM_VERSION, TASK_PROPOSAL_CLAIM_VERSION,
CONFIGURATION_REVISIONS_VERSION,
OWNER_PROJECT_ID_SPLIT_VERSION, OWNER_PROJECT_ID_SPLIT_VERSION,
/* /*
FNXC:PostgresSchema 2026-07-16-08:00: FNXC:PostgresSchema 2026-07-16-08:00:
@@ -518,7 +519,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
ctx = null; 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(); ctx = await setupFreshDb();
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only. // FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
// applySchemaBaseline now runs the plugin schema-init hooks by default, // 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 }>; `)) as unknown as Array<{ table_schema: string; n: number }>;
const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n])); const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n]));
// Project: 87 typed core tables + 2 lossless legacy preservation tables // 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. // 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.central).toBe(18);
expect(bySchema.archive).toBe(1); expect(bySchema.archive).toBe(1);
}); });
@@ -1151,6 +1153,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
TASK_PROPOSAL_CLAIM_VERSION, TASK_PROPOSAL_CLAIM_VERSION,
CONFIGURATION_REVISIONS_VERSION,
]); ]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); 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, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
TASK_PROPOSAL_CLAIM_VERSION, TASK_PROPOSAL_CLAIM_VERSION,
CONFIGURATION_REVISIONS_VERSION,
]); ]);
}); });
@@ -1332,6 +1336,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
TASK_PROPOSAL_CLAIM_VERSION, TASK_PROPOSAL_CLAIM_VERSION,
CONFIGURATION_REVISIONS_VERSION,
]); ]);
}); });
@@ -1392,6 +1397,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
TASK_PROPOSAL_CLAIM_VERSION, TASK_PROPOSAL_CLAIM_VERSION,
CONFIGURATION_REVISIONS_VERSION,
]); ]);
}); });
@@ -1452,6 +1458,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
BULK_COMPLETION_REFUSAL_AT_VERSION, BULK_COMPLETION_REFUSAL_AT_VERSION,
IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION, IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION,
TASK_PROPOSAL_CLAIM_VERSION, TASK_PROPOSAL_CLAIM_VERSION,
CONFIGURATION_REVISIONS_VERSION,
]); ]);
}); });
}); });
@@ -1490,7 +1497,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-006 AUTOINCREMENT → identity with seque
"incidents", "incidents",
]), ]),
); );
expect(rows.length).toBe(8); expect(rows.length).toBe(9);
}); });
it("sequence continuity: consecutive inserts produce increasing IDs without collision", async () => { 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 { Database, fromJson } from "./db.js";
import { assertProjectRootDir } from "./project-root-guard.js"; import { assertProjectRootDir } from "./project-root-guard.js";
import type { AsyncDataLayer } from "./postgres/data-layer.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: * FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:00:
* Async Drizzle helpers for backend-mode (PostgreSQL) AutomationStore operations. * Async Drizzle helpers for backend-mode (PostgreSQL) AutomationStore operations.
@@ -104,6 +106,11 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
this.asyncLayer = options?.asyncLayer ?? null; 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. * Get the SQLite database, initializing it on first access.
* *
@@ -269,7 +276,15 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
// ── CRUD ─────────────────────────────────────────────────────────── // ── 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()) { if (!input.name?.trim()) {
throw new Error("Name is required and cannot be empty"); throw new Error("Name is required and cannot be empty");
} }
@@ -314,11 +329,43 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
updatedAt: now, 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); this.emit("schedule:created", schedule);
return 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> { async getSchedule(id: string): Promise<ScheduledTask> {
if (this.backendMode) { if (this.backendMode) {
return getScheduleAsync(this.asyncLayer!, id); return getScheduleAsync(this.asyncLayer!, id);
@@ -334,9 +381,11 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
return rows.map((row) => this.rowToSchedule(row)); 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 () => { return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id); const schedule = await this.getSchedule(id);
const before = structuredClone(schedule);
const previousEnabled = schedule.enabled; const previousEnabled = schedule.enabled;
const previousScheduleType = schedule.scheduleType; const previousScheduleType = schedule.scheduleType;
const previousCronExpression = schedule.cronExpression; const previousCronExpression = schedule.cronExpression;
@@ -401,7 +450,15 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
} }
schedule.updatedAt = new Date().toISOString(); 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); this.emit("schedule:updated", schedule);
return 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. * 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. * 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 () => { return this.withScheduleLock(scheduleId, async () => {
const schedule = await this.getSchedule(scheduleId); const schedule = await this.getSchedule(scheduleId);
const before = structuredClone(schedule);
if (!schedule.steps || schedule.steps.length === 0) { if (!schedule.steps || schedule.steps.length === 0) {
throw new Error("Schedule has no steps to reorder"); throw new Error("Schedule has no steps to reorder");
} }
@@ -435,17 +494,30 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
schedule.steps = reordered; schedule.steps = reordered;
schedule.updatedAt = new Date().toISOString(); 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); this.emit("schedule:updated", schedule);
return 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 () => { return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id); const schedule = await this.getSchedule(id);
if (this.backendMode) { 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 { } else {
// Delete from SQLite // Delete from SQLite
this.db.prepare('DELETE FROM automations WHERE id = ?').run(id); 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 { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; 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 { 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 { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { sanitizeCliAgentsSettings } from "./settings-schema.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 { function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir(); return process.env.HOME || process.env.USERPROFILE || homedir();
@@ -134,6 +146,7 @@ export function resolveGlobalDir(dir?: string): string {
export class GlobalSettingsStore { export class GlobalSettingsStore {
private readonly settingsPath: string; private readonly settingsPath: string;
private readonly revisionIntentPath: string;
private readonly dir: string; private readonly dir: string;
/** Write-through cache for settings. Invalidated on every updateSettings() call. */ /** 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/`. * @param dir — Directory to store settings.json. Defaults to `~/.fusion/`.
* Accepts a custom path for testing. * Accepts a custom path for testing.
*/ */
constructor(dir?: string) { constructor(dir?: string, private readonly asyncLayer?: AsyncDataLayer) {
this.dir = resolveGlobalDir(dir); this.dir = resolveGlobalDir(dir);
this.settingsPath = join(this.dir, "settings.json"); 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. * @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 () => { 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(); const raw = await this.readRawForUpdate();
// Apply null-as-delete semantics: null means "remove this field" // 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 // This ensures fields that were deleted (by null) get their default value
const withDefaults = { ...DEFAULT_GLOBAL_SETTINGS, ...merged } as GlobalSettings; const withDefaults = { ...DEFAULT_GLOBAL_SETTINGS, ...merged } as GlobalSettings;
await mkdir(this.dir, { recursive: true }); const revision = revisionLayer ? createConfigurationRevision({
await this.atomicWrite(withDefaults); projectId: GLOBAL_CONFIGURATION_OWNER_ID,
// Update the write-through cache 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; this.cachedSettings = withDefaults;
return this.cachedSettings; 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). * Get the path to the settings file (useful for diagnostics/logging).
*/ */
@@ -290,6 +395,59 @@ export class GlobalSettingsStore {
// ── Private helpers ───────────────────────────────────────────── // ── 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, * Atomically write settings to disk. Writes to a temp file first,
* then renames into place (atomic on POSIX). * 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"; 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. // 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 { 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 { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js"; export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
export { export {

View File

@@ -953,6 +953,9 @@ export type { ProjectIdentity } from "./project-identity.js";
export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./central-core.js"; export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./central-core.js";
export { ArchiveDatabase } from "./archive-db.js"; export { ArchiveDatabase } from "./archive-db.js";
export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "./global-settings.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 { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js"; export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
export { 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: 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. 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 INITIAL_SCHEMA_VERSION = "0000";
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; 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"; 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. */ /** 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"; 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. */ /** Bookkeeping table for the fresh Drizzle migration history. */
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; 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 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 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 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 * 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 taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION);
const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION); const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION);
const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION); const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION);
const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION);
let schemaChanged = false; let schemaChanged = false;
if (!baselineAlreadyApplied) { if (!baselineAlreadyApplied) {
@@ -630,6 +634,19 @@ export async function applySchemaBaseline(
on upgrades. Apply 0016 separately before runtime cache reads so existing on upgrades. Apply 0016 separately before runtime cache reads so existing
rows, RLS, and unbound compatibility stores share one partition contract. 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) { if (!importTranslationCacheScopeFixAlreadyApplied) {
const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8"); const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql)); 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), 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 ─────────────────────────────────────── // ── Task documents + revisions ───────────────────────────────────────
export const taskDocuments = projectSchema.table("task_documents", { export const taskDocuments = projectSchema.table("task_documents", {
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),

View File

@@ -27,6 +27,8 @@ import {
} from "./routine.js"; } from "./routine.js";
import { assertProjectRootDir } from "./project-root-guard.js"; import { assertProjectRootDir } from "./project-root-guard.js";
import type { AsyncDataLayer } from "./postgres/data-layer.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: * FNXC:SqliteFinalRemoval 2026-06-26-10:30:
* Async Drizzle helpers for backend-mode (PostgreSQL) RoutineStore operations. * Async Drizzle helpers for backend-mode (PostgreSQL) RoutineStore operations.
@@ -112,6 +114,11 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
this.asyncLayer = options?.asyncLayer ?? null; 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 ──────────────────────────────────────────────── // ── Database Access ────────────────────────────────────────────────
/** /**
@@ -325,10 +332,18 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
// ── CRUD ────────────────────────────────────────────────────────── // ── 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. * 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()) { if (!input.name?.trim()) {
throw new Error("Name is required and cannot be empty"); 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); 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); this.emit("routine:created", routine);
return routine; return routine;
} }
@@ -409,9 +446,11 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
/** /**
* Update an existing routine. * 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 () => { return this.withRoutineLock(id, async () => {
const routine = await this.getRoutine(id); const routine = await this.getRoutine(id);
const before = structuredClone(routine);
if (updates.name !== undefined) { if (updates.name !== undefined) {
if (!updates.name.trim()) throw new Error("Name cannot be empty"); 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(); 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); this.emit("routine:updated", routine);
return 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. * 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 () => { return this.withRoutineLock(id, async () => {
const routine = await this.getRoutine(id); 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. * Backend-mode: delegate to the async Drizzle deleteRoutine helper.
*/ */
if (this.backendMode) { 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 { } else {
this.db.prepare("DELETE FROM routines WHERE id = ?").run(id); this.db.prepare("DELETE FROM routines WHERE id = ?").run(id);
this.db.bumpLastModified(); this.db.bumpLastModified();

View File

@@ -104,7 +104,7 @@ import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedM
import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js"; 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 { 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 { 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 { 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 { 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"; 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 const resolvedGlobalSettingsDir = globalSettingsDir
?? (process.env.VITEST === "true" ? join(rootDir, ".fusion-global-settings") : undefined); ?? (process.env.VITEST === "true" ? join(rootDir, ".fusion-global-settings") : undefined);
this.globalSettingsDir = resolvedGlobalSettingsDir; 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 { public emitTaskLifecycleEventSafely( event: "task:created" | "task:updated", args: TaskStoreEvents["task:created"] | TaskStoreEvents["task:updated"], ): boolean {
return emitTaskLifecycleEventSafelyImpl(this, event, args); return emitTaskLifecycleEventSafelyImpl(this, event, args);
@@ -769,11 +769,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async getSettingsByScopeFast(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> { async getSettingsByScopeFast(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
return getSettingsByScopeFastImpl(this); return getSettingsByScopeFastImpl(this);
} }
async updateSettings(patch: Partial<Settings>): Promise<Settings> { /* FNXC:ConfigVersioning 2026-07-18-14:10: callers without an established request/agent identity are system changes, never falsely attributed to a local human. */
return updateSettingsImpl(this, patch); async updateSettings(patch: Partial<Settings>, changedBy?: import("./types.js").ConfigChangedBy): Promise<Settings> {
return updateSettingsImpl(this, patch, changedBy);
} }
async updateGlobalSettings(patch: Partial<GlobalSettings>): Promise<Settings> { async updateGlobalSettings(patch: Partial<GlobalSettings>, changedBy?: import("./types.js").ConfigChangedBy): Promise<Settings> {
return updateGlobalSettingsImpl(this, patch); return updateGlobalSettingsImpl(this, patch, changedBy);
} }
/** Get the GlobalSettingsStore instance (used by API routes). */ /** 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>> { async updateWorkflowPromptOverrides( workflowId: string, projectId: string, patch: Record<string, string | null | undefined>, ): Promise<Record<string, string>> {
return updateWorkflowPromptOverridesImpl(this, workflowId, projectId, patch); return updateWorkflowPromptOverridesImpl(this, workflowId, projectId, patch);
} }
async updateWorkflowSettingValues( workflowId: string, projectId: string, patch: Record<string, unknown>, ): Promise<Record<string, unknown>> { 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); 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> { public async updateTaskUnlocked( id: string, updates: Parameters<TaskStore["updateTask"]>[1], runContext?: RunMutationContext, ): Promise<Task> {
return updateTaskUnlockedImpl(this, id, updates, runContext); return updateTaskUnlockedImpl(this, id, updates, runContext);

View File

@@ -25,7 +25,7 @@
*/ */
import { eq, sql, type SQL } from "drizzle-orm"; import { eq, sql, type SQL } from "drizzle-orm";
import * as schema from "../postgres/schema/index.js"; 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: * FNXC:TaskStoreSettings 2026-06-24-15:05:
@@ -67,8 +67,9 @@ function configScope(layer: Pick<AsyncDataLayer, "projectId">): SQL {
*/ */
export async function readProjectConfig( export async function readProjectConfig(
layer: AsyncDataLayer, layer: AsyncDataLayer,
handle: AsyncDataLayer["db"] | DbTransaction = layer.db,
): Promise<ProjectConfigRow> { ): Promise<ProjectConfigRow> {
const rows = await layer.db const rows = await handle
.select({ .select({
nextId: schema.project.config.nextId, nextId: schema.project.config.nextId,
nextWorkflowStepId: schema.project.config.nextWorkflowStepId, nextWorkflowStepId: schema.project.config.nextWorkflowStepId,
@@ -95,8 +96,9 @@ export async function readProjectConfig(
*/ */
export async function readProjectSettings( export async function readProjectSettings(
layer: AsyncDataLayer, layer: AsyncDataLayer,
handle: AsyncDataLayer["db"] | DbTransaction = layer.db,
): Promise<Record<string, unknown> | null> { ): Promise<Record<string, unknown> | null> {
const rows = await layer.db const rows = await handle
.select({ settings: schema.project.config.settings }) .select({ settings: schema.project.config.settings })
.from(schema.project.config) .from(schema.project.config)
.where(configScope(layer)); .where(configScope(layer));
@@ -125,6 +127,7 @@ export async function writeProjectConfig(
layer: AsyncDataLayer, layer: AsyncDataLayer,
settings: Record<string, unknown>, settings: Record<string, unknown>,
options?: { nextWorkflowStepId?: number; nextWorkflowDefinitionId?: number }, options?: { nextWorkflowStepId?: number; nextWorkflowDefinitionId?: number },
handle: AsyncDataLayer["db"] | DbTransaction = layer.db,
): Promise<void> { ): Promise<void> {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
@@ -133,12 +136,13 @@ export async function writeProjectConfig(
let nextWorkflowStepId = options?.nextWorkflowStepId; let nextWorkflowStepId = options?.nextWorkflowStepId;
let nextWorkflowDefinitionId = options?.nextWorkflowDefinitionId; let nextWorkflowDefinitionId = options?.nextWorkflowDefinitionId;
if (nextWorkflowStepId === undefined || nextWorkflowDefinitionId === undefined) { 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 (nextWorkflowStepId === undefined) nextWorkflowStepId = existing.nextWorkflowStepId ?? 1;
if (nextWorkflowDefinitionId === undefined) nextWorkflowDefinitionId = existing.nextWorkflowDefinitionId ?? 1; if (nextWorkflowDefinitionId === undefined) nextWorkflowDefinitionId = existing.nextWorkflowDefinitionId ?? 1;
} }
await layer.db await handle
.insert(schema.project.config) .insert(schema.project.config)
.values({ .values({
id: CONFIG_ROW_ID, 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 {insertArtifactRow as insertArtifactRowAsync} from "../task-store/async-comments-attachments.js";
import type { ArtifactRow } from "./row-types.js"; import type { ArtifactRow } from "./row-types.js";
import type {MergeQueueRow, CompletionHandoffMarkerRow, ActivityLogRow} from "../task-store/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 { export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, limit: number): string {
const columns = [ const columns = [
@@ -604,7 +608,20 @@ export function getWorkflowPromptOverridesImpl(store: TaskStore, workflowId: str
return store.parseWorkflowPromptOverrideJson(row?.overrides); 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 declarations = await store.resolveWorkflowSettingDeclarations(workflowId);
const result = validateSettingValuePatch(declarations, patch); const result = validateSettingValuePatch(declarations, patch);
if (result.rejections.length > 0) { if (result.rejections.length > 0) {
@@ -662,6 +679,17 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow
updatedAt: now, 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; 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[]> { 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: // FNXC:PostgresCutover 2026-06-27-10:20:
// Accept an optional outer transaction so handoff-to-review can thread the // 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 {rm} from "node:fs/promises";
import {join} from "node:path"; import {join} from "node:path";
import {detectWorkspaceRepos, saveWorkspaceConfig, loadWorkspaceConfig} from "../git-repository.js"; 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 {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js";
import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../moved-settings.js"; import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../moved-settings.js";
import "../builtin-traits.js"; import "../builtin-traits.js";
@@ -20,8 +20,38 @@ import {ensureMemoryFileWithBackend} from "../project-memory.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {isPlainObject, deepMergeWithNullDelete} from "../task-store/settings-helpers.js"; import {isPlainObject, deepMergeWithNullDelete} from "../task-store/settings-helpers.js";
import {readProjectConfig as readProjectConfigAsync, writeProjectConfig as writeProjectConfigAsync} from "../task-store/async-settings.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 — // 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 // 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 // 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. // merge, null-delete semantics) is identical across backends.
if (store.backendMode) { if (store.backendMode) {
const layer = store.asyncLayer!; 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 = { const config: BoardConfig = {
nextId: projectConfig.nextId ?? 1, nextId: projectConfig.nextId ?? 1,
settings: (projectConfig.settings ?? {}) as Settings, 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"]; const incomingPromptOverrides = (projectPatch as Record<string, unknown>)["promptOverrides"];
if (incomingPromptOverrides === null) { 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" // FNXC:TaskPinnedWorktrees 2026-07-16-00:00: reject recycleWorktrees + worktreeNaming:"task-id"
// (mutually exclusive) against the resolved next state BEFORE persisting the invalid combination. // (mutually exclusive) against the resolved next state BEFORE persisting the invalid combination.
assertWorktreeNamingRecycleExclusive({ ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings); 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; 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 { FNXC:ConfigVersioning 2026-07-18-11:00:
await store.evacuateCustomColumnsToLegacy("flag-toggled-off"); Configuration observers and filesystem follow-up work run only after the
} catch (err) { target-plus-revision transaction commits. A failed journal append must
storeLog.warn("workflowColumns ON→OFF evacuation failed", { not make a rolled-back setting observable as a successful update.
phase: "evacuate-custom-columns", */
error: err instanceof Error ? err.message : String(err), await publishSettingsUpdated(store, transactionResult.previousMerged, transactionResult.updatedMerged);
}); return transactionResult.updatedMerged;
}
}
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;
} }
const config = store.readConfigFast(); 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 // Read previous state BEFORE writing so the diff is correct
const previousGlobal = await store.globalSettingsStore.getSettings(); 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; const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
try { try {
merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore()); merged.secretsSyncPassphraseConfigured = await hasSyncPassphraseConfigured(await store.getSecretsStore());

View File

@@ -6492,6 +6492,42 @@ export interface RevisionFieldDiff {
newValue: unknown; 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 */ /** A revision entry recording a configuration change to an agent */
export interface AgentConfigRevision { export interface AgentConfigRevision {
/** Unique revision identifier */ /** 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 " + "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.", "fn_workflow_create/update. An invalid value returns the typed rejection list and persists nothing.",
parameters: workflowSettingsParams, 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(); const workflowId = params.workflow_id?.trim();
if (!workflowId) { if (!workflowId) {
return { return {
@@ -2920,7 +2920,10 @@ export function createWorkflowSettingsTool(store: TaskStore): ToolDefinition {
}; };
} }
try { 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 effective = await resolveEffectiveSettingsById(store, workflowId, projectId);
const declarations = await resolveWorkflowSettingDeclarationsForTool(store, workflowId); const declarations = await resolveWorkflowSettingDeclarationsForTool(store, workflowId);
const orphaned = findOrphanedSettingValues(declarations, next); const orphaned = findOrphanedSettingValues(declarations, next);