/** * AgentStore - SQLite-backed persistence for agent lifecycle management. * * Agent records, heartbeat events, runs, task sessions, API keys, config * revisions, and blocked-state snapshots are stored in `.fusion/fusion.db`. * Managed instruction bundle markdown files remain on disk because they are * edited as normal project files. */ import { mkdir, readFile, writeFile, readdir, unlink, rename, access, appendFile } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { randomUUID, randomBytes, createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import type { Agent, AgentState, AgentCapability, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, AgentDetail, AgentBudgetConfig, AgentBudgetStatus, AgentTaskSession, AgentConfigRevision, AgentConfigSnapshot, AgentAccessState, AgentPermissionPolicy, OrgTreeNode, InstructionsBundleConfig, AgentRating, AgentRatingSummary, AgentRatingInput, Task, AgentLogEntry, } from "./types.js"; import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, getCanonicalAgentInstructionsBundleDirName, getLegacyAgentAssetDirectoryName, getLegacyAgentInstructionsBundleDirName, getSafeAgentAssetIdSegment, } from "./types.js"; import type { CentralClaimStore, CheckoutClaimContext, RunMutationContext } from "./types.js"; import type { TaskStore } from "./store.js"; import { computeAccessState } from "./agent-permissions.js"; import { canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, formatRoleMismatchReason } from "./agent-role-policy.js"; import { normalizeAgentPermissionPolicy, resolveEffectiveAgentPermissionPolicy } from "./agent-permission-policy.js"; import { Database } from "./db.js"; import { createAgentRunSnapshot, createAgentSnapshot, validateSnapshotEnvelope, type AgentRunSnapshot, type AgentSnapshot } from "./shared-mesh-state.js"; /** Database row shape returned by SELECT on agentRatings. */ interface AgentRatingRow { id: string; agentId: string; raterType: string; raterId: string | null; score: number; category: string | null; comment: string | null; runId: string | null; taskId: string | null; createdAt: string; } /** Events emitted by AgentStore */ export interface AgentStoreEvents { /** Emitted when an agent is created */ "agent:created": (agent: Agent) => void; /** Emitted when an agent is updated */ "agent:updated": (agent: Agent, previousState?: AgentState) => void; /** Emitted when an agent is deleted */ "agent:deleted": (agentId: string) => void; /** Emitted when a heartbeat is recorded */ "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; /** Emitted when a rating is added */ "rating:added": (rating: AgentRating) => void; /** Emitted when a log entry is appended to a run's JSONL log. */ "run:log": (agentId: string, runId: string, entry: AgentLogEntry) => void; } /** Options for AgentStore constructor */ export interface AgentStoreOptions { /** Root directory for kb data (default: .fusion) */ rootDir?: string; /** Optional TaskStore for checkout/release operations */ taskStore?: TaskStore; /** Optional authoritative central claim store for cross-node checkout mutex. */ claimStore?: CentralClaimStore; /** Project ID for central claim ownership rows (required when claimStore is set). */ projectId?: string; /** Optional default nodeId when checkout leaseContext omits one. */ nodeId?: string; /** * Test-only: open the underlying SQLite DB as `:memory:` instead of a * disk-backed file. Skips per-test fsync and WAL setup; mirrors the * pattern in TaskStore. Production callers must leave this unset. */ inMemoryDb?: boolean; } /** Agent data as stored in SQLite JSON columns */ interface AgentData { id: string; name: string; role: AgentCapability; state: AgentState; taskId?: string; createdAt: string; updatedAt: string; lastHeartbeatAt?: string; metadata: Record; title?: string; icon?: string; imageUrl?: string; reportsTo?: string; runtimeConfig?: Record; pauseReason?: string; permissions?: Record; permissionPolicy?: AgentPermissionPolicy; totalInputTokens?: number; totalOutputTokens?: number; lastError?: string; instructionsPath?: string; instructionsText?: string; soul?: string; memory?: string; bundleConfig?: InstructionsBundleConfig; heartbeatProcedurePath?: string; } interface AgentRow { id: string; name: string; role: AgentCapability; state: AgentState; taskId: string | null; createdAt: string; updatedAt: string; lastHeartbeatAt: string | null; metadata: string | null; data: string | null; } interface AgentLock { promise: Promise; } /** * Default recurring heartbeat interval (1 hour). The engine's * HeartbeatTriggerScheduler already falls back to this value when an agent * has no explicit interval, but we also write it onto non-ephemeral agents at * creation time so the persisted config mirrors the effective schedule. */ export const DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS = 3_600_000; /** * Compute the runtimeConfig to persist for a newly created agent. * * - Ephemeral/task-worker agents (see {@link isEphemeralAgent}) keep whatever * the caller supplied — task workers explicitly opt out of heartbeats via * `runtimeConfig.enabled: false` and must not get a timer reintroduced. * - Every other agent has `heartbeatIntervalMs` filled in with the default * when the caller omitted it, matching the scheduler's fallback behavior. * * Returns `undefined` when there's nothing to persist (only possible for * ephemeral agents with no incoming config). */ function resolveCreationRuntimeConfig( incoming: Record | undefined, metadata: Record, ): Record | undefined { const isEphemeral = isEphemeralAgent({ metadata }); if (isEphemeral) { return incoming; } const rc: Record = { ...(incoming ?? {}) }; if (typeof rc.enabled !== "boolean") { rc.enabled = true; } if (typeof rc.autoClaimRelevantTasks !== "boolean") { rc.autoClaimRelevantTasks = true; } if (typeof rc.heartbeatIntervalMs !== "number" || !Number.isFinite(rc.heartbeatIntervalMs)) { rc.heartbeatIntervalMs = DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS; } return rc; } /** * Process-wide cache of initialized Database connections, keyed by absolute * rootDir. Without this, callers that re-instantiate AgentStore per request * (the dashboard does this in ~65 places) reopen the SQLite file, re-run the * full schema migration, and re-execute `PRAGMA integrity_check` (a full table * scan) every time — on a multi-hundred-MB DB that's seconds per request and * leaks file handles. Sharing one Database object reduces all of that to a * one-time cost per process. * * In-memory DBs are intentionally *not* cached: they're test-only and each * test wants its own isolated `:memory:` connection. */ const agentStoreDbCache = new Map(); /** * AgentStore manages agent lifecycle with SQLite-backed persistence. * Follows the same patterns as TaskStore for consistency. */ export class AgentStore extends EventEmitter { private rootDir: string; private agentsDir: string; private locks: Map = new Map(); private _db: Database | null = null; private taskStore?: TaskStore; private readonly claimStore?: CentralClaimStore; private readonly claimProjectId?: string; private readonly defaultNodeId?: string; private readonly inMemoryDb: boolean; constructor(options: AgentStoreOptions = {}) { super(); if (!options.rootDir && process.env.VITEST === "true") { throw new Error( "AgentStore requires an explicit rootDir during test execution. Pass an absolute path to avoid writing to unintended locations.", ); } this.rootDir = options.rootDir ?? resolve(".fusion"); this.agentsDir = join(this.rootDir, "agents"); this.taskStore = options.taskStore; this.claimStore = options.claimStore; this.claimProjectId = options.projectId; this.defaultNodeId = options.nodeId; if (this.claimStore && !this.claimProjectId) { throw new Error("AgentStore requires projectId when claimStore is configured"); } this.inMemoryDb = options.inMemoryDb === true; } private get db(): Database { if (this._db) return this._db; if (this.inMemoryDb) { this._db = new Database(this.rootDir, { inMemory: true }); this._db.init(); return this._db; } const cached = agentStoreDbCache.get(this.rootDir); if (cached) { this._db = cached; return cached; } const fresh = new Database(this.rootDir, { inMemory: false }); fresh.init(); agentStoreDbCache.set(this.rootDir, fresh); this._db = fresh; return fresh; } /** * Initialize the store by creating necessary directories. * Should be called before other operations. */ async init(): Promise { void this.db; await mkdir(this.agentsDir, { recursive: true }); await this.importLegacyFileDataOnce(); await this.migrateTerminatedAgentStateOnce(); await this.migrateHeartbeatProcedurePathOnce(); } /** * One-shot migration that re-points every non-ephemeral agent off the * legacy shared `.fusion/HEARTBEAT.md` path onto their own per-agent * `.fusion/agents//HEARTBEAT.md` file. The legacy file's contents * are copied to the new location when present so operator edits are * preserved across the upgrade. The legacy file itself is left in place * — the migration is non-destructive in case the operator wants a * reference copy. * * Idempotent: tracks completion in the `__meta` table and short-circuits * on subsequent calls. Failures during file copy are logged via the * legacy console (no log dependency in core) and do not block startup — * the agent's `heartbeatProcedurePath` is still flipped, and the engine's * heartbeat resolver will fall back to the built-in template until the * file is seeded on next dashboard interaction. */ private async migrateHeartbeatProcedurePathOnce(): Promise { const migrationKey = "heartbeatProcedurePathPerAgent"; const migrationVersion = "1"; const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as | { value: string } | undefined; if (row?.value === migrationVersion) { return; } // The legacy shared file lives at /.fusion/HEARTBEAT.md. // `this.rootDir` is already `/.fusion`, so the file is just // "HEARTBEAT.md" relative to it. let legacyContent: string | null = null; try { legacyContent = await readFile(join(this.rootDir, "HEARTBEAT.md"), "utf-8"); } catch { legacyContent = null; } const agents = await this.listAgents({ includeEphemeral: false }); let migratedCount = 0; for (const agent of agents) { if (agent.heartbeatProcedurePath !== DEFAULT_HEARTBEAT_PROCEDURE_PATH) { continue; } const newRelPath = await this.resolveCompatibleHeartbeatProcedurePath(agent); const newAbsPath = join(this.rootDir, "..", newRelPath); // Best-effort copy of operator edits to the new per-agent location. // Skip the write when the per-agent file already exists (someone // could have set this up manually) so we never clobber it. if (legacyContent !== null) { try { await mkdir(dirname(newAbsPath), { recursive: true }); // Only seed the file if it doesn't already exist. try { await access(newAbsPath, fsConstants.F_OK); } catch { await writeFile(newAbsPath, legacyContent, "utf-8"); } } catch { // Non-fatal — proceed with path flip even if the file copy failed. } } const updated: Agent = { ...agent, heartbeatProcedurePath: newRelPath, updatedAt: new Date().toISOString(), }; await this.writeAgent(updated); migratedCount += 1; } this.db.prepare(` INSERT INTO __meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `).run(migrationKey, migrationVersion); if (migratedCount > 0) { this.db.bumpLastModified(); } } /** * One-way migration helper for projects that still have legacy agent JSON * files. Runtime code does not read these files after startup migration. */ async importLegacyFileAgents(): Promise { const files = await readdir(this.agentsDir).catch(() => [] as string[]); const agentFiles = files.filter((file) => file.endsWith(".json") && !file.includes("-heartbeats") && !file.includes("-sessions") && !file.includes("-runs") && !file.includes("-revisions") && !file.includes("-last-blocked") ); let imported = 0; for (const file of agentFiles) { const agentId = file.replace(/\.json$/, ""); const existing = await this.getAgent(agentId); if (existing) { continue; } try { const content = await readFile(join(this.agentsDir, file), "utf-8"); const agent = this.parseAgent(JSON.parse(content) as AgentData); await this.writeAgent(agent); imported += 1; } catch { // Legacy files may be partially written or manually edited; ignore them. } } return imported; } /** * One-way migration helper for legacy structured run JSON files. * Runtime reads come from SQLite; this only seeds old projects. */ async importLegacyFileRuns(): Promise { const entries = await readdir(this.agentsDir, { withFileTypes: true }).catch(() => []); const runDirs = entries.filter((entry) => entry.isDirectory() && entry.name.endsWith("-runs")); let imported = 0; for (const dir of runDirs) { const agentId = dir.name.replace(/-runs$/, ""); const runDir = join(this.agentsDir, dir.name); const runFiles = await readdir(runDir).catch(() => [] as string[]); for (const file of runFiles) { if (!file.endsWith(".json")) { continue; } try { const content = await readFile(join(runDir, file), "utf-8"); const run = JSON.parse(content) as Partial; if ( typeof run.id !== "string" || typeof run.startedAt !== "string" || !["active", "completed", "terminated", "failed"].includes(String(run.status)) ) { continue; } const normalizedRun: AgentHeartbeatRun = { id: run.id, agentId: typeof run.agentId === "string" ? run.agentId : agentId, startedAt: run.startedAt, endedAt: typeof run.endedAt === "string" ? run.endedAt : null, status: run.status as AgentHeartbeatRun["status"], invocationSource: run.invocationSource, triggerDetail: run.triggerDetail, processPid: run.processPid, exitCode: run.exitCode, sessionIdBefore: run.sessionIdBefore, sessionIdAfter: run.sessionIdAfter, usageJson: run.usageJson, resultJson: run.resultJson, contextSnapshot: run.contextSnapshot, stdoutExcerpt: run.stdoutExcerpt, stderrExcerpt: run.stderrExcerpt, }; const result = this.db.prepare(` INSERT OR IGNORE INTO agentRuns (id, agentId, data, startedAt, endedAt, status) VALUES (?, ?, ?, ?, ?, ?) `).run( normalizedRun.id, normalizedRun.agentId, JSON.stringify(normalizedRun), normalizedRun.startedAt, normalizedRun.endedAt, normalizedRun.status, ); imported += Number(result.changes); } catch { // Legacy run files may be partially written or manually edited; ignore them. } } } if (imported > 0) { this.db.bumpLastModified(); } return imported; } private async importLegacyFileDataOnce(): Promise { const migrationKey = "agentLegacyFileImportVersion"; const migrationVersion = "2"; const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as | { value: string } | undefined; if (row?.value === migrationVersion) { return; } await this.importLegacyFileAgents(); await this.importLegacyFileRuns(); this.db.prepare(` INSERT INTO __meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `).run(migrationKey, migrationVersion); this.db.bumpLastModified(); } /** * One-shot migration that rewrites legacy `state = "terminated"` agents to * `state = "paused"` and preserves the origin via * `pauseReason = "migrated-from-terminated"`. * * Heartbeat run rows intentionally keep their independent `terminated` * terminal status; this migration only normalizes the agent lifecycle state. */ private async migrateTerminatedAgentStateOnce(): Promise { const migrationKey = "removeTerminatedAgentState"; const migrationVersion = "1"; const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as | { value: string } | undefined; if (row?.value === migrationVersion) { return; } const rows = this.db.prepare("SELECT * FROM agents WHERE state = 'terminated'").all() as unknown as AgentRow[]; let migratedCount = 0; for (const row of rows) { const agent = this.mapAgentRow(row); const updated: Agent = { ...agent, state: "paused", pauseReason: "migrated-from-terminated", updatedAt: new Date().toISOString(), }; await this.writeAgent(updated); migratedCount += 1; } this.db.prepare(` INSERT INTO __meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `).run(migrationKey, migrationVersion); if (migratedCount > 0) { this.db.bumpLastModified(); } } /** * Find the first non-ephemeral agent by exact name. * * Ephemeral task-worker/spawned agents are excluded so callers can use this * for durable identity checks without transient runtime workers conflicting. * * @param name - Agent name to match exactly * @returns Matching non-ephemeral agent, or null when none exists */ async findAgentByName(name: string): Promise { const rows = this.db .prepare("SELECT * FROM agents WHERE name = ? ORDER BY createdAt DESC") .all(name) as unknown as AgentRow[]; for (const row of rows) { const agent = this.mapAgentRow(row); if (!isEphemeralAgent(agent)) { return agent; } } return null; } async hasNonEphemeralAgentWithName(name: string): Promise { const normalizedName = name.trim(); if (!normalizedName) { return false; } const existing = await this.findAgentByName(normalizedName); return existing !== null; } /** * Create a new agent with a default state based on ephemeral classification. * * For non-ephemeral agents, ensures `runtimeConfig.heartbeatIntervalMs` is * persisted at creation time — previously it was only ever written when the * user interacted with the dashboard dropdown, so agents created and never * touched would end up with no interval on disk. That made the dashboard's * freshness check behave inconsistently between agents that had been * configured and agents that hadn't, even though the scheduler applied the * same default (1h) to both at runtime. Writing the default explicitly * removes that divergence and keeps the persisted config truthful. * * Also enforces non-ephemeral name uniqueness: durable agents cannot share a * name, while ephemeral task-worker agents are allowed to duplicate names. * * @param input - Creation parameters * @returns The created agent * @throws Error if input is invalid or a duplicate non-ephemeral name exists */ async createAgent(input: AgentCreateInput): Promise { if (!input.name?.trim()) { throw new Error("Agent name is required"); } if (!input.role) { throw new Error("Agent role is required"); } const normalizedName = input.name.trim(); const metadata = input.metadata ?? {}; const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo }); if (!ephemeral) { const existing = await this.findAgentByName(normalizedName); if (existing) { throw new Error(`Agent with name "${normalizedName}" already exists (agentId: ${existing.id})`); } } const now = new Date().toISOString(); const agentId = `agent-${randomUUID().slice(0, 8)}`; const runtimeConfig = resolveCreationRuntimeConfig(input.runtimeConfig, metadata); // Default heartbeatProcedurePath for new non-ephemeral agents so operators // get an editable HEARTBEAT.md file from day one. Each agent gets its // own per-agent file (under `.fusion/agents//HEARTBEAT.md`) so // tweaks to one agent's procedure do not bleed into the rest of the // team. Ephemeral task workers skip this — they're short-lived and // don't need persistent procedure files. const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath ?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId, input.name)); const normalizedPermissionPolicy = ephemeral ? input.permissionPolicy : resolveEffectiveAgentPermissionPolicy(input.permissionPolicy); const agent: Agent = { id: agentId, name: normalizedName, role: input.role, // Non-ephemeral agents start active so they immediately participate in // heartbeat scheduling; ephemeral/task-worker agents start idle and are // activated by the engine when work is assigned. state: ephemeral ? "idle" : "active", createdAt: now, updatedAt: now, metadata, ...(input.title && { title: input.title }), ...(input.icon && { icon: input.icon }), ...(input.imageUrl && { imageUrl: input.imageUrl }), ...(input.reportsTo && { reportsTo: input.reportsTo }), ...(runtimeConfig && { runtimeConfig }), ...(input.permissions && { permissions: input.permissions }), ...(normalizedPermissionPolicy && { permissionPolicy: normalizedPermissionPolicy }), ...(input.instructionsPath && { instructionsPath: input.instructionsPath }), ...(input.instructionsText && { instructionsText: input.instructionsText }), ...(input.soul && { soul: input.soul }), ...(input.memory && { memory: input.memory }), ...(input.bundleConfig && { bundleConfig: input.bundleConfig }), ...(resolvedHeartbeatProcedurePath && { heartbeatProcedurePath: resolvedHeartbeatProcedurePath }), }; await this.writeAgent(agent); this.emit("agent:created", agent); return agent; } /** * Get an agent by ID. * @param agentId - The agent ID * @returns The agent, or null if not found */ async getAgent(agentId: string): Promise { return this.readAgent(agentId); } /** * Get computed access capabilities for an agent. * @param agentId - The agent ID * @returns Computed access state, or null if agent not found */ async getAccessState(agentId: string): Promise { const agent = await this.getAgent(agentId); if (!agent) { return null; } return computeAccessState(agent); } /** * Get computed budget usage status for an agent. * @param agentId - The agent ID * @returns Computed budget usage status * @throws Error if agent not found */ async getBudgetStatus(agentId: string): Promise { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const totalInputTokens = agent.totalInputTokens ?? 0; const totalOutputTokens = agent.totalOutputTokens ?? 0; const currentUsage = totalInputTokens + totalOutputTokens; const runtimeConfig = (agent.runtimeConfig ?? {}) as Record; const budgetConfig = runtimeConfig.budgetConfig as AgentBudgetConfig | undefined; const rawLastResetAt = runtimeConfig.budgetResetAt; const lastResetAt = typeof rawLastResetAt === "string" ? rawLastResetAt : null; if (!budgetConfig || budgetConfig.tokenBudget === undefined) { return { agentId, currentUsage, budgetLimit: null, usagePercent: null, thresholdPercent: null, isOverBudget: false, isOverThreshold: false, lastResetAt, nextResetAt: null, }; } const tokenBudget = budgetConfig.tokenBudget; const usagePercent = Math.min((currentUsage / tokenBudget) * 100, 100); const usageThreshold = budgetConfig.usageThreshold ?? 0.8; const thresholdPercent = usageThreshold * 100; return { agentId, currentUsage, budgetLimit: tokenBudget, usagePercent, thresholdPercent, isOverBudget: currentUsage >= tokenBudget, isOverThreshold: usagePercent >= thresholdPercent, lastResetAt, nextResetAt: this.computeNextResetAt(budgetConfig.budgetPeriod, budgetConfig.resetDay), }; } /** * Get detailed agent info including heartbeat history. * @param agentId - The agent ID * @param heartbeatLimit - Max number of heartbeat events to return (default: 50) * @returns Agent detail, or null if not found */ async getAgentDetail(agentId: string, heartbeatLimit = 50): Promise { const agent = await this.getAgent(agentId); if (!agent) return null; const [history, activeRun, completedRuns] = await Promise.all([ this.getHeartbeatHistory(agentId, heartbeatLimit), this.getActiveHeartbeatRun(agentId), this.getCompletedHeartbeatRuns(agentId), ]); return { ...agent, heartbeatHistory: history, activeRun: activeRun ?? undefined, completedRuns, }; } private mapRatingRow(row: AgentRatingRow): AgentRating { return { id: row.id, agentId: row.agentId, raterType: row.raterType as AgentRating["raterType"], raterId: row.raterId ?? undefined, score: row.score, category: row.category ?? undefined, comment: row.comment ?? undefined, runId: row.runId ?? undefined, taskId: row.taskId ?? undefined, createdAt: row.createdAt, }; } async addRating(agentId: string, input: AgentRatingInput): Promise { if (input.score < 1 || input.score > 5) { throw new Error("Rating score must be between 1 and 5"); } const rating: AgentRating = { id: `rating-${randomUUID().slice(0, 8)}`, agentId, raterType: input.raterType, raterId: input.raterId, score: input.score, category: input.category, comment: input.comment, runId: input.runId, taskId: input.taskId, createdAt: new Date().toISOString(), }; this.db.prepare(` INSERT INTO agentRatings (id, agentId, raterType, raterId, score, category, comment, runId, taskId, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( rating.id, rating.agentId, rating.raterType, rating.raterId ?? null, rating.score, rating.category ?? null, rating.comment ?? null, rating.runId ?? null, rating.taskId ?? null, rating.createdAt, ); this.db.bumpLastModified(); this.emit("rating:added", rating); return rating; } async getRatings(agentId: string, options?: { limit?: number; category?: string }): Promise { const params: Array = [agentId]; let query = "SELECT * FROM agentRatings WHERE agentId = ?"; if (options?.category !== undefined) { query += " AND category = ?"; params.push(options.category); } query += " ORDER BY createdAt DESC"; if (options?.limit !== undefined) { query += " LIMIT ?"; params.push(options.limit); } const rows = this.db.prepare(query).all(...params) as unknown as AgentRatingRow[]; return rows.map((row) => this.mapRatingRow(row)); } async getRatingSummary(agentId: string): Promise { const ratings = await this.getRatings(agentId); if (ratings.length === 0) { return { agentId, averageScore: 0, totalRatings: 0, categoryAverages: {}, recentRatings: [], trend: "insufficient-data", }; } const averageScore = Math.round((ratings.reduce((sum, rating) => sum + rating.score, 0) / ratings.length) * 100) / 100; const categoryBuckets = new Map(); for (const rating of ratings) { if (rating.category === undefined) { continue; } const existing = categoryBuckets.get(rating.category) ?? { total: 0, count: 0 }; existing.total += rating.score; existing.count += 1; categoryBuckets.set(rating.category, existing); } const categoryAverages: Record = {}; for (const [category, bucket] of categoryBuckets) { categoryAverages[category] = Math.round((bucket.total / bucket.count) * 100) / 100; } const recentRatings = ratings.slice(0, 10); let trend: AgentRatingSummary["trend"] = "insufficient-data"; if (ratings.length >= 10) { const recentWindow = ratings.slice(0, 5); const previousWindow = ratings.slice(5, 10); const recentAvg = recentWindow.reduce((sum, rating) => sum + rating.score, 0) / recentWindow.length; const previousAvg = previousWindow.reduce((sum, rating) => sum + rating.score, 0) / previousWindow.length; if (Math.abs(recentAvg - previousAvg) <= 0.01) { trend = "stable"; } else if (recentAvg > previousAvg) { trend = "improving"; } else { trend = "declining"; } } return { agentId, averageScore, totalRatings: ratings.length, categoryAverages, recentRatings, trend, }; } async deleteRating(ratingId: string): Promise { this.db.prepare("DELETE FROM agentRatings WHERE id = ?").run(ratingId); this.db.bumpLastModified(); } /** * Get the managed instructions directory path for an agent. * Does not create the directory. */ getInstructionsDir(agentId: string): string { const agent = this.readAgent(agentId); const agentName = agent?.name ?? ""; return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agentName, agentId)); } /** * List markdown files in an agent's managed instructions bundle. * Returns [] when the bundle directory does not exist. */ async listBundleFiles(agentId: string): Promise { const bundleDir = await this.resolveCompatibleBundleDir(agentId, false); try { const entries = await readdir(bundleDir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { return []; } throw err; } } /** * Read a markdown file from an agent's managed instructions bundle. */ async readBundleFile(agentId: string, filePath: string): Promise { this.validateBundleFilePath(filePath); const bundleDir = await this.resolveCompatibleBundleDir(agentId, false); const resolvedPath = join(bundleDir, filePath); return readFile(resolvedPath, "utf-8"); } /** * Write a markdown file to an agent's managed instructions bundle. */ async writeBundleFile(agentId: string, filePath: string, content: string): Promise { return this.withLock(agentId, async () => { this.validateBundleFilePath(filePath); const bundleDir = await this.resolveCompatibleBundleDir(agentId, true); await mkdir(bundleDir, { recursive: true }); const existingFiles = await this.listBundleFiles(agentId); const isOverwrite = existingFiles.includes(filePath); if (!isOverwrite && existingFiles.length >= 10) { throw new Error("Instruction bundles are limited to 10 markdown files"); } const resolvedPath = join(bundleDir, filePath); const tempPath = `${resolvedPath}.tmp.${Date.now()}`; await writeFile(tempPath, content, "utf-8"); await rename(tempPath, resolvedPath); }); } /** * Delete a markdown file from an agent's managed instructions bundle. */ async deleteBundleFile(agentId: string, filePath: string): Promise { return this.withLock(agentId, async () => { this.validateBundleFilePath(filePath); const bundleDir = await this.resolveCompatibleBundleDir(agentId, false); await unlink(join(bundleDir, filePath)); }); } /** * Set an agent's instructions bundle configuration. */ async setBundleConfig(agentId: string, config: InstructionsBundleConfig): Promise { const entryFile = config.entryFile?.trim(); if (!entryFile) { throw new Error("Bundle config entryFile is required"); } if (config.mode === "external" && !config.externalPath?.trim()) { throw new Error("Bundle config externalPath is required when mode is 'external'"); } const normalizedConfig: InstructionsBundleConfig = { ...config, entryFile, files: [...(config.files ?? [])], ...(config.externalPath !== undefined ? { externalPath: config.externalPath } : {}), }; const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig }); if (normalizedConfig.mode === "managed") { await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true }); } return updated; } /** * Migrate legacy instructionsText/instructionsPath fields into bundleConfig. */ async migrateLegacyInstructions(agentId: string): Promise { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } if (agent.bundleConfig) { return agent; } const entryFile = "AGENTS.md"; const hasInstructionsText = typeof agent.instructionsText === "string" && agent.instructionsText.length > 0; const hasInstructionsPath = typeof agent.instructionsPath === "string" && agent.instructionsPath.length > 0; if (!hasInstructionsText && !hasInstructionsPath) { return this.updateAgent(agentId, { bundleConfig: { mode: "managed", entryFile, files: [] }, }); } await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true }); const files: string[] = []; if (hasInstructionsText) { await this.writeBundleFile(agentId, entryFile, agent.instructionsText ?? ""); files.push(entryFile); } if (hasInstructionsPath) { const sourcePath = join(this.rootDir, agent.instructionsPath ?? ""); const sourceContent = await readFile(sourcePath, "utf-8"); if (hasInstructionsText) { const secondaryFile = basename(agent.instructionsPath ?? ""); await this.writeBundleFile(agentId, secondaryFile, sourceContent); if (!files.includes(secondaryFile)) { files.push(secondaryFile); } } else { await this.writeBundleFile(agentId, entryFile, sourceContent); files.push(entryFile); } } return this.updateAgent(agentId, { instructionsPath: undefined, instructionsText: undefined, bundleConfig: { mode: "managed", entryFile, files, }, }); } /** * Update an agent with partial updates. * @param agentId - The agent ID * @param updates - Fields to update * @returns The updated agent * @throws Error if agent not found */ async updateAgent(agentId: string, updates: AgentUpdateInput): Promise { return this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const nextName = "name" in updates && typeof updates.name === "string" ? updates.name.trim() : undefined; if (nextName !== undefined && !nextName) { throw new Error("Agent name cannot be empty"); } const beforeSnapshot = agentToConfigSnapshot(agent); const updatedAt = new Date().toISOString(); const normalizedUpdatedPermissionPolicy = "permissionPolicy" in updates && updates.permissionPolicy !== undefined ? normalizeAgentPermissionPolicy(updates.permissionPolicy) : updates.permissionPolicy; const updated: Agent = { ...agent, name: nextName ?? agent.name, role: updates.role ?? agent.role, metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata, updatedAt, ...("title" in updates && { title: updates.title }), ...("icon" in updates && { icon: updates.icon }), ...("imageUrl" in updates && { imageUrl: updates.imageUrl }), ...("reportsTo" in updates && { reportsTo: updates.reportsTo }), ...("runtimeConfig" in updates && { runtimeConfig: updates.runtimeConfig }), ...("pauseReason" in updates && { pauseReason: updates.pauseReason }), ...("permissions" in updates && { permissions: updates.permissions }), ...("permissionPolicy" in updates && { permissionPolicy: normalizedUpdatedPermissionPolicy }), ...("lastError" in updates && { lastError: updates.lastError }), ...("totalInputTokens" in updates && { totalInputTokens: updates.totalInputTokens }), ...("totalOutputTokens" in updates && { totalOutputTokens: updates.totalOutputTokens }), ...("instructionsPath" in updates && { instructionsPath: updates.instructionsPath }), ...("instructionsText" in updates && { instructionsText: updates.instructionsText }), ...(updates.soul !== undefined && { soul: updates.soul }), ...(updates.memory !== undefined && { memory: updates.memory }), ...("bundleConfig" in updates && { bundleConfig: updates.bundleConfig }), ...("heartbeatProcedurePath" in updates && { heartbeatProcedurePath: updates.heartbeatProcedurePath }), }; 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 { 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 { 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 * @param newState - The target state * @returns The updated agent * @throws Error if transition is invalid or agent not found */ async updateAgentState(agentId: string, newState: AgentState): Promise { return this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const currentState = agent.state; // Validate transition if (currentState === newState) { return agent; // No change needed } const validTransitions = AGENT_VALID_TRANSITIONS[currentState]; if (!validTransitions.includes(newState)) { throw new Error( `Invalid state transition: ${currentState} -> ${newState}. Valid transitions: ${validTransitions.join(", ")}` ); } const updated: Agent = { ...agent, state: newState, updatedAt: new Date().toISOString(), }; await this.writeAgent(updated); this.emit("agent:stateChanged", agentId, currentState, newState); this.emit("agent:updated", updated, currentState); return updated; }); } /** * Assign a task to an agent. * @param agentId - The agent ID * @param taskId - The task ID to assign, or undefined to unassign * @returns The updated agent */ async assignTask(agentId: string, taskId: string | undefined, runContext?: RunMutationContext): Promise { const updated = await this.syncExecutionTaskLink(agentId, taskId); // Emit agent:assigned only when assigning a task (not when clearing) if (taskId !== undefined) { this.emit("agent:assigned", updated, taskId); } // Log the assignment to the task when a non-empty taskId is provided if (taskId && this.taskStore) { await this.taskStore.logEntry(taskId, `Task assigned to agent ${agentId}`, undefined, runContext); } return updated; } /** * Synchronize execution task ownership on an agent without firing * assignment-side effects (`agent:assigned`, task assignment logs). * * Used by runtime execution bookkeeping so durable assigned agents can * reflect active task ownership without triggering heartbeat assignment wakeups. */ async syncExecutionTaskLink(agentId: string, taskId: string | undefined): Promise { return this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const updated: Agent = { ...agent, taskId, updatedAt: new Date().toISOString(), }; await this.writeAgent(updated); this.emit("agent:updated", updated); return updated; }); } /** * Claim task ownership for the calling agent with safety guards. * * Guards: * - task must exist and not be paused * - task must not be in terminal columns (done/archived) * - task must not already be assigned to another agent * - task checkout must be unheld or already held by this agent * * On success, updates both durable task assignment (assignedAgentId) and the * agent's active execution linkage (agent.taskId). Task linkage is only updated * after ownership + checkout checks pass. */ async claimTaskForAgent(agentId: string, taskId: string, runContext?: RunMutationContext): Promise<{ ok: true; task: Task } | { ok: false; reason: string; task?: Task }> { if (!this.taskStore) { throw new Error("TaskStore not configured for task-claim operations"); } const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } let task: Task | null = null; try { task = await this.taskStore.getTask(taskId); } catch { task = null; } if (!task) { return { ok: false, reason: "task_not_found" }; } if (task.paused) { return { ok: false, reason: "paused", task }; } const isExplicitlyAssignedToAgent = task.assignedAgentId === agentId; const roleAllowed = isExplicitlyAssignedToAgent ? canAgentTakeImplementationTaskForExplicitRouting(agent, task) : canAgentTakeImplementationTask(agent, task); if (!roleAllowed) { return { ok: false, reason: formatRoleMismatchReason(agent, task), task }; } if (task.column === "done" || task.column === "archived") { return { ok: false, reason: "terminal", task }; } if (task.assignedAgentId && task.assignedAgentId !== agentId) { return { ok: false, reason: "assigned_to_other", task }; } if (task.checkedOutBy && task.checkedOutBy !== agentId) { return { ok: false, reason: "checkout_conflict", task }; } try { await this.checkoutTask(agentId, taskId, undefined, runContext); } catch (error) { if (error instanceof CheckoutConflictError) { return { ok: false, reason: "checkout_conflict", task }; } throw error; } const claimedTask = await this.taskStore.updateTask(taskId, { assignedAgentId: agentId }, runContext); await this.syncExecutionTaskLink(agentId, taskId); return { ok: true, task: claimedTask }; } /** * Acquire a checkout lease for a task. * Throws CheckoutConflictError when another agent already holds the lease. */ async checkoutTask( agentId: string, taskId: string, leaseContext?: CheckoutClaimContext, runContext?: RunMutationContext, ): Promise { if (!this.taskStore) { throw new Error("TaskStore not configured for checkout operations"); } const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const task = await this.taskStore.getTask(taskId); if (!task) { throw new Error(`Task ${taskId} not found`); } if (task.checkedOutBy && task.checkedOutBy !== agentId) { throw new CheckoutConflictError(taskId, task.checkedOutBy, agentId); } const nextRenewedAt = leaseContext?.renewedAt ?? new Date().toISOString(); const existingNodeId = task.checkoutNodeId ?? null; const existingEpoch = task.checkoutLeaseEpoch ?? 0; const tryClaimCheckout = "tryClaimCheckout" in this.taskStore ? (this.taskStore as TaskStore & { tryClaimCheckout: NonNullable; }).tryClaimCheckout : undefined; if (tryClaimCheckout) { const requestNodeId = this.claimStore ? (leaseContext?.nodeId ?? this.defaultNodeId) : (leaseContext?.nodeId ?? existingNodeId ?? ""); if (this.claimStore && !requestNodeId) { throw new Error("checkoutTask requires leaseContext.nodeId or AgentStore nodeId when claimStore is configured"); } if (this.claimStore && this.claimProjectId) { const isSameAgentHolder = task.checkedOutBy === agentId; const isSameNodeHolder = existingNodeId !== null && requestNodeId === existingNodeId; const expectedEpoch = isSameAgentHolder && isSameNodeHolder ? existingEpoch : undefined; const centralResult = await Promise.resolve(this.claimStore.tryClaimTask({ projectId: this.claimProjectId, taskId, nodeId: requestNodeId!, agentId, runId: leaseContext?.runId ?? task.checkoutRunId ?? null, renewedAt: nextRenewedAt, expectedEpoch, })); if (!centralResult.ok) { throw new CheckoutConflictError(taskId, centralResult.current.ownerAgentId, agentId); } const claim = centralResult.claim; const mirrorLease = { agentId, nodeId: claim.ownerNodeId, runId: claim.ownerRunId, renewedAt: claim.leaseRenewedAt, leaseEpoch: claim.leaseEpoch, }; const mirrorPrecondition = { expectedCheckedOutBy: task.checkedOutBy ?? null, expectedNodeId: existingNodeId, expectedLeaseEpoch: existingEpoch, }; let result = await tryClaimCheckout.call(this.taskStore, taskId, mirrorLease, mirrorPrecondition); if (!result.ok) { const mirrorTask = await this.taskStore.getTask(taskId); if (mirrorTask) { result = await tryClaimCheckout.call(this.taskStore, taskId, mirrorLease, { expectedCheckedOutBy: mirrorTask.checkedOutBy ?? null, expectedNodeId: mirrorTask.checkoutNodeId ?? null, expectedLeaseEpoch: mirrorTask.checkoutLeaseEpoch ?? 0, }); } if (!result.ok) { await this.taskStore.logEntry( taskId, "Warning: central checkout claim succeeded but per-project mirror update failed", undefined, runContext, ); const latestTask = await this.taskStore.getTask(taskId); if (!latestTask) { throw new Error(`Task ${taskId} not found`); } return latestTask; } } await this.taskStore.logEntry(taskId, `Checked out by agent ${agentId}`, undefined, runContext); return result.task; } const isSameAgentHolder = task.checkedOutBy === agentId; const isRenewal = isSameAgentHolder && existingNodeId !== null && leaseContext?.nodeId === existingNodeId && leaseContext?.leaseEpoch === existingEpoch; if (isSameAgentHolder && !isRenewal) { throw new CheckoutConflictError(taskId, agentId, agentId); } const result = await tryClaimCheckout.call(this.taskStore, taskId, { agentId, nodeId: leaseContext?.nodeId ?? existingNodeId ?? "", runId: leaseContext?.runId ?? task.checkoutRunId ?? null, renewedAt: nextRenewedAt, leaseEpoch: isRenewal ? existingEpoch : existingEpoch + 1, }, { expectedCheckedOutBy: task.checkedOutBy ?? null, expectedNodeId: existingNodeId, expectedLeaseEpoch: existingEpoch, }); if (!result.ok) { if (result.reason === "row_not_found") { throw new Error(`Task ${taskId} not found`); } const currentHolder = result.current?.checkedOutBy; throw new CheckoutConflictError(taskId, currentHolder ?? "unknown", agentId); } await this.taskStore.logEntry(taskId, `Checked out by agent ${agentId}`, undefined, runContext); return result.task; } const nextEpoch = leaseContext?.leaseEpoch ?? task.checkoutLeaseEpoch ?? 0; if ( task.checkedOutBy === agentId && existingNodeId === (leaseContext?.nodeId ?? existingNodeId) && existingEpoch === nextEpoch ) { return this.taskStore.updateTask(taskId, { checkoutRunId: leaseContext?.runId ?? task.checkoutRunId ?? null, checkoutLeaseRenewedAt: nextRenewedAt, }); } const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: agentId, checkedOutAt: task.checkedOutBy === agentId ? task.checkedOutAt : undefined, checkoutNodeId: leaseContext?.nodeId ?? null, checkoutRunId: leaseContext?.runId ?? null, checkoutLeaseRenewedAt: nextRenewedAt, checkoutLeaseEpoch: nextEpoch, }); await this.taskStore.logEntry(taskId, `Checked out by agent ${agentId}`, undefined, runContext); return updated; } /** * Release a checkout lease for a task. */ async releaseTask(agentId: string, taskId: string, runContext?: RunMutationContext): Promise { if (!this.taskStore) { throw new Error("TaskStore not configured for checkout operations"); } const task = await this.taskStore.getTask(taskId); if (!task) { throw new Error(`Task ${taskId} not found`); } if (task.checkedOutBy && task.checkedOutBy !== agentId) { throw new Error("Cannot release: not the checkout holder"); } if (!task.checkedOutBy) { return task; } if (this.claimStore && this.claimProjectId && task.checkoutNodeId) { this.claimStore.releaseTaskClaim({ projectId: this.claimProjectId, taskId, nodeId: task.checkoutNodeId, agentId, }); } const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: null, checkedOutAt: null, checkoutNodeId: null, checkoutRunId: null, checkoutLeaseRenewedAt: null, }); await this.taskStore.logEntry(taskId, `Released by agent ${agentId}`, undefined, runContext); return updated; } /** * Force release a task checkout lease regardless of holder. */ async forceReleaseTask(taskId: string, runContext?: RunMutationContext): Promise { if (!this.taskStore) { throw new Error("TaskStore not configured for checkout operations"); } const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: null, checkedOutAt: null, checkoutNodeId: null, checkoutRunId: null, checkoutLeaseRenewedAt: null, checkoutLeaseEpoch: null, }); await this.taskStore.logEntry(taskId, "Checkout force-released", undefined, runContext); return updated; } /** * Get the current checkout lease holder for a task. */ async getCheckedOutBy(taskId: string): Promise { if (!this.taskStore) { throw new Error("TaskStore not configured for checkout operations"); } const task = await this.taskStore.getTask(taskId); if (!task) { throw new Error(`Task ${taskId} not found`); } return task.checkedOutBy; } /** * Reset budget token usage counters for an agent. * @param agentId - The agent ID * @throws Error if agent not found */ async resetBudgetUsage(agentId: string): Promise { await this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const budgetResetAt = new Date().toISOString(); const updated: Agent = { ...agent, totalInputTokens: 0, totalOutputTokens: 0, runtimeConfig: { ...(agent.runtimeConfig ?? {}), budgetResetAt, }, updatedAt: budgetResetAt, }; await this.writeAgent(updated); this.emit("agent:updated", updated); }); } /** * Reset an agent from any state back to "idle". * Clears transient execution state (taskId, lastError, pauseReason) * and ends any active heartbeat run. * @param agentId - The agent ID * @returns The reset agent * @throws Error if agent not found or transition is invalid */ async resetAgent(agentId: string): Promise { let agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } // End any active heartbeat run before transitioning const activeRun = await this.getActiveHeartbeatRun(agentId); if (activeRun) { await this.endHeartbeatRun(activeRun.id, "terminated"); } // Any non-idle state can transition directly to idle in the new // lifecycle (see AGENT_VALID_TRANSITIONS in types.ts), so no // intermediate hop is required. if (agent.state !== "idle") { agent = await this.updateAgentState(agentId, "idle"); } if (agent.taskId !== undefined) { agent = await this.assignTask(agentId, undefined); } if (agent.lastError !== undefined || agent.pauseReason !== undefined) { agent = await this.updateAgent(agentId, { lastError: undefined, pauseReason: undefined, }); } return agent; } /** * List all agents, optionally filtered by state. * @param filter - Optional filter criteria * @returns Array of agents */ async listAgents(filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean }): Promise { const clauses: string[] = []; const params: string[] = []; if (filter?.state) { clauses.push("state = ?"); params.push(filter.state); } if (filter?.role) { clauses.push("role = ?"); params.push(filter.role); } const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; const rows = this.db .prepare(`SELECT * FROM agents ${where} ORDER BY createdAt DESC`) .all(...params) as unknown as AgentRow[]; return rows .map((row) => this.mapAgentRow(row)) .filter((agent) => filter?.includeEphemeral === true || !isEphemeralAgent(agent)); } /** * Create an API key for an agent. * Persists only the SHA-256 token hash; plaintext token is returned once. */ async createApiKey(agentId: string, options?: { label?: string }): Promise { return this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const token = randomBytes(32).toString("hex"); const tokenHash = createHash("sha256").update(token).digest("hex"); const createdAt = new Date().toISOString(); const label = options?.label?.trim(); const key: AgentApiKey = { id: `key-${randomUUID().slice(0, 8)}`, agentId, tokenHash, createdAt, ...(label ? { label } : {}), }; this.db.prepare(` INSERT INTO agentApiKeys (id, agentId, data, createdAt, revokedAt) VALUES (?, ?, ?, ?, ?) `).run(key.id, key.agentId, JSON.stringify(key), key.createdAt, key.revokedAt ?? null); this.db.bumpLastModified(); return { key, token }; }); } /** * List all API keys for an agent, including revoked keys. */ async listApiKeys(agentId: string): Promise { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } return this.readApiKeys(agentId); } /** * Revoke an API key for an agent. * Revoking an already-revoked key is a no-op. */ async revokeApiKey(agentId: string, keyId: string): Promise { return this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const keys = await this.readApiKeys(agentId); const keyIndex = keys.findIndex((key) => key.id === keyId); if (keyIndex === -1) { throw new Error(`API key ${keyId} not found for agent ${agentId}`); } const existing = keys[keyIndex]; if (existing.revokedAt) { return existing; } const revoked: AgentApiKey = { ...existing, revokedAt: new Date().toISOString(), }; this.db.prepare(` UPDATE agentApiKeys SET data = ?, revokedAt = ? WHERE id = ? AND agentId = ? `).run(JSON.stringify(revoked), revoked.revokedAt ?? null, keyId, agentId); this.db.bumpLastModified(); return revoked; }); } /** * Delete an agent and its heartbeat history. * @param agentId - The agent ID * @throws Error if agent not found */ async deleteAgent(agentId: string, options?: { force?: boolean; reassignTo?: string }): Promise { await this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } if (this.taskStore && typeof (this.taskStore as { getTasksByAssignedAgent?: unknown }).getTasksByAssignedAgent === "function") { const assignedTasks = await this.taskStore.getTasksByAssignedAgent(agentId); for (const task of assignedTasks) { if (task.checkedOutBy === agentId && options?.force !== true) { throw new Error(`Agent ${agentId} holds checkout for task ${task.id}; pass force=true to delete`); } await this.taskStore.updateTask(task.id, { assignedAgentId: options?.reassignTo ?? null, checkedOutBy: task.checkedOutBy === agentId ? null : undefined, }); } } this.db.prepare("DELETE FROM agents WHERE id = ?").run(agentId); this.db.bumpLastModified(); this.emit("agent:deleted", agentId); }); } /** * Record a heartbeat event for an agent. * @param agentId - The agent ID * @param status - Heartbeat status * @param runId - Optional run ID (uses active run if not provided) * @returns The recorded heartbeat event */ async recordHeartbeat( agentId: string, status: AgentHeartbeatEvent["status"], runId?: string ): Promise { return this.withLock(agentId, async () => { // Verify agent exists const agent = await this.getAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } // Get or determine run ID let effectiveRunId = runId; if (!effectiveRunId) { const activeRun = await this.getActiveHeartbeatRun(agentId); effectiveRunId = activeRun?.id ?? `run-${randomUUID().slice(0, 8)}`; } const event: AgentHeartbeatEvent = { timestamp: new Date().toISOString(), status, runId: effectiveRunId, }; this.db.prepare(` INSERT INTO agentHeartbeats (agentId, timestamp, status, runId) VALUES (?, ?, ?, ?) `).run(agentId, event.timestamp, event.status, event.runId); // Update agent's lastHeartbeatAt if status is ok if (status === "ok") { const updated: Agent = { ...agent, lastHeartbeatAt: event.timestamp, updatedAt: event.timestamp, }; await this.writeAgent(updated); } else { this.db.bumpLastModified(); } this.emit("agent:heartbeat", agentId, event); return event; }); } /** * Get heartbeat history for an agent. * @param agentId - The agent ID * @param limit - Maximum number of events to return (default: 50) * @returns Array of heartbeat events (newest first) */ async getHeartbeatHistory(agentId: string, limit = 50): Promise { const rows = this.db.prepare(` SELECT timestamp, status, runId FROM agentHeartbeats WHERE agentId = ? ORDER BY timestamp DESC LIMIT ? `).all(agentId, limit) as Array<{ timestamp: string; status: AgentHeartbeatEvent["status"]; runId: string }>; return rows.map((row) => ({ timestamp: row.timestamp, status: row.status, runId: row.runId, })); } /** * Start a new heartbeat run for an agent. * Persists the run to structured storage as the source of truth. * @param agentId - The agent ID * @returns The created run */ async startHeartbeatRun(agentId: string): Promise { const runId = `run-${randomUUID().slice(0, 8)}`; const now = new Date().toISOString(); const run: AgentHeartbeatRun = { id: runId, agentId, startedAt: now, endedAt: null, status: "active", }; // Persist to structured storage as source of truth await this.saveRun(run); await this.recordHeartbeat(agentId, "ok", runId); return run; } /** * End a heartbeat run. * Updates the persisted run's terminal state in structured storage. * Also records a heartbeat event for history views. * @param runId - The run ID * @param status - End status (completed or terminated) */ async endHeartbeatRun(runId: string, status: "completed" | "terminated"): Promise { const now = new Date().toISOString(); const row = this.db.prepare("SELECT agentId, data FROM agentRuns WHERE id = ?").get(runId) as | { agentId: string; data: string } | undefined; if (!row) { return; } const existingRun = this.parseJson(row.data, { id: runId, agentId: row.agentId, startedAt: now, endedAt: null, status: "active", }); const updatedRun: AgentHeartbeatRun = { ...existingRun, endedAt: now, status, }; await this.saveRun(updatedRun); await this.recordHeartbeat(row.agentId, status === "terminated" ? "missed" : "ok", runId); } /** * Get the active heartbeat run for an agent. * Reads from structured run storage first (source of truth), * falls back to heartbeat event reconstruction for legacy data. * @param agentId - The agent ID * @returns The active run, or null if none */ async getActiveHeartbeatRun(agentId: string): Promise { const recentRuns = await this.getRecentRuns(agentId, 50); return recentRuns.find((run) => run.status === "active") ?? null; } /** * Get all completed heartbeat runs for an agent. * Reads from structured run storage first (source of truth), * falls back to heartbeat event reconstruction for legacy data. * Returns terminal runs (completed, terminated, failed) in newest-first order. * @param agentId - The agent ID * @returns Array of completed runs */ async getCompletedHeartbeatRuns(agentId: string): Promise { const recentRuns = await this.getRecentRuns(agentId, 50); return recentRuns .filter((run) => run.status !== "active") .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()); } /** * List every heartbeat run currently in `status = 'active'` across all * agents. Used by self-healing to detect orphaned runs from prior process * incarnations that crashed before calling endHeartbeatRun(). Without this * sweep an active row blocks all subsequent timer ticks for the agent * because HeartbeatTriggerScheduler.onTimerTick treats any active run as * "already running". */ async listActiveHeartbeatRuns(): Promise { const rows = this.db.prepare(` SELECT data FROM agentRuns WHERE status = 'active' ORDER BY startedAt ASC `).all() as Array<{ data: string }>; return rows .map((row) => this.parseJson(row.data, null)) .filter((run): run is AgentHeartbeatRun => run !== null); } // ───────────────────────────────────────────────────────────────────────── // Task Session Management // ───────────────────────────────────────────────────────────────────────── /** * Get a task session for an agent. * @param agentId - The agent ID * @param taskId - The task ID * @returns The session, or null if not found */ async getTaskSession(agentId: string, taskId: string): Promise { const row = this.db.prepare(` SELECT data FROM agentTaskSessions WHERE agentId = ? AND taskId = ? `).get(agentId, taskId) as { data: string } | undefined; return row ? this.parseJson(row.data, null) : null; } /** * Create or update a task session for an agent. * @param session - The session data * @returns The saved session */ async upsertTaskSession(session: AgentTaskSession): Promise { const now = new Date().toISOString(); const existing = await this.getTaskSession(session.agentId, session.taskId); const saved: AgentTaskSession = { ...session, createdAt: existing?.createdAt ?? now, updatedAt: now, }; this.db.prepare(` INSERT INTO agentTaskSessions (agentId, taskId, data, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(agentId, taskId) DO UPDATE SET data = excluded.data, updatedAt = excluded.updatedAt `).run(session.agentId, session.taskId, JSON.stringify(saved), saved.createdAt, saved.updatedAt); this.db.bumpLastModified(); return saved; } /** * Delete a task session. * @param agentId - The agent ID * @param taskId - The task ID */ async deleteTaskSession(agentId: string, taskId: string): Promise { this.db.prepare("DELETE FROM agentTaskSessions WHERE agentId = ? AND taskId = ?").run(agentId, taskId); this.db.bumpLastModified(); } // ───────────────────────────────────────────────────────────────────────── // Org Hierarchy // ───────────────────────────────────────────────────────────────────────── /** * Get agents that report to a specific agent. * @param agentId - The parent agent ID * @returns Array of agents that report to this agent */ async getAgentsByReportsTo(agentId: string): Promise { const all = await this.listAgents(); return all.filter((a) => a.reportsTo === agentId); } /** * Walk the chain of command for an agent. * @param agentId - Starting agent ID * @returns Ordered chain [self, manager, grandManager, ...] */ async getChainOfCommand(agentId: string): Promise { const chain: Agent[] = []; const visited = new Set(); let currentId: string | undefined = agentId; for (let depth = 0; depth < 20 && currentId; depth += 1) { if (visited.has(currentId)) { break; } visited.add(currentId); const agent = await this.getAgent(currentId); if (!agent) { return depth === 0 ? [] : chain; } chain.push(agent); currentId = agent.reportsTo; } return chain; } /** * Build the recursive org tree for all agents. * @param filter - Optional filter for listing agents * @returns Root nodes with nested children */ async getOrgTree(filter?: { includeEphemeral?: boolean }): Promise { const agents = await this.listAgents(filter); if (agents.length === 0) { return []; } const agentsById = new Map(agents.map((agent) => [agent.id, agent])); const childrenByParent = new Map(); const roots: Agent[] = []; for (const agent of agents) { if (!agent.reportsTo || !agentsById.has(agent.reportsTo)) { roots.push(agent); continue; } const siblings = childrenByParent.get(agent.reportsTo) ?? []; siblings.push(agent); childrenByParent.set(agent.reportsTo, siblings); } const sortByCreatedAtAsc = (a: Agent, b: Agent): number => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); for (const children of childrenByParent.values()) { children.sort(sortByCreatedAtAsc); } roots.sort(sortByCreatedAtAsc); const buildNode = (agent: Agent): OrgTreeNode => ({ agent, children: (childrenByParent.get(agent.id) ?? []).map((child) => buildNode(child)), }); return roots.map((root) => buildNode(root)); } /** * Resolve an agent by exact ID or normalized shortname derived from display name. * @param shortname - Agent ID or normalized agent name * @returns Matching agent when unambiguous; otherwise null */ async resolveAgent(shortname: string): Promise { const normalize = (value: string): string => value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); const all = await this.listAgents(); const exact = all.find((agent) => agent.id === shortname); if (exact) { return exact; } const normalizedTarget = normalize(shortname); if (!normalizedTarget) { return null; } const matches = all.filter((agent) => normalize(agent.name) === normalizedTarget); return matches.length === 1 ? matches[0] : null; } // ───────────────────────────────────────────────────────────────────────── // Rich Run Storage // ───────────────────────────────────────────────────────────────────────── /** * Save a rich heartbeat run record (structured JSON, not JSONL events). * @param run - The heartbeat run data */ async saveRun(run: AgentHeartbeatRun): Promise { this.db.prepare(` INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET agentId = excluded.agentId, data = excluded.data, startedAt = excluded.startedAt, endedAt = excluded.endedAt, status = excluded.status `).run(run.id, run.agentId, JSON.stringify(run), run.startedAt, run.endedAt, run.status); this.db.bumpLastModified(); } /** * Get a specific run by ID. * @param agentId - The agent ID * @param runId - The run ID * @returns The run detail, or null if not found */ async getRunDetail(agentId: string, runId: string): Promise { const row = this.db.prepare(` SELECT data FROM agentRuns WHERE agentId = ? AND id = ? `).get(agentId, runId) as { data: string } | undefined; return row ? this.parseJson(row.data, null) : null; } /** * Get recent runs for an agent from structured run storage. * @param agentId - The agent ID * @param limit - Max number of runs to return (default: 20) * @returns Array of runs (newest first) */ async getRecentRuns(agentId: string, limit = 20): Promise { const rows = this.db.prepare(` SELECT data FROM agentRuns WHERE agentId = ? ORDER BY startedAt DESC LIMIT ? `).all(agentId, limit) as Array<{ data: string }>; return rows .map((row) => this.parseJson(row.data, null)) .filter((run): run is AgentHeartbeatRun => run !== null); } async getRunStatusCounts(agentIds?: readonly string[]): Promise<{ completedRuns: number; failedRuns: number }> { let rows: Array<{ status: string; count: number }>; if (agentIds && agentIds.length > 0) { const placeholders = agentIds.map(() => "?").join(","); rows = this.db.prepare(` SELECT status, COUNT(*) as count FROM agentRuns WHERE agentId IN (${placeholders}) GROUP BY status `).all(...agentIds) as Array<{ status: string; count: number }>; } else { rows = this.db.prepare(` SELECT status, COUNT(*) as count FROM agentRuns GROUP BY status `).all() as Array<{ status: string; count: number }>; } let completedRuns = 0; let failedRuns = 0; for (const row of rows) { if (row.status === "completed") { completedRuns += row.count; } else if (row.status === "failed" || row.status === "terminated") { failedRuns += row.count; } } return { completedRuns, failedRuns }; } // ───────────────────────────────────────────────────────────────────────── // Run-scoped log storage (JSONL files alongside run JSON in agentsDir) // ───────────────────────────────────────────────────────────────────────── /** Maximum byte size for any single log entry field (64 KB) to bound disk growth. */ private static readonly RUN_LOG_ENTRY_MAX_BYTES = 64 * 1024; /** Return the path to the JSONL run-log file for a given agent/run pair. */ private runLogPath(agentId: string, runId: string): string { return join(this.agentsDir, `${agentId}-runlogs-${runId}.jsonl`); } /** * Append a single {@link AgentLogEntry} to the JSONL run log for the given run. * Individual `text` and `detail` fields are capped at 64 KB so one large tool * result cannot grow the file unboundedly. * @param agentId - The agent ID * @param runId - The run ID * @param entry - The log entry to append */ async appendRunLog(agentId: string, runId: string, entry: AgentLogEntry): Promise { const cap = AgentStore.RUN_LOG_ENTRY_MAX_BYTES; const safeEntry: AgentLogEntry = { ...entry, text: entry.text.length > cap ? `${entry.text.slice(0, cap)}\n\n... (truncated, ${entry.text.length} chars)` : entry.text, ...(entry.detail !== undefined && { detail: entry.detail.length > cap ? `${entry.detail.slice(0, cap)}\n\n... (truncated, ${entry.detail.length} chars)` : entry.detail, }), }; const line = JSON.stringify(safeEntry) + "\n"; await appendFile(this.runLogPath(agentId, runId), line, "utf-8"); this.emit("run:log", agentId, runId, safeEntry); } /** * Read all log entries for a given run from its JSONL file. * Returns an empty array when the file does not exist (e.g., the run had no * logs or was recorded before this feature was added). * @param agentId - The agent ID * @param runId - The run ID * @param opts.limit - Optional maximum number of entries to return (newest-first capped) */ async getRunLogs(agentId: string, runId: string, opts?: { limit?: number }): Promise { const filePath = this.runLogPath(agentId, runId); let raw: string; try { raw = await readFile(filePath, "utf-8"); } catch { return []; } const lines = raw.split("\n").filter((l) => l.trim().length > 0); const entries: AgentLogEntry[] = []; for (const line of lines) { try { entries.push(JSON.parse(line) as AgentLogEntry); } catch { // Skip malformed lines — append-only means partial writes can occur on crash } } if (opts?.limit !== undefined && entries.length > opts.limit) { return entries.slice(entries.length - opts.limit); } return entries; } /** * Get the most recently persisted blocked-task dedup state for an agent. */ async getLastBlockedState(agentId: string): Promise { const row = this.db.prepare("SELECT data FROM agentBlockedStates WHERE agentId = ?").get(agentId) as | { data: string } | undefined; return row ? this.parseJson(row.data, null) : null; } /** * Persist the latest blocked-task dedup state for an agent. */ async setLastBlockedState(agentId: string, state: BlockedStateSnapshot): Promise { await this.withLock(agentId, async () => { const updatedAt = new Date().toISOString(); this.db.prepare(` INSERT INTO agentBlockedStates (agentId, data, updatedAt) VALUES (?, ?, ?) ON CONFLICT(agentId) DO UPDATE SET data = excluded.data, updatedAt = excluded.updatedAt `).run(agentId, JSON.stringify(state), updatedAt); this.db.bumpLastModified(); }); } /** * Clear any persisted blocked-task dedup state for an agent. */ async clearLastBlockedState(agentId: string): Promise { await this.withLock(agentId, async () => { this.db.prepare("DELETE FROM agentBlockedStates WHERE agentId = ?").run(agentId); this.db.bumpLastModified(); }); } getAgentSnapshot(): Promise { return (async () => { const agents = await this.listAgents({ includeEphemeral: true }); const blockedRows = this.db.prepare("SELECT agentId, data FROM agentBlockedStates ORDER BY updatedAt ASC").all() as Array<{ agentId: string; data: string }>; const blockedStates = blockedRows .map((row) => ({ agentId: row.agentId, state: this.parseJson(row.data, null) })) .filter((row): row is { agentId: string; state: BlockedStateSnapshot } => row.state !== null); return createAgentSnapshot({ agents, blockedStates }); })(); } async applyAgentSnapshot(snapshot: AgentSnapshot): Promise<{ appliedAgents: number; appliedBlockedStates: number }> { validateSnapshotEnvelope(snapshot); let appliedAgents = 0; let appliedBlockedStates = 0; for (const agent of snapshot.payload.agents) { this.db.prepare(`INSERT INTO agents (id, name, role, state, taskId, createdAt, updatedAt, lastHeartbeatAt, metadata, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, role=excluded.role, state=excluded.state, taskId=excluded.taskId, updatedAt=excluded.updatedAt, lastHeartbeatAt=excluded.lastHeartbeatAt, metadata=excluded.metadata, data=excluded.data`) .run( agent.id, agent.name, agent.role, agent.state, agent.taskId ?? null, agent.createdAt, agent.updatedAt, agent.lastHeartbeatAt ?? null, JSON.stringify(agent.metadata ?? {}), JSON.stringify(agent), ); appliedAgents++; } for (const blocked of snapshot.payload.blockedStates) { this.db.prepare(`INSERT INTO agentBlockedStates (agentId, data, updatedAt) VALUES (?, ?, ?) ON CONFLICT(agentId) DO UPDATE SET data=excluded.data, updatedAt=excluded.updatedAt`) .run(blocked.agentId, JSON.stringify(blocked.state), blocked.state.recordedAt); appliedBlockedStates++; } this.db.bumpLastModified(); return { appliedAgents, appliedBlockedStates }; } getAgentRunSnapshot(limit?: number): AgentRunSnapshot { const normalizedLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : undefined; const query = normalizedLimit ? "SELECT data FROM agentRuns ORDER BY startedAt DESC, rowid DESC LIMIT ?" : "SELECT data FROM agentRuns ORDER BY startedAt ASC, rowid ASC"; const rows = normalizedLimit ? (this.db.prepare(query).all(normalizedLimit) as Array<{ data: string }>) : (this.db.prepare(query).all() as Array<{ data: string }>); const parsed = rows .map((row) => this.parseJson(row.data, null)) .filter((run): run is AgentHeartbeatRun => run !== null); const orderedRuns = normalizedLimit ? parsed.reverse() : parsed; return createAgentRunSnapshot(orderedRuns); } async applyAgentRunSnapshot(snapshot: AgentRunSnapshot): Promise<{ applied: number; skipped: number }> { validateSnapshotEnvelope(snapshot); let applied = 0; let skipped = 0; for (const run of snapshot.payload.runs) { const exists = this.db.prepare("SELECT 1 FROM agentRuns WHERE id = ?").get(run.id); if (exists) { skipped++; continue; } await this.saveRun(run); applied++; } return { applied, skipped }; } // ───────────────────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────────────────── private getConfigRevisionsPath(agentId: string): string { return join(this.agentsDir, `${agentId}-revisions.jsonl`); } private async appendConfigRevision(revision: AgentConfigRevision): Promise { this.db.prepare(` INSERT INTO agentConfigRevisions (id, agentId, data, createdAt) VALUES (?, ?, ?, ?) `).run(revision.id, revision.agentId, JSON.stringify(revision), revision.createdAt); this.db.bumpLastModified(); } private async readConfigRevisions(agentId: string): Promise { const rows = this.db.prepare(` SELECT data FROM agentConfigRevisions WHERE agentId = ? ORDER BY createdAt ASC `).all(agentId) as Array<{ data: string }>; return rows .map((row) => this.parseJson(row.data, null)) .filter((revision): revision is AgentConfigRevision => revision !== null); } 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" | "imageUrl" | "reportsTo" | "runtimeConfig" | "permissions" | "permissionPolicy" | "instructionsPath" | "instructionsText" | "soul" | "memory" | "bundleConfig" | "heartbeatProcedurePath" | "metadata" > { return { name: snapshot.name, role: snapshot.role, title: snapshot.title, icon: snapshot.icon, imageUrl: snapshot.imageUrl, reportsTo: snapshot.reportsTo, runtimeConfig: snapshot.runtimeConfig ? { ...snapshot.runtimeConfig } : undefined, permissions: snapshot.permissions ? { ...snapshot.permissions } : undefined, permissionPolicy: snapshot.permissionPolicy ? { presetId: snapshot.permissionPolicy.presetId, rules: { ...snapshot.permissionPolicy.rules }, } : undefined, instructionsPath: snapshot.instructionsPath, instructionsText: snapshot.instructionsText, soul: snapshot.soul, memory: snapshot.memory, bundleConfig: snapshot.bundleConfig ? { ...snapshot.bundleConfig, files: [...snapshot.bundleConfig.files], } : undefined, heartbeatProcedurePath: snapshot.heartbeatProcedurePath, metadata: { ...snapshot.metadata }, }; } private async findConfigRevisionAcrossAgents(revisionId: string): Promise { const row = this.db.prepare("SELECT data FROM agentConfigRevisions WHERE id = ?").get(revisionId) as | { data: string } | undefined; return row ? this.parseJson(row.data, null) : null; } private computeNextResetAt(period: AgentBudgetConfig["budgetPeriod"], resetDay?: number): string | null { if (!period || period === "lifetime") { return null; } const now = new Date(); if (period === "daily") { const nextMidnight = new Date(now); nextMidnight.setHours(0, 0, 0, 0); nextMidnight.setDate(nextMidnight.getDate() + 1); return nextMidnight.toISOString(); } if (period === "weekly") { const normalizedResetDay = typeof resetDay === "number" && Number.isFinite(resetDay) ? Math.max(0, Math.min(6, Math.floor(resetDay))) : 0; const nextWeeklyReset = new Date(now); nextWeeklyReset.setHours(0, 0, 0, 0); const currentDay = nextWeeklyReset.getDay(); let daysUntilReset = (normalizedResetDay - currentDay + 7) % 7; if (daysUntilReset === 0) { daysUntilReset = 7; } nextWeeklyReset.setDate(nextWeeklyReset.getDate() + daysUntilReset); return nextWeeklyReset.toISOString(); } if (period === "monthly") { const normalizedResetDay = typeof resetDay === "number" && Number.isFinite(resetDay) ? Math.max(1, Math.min(31, Math.floor(resetDay))) : 1; const createMonthlyReset = (year: number, month: number): Date => { const lastDayOfMonth = new Date(year, month + 1, 0).getDate(); const clampedResetDay = Math.min(normalizedResetDay, lastDayOfMonth); return new Date(year, month, clampedResetDay, 0, 0, 0, 0); }; let nextMonthlyReset = createMonthlyReset(now.getFullYear(), now.getMonth()); if (nextMonthlyReset <= now) { nextMonthlyReset = createMonthlyReset(now.getFullYear(), now.getMonth() + 1); } return nextMonthlyReset.toISOString(); } return null; } private getCanonicalBundleDir(agent: Agent): string { return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id)); } private getLegacyBundleDir(agentId: string): string { return join(this.agentsDir, getLegacyAgentInstructionsBundleDirName(agentId)); } private async resolveCompatibleBundleDir(agentId: string, createIfMissing: boolean): Promise { const agent = this.readAgent(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); } const canonicalDir = this.getCanonicalBundleDir(agent); if (await this.pathExists(canonicalDir)) { return canonicalDir; } const compatibleDir = await this.findExistingDisplayNameBundleDir(agent); if (compatibleDir) { return compatibleDir; } const legacyDir = this.getLegacyBundleDir(agent.id); if (await this.pathExists(legacyDir)) { return legacyDir; } return createIfMissing ? canonicalDir : canonicalDir; } private async findExistingDisplayNameBundleDir(agent: Agent): Promise { const safeId = getSafeAgentAssetIdSegment(agent.id); try { const entries = await readdir(this.agentsDir, { withFileTypes: true }); const candidates = entries .filter((entry) => entry.isDirectory() && entry.name.endsWith("-instructions")) .map((entry) => entry.name) .filter((name) => { const base = name.slice(0, -"-instructions".length); return base.endsWith(`-${safeId}`); }) .sort((a, b) => a.localeCompare(b)); if (candidates.length === 0) { return null; } const canonicalName = getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id); const selected = candidates.find((candidate) => candidate === canonicalName) ?? candidates[0]; return join(this.agentsDir, selected); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { return null; } throw err; } } private async resolveCompatibleHeartbeatProcedurePath(agent: Agent): Promise { const canonicalPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name); const canonicalAbs = join(this.rootDir, "..", canonicalPath); if (await this.pathExists(canonicalAbs)) { return canonicalPath; } const safeId = getSafeAgentAssetIdSegment(agent.id); try { const entries = await readdir(this.agentsDir, { withFileTypes: true }); const compatibleDir = entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .find((name) => name.endsWith(`-${safeId}`)); if (compatibleDir) { const candidatePath = `.fusion/agents/${compatibleDir}/HEARTBEAT.md`; if (await this.pathExists(join(this.rootDir, "..", candidatePath))) { return candidatePath; } } } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { throw err; } } const legacyPath = `.fusion/agents/${getLegacyAgentAssetDirectoryName(agent.id)}/HEARTBEAT.md`; const legacyAbs = join(this.rootDir, "..", legacyPath); if (await this.pathExists(legacyAbs)) { return legacyPath; } return canonicalPath; } private async pathExists(path: string): Promise { try { await access(path, fsConstants.F_OK); return true; } catch { return false; } } private validateBundleFilePath(filePath: string): void { if (typeof filePath !== "string") { throw new Error("Bundle file path must be a string"); } const trimmedPath = filePath.trim(); if (!trimmedPath) { throw new Error("Bundle file path cannot be empty"); } const normalizedPath = trimmedPath.replace(/\\/g, "/"); if (normalizedPath.startsWith("/")) { throw new Error("Bundle file path must be relative (absolute paths are not allowed)"); } const segments = normalizedPath.split("/"); if (segments.some((segment) => segment === "..")) { throw new Error("Bundle file path cannot include '..' path traversal segments"); } if (!normalizedPath.endsWith(".md")) { throw new Error("Bundle file path must end with .md"); } const filename = basename(normalizedPath); if (!filename) { throw new Error("Bundle file name cannot be empty"); } if (filename.length > 500) { throw new Error("Bundle file name cannot exceed 500 characters"); } } private async readApiKeys(agentId: string): Promise { const rows = this.db.prepare(` SELECT data FROM agentApiKeys WHERE agentId = ? ORDER BY createdAt ASC `).all(agentId) as Array<{ data: string }>; return rows .map((row) => this.parseJson(row.data, null)) .filter((key): key is AgentApiKey => key !== null); } private readAgent(agentId: string): Agent | null { const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(agentId) as AgentRow | undefined; return row ? this.mapAgentRow(row) : null; } private mapAgentRow(row: AgentRow): Agent { const data = this.parseJson>(row.data, {}); const metadata = this.parseJson>(row.metadata, {}); return this.parseAgent({ ...data, id: row.id, name: row.name, role: row.role, state: row.state, taskId: row.taskId ?? undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, lastHeartbeatAt: row.lastHeartbeatAt ?? undefined, metadata, } as AgentData); } private parseJson(value: string | null | undefined, fallback: T): T { if (!value) { return fallback; } try { return JSON.parse(value) as T; } catch { return fallback; } } /** * Synchronously read an agent from SQLite (for use in synchronous hot paths). * Returns null if the agent does not exist or cannot be parsed. * @param agentId - The agent ID */ getCachedAgent(agentId: string): Agent | null { return this.readAgent(agentId); } private parseAgent(data: AgentData): Agent { return { id: data.id, name: data.name, role: data.role, state: data.state, taskId: data.taskId, createdAt: data.createdAt, updatedAt: data.updatedAt, lastHeartbeatAt: data.lastHeartbeatAt, metadata: data.metadata ?? {}, title: data.title, icon: data.icon, imageUrl: data.imageUrl, reportsTo: data.reportsTo, runtimeConfig: data.runtimeConfig, pauseReason: data.pauseReason, permissions: data.permissions, permissionPolicy: isEphemeralAgent(data) ? data.permissionPolicy : resolveEffectiveAgentPermissionPolicy(data.permissionPolicy), totalInputTokens: data.totalInputTokens, totalOutputTokens: data.totalOutputTokens, lastError: data.lastError, instructionsPath: data.instructionsPath, instructionsText: data.instructionsText, soul: data.soul, memory: data.memory, bundleConfig: data.bundleConfig, heartbeatProcedurePath: data.heartbeatProcedurePath, }; } private async writeAgent(agent: Agent): Promise { const data: AgentData = { id: agent.id, name: agent.name, role: agent.role, state: agent.state, taskId: agent.taskId, createdAt: agent.createdAt, updatedAt: agent.updatedAt, lastHeartbeatAt: agent.lastHeartbeatAt, metadata: agent.metadata, title: agent.title, icon: agent.icon, imageUrl: agent.imageUrl, reportsTo: agent.reportsTo, runtimeConfig: agent.runtimeConfig, pauseReason: agent.pauseReason, permissions: agent.permissions, permissionPolicy: agent.permissionPolicy, totalInputTokens: agent.totalInputTokens, totalOutputTokens: agent.totalOutputTokens, lastError: agent.lastError, instructionsPath: agent.instructionsPath, instructionsText: agent.instructionsText, soul: agent.soul, memory: agent.memory, bundleConfig: agent.bundleConfig, heartbeatProcedurePath: agent.heartbeatProcedurePath, }; this.db.prepare(` INSERT INTO agents ( id, name, role, state, taskId, createdAt, updatedAt, lastHeartbeatAt, metadata, data ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, role = excluded.role, state = excluded.state, taskId = excluded.taskId, updatedAt = excluded.updatedAt, lastHeartbeatAt = excluded.lastHeartbeatAt, metadata = excluded.metadata, data = excluded.data `).run( agent.id, agent.name, agent.role, agent.state, agent.taskId ?? null, agent.createdAt, agent.updatedAt, agent.lastHeartbeatAt ?? null, JSON.stringify(agent.metadata ?? {}), JSON.stringify(data), ); this.db.bumpLastModified(); } /** * Close the underlying SQLite connection and release resources. */ close(): void { if (!this._db) { return; } if (!this.inMemoryDb && agentStoreDbCache.get(this.rootDir) === this._db) { agentStoreDbCache.delete(this.rootDir); } try { this._db.close(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!message.includes("database is not open")) { throw error; } } finally { this._db = null; } } private async withLock(agentId: string, fn: () => Promise): Promise { // Get or create lock for this agent let lock = this.locks.get(agentId); if (!lock) { lock = { promise: Promise.resolve() }; this.locks.set(agentId, lock); } // Chain operations const operation = lock.promise.then(fn, fn); lock.promise = operation; return operation as Promise; } }