feat: add loop, schedule, and chat CLI commands (#149)
* 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
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -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<SingleResult<ChatRoomRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<SingleResult<ChatRoomRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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("<name>", "Room name (must be unique)")
|
||||
.option("--purpose <text>", "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("<name-or-id>", "Room name or ID"),
|
||||
).action(withOutput(runInspectCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("delete")
|
||||
.description("Delete a chat room")
|
||||
.argument("<name-or-id>", "Room name or ID"),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("post")
|
||||
.description("Post a chat message")
|
||||
.argument("<name-or-id>", "Room name or ID")
|
||||
.argument("<message>", "Message body")
|
||||
.option("--reply-to <msg-id>", "Reply to a specific message ID")
|
||||
.option(
|
||||
"--mention <agent-id>",
|
||||
"Mention an agent ID (repeatable)",
|
||||
collectMultiple,
|
||||
[],
|
||||
),
|
||||
).action(withOutput(runPostCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("read")
|
||||
.description("Read chat messages")
|
||||
.argument("<name-or-id>", "Room name or ID")
|
||||
.option("--limit <n>", "Maximum number of messages to return")
|
||||
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
|
||||
.option("--agent <agent-id>", "Filter by author agent ID"),
|
||||
).action(withOutput(runReadCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("wait")
|
||||
.description("Wait for new chat messages")
|
||||
.argument("<name-or-id>", "Room name or ID")
|
||||
.option("--timeout <duration>", "Maximum wait time"),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
return chat;
|
||||
}
|
||||
@@ -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<SingleResult<ChatRoomRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<ListResult<ChatRoomRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<SingleResult<ChatMessageRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<ListResult<ChatMessageRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<ChatRoomRow> = {
|
||||
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<ChatMessageRow> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ListResult<ChatMessageRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("<id>", "Loop ID");
|
||||
}
|
||||
|
||||
function createInspectSchema(loop: LoopRecord): OutputSchema<InspectRow> {
|
||||
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<InspectRow>;
|
||||
|
||||
export async function runLoopInspectCommand(
|
||||
id: string,
|
||||
options: LoopInspectOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopInspectResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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("<id>", "Loop ID")
|
||||
.option("--poll-interval <ms>", "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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<LoopListRow> = {
|
||||
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<LoopListRow>;
|
||||
|
||||
export async function runLoopLsCommand(
|
||||
options: LoopLsOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopLsResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<LoopRunRow> = {
|
||||
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>", "Prompt for each fresh worker iteration")
|
||||
.option("--verify <prompt>", "Verifier agent prompt")
|
||||
.option(
|
||||
"--verify-check <command>",
|
||||
"Shell command that must exit 0 (repeatable)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--name <name>", "Optional loop name")
|
||||
.option("--sleep <duration>", "Delay between iterations (for example: 30s, 5m)")
|
||||
.option("--max-iterations <n>", "Maximum number of iterations")
|
||||
.option("--max-time <duration>", "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<LoopRunRow>;
|
||||
|
||||
export async function runLoopRunCommand(
|
||||
prompt: string,
|
||||
options: LoopRunOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopRunResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<LoopStopRow> = {
|
||||
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("<id>", "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<LoopStopRow>;
|
||||
|
||||
export async function runLoopStopCommand(
|
||||
id: string,
|
||||
options: LoopStopOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopStopResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<LoopRunPayload>;
|
||||
loopList(): Promise<LoopListPayload>;
|
||||
loopInspect(id: string): Promise<LoopInspectPayload>;
|
||||
loopLogs(id: string, afterSeq?: number): Promise<LoopLogsPayload>;
|
||||
loopStop(id: string): Promise<LoopStopPayload>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -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<SingleResult<ScheduleRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<ScheduleDeleteRow> = {
|
||||
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<SingleResult<ScheduleDeleteRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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>", "Prompt to run on the schedule")
|
||||
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
|
||||
.option("--cron <expr>", "Cron cadence expression")
|
||||
.option("--name <name>", "Optional schedule name")
|
||||
.option("--target <self|new-agent|agent-id>", "Run target")
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "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("<id>", "Schedule ID"),
|
||||
).action(withOutput(runInspectCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
schedule
|
||||
.command("logs")
|
||||
.description("Show recent schedule run logs")
|
||||
.argument("<id>", "Schedule ID"),
|
||||
).action(withOutput(runLogsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
schedule
|
||||
.command("pause")
|
||||
.description("Pause a schedule")
|
||||
.argument("<id>", "Schedule ID"),
|
||||
).action(withOutput(runPauseCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
schedule
|
||||
.command("resume")
|
||||
.description("Resume a paused schedule")
|
||||
.argument("<id>", "Schedule ID"),
|
||||
).action(withOutput(runResumeCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
schedule
|
||||
.command("delete")
|
||||
.description("Delete a schedule")
|
||||
.argument("<id>", "Schedule ID"),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
return schedule;
|
||||
}
|
||||
@@ -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<ListResult<ScheduleInspectRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<ListResult<ScheduleLogRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<ListResult<ScheduleRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<SingleResult<ScheduleRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<SingleResult<ScheduleRow>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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<ScheduleRow> = {
|
||||
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<ScheduleInspectRow> {
|
||||
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<ScheduleLogRow> = {
|
||||
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}` },
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<ScheduleTarget, { type: "self" }>;
|
||||
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<ScheduleTarget, { type: "self" }>;
|
||||
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<ScheduleCreatePayload>;
|
||||
scheduleList(): Promise<ScheduleListPayload>;
|
||||
scheduleInspect(input: { id: string }): Promise<ScheduleInspectPayload>;
|
||||
scheduleLogs(input: { id: string }): Promise<ScheduleLogsPayload>;
|
||||
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
|
||||
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
|
||||
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 ===");
|
||||
@@ -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<void> {
|
||||
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 ===");
|
||||
@@ -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<FetchWorkspacesRequest, "type" | "requ
|
||||
};
|
||||
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
|
||||
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
|
||||
export type CreateChatRoomOptions = {
|
||||
name: string;
|
||||
purpose?: string | null;
|
||||
requestId?: string;
|
||||
};
|
||||
export type InspectChatRoomOptions = {
|
||||
room: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type DeleteChatRoomOptions = {
|
||||
room: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type PostChatMessageOptions = {
|
||||
room: string;
|
||||
body: string;
|
||||
replyToMessageId?: string | null;
|
||||
mentionAgentIds?: string[];
|
||||
requestId?: string;
|
||||
};
|
||||
export type ReadChatMessagesOptions = {
|
||||
room: string;
|
||||
limit?: number;
|
||||
since?: string;
|
||||
authorAgentId?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type WaitForChatMessagesOptions = {
|
||||
room: string;
|
||||
afterMessageId?: string | null;
|
||||
timeoutMs?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
export type RunLoopOptions = {
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
verifyPrompt?: string | null;
|
||||
verifyChecks?: string[];
|
||||
name?: string | null;
|
||||
sleepMs?: number;
|
||||
maxIterations?: number;
|
||||
maxTimeMs?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
export type InspectLoopOptions = {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type LoopLogsOptions = {
|
||||
id: string;
|
||||
afterSeq?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
export type StopLoopOptions = {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type CreateScheduleOptions = {
|
||||
prompt: string;
|
||||
name?: string | null;
|
||||
cadence:
|
||||
| {
|
||||
type: "every";
|
||||
everyMs: number;
|
||||
}
|
||||
| {
|
||||
type: "cron";
|
||||
expression: string;
|
||||
};
|
||||
target:
|
||||
| {
|
||||
type: "self";
|
||||
agentId: string;
|
||||
}
|
||||
| {
|
||||
type: "agent";
|
||||
agentId: string;
|
||||
}
|
||||
| {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
title?: string | null;
|
||||
approvalPolicy?: string;
|
||||
sandboxMode?: string;
|
||||
networkAccess?: boolean;
|
||||
webSearch?: boolean;
|
||||
extra?: AgentSessionConfig["extra"];
|
||||
systemPrompt?: string;
|
||||
mcpServers?: AgentSessionConfig["mcpServers"];
|
||||
};
|
||||
};
|
||||
maxRuns?: number;
|
||||
expiresAt?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
export type InspectScheduleOptions = {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
|
||||
@@ -2677,6 +2857,263 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
|
||||
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<ChatListPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "chat/list",
|
||||
},
|
||||
responseType: "chat/list/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async inspectChatRoom(options: InspectChatRoomOptions): Promise<ChatInspectPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
message: {
|
||||
type: "chat/inspect",
|
||||
room: options.room,
|
||||
},
|
||||
responseType: "chat/inspect/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChatRoom(options: DeleteChatRoomOptions): Promise<ChatDeletePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
message: {
|
||||
type: "chat/delete",
|
||||
room: options.room,
|
||||
},
|
||||
responseType: "chat/delete/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async postChatMessage(options: PostChatMessageOptions): Promise<ChatPostPayload> {
|
||||
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<ChatReadPayload> {
|
||||
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<ChatWaitPayload> {
|
||||
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<ScheduleCreatePayload> {
|
||||
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<ScheduleListPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "schedule/list",
|
||||
},
|
||||
responseType: "schedule/list/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async scheduleInspect(options: InspectScheduleOptions): Promise<ScheduleInspectPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
message: {
|
||||
type: "schedule/inspect",
|
||||
scheduleId: options.id,
|
||||
},
|
||||
responseType: "schedule/inspect/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async scheduleLogs(options: InspectScheduleOptions): Promise<ScheduleLogsPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
message: {
|
||||
type: "schedule/logs",
|
||||
scheduleId: options.id,
|
||||
},
|
||||
responseType: "schedule/logs/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async schedulePause(options: InspectScheduleOptions): Promise<SchedulePausePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
message: {
|
||||
type: "schedule/pause",
|
||||
scheduleId: options.id,
|
||||
},
|
||||
responseType: "schedule/pause/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async scheduleResume(options: InspectScheduleOptions): Promise<ScheduleResumePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
message: {
|
||||
type: "schedule/resume",
|
||||
scheduleId: options.id,
|
||||
},
|
||||
responseType: "schedule/resume/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async scheduleDelete(options: InspectScheduleOptions): Promise<ScheduleDeletePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
message: {
|
||||
type: "schedule/delete",
|
||||
scheduleId: options.id,
|
||||
},
|
||||
responseType: "schedule/delete/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async loopRun(options: RunLoopOptions): Promise<LoopRunPayload> {
|
||||
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<LoopListPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "loop/list",
|
||||
},
|
||||
responseType: "loop/list/response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async loopInspect(options: string | InspectLoopOptions): Promise<LoopInspectPayload> {
|
||||
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<LoopLogsPayload> {
|
||||
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<LoopStopPayload> {
|
||||
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 () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
@@ -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<Partial<ChatServiceError>>({
|
||||
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<Partial<ChatServiceError>>({
|
||||
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<Partial<ChatServiceError>>({
|
||||
code: "chat_room_deleted",
|
||||
});
|
||||
await expect(service.inspectRoom({ room: room.name })).rejects.toMatchObject<
|
||||
Partial<ChatServiceError>
|
||||
>({
|
||||
code: "chat_room_not_found",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof ChatStorePayloadSchema>;
|
||||
|
||||
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<typeof setTimeout> | 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<string, ChatRoom>();
|
||||
private readonly messagesByRoomId = new Map<string, ChatMessage[]>();
|
||||
private persistQueue: Promise<void> = Promise.resolve();
|
||||
private readonly waitersByRoomId = new Map<string, Set<Waiter>>();
|
||||
|
||||
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<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async createRoom(input: CreateChatRoomInput): Promise<ChatRoomDetail> {
|
||||
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<ChatRoomDetail[]> {
|
||||
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<InspectChatRoomResult> {
|
||||
await this.load();
|
||||
const room = this.resolveRoom(input.room);
|
||||
return {
|
||||
room: this.toRoomDetail(room),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteRoom(input: DeleteChatRoomInput): Promise<DeleteChatRoomResult> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage[]> {
|
||||
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<ChatMessage[]> {
|
||||
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<ChatMessage[]>((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<Waiter>();
|
||||
roomWaiters.add(waiter);
|
||||
this.waitersByRoomId.set(room.id, roomWaiters);
|
||||
});
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
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<void> {
|
||||
const nextPersist = this.persistQueue.then(() => this.persist());
|
||||
this.persistQueue = nextPersist.catch(() => {});
|
||||
await nextPersist;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof ChatRoomSchema>;
|
||||
|
||||
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<typeof ChatMessageSchema>;
|
||||
|
||||
export const ChatRoomDetailSchema = ChatRoomSchema.extend({
|
||||
messageCount: z.number().int().nonnegative(),
|
||||
lastMessageAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type ChatRoomDetail = z.infer<typeof ChatRoomDetailSchema>;
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
class ScriptedAgentClient implements AgentClient {
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
|
||||
constructor(private readonly behavior: ScriptedAgentBehavior) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
return new ScriptedAgentSession(config, this.behavior);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new ScriptedAgentSession(
|
||||
{
|
||||
provider: "claude",
|
||||
cwd: overrides?.cwd ?? process.cwd(),
|
||||
...overrides,
|
||||
},
|
||||
this.behavior,
|
||||
);
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
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<AgentRunResult> {
|
||||
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<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return this.config.modeId ?? null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
};
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
this.interrupted = true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
|
||||
private async runScript(prompt: string, turnId: string): Promise<void> {
|
||||
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<void>((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<void> {
|
||||
await import("node:fs/promises").then(({ mkdir }) => mkdir(target, { recursive: true }));
|
||||
}
|
||||
|
||||
function pathExists(target: string): boolean {
|
||||
return existsSync(target);
|
||||
}
|
||||
@@ -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<typeof LoopRecordSchema>["status"];
|
||||
export type LoopLogEntry = z.infer<typeof LoopLogEntrySchema>;
|
||||
export type LoopVerifyCheckResult = z.infer<typeof LoopVerifyCheckResultSchema>;
|
||||
export type LoopVerifyPromptResult = z.infer<typeof LoopVerifyPromptResultSchema>;
|
||||
export type LoopIterationRecord = z.infer<typeof LoopIterationRecordSchema>;
|
||||
export type LoopRecord = z.infer<typeof LoopRecordSchema>;
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((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<LoopVerifyCheckResult> {
|
||||
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<string, LoopRecord>();
|
||||
private persistQueue: Promise<void> = Promise.resolve();
|
||||
private readonly running = new Map<string, RunningLoopState>();
|
||||
|
||||
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<void> {
|
||||
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<LoopRecord> {
|
||||
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<LoopListItem[]> {
|
||||
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<LoopRecord> {
|
||||
await this.initialize();
|
||||
const loop = this.requireLoop(idOrPrefix);
|
||||
return cloneLoop(loop);
|
||||
}
|
||||
|
||||
async getLoopLogs(idOrPrefix: string, afterSeq = 0): Promise<LoopLogsResult> {
|
||||
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<LoopRecord> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<LoopStatus, "running">, 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<LoopLogEntry, "seq" | "timestamp">,
|
||||
): 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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof LoopLogEntrySchema>;
|
||||
export type LoopVerifyCheckResult = z.infer<typeof LoopVerifyCheckResultSchema>;
|
||||
export type LoopVerifyPromptResult = z.infer<typeof LoopVerifyPromptResultSchema>;
|
||||
export type LoopIterationRecord = z.infer<typeof LoopIterationRecordSchema>;
|
||||
export type LoopRecord = z.infer<typeof LoopRecordSchema>;
|
||||
export type LoopListItem = z.infer<typeof LoopListItemSchema>;
|
||||
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
|
||||
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
|
||||
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
|
||||
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
|
||||
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
|
||||
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
|
||||
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
|
||||
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
|
||||
export type LoopLogsResponse = z.infer<typeof LoopLogsResponseSchema>;
|
||||
export type LoopStopResponse = z.infer<typeof LoopStopResponseSchema>;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { ScheduleCadence } from "./types.js";
|
||||
|
||||
interface CronFieldMatcher {
|
||||
matches(value: number): boolean;
|
||||
}
|
||||
|
||||
function buildValueSet(values: Iterable<number>): Set<number> {
|
||||
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<number>();
|
||||
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}`);
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
@@ -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<void>((resolve) => {
|
||||
releaseRun = resolve;
|
||||
});
|
||||
let finishRun: (() => void) | null = null;
|
||||
const runBlocked = new Promise<void>((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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, Promise<ManagedAgent>>();
|
||||
|
||||
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<ScheduleExecutionResult>;
|
||||
}
|
||||
|
||||
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<ScheduleExecutionResult>;
|
||||
private readonly runningScheduleIds = new Set<string>();
|
||||
private tickTimer: ReturnType<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateScheduleInput): Promise<StoredSchedule> {
|
||||
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<StoredSchedule[]> {
|
||||
return this.store.list();
|
||||
}
|
||||
|
||||
async inspect(id: string): Promise<StoredSchedule> {
|
||||
const schedule = await this.store.get(id);
|
||||
if (!schedule) {
|
||||
throw new Error(`Schedule not found: ${id}`);
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async logs(id: string): Promise<ScheduleRun[]> {
|
||||
const schedule = await this.inspect(id);
|
||||
return [...schedule.runs].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
||||
}
|
||||
|
||||
async pause(id: string): Promise<StoredSchedule> {
|
||||
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<StoredSchedule> {
|
||||
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<void> {
|
||||
await this.store.delete(id);
|
||||
}
|
||||
|
||||
async tick(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ScheduleExecutionResult> {
|
||||
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<ManagedAgent> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
async list(): Promise<StoredSchedule[]> {
|
||||
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<StoredSchedule | null> {
|
||||
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<StoredSchedule, "id">): Promise<StoredSchedule> {
|
||||
const created = { ...schedule, id: generateScheduleId() };
|
||||
await this.put(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async put(schedule: StoredSchedule): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeFile(this.filePath(schedule.id), JSON.stringify(schedule, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await rm(this.filePath(id), { force: true });
|
||||
}
|
||||
}
|
||||
@@ -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<typeof ScheduleStatusSchema>;
|
||||
|
||||
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<typeof ScheduleCadenceSchema>;
|
||||
|
||||
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<typeof ScheduleTargetSchema>;
|
||||
|
||||
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<typeof ScheduleRunSchema>;
|
||||
|
||||
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<typeof StoredScheduleSchema>;
|
||||
|
||||
export const ScheduleSummarySchema = StoredScheduleSchema.omit({
|
||||
runs: true,
|
||||
});
|
||||
export type ScheduleSummary = z.infer<typeof ScheduleSummarySchema>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
@@ -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<SessionInboundMessage, { type: "chat/create" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "chat/list" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "chat/inspect" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "chat/delete" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "chat/post" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "chat/read" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "chat/wait" }>,
|
||||
): Promise<void> {
|
||||
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<ReturnType<ScheduleService["inspect"]>>,
|
||||
): Extract<SessionOutboundMessage, { type: "schedule/list/response" }>["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<SessionInboundMessage, { type: "schedule/create" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "schedule/list" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "schedule/inspect" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "schedule/logs" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "schedule/pause" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "schedule/resume" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "schedule/delete" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "loop/run" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "loop/list" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "loop/inspect" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "loop/logs" }>,
|
||||
): Promise<void> {
|
||||
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<SessionInboundMessage, { type: "loop/stop" }>,
|
||||
): Promise<void> {
|
||||
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 }>;
|
||||
|
||||
@@ -82,6 +82,12 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
undefined,
|
||||
undefined,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
return { server, agentManager };
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof SessionInboundMessageSchema>;
|
||||
@@ -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<typeof SessionOutboundMessageSchema>;
|
||||
@@ -2301,6 +2383,25 @@ export type ListProviderModelsResponseMessage = z.infer<
|
||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
|
||||
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
|
||||
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
||||
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
||||
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
|
||||
export type ChatDeleteResponse = z.infer<typeof ChatDeleteResponseSchema>;
|
||||
export type ChatPostResponse = z.infer<typeof ChatPostResponseSchema>;
|
||||
export type ChatReadResponse = z.infer<typeof ChatReadResponseSchema>;
|
||||
export type ChatWaitResponse = z.infer<typeof ChatWaitResponseSchema>;
|
||||
export type ScheduleCreateResponse = z.infer<typeof ScheduleCreateResponseSchema>;
|
||||
export type ScheduleListResponse = z.infer<typeof ScheduleListResponseSchema>;
|
||||
export type ScheduleInspectResponse = z.infer<typeof ScheduleInspectResponseSchema>;
|
||||
export type ScheduleLogsResponse = z.infer<typeof ScheduleLogsResponseSchema>;
|
||||
export type SchedulePauseResponse = z.infer<typeof SchedulePauseResponseSchema>;
|
||||
export type ScheduleResumeResponse = z.infer<typeof ScheduleResumeResponseSchema>;
|
||||
export type ScheduleDeleteResponse = z.infer<typeof ScheduleDeleteResponseSchema>;
|
||||
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
|
||||
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
|
||||
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
|
||||
export type LoopLogsResponse = z.infer<typeof LoopLogsResponseSchema>;
|
||||
export type LoopStopResponse = z.infer<typeof LoopStopResponseSchema>;
|
||||
|
||||
// Type exports for payload types
|
||||
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
||||
@@ -2323,6 +2424,25 @@ export type ListProviderModelsRequestMessage = z.infer<
|
||||
export type ListAvailableProvidersRequestMessage = z.infer<
|
||||
typeof ListAvailableProvidersRequestMessageSchema
|
||||
>;
|
||||
export type ChatCreateRequest = z.infer<typeof ChatCreateRequestSchema>;
|
||||
export type ChatListRequest = z.infer<typeof ChatListRequestSchema>;
|
||||
export type ChatInspectRequest = z.infer<typeof ChatInspectRequestSchema>;
|
||||
export type ChatDeleteRequest = z.infer<typeof ChatDeleteRequestSchema>;
|
||||
export type ChatPostRequest = z.infer<typeof ChatPostRequestSchema>;
|
||||
export type ChatReadRequest = z.infer<typeof ChatReadRequestSchema>;
|
||||
export type ChatWaitRequest = z.infer<typeof ChatWaitRequestSchema>;
|
||||
export type ScheduleCreateRequest = z.infer<typeof ScheduleCreateRequestSchema>;
|
||||
export type ScheduleListRequest = z.infer<typeof ScheduleListRequestSchema>;
|
||||
export type ScheduleInspectRequest = z.infer<typeof ScheduleInspectRequestSchema>;
|
||||
export type ScheduleLogsRequest = z.infer<typeof ScheduleLogsRequestSchema>;
|
||||
export type SchedulePauseRequest = z.infer<typeof SchedulePauseRequestSchema>;
|
||||
export type ScheduleResumeRequest = z.infer<typeof ScheduleResumeRequestSchema>;
|
||||
export type ScheduleDeleteRequest = z.infer<typeof ScheduleDeleteRequestSchema>;
|
||||
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
|
||||
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
|
||||
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
|
||||
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
|
||||
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
|
||||
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
|
||||
export type SpeechModelsDownloadRequestMessage = z.infer<typeof SpeechModelsDownloadRequestSchema>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
|
||||
Reference in New Issue
Block a user