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