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:
@@ -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", () => {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
212
packages/dashboard/src/__tests__/routes-agent-revisions.test.ts
Normal file
212
packages/dashboard/src/__tests__/routes-agent-revisions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user