Files
waggle-os/packages/server/src/local/agents-store.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

150 lines
5.0 KiB
TypeScript

/**
* Agents store (UX-Refactor Phase 3, gap card S09/S18 / gate B3).
*
* The Agent entity is a real object persisted in the flat `{dataDir}/agents.json`
* file (B3 ratified 2026-06-10) — NO `.mind` migration (M3 explicitly NOT
* shipped), mirroring the `agent-groups.json` precedent but with the Phase-2C
* store quality bar (artifact-index.ts): pure I/O, immutable updates, corrupt-file
* degradation, atomic write (temp file + rename).
*
* `lastRunAt`/`successRate` are NEVER stored here — they are derived at read
* from `execution_traces` by the route layer (B3). This module owns only the
* declared record.
*/
import type { AgentRunState, AgentType, AutonomyLevel, Scope } from '@waggle/shared';
import { AGENT_RUN_STATES } from '@waggle/shared';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
// PRD §14.5 agent lifecycle states — single source of truth lives in
// @waggle/shared (the FE re-exports the same union). Re-exported here so the
// existing route/test import sites keep working.
export { AGENT_RUN_STATES };
export type { AgentRunState };
/** The persisted agents.json record (PRD §15.5 / build spec §1.1). */
export interface AgentRecord {
id: string;
name: string;
/** Required — Agent Builder hard gate. */
goal: string;
description?: string;
type: AgentType;
/** Persona = behavioral template FIELD of the agent (B3). */
personaId?: string;
avatar?: string;
/** Required — Agent Builder hard gate. */
model: string;
/** Required — Agent Builder hard gate. */
autonomyLevel: AutonomyLevel;
workspaceIds?: string[];
teamId?: string;
/** Required — Agent Builder hard gate. */
memoryScopes: Scope[];
skillIds?: string[];
connectorIds?: string[];
mcpIds?: string[];
permissions?: Record<string, unknown>;
status: AgentRunState;
createdBy?: string;
createdAt: string;
updatedAt: string;
}
interface AgentsFile {
agents: AgentRecord[];
}
function agentsFilePath(dataDir: string): string {
return path.join(dataDir, 'agents.json');
}
/** Read the agent index. Empty list on a missing/corrupt file. */
export function readAgents(dataDir: string): AgentRecord[] {
const filePath = agentsFilePath(dataDir);
try {
if (fs.existsSync(filePath)) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as AgentsFile;
return Array.isArray(parsed.agents) ? parsed.agents : [];
}
} catch {
// Corrupted file — degrade to empty rather than throw.
}
return [];
}
/** Atomic write: temp file in the same directory, then rename over the target. */
function writeAgents(dataDir: string, agents: AgentRecord[]): void {
const filePath = agentsFilePath(dataDir);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify({ agents }, null, 2), 'utf-8');
try {
fs.renameSync(tmpPath, filePath);
} catch (err) {
// Windows AV/file-lock on the target is a real occurrence — don't orphan
// the temp file when the swap fails; surface the original error.
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
throw err;
}
}
/** Fields a caller may supply on create. id/createdAt/updatedAt assigned here. */
export type NewAgentInput = Omit<AgentRecord, 'id' | 'createdAt' | 'updatedAt'>;
/** Append a new agent record and return the saved row. */
export function addAgent(dataDir: string, input: NewAgentInput): AgentRecord {
const now = new Date().toISOString();
const record: AgentRecord = {
...input,
id: `agent_${randomUUID()}`,
createdAt: now,
updatedAt: now,
};
const agents = readAgents(dataDir);
writeAgents(dataDir, [...agents, record]);
return record;
}
/** Find one agent by id. */
export function getAgent(dataDir: string, id: string): AgentRecord | undefined {
return readAgents(dataDir).find((a) => a.id === id);
}
/** Apply a partial update. Immutable fields (id/createdAt) are never overwritten;
* updatedAt is restamped. Returns the updated record, or undefined if absent. */
export function patchAgent(
dataDir: string,
id: string,
patch: Partial<AgentRecord>,
): AgentRecord | undefined {
const agents = readAgents(dataDir);
const idx = agents.findIndex((a) => a.id === id);
if (idx === -1) return undefined;
const existing = agents[idx];
const updated: AgentRecord = {
...existing,
...patch,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
const next = agents.map((a, i) => (i === idx ? updated : a));
writeAgents(dataDir, next);
return updated;
}
/** Remove an agent record (hard delete). Returns whether a row was removed. */
export function deleteAgent(dataDir: string, id: string): boolean {
const agents = readAgents(dataDir);
const next = agents.filter((a) => a.id !== id);
if (next.length === agents.length) return false;
writeAgents(dataDir, next);
return true;
}