feat(FN-1120): add agent config revision history APIs

- Define and export agent config revision types in @fusion/core for revision metadata and snapshots
- Persist config revisions in AgentStore with type-safe snapshot diffing when agent runtime config changes
- Add dashboard routes to list agent config revisions and fetch individual revision details
- Add focused tests for core revision persistence flows and dashboard agent revision route behavior
This commit is contained in:
gsxdsm
2026-04-08 01:12:48 -07:00
parent 9118fdb16f
commit bd601959b6
6 changed files with 834 additions and 5 deletions

View File

@@ -233,6 +233,216 @@ describe("AgentStore", () => {
});
});
// ── config revisions ───────────────────────────────────────────────
describe("config revisions", () => {
it("records revision when name changes", async () => {
const created = await store.createAgent({ name: "Original", role: "executor" });
await store.updateAgent(created.id, { name: "Renamed" });
const revisions = await store.getConfigRevisions(created.id);
expect(revisions).toHaveLength(1);
expect(revisions[0].source).toBe("user");
expect(revisions[0].diffs).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: "name", oldValue: "Original", newValue: "Renamed" }),
]),
);
expect(revisions[0].summary).toContain("name");
expect(revisions[0].before.name).toBe("Original");
expect(revisions[0].after.name).toBe("Renamed");
});
it("records revisions for runtimeConfig, permissions, instructionsPath, and instructionsText changes", async () => {
const created = await store.createAgent({
name: "Configurable",
role: "executor",
runtimeConfig: { heartbeatIntervalMs: 30000 },
permissions: { canReview: false },
});
await store.updateAgent(created.id, { runtimeConfig: { heartbeatIntervalMs: 10000 } });
await store.updateAgent(created.id, { permissions: { canReview: true, canExecute: true } });
await store.updateAgent(created.id, { instructionsPath: "docs/agent.md" });
await store.updateAgent(created.id, { instructionsText: "Follow safety checks." });
const revisions = await store.getConfigRevisions(created.id);
const changedFields = revisions.flatMap((revision) => revision.diffs.map((diff) => diff.field));
expect(changedFields).toContain("runtimeConfig");
expect(changedFields).toContain("permissions");
expect(changedFields).toContain("instructionsPath");
expect(changedFields).toContain("instructionsText");
});
it("does not create a revision when only non-config fields change", async () => {
const created = await store.createAgent({ name: "No Diff", role: "executor" });
await store.updateAgent(created.id, {
totalInputTokens: 120,
totalOutputTokens: 80,
pauseReason: "manual",
lastError: "temporary",
});
const revisions = await store.getConfigRevisions(created.id);
expect(revisions).toEqual([]);
});
it("returns revisions in reverse chronological order and respects limit", async () => {
const created = await store.createAgent({ name: "Chrono", role: "executor" });
await store.updateAgent(created.id, { name: "Chrono-1" });
await store.updateAgent(created.id, { name: "Chrono-2" });
await store.updateAgent(created.id, { name: "Chrono-3" });
const revisions = await store.getConfigRevisions(created.id);
expect(revisions).toHaveLength(3);
expect(revisions[0].after.name).toBe("Chrono-3");
expect(revisions[1].after.name).toBe("Chrono-2");
expect(revisions[2].after.name).toBe("Chrono-1");
const limited = await store.getConfigRevisions(created.id, 2);
expect(limited).toHaveLength(2);
expect(limited.map((revision) => revision.after.name)).toEqual(["Chrono-3", "Chrono-2"]);
});
it("getConfigRevisions returns empty for agents with no revisions and non-existent agents", async () => {
const created = await store.createAgent({ name: "No Revisions", role: "executor" });
expect(await store.getConfigRevisions(created.id)).toEqual([]);
expect(await store.getConfigRevisions("agent-missing")).toEqual([]);
});
it("getConfigRevision returns matching revision and null when missing", async () => {
const created = await store.createAgent({ name: "Find Revision", role: "executor" });
await store.updateAgent(created.id, { name: "Find Revision v2" });
const [revision] = await store.getConfigRevisions(created.id);
const found = await store.getConfigRevision(created.id, revision.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(revision.id);
expect(await store.getConfigRevision(created.id, "revision-missing")).toBeNull();
expect(await store.getConfigRevision("agent-missing", revision.id)).toBeNull();
});
it("rollbackConfig restores previous config and records rollback revision", async () => {
const created = await store.createAgent({
name: "Rollback Me",
role: "executor",
runtimeConfig: { heartbeatTimeoutMs: 60000 },
});
await store.updateAgent(created.id, {
name: "Rollback Me v2",
runtimeConfig: { heartbeatTimeoutMs: 90000 },
});
const [targetRevision] = await store.getConfigRevisions(created.id);
const result = await store.rollbackConfig(created.id, targetRevision.id);
expect(result.agent.name).toBe("Rollback Me");
expect(result.agent.runtimeConfig).toEqual({ heartbeatTimeoutMs: 60000 });
expect(result.revision.source).toBe("rollback");
expect(result.revision.rollbackToRevisionId).toBe(targetRevision.id);
const revisions = await store.getConfigRevisions(created.id);
expect(revisions[0].id).toBe(result.revision.id);
expect(revisions[0].source).toBe("rollback");
});
it("rollbackConfig supports chained rollbacks", async () => {
const created = await store.createAgent({ name: "Version 1", role: "executor" });
await store.updateAgent(created.id, { name: "Version 2" });
await store.updateAgent(created.id, { name: "Version 3" });
const revisions = await store.getConfigRevisions(created.id);
const revToV2 = revisions.find((revision) => revision.after.name === "Version 3");
const revToV1 = revisions.find((revision) => revision.after.name === "Version 2");
expect(revToV2).toBeDefined();
expect(revToV1).toBeDefined();
await store.rollbackConfig(created.id, revToV2!.id);
const afterFirstRollback = await store.getAgent(created.id);
expect(afterFirstRollback!.name).toBe("Version 2");
await store.rollbackConfig(created.id, revToV1!.id);
const afterSecondRollback = await store.getAgent(created.id);
expect(afterSecondRollback!.name).toBe("Version 1");
});
it("rollbackConfig throws for missing revision", async () => {
const created = await store.createAgent({ name: "Rollback Missing", role: "executor" });
await expect(store.rollbackConfig(created.id, "revision-missing")).rejects.toThrow(
`Config revision revision-missing not found for agent ${created.id}`,
);
});
it("rollbackConfig throws when revision belongs to a different agent", async () => {
const agentA = await store.createAgent({ name: "Agent A", role: "executor" });
const agentB = await store.createAgent({ name: "Agent B", role: "reviewer" });
await store.updateAgent(agentA.id, { name: "Agent A v2" });
const [revisionA] = await store.getConfigRevisions(agentA.id);
await expect(store.rollbackConfig(agentB.id, revisionA.id)).rejects.toThrow(
`Config revision ${revisionA.id} belongs to agent ${agentA.id}`,
);
});
it("emits agent:configRevision on config updates and rollback, but not updateAgentState", async () => {
const created = await store.createAgent({ name: "Events", role: "executor" });
const handler = vi.fn();
store.on("agent:configRevision", handler);
await store.updateAgent(created.id, { name: "Events v2" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenLastCalledWith(
created.id,
expect.objectContaining({ agentId: created.id, source: "user" }),
);
await store.updateAgentState(created.id, "idle");
expect(handler).toHaveBeenCalledTimes(1);
const [firstRevision] = await store.getConfigRevisions(created.id);
await store.rollbackConfig(created.id, firstRevision.id);
expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenLastCalledWith(
created.id,
expect.objectContaining({ source: "rollback", rollbackToRevisionId: firstRevision.id }),
);
});
it("persists revisions to an append-only JSONL file without affecting heartbeat files", async () => {
const created = await store.createAgent({ name: "Persisted", role: "executor" });
await store.updateAgent(created.id, { name: "Persisted v2" });
await store.updateAgent(created.id, { name: "Persisted v3" });
await store.recordHeartbeat(created.id, "ok");
const revisionsPath = join(rootDir, "agents", `${created.id}-revisions.jsonl`);
const heartbeatsPath = join(rootDir, "agents", `${created.id}-heartbeats.jsonl`);
expect(existsSync(revisionsPath)).toBe(true);
expect(existsSync(heartbeatsPath)).toBe(true);
const revisionLines = readFileSync(revisionsPath, "utf-8").trim().split("\n").filter(Boolean);
expect(revisionLines).toHaveLength(2);
const parsedRevisions = revisionLines.map((line) => JSON.parse(line) as { agentId: string });
expect(parsedRevisions.every((line) => line.agentId === created.id)).toBe(true);
const heartbeatLines = readFileSync(heartbeatsPath, "utf-8").trim().split("\n").filter(Boolean);
expect(heartbeatLines.length).toBeGreaterThan(0);
const parsedHeartbeat = JSON.parse(heartbeatLines[0]) as { status: string };
expect(parsedHeartbeat.status).toBe("ok");
});
});
// ── deleteAgent ───────────────────────────────────────────────────
describe("deleteAgent", () => {

View File

@@ -4,11 +4,13 @@
* Agents are stored at `.fusion/agents/{agentId}.json` with their metadata.
* Heartbeat events are appended to `.fusion/agents/{agentId}-heartbeats.jsonl`.
* API keys are stored in `.fusion/agents/{agentId}-keys.jsonl` (hash-only).
* Config revisions are stored in `.fusion/agents/{agentId}-revisions.jsonl` (append-only snapshots).
*
* File Structure:
* - agents/{agentId}.json: Agent metadata (id, name, role, state, taskId, timestamps, metadata)
* - agents/{agentId}-heartbeats.jsonl: Append-only heartbeat events
* - agents/{agentId}-keys.jsonl: API key records with SHA-256 token hashes
* - agents/{agentId}-revisions.jsonl: Config revision history
*/
import { mkdir, readFile, writeFile, readdir, unlink } from "node:fs/promises";
@@ -28,8 +30,10 @@ import type {
AgentHeartbeatRun,
AgentDetail,
AgentTaskSession,
AgentConfigRevision,
AgentConfigSnapshot,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS } from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
/** Events emitted by AgentStore */
export interface AgentStoreEvents {
@@ -43,6 +47,8 @@ export interface AgentStoreEvents {
"agent:heartbeat": (agentId: string, event: AgentHeartbeatEvent) => void;
/** Emitted when an agent state changes */
"agent:stateChanged": (agentId: string, from: AgentState, to: AgentState) => void;
/** Emitted when a config revision is recorded */
"agent:configRevision": (agentId: string, revision: AgentConfigRevision) => void;
/** Emitted when a task is assigned to an agent (taskId is non-empty) */
"agent:assigned": (agent: Agent, taskId: string) => void;
}
@@ -212,12 +218,15 @@ export class AgentStore extends EventEmitter {
throw new Error("Agent name cannot be empty");
}
const beforeSnapshot = agentToConfigSnapshot(agent);
const updatedAt = new Date().toISOString();
const updated: Agent = {
...agent,
name: nextName ?? agent.name,
role: updates.role ?? agent.role,
metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata,
updatedAt: new Date().toISOString(),
updatedAt,
...("title" in updates && { title: updates.title }),
...("icon" in updates && { icon: updates.icon }),
...("reportsTo" in updates && { reportsTo: updates.reportsTo }),
@@ -232,12 +241,106 @@ export class AgentStore extends EventEmitter {
};
await this.writeAgent(updated);
const afterSnapshot = agentToConfigSnapshot(updated);
const diffs = diffConfigSnapshots(beforeSnapshot, afterSnapshot);
if (diffs.length > 0) {
const revision = this.createConfigRevision({
agentId,
before: beforeSnapshot,
after: afterSnapshot,
diffs,
source: "user",
createdAt: updatedAt,
});
await this.appendConfigRevision(revision);
this.emit("agent:configRevision", agentId, revision);
}
this.emit("agent:updated", updated);
return updated;
});
}
/**
* Get config revision history for an agent (most recent first).
*/
async getConfigRevisions(agentId: string, limit?: number): Promise<AgentConfigRevision[]> {
const revisions = await this.readConfigRevisions(agentId);
const ordered = revisions.reverse();
if (limit === undefined) {
return ordered;
}
const normalizedLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0;
return ordered.slice(0, normalizedLimit);
}
/**
* Get a specific config revision for an agent.
*/
async getConfigRevision(agentId: string, revisionId: string): Promise<AgentConfigRevision | null> {
const revisions = await this.readConfigRevisions(agentId);
return revisions.find((revision) => revision.id === revisionId) ?? null;
}
/**
* Roll back agent to a previous configuration revision.
*/
async rollbackConfig(agentId: string, revisionId: string): Promise<{ agent: Agent; revision: AgentConfigRevision }> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const targetRevision = await this.getConfigRevision(agentId, revisionId);
if (!targetRevision) {
const revisionOwner = await this.findConfigRevisionAcrossAgents(revisionId);
if (revisionOwner && revisionOwner.agentId !== agentId) {
throw new Error(`Config revision ${revisionId} belongs to agent ${revisionOwner.agentId}`);
}
throw new Error(`Config revision ${revisionId} not found for agent ${agentId}`);
}
if (targetRevision.agentId !== agentId) {
throw new Error(`Config revision ${revisionId} belongs to agent ${targetRevision.agentId}`);
}
const beforeSnapshot = agentToConfigSnapshot(agent);
const updatedAt = new Date().toISOString();
const restoredAgent: Agent = {
...agent,
...this.snapshotToAgentConfig(targetRevision.before),
updatedAt,
};
await this.writeAgent(restoredAgent);
const rollbackRevision = this.createConfigRevision({
agentId,
before: beforeSnapshot,
after: agentToConfigSnapshot(restoredAgent),
source: "rollback",
rollbackToRevisionId: revisionId,
createdAt: updatedAt,
});
await this.appendConfigRevision(rollbackRevision);
this.emit("agent:updated", restoredAgent);
this.emit("agent:configRevision", agentId, rollbackRevision);
return {
agent: restoredAgent,
revision: rollbackRevision,
};
});
}
/**
* Update an agent's state with validation.
* @param agentId - The agent ID
@@ -363,7 +466,7 @@ export class AgentStore extends EventEmitter {
*/
async listAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats") && !f.includes("-sessions") && !f.includes("-runs"));
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats") && !f.includes("-sessions") && !f.includes("-runs") && !f.includes("-revisions"));
const agents: Agent[] = [];
for (const file of agentFiles) {
@@ -471,6 +574,7 @@ export class AgentStore extends EventEmitter {
await this.withLock(agentId, async () => {
const agentPath = join(this.agentsDir, `${agentId}.json`);
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
const revisionsPath = this.getConfigRevisionsPath(agentId);
// Verify agent exists
const agent = await this.getAgent(agentId);
@@ -481,6 +585,7 @@ export class AgentStore extends EventEmitter {
// Delete files
await unlink(agentPath).catch(() => {});
await unlink(heartbeatPath).catch(() => {});
await unlink(revisionsPath).catch(() => {});
// Clean up sessions and runs directories
const { rm } = await import("node:fs/promises");
@@ -835,6 +940,125 @@ export class AgentStore extends EventEmitter {
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private getConfigRevisionsPath(agentId: string): string {
return join(this.agentsDir, `${agentId}-revisions.jsonl`);
}
private async appendConfigRevision(revision: AgentConfigRevision): Promise<void> {
const revisionsPath = this.getConfigRevisionsPath(revision.agentId);
await writeFile(revisionsPath, `${JSON.stringify(revision)}\n`, { flag: "a" });
}
private async readConfigRevisions(agentId: string): Promise<AgentConfigRevision[]> {
const revisionsPath = this.getConfigRevisionsPath(agentId);
if (!existsSync(revisionsPath)) {
return [];
}
try {
const content = await readFile(revisionsPath, "utf-8");
if (!content.trim()) {
return [];
}
const revisions: AgentConfigRevision[] = [];
const lines = content.split("\n").filter(Boolean);
for (const line of lines) {
try {
const revision = JSON.parse(line) as AgentConfigRevision;
if (revision.agentId === agentId) {
revisions.push(revision);
}
} catch {
// Skip malformed lines
}
}
return revisions;
} catch {
return [];
}
}
private createConfigRevision(params: {
agentId: string;
before: AgentConfigSnapshot;
after: AgentConfigSnapshot;
source: AgentConfigRevision["source"];
createdAt?: string;
rollbackToRevisionId?: string;
diffs?: AgentConfigRevision["diffs"];
}): AgentConfigRevision {
const diffs = params.diffs ?? diffConfigSnapshots(params.before, params.after);
const changedFields = diffs.map((diff) => diff.field).join(", ");
const summary =
params.source === "rollback"
? diffs.length > 0
? `Rolled back config fields: ${changedFields}`
: `Rolled back to revision ${params.rollbackToRevisionId ?? "unknown"}`
: diffs.length > 0
? `Updated ${changedFields}`
: "No config changes";
return {
id: `revision-${randomUUID().slice(0, 8)}`,
agentId: params.agentId,
createdAt: params.createdAt ?? new Date().toISOString(),
before: params.before,
after: params.after,
diffs,
summary,
source: params.source,
...(params.rollbackToRevisionId ? { rollbackToRevisionId: params.rollbackToRevisionId } : {}),
};
}
private snapshotToAgentConfig(
snapshot: AgentConfigSnapshot,
): Pick<
Agent,
| "name"
| "role"
| "title"
| "icon"
| "reportsTo"
| "runtimeConfig"
| "permissions"
| "instructionsPath"
| "instructionsText"
| "metadata"
> {
return {
name: snapshot.name,
role: snapshot.role,
title: snapshot.title,
icon: snapshot.icon,
reportsTo: snapshot.reportsTo,
runtimeConfig: snapshot.runtimeConfig ? { ...snapshot.runtimeConfig } : undefined,
permissions: snapshot.permissions ? { ...snapshot.permissions } : undefined,
instructionsPath: snapshot.instructionsPath,
instructionsText: snapshot.instructionsText,
metadata: { ...snapshot.metadata },
};
}
private async findConfigRevisionAcrossAgents(revisionId: string): Promise<AgentConfigRevision | null> {
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
const revisionFiles = files.filter((file) => file.endsWith("-revisions.jsonl"));
for (const file of revisionFiles) {
const agentId = file.replace(/-revisions\.jsonl$/, "");
const revisions = await this.readConfigRevisions(agentId);
const match = revisions.find((revision) => revision.id === revisionId);
if (match) {
return match;
}
}
return null;
}
private getApiKeysPath(agentId: string): string {
return join(this.agentsDir, `${agentId}-keys.jsonl`);
}

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -1641,6 +1641,98 @@ export interface AgentTaskSession {
updatedAt: string;
}
/** Trackable configuration fields for revision history.
* Excludes budget-related items, state, taskId, token counts, and timestamps. */
export interface AgentConfigSnapshot {
name: string;
role: AgentCapability;
title?: string;
icon?: string;
reportsTo?: string;
runtimeConfig?: Record<string, unknown>;
permissions?: Record<string, boolean>;
instructionsPath?: string;
instructionsText?: string;
metadata: Record<string, unknown>;
}
/** A single key-value change within a config revision */
export interface RevisionFieldDiff {
field: string;
oldValue: unknown;
newValue: unknown;
}
/** A revision entry recording a configuration change to an agent */
export interface AgentConfigRevision {
/** Unique revision identifier */
id: string;
/** Agent ID this revision belongs to */
agentId: string;
/** ISO-8601 timestamp when the revision was created */
createdAt: string;
/** Snapshot of config BEFORE the change */
before: AgentConfigSnapshot;
/** Snapshot of config AFTER the change */
after: AgentConfigSnapshot;
/** Field-level diffs between before and after */
diffs: RevisionFieldDiff[];
/** Description of what changed (e.g., "Updated runtimeConfig, name") */
summary: string;
/** Who or what triggered the change */
source: "user" | "system" | "rollback";
/** If this was a rollback, the revision ID that was restored */
rollbackToRevisionId?: string;
}
/** Extract trackable config fields from an Agent into a snapshot */
export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
return {
name: agent.name,
role: agent.role,
title: agent.title,
icon: agent.icon,
reportsTo: agent.reportsTo,
runtimeConfig: agent.runtimeConfig ? { ...agent.runtimeConfig } : undefined,
permissions: agent.permissions ? { ...agent.permissions } : undefined,
instructionsPath: agent.instructionsPath,
instructionsText: agent.instructionsText,
metadata: { ...agent.metadata },
};
}
/** Compare two config snapshots and return field-level diffs */
export function diffConfigSnapshots(
before: AgentConfigSnapshot,
after: AgentConfigSnapshot,
): RevisionFieldDiff[] {
const trackedFields: Array<keyof AgentConfigSnapshot> = [
"name",
"role",
"title",
"icon",
"reportsTo",
"runtimeConfig",
"permissions",
"instructionsPath",
"instructionsText",
"metadata",
];
const diffs: RevisionFieldDiff[] = [];
for (const field of trackedFields) {
const oldVal = before[field];
const newVal = after[field];
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
diffs.push({ field, oldValue: oldVal, newValue: newVal });
}
}
return diffs;
}
/** Aggregate statistics for agents */
export interface AgentStats {
/** Number of agents in active/running state */