Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 77 additions & 4 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { loadConfig } from "./config.js";
import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js";
import {
LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
localAgentDaemonPaths,
} from "./local-agent-daemon-lifecycle.js";
import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js";
import { LocalAgentStore } from "./local-agent-store.js";
import { writeTestDevspaceConfig } from "./test-support/config.test.js";
Expand Down Expand Up @@ -100,7 +103,7 @@ try {
if (request.method === "agent.start") {
socket.end(encodeLocalAgentDaemonResponse({
requestId: request.requestId,
protocolVersion: 3,
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
ok: false,
error: {
code: "UNKNOWN_TARGET",
Expand All @@ -113,10 +116,17 @@ try {
}
const result = request.method === "agent.list"
? [current]
: request.method === "agent.get"
? current
: request.method === "agent.wait"
? [
{ id: current.id, status: "completed", response: "Review complete." },
{ id: other.id, status: "running", wait: "timeout" },
]
: request.method === "hello"
? {
state: "ready",
protocolVersion: 3,
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
pid: process.pid,
endpoint: daemonSocket,
startedAt: "now",
Expand All @@ -127,7 +137,7 @@ try {
: null;
socket.end(encodeLocalAgentDaemonResponse({
requestId: request.requestId,
protocolVersion: 3,
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
ok: true,
result,
}));
Expand Down Expand Up @@ -192,6 +202,69 @@ try {
const directList = [...daemonRequests].reverse().find((request) => request.method === "agent.list");
assert.deepEqual(directList?.params, { workspaceRoot: realpathSync.native(projectRoot) });

const { stdout: showOutput } = await execFileAsync(
"node",
["--import", "tsx", "src/cli.ts", "agents", "show", current.id],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
...cliConfigEnv,
DEVSPACE_WORKSPACE_ID: "ws_current",
DEVSPACE_WORKSPACE_ROOT: projectRoot,
},
},
);
assert.equal(
showOutput,
`<agent id="${current.id}" status="completed">Review complete.</agent>\n`,
);
assert.equal(
daemonRequests.filter((request) => request.method === "agent.get").length,
1,
"show must be an immediate snapshot",
);

const { stdout: waitOutput } = await execFileAsync(
"node",
[
"--import",
"tsx",
"src/cli.ts",
"agents",
"wait",
current.id,
other.id,
"--timeout",
"0",
],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
...cliConfigEnv,
DEVSPACE_WORKSPACE_ID: "ws_current",
DEVSPACE_WORKSPACE_ROOT: projectRoot,
},
},
);
assert.equal(
waitOutput,
[
`<agent id="${current.id}" status="completed">Review complete.</agent>`,
`<agent id="${other.id}" status="running" wait="timeout"/>`,
"",
].join("\n"),
);
const waitRequest = daemonRequests.find((request) => request.method === "agent.wait");
assert.deepEqual(waitRequest?.params, {
ids: [current.id, other.id],
scope: { workspaceId: "ws_current", workspaceRoot: realpathSync.native(projectRoot) },
timeoutMs: 0,
});

let commandFailure: unknown;
try {
await execFileAsync(
Expand Down
66 changes: 53 additions & 13 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ async function runAgentsCommand(args: string[]): Promise<void> {
case "show":
await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json));
return;
case "wait":
await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json));
return;
case "targets":
await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json));
return;
Expand Down Expand Up @@ -558,22 +561,62 @@ async function runAgentsShow(args: string[], json: boolean): Promise<void> {
const client = createLocalAgentClient(config);
const scope = resolveCliWorkspaceContext(config.allowedRoots);
const initial = await client.get(id, scope);
let record = presentAgentWorkflowResult(initial, json);
const record = presentAgentWorkflowResult(initial, json);
if (!record) return;

const deadline = Date.now() + 15_000;
while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) {
await sleep(500);
const refreshed = presentAgentWorkflowResult(await client.get(id, scope), json);
if (!refreshed) return;
record = refreshed;
}

const observation = presentAgentObservation(record);
if (json) printJson(observation);
else printAgentXml(formatAgentObservation(observation));
}

async function runAgentsWait(args: string[], json: boolean): Promise<void> {
const { ids, timeoutMs } = parseAgentsWaitArgs(args);
const config = loadConfig();
const client = createLocalAgentClient(config);
const scope = resolveCliWorkspaceContext(config.allowedRoots);
const results = presentAgentWorkflowResult(await client.wait(ids, scope, timeoutMs), json);
if (!results) return;
if (json) {
printJson(results);
return;
}
printAgentXml(results.map(formatAgentObservation).join("\n"));
}

function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } {
const ids: string[] = [];
let timeoutMs: number | undefined;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]!;
if (argument === "--timeout") {
timeoutMs = parseAgentWaitTimeout(args[index + 1]);
index += 1;
continue;
}
if (argument.startsWith("--timeout=")) {
timeoutMs = parseAgentWaitTimeout(argument.slice("--timeout=".length));
continue;
}
if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}.`);
ids.push(argument);
}
if (ids.length === 0) {
throw new Error("Usage: devspace agents wait <id>... [--timeout <seconds>] [--json]");
}
return { ids, ...(timeoutMs === undefined ? {} : { timeoutMs }) };
}

function parseAgentWaitTimeout(value: string | undefined): number {
if (!value || !/^\d+$/.test(value)) {
throw new Error("Agent wait timeout must be a non-negative integer number of seconds.");
}
const timeoutMs = Number(value) * 1_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs > 2_147_483_647) {
throw new Error("Agent wait timeout is too large.");
}
return timeoutMs;
}

async function runAgentsDaemon(args: string[], json: boolean): Promise<void> {
const [subcommand, ...extra] = args;
if (extra.length > 0) throw new Error("Usage: devspace agents daemon <status|stop|logs> [--json]");
Expand Down Expand Up @@ -672,10 +715,6 @@ function printJson(value: unknown): void {
console.log(JSON.stringify(value));
}

function sleep(ms: number): Promise<void> {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}

function printAgentsHelp(): void {
console.log(
[
Expand All @@ -686,6 +725,7 @@ function printAgentsHelp(): void {
" devspace agents run <profile-or-provider> [--model <model>] [--effort <level>] [--json] <prompt>",
" devspace agents continue <id> [--model <model>] [--effort <level>] [--json] <prompt>",
" devspace agents show <id> [--json]",
" devspace agents wait <id>... [--timeout <seconds>] [--json]",
" devspace agents targets [--json]",
" devspace agents daemon <status|stop|logs> [--json]",
].join("\n"),
Expand Down
46 changes: 35 additions & 11 deletions src/local-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import {
decodeAgentRecord,
decodeAgentRecordList,
decodeAgentWaitResults,
decodeDaemonLogs,
decodeDaemonStatus,
decodeLocalAgentDaemonResponse,
Expand All @@ -45,6 +46,8 @@ import type {
AgentListError,
AgentLookupError,
AgentStartError,
AgentWaitError,
LocalAgentWaitResult,
RunOverrides,
StartLocalAgentInput,
} from "./local-agent-manager.js";
Expand All @@ -60,6 +63,7 @@ type RequestError<M extends LocalAgentDaemonRequest["method"]> =
: M extends "agent.continue" ? AgentContinueError | AgentDaemonError
: M extends "agent.get" ? AgentLookupError | AgentDaemonError
: M extends "agent.list" ? AgentListError | AgentDaemonError
: M extends "agent.wait" ? AgentWaitError | AgentDaemonError
: AgentDaemonError;

export interface LocalAgentClientOptions {
Expand Down Expand Up @@ -134,6 +138,22 @@ export class LocalAgentClient {
return decodeRequestResult(result, "agent.list", decodeAgentRecordList);
}

async wait(
agentIds: readonly string[],
scope: LocalAgentWorkspaceScope,
timeoutMs?: number,
): Promise<BetterResult<LocalAgentWaitResult[], AgentWaitError | AgentDaemonError>> {
const transportTimeoutMs = timeoutMs === undefined
? null
: Math.min(2_147_483_647, timeoutMs + this.requestTimeoutMs);
const result = await this.request("agent.wait", {
ids: [...agentIds],
scope,
...(timeoutMs === undefined ? {} : { timeoutMs }),
}, transportTimeoutMs);
return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults);
}

async status(): Promise<BetterResult<LocalAgentDaemonStatus, AgentDaemonError>> {
const result = await this.requestExisting("daemon.status", {});
return decodeRequestResult(result, "daemon.status", decodeDaemonStatus);
Expand Down Expand Up @@ -308,6 +328,7 @@ export class LocalAgentClient {
private async request<M extends LocalAgentDaemonRequest["method"]>(
method: M,
params: Extract<LocalAgentDaemonRequest, { method: M }>['params'],
timeoutMs: number | null = this.requestTimeoutMs,
): Promise<BetterResult<unknown, RequestError<M>>> {
const ready = await this.ensureReady();
if (ready.isErr()) return ready as BetterResult<unknown, RequestError<M>>;
Expand All @@ -319,7 +340,7 @@ export class LocalAgentClient {
authToken: authToken.value,
method,
params,
} as LocalAgentDaemonRequest, this.requestTimeoutMs);
} as LocalAgentDaemonRequest, timeoutMs ?? undefined);
if (response.isErr()) return response as BetterResult<unknown, RequestError<M>>;
if (!response.value.ok) {
const error = decodeRemoteError(response.value.error, method);
Expand Down Expand Up @@ -459,28 +480,30 @@ export function resolveDaemonEntrypoint(): string {
async function sendRequest(
endpoint: string,
request: LocalAgentDaemonRequest,
timeoutMs: number,
timeoutMs?: number,
): Promise<BetterResult<LocalAgentDaemonResponse, AgentDaemonError>> {
return new Promise((resolve) => {
const socket = createConnection(endpoint);
let buffer = "";
let settled = false;
const timer = setTimeout(() => {
finish(Result.err(new AgentDaemonTimeoutError({
code: "DAEMON_TIMEOUT",
operation: request.method,
retryable: true,
message: "Timed out waiting for the local agent daemon.",
})), true);
}, timeoutMs);
const timer = timeoutMs === undefined
? undefined
: setTimeout(() => {
finish(Result.err(new AgentDaemonTimeoutError({
code: "DAEMON_TIMEOUT",
operation: request.method,
retryable: true,
message: "Timed out waiting for the local agent daemon.",
})), true);
}, timeoutMs);

const finish = (
result: BetterResult<LocalAgentDaemonResponse, AgentDaemonError>,
destroy = false,
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (timer) clearTimeout(timer);
if (destroy) socket.destroy();
resolve(result);
};
Expand Down Expand Up @@ -599,6 +622,7 @@ function isRequestError(
|| category === "conflict"
|| category === "store";
case "agent.get":
case "agent.wait":
return category === "target" || category === "scope" || category === "store";
case "agent.list":
return category === "scope" || category === "store";
Expand Down
2 changes: 1 addition & 1 deletion src/local-agent-daemon-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "node:fs";
import { join, resolve } from "node:path";

export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 3;
export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 4;
Comment thread
Waishnav marked this conversation as resolved.
export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock";
export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid";
export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock";
Expand Down
Loading
Loading