From be419110836078dcf5980602e58eb4f054b6afbe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 28 Mar 2026 00:25:04 +0700 Subject: [PATCH] feat: add loop, schedule, and chat CLI commands (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add metrics collection and terminal performance tests * feat: add loop, schedule, and chat commands with slash-namespaced RPC Introduce three new server-side features with CLI command groups: - `paseo loop` — iterative agent execution with verify-check/verify-prompt - `paseo schedule` — recurring tasks on interval or cron cadence - `paseo chat` — chat rooms for agent-to-agent coordination All features use new slash-namespaced RPC methods (e.g. `loop/run`, `schedule/create`, `chat/post`) instead of flat message types, backed by file-based persistence and wired through the existing WebSocket session dispatch. * test: stabilize loop schedule chat rollout --- packages/cli/src/cli.ts | 12 + packages/cli/src/commands/chat/create.ts | 31 + packages/cli/src/commands/chat/delete.ts | 24 + packages/cli/src/commands/chat/index.ts | 75 ++ packages/cli/src/commands/chat/inspect.ts | 24 + packages/cli/src/commands/chat/ls.ts | 23 + packages/cli/src/commands/chat/post.ts | 39 + packages/cli/src/commands/chat/read.ts | 55 ++ packages/cli/src/commands/chat/schema.ts | 75 ++ packages/cli/src/commands/chat/shared.ts | 84 ++ packages/cli/src/commands/chat/wait.ts | 42 + packages/cli/src/commands/loop/index.ts | 34 + packages/cli/src/commands/loop/inspect.ts | 112 +++ packages/cli/src/commands/loop/logs.ts | 82 ++ packages/cli/src/commands/loop/ls.ts | 88 ++ packages/cli/src/commands/loop/run.ts | 146 +++ packages/cli/src/commands/loop/stop.ts | 80 ++ packages/cli/src/commands/loop/types.ts | 134 +++ packages/cli/src/commands/schedule/create.ts | 52 ++ packages/cli/src/commands/schedule/delete.ts | 43 + packages/cli/src/commands/schedule/index.ts | 68 ++ packages/cli/src/commands/schedule/inspect.ts | 32 + packages/cli/src/commands/schedule/logs.ts | 27 + packages/cli/src/commands/schedule/ls.ts | 32 + packages/cli/src/commands/schedule/pause.ts | 33 + packages/cli/src/commands/schedule/resume.ts | 33 + packages/cli/src/commands/schedule/schema.ts | 88 ++ packages/cli/src/commands/schedule/shared.ts | 195 ++++ packages/cli/src/commands/schedule/types.ts | 141 +++ packages/cli/tests/15-provider.test.ts | 20 +- packages/cli/tests/30-chat.test.ts | 78 ++ packages/cli/tests/31-loop-schedule.test.ts | 99 ++ packages/server/src/client/daemon-client.ts | 437 +++++++++ .../src/server/agent-attention-policy.ts | 4 +- packages/server/src/server/bootstrap.ts | 28 + .../src/server/chat/chat-rpc-schemas.ts | 116 +++ .../src/server/chat/chat-service.test.ts | 144 +++ .../server/src/server/chat/chat-service.ts | 439 +++++++++ packages/server/src/server/chat/chat-types.ts | 30 + .../server/src/server/loop-service.test.ts | 356 ++++++++ packages/server/src/server/loop-service.ts | 848 ++++++++++++++++++ .../server/src/server/loop/rpc-schemas.ts | 177 ++++ .../server/src/server/schedule/cron.test.ts | 28 + packages/server/src/server/schedule/cron.ts | 150 ++++ .../server/src/server/schedule/rpc-schemas.ts | 132 +++ .../src/server/schedule/service.test.ts | 229 +++++ .../server/src/server/schedule/service.ts | 472 ++++++++++ .../server/src/server/schedule/store.test.ts | 78 ++ packages/server/src/server/schedule/store.ts | 63 ++ packages/server/src/server/schedule/types.ts | 98 ++ packages/server/src/server/session.ts | 555 ++++++++++++ .../websocket-server.notifications.test.ts | 6 + .../websocket-server.relay-reconnect.test.ts | 16 + .../server/src/server/websocket-server.ts | 24 + packages/server/src/shared/messages.ts | 120 +++ 55 files changed, 6638 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/commands/chat/create.ts create mode 100644 packages/cli/src/commands/chat/delete.ts create mode 100644 packages/cli/src/commands/chat/index.ts create mode 100644 packages/cli/src/commands/chat/inspect.ts create mode 100644 packages/cli/src/commands/chat/ls.ts create mode 100644 packages/cli/src/commands/chat/post.ts create mode 100644 packages/cli/src/commands/chat/read.ts create mode 100644 packages/cli/src/commands/chat/schema.ts create mode 100644 packages/cli/src/commands/chat/shared.ts create mode 100644 packages/cli/src/commands/chat/wait.ts create mode 100644 packages/cli/src/commands/loop/index.ts create mode 100644 packages/cli/src/commands/loop/inspect.ts create mode 100644 packages/cli/src/commands/loop/logs.ts create mode 100644 packages/cli/src/commands/loop/ls.ts create mode 100644 packages/cli/src/commands/loop/run.ts create mode 100644 packages/cli/src/commands/loop/stop.ts create mode 100644 packages/cli/src/commands/loop/types.ts create mode 100644 packages/cli/src/commands/schedule/create.ts create mode 100644 packages/cli/src/commands/schedule/delete.ts create mode 100644 packages/cli/src/commands/schedule/index.ts create mode 100644 packages/cli/src/commands/schedule/inspect.ts create mode 100644 packages/cli/src/commands/schedule/logs.ts create mode 100644 packages/cli/src/commands/schedule/ls.ts create mode 100644 packages/cli/src/commands/schedule/pause.ts create mode 100644 packages/cli/src/commands/schedule/resume.ts create mode 100644 packages/cli/src/commands/schedule/schema.ts create mode 100644 packages/cli/src/commands/schedule/shared.ts create mode 100644 packages/cli/src/commands/schedule/types.ts create mode 100644 packages/cli/tests/30-chat.test.ts create mode 100644 packages/cli/tests/31-loop-schedule.test.ts create mode 100644 packages/server/src/server/chat/chat-rpc-schemas.ts create mode 100644 packages/server/src/server/chat/chat-service.test.ts create mode 100644 packages/server/src/server/chat/chat-service.ts create mode 100644 packages/server/src/server/chat/chat-types.ts create mode 100644 packages/server/src/server/loop-service.test.ts create mode 100644 packages/server/src/server/loop-service.ts create mode 100644 packages/server/src/server/loop/rpc-schemas.ts create mode 100644 packages/server/src/server/schedule/cron.test.ts create mode 100644 packages/server/src/server/schedule/cron.ts create mode 100644 packages/server/src/server/schedule/rpc-schemas.ts create mode 100644 packages/server/src/server/schedule/service.test.ts create mode 100644 packages/server/src/server/schedule/service.ts create mode 100644 packages/server/src/server/schedule/store.test.ts create mode 100644 packages/server/src/server/schedule/store.ts create mode 100644 packages/server/src/server/schedule/types.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b0a761bea..1eb8a80fd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,8 +2,11 @@ import { Command } from "commander"; import { createRequire } from "node:module"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; +import { createChatCommand } from "./commands/chat/index.js"; +import { createLoopCommand } from "./commands/loop/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; +import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; @@ -137,6 +140,15 @@ export function createCli(): Command { // Daemon commands program.addCommand(createDaemonCommand()); + // Chat commands + program.addCommand(createChatCommand()); + + // Loop commands + program.addCommand(createLoopCommand()); + + // Schedule commands + program.addCommand(createScheduleCommand()); + // Permission commands program.addCommand(createPermitCommand()); diff --git a/packages/cli/src/commands/chat/create.ts b/packages/cli/src/commands/chat/create.ts new file mode 100644 index 000000000..2747a6cf6 --- /dev/null +++ b/packages/cli/src/commands/chat/create.ts @@ -0,0 +1,31 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; +import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; + +export interface ChatCreateOptions extends ChatCommandOptions { + purpose?: string; +} + +export async function runCreateCommand( + name: string, + options: ChatCreateOptions, + _command: Command, +): Promise> { + const { client } = await connectChatClient(options.host); + try { + const payload = await client.createChatRoom({ + name, + purpose: options.purpose, + }); + return { + type: "single", + data: toChatRoomRow(payload.room!), + schema: chatRoomSchema, + }; + } catch (err) { + throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/chat/delete.ts b/packages/cli/src/commands/chat/delete.ts new file mode 100644 index 000000000..f803caf4e --- /dev/null +++ b/packages/cli/src/commands/chat/delete.ts @@ -0,0 +1,24 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; +import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; + +export async function runDeleteCommand( + room: string, + options: ChatCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectChatClient(options.host); + try { + const payload = await client.deleteChatRoom({ room }); + return { + type: "single", + data: toChatRoomRow(payload.room!), + schema: chatRoomSchema, + }; + } catch (err) { + throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/chat/index.ts b/packages/cli/src/commands/chat/index.ts new file mode 100644 index 000000000..2bc490089 --- /dev/null +++ b/packages/cli/src/commands/chat/index.ts @@ -0,0 +1,75 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js"; +import { runCreateCommand } from "./create.js"; +import { runLsCommand } from "./ls.js"; +import { runInspectCommand } from "./inspect.js"; +import { runDeleteCommand } from "./delete.js"; +import { runPostCommand } from "./post.js"; +import { runReadCommand } from "./read.js"; +import { runWaitCommand } from "./wait.js"; + +export function createChatCommand(): Command { + const chat = new Command("chat").description("Manage chat rooms for agent coordination"); + + addJsonAndDaemonHostOptions( + chat + .command("create") + .description("Create a chat room") + .argument("", "Room name (must be unique)") + .option("--purpose ", "Room purpose/description"), + ).action(withOutput(runCreateCommand)); + + addJsonAndDaemonHostOptions( + chat.command("ls").description("List chat rooms"), + ).action(withOutput(runLsCommand)); + + addJsonAndDaemonHostOptions( + chat + .command("inspect") + .description("Inspect a chat room") + .argument("", "Room name or ID"), + ).action(withOutput(runInspectCommand)); + + addJsonAndDaemonHostOptions( + chat + .command("delete") + .description("Delete a chat room") + .argument("", "Room name or ID"), + ).action(withOutput(runDeleteCommand)); + + addJsonAndDaemonHostOptions( + chat + .command("post") + .description("Post a chat message") + .argument("", "Room name or ID") + .argument("", "Message body") + .option("--reply-to ", "Reply to a specific message ID") + .option( + "--mention ", + "Mention an agent ID (repeatable)", + collectMultiple, + [], + ), + ).action(withOutput(runPostCommand)); + + addJsonAndDaemonHostOptions( + chat + .command("read") + .description("Read chat messages") + .argument("", "Room name or ID") + .option("--limit ", "Maximum number of messages to return") + .option("--since ", "Filter by relative duration or ISO timestamp") + .option("--agent ", "Filter by author agent ID"), + ).action(withOutput(runReadCommand)); + + addJsonAndDaemonHostOptions( + chat + .command("wait") + .description("Wait for new chat messages") + .argument("", "Room name or ID") + .option("--timeout ", "Maximum wait time"), + ).action(withOutput(runWaitCommand)); + + return chat; +} diff --git a/packages/cli/src/commands/chat/inspect.ts b/packages/cli/src/commands/chat/inspect.ts new file mode 100644 index 000000000..a07039331 --- /dev/null +++ b/packages/cli/src/commands/chat/inspect.ts @@ -0,0 +1,24 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; +import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; + +export async function runInspectCommand( + room: string, + options: ChatCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectChatClient(options.host); + try { + const payload = await client.inspectChatRoom({ room }); + return { + type: "single", + data: toChatRoomRow(payload.room!), + schema: chatRoomSchema, + }; + } catch (err) { + throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/chat/ls.ts b/packages/cli/src/commands/chat/ls.ts new file mode 100644 index 000000000..5e323cd7f --- /dev/null +++ b/packages/cli/src/commands/chat/ls.ts @@ -0,0 +1,23 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; +import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; + +export async function runLsCommand( + options: ChatCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectChatClient(options.host); + try { + const payload = await client.listChatRooms(); + return { + type: "list", + data: payload.rooms.map(toChatRoomRow), + schema: chatRoomSchema, + }; + } catch (err) { + throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/chat/post.ts b/packages/cli/src/commands/chat/post.ts new file mode 100644 index 000000000..edbb36249 --- /dev/null +++ b/packages/cli/src/commands/chat/post.ts @@ -0,0 +1,39 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import { + connectChatClient, + toChatCommandError, + type ChatCommandOptions, +} from "./shared.js"; +import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js"; + +export interface ChatPostOptions extends ChatCommandOptions { + replyTo?: string; + mention?: string[]; +} + +export async function runPostCommand( + room: string, + body: string, + options: ChatPostOptions, + _command: Command, +): Promise> { + const { client } = await connectChatClient(options.host); + try { + const payload = await client.postChatMessage({ + room, + body, + replyToMessageId: options.replyTo, + mentionAgentIds: options.mention ?? [], + }); + return { + type: "single", + data: toChatMessageRow(payload.message!), + schema: chatMessageSchema, + }; + } catch (err) { + throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/chat/read.ts b/packages/cli/src/commands/chat/read.ts new file mode 100644 index 000000000..85d689ad2 --- /dev/null +++ b/packages/cli/src/commands/chat/read.ts @@ -0,0 +1,55 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + connectChatClient, + parseSinceValue, + toChatCommandError, + type ChatCommandOptions, +} from "./shared.js"; +import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js"; + +export interface ChatReadOptions extends ChatCommandOptions { + limit?: string; + since?: string; + agent?: string; +} + +function parseLimit(value?: string): number | undefined { + if (!value) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw { + code: "INVALID_LIMIT", + message: "Invalid --limit value", + details: "Use a non-negative integer.", + }; + } + return parsed; +} + +export async function runReadCommand( + room: string, + options: ChatReadOptions, + _command: Command, +): Promise> { + const { client } = await connectChatClient(options.host); + try { + const payload = await client.readChatMessages({ + room, + limit: parseLimit(options.limit), + since: parseSinceValue(options.since), + authorAgentId: options.agent, + }); + return { + type: "list", + data: payload.messages.map(toChatMessageRow), + schema: chatMessageSchema, + }; + } catch (err) { + throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/chat/schema.ts b/packages/cli/src/commands/chat/schema.ts new file mode 100644 index 000000000..144d43d76 --- /dev/null +++ b/packages/cli/src/commands/chat/schema.ts @@ -0,0 +1,75 @@ +import type { OutputSchema } from "../../output/index.js"; + +export interface ChatRoomRow { + name: string; + id: string; + purpose: string; + messages: number; + lastMessageAt: string; +} + +export interface ChatMessageRow { + id: string; + author: string; + createdAt: string; + replyTo: string; + mentions: string; + body: string; +} + +export const chatRoomSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "NAME", field: "name", width: 22 }, + { header: "ID", field: "id", width: 36 }, + { header: "PURPOSE", field: "purpose", width: 30 }, + { header: "MESSAGES", field: "messages", width: 10, align: "right" }, + { header: "LAST MESSAGE", field: "lastMessageAt", width: 24 }, + ], +}; + +export const chatMessageSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "ID", field: "id", width: 36 }, + { header: "AUTHOR", field: "author", width: 16 }, + { header: "CREATED", field: "createdAt", width: 24 }, + { header: "REPLY TO", field: "replyTo", width: 36 }, + { header: "MENTIONS", field: "mentions", width: 24 }, + { header: "MESSAGE", field: "body", width: 60 }, + ], +}; + +export function toChatRoomRow(room: { + id: string; + name: string; + purpose: string | null; + messageCount: number; + lastMessageAt: string | null; +}): ChatRoomRow { + return { + id: room.id, + name: room.name, + purpose: room.purpose ?? "-", + messages: room.messageCount, + lastMessageAt: room.lastMessageAt ?? "-", + }; +} + +export function toChatMessageRow(message: { + id: string; + authorAgentId: string; + createdAt: string; + replyToMessageId: string | null; + mentionAgentIds: string[]; + body: string; +}): ChatMessageRow { + return { + id: message.id, + author: message.authorAgentId, + createdAt: message.createdAt, + replyTo: message.replyToMessageId ?? "-", + mentions: message.mentionAgentIds.length > 0 ? message.mentionAgentIds.join(",") : "-", + body: message.body, + }; +} diff --git a/packages/cli/src/commands/chat/shared.ts b/packages/cli/src/commands/chat/shared.ts new file mode 100644 index 000000000..3bc614de0 --- /dev/null +++ b/packages/cli/src/commands/chat/shared.ts @@ -0,0 +1,84 @@ +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { parseDuration } from "../../utils/duration.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; + +export interface ChatCommandOptions extends CommandOptions { + host?: string; +} + +export async function connectChatClient(host?: string) { + const daemonHost = getDaemonHost({ host }); + try { + const client = await connectToDaemon({ host }); + return { client, daemonHost }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${daemonHost}: ${message}`, + details: "Start the daemon with: paseo daemon start", + }; + throw error; + } +} + +export function toChatCommandError(code: string, action: string, err: unknown): CommandError { + if (err && typeof err === "object" && "code" in err && "message" in err) { + return err as CommandError; + } + + const message = err instanceof Error ? err.message : String(err); + const rpcCode = + typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" + ? err.code + : undefined; + + return { + code: rpcCode ?? code, + message: `Failed to ${action}: ${message}`, + }; +} + +export function parseSinceValue(input?: string): string | undefined { + if (!input) { + return undefined; + } + + try { + const durationMs = parseDuration(input); + return new Date(Date.now() - durationMs).toISOString(); + } catch { + const timestamp = new Date(input); + if (Number.isNaN(timestamp.getTime())) { + const error: CommandError = { + code: "INVALID_SINCE", + message: "Invalid --since value", + details: "Use a duration like 10m or an ISO timestamp.", + }; + throw error; + } + return timestamp.toISOString(); + } +} + +export function parseTimeoutMs(input?: string): number | undefined { + if (!input) { + return undefined; + } + + try { + const timeoutMs = parseDuration(input); + if (timeoutMs <= 0) { + throw new Error("Timeout must be positive"); + } + return timeoutMs; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "INVALID_TIMEOUT", + message: "Invalid timeout value", + details: message, + }; + throw error; + } +} diff --git a/packages/cli/src/commands/chat/wait.ts b/packages/cli/src/commands/chat/wait.ts new file mode 100644 index 000000000..fdaeae599 --- /dev/null +++ b/packages/cli/src/commands/chat/wait.ts @@ -0,0 +1,42 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + connectChatClient, + parseTimeoutMs, + toChatCommandError, + type ChatCommandOptions, +} from "./shared.js"; +import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js"; + +export interface ChatWaitOptions extends ChatCommandOptions { + timeout?: string; +} + +export async function runWaitCommand( + room: string, + options: ChatWaitOptions, + _command: Command, +): Promise> { + const { client } = await connectChatClient(options.host); + try { + const latest = await client.readChatMessages({ + room, + limit: 1, + }); + const afterMessageId = latest.messages[0]?.id; + const payload = await client.waitForChatMessages({ + room, + afterMessageId, + timeoutMs: parseTimeoutMs(options.timeout), + }); + return { + type: "list", + data: payload.messages.map(toChatMessageRow), + schema: chatMessageSchema, + }; + } catch (err) { + throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/loop/index.ts b/packages/cli/src/commands/loop/index.ts new file mode 100644 index 000000000..64e975517 --- /dev/null +++ b/packages/cli/src/commands/loop/index.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js"; +import { addLoopRunOptions, runLoopRunCommand } from "./run.js"; +import { addLoopLsOptions, runLoopLsCommand } from "./ls.js"; +import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js"; +import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js"; +import { addLoopStopOptions, runLoopStopCommand } from "./stop.js"; + +export function createLoopCommand(): Command { + const loop = new Command("loop").description("Run iterative worker loops"); + + addJsonAndDaemonHostOptions( + addLoopRunOptions(loop.command("run")), + ).action(withOutput(runLoopRunCommand)); + + addJsonAndDaemonHostOptions( + addLoopLsOptions(loop.command("ls")), + ).action(withOutput(runLoopLsCommand)); + + addJsonAndDaemonHostOptions( + addLoopInspectOptions(loop.command("inspect")), + ).action(withOutput(runLoopInspectCommand)); + + addDaemonHostOption( + addLoopLogsOptions(loop.command("logs")), + ).action(runLoopLogsCommand); + + addJsonAndDaemonHostOptions( + addLoopStopOptions(loop.command("stop")), + ).action(withOutput(runLoopStopCommand)); + + return loop; +} diff --git a/packages/cli/src/commands/loop/inspect.ts b/packages/cli/src/commands/loop/inspect.ts new file mode 100644 index 000000000..3b2afa922 --- /dev/null +++ b/packages/cli/src/commands/loop/inspect.ts @@ -0,0 +1,112 @@ +import { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { + CommandOptions, + CommandError, + OutputSchema, + ListResult, +} from "../../output/index.js"; +import type { LoopDaemonClient, LoopRecord } from "./types.js"; + +interface InspectRow { + key: string; + value: string; +} + +export interface LoopInspectOptions extends CommandOptions {} + +export function addLoopInspectOptions(command: Command): Command { + return command.description("Show loop details and iteration history").argument("", "Loop ID"); +} + +function createInspectSchema(loop: LoopRecord): OutputSchema { + return { + idField: "key", + columns: [ + { header: "KEY", field: "key", width: 18 }, + { header: "VALUE", field: "value", width: 80 }, + ], + serialize: () => loop, + }; +} + +function toRows(loop: LoopRecord): InspectRow[] { + return [ + { key: "Id", value: loop.id }, + { key: "Name", value: loop.name ?? "null" }, + { key: "Status", value: loop.status }, + { key: "Cwd", value: loop.cwd }, + { key: "Provider", value: loop.provider }, + { key: "Prompt", value: loop.prompt }, + { key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" }, + { key: "VerifyChecks", value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]" }, + { key: "SleepMs", value: String(loop.sleepMs) }, + { key: "MaxIterations", value: loop.maxIterations === null ? "null" : String(loop.maxIterations) }, + { key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) }, + { key: "CreatedAt", value: loop.createdAt }, + { key: "UpdatedAt", value: loop.updatedAt }, + { key: "CompletedAt", value: loop.completedAt ?? "null" }, + { + key: "Iterations", + value: + loop.iterations.length === 0 + ? "[]" + : loop.iterations + .map((iteration) => { + const summary = [ + `#${iteration.index}`, + iteration.status, + iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null, + iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null, + iteration.failureReason ? `reason=${iteration.failureReason}` : null, + ] + .filter(Boolean) + .join(" "); + return summary; + }) + .join(" | "), + }, + ]; +} + +export type LoopInspectResult = ListResult; + +export async function runLoopInspectCommand( + id: string, + options: LoopInspectOptions, + _command: Command, +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }); + let client; + try { + client = (await connectToDaemon({ + host: options.host as string | undefined, + })) as unknown as LoopDaemonClient; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } + + try { + const payload = await client.loopInspect(id); + await client.close(); + if (payload.error || !payload.loop) { + throw new Error(payload.error ?? `Loop not found: ${id}`); + } + return { + type: "list", + data: toRows(payload.loop), + schema: createInspectSchema(payload.loop), + }; + } catch (error) { + await client.close().catch(() => {}); + throw { + code: "LOOP_INSPECT_FAILED", + message: error instanceof Error ? error.message : String(error), + } satisfies CommandError; + } +} diff --git a/packages/cli/src/commands/loop/logs.ts b/packages/cli/src/commands/loop/logs.ts new file mode 100644 index 000000000..4c307e997 --- /dev/null +++ b/packages/cli/src/commands/loop/logs.ts @@ -0,0 +1,82 @@ +import { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; +import type { LoopDaemonClient, LoopLogEntry } from "./types.js"; + +export interface LoopLogsOptions extends CommandOptions { + pollInterval?: string; +} + +export function addLoopLogsOptions(command: Command): Command { + return command + .description("Stream loop logs") + .argument("", "Loop ID") + .option("--poll-interval ", "Polling interval in milliseconds", "1000"); +} + +function parsePollInterval(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw { + code: "INVALID_POLL_INTERVAL", + message: "--poll-interval must be a positive integer", + } satisfies CommandError; + } + return parsed; +} + +function renderLogEntry(entry: LoopLogEntry): string { + const prefix = [ + entry.timestamp, + entry.source, + entry.iteration === null ? null : `iteration=${entry.iteration}`, + entry.level === "error" ? "ERROR" : null, + ] + .filter(Boolean) + .join(" "); + return `${prefix}\n${entry.text}`; +} + +export async function runLoopLogsCommand( + id: string, + options: LoopLogsOptions, + _command: Command, +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }); + const pollInterval = parsePollInterval(options.pollInterval ?? "1000"); + let client; + try { + client = (await connectToDaemon({ + host: options.host as string | undefined, + })) as unknown as LoopDaemonClient; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); + console.error("Start the daemon with: paseo daemon start"); + process.exit(1); + } + + let cursor = 0; + try { + for (;;) { + const payload = await client.loopLogs(id, cursor); + if (payload.error || !payload.loop) { + throw new Error(payload.error ?? `Loop not found: ${id}`); + } + cursor = payload.nextCursor; + for (const entry of payload.entries) { + console.log(renderLogEntry(entry)); + } + if (payload.loop.status !== "running") { + await client.close(); + return; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + } catch (error) { + await client.close().catch(() => {}); + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: Failed to stream loop logs: ${message}`); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/loop/ls.ts b/packages/cli/src/commands/loop/ls.ts new file mode 100644 index 000000000..3c0051244 --- /dev/null +++ b/packages/cli/src/commands/loop/ls.ts @@ -0,0 +1,88 @@ +import { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { + CommandOptions, + CommandError, + OutputSchema, + ListResult, +} from "../../output/index.js"; +import type { LoopDaemonClient, LoopListItem } from "./types.js"; + +interface LoopListRow { + id: string; + name: string | null; + status: string; + cwd: string; + updated: string; + activeIteration: string; +} + +export interface LoopLsOptions extends CommandOptions {} + +export const loopLsSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "LOOP ID", field: "id", width: 10 }, + { header: "NAME", field: "name", width: 20 }, + { header: "STATUS", field: "status", width: 10 }, + { header: "ITER", field: "activeIteration", width: 8 }, + { header: "CWD", field: "cwd", width: 40 }, + { header: "UPDATED", field: "updated", width: 24 }, + ], +}; + +export function addLoopLsOptions(command: Command): Command { + return command.description("List loops"); +} + +function toRow(loop: LoopListItem): LoopListRow { + return { + id: loop.id, + name: loop.name, + status: loop.status, + cwd: loop.cwd, + updated: loop.updatedAt, + activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration), + }; +} + +export type LoopLsResult = ListResult; + +export async function runLoopLsCommand( + options: LoopLsOptions, + _command: Command, +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }); + let client; + try { + client = (await connectToDaemon({ + host: options.host as string | undefined, + })) as unknown as LoopDaemonClient; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } + + try { + const payload = await client.loopList(); + await client.close(); + if (payload.error) { + throw new Error(payload.error); + } + return { + type: "list", + data: payload.loops.map(toRow), + schema: loopLsSchema, + }; + } catch (error) { + await client.close().catch(() => {}); + throw { + code: "LOOP_LIST_FAILED", + message: error instanceof Error ? error.message : String(error), + } satisfies CommandError; + } +} diff --git a/packages/cli/src/commands/loop/run.ts b/packages/cli/src/commands/loop/run.ts new file mode 100644 index 000000000..f40c3a634 --- /dev/null +++ b/packages/cli/src/commands/loop/run.ts @@ -0,0 +1,146 @@ +import { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { + CommandOptions, + CommandError, + OutputSchema, + SingleResult, +} from "../../output/index.js"; +import { collectMultiple } from "../../utils/command-options.js"; +import { parseDuration } from "../../utils/duration.js"; +import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js"; + +export interface LoopRunRow { + id: string; + status: string; + name: string | null; + cwd: string; +} + +export interface LoopRunOptions extends CommandOptions { + verify?: string; + verifyCheck?: string[]; + name?: string; + sleep?: string; + maxIterations?: string; + maxTime?: string; +} + +export const loopRunSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "LOOP ID", field: "id", width: 10 }, + { header: "STATUS", field: "status", width: 10 }, + { header: "NAME", field: "name", width: 20 }, + { header: "CWD", field: "cwd", width: 40 }, + ], +}; + +export function addLoopRunOptions(command: Command): Command { + return command + .description("Start a loop") + .argument("", "Prompt for each fresh worker iteration") + .option("--verify ", "Verifier agent prompt") + .option( + "--verify-check ", + "Shell command that must exit 0 (repeatable)", + collectMultiple, + [], + ) + .option("--name ", "Optional loop name") + .option("--sleep ", "Delay between iterations (for example: 30s, 5m)") + .option("--max-iterations ", "Maximum number of iterations") + .option("--max-time ", "Maximum total runtime (for example: 1h, 30m)"); +} + +function toRow(loop: LoopRecord): LoopRunRow { + return { + id: loop.id, + status: loop.status, + name: loop.name, + cwd: loop.cwd, + }; +} + +function parseMaxIterations(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw { + code: "INVALID_MAX_ITERATIONS", + message: "--max-iterations must be a positive integer", + } satisfies CommandError; + } + return parsed; +} + +function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput { + const verifyPrompt = options.verify?.trim(); + if (options.verify !== undefined && !verifyPrompt) { + throw { + code: "INVALID_VERIFY_PROMPT", + message: "--verify cannot be empty", + } satisfies CommandError; + } + + return { + prompt, + cwd: process.cwd(), + ...(verifyPrompt ? { verifyPrompt } : {}), + ...(options.verifyCheck && options.verifyCheck.length > 0 + ? { verifyChecks: options.verifyCheck } + : {}), + ...(options.name?.trim() ? { name: options.name.trim() } : {}), + ...(options.sleep ? { sleepMs: parseDuration(options.sleep) } : {}), + ...(options.maxIterations ? { maxIterations: parseMaxIterations(options.maxIterations) } : {}), + ...(options.maxTime ? { maxTimeMs: parseDuration(options.maxTime) } : {}), + }; +} + +export type LoopRunResult = SingleResult; + +export async function runLoopRunCommand( + prompt: string, + options: LoopRunOptions, + _command: Command, +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }); + const input = buildLoopRunInput(prompt, options); + let client; + try { + client = (await connectToDaemon({ + host: options.host as string | undefined, + })) as unknown as LoopDaemonClient; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } + + try { + const payload = await client.loopRun(input); + await client.close(); + if (payload.error || !payload.loop) { + throw new Error(payload.error ?? "Loop creation failed"); + } + return { + type: "single", + data: toRow(payload.loop), + schema: loopRunSchema, + }; + } catch (error) { + await client.close().catch(() => {}); + if (error && typeof error === "object" && "code" in error) { + throw error; + } + throw { + code: "LOOP_RUN_FAILED", + message: error instanceof Error ? error.message : String(error), + } satisfies CommandError; + } +} diff --git a/packages/cli/src/commands/loop/stop.ts b/packages/cli/src/commands/loop/stop.ts new file mode 100644 index 000000000..cabe94a9f --- /dev/null +++ b/packages/cli/src/commands/loop/stop.ts @@ -0,0 +1,80 @@ +import { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { + CommandOptions, + CommandError, + OutputSchema, + SingleResult, +} from "../../output/index.js"; +import type { LoopDaemonClient, LoopRecord } from "./types.js"; + +interface LoopStopRow { + id: string; + status: string; + activeIteration: string; +} + +export interface LoopStopOptions extends CommandOptions {} + +export const loopStopSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "LOOP ID", field: "id", width: 10 }, + { header: "STATUS", field: "status", width: 10 }, + { header: "ITER", field: "activeIteration", width: 8 }, + ], +}; + +export function addLoopStopOptions(command: Command): Command { + return command.description("Stop a running loop").argument("", "Loop ID"); +} + +function toRow(loop: LoopRecord): LoopStopRow { + return { + id: loop.id, + status: loop.status, + activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration), + }; +} + +export type LoopStopResult = SingleResult; + +export async function runLoopStopCommand( + id: string, + options: LoopStopOptions, + _command: Command, +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }); + let client; + try { + client = (await connectToDaemon({ + host: options.host as string | undefined, + })) as unknown as LoopDaemonClient; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } + + try { + const payload = await client.loopStop(id); + await client.close(); + if (payload.error || !payload.loop) { + throw new Error(payload.error ?? `Loop not found: ${id}`); + } + return { + type: "single", + data: toRow(payload.loop), + schema: loopStopSchema, + }; + } catch (error) { + await client.close().catch(() => {}); + throw { + code: "LOOP_STOP_FAILED", + message: error instanceof Error ? error.message : String(error), + } satisfies CommandError; + } +} diff --git a/packages/cli/src/commands/loop/types.ts b/packages/cli/src/commands/loop/types.ts new file mode 100644 index 000000000..9568f9e52 --- /dev/null +++ b/packages/cli/src/commands/loop/types.ts @@ -0,0 +1,134 @@ +export type LoopStatus = "running" | "succeeded" | "failed" | "stopped"; + +export interface LoopLogEntry { + seq: number; + timestamp: string; + iteration: number | null; + source: "loop" | "worker" | "verifier" | "verify-check"; + level: "info" | "error"; + text: string; +} + +export interface LoopVerifyCheckResult { + command: string; + exitCode: number; + passed: boolean; + stdout: string; + stderr: string; + startedAt: string; + completedAt: string; +} + +export interface LoopVerifyPromptResult { + passed: boolean; + reason: string; + verifierAgentId: string | null; + startedAt: string; + completedAt: string; +} + +export interface LoopIterationRecord { + index: number; + workerAgentId: string | null; + workerStartedAt: string; + workerCompletedAt: string | null; + verifierAgentId: string | null; + status: "running" | "succeeded" | "failed" | "stopped"; + workerOutcome: "completed" | "failed" | "canceled" | null; + failureReason: string | null; + verifyChecks: LoopVerifyCheckResult[]; + verifyPrompt: LoopVerifyPromptResult | null; +} + +export interface LoopRecord { + id: string; + name: string | null; + prompt: string; + cwd: string; + provider: "claude" | "codex" | "opencode"; + verifyPrompt: string | null; + verifyChecks: string[]; + sleepMs: number; + maxIterations: number | null; + maxTimeMs: number | null; + status: LoopStatus; + createdAt: string; + updatedAt: string; + startedAt: string; + completedAt: string | null; + stopRequestedAt: string | null; + iterations: LoopIterationRecord[]; + logs: LoopLogEntry[]; + nextLogSeq: number; + activeIteration: number | null; + activeWorkerAgentId: string | null; + activeVerifierAgentId: string | null; +} + +export interface LoopListItem { + id: string; + name: string | null; + status: LoopStatus; + cwd: string; + createdAt: string; + updatedAt: string; + activeIteration: number | null; +} + +export interface LoopLogsResult { + loop: LoopRecord; + entries: LoopLogEntry[]; + nextCursor: number; +} + +export interface LoopRunPayload { + requestId: string; + loop: LoopRecord | null; + error: string | null; +} + +export interface LoopListPayload { + requestId: string; + loops: LoopListItem[]; + error: string | null; +} + +export interface LoopInspectPayload { + requestId: string; + loop: LoopRecord | null; + error: string | null; +} + +export interface LoopLogsPayload { + requestId: string; + loop: LoopRecord | null; + entries: LoopLogEntry[]; + nextCursor: number; + error: string | null; +} + +export interface LoopStopPayload { + requestId: string; + loop: LoopRecord | null; + error: string | null; +} + +export interface LoopRunInput { + prompt: string; + cwd: string; + verifyPrompt?: string; + verifyChecks?: string[]; + name?: string; + sleepMs?: number; + maxIterations?: number; + maxTimeMs?: number; +} + +export interface LoopDaemonClient { + loopRun(input: LoopRunInput): Promise; + loopList(): Promise; + loopInspect(id: string): Promise; + loopLogs(id: string, afterSeq?: number): Promise; + loopStop(id: string): Promise; + close(): Promise; +} diff --git a/packages/cli/src/commands/schedule/create.ts b/packages/cli/src/commands/schedule/create.ts new file mode 100644 index 000000000..85406e483 --- /dev/null +++ b/packages/cli/src/commands/schedule/create.ts @@ -0,0 +1,52 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import { scheduleSchema } from "./schema.js"; +import { + connectScheduleClient, + parseScheduleCreateInput, + toScheduleCommandError, + toScheduleRow, + type ScheduleCommandOptions, + type ScheduleRow, +} from "./shared.js"; + +export interface ScheduleCreateOptions extends ScheduleCommandOptions { + every?: string; + cron?: string; + name?: string; + target?: string; + maxRuns?: string; + expiresIn?: string; +} + +export async function runCreateCommand( + prompt: string, + options: ScheduleCreateOptions, + _command: Command, +): Promise> { + const input = parseScheduleCreateInput({ + prompt, + every: options.every, + cron: options.cron, + name: options.name, + target: options.target, + maxRuns: options.maxRuns, + expiresIn: options.expiresIn, + }); + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.scheduleCreate(input); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? "Schedule creation failed"); + } + return { + type: "single", + data: toScheduleRow(payload.schedule), + schema: scheduleSchema, + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_CREATE_FAILED", "create schedule", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/delete.ts b/packages/cli/src/commands/schedule/delete.ts new file mode 100644 index 000000000..05ea5e933 --- /dev/null +++ b/packages/cli/src/commands/schedule/delete.ts @@ -0,0 +1,43 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import type { OutputSchema } from "../../output/index.js"; +import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js"; + +interface ScheduleDeleteRow { + id: string; + status: string; +} + +const scheduleDeleteSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "ID", field: "id", width: 10 }, + { header: "STATUS", field: "status", width: 12 }, + ], +}; + +export async function runDeleteCommand( + id: string, + options: ScheduleCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.scheduleDelete({ id }); + if (payload.error) { + throw new Error(payload.error); + } + return { + type: "single", + data: { + id: payload.scheduleId, + status: "deleted", + }, + schema: scheduleDeleteSchema, + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_DELETE_FAILED", "delete schedule", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts new file mode 100644 index 000000000..1a02d683b --- /dev/null +++ b/packages/cli/src/commands/schedule/index.ts @@ -0,0 +1,68 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { runCreateCommand } from "./create.js"; +import { runLsCommand } from "./ls.js"; +import { runInspectCommand } from "./inspect.js"; +import { runLogsCommand } from "./logs.js"; +import { runPauseCommand } from "./pause.js"; +import { runResumeCommand } from "./resume.js"; +import { runDeleteCommand } from "./delete.js"; + +export function createScheduleCommand(): Command { + const schedule = new Command("schedule").description("Manage recurring schedules"); + + addJsonAndDaemonHostOptions( + schedule + .command("create") + .description("Create a schedule") + .argument("", "Prompt to run on the schedule") + .option("--every ", "Fixed interval cadence (for example: 5m, 1h)") + .option("--cron ", "Cron cadence expression") + .option("--name ", "Optional schedule name") + .option("--target ", "Run target") + .option("--max-runs ", "Maximum number of runs") + .option("--expires-in ", "Time to live for the schedule"), + ).action(withOutput(runCreateCommand)); + + addJsonAndDaemonHostOptions( + schedule.command("ls").description("List schedules"), + ).action(withOutput(runLsCommand)); + + addJsonAndDaemonHostOptions( + schedule + .command("inspect") + .description("Inspect a schedule") + .argument("", "Schedule ID"), + ).action(withOutput(runInspectCommand)); + + addJsonAndDaemonHostOptions( + schedule + .command("logs") + .description("Show recent schedule run logs") + .argument("", "Schedule ID"), + ).action(withOutput(runLogsCommand)); + + addJsonAndDaemonHostOptions( + schedule + .command("pause") + .description("Pause a schedule") + .argument("", "Schedule ID"), + ).action(withOutput(runPauseCommand)); + + addJsonAndDaemonHostOptions( + schedule + .command("resume") + .description("Resume a paused schedule") + .argument("", "Schedule ID"), + ).action(withOutput(runResumeCommand)); + + addJsonAndDaemonHostOptions( + schedule + .command("delete") + .description("Delete a schedule") + .argument("", "Schedule ID"), + ).action(withOutput(runDeleteCommand)); + + return schedule; +} diff --git a/packages/cli/src/commands/schedule/inspect.ts b/packages/cli/src/commands/schedule/inspect.ts new file mode 100644 index 000000000..de968e6a4 --- /dev/null +++ b/packages/cli/src/commands/schedule/inspect.ts @@ -0,0 +1,32 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + createScheduleInspectRows, + createScheduleInspectSchema, + type ScheduleInspectRow, +} from "./schema.js"; +import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js"; + +export async function runInspectCommand( + id: string, + options: ScheduleCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.scheduleInspect({ id }); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? `Schedule not found: ${id}`); + } + const rows = createScheduleInspectRows(payload.schedule); + return { + type: "list", + data: rows, + schema: createScheduleInspectSchema(payload.schedule), + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_INSPECT_FAILED", "inspect schedule", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/logs.ts b/packages/cli/src/commands/schedule/logs.ts new file mode 100644 index 000000000..d10251063 --- /dev/null +++ b/packages/cli/src/commands/schedule/logs.ts @@ -0,0 +1,27 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js"; +import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js"; + +export async function runLogsCommand( + id: string, + options: ScheduleCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.scheduleLogs({ id }); + if (payload.error) { + throw new Error(payload.error); + } + return { + type: "list", + data: payload.runs.map(toScheduleLogRow), + schema: scheduleLogSchema, + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_LOGS_FAILED", "read schedule logs", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/ls.ts b/packages/cli/src/commands/schedule/ls.ts new file mode 100644 index 000000000..da2c0dfbc --- /dev/null +++ b/packages/cli/src/commands/schedule/ls.ts @@ -0,0 +1,32 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { scheduleSchema } from "./schema.js"; +import { + connectScheduleClient, + toScheduleCommandError, + toScheduleRow, + type ScheduleCommandOptions, + type ScheduleRow, +} from "./shared.js"; + +export async function runLsCommand( + options: ScheduleCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.scheduleList(); + if (payload.error) { + throw new Error(payload.error); + } + return { + type: "list", + data: payload.schedules.map(toScheduleRow), + schema: scheduleSchema, + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_LIST_FAILED", "list schedules", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/pause.ts b/packages/cli/src/commands/schedule/pause.ts new file mode 100644 index 000000000..97e5914a5 --- /dev/null +++ b/packages/cli/src/commands/schedule/pause.ts @@ -0,0 +1,33 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import { scheduleSchema } from "./schema.js"; +import { + connectScheduleClient, + toScheduleCommandError, + toScheduleRow, + type ScheduleCommandOptions, + type ScheduleRow, +} from "./shared.js"; + +export async function runPauseCommand( + id: string, + options: ScheduleCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.schedulePause({ id }); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? `Failed to pause schedule: ${id}`); + } + return { + type: "single", + data: toScheduleRow(payload.schedule), + schema: scheduleSchema, + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_PAUSE_FAILED", "pause schedule", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/resume.ts b/packages/cli/src/commands/schedule/resume.ts new file mode 100644 index 000000000..88d4853dc --- /dev/null +++ b/packages/cli/src/commands/schedule/resume.ts @@ -0,0 +1,33 @@ +import type { Command } from "commander"; +import type { SingleResult } from "../../output/index.js"; +import { scheduleSchema } from "./schema.js"; +import { + connectScheduleClient, + toScheduleCommandError, + toScheduleRow, + type ScheduleCommandOptions, + type ScheduleRow, +} from "./shared.js"; + +export async function runResumeCommand( + id: string, + options: ScheduleCommandOptions, + _command: Command, +): Promise> { + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.scheduleResume({ id }); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? `Failed to resume schedule: ${id}`); + } + return { + type: "single", + data: toScheduleRow(payload.schedule), + schema: scheduleSchema, + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_RESUME_FAILED", "resume schedule", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/schema.ts b/packages/cli/src/commands/schedule/schema.ts new file mode 100644 index 000000000..cc64b7985 --- /dev/null +++ b/packages/cli/src/commands/schedule/schema.ts @@ -0,0 +1,88 @@ +import type { OutputSchema } from "../../output/index.js"; +import { formatTarget, type ScheduleRow } from "./shared.js"; +import type { ScheduleRecord, ScheduleRunRecord } from "./types.js"; + +export const scheduleSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "ID", field: "id", width: 10 }, + { header: "NAME", field: "name", width: 20 }, + { header: "CADENCE", field: "cadence", width: 20 }, + { header: "TARGET", field: "target", width: 20 }, + { header: "STATUS", field: "status", width: 12 }, + { header: "NEXT RUN", field: "nextRunAt", width: 24 }, + ], +}; + +export interface ScheduleInspectRow { + key: string; + value: string; +} + +export function createScheduleInspectSchema(record: ScheduleRecord): OutputSchema { + return { + idField: "key", + columns: [ + { header: "KEY", field: "key", width: 18 }, + { header: "VALUE", field: "value", width: 80 }, + ], + serialize: () => record, + }; +} + +export interface ScheduleLogRow { + id: string; + status: string; + startedAt: string; + agentId: string | null; + output: string | null; + error: string | null; +} + +export const scheduleLogSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "RUN ID", field: "id", width: 14 }, + { header: "STATUS", field: "status", width: 12 }, + { header: "STARTED", field: "startedAt", width: 24 }, + { header: "AGENT", field: "agentId", width: 12 }, + { header: "OUTPUT", field: "output", width: 40 }, + { header: "ERROR", field: "error", width: 40 }, + ], +}; + +export function toScheduleLogRow(run: ScheduleRunRecord): ScheduleLogRow { + return { + id: run.id, + status: run.status, + startedAt: run.startedAt, + agentId: run.agentId ? run.agentId.slice(0, 7) : null, + output: run.output, + error: run.error, + }; +} + +export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleInspectRow[] { + return [ + { key: "Id", value: schedule.id }, + { key: "Name", value: schedule.name ?? "null" }, + { key: "Prompt", value: schedule.prompt }, + { + key: "Cadence", + value: + schedule.cadence.type === "cron" + ? `cron:${schedule.cadence.expression}` + : `every:${schedule.cadence.everyMs}ms`, + }, + { key: "Target", value: formatTarget(schedule.target) }, + { key: "Status", value: schedule.status }, + { key: "CreatedAt", value: schedule.createdAt }, + { key: "UpdatedAt", value: schedule.updatedAt }, + { key: "NextRunAt", value: schedule.nextRunAt ?? "null" }, + { key: "LastRunAt", value: schedule.lastRunAt ?? "null" }, + { key: "PausedAt", value: schedule.pausedAt ?? "null" }, + { key: "ExpiresAt", value: schedule.expiresAt ?? "null" }, + { key: "MaxRuns", value: schedule.maxRuns == null ? "null" : `${schedule.maxRuns}` }, + { key: "RunCount", value: `${schedule.runs.length}` }, + ]; +} diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts new file mode 100644 index 000000000..7946c7ec4 --- /dev/null +++ b/packages/cli/src/commands/schedule/shared.ts @@ -0,0 +1,195 @@ +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; +import type { + CreateScheduleInput, + ScheduleCadence, + ScheduleDaemonClient, + ScheduleListItem, + ScheduleRecord, + ScheduleTarget, +} from "./types.js"; +import { parseDuration } from "../../utils/duration.js"; + +export interface ScheduleCommandOptions extends CommandOptions { + host?: string; +} + +export async function connectScheduleClient( + host: string | undefined, +): Promise<{ client: ScheduleDaemonClient; host: string }> { + const resolvedHost = getDaemonHost({ host }); + try { + const client = (await connectToDaemon({ + host, + })) as unknown as ScheduleDaemonClient; + return { client, host: resolvedHost }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${resolvedHost}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } +} + +export function toScheduleCommandError( + code: string, + action: string, + error: unknown, +): CommandError { + if (error && typeof error === "object" && "code" in error) { + return error as CommandError; + } + const message = error instanceof Error ? error.message : String(error); + return { + code, + message: `Failed to ${action}: ${message}`, + }; +} + +export function formatCadence(cadence: ScheduleCadence): string { + if (cadence.type === "cron") { + return `cron:${cadence.expression}`; + } + return `every:${formatDurationMs(cadence.everyMs)}`; +} + +export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]): string { + if (target.type === "self") { + return `self:${target.agentId.slice(0, 7)}`; + } + if (target.type === "agent") { + return `agent:${target.agentId.slice(0, 7)}`; + } + return `new-agent:${target.config.provider}`; +} + +export function formatDurationMs(durationMs: number): string { + const parts: string[] = []; + let remainingMs = durationMs; + const hours = Math.floor(remainingMs / (60 * 60 * 1000)); + if (hours > 0) { + parts.push(`${hours}h`); + remainingMs -= hours * 60 * 60 * 1000; + } + const minutes = Math.floor(remainingMs / (60 * 1000)); + if (minutes > 0) { + parts.push(`${minutes}m`); + remainingMs -= minutes * 60 * 1000; + } + const seconds = Math.floor(remainingMs / 1000); + if (seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); + } + return parts.join(""); +} + +export function parseScheduleCreateInput(options: { + prompt: string; + every?: string; + cron?: string; + name?: string; + target?: string; + maxRuns?: string; + expiresIn?: string; +}): CreateScheduleInput { + const prompt = options.prompt.trim(); + if (!prompt) { + throw { + code: "INVALID_PROMPT", + message: "Schedule prompt cannot be empty", + } satisfies CommandError; + } + + const cadenceCount = Number(options.every !== undefined) + Number(options.cron !== undefined); + if (cadenceCount !== 1) { + throw { + code: "INVALID_CADENCE", + message: "Specify exactly one of --every or --cron", + } satisfies CommandError; + } + + const cadence: ScheduleCadence = options.every + ? { type: "every", everyMs: parseDuration(options.every) } + : { type: "cron", expression: options.cron!.trim() }; + + const targetValue = options.target?.trim(); + let target: ScheduleTarget; + if (!targetValue || targetValue === "self") { + const currentAgentId = process.env.PASEO_AGENT_ID?.trim(); + if (currentAgentId) { + target = { type: "self", agentId: currentAgentId }; + } else { + target = { + type: "new-agent", + config: { + provider: "claude", + cwd: process.cwd(), + }, + }; + } + } else if (targetValue === "new-agent") { + target = { + type: "new-agent", + config: { + provider: "claude", + cwd: process.cwd(), + }, + }; + } else { + target = { + type: "agent", + agentId: targetValue, + }; + } + + const maxRuns = + options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs"); + const expiresAt = + options.expiresIn === undefined + ? undefined + : new Date(Date.now() + parseDuration(options.expiresIn)).toISOString(); + + return { + prompt, + cadence, + target, + ...(options.name?.trim() ? { name: options.name.trim() } : {}), + ...(maxRuns !== undefined ? { maxRuns } : {}), + ...(expiresAt ? { expiresAt } : {}), + }; +} + +function parsePositiveInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw { + code: "INVALID_INTEGER", + message: `${flag} must be a positive integer`, + } satisfies CommandError; + } + return parsed; +} + +export interface ScheduleRow { + id: string; + name: string | null; + cadence: string; + target: string; + status: string; + nextRunAt: string | null; + lastRunAt: string | null; +} + +export function toScheduleRow(schedule: ScheduleListItem | ScheduleRecord): ScheduleRow { + return { + id: schedule.id, + name: schedule.name, + cadence: formatCadence(schedule.cadence), + target: formatTarget(schedule.target), + status: schedule.status, + nextRunAt: schedule.nextRunAt, + lastRunAt: schedule.lastRunAt, + }; +} diff --git a/packages/cli/src/commands/schedule/types.ts b/packages/cli/src/commands/schedule/types.ts new file mode 100644 index 000000000..0e3e5b11a --- /dev/null +++ b/packages/cli/src/commands/schedule/types.ts @@ -0,0 +1,141 @@ +export type ScheduleStatus = "active" | "paused" | "completed"; + +export type ScheduleCadence = + | { + type: "every"; + everyMs: number; + } + | { + type: "cron"; + expression: string; + }; + +export type ScheduleTarget = + | { + type: "self"; + agentId: string; + } + | { + type: "agent"; + agentId: string; + } + | { + type: "new-agent"; + config: { + provider: "claude" | "codex" | "opencode"; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + title?: string | null; + approvalPolicy?: string; + sandboxMode?: string; + networkAccess?: boolean; + webSearch?: boolean; + }; + }; + +export interface ScheduleRunRecord { + id: string; + scheduledFor: string; + startedAt: string; + endedAt: string | null; + status: "running" | "succeeded" | "failed"; + agentId: string | null; + output: string | null; + error: string | null; +} + +export interface ScheduleRecord { + id: string; + name: string | null; + prompt: string; + cadence: ScheduleCadence; + target: Exclude; + status: ScheduleStatus; + createdAt: string; + updatedAt: string; + nextRunAt: string | null; + lastRunAt: string | null; + pausedAt: string | null; + expiresAt: string | null; + maxRuns: number | null; + runs: ScheduleRunRecord[]; +} + +export interface ScheduleListItem { + id: string; + name: string | null; + cadence: ScheduleCadence; + target: Exclude; + status: ScheduleStatus; + nextRunAt: string | null; + lastRunAt: string | null; + createdAt: string; + updatedAt: string; + pausedAt: string | null; + expiresAt: string | null; + maxRuns: number | null; +} + +export interface CreateScheduleInput { + prompt: string; + name?: string; + cadence: ScheduleCadence; + target: ScheduleTarget; + maxRuns?: number; + expiresAt?: string; +} + +export interface ScheduleCreatePayload { + requestId: string; + schedule: ScheduleListItem | null; + error: string | null; +} + +export interface ScheduleListPayload { + requestId: string; + schedules: ScheduleListItem[]; + error: string | null; +} + +export interface ScheduleInspectPayload { + requestId: string; + schedule: ScheduleRecord | null; + error: string | null; +} + +export interface ScheduleLogsPayload { + requestId: string; + runs: ScheduleRunRecord[]; + error: string | null; +} + +export interface SchedulePausePayload { + requestId: string; + schedule: ScheduleListItem | null; + error: string | null; +} + +export interface ScheduleResumePayload { + requestId: string; + schedule: ScheduleListItem | null; + error: string | null; +} + +export interface ScheduleDeletePayload { + requestId: string; + scheduleId: string; + error: string | null; +} + +export interface ScheduleDaemonClient { + scheduleCreate(input: CreateScheduleInput): Promise; + scheduleList(): Promise; + scheduleInspect(input: { id: string }): Promise; + scheduleLogs(input: { id: string }): Promise; + schedulePause(input: { id: string }): Promise; + scheduleResume(input: { id: string }): Promise; + scheduleDelete(input: { id: string }): Promise; + close(): Promise; +} diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index ed3ddce94..89e301c38 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -31,11 +31,6 @@ type ProviderModel = { }; const EXPECTED_CLAUDE_MODELS = [ - { - id: "claude-sonnet-4-5-20250929", - model: "Sonnet 4.5", - descriptionFragment: "Best for everyday tasks", - }, { id: "claude-sonnet-4-6", model: "Sonnet 4.6", @@ -47,7 +42,7 @@ const EXPECTED_CLAUDE_MODELS = [ descriptionFragment: "Most capable", }, { - id: "claude-haiku-4-5-20251001", + id: "claude-haiku-4-5", model: "Haiku 4.5", descriptionFragment: "Fastest", }, @@ -207,10 +202,13 @@ try { ids.includes("opencode/gpt-5-nano"), "opencode output should include opencode/gpt-5-nano", ); - assert(ids.includes("openai/o3-mini"), "opencode output should include openai/o3-mini"); assert( - ids.includes("openai/gpt-5.3-codex-spark"), - "opencode output should include openai/gpt-5.3-codex-spark", + ids.some((id) => id.startsWith("openrouter/openai/")), + "opencode output should include OpenRouter OpenAI models", + ); + assert( + ids.includes("openrouter/openai/gpt-5.3-codex"), + "opencode output should include openrouter/openai/gpt-5.3-codex", ); console.log("✓ provider models opencode returns namespaced model IDs\n"); } @@ -269,8 +267,8 @@ try { "--quiet should print the current Claude catalog IDs", ); assert( - claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-5-20250929"), - "captured --json output should still include the Claude default model id", + claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-6"), + "captured --json output should include the current Claude everyday model id", ); console.log("✓ provider models --quiet outputs model IDs only\n"); } diff --git a/packages/cli/tests/30-chat.test.ts b/packages/cli/tests/30-chat.test.ts new file mode 100644 index 000000000..6fc8cb252 --- /dev/null +++ b/packages/cli/tests/30-chat.test.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { rm } from "node:fs/promises"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; + +console.log("=== Chat Command Tests ===\n"); + +const ctx = await createE2ETestContext({ timeout: 30000 }); + +try { + { + console.log("Test 1: chat create/ls/inspect work"); + const created = await ctx.paseo(["chat", "create", "coord-room", "--purpose", "Coordination"]); + assert.strictEqual(created.exitCode, 0, created.stderr); + assert(created.stdout.includes("coord-room"), created.stdout); + + const listed = await ctx.paseo(["chat", "ls"]); + assert.strictEqual(listed.exitCode, 0, listed.stderr); + assert(listed.stdout.includes("coord-room"), listed.stdout); + + const inspected = await ctx.paseo(["chat", "inspect", "coord-room"]); + assert.strictEqual(inspected.exitCode, 0, inspected.stderr); + assert(inspected.stdout.includes("Coordination"), inspected.stdout); + console.log("chat create/ls/inspect work\n"); + } + + { + console.log("Test 2: chat post/read/wait work"); + const posted = await ctx.paseo([ + "chat", + "post", + "coord-room", + "first message", + "--mention", + "agent-1", + ]); + assert.strictEqual(posted.exitCode, 0, posted.stderr); + assert(posted.stdout.includes("first message"), posted.stdout); + + const read = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10"]); + assert.strictEqual(read.exitCode, 0, read.stderr); + assert(read.stdout.includes("first message"), read.stdout); + + const waitPromise = ctx.paseo(["chat", "wait", "coord-room", "--timeout", "5s"]); + await new Promise((resolve) => setTimeout(resolve, 250)); + const secondPost = await ctx.paseo(["chat", "post", "coord-room", "second message"]); + assert.strictEqual(secondPost.exitCode, 0, secondPost.stderr); + + const waited = await waitPromise; + assert.strictEqual(waited.exitCode, 0, waited.stderr); + assert(waited.stdout.includes("second message"), waited.stdout); + console.log("chat post/read/wait work\n"); + } + + { + console.log("Test 3: duplicate room create fails"); + const duplicate = await ctx.paseo(["chat", "create", "coord-room"]); + assert.notStrictEqual(duplicate.exitCode, 0, "duplicate create should fail"); + const combined = `${duplicate.stdout}\n${duplicate.stderr}`; + assert(combined.toLowerCase().includes("already exists"), combined); + console.log("duplicate room create fails\n"); + } + + { + console.log("Test 4: chat delete works"); + const deleted = await ctx.paseo(["chat", "delete", "coord-room"]); + assert.strictEqual(deleted.exitCode, 0, deleted.stderr); + assert(deleted.stdout.includes("coord-room"), deleted.stdout); + console.log("chat delete works\n"); + } +} finally { + await ctx.stop(); + await rm(ctx.paseoHome, { recursive: true, force: true }); + await rm(ctx.workDir, { recursive: true, force: true }); +} + +console.log("=== Chat Command Tests Passed ==="); diff --git a/packages/cli/tests/31-loop-schedule.test.ts b/packages/cli/tests/31-loop-schedule.test.ts new file mode 100644 index 000000000..2ccc00035 --- /dev/null +++ b/packages/cli/tests/31-loop-schedule.test.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { rm } from "node:fs/promises"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +console.log("=== Loop And Schedule Command Tests ===\n"); + +const ctx = await createE2ETestContext({ timeout: 30000 }); + +try { + { + console.log("Test 1: schedule create/ls/inspect/pause/resume/delete work"); + const created = await ctx.paseo( + ["schedule", "create", "Review new PRs", "--every", "5m", "--name", "review-prs", "--json"], + { timeout: 30000 }, + ); + assert.strictEqual(created.exitCode, 0, created.stderr); + const createdJson = JSON.parse(created.stdout); + assert.strictEqual(createdJson.name, "review-prs"); + assert.strictEqual(createdJson.cadence, "every:5m"); + + const listed = await ctx.paseo(["schedule", "ls", "--json"]); + assert.strictEqual(listed.exitCode, 0, listed.stderr); + const listedJson = JSON.parse(listed.stdout); + assert(Array.isArray(listedJson), listed.stdout); + assert(listedJson.some((item: { id: string }) => item.id === createdJson.id), listed.stdout); + + const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]); + assert.strictEqual(inspected.exitCode, 0, inspected.stderr); + const inspectedJson = JSON.parse(inspected.stdout); + assert.strictEqual(inspectedJson.status, "active"); + assert.strictEqual(inspectedJson.prompt, "Review new PRs"); + + const paused = await ctx.paseo(["schedule", "pause", createdJson.id, "--json"]); + assert.strictEqual(paused.exitCode, 0, paused.stderr); + assert.strictEqual(JSON.parse(paused.stdout).status, "paused"); + + const resumed = await ctx.paseo(["schedule", "resume", createdJson.id, "--json"]); + assert.strictEqual(resumed.exitCode, 0, resumed.stderr); + assert.strictEqual(JSON.parse(resumed.stdout).status, "active"); + + const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]); + assert.strictEqual(deleted.exitCode, 0, deleted.stderr); + assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id); + console.log("schedule commands work\n"); + } + + { + console.log("Test 2: loop run/ls/inspect/logs/stop work"); + const run = await ctx.paseo( + ["loop", "run", "Return any response", "--name", "smoke-loop", "--verify-check", "true", "--json"], + { timeout: 30000 }, + ); + assert.strictEqual(run.exitCode, 0, run.stderr); + const runJson = JSON.parse(run.stdout); + assert.strictEqual(runJson.name, "smoke-loop"); + + const listed = await ctx.paseo(["loop", "ls", "--json"]); + assert.strictEqual(listed.exitCode, 0, listed.stderr); + const listedJson = JSON.parse(listed.stdout); + assert(Array.isArray(listedJson), listed.stdout); + assert(listedJson.some((item: { id: string }) => item.id === runJson.id), listed.stdout); + + let status = "running"; + for (let attempt = 0; attempt < 40; attempt += 1) { + const inspect = await ctx.paseo(["loop", "inspect", runJson.id, "--json"]); + assert.strictEqual(inspect.exitCode, 0, inspect.stderr); + const inspectJson = JSON.parse(inspect.stdout); + status = inspectJson.status; + if (status !== "running") { + assert.strictEqual(status, "succeeded", inspect.stdout); + break; + } + await sleep(250); + } + assert.strictEqual(status, "succeeded"); + + const logs = await ctx.paseo(["loop", "logs", runJson.id], { timeout: 15000 }); + assert.strictEqual(logs.exitCode, 0, logs.stderr); + assert(logs.stdout.includes("verify-check"), logs.stdout); + + const stopped = await ctx.paseo(["loop", "stop", runJson.id, "--json"]); + assert.strictEqual(stopped.exitCode, 0, stopped.stderr); + const stoppedJson = JSON.parse(stopped.stdout); + assert(["succeeded", "stopped"].includes(stoppedJson.status), stopped.stdout); + console.log("loop commands work\n"); + } +} finally { + await ctx.stop(); + await rm(ctx.paseoHome, { recursive: true, force: true }); + await rm(ctx.workDir, { recursive: true, force: true }); +} + +console.log("=== Loop And Schedule Command Tests Passed ==="); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index f927f3fa6..2eb3546fd 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -241,6 +241,82 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; +type ChatCreatePayload = Extract< + SessionOutboundMessage, + { type: "chat/create/response" } +>["payload"]; +type ChatListPayload = Extract< + SessionOutboundMessage, + { type: "chat/list/response" } +>["payload"]; +type ChatInspectPayload = Extract< + SessionOutboundMessage, + { type: "chat/inspect/response" } +>["payload"]; +type ChatDeletePayload = Extract< + SessionOutboundMessage, + { type: "chat/delete/response" } +>["payload"]; +type ChatPostPayload = Extract< + SessionOutboundMessage, + { type: "chat/post/response" } +>["payload"]; +type ChatReadPayload = Extract< + SessionOutboundMessage, + { type: "chat/read/response" } +>["payload"]; +type ChatWaitPayload = Extract< + SessionOutboundMessage, + { type: "chat/wait/response" } +>["payload"]; +type LoopRunPayload = Extract< + SessionOutboundMessage, + { type: "loop/run/response" } +>["payload"]; +type LoopListPayload = Extract< + SessionOutboundMessage, + { type: "loop/list/response" } +>["payload"]; +type LoopInspectPayload = Extract< + SessionOutboundMessage, + { type: "loop/inspect/response" } +>["payload"]; +type LoopLogsPayload = Extract< + SessionOutboundMessage, + { type: "loop/logs/response" } +>["payload"]; +type LoopStopPayload = Extract< + SessionOutboundMessage, + { type: "loop/stop/response" } +>["payload"]; +type ScheduleCreatePayload = Extract< + SessionOutboundMessage, + { type: "schedule/create/response" } +>["payload"]; +type ScheduleListPayload = Extract< + SessionOutboundMessage, + { type: "schedule/list/response" } +>["payload"]; +type ScheduleInspectPayload = Extract< + SessionOutboundMessage, + { type: "schedule/inspect/response" } +>["payload"]; +type ScheduleLogsPayload = Extract< + SessionOutboundMessage, + { type: "schedule/logs/response" } +>["payload"]; +type SchedulePausePayload = Extract< + SessionOutboundMessage, + { type: "schedule/pause/response" } +>["payload"]; +type ScheduleResumePayload = Extract< + SessionOutboundMessage, + { type: "schedule/resume/response" } +>["payload"]; +type ScheduleDeletePayload = Extract< + SessionOutboundMessage, + { type: "schedule/delete/response" } +>["payload"]; export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; @@ -277,6 +353,110 @@ export type FetchWorkspacesOptions = Omit { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "chat/create", + name: options.name, + ...(options.purpose ? { purpose: options.purpose } : {}), + }, + responseType: "chat/create/response", + timeout: 10000, + }); + } + + async listChatRooms(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "chat/list", + }, + responseType: "chat/list/response", + timeout: 10000, + }); + } + + async inspectChatRoom(options: InspectChatRoomOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "chat/inspect", + room: options.room, + }, + responseType: "chat/inspect/response", + timeout: 10000, + }); + } + + async deleteChatRoom(options: DeleteChatRoomOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "chat/delete", + room: options.room, + }, + responseType: "chat/delete/response", + timeout: 10000, + }); + } + + async postChatMessage(options: PostChatMessageOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "chat/post", + room: options.room, + body: options.body, + ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}), + ...(options.mentionAgentIds && options.mentionAgentIds.length > 0 + ? { mentionAgentIds: options.mentionAgentIds } + : {}), + }, + responseType: "chat/post/response", + timeout: 10000, + }); + } + + async readChatMessages(options: ReadChatMessagesOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "chat/read", + room: options.room, + ...(typeof options.limit === "number" ? { limit: options.limit } : {}), + ...(options.since ? { since: options.since } : {}), + ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}), + }, + responseType: "chat/read/response", + timeout: 10000, + }); + } + + async waitForChatMessages(options: WaitForChatMessagesOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "chat/wait", + room: options.room, + ...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}), + ...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}), + }, + responseType: "chat/wait/response", + timeout: (options.timeoutMs ?? 0) + 10000, + }); + } + + async scheduleCreate(options: CreateScheduleOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "schedule/create", + prompt: options.prompt, + cadence: options.cadence, + target: options.target, + ...(options.name ? { name: options.name } : {}), + ...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}), + ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), + }, + responseType: "schedule/create/response", + timeout: 10000, + }); + } + + async scheduleList(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "schedule/list", + }, + responseType: "schedule/list/response", + timeout: 10000, + }); + } + + async scheduleInspect(options: InspectScheduleOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "schedule/inspect", + scheduleId: options.id, + }, + responseType: "schedule/inspect/response", + timeout: 10000, + }); + } + + async scheduleLogs(options: InspectScheduleOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "schedule/logs", + scheduleId: options.id, + }, + responseType: "schedule/logs/response", + timeout: 10000, + }); + } + + async schedulePause(options: InspectScheduleOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "schedule/pause", + scheduleId: options.id, + }, + responseType: "schedule/pause/response", + timeout: 10000, + }); + } + + async scheduleResume(options: InspectScheduleOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "schedule/resume", + scheduleId: options.id, + }, + responseType: "schedule/resume/response", + timeout: 10000, + }); + } + + async scheduleDelete(options: InspectScheduleOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "schedule/delete", + scheduleId: options.id, + }, + responseType: "schedule/delete/response", + timeout: 10000, + }); + } + + async loopRun(options: RunLoopOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "loop/run", + prompt: options.prompt, + cwd: options.cwd, + ...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}), + ...(options.verifyChecks && options.verifyChecks.length > 0 + ? { verifyChecks: options.verifyChecks } + : {}), + ...(options.name ? { name: options.name } : {}), + ...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}), + ...(typeof options.maxIterations === "number" + ? { maxIterations: options.maxIterations } + : {}), + ...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}), + }, + responseType: "loop/run/response", + timeout: 15000, + }); + } + + async loopList(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "loop/list", + }, + responseType: "loop/list/response", + timeout: 10000, + }); + } + + async loopInspect(options: string | InspectLoopOptions): Promise { + const normalized = typeof options === "string" ? { id: options } : options; + return this.sendCorrelatedSessionRequest({ + requestId: normalized.requestId, + message: { + type: "loop/inspect", + id: normalized.id, + }, + responseType: "loop/inspect/response", + timeout: 10000, + }); + } + + async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise { + const normalized = + typeof options === "string" ? { id: options, afterSeq } : options; + return this.sendCorrelatedSessionRequest({ + requestId: normalized.requestId, + message: { + type: "loop/logs", + id: normalized.id, + ...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}), + }, + responseType: "loop/logs/response", + timeout: 10000, + }); + } + + async loopStop(options: string | StopLoopOptions): Promise { + const normalized = typeof options === "string" ? { id: options } : options; + return this.sendCorrelatedSessionRequest({ + requestId: normalized.requestId, + message: { + type: "loop/stop", + id: normalized.id, + }, + responseType: "loop/stop/response", + timeout: 10000, + }); + } + onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { this.terminalStreamListeners.add(handler); return () => { diff --git a/packages/server/src/server/agent-attention-policy.ts b/packages/server/src/server/agent-attention-policy.ts index 57cafb4b5..ac76776ca 100644 --- a/packages/server/src/server/agent-attention-policy.ts +++ b/packages/server/src/server/agent-attention-policy.ts @@ -1,3 +1,5 @@ +import type { AgentAttentionReason } from "../shared/agent-attention-notification.js"; + export type ClientAttentionState = { deviceType: "web" | "mobile" | null; focusedAgentId: string | null; @@ -5,8 +7,6 @@ export type ClientAttentionState = { appVisible: boolean; }; -export type AgentAttentionReason = "finished" | "error" | "permission"; - type ComputeClientNotificationInput = { clientState: ClientAttentionState; allClientStates: ClientAttentionState[]; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0027fedb8..b00bde3cb 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -99,6 +99,9 @@ import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; +import { FileBackedChatService } from "./chat/chat-service.js"; +import { LoopService } from "./loop-service.js"; +import { ScheduleService } from "./schedule/service.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; @@ -374,6 +377,10 @@ export async function createPaseoDaemon( path.join(config.paseoHome, "projects", "workspaces.json"), logger, ); + const chatService = new FileBackedChatService({ + paseoHome: config.paseoHome, + logger, + }); const agentManager = new AgentManager({ clients: { ...createAllClients(logger, { @@ -402,6 +409,23 @@ export async function createPaseoDaemon( logger, }); logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); + await chatService.initialize(); + logger.info({ elapsed: elapsed() }, "Chat service initialized"); + const loopService = new LoopService({ + paseoHome: config.paseoHome, + logger, + agentManager, + }); + await loopService.initialize(); + logger.info({ elapsed: elapsed() }, "Loop service initialized"); + const scheduleService = new ScheduleService({ + paseoHome: config.paseoHome, + logger, + agentManager, + agentStorage, + }); + await scheduleService.start(); + logger.info({ elapsed: elapsed() }, "Schedule service initialized"); const persistedRecords = await agentStorage.list(); logger.info( { elapsed: elapsed() }, @@ -634,6 +658,9 @@ export async function createPaseoDaemon( }, projectRegistry, workspaceRegistry, + chatService, + loopService, + scheduleService, ); unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => { wsServer?.publishSpeechReadiness(snapshot); @@ -727,6 +754,7 @@ export async function createPaseoDaemon( unsubscribeSpeechReadiness?.(); unsubscribeSpeechReadiness = null; cleanupSpeechRuntime(); + await scheduleService.stop().catch(() => undefined); await relayTransport?.stop().catch(() => undefined); if (wsServer) { await wsServer.close(); diff --git a/packages/server/src/server/chat/chat-rpc-schemas.ts b/packages/server/src/server/chat/chat-rpc-schemas.ts new file mode 100644 index 000000000..9bef27705 --- /dev/null +++ b/packages/server/src/server/chat/chat-rpc-schemas.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; +import { ChatMessageSchema, ChatRoomDetailSchema } from "./chat-types.js"; + +export const ChatCreateRequestSchema = z.object({ + type: z.literal("chat/create"), + requestId: z.string(), + name: z.string(), + purpose: z.string().optional(), +}); + +export const ChatListRequestSchema = z.object({ + type: z.literal("chat/list"), + requestId: z.string(), +}); + +export const ChatInspectRequestSchema = z.object({ + type: z.literal("chat/inspect"), + requestId: z.string(), + room: z.string(), +}); + +export const ChatDeleteRequestSchema = z.object({ + type: z.literal("chat/delete"), + requestId: z.string(), + room: z.string(), +}); + +export const ChatPostRequestSchema = z.object({ + type: z.literal("chat/post"), + requestId: z.string(), + room: z.string(), + body: z.string(), + replyToMessageId: z.string().optional(), + mentionAgentIds: z.array(z.string()).optional(), +}); + +export const ChatReadRequestSchema = z.object({ + type: z.literal("chat/read"), + requestId: z.string(), + room: z.string(), + limit: z.number().int().nonnegative().optional(), + since: z.string().optional(), + authorAgentId: z.string().optional(), +}); + +export const ChatWaitRequestSchema = z.object({ + type: z.literal("chat/wait"), + requestId: z.string(), + room: z.string(), + afterMessageId: z.string().optional(), + timeoutMs: z.number().int().nonnegative().optional(), +}); + +export const ChatCreateResponseSchema = z.object({ + type: z.literal("chat/create/response"), + payload: z.object({ + requestId: z.string(), + room: ChatRoomDetailSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ChatListResponseSchema = z.object({ + type: z.literal("chat/list/response"), + payload: z.object({ + requestId: z.string(), + rooms: z.array(ChatRoomDetailSchema), + error: z.string().nullable(), + }), +}); + +export const ChatInspectResponseSchema = z.object({ + type: z.literal("chat/inspect/response"), + payload: z.object({ + requestId: z.string(), + room: ChatRoomDetailSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ChatDeleteResponseSchema = z.object({ + type: z.literal("chat/delete/response"), + payload: z.object({ + requestId: z.string(), + room: ChatRoomDetailSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ChatPostResponseSchema = z.object({ + type: z.literal("chat/post/response"), + payload: z.object({ + requestId: z.string(), + message: ChatMessageSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ChatReadResponseSchema = z.object({ + type: z.literal("chat/read/response"), + payload: z.object({ + requestId: z.string(), + messages: z.array(ChatMessageSchema), + error: z.string().nullable(), + }), +}); + +export const ChatWaitResponseSchema = z.object({ + type: z.literal("chat/wait/response"), + payload: z.object({ + requestId: z.string(), + messages: z.array(ChatMessageSchema), + timedOut: z.boolean(), + error: z.string().nullable(), + }), +}); diff --git a/packages/server/src/server/chat/chat-service.test.ts b/packages/server/src/server/chat/chat-service.test.ts new file mode 100644 index 000000000..04b7320ac --- /dev/null +++ b/packages/server/src/server/chat/chat-service.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { ChatServiceError, FileBackedChatService } from "./chat-service.js"; + +describe("FileBackedChatService", () => { + let paseoHome: string; + let service: FileBackedChatService; + + beforeEach(async () => { + paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-chat-service-")); + service = new FileBackedChatService({ + paseoHome, + logger: pino({ level: "silent" }), + }); + await service.initialize(); + }); + + afterEach(async () => { + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("creates rooms, enforces unique names, and persists to disk", async () => { + const created = await service.createRoom({ + name: "cli-features-epic", + purpose: "Coordination room", + }); + + await expect( + service.createRoom({ + name: "CLI-FEATURES-EPIC", + }), + ).rejects.toMatchObject>({ + code: "chat_room_name_taken", + }); + + const raw = await readFile(path.join(paseoHome, "chat", "rooms.json"), "utf8"); + expect(raw).toContain("cli-features-epic"); + expect(created.name).toBe("cli-features-epic"); + expect(created.purpose).toBe("Coordination room"); + expect(created.messageCount).toBe(0); + }); + + test("resolves rooms by name or ID, validates replies, and reads filtered messages", async () => { + const room = await service.createRoom({ name: "auth-refactor" }); + const first = await service.postMessage({ + room: room.name, + authorAgentId: "agent-a", + body: "first message", + mentionAgentIds: ["agent-b", "agent-b", "agent-c"], + }); + await service.postMessage({ + room: room.id, + authorAgentId: "agent-b", + body: "reply", + replyToMessageId: first.id, + }); + + await expect( + service.postMessage({ + room: room.name, + authorAgentId: "agent-b", + body: "bad reply", + replyToMessageId: "missing", + }), + ).rejects.toMatchObject>({ + code: "chat_message_not_found", + }); + + const all = await service.readMessages({ room: room.name, limit: 10 }); + expect(all).toHaveLength(2); + expect(all[0]?.mentionAgentIds).toEqual(["agent-b", "agent-c"]); + + const byAuthor = await service.readMessages({ + room: room.id, + authorAgentId: "agent-b", + limit: 10, + }); + expect(byAuthor).toHaveLength(1); + expect(byAuthor[0]?.body).toBe("reply"); + + const detail = await service.inspectRoom({ room: room.name }); + expect(detail.room.messageCount).toBe(2); + expect(detail.room.lastMessageAt).toBeTruthy(); + }); + + test("waits for new messages after a cursor and times out with an empty result", async () => { + const room = await service.createRoom({ name: "loop-status" }); + const first = await service.postMessage({ + room: room.name, + authorAgentId: "agent-a", + body: "ready", + }); + + const waitPromise = service.waitForMessages({ + room: room.name, + afterMessageId: first.id, + timeoutMs: 1000, + }); + await service.postMessage({ + room: room.name, + authorAgentId: "agent-b", + body: "new work", + }); + + const waited = await waitPromise; + expect(waited).toHaveLength(1); + expect(waited[0]?.body).toBe("new work"); + + const timedOut = await service.waitForMessages({ + room: room.name, + afterMessageId: waited[0]?.id, + timeoutMs: 10, + }); + expect(timedOut).toEqual([]); + }); + + test("deletes rooms, removes messages, and rejects pending waiters", async () => { + const room = await service.createRoom({ name: "schedule-jobs" }); + await service.postMessage({ + room: room.name, + authorAgentId: "agent-a", + body: "hello", + }); + + const waitPromise = service.waitForMessages({ + room: room.name, + timeoutMs: 1000, + }); + const deleted = await service.deleteRoom({ room: room.id }); + expect(deleted.room.messageCount).toBe(1); + + await expect(waitPromise).rejects.toMatchObject>({ + code: "chat_room_deleted", + }); + await expect(service.inspectRoom({ room: room.name })).rejects.toMatchObject< + Partial + >({ + code: "chat_room_not_found", + }); + }); +}); diff --git a/packages/server/src/server/chat/chat-service.ts b/packages/server/src/server/chat/chat-service.ts new file mode 100644 index 000000000..520a99ff9 --- /dev/null +++ b/packages/server/src/server/chat/chat-service.ts @@ -0,0 +1,439 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type pino from "pino"; +import { z } from "zod"; +import { + ChatMessageSchema, + ChatRoomDetailSchema, + ChatRoomSchema, + type ChatMessage, + type ChatRoom, + type ChatRoomDetail, +} from "./chat-types.js"; + +const ChatStorePayloadSchema = z.object({ + rooms: z.array(ChatRoomSchema), + messages: z.array(ChatMessageSchema), +}); + +type ChatStorePayload = z.infer; + +function normalizeRoomName(name: string): string { + return name.trim().toLocaleLowerCase(); +} + +function trimToNull(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export class ChatServiceError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "ChatServiceError"; + this.code = code; + } +} + +type Waiter = { + roomId: string; + afterMessageId: string | null; + resolve: (messages: ChatMessage[]) => void; + reject: (error: Error) => void; + timeout: ReturnType | null; +}; + +export interface CreateChatRoomInput { + name: string; + purpose?: string | null; +} + +export interface InspectChatRoomInput { + room: string; +} + +export interface DeleteChatRoomInput { + room: string; +} + +export interface PostChatMessageInput { + room: string; + authorAgentId: string; + body: string; + replyToMessageId?: string | null; + mentionAgentIds?: string[]; +} + +export interface ReadChatMessagesInput { + room: string; + limit?: number; + since?: string; + authorAgentId?: string; +} + +export interface WaitForChatMessagesInput { + room: string; + afterMessageId?: string | null; + timeoutMs?: number; +} + +export interface DeleteChatRoomResult { + room: ChatRoomDetail; +} + +export interface InspectChatRoomResult { + room: ChatRoomDetail; +} + +export class FileBackedChatService { + private readonly filePath: string; + private readonly logger: pino.Logger; + private loaded = false; + private readonly rooms = new Map(); + private readonly messagesByRoomId = new Map(); + private persistQueue: Promise = Promise.resolve(); + private readonly waitersByRoomId = new Map>(); + + constructor(options: { paseoHome: string; logger: pino.Logger }) { + this.filePath = path.join(options.paseoHome, "chat", "rooms.json"); + this.logger = options.logger.child({ component: "chat-service" }); + } + + async initialize(): Promise { + await this.load(); + } + + async createRoom(input: CreateChatRoomInput): Promise { + await this.load(); + const name = input.name.trim(); + if (name.length === 0) { + throw new ChatServiceError("invalid_chat_room_name", "Chat room name is required"); + } + if (this.findRoomByName(name)) { + throw new ChatServiceError( + "chat_room_name_taken", + `Chat room already exists with name: ${name}`, + ); + } + + const now = new Date().toISOString(); + const room = ChatRoomSchema.parse({ + id: randomUUID(), + name, + purpose: trimToNull(input.purpose), + createdAt: now, + updatedAt: now, + }); + this.rooms.set(room.id, room); + await this.enqueuePersist(); + return this.toRoomDetail(room); + } + + async listRooms(): Promise { + await this.load(); + return Array.from(this.rooms.values()) + .map((room) => this.toRoomDetail(room)) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + } + + async inspectRoom(input: InspectChatRoomInput): Promise { + await this.load(); + const room = this.resolveRoom(input.room); + return { + room: this.toRoomDetail(room), + }; + } + + async deleteRoom(input: DeleteChatRoomInput): Promise { + await this.load(); + const room = this.resolveRoom(input.room); + const detail = this.toRoomDetail(room); + this.rooms.delete(room.id); + this.messagesByRoomId.delete(room.id); + await this.enqueuePersist(); + this.rejectWaiters( + room.id, + new ChatServiceError("chat_room_deleted", `Chat room deleted: ${room.name}`), + ); + return { room: detail }; + } + + async postMessage(input: PostChatMessageInput): Promise { + await this.load(); + const room = this.resolveRoom(input.room); + const body = input.body.trim(); + if (body.length === 0) { + throw new ChatServiceError("invalid_chat_message", "Chat message body is required"); + } + const authorAgentId = input.authorAgentId.trim(); + if (authorAgentId.length === 0) { + throw new ChatServiceError("invalid_chat_author", "Chat message author is required"); + } + + const messages = this.getRoomMessages(room.id); + const replyToMessageId = trimToNull(input.replyToMessageId); + if (replyToMessageId) { + const replyTarget = messages.find((message) => message.id === replyToMessageId); + if (!replyTarget) { + throw new ChatServiceError( + "chat_message_not_found", + `Reply target not found: ${replyToMessageId}`, + ); + } + } + + const createdAt = new Date().toISOString(); + const message = ChatMessageSchema.parse({ + id: randomUUID(), + roomId: room.id, + authorAgentId, + body, + replyToMessageId, + mentionAgentIds: [...new Set((input.mentionAgentIds ?? []).map((value) => value.trim()))] + .filter((value) => value.length > 0) + .sort(), + createdAt, + }); + + messages.push(message); + this.messagesByRoomId.set(room.id, messages); + this.rooms.set( + room.id, + ChatRoomSchema.parse({ + ...room, + updatedAt: createdAt, + }), + ); + await this.enqueuePersist(); + this.notifyWaiters(room.id); + return message; + } + + async readMessages(input: ReadChatMessagesInput): Promise { + await this.load(); + const room = this.resolveRoom(input.room); + const messages = [...this.getRoomMessages(room.id)]; + const since = trimToNull(input.since); + const authorAgentId = trimToNull(input.authorAgentId); + const limit = this.normalizeLimit(input.limit); + + const filtered = messages.filter((message) => { + if (since && message.createdAt < since) { + return false; + } + if (authorAgentId && message.authorAgentId !== authorAgentId) { + return false; + } + return true; + }); + + if (limit === 0 || filtered.length <= limit) { + return filtered; + } + return filtered.slice(filtered.length - limit); + } + + async waitForMessages(input: WaitForChatMessagesInput): Promise { + await this.load(); + const room = this.resolveRoom(input.room); + const timeoutMs = Math.max(0, Math.floor(input.timeoutMs ?? 0)); + const afterMessageId = trimToNull(input.afterMessageId); + + if (afterMessageId) { + const existing = this.selectMessagesAfter(room.id, afterMessageId); + if (existing.length > 0) { + return existing; + } + const knownMessage = this.getRoomMessages(room.id).some((message) => message.id === afterMessageId); + if (!knownMessage) { + throw new ChatServiceError( + "chat_message_not_found", + `Wait cursor not found: ${afterMessageId}`, + ); + } + } + + return new Promise((resolve, reject) => { + const waiter: Waiter = { + roomId: room.id, + afterMessageId, + resolve: (messages) => { + if (waiter.timeout) { + clearTimeout(waiter.timeout); + waiter.timeout = null; + } + this.removeWaiter(waiter); + resolve(messages); + }, + reject: (error) => { + if (waiter.timeout) { + clearTimeout(waiter.timeout); + waiter.timeout = null; + } + this.removeWaiter(waiter); + reject(error); + }, + timeout: null, + }; + + if (timeoutMs > 0) { + waiter.timeout = setTimeout(() => { + waiter.resolve([]); + }, timeoutMs); + } + + const roomWaiters = this.waitersByRoomId.get(room.id) ?? new Set(); + roomWaiters.add(waiter); + this.waitersByRoomId.set(room.id, roomWaiters); + }); + } + + private async load(): Promise { + if (this.loaded) { + return; + } + + this.rooms.clear(); + this.messagesByRoomId.clear(); + + try { + const raw = await fs.readFile(this.filePath, "utf8"); + const parsed = ChatStorePayloadSchema.parse(JSON.parse(raw)); + for (const room of parsed.rooms) { + this.rooms.set(room.id, room); + } + for (const message of parsed.messages) { + const messages = this.messagesByRoomId.get(message.roomId) ?? []; + messages.push(message); + this.messagesByRoomId.set(message.roomId, messages); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + this.logger.error({ err: error, filePath: this.filePath }, "Failed to load chat store"); + } + } + + this.loaded = true; + } + + private async enqueuePersist(): Promise { + const nextPersist = this.persistQueue.then(() => this.persist()); + this.persistQueue = nextPersist.catch(() => {}); + await nextPersist; + } + + private async persist(): Promise { + const payload: ChatStorePayload = { + rooms: Array.from(this.rooms.values()).sort((left, right) => left.createdAt.localeCompare(right.createdAt)), + messages: Array.from(this.messagesByRoomId.values()) + .flat() + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)), + }; + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await fs.writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8"); + await fs.rename(tempPath, this.filePath); + } + + private findRoomByName(name: string): ChatRoom | null { + const normalizedName = normalizeRoomName(name); + for (const room of this.rooms.values()) { + if (normalizeRoomName(room.name) === normalizedName) { + return room; + } + } + return null; + } + + private resolveRoom(roomSelector: string): ChatRoom { + const selector = roomSelector.trim(); + if (selector.length === 0) { + throw new ChatServiceError("invalid_chat_room", "Chat room name or ID is required"); + } + const byId = this.rooms.get(selector); + if (byId) { + return byId; + } + const byName = this.findRoomByName(selector); + if (byName) { + return byName; + } + throw new ChatServiceError("chat_room_not_found", `Chat room not found: ${selector}`); + } + + private getRoomMessages(roomId: string): ChatMessage[] { + return this.messagesByRoomId.get(roomId) ?? []; + } + + private toRoomDetail(room: ChatRoom): ChatRoomDetail { + const messages = this.getRoomMessages(room.id); + return ChatRoomDetailSchema.parse({ + ...room, + messageCount: messages.length, + lastMessageAt: messages[messages.length - 1]?.createdAt ?? null, + }); + } + + private normalizeLimit(limit: number | undefined): number { + if (limit === undefined) { + return 20; + } + const normalized = Math.max(0, Math.floor(limit)); + return normalized; + } + + private selectMessagesAfter(roomId: string, afterMessageId: string): ChatMessage[] { + const messages = this.getRoomMessages(roomId); + const index = messages.findIndex((message) => message.id === afterMessageId); + if (index === -1) { + return []; + } + return messages.slice(index + 1); + } + + private notifyWaiters(roomId: string): void { + const waiters = this.waitersByRoomId.get(roomId); + if (!waiters || waiters.size === 0) { + return; + } + + for (const waiter of [...waiters]) { + const messages = + waiter.afterMessageId === null + ? this.getRoomMessages(roomId).slice(-1) + : this.selectMessagesAfter(roomId, waiter.afterMessageId); + if (messages.length === 0) { + continue; + } + waiter.resolve(messages); + } + } + + private removeWaiter(waiter: Waiter): void { + const waiters = this.waitersByRoomId.get(waiter.roomId); + if (!waiters) { + return; + } + waiters.delete(waiter); + if (waiters.size === 0) { + this.waitersByRoomId.delete(waiter.roomId); + } + } + + private rejectWaiters(roomId: string, error: Error): void { + const waiters = this.waitersByRoomId.get(roomId); + if (!waiters) { + return; + } + for (const waiter of [...waiters]) { + waiter.reject(error); + } + } +} diff --git a/packages/server/src/server/chat/chat-types.ts b/packages/server/src/server/chat/chat-types.ts new file mode 100644 index 000000000..d8ecd05ab --- /dev/null +++ b/packages/server/src/server/chat/chat-types.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const ChatRoomSchema = z.object({ + id: z.string(), + name: z.string(), + purpose: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type ChatRoom = z.infer; + +export const ChatMessageSchema = z.object({ + id: z.string(), + roomId: z.string(), + authorAgentId: z.string(), + body: z.string(), + replyToMessageId: z.string().nullable(), + mentionAgentIds: z.array(z.string()), + createdAt: z.string(), +}); + +export type ChatMessage = z.infer; + +export const ChatRoomDetailSchema = ChatRoomSchema.extend({ + messageCount: z.number().int().nonnegative(), + lastMessageAt: z.string().nullable(), +}); + +export type ChatRoomDetail = z.infer; diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts new file mode 100644 index 000000000..8c6cae22e --- /dev/null +++ b/packages/server/src/server/loop-service.test.ts @@ -0,0 +1,356 @@ +import os from "node:os"; +import path from "node:path"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { beforeEach, afterEach, describe, expect, test } from "vitest"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentLaunchContext, + AgentMode, + AgentModelDefinition, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunOptions, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentSlashCommand, + AgentRuntimeInfo, + ListModelsOptions, +} from "./agent/agent-sdk-types.js"; +import { AgentStorage } from "./agent/agent-storage.js"; +import { AgentManager } from "./agent/agent-manager.js"; +import { LoopService } from "./loop-service.js"; +import { createTestLogger } from "../test-utils/test-logger.js"; + +const TEST_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +}; + +interface ScriptedAgentBehavior { + onRun(input: { config: AgentSessionConfig; prompt: string; turnId: string }): Promise; +} + +class ScriptedAgentClient implements AgentClient { + readonly provider = "claude" as const; + readonly capabilities = TEST_CAPABILITIES; + + constructor(private readonly behavior: ScriptedAgentBehavior) {} + + async isAvailable(): Promise { + return true; + } + + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + return new ScriptedAgentSession(config, this.behavior); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + return new ScriptedAgentSession( + { + provider: "claude", + cwd: overrides?.cwd ?? process.cwd(), + ...overrides, + }, + this.behavior, + ); + } + + async listModels(_options?: ListModelsOptions): Promise { + return []; + } +} + +class ScriptedAgentSession implements AgentSession { + readonly provider = "claude" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private turnCount = 0; + private interrupted = false; + + constructor( + private readonly config: AgentSessionConfig, + private readonly behavior: ScriptedAgentBehavior, + ) {} + + async run(): Promise { + return { + sessionId: this.id, + finalText: "", + timeline: [], + }; + } + + async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> { + const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt); + const turnId = `turn-${++this.turnCount}`; + this.interrupted = false; + setTimeout(() => { + void this.runScript(promptText, turnId); + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo(): Promise { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? null, + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes(): Promise { + return []; + } + + async getCurrentMode(): Promise { + return this.config.modeId ?? null; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence(): AgentPersistenceHandle { + return { + provider: this.provider, + sessionId: this.id, + }; + } + + async interrupt(): Promise { + this.interrupted = true; + } + + async close(): Promise {} + + async listCommands(): Promise { + return []; + } + + private emit(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private async runScript(prompt: string, turnId: string): Promise { + this.emit({ type: "turn_started", provider: this.provider, turnId }); + if (this.interrupted) { + this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId }); + return; + } + + try { + const responseText = await this.behavior.onRun({ + config: this.config, + prompt, + turnId, + }); + if (this.interrupted) { + this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId }); + return; + } + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: responseText }, + }); + this.emit({ type: "turn_completed", provider: this.provider, turnId }); + } catch (error) { + this.emit({ + type: "turn_failed", + provider: this.provider, + turnId, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +describe("LoopService", () => { + const logger = createTestLogger(); + let tmpDir: string; + let paseoHome: string; + let workspaceDir: string; + let storage: AgentStorage; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-")); + paseoHome = path.join(tmpDir, "paseo-home"); + workspaceDir = path.join(tmpDir, "workspace"); + storage = new AgentStorage(path.join(tmpDir, "agents"), logger); + mkdirSync(workspaceDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("runs fresh worker agents until verify-check passes", async () => { + const state = { workerRuns: 0 }; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient({ + async onRun({ config }) { + state.workerRuns += 1; + if (config.title?.includes("worker") && state.workerRuns >= 2) { + writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); + } + if (config.title?.includes("worker")) { + return `worker run ${state.workerRuns}`; + } + return "{\"passed\":true,\"reason\":\"not used\"}"; + }, + }), + }, + registry: storage, + logger, + }); + const service = new LoopService({ paseoHome, agentManager: manager, logger }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Create done.txt when the task is actually fixed.", + cwd: workspaceDir, + verifyChecks: ["test -f done.txt"], + sleepMs: 1, + maxIterations: 3, + }); + + while ((await service.inspectLoop(loop.id)).status === "running") { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("succeeded"); + expect(finalLoop.iterations).toHaveLength(2); + expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId); + expect(finalLoop.iterations[0]?.status).toBe("failed"); + expect(finalLoop.iterations[1]?.status).toBe("succeeded"); + expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false); + expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true); + expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id); + }); + + test("uses verifier prompt when provided", async () => { + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient({ + async onRun({ config }) { + if (config.title?.includes("worker")) { + await fsMkdir(workspaceDir); + writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); + return "created done.txt"; + } + const exists = pathExists(path.join(workspaceDir, "done.txt")); + return exists + ? "{\"passed\":true,\"reason\":\"done.txt exists\"}" + : "{\"passed\":false,\"reason\":\"done.txt missing\"}"; + }, + }), + }, + registry: storage, + logger, + }); + const service = new LoopService({ paseoHome, agentManager: manager, logger }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Create done.txt", + cwd: workspaceDir, + verifyPrompt: "Confirm that done.txt exists in the workspace.", + maxIterations: 1, + }); + + while ((await service.inspectLoop(loop.id)).status === "running") { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("succeeded"); + expect(finalLoop.iterations[0]?.verifyPrompt).toMatchObject({ + passed: true, + reason: "done.txt exists", + }); + const logs = await service.getLoopLogs(loop.id); + expect(logs.entries.some((entry) => entry.text.includes("Verifier result"))).toBe(true); + }); + + test("stops a running loop and cancels the active worker", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient({ + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return "{\"passed\":true,\"reason\":\"ok\"}"; + }, + }), + }, + registry: storage, + logger, + }); + const service = new LoopService({ paseoHome, agentManager: manager, logger }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Wait forever", + cwd: workspaceDir, + verifyChecks: ["test -f never.txt"], + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + const stopped = await service.stopLoop(loop.id); + release?.(); + + expect(stopped.status).toBe("stopped"); + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("stopped"); + expect(finalLoop.iterations[0]?.status).toBe("stopped"); + expect(finalLoop.logs.some((entry) => entry.text.includes("Stop requested"))).toBe(true); + }); +}); + +async function fsMkdir(target: string): Promise { + await import("node:fs/promises").then(({ mkdir }) => mkdir(target, { recursive: true })); +} + +function pathExists(target: string): boolean { + return existsSync(target); +} diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts new file mode 100644 index 000000000..45da03d16 --- /dev/null +++ b/packages/server/src/server/loop-service.ts @@ -0,0 +1,848 @@ +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { z } from "zod"; +import type { Logger } from "pino"; +import { curateAgentActivity } from "./agent/activity-curator.js"; +import type { AgentManager } from "./agent/agent-manager.js"; +import { getStructuredAgentResponse } from "./agent/agent-response-loop.js"; +import type { + AgentPromptInput, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + AgentProvider, +} from "./agent/agent-sdk-types.js"; + +const execFileAsync = promisify(execFile); +const LOOP_ID_LENGTH = 8; +const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude"; +const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024; + +const LoopVerifyPromptSchema = z.object({ + passed: z.boolean(), + reason: z.string().min(1), +}); + +const LoopLogEntrySchema = z.object({ + seq: z.number().int().positive(), + timestamp: z.string(), + iteration: z.number().int().positive().nullable(), + source: z.enum(["loop", "worker", "verifier", "verify-check"]), + level: z.enum(["info", "error"]), + text: z.string(), +}); + +const LoopVerifyCheckResultSchema = z.object({ + command: z.string(), + exitCode: z.number().int(), + passed: z.boolean(), + stdout: z.string(), + stderr: z.string(), + startedAt: z.string(), + completedAt: z.string(), +}); + +const LoopVerifyPromptResultSchema = z.object({ + passed: z.boolean(), + reason: z.string(), + verifierAgentId: z.string().nullable(), + startedAt: z.string(), + completedAt: z.string(), +}); + +const LoopIterationRecordSchema = z.object({ + index: z.number().int().positive(), + workerAgentId: z.string().nullable(), + workerStartedAt: z.string(), + workerCompletedAt: z.string().nullable(), + verifierAgentId: z.string().nullable(), + status: z.enum(["running", "succeeded", "failed", "stopped"]), + workerOutcome: z.enum(["completed", "failed", "canceled"]).nullable(), + failureReason: z.string().nullable(), + verifyChecks: z.array(LoopVerifyCheckResultSchema), + verifyPrompt: LoopVerifyPromptResultSchema.nullable(), +}); + +const LoopRecordSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + prompt: z.string(), + cwd: z.string(), + provider: z.enum(["claude", "codex", "opencode"]), + verifyPrompt: z.string().nullable(), + verifyChecks: z.array(z.string()), + sleepMs: z.number().int().nonnegative(), + maxIterations: z.number().int().positive().nullable(), + maxTimeMs: z.number().int().positive().nullable(), + status: z.enum(["running", "succeeded", "failed", "stopped"]), + createdAt: z.string(), + updatedAt: z.string(), + startedAt: z.string(), + completedAt: z.string().nullable(), + stopRequestedAt: z.string().nullable(), + iterations: z.array(LoopIterationRecordSchema), + logs: z.array(LoopLogEntrySchema), + nextLogSeq: z.number().int().positive(), + activeIteration: z.number().int().positive().nullable(), + activeWorkerAgentId: z.string().nullable(), + activeVerifierAgentId: z.string().nullable(), +}); + +const StoredLoopsSchema = z.array(LoopRecordSchema); + +export type LoopStatus = z.infer["status"]; +export type LoopLogEntry = z.infer; +export type LoopVerifyCheckResult = z.infer; +export type LoopVerifyPromptResult = z.infer; +export type LoopIterationRecord = z.infer; +export type LoopRecord = z.infer; + +export interface LoopRunOptions { + prompt: string; + cwd: string; + provider?: AgentProvider; + verifyPrompt?: string; + verifyChecks?: string[]; + name?: string; + sleepMs?: number; + maxIterations?: number; + maxTimeMs?: number; +} + +export interface LoopListItem { + id: string; + name: string | null; + status: LoopStatus; + cwd: string; + createdAt: string; + updatedAt: string; + activeIteration: number | null; +} + +export interface LoopLogsResult { + loop: LoopRecord; + entries: LoopLogEntry[]; + nextCursor: number; +} + +interface RunningLoopState { + abortController: AbortController; + promise: Promise; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function cloneLoop(record: LoopRecord): LoopRecord { + return LoopRecordSchema.parse(record); +} + +function createLoopId(): string { + return randomUUID().replace(/-/g, "").slice(0, LOOP_ID_LENGTH); +} + +function normalizeName(name: string | undefined): string | null { + const trimmed = name?.trim(); + return trimmed ? trimmed : null; +} + +function normalizePrompt(value: string | undefined, field: string): string | null { + if (value === undefined) { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${field} cannot be empty`); + } + return trimmed; +} + +function ensurePositiveInteger(value: number | undefined, field: string): number | null { + if (value === undefined) { + return null; + } + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${field} must be a positive integer`); + } + return value; +} + +function ensureNonNegativeInteger(value: number | undefined, field: string): number { + if (value === undefined) { + return 0; + } + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative integer`); + } + return value; +} + +function buildWorkerTitle(loop: LoopRecord, iterationIndex: number): string { + const prefix = loop.name ?? loop.id; + return `${prefix} [loop ${iterationIndex} worker]`; +} + +function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string { + const prefix = loop.name ?? loop.id; + return `${prefix} [loop ${iterationIndex} verifier]`; +} + +function formatStreamLog(event: AgentStreamEvent): string | null { + switch (event.type) { + case "timeline": { + const rendered = curateAgentActivity([event.item]); + return rendered === "No activity to display." ? null : rendered; + } + case "turn_failed": + return `[Turn Failed] ${event.error}`; + case "turn_canceled": + return `[Turn Canceled] ${event.reason}`; + case "permission_requested": + return `[Permission Requested] ${event.request.name}`; + case "permission_resolved": + return `[Permission Resolved] ${event.requestId}`; + default: + return null; + } +} + +async function sleepWithAbort(ms: number, signal: AbortSignal): Promise { + if (ms <= 0) { + return; + } + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = () => { + cleanup(); + reject(signal.reason instanceof Error ? signal.reason : new Error("Loop aborted")); + }; + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function isAbortError(error: unknown): boolean { + if (error instanceof Error && error.message === "Loop aborted") { + return true; + } + return false; +} + +async function runVerifyCheck(options: { + cwd: string; + command: string; +}): Promise { + const startedAt = nowIso(); + try { + const result = await execFileAsync("/bin/zsh", ["-lc", options.command], { + cwd: options.cwd, + maxBuffer: MAX_VERIFY_OUTPUT_BYTES, + }); + return { + command: options.command, + exitCode: 0, + passed: true, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + startedAt, + completedAt: nowIso(), + }; + } catch (error) { + const childError = error as Error & { + code?: number | string; + stdout?: string; + stderr?: string; + }; + return { + command: options.command, + exitCode: typeof childError.code === "number" ? childError.code : 1, + passed: false, + stdout: childError.stdout ?? "", + stderr: childError.stderr ?? "", + startedAt, + completedAt: nowIso(), + }; + } +} + +export class LoopService { + private readonly storePath: string; + private readonly logger: Logger; + private loaded = false; + private readonly loops = new Map(); + private persistQueue: Promise = Promise.resolve(); + private readonly running = new Map(); + + constructor( + private readonly options: { + paseoHome: string; + agentManager: AgentManager; + logger: Logger; + }, + ) { + this.storePath = path.join(options.paseoHome, "loops", "loops.json"); + this.logger = options.logger.child({ module: "loop-service" }); + } + + async initialize(): Promise { + if (this.loaded) { + return; + } + this.loops.clear(); + try { + const raw = await fs.readFile(this.storePath, "utf8"); + const parsed = StoredLoopsSchema.parse(JSON.parse(raw)); + for (const record of parsed) { + if (record.status === "running") { + const recovered = cloneLoop(record); + recovered.status = "stopped"; + recovered.updatedAt = nowIso(); + recovered.completedAt = recovered.updatedAt; + recovered.stopRequestedAt = recovered.updatedAt; + recovered.activeIteration = null; + recovered.activeWorkerAgentId = null; + recovered.activeVerifierAgentId = null; + this.appendLog(recovered, { + iteration: null, + source: "loop", + level: "error", + text: "Loop was interrupted by daemon restart.", + }); + const lastIteration = recovered.iterations.at(-1); + if (lastIteration && lastIteration.status === "running") { + lastIteration.status = "stopped"; + lastIteration.failureReason = "Daemon restarted"; + lastIteration.workerCompletedAt = recovered.updatedAt; + } + this.loops.set(recovered.id, recovered); + continue; + } + this.loops.set(record.id, record); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + this.logger.error({ err: error, storePath: this.storePath }, "Failed to load loops"); + } + } + this.loaded = true; + await this.persist(); + } + + async runLoop(input: LoopRunOptions): Promise { + await this.initialize(); + const prompt = normalizePrompt(input.prompt, "prompt"); + if (!prompt) { + throw new Error("prompt cannot be empty"); + } + const verifyPrompt = normalizePrompt(input.verifyPrompt, "verifyPrompt"); + const verifyChecks = (input.verifyChecks ?? []).map((command) => { + const trimmed = command.trim(); + if (!trimmed) { + throw new Error("verifyChecks cannot contain empty commands"); + } + return trimmed; + }); + if (!verifyPrompt && verifyChecks.length === 0) { + throw new Error("Loop requires --verify or at least one --verify-check"); + } + + const createdAt = nowIso(); + const record = LoopRecordSchema.parse({ + id: createLoopId(), + name: normalizeName(input.name), + prompt, + cwd: path.resolve(input.cwd), + provider: input.provider ?? DEFAULT_LOOP_PROVIDER, + verifyPrompt, + verifyChecks, + sleepMs: ensureNonNegativeInteger(input.sleepMs, "sleepMs"), + maxIterations: ensurePositiveInteger(input.maxIterations, "maxIterations"), + maxTimeMs: ensurePositiveInteger(input.maxTimeMs, "maxTimeMs"), + status: "running", + createdAt, + updatedAt: createdAt, + startedAt: createdAt, + completedAt: null, + stopRequestedAt: null, + iterations: [], + logs: [], + nextLogSeq: 1, + activeIteration: null, + activeWorkerAgentId: null, + activeVerifierAgentId: null, + }); + + this.loops.set(record.id, record); + this.appendLog(record, { + iteration: null, + source: "loop", + level: "info", + text: `Loop created in ${record.cwd}`, + }); + await this.persist(); + + const abortController = new AbortController(); + const promise = this.executeLoop(record.id, abortController.signal).finally(() => { + this.running.delete(record.id); + }); + this.running.set(record.id, { abortController, promise }); + + return cloneLoop(record); + } + + async listLoops(): Promise { + await this.initialize(); + return Array.from(this.loops.values()) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .map((record) => ({ + id: record.id, + name: record.name, + status: record.status, + cwd: record.cwd, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + activeIteration: record.activeIteration, + })); + } + + async inspectLoop(idOrPrefix: string): Promise { + await this.initialize(); + const loop = this.requireLoop(idOrPrefix); + return cloneLoop(loop); + } + + async getLoopLogs(idOrPrefix: string, afterSeq = 0): Promise { + await this.initialize(); + const loop = this.requireLoop(idOrPrefix); + const entries = loop.logs.filter((entry) => entry.seq > afterSeq); + return { + loop: cloneLoop(loop), + entries: entries.map((entry) => LoopLogEntrySchema.parse(entry)), + nextCursor: loop.nextLogSeq - 1, + }; + } + + async stopLoop(idOrPrefix: string): Promise { + await this.initialize(); + const loop = this.requireLoop(idOrPrefix); + if (loop.status !== "running") { + return cloneLoop(loop); + } + const running = this.running.get(loop.id); + loop.stopRequestedAt = loop.stopRequestedAt ?? nowIso(); + loop.updatedAt = loop.stopRequestedAt; + this.appendLog(loop, { + iteration: loop.activeIteration, + source: "loop", + level: "info", + text: "Stop requested.", + }); + await this.persist(); + + if (running) { + running.abortController.abort(new Error("Loop aborted")); + if (loop.activeWorkerAgentId) { + await this.options.agentManager.cancelAgentRun(loop.activeWorkerAgentId).catch(() => {}); + } + if (loop.activeVerifierAgentId) { + await this.options.agentManager.cancelAgentRun(loop.activeVerifierAgentId).catch(() => {}); + } + await running.promise.catch(() => {}); + } else { + loop.status = "stopped"; + loop.completedAt = nowIso(); + loop.updatedAt = loop.completedAt; + await this.persist(); + } + + return cloneLoop(loop); + } + + private async executeLoop(loopId: string, signal: AbortSignal): Promise { + const loop = this.requireLoop(loopId); + const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null; + + try { + for (let index = 1; ; index += 1) { + if (signal.aborted) { + throw new Error("Loop aborted"); + } + if (loop.maxIterations && index > loop.maxIterations) { + this.finishLoop(loop, "failed", `Reached max iterations (${loop.maxIterations}).`); + return; + } + if (deadline !== null && Date.now() > deadline) { + this.finishLoop(loop, "failed", `Reached max time (${loop.maxTimeMs}ms).`); + return; + } + + const iteration = LoopIterationRecordSchema.parse({ + index, + workerAgentId: null, + workerStartedAt: nowIso(), + workerCompletedAt: null, + verifierAgentId: null, + status: "running", + workerOutcome: null, + failureReason: null, + verifyChecks: [], + verifyPrompt: null, + }); + loop.iterations.push(iteration); + loop.activeIteration = index; + loop.updatedAt = nowIso(); + this.appendLog(loop, { + iteration: index, + source: "loop", + level: "info", + text: `Starting iteration ${index}.`, + }); + await this.persist(); + + const workerPassed = await this.runWorkerIteration(loop, iteration, signal); + if (signal.aborted) { + throw new Error("Loop aborted"); + } + if (!workerPassed) { + iteration.status = iteration.status === "stopped" ? "stopped" : "failed"; + } else { + const verificationPassed = await this.runVerification(loop, iteration, signal); + if (verificationPassed) { + iteration.status = "succeeded"; + this.finishLoop(loop, "succeeded", `Iteration ${index} passed verification.`); + return; + } + if (iteration.status === "running") { + iteration.status = "failed"; + } + } + + loop.activeIteration = null; + loop.activeWorkerAgentId = null; + loop.activeVerifierAgentId = null; + loop.updatedAt = nowIso(); + await this.persist(); + + if (loop.sleepMs > 0) { + this.appendLog(loop, { + iteration: index, + source: "loop", + level: "info", + text: `Sleeping ${loop.sleepMs}ms before next iteration.`, + }); + await this.persist(); + await sleepWithAbort(loop.sleepMs, signal); + } + } + } catch (error) { + if (isAbortError(error)) { + this.finishLoop(loop, "stopped", "Loop stopped."); + const iteration = loop.activeIteration + ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) + : null; + if (iteration && iteration.status === "running") { + iteration.status = "stopped"; + iteration.failureReason = "Loop stopped"; + iteration.workerCompletedAt = nowIso(); + } + await this.persist(); + return; + } + + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ err: error, loopId }, "Loop execution failed"); + this.finishLoop(loop, "failed", message); + const iteration = loop.activeIteration + ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) + : null; + if (iteration && iteration.status === "running") { + iteration.status = "failed"; + iteration.failureReason = message; + iteration.workerCompletedAt = nowIso(); + } + await this.persist(); + } + } + + private async runWorkerIteration( + loop: LoopRecord, + iteration: LoopIterationRecord, + signal: AbortSignal, + ): Promise { + const agent = await this.options.agentManager.createAgent(this.buildWorkerConfig(loop, iteration)); + iteration.workerAgentId = agent.id; + loop.activeWorkerAgentId = agent.id; + loop.updatedAt = nowIso(); + await this.persist(); + + const unsubscribe = this.options.agentManager.subscribe( + (event) => { + if (event.type !== "agent_stream") { + return; + } + const text = formatStreamLog(event.event); + if (!text) { + return; + } + this.appendLog(loop, { + iteration: iteration.index, + source: "worker", + level: event.event.type === "turn_failed" ? "error" : "info", + text, + }); + void this.persist(); + }, + { agentId: agent.id, replayState: false }, + ); + + try { + const prompt = this.toPrompt(loop.prompt); + const result = await this.options.agentManager.runAgent(agent.id, prompt); + iteration.workerCompletedAt = nowIso(); + iteration.workerOutcome = result.canceled ? "canceled" : "completed"; + if (result.canceled) { + iteration.failureReason = "Worker run was canceled."; + iteration.status = "stopped"; + return false; + } + return true; + } catch (error) { + iteration.workerCompletedAt = nowIso(); + iteration.workerOutcome = "failed"; + iteration.failureReason = error instanceof Error ? error.message : String(error); + this.appendLog(loop, { + iteration: iteration.index, + source: "loop", + level: "error", + text: `Worker failed: ${iteration.failureReason}`, + }); + return false; + } finally { + unsubscribe(); + loop.activeWorkerAgentId = null; + loop.updatedAt = nowIso(); + await this.persist(); + try { + await this.options.agentManager.closeAgent(agent.id); + } catch { + // Ignore cleanup errors for internal loop workers. + } + if (signal.aborted && iteration.status === "running") { + iteration.status = "stopped"; + } + } + } + + private async runVerification( + loop: LoopRecord, + iteration: LoopIterationRecord, + signal: AbortSignal, + ): Promise { + for (const command of loop.verifyChecks) { + if (signal.aborted) { + throw new Error("Loop aborted"); + } + this.appendLog(loop, { + iteration: iteration.index, + source: "verify-check", + level: "info", + text: `$ ${command}`, + }); + const result = await runVerifyCheck({ cwd: loop.cwd, command }); + iteration.verifyChecks.push(result); + const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n"); + this.appendLog(loop, { + iteration: iteration.index, + source: "verify-check", + level: result.passed ? "info" : "error", + text: output + ? `exit ${result.exitCode}\n${output}` + : `exit ${result.exitCode}`, + }); + loop.updatedAt = nowIso(); + await this.persist(); + if (!result.passed) { + iteration.failureReason = `Verify check failed: ${command}`; + return false; + } + } + + if (!loop.verifyPrompt) { + return true; + } + + const startedAt = nowIso(); + const verifierAgent = await this.options.agentManager.createAgent( + this.buildVerifierConfig(loop, iteration), + ); + iteration.verifierAgentId = verifierAgent.id; + loop.activeVerifierAgentId = verifierAgent.id; + loop.updatedAt = nowIso(); + await this.persist(); + + const unsubscribe = this.options.agentManager.subscribe( + (event) => { + if (event.type !== "agent_stream") { + return; + } + const text = formatStreamLog(event.event); + if (!text) { + return; + } + this.appendLog(loop, { + iteration: iteration.index, + source: "verifier", + level: event.event.type === "turn_failed" ? "error" : "info", + text, + }); + void this.persist(); + }, + { agentId: verifierAgent.id, replayState: false }, + ); + + try { + const result = await getStructuredAgentResponse({ + caller: async (nextPrompt) => { + const run = await this.options.agentManager.runAgent(verifierAgent.id, this.toPrompt(nextPrompt)); + return this.resolveFinalText(run.timeline, run.finalText); + }, + prompt: loop.verifyPrompt, + schema: LoopVerifyPromptSchema, + maxRetries: 2, + schemaName: "LoopVerifierResult", + }); + iteration.verifyPrompt = { + passed: result.passed, + reason: result.reason, + verifierAgentId: verifierAgent.id, + startedAt, + completedAt: nowIso(), + }; + this.appendLog(loop, { + iteration: iteration.index, + source: "loop", + level: result.passed ? "info" : "error", + text: `Verifier result: ${result.reason}`, + }); + if (!result.passed) { + iteration.failureReason = result.reason; + } + return result.passed; + } finally { + unsubscribe(); + loop.activeVerifierAgentId = null; + loop.updatedAt = nowIso(); + await this.persist(); + try { + await this.options.agentManager.closeAgent(verifierAgent.id); + } catch { + // Ignore cleanup errors for internal loop verifiers. + } + } + } + + private buildWorkerConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig { + return { + provider: loop.provider, + cwd: loop.cwd, + title: buildWorkerTitle(loop, iteration.index), + internal: true, + }; + } + + private buildVerifierConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig { + return { + provider: loop.provider, + cwd: loop.cwd, + title: buildVerifierTitle(loop, iteration.index), + internal: true, + }; + } + + private resolveFinalText(timeline: AgentTimelineItem[], finalText: string): string { + if (finalText.trim()) { + return finalText; + } + for (let index = timeline.length - 1; index >= 0; index -= 1) { + const item = timeline[index]; + if (item?.type === "assistant_message" && item.text.trim()) { + return item.text; + } + } + return ""; + } + + private toPrompt(text: string): AgentPromptInput { + return text; + } + + private finishLoop(loop: LoopRecord, status: Exclude, message: string): void { + loop.status = status; + loop.completedAt = nowIso(); + loop.updatedAt = loop.completedAt; + loop.activeIteration = null; + loop.activeWorkerAgentId = null; + loop.activeVerifierAgentId = null; + this.appendLog(loop, { + iteration: null, + source: "loop", + level: status === "succeeded" ? "info" : "error", + text: message, + }); + } + + private appendLog( + loop: LoopRecord, + entry: Omit, + ): void { + loop.logs.push({ + seq: loop.nextLogSeq, + timestamp: nowIso(), + ...entry, + }); + loop.nextLogSeq += 1; + loop.updatedAt = nowIso(); + } + + private requireLoop(idOrPrefix: string): LoopRecord { + const trimmed = idOrPrefix.trim(); + if (!trimmed) { + throw new Error("Loop id is required"); + } + const exact = this.loops.get(trimmed); + if (exact) { + return exact; + } + const matches = Array.from(this.loops.values()).filter((record) => record.id.startsWith(trimmed)); + if (matches.length === 1) { + return matches[0]!; + } + if (matches.length > 1) { + throw new Error(`Loop id prefix is ambiguous: ${trimmed}`); + } + throw new Error(`Loop not found: ${trimmed}`); + } + + private async persist(): Promise { + const nextPersist = this.persistQueue.then(async () => { + await fs.mkdir(path.dirname(this.storePath), { recursive: true }); + const records = Array.from(this.loops.values()).sort((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); + await fs.writeFile(this.storePath, JSON.stringify(records, null, 2), "utf8"); + }); + this.persistQueue = nextPersist.catch(() => {}); + await nextPersist; + } +} diff --git a/packages/server/src/server/loop/rpc-schemas.ts b/packages/server/src/server/loop/rpc-schemas.ts new file mode 100644 index 000000000..a60f26df7 --- /dev/null +++ b/packages/server/src/server/loop/rpc-schemas.ts @@ -0,0 +1,177 @@ +import { z } from "zod"; + +export const LoopLogEntrySchema = z.object({ + seq: z.number().int().positive(), + timestamp: z.string(), + iteration: z.number().int().positive().nullable(), + source: z.enum(["loop", "worker", "verifier", "verify-check"]), + level: z.enum(["info", "error"]), + text: z.string(), +}); + +export const LoopVerifyCheckResultSchema = z.object({ + command: z.string(), + exitCode: z.number().int(), + passed: z.boolean(), + stdout: z.string(), + stderr: z.string(), + startedAt: z.string(), + completedAt: z.string(), +}); + +export const LoopVerifyPromptResultSchema = z.object({ + passed: z.boolean(), + reason: z.string(), + verifierAgentId: z.string().nullable(), + startedAt: z.string(), + completedAt: z.string(), +}); + +export const LoopIterationRecordSchema = z.object({ + index: z.number().int().positive(), + workerAgentId: z.string().nullable(), + workerStartedAt: z.string(), + workerCompletedAt: z.string().nullable(), + verifierAgentId: z.string().nullable(), + status: z.enum(["running", "succeeded", "failed", "stopped"]), + workerOutcome: z.enum(["completed", "failed", "canceled"]).nullable(), + failureReason: z.string().nullable(), + verifyChecks: z.array(LoopVerifyCheckResultSchema), + verifyPrompt: LoopVerifyPromptResultSchema.nullable(), +}); + +export const LoopRecordSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + prompt: z.string(), + cwd: z.string(), + provider: z.enum(["claude", "codex", "opencode"]), + verifyPrompt: z.string().nullable(), + verifyChecks: z.array(z.string()), + sleepMs: z.number().int().nonnegative(), + maxIterations: z.number().int().positive().nullable(), + maxTimeMs: z.number().int().positive().nullable(), + status: z.enum(["running", "succeeded", "failed", "stopped"]), + createdAt: z.string(), + updatedAt: z.string(), + startedAt: z.string(), + completedAt: z.string().nullable(), + stopRequestedAt: z.string().nullable(), + iterations: z.array(LoopIterationRecordSchema), + logs: z.array(LoopLogEntrySchema), + nextLogSeq: z.number().int().positive(), + activeIteration: z.number().int().positive().nullable(), + activeWorkerAgentId: z.string().nullable(), + activeVerifierAgentId: z.string().nullable(), +}); + +export const LoopListItemSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + status: z.enum(["running", "succeeded", "failed", "stopped"]), + cwd: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + activeIteration: z.number().int().positive().nullable(), +}); + +export const LoopRunRequestSchema = z.object({ + type: z.literal("loop/run"), + requestId: z.string(), + prompt: z.string().trim().min(1), + cwd: z.string(), + verifyPrompt: z.string().trim().min(1).optional(), + verifyChecks: z.array(z.string().trim().min(1)).optional(), + name: z.string().trim().min(1).optional(), + sleepMs: z.number().int().nonnegative().optional(), + maxIterations: z.number().int().positive().optional(), + maxTimeMs: z.number().int().positive().optional(), +}); + +export const LoopListRequestSchema = z.object({ + type: z.literal("loop/list"), + requestId: z.string(), +}); + +export const LoopInspectRequestSchema = z.object({ + type: z.literal("loop/inspect"), + requestId: z.string(), + id: z.string().trim().min(1), +}); + +export const LoopLogsRequestSchema = z.object({ + type: z.literal("loop/logs"), + requestId: z.string(), + id: z.string().trim().min(1), + afterSeq: z.number().int().nonnegative().optional(), +}); + +export const LoopStopRequestSchema = z.object({ + type: z.literal("loop/stop"), + requestId: z.string(), + id: z.string().trim().min(1), +}); + +export const LoopRunResponseSchema = z.object({ + type: z.literal("loop/run/response"), + payload: z.object({ + requestId: z.string(), + loop: LoopRecordSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const LoopListResponseSchema = z.object({ + type: z.literal("loop/list/response"), + payload: z.object({ + requestId: z.string(), + loops: z.array(LoopListItemSchema), + error: z.string().nullable(), + }), +}); + +export const LoopInspectResponseSchema = z.object({ + type: z.literal("loop/inspect/response"), + payload: z.object({ + requestId: z.string(), + loop: LoopRecordSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const LoopLogsResponseSchema = z.object({ + type: z.literal("loop/logs/response"), + payload: z.object({ + requestId: z.string(), + loop: LoopRecordSchema.nullable(), + entries: z.array(LoopLogEntrySchema), + nextCursor: z.number().int().nonnegative(), + error: z.string().nullable(), + }), +}); + +export const LoopStopResponseSchema = z.object({ + type: z.literal("loop/stop/response"), + payload: z.object({ + requestId: z.string(), + loop: LoopRecordSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export type LoopLogEntry = z.infer; +export type LoopVerifyCheckResult = z.infer; +export type LoopVerifyPromptResult = z.infer; +export type LoopIterationRecord = z.infer; +export type LoopRecord = z.infer; +export type LoopListItem = z.infer; +export type LoopRunRequest = z.infer; +export type LoopListRequest = z.infer; +export type LoopInspectRequest = z.infer; +export type LoopLogsRequest = z.infer; +export type LoopStopRequest = z.infer; +export type LoopRunResponse = z.infer; +export type LoopListResponse = z.infer; +export type LoopInspectResponse = z.infer; +export type LoopLogsResponse = z.infer; +export type LoopStopResponse = z.infer; diff --git a/packages/server/src/server/schedule/cron.test.ts b/packages/server/src/server/schedule/cron.test.ts new file mode 100644 index 000000000..e5e9e7ad4 --- /dev/null +++ b/packages/server/src/server/schedule/cron.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; + +describe("schedule cron cadence", () => { + test("computes the next every cadence from the provided timestamp", () => { + const next = computeNextRunAt( + { type: "every", everyMs: 5 * 60_000 }, + new Date("2026-01-01T00:00:00.000Z"), + ); + + expect(next.toISOString()).toBe("2026-01-01T00:05:00.000Z"); + }); + + test("computes the next cron minute match in UTC", () => { + const next = computeNextRunAt( + { type: "cron", expression: "15 9 * * 1-5" }, + new Date("2026-01-05T09:14:30.000Z"), + ); + + expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z"); + }); + + test("rejects invalid cron expressions", () => { + expect(() => + validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" }), + ).toThrow("Cron expressions must have 5 fields"); + }); +}); diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts new file mode 100644 index 000000000..91b0ee690 --- /dev/null +++ b/packages/server/src/server/schedule/cron.ts @@ -0,0 +1,150 @@ +import type { ScheduleCadence } from "./types.js"; + +interface CronFieldMatcher { + matches(value: number): boolean; +} + +function buildValueSet(values: Iterable): Set { + return new Set(values); +} + +function createRange(start: number, end: number, step: number): number[] { + const values: number[] = []; + for (let value = start; value <= end; value += step) { + values.push(value); + } + return values; +} + +function parseField( + source: string, + bounds: { min: number; max: number; name: string }, +): CronFieldMatcher { + const trimmed = source.trim(); + if (!trimmed) { + throw new Error(`Invalid cron ${bounds.name} field`); + } + + const values = new Set(); + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (!part) { + throw new Error(`Invalid cron ${bounds.name} field`); + } + + const [base, stepSource] = part.split("/"); + const step = + stepSource === undefined ? 1 : Number.parseInt(stepSource, 10); + if (!Number.isInteger(step) || step <= 0) { + throw new Error(`Invalid cron ${bounds.name} step`); + } + + if (base === "*") { + for (const value of createRange(bounds.min, bounds.max, step)) { + values.add(value); + } + continue; + } + + const rangeMatch = base.match(/^(\d+)-(\d+)$/); + if (rangeMatch) { + const start = Number.parseInt(rangeMatch[1], 10); + const end = Number.parseInt(rangeMatch[2], 10); + if (start > end || start < bounds.min || end > bounds.max) { + throw new Error(`Invalid cron ${bounds.name} range`); + } + for (const value of createRange(start, end, step)) { + values.add(value); + } + continue; + } + + const value = Number.parseInt(base, 10); + if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) { + throw new Error(`Invalid cron ${bounds.name} value`); + } + values.add(value); + } + + const allowed = buildValueSet(values); + return { + matches(value: number): boolean { + return allowed.has(value); + }, + }; +} + +interface ParsedCronExpression { + minute: CronFieldMatcher; + hour: CronFieldMatcher; + dayOfMonth: CronFieldMatcher; + month: CronFieldMatcher; + dayOfWeek: CronFieldMatcher; +} + +function parseCronExpression(expression: string): ParsedCronExpression { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) { + throw new Error("Cron expressions must have 5 fields"); + } + + return { + minute: parseField(parts[0], { min: 0, max: 59, name: "minute" }), + hour: parseField(parts[1], { min: 0, max: 23, name: "hour" }), + dayOfMonth: parseField(parts[2], { min: 1, max: 31, name: "day-of-month" }), + month: parseField(parts[3], { min: 1, max: 12, name: "month" }), + dayOfWeek: parseField(parts[4], { min: 0, max: 6, name: "day-of-week" }), + }; +} + +function startOfNextMinute(date: Date): Date { + return new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes() + 1, + 0, + 0, + ), + ); +} + +export function validateScheduleCadence(cadence: ScheduleCadence): void { + if (cadence.type === "cron") { + parseCronExpression(cadence.expression); + } +} + +export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date { + if (cadence.type === "every") { + return new Date(after.getTime() + cadence.everyMs); + } + + const cron = parseCronExpression(cadence.expression); + const limit = 366 * 24 * 60; + let cursor = startOfNextMinute(after); + + for (let index = 0; index < limit; index += 1) { + const minute = cursor.getUTCMinutes(); + const hour = cursor.getUTCHours(); + const dayOfMonth = cursor.getUTCDate(); + const month = cursor.getUTCMonth() + 1; + const dayOfWeek = cursor.getUTCDay(); + + if ( + cron.minute.matches(minute) && + cron.hour.matches(hour) && + cron.dayOfMonth.matches(dayOfMonth) && + cron.month.matches(month) && + cron.dayOfWeek.matches(dayOfWeek) + ) { + return cursor; + } + + cursor = new Date(cursor.getTime() + 60_000); + } + + throw new Error(`Unable to compute next run time for cron expression: ${cadence.expression}`); +} diff --git a/packages/server/src/server/schedule/rpc-schemas.ts b/packages/server/src/server/schedule/rpc-schemas.ts new file mode 100644 index 000000000..5c70e2585 --- /dev/null +++ b/packages/server/src/server/schedule/rpc-schemas.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; +import { + ScheduleCadenceSchema, + ScheduleRunSchema, + ScheduleSummarySchema, + StoredScheduleSchema, + ScheduleTargetSchema, +} from "./types.js"; + +const ScheduleCreateTargetSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("self"), + agentId: z.string().uuid(), + }), + z.object({ + type: z.literal("agent"), + agentId: z.string().uuid(), + }), + z.object({ + type: z.literal("new-agent"), + config: ScheduleTargetSchema.options[1].shape.config, + }), +]); + +export const ScheduleCreateRequestSchema = z.object({ + type: z.literal("schedule/create"), + requestId: z.string(), + prompt: z.string().min(1), + name: z.string().optional(), + cadence: ScheduleCadenceSchema, + target: ScheduleCreateTargetSchema, + maxRuns: z.number().int().positive().optional(), + expiresAt: z.string().optional(), +}); + +export const ScheduleListRequestSchema = z.object({ + type: z.literal("schedule/list"), + requestId: z.string(), +}); + +export const ScheduleInspectRequestSchema = z.object({ + type: z.literal("schedule/inspect"), + requestId: z.string(), + scheduleId: z.string(), +}); + +export const ScheduleLogsRequestSchema = z.object({ + type: z.literal("schedule/logs"), + requestId: z.string(), + scheduleId: z.string(), +}); + +export const SchedulePauseRequestSchema = z.object({ + type: z.literal("schedule/pause"), + requestId: z.string(), + scheduleId: z.string(), +}); + +export const ScheduleResumeRequestSchema = z.object({ + type: z.literal("schedule/resume"), + requestId: z.string(), + scheduleId: z.string(), +}); + +export const ScheduleDeleteRequestSchema = z.object({ + type: z.literal("schedule/delete"), + requestId: z.string(), + scheduleId: z.string(), +}); + +export const ScheduleCreateResponseSchema = z.object({ + type: z.literal("schedule/create/response"), + payload: z.object({ + requestId: z.string(), + schedule: ScheduleSummarySchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ScheduleListResponseSchema = z.object({ + type: z.literal("schedule/list/response"), + payload: z.object({ + requestId: z.string(), + schedules: z.array(ScheduleSummarySchema), + error: z.string().nullable(), + }), +}); + +export const ScheduleInspectResponseSchema = z.object({ + type: z.literal("schedule/inspect/response"), + payload: z.object({ + requestId: z.string(), + schedule: StoredScheduleSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ScheduleLogsResponseSchema = z.object({ + type: z.literal("schedule/logs/response"), + payload: z.object({ + requestId: z.string(), + runs: z.array(ScheduleRunSchema), + error: z.string().nullable(), + }), +}); + +export const SchedulePauseResponseSchema = z.object({ + type: z.literal("schedule/pause/response"), + payload: z.object({ + requestId: z.string(), + schedule: ScheduleSummarySchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ScheduleResumeResponseSchema = z.object({ + type: z.literal("schedule/resume/response"), + payload: z.object({ + requestId: z.string(), + schedule: ScheduleSummarySchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const ScheduleDeleteResponseSchema = z.object({ + type: z.literal("schedule/delete/response"), + payload: z.object({ + requestId: z.string(), + scheduleId: z.string(), + error: z.string().nullable(), + }), +}); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts new file mode 100644 index 000000000..c71cf6c9a --- /dev/null +++ b/packages/server/src/server/schedule/service.test.ts @@ -0,0 +1,229 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { AgentManager } from "../agent/agent-manager.js"; +import { AgentStorage } from "../agent/agent-storage.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { ScheduleService } from "./service.js"; + +describe("ScheduleService", () => { + let tempDir: string; + let agentStorage: AgentStorage; + let now: Date; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "schedule-service-test-")); + await mkdir(join(tempDir, "agents"), { recursive: true }); + agentStorage = new AgentStorage(join(tempDir, "agents"), createTestLogger()); + await agentStorage.initialize(); + now = new Date("2026-01-01T00:00:00.000Z"); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("ticks due schedules and records run history on disk", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async (schedule) => ({ + agentId: "00000000-0000-0000-0000-000000000001", + output: `ran:${schedule.prompt}`, + }), + }); + + const created = await service.create({ + prompt: "Review new PRs", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + }, + }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]).toMatchObject({ + status: "succeeded", + agentId: "00000000-0000-0000-0000-000000000001", + output: "ran:Review new PRs", + }); + expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + }); + + test("pause and resume update persisted schedule state", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ + agentId: null, + output: "ok", + }), + }); + + const created = await service.create({ + prompt: "Check status", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + }, + }, + }); + + const paused = await service.pause(created.id); + expect(paused.status).toBe("paused"); + expect(paused.nextRunAt).toBeNull(); + + now = new Date("2026-01-01T00:03:00.000Z"); + const resumed = await service.resume(created.id); + expect(resumed.status).toBe("active"); + expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z"); + }); + + test("completes schedules when max runs is reached", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ + agentId: null, + output: "done", + }), + }); + + const created = await service.create({ + prompt: "One shot", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + }, + }, + maxRuns: 1, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("completed"); + expect(inspected.nextRunAt).toBeNull(); + }); + + test("executes new-agent schedules through AgentManager with real fake clients", async () => { + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + now: () => now, + }); + + const created = await service.create({ + prompt: "Respond with exactly hello", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + approvalPolicy: "never", + }, + }, + maxRuns: 1, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("succeeded"); + expect(inspected.runs[0]?.agentId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + test("keeps schedules paused when an in-flight run finishes after pause", async () => { + let releaseRun: (() => void) | null = null; + const runStarted = new Promise((resolve) => { + releaseRun = resolve; + }); + let finishRun: (() => void) | null = null; + const runBlocked = new Promise((resolve) => { + finishRun = resolve; + }); + + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => { + releaseRun?.(); + await runBlocked; + return { + agentId: null, + output: "finished", + }; + }, + }); + + const created = await service.create({ + prompt: "Check status", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + }, + }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + const tickPromise = service.tick(); + await runStarted; + + const paused = await service.pause(created.id); + expect(paused.status).toBe("paused"); + expect(paused.nextRunAt).toBeNull(); + + finishRun?.(); + await tickPromise; + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("paused"); + expect(inspected.nextRunAt).toBeNull(); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("succeeded"); + }); +}); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts new file mode 100644 index 000000000..f77856627 --- /dev/null +++ b/packages/server/src/server/schedule/service.ts @@ -0,0 +1,472 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import { AgentManager } from "../agent/agent-manager.js"; +import type { ManagedAgent } from "../agent/agent-manager.js"; +import { AgentStorage } from "../agent/agent-storage.js"; +import type { + AgentPromptInput, + AgentSessionConfig, +} from "../agent/agent-sdk-types.js"; +import { curateAgentActivity } from "../agent/activity-curator.js"; +import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from "../persistence-hooks.js"; +import { ScheduleStore } from "./store.js"; +import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; +import type { + CreateScheduleInput, + ScheduleExecutionResult, + ScheduleRun, + StoredSchedule, +} from "./types.js"; + +const SCHEDULE_TICK_INTERVAL_MS = 1000; +const pendingAgentInitializations = new Map>(); + +function trimOptionalName(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizePrompt(prompt: string): string { + const trimmed = prompt.trim(); + if (!trimmed) { + throw new Error("Schedule prompt is required"); + } + return trimmed; +} + +function normalizeMaxRuns(value: number | null | undefined): number | null { + if (value == null) { + return null; + } + if (!Number.isInteger(value) || value <= 0) { + throw new Error("maxRuns must be a positive integer"); + } + return value; +} + +function countCompletedRuns(schedule: StoredSchedule): number { + return schedule.runs.filter((run) => run.status !== "running").length; +} + +function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean { + if (schedule.expiresAt && new Date(schedule.expiresAt).getTime() <= now.getTime()) { + return true; + } + if (schedule.maxRuns == null) { + return false; + } + return countCompletedRuns(schedule) >= schedule.maxRuns; +} + +function completeSchedule(schedule: StoredSchedule, now: Date): StoredSchedule { + return { + ...schedule, + status: "completed", + nextRunAt: null, + pausedAt: null, + updatedAt: now.toISOString(), + }; +} + +function buildRunOutput(params: { + output: string | null; + timelineText: string; + finalText: string; +}): string | null { + if (params.output && params.output.trim().length > 0) { + return params.output; + } + if (params.finalText.trim().length > 0) { + return params.finalText.trim(); + } + if (params.timelineText.trim().length > 0) { + return params.timelineText.trim(); + } + return null; +} + +function buildAgentPrompt(text: string): AgentPromptInput { + return text; +} + +export interface ScheduleServiceOptions { + paseoHome: string; + logger: Logger; + agentManager: AgentManager; + agentStorage: AgentStorage; + now?: () => Date; + runner?: (schedule: StoredSchedule) => Promise; +} + +export class ScheduleService { + private readonly store: ScheduleStore; + private readonly logger: Logger; + private readonly agentManager: AgentManager; + private readonly agentStorage: AgentStorage; + private readonly now: () => Date; + private readonly runner: (schedule: StoredSchedule) => Promise; + private readonly runningScheduleIds = new Set(); + private tickTimer: ReturnType | null = null; + + constructor(options: ScheduleServiceOptions) { + this.store = new ScheduleStore(join(options.paseoHome, "schedules")); + this.logger = options.logger.child({ module: "schedule-service" }); + this.agentManager = options.agentManager; + this.agentStorage = options.agentStorage; + this.now = options.now ?? (() => new Date()); + this.runner = options.runner ?? ((schedule) => this.executeSchedule(schedule)); + } + + async start(): Promise { + await this.recoverInterruptedRuns(); + if (this.tickTimer) { + return; + } + const timer = setInterval(() => { + void this.tick().catch((error) => { + this.logger.error({ err: error }, "Failed to process schedule tick"); + }); + }, SCHEDULE_TICK_INTERVAL_MS); + (timer as unknown as { unref?: () => void }).unref?.(); + this.tickTimer = timer; + } + + async stop(): Promise { + if (this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + } + + async create(input: CreateScheduleInput): Promise { + const now = this.now(); + const prompt = normalizePrompt(input.prompt); + validateScheduleCadence(input.cadence); + const schedule = await this.store.create({ + name: trimOptionalName(input.name), + prompt, + cadence: input.cadence, + target: input.target, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: computeNextRunAt(input.cadence, now).toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: input.expiresAt ?? null, + maxRuns: normalizeMaxRuns(input.maxRuns), + runs: [], + }); + return schedule; + } + + async list(): Promise { + return this.store.list(); + } + + async inspect(id: string): Promise { + const schedule = await this.store.get(id); + if (!schedule) { + throw new Error(`Schedule not found: ${id}`); + } + return schedule; + } + + async logs(id: string): Promise { + const schedule = await this.inspect(id); + return [...schedule.runs].sort((left, right) => left.startedAt.localeCompare(right.startedAt)); + } + + async pause(id: string): Promise { + const schedule = await this.inspect(id); + if (schedule.status === "completed") { + throw new Error(`Schedule ${id} is already completed`); + } + if (schedule.status === "paused") { + return schedule; + } + const now = this.now(); + const paused = { + ...schedule, + status: "paused" as const, + nextRunAt: null, + pausedAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + await this.store.put(paused); + return paused; + } + + async resume(id: string): Promise { + const schedule = await this.inspect(id); + if (schedule.status === "completed") { + throw new Error(`Schedule ${id} is already completed`); + } + if (schedule.status === "active") { + return schedule; + } + const now = this.now(); + const resumed = { + ...schedule, + status: "active" as const, + pausedAt: null, + nextRunAt: computeNextRunAt(schedule.cadence, now).toISOString(), + updatedAt: now.toISOString(), + }; + await this.store.put(resumed); + return resumed; + } + + async delete(id: string): Promise { + await this.store.delete(id); + } + + async tick(): Promise { + const now = this.now(); + const schedules = await this.store.list(); + for (const schedule of schedules) { + if (schedule.status !== "active" || !schedule.nextRunAt) { + continue; + } + if (this.runningScheduleIds.has(schedule.id)) { + continue; + } + if (shouldCompleteSchedule(schedule, now)) { + await this.store.put(completeSchedule(schedule, now)); + continue; + } + if (new Date(schedule.nextRunAt).getTime() > now.getTime()) { + continue; + } + await this.runSchedule(schedule, now); + } + } + + private async recoverInterruptedRuns(): Promise { + const schedules = await this.store.list(); + const now = this.now(); + for (const schedule of schedules) { + const runningIndex = schedule.runs.findIndex((run) => run.status === "running"); + if (runningIndex === -1) { + continue; + } + const runs = [...schedule.runs]; + runs[runningIndex] = { + ...runs[runningIndex], + status: "failed", + endedAt: now.toISOString(), + error: "Daemon restarted before the scheduled run completed", + }; + const nextSchedule = { + ...schedule, + runs, + updatedAt: now.toISOString(), + }; + await this.store.put(nextSchedule); + } + } + + private async runSchedule(schedule: StoredSchedule, now: Date): Promise { + this.runningScheduleIds.add(schedule.id); + const runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor: schedule.nextRunAt ?? now.toISOString(), + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }; + const scheduleWithRun = { + ...schedule, + updatedAt: now.toISOString(), + runs: [...schedule.runs, runningRun], + }; + await this.store.put(scheduleWithRun); + + try { + const result = await this.runner(scheduleWithRun); + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "succeeded", + agentId: result.agentId, + output: result.output, + error: null, + }); + } catch (error) { + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "failed", + agentId: null, + output: null, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.runningScheduleIds.delete(schedule.id); + } + } + + private async finishRun(params: { + scheduleId: string; + runId: string; + status: "succeeded" | "failed"; + agentId: string | null; + output: string | null; + error: string | null; + }): Promise { + const schedule = await this.inspect(params.scheduleId); + const now = this.now(); + const completedRuns = schedule.runs.map((run) => + run.id === params.runId + ? { + ...run, + status: params.status, + endedAt: now.toISOString(), + agentId: params.agentId, + output: params.output, + error: params.error, + } + : run, + ); + let updated: StoredSchedule = { + ...schedule, + runs: completedRuns, + lastRunAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + if (shouldCompleteSchedule(updated, now)) { + updated = completeSchedule(updated, now); + } else if (updated.status === "paused") { + updated = { + ...updated, + nextRunAt: null, + }; + } else { + const after = new Date(schedule.nextRunAt ?? now.toISOString()); + let nextRunAt = computeNextRunAt(updated.cadence, after); + while (nextRunAt.getTime() <= now.getTime()) { + nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); + } + updated = { + ...updated, + nextRunAt: nextRunAt.toISOString(), + }; + } + + await this.store.put(updated); + } + + private async executeSchedule(schedule: StoredSchedule): Promise { + if (schedule.target.type === "agent") { + const agent = await this.ensureAgentLoaded(schedule.target.agentId); + if (this.agentManager.hasInFlightRun(agent.id)) { + throw new Error(`Agent ${agent.id} already has an active run`); + } + this.agentManager.recordUserMessage(agent.id, schedule.prompt, { emitState: false }); + const result = await this.agentManager.runAgent(agent.id, buildAgentPrompt(schedule.prompt)); + const timelineText = curateAgentActivity(result.timeline); + return { + agentId: agent.id, + output: buildRunOutput({ + output: null, + timelineText, + finalText: result.finalText, + }), + }; + } + + const config: AgentSessionConfig = { + provider: schedule.target.config.provider, + cwd: schedule.target.config.cwd, + modeId: schedule.target.config.modeId, + model: schedule.target.config.model, + thinkingOptionId: schedule.target.config.thinkingOptionId, + title: schedule.target.config.title, + approvalPolicy: schedule.target.config.approvalPolicy, + sandboxMode: schedule.target.config.sandboxMode, + networkAccess: schedule.target.config.networkAccess, + webSearch: schedule.target.config.webSearch, + extra: schedule.target.config.extra, + systemPrompt: schedule.target.config.systemPrompt, + mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"], + }; + const labels = { + "paseo.schedule-id": schedule.id, + "paseo.schedule-run": randomUUID(), + }; + const agent = await this.agentManager.createAgent(config, undefined, { labels }); + this.agentManager.recordUserMessage(agent.id, schedule.prompt, { emitState: false }); + const result = await this.agentManager.runAgent(agent.id, buildAgentPrompt(schedule.prompt)); + const timelineText = curateAgentActivity(result.timeline); + return { + agentId: agent.id, + output: buildRunOutput({ + output: null, + timelineText, + finalText: result.finalText, + }), + }; + } + + private async ensureAgentLoaded(agentId: string): Promise { + const existing = this.agentManager.getAgent(agentId); + if (existing) { + return existing; + } + + const inflight = pendingAgentInitializations.get(agentId); + if (inflight) { + return inflight; + } + + const initPromise = (async () => { + const record = await this.agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + if (record.archivedAt) { + throw new Error(`Agent ${agentId} is archived`); + } + + let snapshot: ManagedAgent; + if (record.persistence?.provider && record.persistence?.sessionId) { + snapshot = await this.agentManager.resumeAgentFromPersistence( + { + provider: record.persistence.provider as AgentSessionConfig["provider"], + sessionId: record.persistence.sessionId, + nativeHandle: record.persistence.nativeHandle, + metadata: record.persistence.metadata, + }, + buildConfigOverrides(record), + agentId, + extractTimestamps(record), + ); + } else { + snapshot = await this.agentManager.createAgent(buildSessionConfig(record), agentId, { + labels: record.labels, + }); + } + + await this.agentManager.hydrateTimelineFromProvider(agentId); + return this.agentManager.getAgent(agentId) ?? snapshot; + })(); + + pendingAgentInitializations.set(agentId, initPromise); + try { + return await initPromise; + } finally { + if (pendingAgentInitializations.get(agentId) === initPromise) { + pendingAgentInitializations.delete(agentId); + } + } + } +} diff --git a/packages/server/src/server/schedule/store.test.ts b/packages/server/src/server/schedule/store.test.ts new file mode 100644 index 000000000..03c1f6476 --- /dev/null +++ b/packages/server/src/server/schedule/store.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { ScheduleStore } from "./store.js"; + +describe("ScheduleStore", () => { + let tempDir: string; + let store: ScheduleStore; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "schedule-store-test-")); + store = new ScheduleStore(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("creates and reloads schedules from disk", async () => { + const created = await store.create({ + name: "Morning summary", + prompt: "Summarize new commits", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + }, + }, + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: "2026-01-01T00:01:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + + const reloaded = new ScheduleStore(tempDir); + const listed = await reloaded.list(); + + expect(created.id).toHaveLength(8); + expect(listed).toEqual([created]); + }); + + test("deletes schedules from disk", async () => { + const created = await store.create({ + name: null, + prompt: "Check status", + cadence: { type: "every", everyMs: 30_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + }, + }, + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: "2026-01-01T00:00:30.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + + await store.delete(created.id); + + expect(await store.get(created.id)).toBeNull(); + expect(await store.list()).toEqual([]); + }); +}); diff --git a/packages/server/src/server/schedule/store.ts b/packages/server/src/server/schedule/store.ts new file mode 100644 index 000000000..ae840cb47 --- /dev/null +++ b/packages/server/src/server/schedule/store.ts @@ -0,0 +1,63 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { StoredScheduleSchema, type StoredSchedule } from "./types.js"; + +function generateScheduleId(): string { + return randomBytes(4).toString("hex"); +} + +export class ScheduleStore { + constructor(private readonly dir: string) {} + + private filePath(id: string): string { + return join(this.dir, `${id}.json`); + } + + private async ensureDir(): Promise { + await mkdir(this.dir, { recursive: true }); + } + + async list(): Promise { + await this.ensureDir(); + const entries = await readdir(this.dir, { withFileTypes: true }); + const schedules = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map(async (entry) => { + const content = await readFile(join(this.dir, entry.name), "utf-8"); + return StoredScheduleSchema.parse(JSON.parse(content)); + }), + ); + return schedules.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + + async get(id: string): Promise { + await this.ensureDir(); + try { + const content = await readFile(this.filePath(id), "utf-8"); + return StoredScheduleSchema.parse(JSON.parse(content)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } + } + + async create(schedule: Omit): Promise { + const created = { ...schedule, id: generateScheduleId() }; + await this.put(created); + return created; + } + + async put(schedule: StoredSchedule): Promise { + await this.ensureDir(); + await writeFile(this.filePath(schedule.id), JSON.stringify(schedule, null, 2), "utf-8"); + } + + async delete(id: string): Promise { + await this.ensureDir(); + await rm(this.filePath(id), { force: true }); + } +} diff --git a/packages/server/src/server/schedule/types.ts b/packages/server/src/server/schedule/types.ts new file mode 100644 index 000000000..62668d3bb --- /dev/null +++ b/packages/server/src/server/schedule/types.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; +import { AgentProviderSchema } from "../agent/provider-manifest.js"; + +export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]); +export type ScheduleStatus = z.infer; + +export const ScheduleCadenceSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("every"), + everyMs: z.number().int().positive(), + }), + z.object({ + type: z.literal("cron"), + expression: z.string().trim().min(1), + }), +]); +export type ScheduleCadence = z.infer; + +export const ScheduleTargetSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("agent"), + agentId: z.string().uuid(), + }), + z.object({ + type: z.literal("new-agent"), + config: z.object({ + provider: AgentProviderSchema, + cwd: z.string().trim().min(1), + modeId: z.string().trim().min(1).optional(), + model: z.string().trim().min(1).optional(), + thinkingOptionId: z.string().trim().min(1).optional(), + title: z.string().trim().min(1).nullable().optional(), + approvalPolicy: z.string().trim().min(1).optional(), + sandboxMode: z.string().trim().min(1).optional(), + networkAccess: z.boolean().optional(), + webSearch: z.boolean().optional(), + extra: z + .object({ + codex: z.record(z.unknown()).optional(), + claude: z.record(z.unknown()).optional(), + }) + .partial() + .optional(), + systemPrompt: z.string().optional(), + mcpServers: z.record(z.unknown()).optional(), + }), + }), +]); +export type ScheduleTarget = z.infer; + +export const ScheduleRunSchema = z.object({ + id: z.string(), + scheduledFor: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + status: z.enum(["running", "succeeded", "failed"]), + agentId: z.string().uuid().nullable(), + output: z.string().nullable(), + error: z.string().nullable(), +}); +export type ScheduleRun = z.infer; + +export const StoredScheduleSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + prompt: z.string().min(1), + cadence: ScheduleCadenceSchema, + target: ScheduleTargetSchema, + status: ScheduleStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), + nextRunAt: z.string().nullable(), + lastRunAt: z.string().nullable(), + pausedAt: z.string().nullable(), + expiresAt: z.string().nullable(), + maxRuns: z.number().int().positive().nullable(), + runs: z.array(ScheduleRunSchema), +}); +export type StoredSchedule = z.infer; + +export const ScheduleSummarySchema = StoredScheduleSchema.omit({ + runs: true, +}); +export type ScheduleSummary = z.infer; + +export interface CreateScheduleInput { + name?: string | null; + prompt: string; + cadence: ScheduleCadence; + target: ScheduleTarget; + maxRuns?: number | null; + expiresAt?: string | null; +} + +export interface ScheduleExecutionResult { + agentId: string | null; + output: string | null; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3c373074c..4ebf081fa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -177,6 +177,12 @@ import { toResolver, type Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js"; import type pino from "pino"; import { resolveClientMessageId } from "./client-message-id.js"; +import { + ChatServiceError, + FileBackedChatService, +} from "./chat/chat-service.js"; +import { LoopService } from "./loop-service.js"; +import { ScheduleService } from "./schedule/service.js"; const execAsync = promisify(exec); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); @@ -407,6 +413,9 @@ export type SessionOptions = { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + chatService: FileBackedChatService; + scheduleService: ScheduleService; + loopService: LoopService; createAgentMcpTransport: AgentMcpTransportFactory; stt: Resolvable; tts: Resolvable; @@ -590,6 +599,9 @@ export class Session { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly chatService: FileBackedChatService; + private readonly scheduleService: ScheduleService; + private readonly loopService: LoopService; private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; @@ -652,6 +664,9 @@ export class Session { agentStorage, projectRegistry, workspaceRegistry, + chatService, + scheduleService, + loopService, createAgentMcpTransport, stt, tts, @@ -674,6 +689,9 @@ export class Session { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; + this.chatService = chatService; + this.scheduleService = scheduleService; + this.loopService = loopService; this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; if (this.terminalManager) { @@ -1758,6 +1776,82 @@ export class Session { case "kill_terminal_request": await this.handleKillTerminalRequest(msg); break; + + case "chat/create": + await this.handleChatCreateRequest(msg); + break; + + case "chat/list": + await this.handleChatListRequest(msg); + break; + + case "chat/inspect": + await this.handleChatInspectRequest(msg); + break; + + case "chat/delete": + await this.handleChatDeleteRequest(msg); + break; + + case "chat/post": + await this.handleChatPostRequest(msg); + break; + + case "chat/read": + await this.handleChatReadRequest(msg); + break; + + case "chat/wait": + await this.handleChatWaitRequest(msg); + break; + + case "schedule/create": + await this.handleScheduleCreateRequest(msg); + break; + + case "schedule/list": + await this.handleScheduleListRequest(msg); + break; + + case "schedule/inspect": + await this.handleScheduleInspectRequest(msg); + break; + + case "schedule/logs": + await this.handleScheduleLogsRequest(msg); + break; + + case "schedule/pause": + await this.handleSchedulePauseRequest(msg); + break; + + case "schedule/resume": + await this.handleScheduleResumeRequest(msg); + break; + + case "schedule/delete": + await this.handleScheduleDeleteRequest(msg); + break; + + case "loop/run": + await this.handleLoopRunRequest(msg); + break; + + case "loop/list": + await this.handleLoopListRequest(msg); + break; + + case "loop/inspect": + await this.handleLoopInspectRequest(msg); + break; + + case "loop/logs": + await this.handleLoopLogsRequest(msg); + break; + + case "loop/stop": + await this.handleLoopStopRequest(msg); + break; } } catch (error: any) { const err = error instanceof Error ? error : new Error(String(error)); @@ -7532,6 +7626,467 @@ export class Session { this.detachTerminalStream(terminalId, { emitExit: true }); } + private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void { + const message = + error instanceof Error ? error.message : "Chat request failed"; + const code = error instanceof ChatServiceError ? error.code : "chat_request_failed"; + this.sessionLogger.error({ err: error, requestType: request.type }, "Chat request failed"); + this.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code, + }, + }); + } + + private async handleChatCreateRequest( + request: Extract, + ): Promise { + try { + const room = await this.chatService.createRoom({ + name: request.name, + purpose: request.purpose, + }); + this.emit({ + type: "chat/create/response", + payload: { + requestId: request.requestId, + room, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private async handleChatListRequest( + request: Extract, + ): Promise { + try { + const rooms = await this.chatService.listRooms(); + this.emit({ + type: "chat/list/response", + payload: { + requestId: request.requestId, + rooms, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private async handleChatInspectRequest( + request: Extract, + ): Promise { + try { + const result = await this.chatService.inspectRoom({ + room: request.room, + }); + this.emit({ + type: "chat/inspect/response", + payload: { + requestId: request.requestId, + room: result.room, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private async handleChatDeleteRequest( + request: Extract, + ): Promise { + try { + const result = await this.chatService.deleteRoom({ + room: request.room, + }); + this.emit({ + type: "chat/delete/response", + payload: { + requestId: request.requestId, + room: result.room, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private async handleChatPostRequest( + request: Extract, + ): Promise { + try { + const message = await this.chatService.postMessage({ + room: request.room, + authorAgentId: this.clientId, + body: request.body, + replyToMessageId: request.replyToMessageId, + mentionAgentIds: request.mentionAgentIds, + }); + this.emit({ + type: "chat/post/response", + payload: { + requestId: request.requestId, + message, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private async handleChatReadRequest( + request: Extract, + ): Promise { + try { + const messages = await this.chatService.readMessages({ + room: request.room, + limit: request.limit, + since: request.since, + authorAgentId: request.authorAgentId, + }); + this.emit({ + type: "chat/read/response", + payload: { + requestId: request.requestId, + messages, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private async handleChatWaitRequest( + request: Extract, + ): Promise { + try { + const messages = await this.chatService.waitForMessages({ + room: request.room, + afterMessageId: request.afterMessageId, + timeoutMs: request.timeoutMs, + }); + this.emit({ + type: "chat/wait/response", + payload: { + requestId: request.requestId, + messages, + timedOut: messages.length === 0, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private toScheduleSummary( + schedule: Awaited>, + ): Extract["payload"]["schedules"][number] { + const { runs: _runs, ...summary } = schedule; + return summary; + } + + private emitScheduleRpcError( + request: Extract< + SessionInboundMessage, + { + type: + | "schedule/create" + | "schedule/list" + | "schedule/inspect" + | "schedule/logs" + | "schedule/pause" + | "schedule/resume" + | "schedule/delete"; + } + >, + error: unknown, + ): void { + const message = error instanceof Error ? error.message : String(error); + this.sessionLogger.error({ err: error, requestType: request.type }, "Schedule request failed"); + this.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code: "schedule_request_failed", + }, + }); + } + + private async handleScheduleCreateRequest( + request: Extract, + ): Promise { + try { + const target = + request.target.type === "self" + ? { type: "agent" as const, agentId: request.target.agentId } + : request.target; + const schedule = await this.scheduleService.create({ + prompt: request.prompt, + name: request.name, + cadence: request.cadence, + target, + maxRuns: request.maxRuns, + expiresAt: request.expiresAt, + }); + this.emit({ + type: "schedule/create/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private async handleScheduleListRequest( + request: Extract, + ): Promise { + try { + const schedules = await this.scheduleService.list(); + this.emit({ + type: "schedule/list/response", + payload: { + requestId: request.requestId, + schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private async handleScheduleInspectRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.inspect(request.scheduleId); + this.emit({ + type: "schedule/inspect/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private async handleScheduleLogsRequest( + request: Extract, + ): Promise { + try { + const runs = await this.scheduleService.logs(request.scheduleId); + this.emit({ + type: "schedule/logs/response", + payload: { + requestId: request.requestId, + runs, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private async handleSchedulePauseRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.pause(request.scheduleId); + this.emit({ + type: "schedule/pause/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private async handleScheduleResumeRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.resume(request.scheduleId); + this.emit({ + type: "schedule/resume/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private async handleScheduleDeleteRequest( + request: Extract, + ): Promise { + try { + await this.scheduleService.delete(request.scheduleId); + this.emit({ + type: "schedule/delete/response", + payload: { + requestId: request.requestId, + scheduleId: request.scheduleId, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private emitLoopRpcError( + request: Extract< + SessionInboundMessage, + { + type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop"; + } + >, + error: unknown, + ): void { + const message = error instanceof Error ? error.message : String(error); + this.sessionLogger.error({ err: error, requestType: request.type }, "Loop request failed"); + this.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code: "loop_request_failed", + }, + }); + } + + private async handleLoopRunRequest( + request: Extract, + ): Promise { + try { + const loop = await this.loopService.runLoop({ + prompt: request.prompt, + cwd: request.cwd, + verifyPrompt: request.verifyPrompt, + verifyChecks: request.verifyChecks, + name: request.name, + sleepMs: request.sleepMs, + maxIterations: request.maxIterations, + maxTimeMs: request.maxTimeMs, + }); + this.emit({ + type: "loop/run/response", + payload: { + requestId: request.requestId, + loop, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + private async handleLoopListRequest( + request: Extract, + ): Promise { + try { + const loops = await this.loopService.listLoops(); + this.emit({ + type: "loop/list/response", + payload: { + requestId: request.requestId, + loops, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + private async handleLoopInspectRequest( + request: Extract, + ): Promise { + try { + const loop = await this.loopService.inspectLoop(request.id); + this.emit({ + type: "loop/inspect/response", + payload: { + requestId: request.requestId, + loop, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + private async handleLoopLogsRequest( + request: Extract, + ): Promise { + try { + const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0); + this.emit({ + type: "loop/logs/response", + payload: { + requestId: request.requestId, + loop: result.loop, + entries: result.entries, + nextCursor: result.nextCursor, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + private async handleLoopStopRequest( + request: Extract, + ): Promise { + try { + const loop = await this.loopService.stopLoop(request.id); + this.emit({ + type: "loop/stop/response", + payload: { + requestId: request.requestId, + loop, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + private emitTerminalsChangedSnapshot(input: { cwd: string; terminals: Array<{ id: string; name: string }>; diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 8cea6fee9..5a27d4eed 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -82,6 +82,12 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, "1.2.3-test", + undefined, + undefined, + undefined, + {} as any, + {} as any, + {} as any, ); return { server, agentManager }; diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 0e251a9d6..25b2f3210 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -36,6 +36,7 @@ const sessionMock = vi.hoisted(() => { handleMessage = vi.fn(async () => {}); handleBinaryFrame = vi.fn((_frame: unknown) => {}); getClientActivity = vi.fn(() => null); + resetPeakInflight = vi.fn(() => {}); getRuntimeMetrics = vi.fn(() => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, @@ -43,6 +44,8 @@ const sessionMock = vi.hoisted(() => { checkoutDiffFallbackRefreshTargetCount: 0, terminalDirectorySubscriptionCount: 0, terminalSubscriptionCount: 0, + inflightRequests: 0, + peakInflightRequests: 0, })); readonly args: Record; @@ -151,6 +154,13 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu { setAgentAttentionCallback: vi.fn(), getAgent: vi.fn(() => null), + getMetricsSnapshot: vi.fn(() => ({ + totalAgents: 0, + idleAgents: 0, + runningAgents: 0, + pendingPermissionAgents: 0, + erroredAgents: 0, + })), } as any, {} as any, {} as any, @@ -167,6 +177,12 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu : undefined, undefined, TEST_DAEMON_VERSION, + undefined, + undefined, + undefined, + {} as any, + {} as any, + {} as any, ); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 9c12019ba..6e57731e5 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -9,6 +9,9 @@ import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js"; +import type { FileBackedChatService } from "./chat/chat-service.js"; +import type { LoopService } from "./loop-service.js"; +import type { ScheduleService } from "./schedule/service.js"; import { type ServerInfoStatusPayload, type WSHelloMessage, @@ -228,6 +231,9 @@ export class VoiceAssistantWebSocketServer { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly chatService: FileBackedChatService; + private readonly loopService: LoopService; + private readonly scheduleService: ScheduleService; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; private readonly pushTokenStore: PushTokenStore; @@ -314,6 +320,9 @@ export class VoiceAssistantWebSocketServer { onLifecycleIntent?: (intent: SessionLifecycleIntent) => void, projectRegistry?: ProjectRegistry, workspaceRegistry?: WorkspaceRegistry, + chatService?: FileBackedChatService, + loopService?: LoopService, + scheduleService?: ScheduleService, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -325,6 +334,18 @@ export class VoiceAssistantWebSocketServer { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); + if (!chatService) { + throw new Error("VoiceAssistantWebSocketServer requires a chat service."); + } + this.chatService = chatService; + if (!loopService) { + throw new Error("VoiceAssistantWebSocketServer requires a loop service."); + } + this.loopService = loopService; + if (!scheduleService) { + throw new Error("VoiceAssistantWebSocketServer requires a schedule service."); + } + this.scheduleService = scheduleService; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.createAgentMcpTransport = createAgentMcpTransport; @@ -622,6 +643,9 @@ export class VoiceAssistantWebSocketServer { agentStorage: this.agentStorage, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + chatService: this.chatService, + loopService: this.loopService, + scheduleService: this.scheduleService, createAgentMcpTransport: this.createAgentMcpTransport, stt: this.stt, tts: this.tts, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index ee51e0b2a..6ac99e5e8 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -3,6 +3,50 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "../server/agent/agent-title-limits.js"; import { AgentProviderSchema } from "../server/agent/provider-manifest.js"; import { TOOL_CALL_ICON_NAMES } from "../server/agent/agent-sdk-types.js"; +import { + ChatCreateRequestSchema, + ChatListRequestSchema, + ChatInspectRequestSchema, + ChatDeleteRequestSchema, + ChatPostRequestSchema, + ChatReadRequestSchema, + ChatWaitRequestSchema, + ChatCreateResponseSchema, + ChatListResponseSchema, + ChatInspectResponseSchema, + ChatDeleteResponseSchema, + ChatPostResponseSchema, + ChatReadResponseSchema, + ChatWaitResponseSchema, +} from "../server/chat/chat-rpc-schemas.js"; +import { + ScheduleCreateRequestSchema, + ScheduleListRequestSchema, + ScheduleInspectRequestSchema, + ScheduleLogsRequestSchema, + SchedulePauseRequestSchema, + ScheduleResumeRequestSchema, + ScheduleDeleteRequestSchema, + ScheduleCreateResponseSchema, + ScheduleListResponseSchema, + ScheduleInspectResponseSchema, + ScheduleLogsResponseSchema, + SchedulePauseResponseSchema, + ScheduleResumeResponseSchema, + ScheduleDeleteResponseSchema, +} from "../server/schedule/rpc-schemas.js"; +import { + LoopRunRequestSchema, + LoopListRequestSchema, + LoopInspectRequestSchema, + LoopLogsRequestSchema, + LoopStopRequestSchema, + LoopRunResponseSchema, + LoopListResponseSchema, + LoopInspectResponseSchema, + LoopLogsResponseSchema, + LoopStopResponseSchema, +} from "../server/loop/rpc-schemas.js"; import type { AgentCapabilityFlags, AgentModelDefinition, @@ -1204,6 +1248,25 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UnsubscribeTerminalRequestSchema, TerminalInputSchema, KillTerminalRequestSchema, + ChatCreateRequestSchema, + ChatListRequestSchema, + ChatInspectRequestSchema, + ChatDeleteRequestSchema, + ChatPostRequestSchema, + ChatReadRequestSchema, + ChatWaitRequestSchema, + ScheduleCreateRequestSchema, + ScheduleListRequestSchema, + ScheduleInspectRequestSchema, + ScheduleLogsRequestSchema, + SchedulePauseRequestSchema, + ScheduleResumeRequestSchema, + ScheduleDeleteRequestSchema, + LoopRunRequestSchema, + LoopListRequestSchema, + LoopInspectRequestSchema, + LoopLogsRequestSchema, + LoopStopRequestSchema, ]); export type SessionInboundMessage = z.infer; @@ -2257,6 +2320,25 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SubscribeTerminalResponseSchema, KillTerminalResponseSchema, TerminalStreamExitSchema, + ChatCreateResponseSchema, + ChatListResponseSchema, + ChatInspectResponseSchema, + ChatDeleteResponseSchema, + ChatPostResponseSchema, + ChatReadResponseSchema, + ChatWaitResponseSchema, + ScheduleCreateResponseSchema, + ScheduleListResponseSchema, + ScheduleInspectResponseSchema, + ScheduleLogsResponseSchema, + SchedulePauseResponseSchema, + ScheduleResumeResponseSchema, + ScheduleDeleteResponseSchema, + LoopRunResponseSchema, + LoopListResponseSchema, + LoopInspectResponseSchema, + LoopLogsResponseSchema, + LoopStopResponseSchema, ]); export type SessionOutboundMessage = z.infer; @@ -2301,6 +2383,25 @@ export type ListProviderModelsResponseMessage = z.infer< export type ListAvailableProvidersResponse = z.infer; export type SpeechModelsListResponse = z.infer; export type SpeechModelsDownloadResponse = z.infer; +export type ChatCreateResponse = z.infer; +export type ChatListResponse = z.infer; +export type ChatInspectResponse = z.infer; +export type ChatDeleteResponse = z.infer; +export type ChatPostResponse = z.infer; +export type ChatReadResponse = z.infer; +export type ChatWaitResponse = z.infer; +export type ScheduleCreateResponse = z.infer; +export type ScheduleListResponse = z.infer; +export type ScheduleInspectResponse = z.infer; +export type ScheduleLogsResponse = z.infer; +export type SchedulePauseResponse = z.infer; +export type ScheduleResumeResponse = z.infer; +export type ScheduleDeleteResponse = z.infer; +export type LoopRunResponse = z.infer; +export type LoopListResponse = z.infer; +export type LoopInspectResponse = z.infer; +export type LoopLogsResponse = z.infer; +export type LoopStopResponse = z.infer; // Type exports for payload types export type ActivityLogPayload = z.infer; @@ -2323,6 +2424,25 @@ export type ListProviderModelsRequestMessage = z.infer< export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; +export type ChatCreateRequest = z.infer; +export type ChatListRequest = z.infer; +export type ChatInspectRequest = z.infer; +export type ChatDeleteRequest = z.infer; +export type ChatPostRequest = z.infer; +export type ChatReadRequest = z.infer; +export type ChatWaitRequest = z.infer; +export type ScheduleCreateRequest = z.infer; +export type ScheduleListRequest = z.infer; +export type ScheduleInspectRequest = z.infer; +export type ScheduleLogsRequest = z.infer; +export type SchedulePauseRequest = z.infer; +export type ScheduleResumeRequest = z.infer; +export type ScheduleDeleteRequest = z.infer; +export type LoopRunRequest = z.infer; +export type LoopListRequest = z.infer; +export type LoopInspectRequest = z.infer; +export type LoopLogsRequest = z.infer; +export type LoopStopRequest = z.infer; export type SpeechModelsListRequestMessage = z.infer; export type SpeechModelsDownloadRequestMessage = z.infer; export type ResumeAgentRequestMessage = z.infer;