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 */
|
||||
|
||||
Reference in New Issue
Block a user