refactor(server): switch agent registry to AgentStorage
This commit is contained in:
@@ -75,13 +75,13 @@ DEEPGRAM_API_KEY=... # Streaming STT
|
||||
STT_MODEL=whisper-1 # Optional: override to gpt-4o-transcribe, etc.
|
||||
STT_CONFIDENCE_THRESHOLD=-3.0 # Optional: reject low-confidence clips
|
||||
STT_DEBUG_AUDIO_DIR=.stt-debug # Optional: persist raw dictation audio for debugging
|
||||
PASEO_HOME=~/.paseo # Runtime state directory (agents.json, etc.)
|
||||
PASEO_HOME=~/.paseo # Runtime state directory (agents/, etc.)
|
||||
PASEO_PORT=6767 # Voice server + MCP port (falls back to 6767)
|
||||
PORT=3000 # Server port
|
||||
NODE_ENV=development # Environment
|
||||
```
|
||||
|
||||
`PASEO_HOME` defaults to `~/.paseo` and isolates runtime artifacts like `agents.json`. `PASEO_PORT` takes precedence over `PORT` and controls both the HTTP server and MCP endpoint. For blue/green testing you can run a parallel server without touching production state:
|
||||
`PASEO_HOME` defaults to `~/.paseo` and isolates runtime artifacts like `agents/`. `PASEO_PORT` takes precedence over `PORT` and controls both the HTTP server and MCP endpoint. For blue/green testing you can run a parallel server without touching production state:
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue PASEO_PORT=7777 npm run dev
|
||||
|
||||
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentRunResult,
|
||||
@@ -106,13 +106,13 @@ describe("AgentManager", () => {
|
||||
|
||||
test("normalizeConfig does not inject default model when omitted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-without-model",
|
||||
});
|
||||
@@ -127,13 +127,13 @@ describe("AgentManager", () => {
|
||||
|
||||
test("createAgent persists provided title before returning", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-with-title",
|
||||
});
|
||||
@@ -147,20 +147,20 @@ describe("AgentManager", () => {
|
||||
expect(snapshot.id).toBe("agent-with-title");
|
||||
expect(snapshot.lifecycle).toBe("idle");
|
||||
|
||||
const persisted = await registry.get("agent-with-title");
|
||||
const persisted = await storage.get("agent-with-title");
|
||||
expect(persisted?.title).toBe("Fix Login Bug");
|
||||
expect(persisted?.id).toBe("agent-with-title");
|
||||
});
|
||||
|
||||
test("createAgent populates runtimeInfo after session creation", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-with-runtime-info",
|
||||
});
|
||||
@@ -179,13 +179,13 @@ describe("AgentManager", () => {
|
||||
|
||||
test("runAgent refreshes runtimeInfo after completion", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-with-run-runtime",
|
||||
});
|
||||
@@ -205,14 +205,14 @@ describe("AgentManager", () => {
|
||||
|
||||
test("listAgents excludes internal agents", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
let agentCounter = 0;
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => `agent-${agentCounter++}`,
|
||||
});
|
||||
@@ -239,13 +239,13 @@ describe("AgentManager", () => {
|
||||
|
||||
test("getAgent returns internal agents by ID", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "internal-agent",
|
||||
});
|
||||
@@ -264,14 +264,14 @@ describe("AgentManager", () => {
|
||||
|
||||
test("subscribe does not emit state events for internal agents to global subscribers", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
let agentCounter = 0;
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => `agent-${agentCounter++}`,
|
||||
});
|
||||
@@ -305,13 +305,13 @@ describe("AgentManager", () => {
|
||||
|
||||
test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "internal-agent",
|
||||
});
|
||||
@@ -340,14 +340,14 @@ describe("AgentManager", () => {
|
||||
|
||||
test("onAgentAttention is not called for internal agents", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const attentionCalls: string[] = [];
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "internal-agent",
|
||||
onAgentAttention: ({ agentId }) => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentRegistry } from "./agent-registry.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
||||
|
||||
@@ -57,7 +57,7 @@ export type AgentManagerOptions = {
|
||||
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
maxTimelineItems?: number;
|
||||
idFactory?: () => string;
|
||||
registry?: AgentRegistry;
|
||||
registry?: AgentStorage;
|
||||
agentControlMcp?: AgentControlMcpConfig;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
logger: Logger;
|
||||
@@ -187,7 +187,7 @@ export class AgentManager {
|
||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||
private readonly maxTimelineItems: number;
|
||||
private readonly idFactory: () => string;
|
||||
private readonly registry?: AgentRegistry;
|
||||
private readonly registry?: AgentStorage;
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly agentControlMcp?: AgentControlMcpConfig;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("agent MCP end-to-end", () => {
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
agentControlMcp: {
|
||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
||||
},
|
||||
@@ -234,7 +234,7 @@ describe("agent MCP end-to-end", () => {
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
agentControlMcp: {
|
||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentSnapshotPayload } from "../messages.js";
|
||||
import type {
|
||||
SerializableAgentConfig,
|
||||
StoredAgentRecord,
|
||||
} from "./agent-registry.js";
|
||||
} from "./agent-storage.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentMetadata,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import type {
|
||||
@@ -132,11 +132,11 @@ describe("self-identification MCP tools", () => {
|
||||
worktreeSlug: "self-ident",
|
||||
});
|
||||
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-self-ident",
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe("self-identification MCP tools", () => {
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
agentStorage: storage,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
@@ -183,11 +183,11 @@ describe("self-identification MCP tools", () => {
|
||||
const nestedDir = path.join(worktree.worktreePath, "nested");
|
||||
execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" });
|
||||
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-self-ident-subdir",
|
||||
});
|
||||
@@ -199,7 +199,7 @@ describe("self-identification MCP tools", () => {
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
agentStorage: storage,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
@@ -225,11 +225,11 @@ describe("self-identification MCP tools", () => {
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-non-worktree",
|
||||
});
|
||||
@@ -241,7 +241,7 @@ describe("self-identification MCP tools", () => {
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
agentStorage: storage,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
@@ -259,11 +259,11 @@ describe("self-identification MCP tools", () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
|
||||
try {
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-non-git",
|
||||
});
|
||||
@@ -275,7 +275,7 @@ describe("self-identification MCP tools", () => {
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
agentStorage: storage,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
|
||||
+32
-61
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -91,16 +91,16 @@ function createManagedAgent(
|
||||
return agent;
|
||||
}
|
||||
|
||||
describe("AgentRegistry", () => {
|
||||
describe("AgentStorage", () => {
|
||||
let tmpDir: string;
|
||||
let filePath: string;
|
||||
let registry: AgentRegistry;
|
||||
let storagePath: string;
|
||||
let storage: AgentStorage;
|
||||
const logger = createTestLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agent-registry-"));
|
||||
filePath = path.join(tmpDir, "agents.json");
|
||||
registry = new AgentRegistry(filePath, logger);
|
||||
storagePath = path.join(tmpDir, "agents");
|
||||
storage = new AgentStorage(storagePath, logger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,7 +108,7 @@ describe("AgentRegistry", () => {
|
||||
});
|
||||
|
||||
test("applySnapshot persists configs and snapshot metadata", async () => {
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-1",
|
||||
cwd: "/tmp/project",
|
||||
@@ -122,7 +122,7 @@ describe("AgentRegistry", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const records = await registry.list();
|
||||
const records = await storage.list();
|
||||
expect(records).toHaveLength(1);
|
||||
const [record] = records;
|
||||
expect(record.provider).toBe("claude");
|
||||
@@ -131,7 +131,7 @@ describe("AgentRegistry", () => {
|
||||
expect(record.lastModeId).toBe("coding");
|
||||
expect(record.lastStatus).toBe("idle");
|
||||
|
||||
const reloaded = new AgentRegistry(filePath, logger);
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const [persisted] = await reloaded.list();
|
||||
expect(persisted.cwd).toBe("/tmp/project");
|
||||
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
|
||||
@@ -140,14 +140,14 @@ describe("AgentRegistry", () => {
|
||||
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||
const agentId = "agent-created-at";
|
||||
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({ id: agentId, createdAt: firstTimestamp })
|
||||
);
|
||||
|
||||
const initialRecord = await registry.get(agentId);
|
||||
const initialRecord = await storage.get(agentId);
|
||||
expect(initialRecord?.createdAt).toBe(firstTimestamp.toISOString());
|
||||
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
createdAt: new Date("2025-02-01T00:00:00.000Z"),
|
||||
@@ -156,57 +156,57 @@ describe("AgentRegistry", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const updatedRecord = await registry.get(agentId);
|
||||
const updatedRecord = await storage.get(agentId);
|
||||
expect(updatedRecord?.createdAt).toBe(firstTimestamp.toISOString());
|
||||
expect(updatedRecord?.lastStatus).toBe("running");
|
||||
});
|
||||
|
||||
test("stores titles independently of snapshots", async () => {
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-2",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/second",
|
||||
})
|
||||
);
|
||||
await registry.setTitle("agent-2", "Fix Login Bug");
|
||||
await storage.setTitle("agent-2", "Fix Login Bug");
|
||||
|
||||
const current = await registry.get("agent-2");
|
||||
const current = await storage.get("agent-2");
|
||||
expect(current?.title).toBe("Fix Login Bug");
|
||||
|
||||
const reloaded = new AgentRegistry(filePath, logger);
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-2");
|
||||
expect(persisted?.title).toBe("Fix Login Bug");
|
||||
});
|
||||
|
||||
test("setTitle throws when the agent record does not exist", async () => {
|
||||
await expect(registry.setTitle("missing-agent", "Impossible"))
|
||||
await expect(storage.setTitle("missing-agent", "Impossible"))
|
||||
.rejects.toThrow("Agent missing-agent not found");
|
||||
});
|
||||
|
||||
test("applySnapshot accepts explicit title overrides", async () => {
|
||||
const agentId = "agent-override";
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({ id: agentId }),
|
||||
{ title: "Provided Title" }
|
||||
);
|
||||
|
||||
const record = await registry.get(agentId);
|
||||
const record = await storage.get(agentId);
|
||||
expect(record?.title).toBe("Provided Title");
|
||||
});
|
||||
|
||||
test("applySnapshot preserves custom titles while updating metadata", async () => {
|
||||
const agentId = "agent-3";
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
lifecycle: "idle",
|
||||
currentModeId: "plan",
|
||||
})
|
||||
);
|
||||
await registry.setTitle(agentId, "Important Bug Fix");
|
||||
await storage.setTitle(agentId, "Important Bug Fix");
|
||||
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
lifecycle: "running",
|
||||
@@ -215,44 +215,15 @@ describe("AgentRegistry", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const record = await registry.get(agentId);
|
||||
const record = await storage.get(agentId);
|
||||
expect(record?.title).toBe("Important Bug Fix");
|
||||
expect(record?.lastModeId).toBe("build");
|
||||
expect(record?.lastStatus).toBe("running");
|
||||
});
|
||||
|
||||
test("recovers from trailing garbage in agents.json", async () => {
|
||||
const payload = [
|
||||
{
|
||||
id: "agent-4",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
title: "Recovered agent",
|
||||
lastStatus: "idle",
|
||||
lastModeId: "plan",
|
||||
config: null,
|
||||
persistence: null,
|
||||
},
|
||||
];
|
||||
writeFileSync(
|
||||
filePath,
|
||||
`${JSON.stringify(payload, null, 2)}\nGARBAGE-TRAILING`
|
||||
);
|
||||
|
||||
const reloaded = new AgentRegistry(filePath, logger);
|
||||
const result = await reloaded.list();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.title).toBe("Recovered agent");
|
||||
|
||||
const sanitized = readFileSync(filePath, "utf8");
|
||||
expect(sanitized.includes("GARBAGE-TRAILING")).toBe(false);
|
||||
});
|
||||
|
||||
test("list returns all agents including internal ones", async () => {
|
||||
// Create a normal agent
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "normal-agent",
|
||||
cwd: "/tmp/project",
|
||||
@@ -260,7 +231,7 @@ describe("AgentRegistry", () => {
|
||||
);
|
||||
|
||||
// Create an internal agent
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "internal-agent",
|
||||
cwd: "/tmp/project",
|
||||
@@ -270,12 +241,12 @@ describe("AgentRegistry", () => {
|
||||
);
|
||||
|
||||
// Registry should return all agents - filtering is done at the manager level
|
||||
const records = await registry.list();
|
||||
const records = await storage.list();
|
||||
expect(records).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("get returns internal agents by ID", async () => {
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "internal-agent",
|
||||
cwd: "/tmp/project",
|
||||
@@ -284,13 +255,13 @@ describe("AgentRegistry", () => {
|
||||
{ internal: true }
|
||||
);
|
||||
|
||||
const record = await registry.get("internal-agent");
|
||||
const record = await storage.get("internal-agent");
|
||||
expect(record).not.toBeNull();
|
||||
expect(record?.internal).toBe(true);
|
||||
});
|
||||
|
||||
test("internal flag is persisted and reloaded", async () => {
|
||||
await registry.applySnapshot(
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "internal-agent",
|
||||
cwd: "/tmp/project",
|
||||
@@ -300,7 +271,7 @@ describe("AgentRegistry", () => {
|
||||
);
|
||||
|
||||
// Reload the registry from disk
|
||||
const reloaded = new AgentRegistry(filePath, logger);
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const record = await reloaded.get("internal-agent");
|
||||
expect(record?.internal).toBe(true);
|
||||
|
||||
+175
-102
@@ -64,19 +64,112 @@ export type SerializableAgentConfig = Pick<
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
|
||||
export class AgentRegistry {
|
||||
export class AgentStorage {
|
||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||
private pathById: Map<string, string> = new Map();
|
||||
private loaded = false;
|
||||
private filePath: string;
|
||||
private baseDir: string;
|
||||
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
this.filePath = filePath;
|
||||
this.logger = logger.child({ module: "agent", component: "agent-registry" });
|
||||
constructor(baseDir: string, logger: Logger) {
|
||||
this.baseDir = baseDir;
|
||||
this.logger = logger.child({ module: "agent", component: "agent-storage" });
|
||||
}
|
||||
|
||||
async load(): Promise<StoredAgentRecord[]> {
|
||||
async list(): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
||||
await this.load();
|
||||
const cached = this.cache.get(agentId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const discoveredPath = await this.findAgentPathById(agentId);
|
||||
if (!discoveredPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = await this.readRecordFile(discoveredPath);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cache.set(record.id, record);
|
||||
this.pathById.set(record.id, discoveredPath);
|
||||
return record;
|
||||
}
|
||||
|
||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
await this.load();
|
||||
const nextPath = this.buildRecordPath(record);
|
||||
const previousPath = this.pathById.get(record.id);
|
||||
|
||||
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
||||
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
||||
|
||||
if (previousPath && previousPath !== nextPath) {
|
||||
try {
|
||||
await fs.unlink(previousPath);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(record.id, record);
|
||||
this.pathById.set(record.id, nextPath);
|
||||
}
|
||||
|
||||
async remove(agentId: string): Promise<void> {
|
||||
await this.load();
|
||||
const existingPath =
|
||||
this.pathById.get(agentId) ?? (await this.findAgentPathById(agentId));
|
||||
if (existingPath) {
|
||||
try {
|
||||
await fs.unlink(existingPath);
|
||||
} catch {
|
||||
// ignore removal errors
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.delete(agentId);
|
||||
this.pathById.delete(agentId);
|
||||
}
|
||||
|
||||
async applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean }
|
||||
): Promise<void> {
|
||||
await this.load();
|
||||
const existing = (await this.get(agent.id)) ?? null;
|
||||
const hasTitleOverride =
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
|
||||
const hasInternalOverride =
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
|
||||
const record = toStoredAgentRecord(agent, {
|
||||
title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
|
||||
createdAt: existing?.createdAt,
|
||||
internal: hasInternalOverride
|
||||
? options?.internal
|
||||
: (agent.internal ?? existing?.internal),
|
||||
});
|
||||
await this.upsert(record);
|
||||
}
|
||||
|
||||
async setTitle(agentId: string, title: string): Promise<void> {
|
||||
await this.load();
|
||||
const record = await this.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
await this.upsert({ ...record, title });
|
||||
}
|
||||
|
||||
private async load(): Promise<StoredAgentRecord[]> {
|
||||
if (this.loaded) {
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
@@ -89,140 +182,120 @@ export class AgentRegistry {
|
||||
}
|
||||
|
||||
private async doLoad(): Promise<StoredAgentRecord[]> {
|
||||
this.cache.clear();
|
||||
this.pathById.clear();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(this.filePath, "utf8");
|
||||
const parsed = await this.parseContent(content);
|
||||
const records = await this.scanDisk();
|
||||
this.loaded = true;
|
||||
return parsed;
|
||||
return records;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
this.loaded = true;
|
||||
this.cache.clear();
|
||||
return [];
|
||||
}
|
||||
this.logger.error({ err: error }, "Failed to load agents");
|
||||
this.loaded = true;
|
||||
this.cache.clear();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<StoredAgentRecord[]> {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
||||
await this.load();
|
||||
return this.cache.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
await this.load();
|
||||
this.cache.set(record.id, record);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
async remove(agentId: string): Promise<void> {
|
||||
await this.load();
|
||||
this.cache.delete(agentId);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
async applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean }
|
||||
): Promise<void> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(agent.id);
|
||||
const hasTitleOverride =
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
|
||||
const hasInternalOverride =
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
|
||||
const record = toStoredAgentRecord(agent, {
|
||||
title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
|
||||
createdAt: existing?.createdAt,
|
||||
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
|
||||
});
|
||||
this.cache.set(agent.id, record);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
async setTitle(agentId: string, title: string): Promise<void> {
|
||||
await this.load();
|
||||
const record = this.cache.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
this.cache.set(agentId, { ...record, title });
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
const payload = JSON.stringify(Array.from(this.cache.values()), null, 2);
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await writeFileAtomically(this.filePath, payload);
|
||||
}
|
||||
|
||||
private async parseContent(content: string): Promise<StoredAgentRecord[]> {
|
||||
private async scanDisk(): Promise<StoredAgentRecord[]> {
|
||||
const records: StoredAgentRecord[] = [];
|
||||
let entries: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
return this.parseRecords(content);
|
||||
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const recovered = await this.tryRecoverCorruptedContent(content);
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
}
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseRecords(content: string): StoredAgentRecord[] {
|
||||
const parsed = JSON.parse(content);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Invalid agents.json format");
|
||||
}
|
||||
const records: StoredAgentRecord[] = [];
|
||||
for (const entry of parsed) {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const projectDir = path.join(this.baseDir, entry.name);
|
||||
let files: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
const record = STORED_AGENT_SCHEMA.parse(entry);
|
||||
files = await fs.readdir(projectDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.isFile() || !file.name.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(projectDir, file.name);
|
||||
const record = await this.readRecordFile(filePath);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, "Skipping invalid record");
|
||||
this.pathById.set(record.id, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private async tryRecoverCorruptedContent(
|
||||
content: string
|
||||
): Promise<StoredAgentRecord[] | null> {
|
||||
const start = content.indexOf("[");
|
||||
const end = content.lastIndexOf("]");
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
const candidate = content.slice(start, end + 1);
|
||||
private async readRecordFile(filePath: string): Promise<StoredAgentRecord | null> {
|
||||
try {
|
||||
this.cache.clear();
|
||||
const records = this.parseRecords(candidate);
|
||||
this.logger.warn("Recovered corrupted agents.json payload; rewrote sanitized copy");
|
||||
const sanitizedPayload = JSON.stringify(records, null, 2);
|
||||
await writeFileAtomically(this.filePath, sanitizedPayload);
|
||||
return records;
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(content);
|
||||
return STORED_AGENT_SCHEMA.parse(parsed);
|
||||
} catch (error) {
|
||||
this.cache.clear();
|
||||
this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildRecordPath(record: StoredAgentRecord): string {
|
||||
const projectDir = projectDirNameFromCwd(record.cwd);
|
||||
return path.join(this.baseDir, projectDir, `${record.id}.json`);
|
||||
}
|
||||
|
||||
private async findAgentPathById(agentId: string): Promise<string | null> {
|
||||
let projects: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
projects = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(this.baseDir, project.name, `${agentId}.json`);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// not here
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectDirNameFromCwd(cwd: string): string {
|
||||
const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
|
||||
if (!trimmed) {
|
||||
return "root";
|
||||
}
|
||||
return trimmed.replace(/[\\/]+/g, "-");
|
||||
}
|
||||
|
||||
async function writeFileAtomically(targetPath: string, payload: string) {
|
||||
const directory = path.dirname(targetPath);
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.agents.json.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
|
||||
`.agent.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
|
||||
);
|
||||
await fs.writeFile(tempPath, payload, "utf8");
|
||||
await fs.rename(tempPath, targetPath);
|
||||
@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentRegistry } from "./agent-registry.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
type TestDeps = {
|
||||
agentManager: AgentManager;
|
||||
agentRegistry: AgentRegistry;
|
||||
agentStorage: AgentStorage;
|
||||
spies: {
|
||||
agentManager: Record<string, any>;
|
||||
agentRegistry: Record<string, any>;
|
||||
agentStorage: Record<string, any>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ function createTestDeps(): TestDeps {
|
||||
getPendingPermissions: vi.fn(),
|
||||
};
|
||||
|
||||
const agentRegistrySpies = {
|
||||
const agentStorageSpies = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
applySnapshot: vi.fn(),
|
||||
@@ -38,10 +38,10 @@ function createTestDeps(): TestDeps {
|
||||
|
||||
return {
|
||||
agentManager: agentManagerSpies as unknown as AgentManager,
|
||||
agentRegistry: agentRegistrySpies as unknown as AgentRegistry,
|
||||
agentStorage: agentStorageSpies as unknown as AgentStorage,
|
||||
spies: {
|
||||
agentManager: agentManagerSpies,
|
||||
agentRegistry: agentRegistrySpies,
|
||||
agentStorage: agentStorageSpies,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,8 +50,8 @@ describe("create_agent MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("requires a concise title no longer than 60 characters", async () => {
|
||||
const { agentManager, agentRegistry } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentRegistry, logger });
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
it("passes caller-provided titles directly into createAgent", async () => {
|
||||
const { agentManager, agentRegistry, spies } = createTestDeps();
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-123",
|
||||
cwd: "/tmp/repo",
|
||||
@@ -88,7 +88,7 @@ describe("create_agent MCP tool", () => {
|
||||
availableModes: [],
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentRegistry, logger });
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
await tool.callback({
|
||||
cwd: "/tmp/repo",
|
||||
@@ -104,14 +104,14 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
it("set_title trims and persists titles for caller agent", async () => {
|
||||
const { agentManager, agentRegistry, spies } = createTestDeps();
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "agent-1",
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
agentStorage,
|
||||
logger,
|
||||
callerAgentId: "agent-1",
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
createWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
@@ -36,7 +36,7 @@ import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
agentRegistry: AgentRegistry;
|
||||
agentStorage: AgentStorage;
|
||||
/**
|
||||
* ID of the agent that is connecting to this MCP server.
|
||||
* When set, create_agent will auto-inject this as parentAgentId.
|
||||
@@ -240,12 +240,12 @@ function sanitizePermissionRequest(
|
||||
}
|
||||
|
||||
async function resolveAgentTitle(
|
||||
agentRegistry: AgentRegistry,
|
||||
agentStorage: AgentStorage,
|
||||
agentId: string,
|
||||
logger: Logger
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const record = await agentRegistry.get(agentId);
|
||||
const record = await agentStorage.get(agentId);
|
||||
return record?.title ?? null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -257,18 +257,18 @@ async function resolveAgentTitle(
|
||||
}
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentRegistry: AgentRegistry,
|
||||
agentStorage: AgentStorage,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentRegistry, snapshot.id, logger);
|
||||
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
}
|
||||
|
||||
export async function createAgentMcpServer(
|
||||
options: AgentMcpServerOptions
|
||||
): Promise<McpServer> {
|
||||
const { agentManager, agentRegistry, callerAgentId, logger } = options;
|
||||
const { agentManager, agentStorage, callerAgentId, logger } = options;
|
||||
const childLogger = logger.child({ module: "agent", component: "mcp-server" });
|
||||
const waitTracker = new WaitForAgentTracker(logger);
|
||||
|
||||
@@ -776,7 +776,7 @@ export async function createAgentMcpServer(
|
||||
}
|
||||
|
||||
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||
agentRegistry,
|
||||
agentStorage,
|
||||
snapshot,
|
||||
childLogger
|
||||
);
|
||||
@@ -804,7 +804,7 @@ export async function createAgentMcpServer(
|
||||
const snapshots = agentManager.listAgents();
|
||||
const agents = await Promise.all(
|
||||
snapshots.map((snapshot) =>
|
||||
serializeSnapshotWithMetadata(agentRegistry, snapshot, childLogger)
|
||||
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
|
||||
)
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentRegistry } from "../agent-registry.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import { createAgentMcpServer } from "../mcp-server.js";
|
||||
|
||||
const createHTTPServer = createServer;
|
||||
@@ -116,11 +116,11 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const httpServer = createHTTPServer(app);
|
||||
|
||||
const registryDir = mkdtempSync(path.join(os.tmpdir(), "agent-mcp-registry-"));
|
||||
const registryPath = path.join(registryDir, "agents.json");
|
||||
const agentRegistry = new AgentRegistry(registryPath, testLogger);
|
||||
const storagePath = path.join(registryDir, "agents");
|
||||
const agentStorage = new AgentStorage(storagePath, testLogger);
|
||||
const agentManager = new AgentManager({
|
||||
clients: {},
|
||||
registry: agentRegistry,
|
||||
registry: agentStorage,
|
||||
logger: testLogger,
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
agentStorage,
|
||||
callerAgentId,
|
||||
logger: testLogger,
|
||||
});
|
||||
|
||||
@@ -42,8 +42,8 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { OpenAISTT, type STTConfig } from "./agent/stt-openai.js";
|
||||
import { OpenAITTS, type TTSConfig } from "./agent/tts-openai.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentRegistry } from "./agent/agent-registry.js";
|
||||
import { attachAgentRegistryPersistence } from "./persistence-hooks.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
@@ -77,7 +77,7 @@ export type PaseoDaemonConfig = {
|
||||
staticDir: string;
|
||||
mcpDebug: boolean;
|
||||
agentClients: Partial<Record<AgentProvider, AgentClient>>;
|
||||
agentRegistryPath: string;
|
||||
agentStoragePath: string;
|
||||
agentControlMcp: AgentControlMcpConfig;
|
||||
relayEnabled?: boolean;
|
||||
relayEndpoint?: string;
|
||||
@@ -89,7 +89,7 @@ export type PaseoDaemonConfig = {
|
||||
export interface PaseoDaemon {
|
||||
config: PaseoDaemonConfig;
|
||||
agentManager: AgentManager;
|
||||
agentRegistry: AgentRegistry;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager: TerminalManager;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -98,7 +98,7 @@ export interface PaseoDaemon {
|
||||
export async function createPaseoDaemon(
|
||||
config: PaseoDaemonConfig,
|
||||
rootLogger: Logger
|
||||
): Promise<PaseoDaemon> {
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
const connectionSessionId = randomUUID();
|
||||
let relayTransport: RelayTransportController | null = null;
|
||||
@@ -204,21 +204,21 @@ export async function createPaseoDaemon(
|
||||
|
||||
const httpServer = createHTTPServer(app);
|
||||
|
||||
const agentRegistry = new AgentRegistry(config.agentRegistryPath, logger);
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const agentManager = new AgentManager({
|
||||
clients: {
|
||||
...createAllClients(logger),
|
||||
...config.agentClients,
|
||||
},
|
||||
registry: agentRegistry,
|
||||
registry: agentStorage,
|
||||
agentControlMcp: config.agentControlMcp,
|
||||
logger,
|
||||
});
|
||||
|
||||
const terminalManager = createTerminalManager();
|
||||
|
||||
attachAgentRegistryPersistence(logger, agentManager, agentRegistry);
|
||||
const persistedRecords = await agentRegistry.list();
|
||||
attachAgentStoragePersistence(logger, agentManager, agentStorage);
|
||||
const persistedRecords = await agentStorage.list();
|
||||
logger.info(
|
||||
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
|
||||
);
|
||||
@@ -229,7 +229,7 @@ export async function createPaseoDaemon(
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
agentStorage,
|
||||
callerAgentId,
|
||||
logger,
|
||||
});
|
||||
@@ -374,7 +374,7 @@ export async function createPaseoDaemon(
|
||||
httpServer,
|
||||
logger,
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
agentStorage,
|
||||
downloadTokenStore,
|
||||
config.paseoHome,
|
||||
{
|
||||
@@ -470,7 +470,7 @@ export async function createPaseoDaemon(
|
||||
return {
|
||||
config,
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
start,
|
||||
stop,
|
||||
|
||||
@@ -83,7 +83,7 @@ export function loadConfig(
|
||||
agentControlMcp: {
|
||||
url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
|
||||
},
|
||||
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
relayEnabled: true,
|
||||
|
||||
@@ -56,7 +56,7 @@ async function startDaemon(options: {
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentRegistryPath: path.join(options.paseoHome, "agents.json"),
|
||||
agentStoragePath: path.join(options.paseoHome, "agents"),
|
||||
agentControlMcp: {
|
||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
||||
},
|
||||
@@ -184,9 +184,9 @@ describe("daemon restart and agent resume", () => {
|
||||
await stopDaemon(currentDaemon);
|
||||
currentDaemon = null;
|
||||
|
||||
// Verify agents.json was persisted
|
||||
const agentsJsonPath = path.join(paseoHome, "agents.json");
|
||||
expect(existsSync(agentsJsonPath)).toBe(true);
|
||||
// Verify agent storage was persisted
|
||||
const agentsDir = path.join(paseoHome, "agents");
|
||||
expect(existsSync(agentsDir)).toBe(true);
|
||||
|
||||
// === PHASE 3: Start a NEW daemon with the SAME paseoHome ===
|
||||
currentDaemon = await startDaemon({ paseoHome, staticDir });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-registry.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import {
|
||||
attachAgentRegistryPersistence,
|
||||
attachAgentStoragePersistence,
|
||||
} from "./persistence-hooks.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -108,7 +108,7 @@ function createRecord(
|
||||
}
|
||||
|
||||
describe("persistence hooks", () => {
|
||||
test("attachAgentRegistryPersistence forwards agent snapshots", async () => {
|
||||
test("attachAgentStoragePersistence forwards agent snapshots", async () => {
|
||||
const applySnapshot = vi.fn().mockResolvedValue(undefined);
|
||||
let subscriber: (event: any) => void = () => {
|
||||
throw new Error("Agent manager subscriber was not registered");
|
||||
@@ -123,7 +123,7 @@ describe("persistence hooks", () => {
|
||||
};
|
||||
}),
|
||||
};
|
||||
attachAgentRegistryPersistence(testLogger, agentManager as any, {
|
||||
attachAgentStoragePersistence(testLogger, agentManager as any, {
|
||||
applySnapshot,
|
||||
list: vi.fn(),
|
||||
} as any);
|
||||
|
||||
@@ -4,9 +4,9 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type {
|
||||
AgentRegistry,
|
||||
AgentStorage,
|
||||
StoredAgentRecord,
|
||||
} from "./agent/agent-registry.js";
|
||||
} from "./agent/agent-storage.js";
|
||||
|
||||
type LoggerLike = {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
@@ -17,7 +17,7 @@ function getLogger(logger: LoggerLike): LoggerLike {
|
||||
return logger.child({ module: "persistence" });
|
||||
}
|
||||
|
||||
type AgentRegistryPersistence = Pick<AgentRegistry, "applySnapshot" | "list">;
|
||||
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
|
||||
function isKnownProvider(provider: string): provider is AgentProvider {
|
||||
@@ -25,20 +25,20 @@ function isKnownProvider(provider: string): provider is AgentProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach AgentRegistry persistence to an AgentManager instance so every
|
||||
* Attach AgentStorage persistence to an AgentManager instance so every
|
||||
* agent_state snapshot is flushed to disk.
|
||||
*/
|
||||
export function attachAgentRegistryPersistence(
|
||||
export function attachAgentStoragePersistence(
|
||||
logger: LoggerLike,
|
||||
agentManager: AgentManagerStateSource,
|
||||
registry: AgentRegistryPersistence
|
||||
storage: AgentStoragePersistence
|
||||
): () => void {
|
||||
const log = getLogger(logger);
|
||||
const unsubscribe = agentManager.subscribe((event) => {
|
||||
if (event.type !== "agent_state") {
|
||||
return;
|
||||
}
|
||||
void registry.applySnapshot(event.agent).catch((error) => {
|
||||
void storage.applySnapshot(event.agent).catch((error) => {
|
||||
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { AgentRegistry, type StoredAgentRecord } from "./agent/agent-registry.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||
import {
|
||||
listDirectoryEntries,
|
||||
@@ -307,7 +307,7 @@ export class Session {
|
||||
> | null = null;
|
||||
private agentTools: ToolSet | null = null;
|
||||
private agentManager: AgentManager;
|
||||
private readonly agentRegistry: AgentRegistry;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly agentMcpConfig: AgentMcpClientConfig;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
@@ -329,7 +329,7 @@ export class Session {
|
||||
downloadTokenStore: DownloadTokenStore,
|
||||
pushTokenStore: PushTokenStore,
|
||||
agentManager: AgentManager,
|
||||
agentRegistry: AgentRegistry,
|
||||
agentStorage: AgentStorage,
|
||||
agentMcpConfig: AgentMcpClientConfig,
|
||||
stt: OpenAISTT | null,
|
||||
tts: OpenAITTS | null,
|
||||
@@ -342,7 +342,7 @@ export class Session {
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.pushTokenStore = pushTokenStore;
|
||||
this.agentManager = agentManager;
|
||||
this.agentRegistry = agentRegistry;
|
||||
this.agentStorage = agentStorage;
|
||||
this.agentMcpConfig = agentMcpConfig;
|
||||
this.terminalManager = terminalManager;
|
||||
this.voiceConversationStore = voiceConversationStore;
|
||||
@@ -653,7 +653,7 @@ export class Session {
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
@@ -704,7 +704,7 @@ export class Session {
|
||||
|
||||
private async getStoredAgentTitle(agentId: string): Promise<string | null> {
|
||||
try {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
const title = record?.title ?? null;
|
||||
return title;
|
||||
} catch (error) {
|
||||
@@ -1105,7 +1105,7 @@ export class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.agentRegistry.remove(agentId);
|
||||
await this.agentStorage.remove(agentId);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
@@ -1748,7 +1748,7 @@ export class Session {
|
||||
snapshot = existing;
|
||||
}
|
||||
} else {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
@@ -3133,19 +3133,19 @@ export class Session {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
try {
|
||||
await this.agentRegistry.remove(agent.id);
|
||||
await this.agentStorage.remove(agent.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registryRecords = await this.agentRegistry.list();
|
||||
const registryRecords = await this.agentStorage.list();
|
||||
for (const record of registryRecords) {
|
||||
if (this.isPathWithinRoot(targetPath, record.cwd)) {
|
||||
removedAgents.add(record.id);
|
||||
try {
|
||||
await this.agentRegistry.remove(record.id);
|
||||
await this.agentStorage.remove(record.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
@@ -3604,7 +3604,7 @@ export class Session {
|
||||
);
|
||||
|
||||
// Add persisted agents that have not been lazily initialized yet
|
||||
const registryRecords = await this.agentRegistry.list();
|
||||
const registryRecords = await this.agentStorage.list();
|
||||
const liveIds = new Set(agentSnapshots.map((a) => a.id));
|
||||
const persistedAgents = registryRecords
|
||||
.filter((record) => !liveIds.has(record.id))
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function createTestPaseoDaemon(
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
agentControlMcp: {
|
||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WebSocketServer } from "ws";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentRegistry } from "./agent/agent-registry.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAISTT } from "./agent/stt-openai.js";
|
||||
import type { OpenAITTS } from "./agent/tts-openai.js";
|
||||
@@ -31,7 +31,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
server: HTTPServer,
|
||||
logger: pino.Logger,
|
||||
agentManager: AgentManager,
|
||||
agentRegistry: AgentRegistry,
|
||||
agentStorage: AgentStorage,
|
||||
downloadTokenStore: DownloadTokenStore,
|
||||
paseoHome: string,
|
||||
agentMcpConfig: AgentMcpClientConfig,
|
||||
@@ -43,7 +43,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.bridge = new WebSocketSessionBridge(
|
||||
this.logger,
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
agentStorage,
|
||||
downloadTokenStore,
|
||||
paseoHome,
|
||||
agentMcpConfig,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./messages.js";
|
||||
import { Session } from "./session.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentRegistry } from "./agent/agent-registry.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
@@ -29,7 +29,7 @@ export class WebSocketSessionBridge {
|
||||
private readonly sessions: Map<WebSocket, Session> = new Map();
|
||||
private clientIdCounter = 0;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentRegistry: AgentRegistry;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly pushService: PushService;
|
||||
@@ -42,7 +42,7 @@ export class WebSocketSessionBridge {
|
||||
constructor(
|
||||
logger: pino.Logger,
|
||||
agentManager: AgentManager,
|
||||
agentRegistry: AgentRegistry,
|
||||
agentStorage: AgentStorage,
|
||||
downloadTokenStore: DownloadTokenStore,
|
||||
paseoHome: string,
|
||||
agentMcpConfig: AgentMcpClientConfig,
|
||||
@@ -51,7 +51,7 @@ export class WebSocketSessionBridge {
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-session-bridge" });
|
||||
this.agentManager = agentManager;
|
||||
this.agentRegistry = agentRegistry;
|
||||
this.agentStorage = agentStorage;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.agentMcpConfig = agentMcpConfig;
|
||||
this.stt = speech?.stt ?? null;
|
||||
@@ -87,7 +87,7 @@ export class WebSocketSessionBridge {
|
||||
this.downloadTokenStore,
|
||||
this.pushTokenStore,
|
||||
this.agentManager,
|
||||
this.agentRegistry,
|
||||
this.agentStorage,
|
||||
this.agentMcpConfig,
|
||||
this.stt,
|
||||
this.tts,
|
||||
|
||||
Reference in New Issue
Block a user