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 */

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request } from "../test-request.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockGetAgent = vi.fn();
const mockGetConfigRevisions = vi.fn();
const mockGetConfigRevision = vi.fn();
const mockRollbackConfig = vi.fn();
const mockListAgents = vi.fn().mockResolvedValue([]);
vi.mock("@fusion/core", () => {
return {
AgentStore: class MockAgentStore {
init = mockInit;
getAgent = mockGetAgent;
getConfigRevisions = mockGetConfigRevisions;
getConfigRevision = mockGetConfigRevision;
rollbackConfig = mockRollbackConfig;
listAgents = mockListAgents;
},
};
});
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1120-test";
}
getFusionDir(): string {
return "/tmp/fn-1120-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
function createMockAgent(id = "agent-001") {
return {
id,
name: "Test Agent",
role: "executor",
state: "idle",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
function createMockRevision(overrides: Record<string, unknown> = {}) {
return {
id: "revision-001",
agentId: "agent-001",
createdAt: "2026-01-01T00:00:00.000Z",
before: {
name: "Agent v1",
role: "executor",
metadata: {},
},
after: {
name: "Agent v2",
role: "executor",
metadata: {},
},
diffs: [{ field: "name", oldValue: "Agent v1", newValue: "Agent v2" }],
summary: "Updated name",
source: "user",
...overrides,
};
}
describe("Agent config revision routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
beforeEach(async () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/agents/:id/config-revisions", () => {
it("returns revision array", async () => {
mockGetAgent.mockResolvedValue(createMockAgent());
mockGetConfigRevisions.mockResolvedValue([createMockRevision()]);
const response = await request(app, "GET", "/api/agents/agent-001/config-revisions");
expect(response.status).toBe(200);
expect(response.body).toEqual([expect.objectContaining({ id: "revision-001" })]);
expect(mockGetConfigRevisions).toHaveBeenCalledWith("agent-001", 50);
});
it("returns 404 for non-existent agent", async () => {
mockGetAgent.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-404/config-revisions");
expect(response.status).toBe(404);
expect((response.body as any).error).toBe("Agent not found");
});
it("passes limit query parameter correctly", async () => {
mockGetAgent.mockResolvedValue(createMockAgent());
mockGetConfigRevisions.mockResolvedValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/config-revisions?limit=5");
expect(response.status).toBe(200);
expect(mockGetConfigRevisions).toHaveBeenCalledWith("agent-001", 5);
});
});
describe("GET /api/agents/:id/config-revisions/:revisionId", () => {
it("returns a single revision", async () => {
mockGetAgent.mockResolvedValue(createMockAgent());
mockGetConfigRevision.mockResolvedValue(createMockRevision());
const response = await request(app, "GET", "/api/agents/agent-001/config-revisions/revision-001");
expect(response.status).toBe(200);
expect((response.body as any).id).toBe("revision-001");
expect(mockGetConfigRevision).toHaveBeenCalledWith("agent-001", "revision-001");
});
it("returns 404 for non-existent revision", async () => {
mockGetAgent.mockResolvedValue(createMockAgent());
mockGetConfigRevision.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-001/config-revisions/revision-missing");
expect(response.status).toBe(404);
expect((response.body as any).error).toBe("Config revision not found");
});
it("returns 404 for non-existent agent", async () => {
mockGetAgent.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-404/config-revisions/revision-001");
expect(response.status).toBe(404);
expect((response.body as any).error).toBe("Agent not found");
});
});
describe("POST /api/agents/:id/config-revisions/:revisionId/rollback", () => {
it("returns { agent, revision } on successful rollback", async () => {
const rollbackRevision = createMockRevision({
id: "revision-rollback",
source: "rollback",
rollbackToRevisionId: "revision-001",
});
mockGetAgent.mockResolvedValue(createMockAgent());
mockRollbackConfig.mockResolvedValue({
agent: { ...createMockAgent(), name: "Agent v1" },
revision: rollbackRevision,
});
const response = await request(app, "POST", "/api/agents/agent-001/config-revisions/revision-001/rollback");
expect(response.status).toBe(200);
expect((response.body as any).agent.name).toBe("Agent v1");
expect((response.body as any).revision.source).toBe("rollback");
expect(mockRollbackConfig).toHaveBeenCalledWith("agent-001", "revision-001");
});
it("returns 404 for non-existent agent", async () => {
mockGetAgent.mockResolvedValue(null);
const response = await request(app, "POST", "/api/agents/agent-404/config-revisions/revision-001/rollback");
expect(response.status).toBe(404);
expect((response.body as any).error).toBe("Agent not found");
});
it("returns 404 for non-existent revision", async () => {
mockGetAgent.mockResolvedValue(createMockAgent());
mockRollbackConfig.mockRejectedValue(new Error("Config revision revision-missing not found for agent agent-001"));
const response = await request(app, "POST", "/api/agents/agent-001/config-revisions/revision-missing/rollback");
expect(response.status).toBe(404);
expect((response.body as any).error).toContain("not found");
});
it("returns 400 when revision belongs to a different agent", async () => {
mockGetAgent.mockResolvedValue(createMockAgent());
mockRollbackConfig.mockRejectedValue(new Error("Config revision revision-002 belongs to agent agent-002"));
const response = await request(app, "POST", "/api/agents/agent-001/config-revisions/revision-002/rollback");
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("belongs to agent");
});
});
});

View File

@@ -7379,6 +7379,97 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* GET /api/agents/:id/config-revisions
* List config revisions for an agent.
* Query: limit (default: 50)
*/
router.get("/agents/:id/config-revisions", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
const rawLimit = req.query.limit;
const limit = rawLimit === undefined ? 50 : Number.parseInt(String(rawLimit), 10);
if (!Number.isInteger(limit) || limit <= 0) {
res.status(400).json({ error: "limit must be a positive integer" });
return;
}
const revisions = await agentStore.getConfigRevisions(req.params.id, limit);
res.json(revisions);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/agents/:id/config-revisions/:revisionId
* Get a specific config revision for an agent.
*/
router.get("/agents/:id/config-revisions/:revisionId", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
const revision = await agentStore.getConfigRevision(req.params.id, req.params.revisionId);
if (!revision) {
res.status(404).json({ error: "Config revision not found" });
return;
}
res.json(revision);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/agents/:id/config-revisions/:revisionId/rollback
* Roll back an agent to a previous config revision.
*/
router.post("/agents/:id/config-revisions/:revisionId/rollback", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
const result = await agentStore.rollbackConfig(req.params.id, req.params.revisionId);
res.json(result);
} catch (err: any) {
if (err.message?.includes("belongs to agent")) {
res.status(400).json({ error: err.message });
} else if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message });
}
}
});
/**
* POST /api/agents/:id/keys
* Create a new API key for an agent.