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