316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
|
import { formatError } from "@gadget/ai";
|
|
import { GadgetTool } from "@gadget/ai-toolbox";
|
|
import SubProcessService from "../../services/subprocess.ts";
|
|
|
|
const VALID_CMDS = [
|
|
"create",
|
|
"list",
|
|
"kill",
|
|
"killAll",
|
|
"halt",
|
|
"log",
|
|
] as const;
|
|
|
|
type SubprocessCmd = (typeof VALID_CMDS)[number];
|
|
|
|
export class SubprocessTool extends GadgetTool {
|
|
get name(): string {
|
|
return "subprocess";
|
|
}
|
|
|
|
get category(): string {
|
|
return "system";
|
|
}
|
|
|
|
get definition(): IToolDefinition {
|
|
return {
|
|
type: "function",
|
|
function: {
|
|
name: this.name,
|
|
description:
|
|
"Manage child processes spawned by this agent. Use 'create' to spawn a new process, 'list' to see running processes, 'kill' to stop a process and remove its log files, 'halt' to stop a process while retaining its log files for later inspection, 'killAll' to stop all running processes, and 'log' to read stdout or stderr output from a running or halted process.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
cmd: {
|
|
type: "string",
|
|
enum: [...VALID_CMDS],
|
|
description:
|
|
"The subprocess command to execute. create: spawn a new process. list: show all managed processes. kill: terminate a process and remove its logs. halt: terminate a process but keep its logs. killAll: terminate all managed processes. log: read stdout or stderr output from a process.",
|
|
},
|
|
command: {
|
|
type: "string",
|
|
description:
|
|
"The shell command to execute (required for cmd=create). Example: 'node', 'npm', 'python', './my-server'.",
|
|
},
|
|
args: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description:
|
|
"Command-line arguments for the process (optional, used with cmd=create). Example: ['run', 'dev'].",
|
|
},
|
|
pid: {
|
|
type: "number",
|
|
description:
|
|
"Process ID of the managed subprocess (required for cmd=kill, cmd=halt, cmd=log).",
|
|
},
|
|
which: {
|
|
type: "string",
|
|
enum: ["stdout", "stderr"],
|
|
description:
|
|
"Which log stream to read (required for cmd=log). 'stdout' for standard output, 'stderr' for standard error.",
|
|
},
|
|
},
|
|
required: ["cmd"],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
|
const cmd = args.cmd as string | undefined;
|
|
|
|
if (!cmd) {
|
|
return formatError({
|
|
code: "MISSING_PARAMETER",
|
|
message: "The 'cmd' parameter is required.",
|
|
parameter: "cmd",
|
|
expected: `One of: ${VALID_CMDS.join(", ")}`,
|
|
recoveryHint: `Specify a valid command: ${VALID_CMDS.join(", ")}.`,
|
|
});
|
|
}
|
|
|
|
if (!VALID_CMDS.includes(cmd as SubprocessCmd)) {
|
|
return formatError({
|
|
code: "INVALID_PARAMETER",
|
|
message: `Invalid cmd: '${cmd}'. Must be one of: ${VALID_CMDS.join(", ")}`,
|
|
parameter: "cmd",
|
|
expected: `One of: ${VALID_CMDS.join(", ")}`,
|
|
recoveryHint: `Use one of: ${VALID_CMDS.join(", ")}.`,
|
|
});
|
|
}
|
|
|
|
switch (cmd) {
|
|
case "create":
|
|
return this.cmdCreate(args, logger);
|
|
case "list":
|
|
return this.cmdList(args, logger);
|
|
case "kill":
|
|
return this.cmdKill(args, logger);
|
|
case "killAll":
|
|
return this.cmdKillAll(args, logger);
|
|
case "halt":
|
|
return this.cmdHalt(args, logger);
|
|
case "log":
|
|
return this.cmdLog(args, logger);
|
|
default:
|
|
return formatError({
|
|
code: "INVALID_PARAMETER",
|
|
message: `Unknown subprocess command: '${cmd}'`,
|
|
parameter: "cmd",
|
|
expected: `One of: ${VALID_CMDS.join(", ")}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async cmdCreate(
|
|
args: IToolArguments,
|
|
logger: IAiLogger,
|
|
): Promise<string> {
|
|
const command = args.command as string | undefined;
|
|
if (!command || typeof command !== "string" || command.trim().length === 0) {
|
|
return formatError({
|
|
code: "MISSING_PARAMETER",
|
|
message: "The 'command' parameter is required for cmd=create.",
|
|
parameter: "command",
|
|
recoveryHint:
|
|
"Provide the command to execute, e.g. 'node', 'npm', 'python'.",
|
|
});
|
|
}
|
|
|
|
const rawArgs = args.args;
|
|
const cmdArgs: string[] | undefined =
|
|
Array.isArray(rawArgs)
|
|
? rawArgs.map((a) => String(a))
|
|
: undefined;
|
|
|
|
const project = this.toolbox.env.workspace?.projectDir
|
|
? { _id: "workspace", slug: this.toolbox.env.workspace.projectDir.split("/").pop() || "project" }
|
|
: { _id: "workspace", slug: "project" };
|
|
|
|
const projectObj = {
|
|
_id: project._id,
|
|
slug: project.slug,
|
|
name: project.slug,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
createdBy: "system",
|
|
};
|
|
|
|
try {
|
|
const sp = await SubProcessService.create(projectObj as any, command, cmdArgs);
|
|
return [
|
|
"SUBPROCESS CREATED",
|
|
`pid: ${sp.pid}`,
|
|
`stdout: ${sp.stdoutFileName}`,
|
|
`stderr: ${sp.stderrFileName}`,
|
|
].join("\n");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
logger.error("failed to create subprocess", {
|
|
command,
|
|
args: cmdArgs,
|
|
error: message,
|
|
});
|
|
return formatError({
|
|
code: "OPERATION_FAILED",
|
|
message: `Failed to spawn subprocess: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async cmdList(
|
|
_args: IToolArguments,
|
|
_logger: IAiLogger,
|
|
): Promise<string> {
|
|
const processes = SubProcessService.ps();
|
|
if (processes.length === 0) {
|
|
return "SUBPROCESS LIST\n(no running subprocesses)";
|
|
}
|
|
|
|
const lines = ["SUBPROCESS LIST"];
|
|
for (const sp of processes) {
|
|
lines.push(
|
|
`pid: ${sp.pid} | project: ${sp.project.slug} | created: ${sp.createdAt.toISOString()} | updated: ${sp.updatedAt.toISOString()}`,
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
private async cmdKill(
|
|
args: IToolArguments,
|
|
logger: IAiLogger,
|
|
): Promise<string> {
|
|
const pid = args.pid as number | undefined;
|
|
if (typeof pid !== "number" || !Number.isFinite(pid)) {
|
|
return formatError({
|
|
code: "MISSING_PARAMETER",
|
|
message: "The 'pid' parameter is required for cmd=kill.",
|
|
parameter: "pid",
|
|
recoveryHint: "Provide the numeric process ID to kill.",
|
|
});
|
|
}
|
|
|
|
try {
|
|
await SubProcessService.killPid(pid);
|
|
return `SUBPROCESS KILLED\npid: ${pid}`;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
logger.error("failed to kill subprocess", { pid, error: message });
|
|
return formatError({
|
|
code: "OPERATION_FAILED",
|
|
message: `Failed to kill subprocess: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async cmdKillAll(
|
|
_args: IToolArguments,
|
|
logger: IAiLogger,
|
|
): Promise<string> {
|
|
try {
|
|
await SubProcessService.killAll();
|
|
return "SUBPROCESS KILLALL\nAll managed subprocesses have been terminated and their logs removed.";
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
logger.error("failed to kill all subprocesses", { error: message });
|
|
return formatError({
|
|
code: "OPERATION_FAILED",
|
|
message: `Failed to kill all subprocesses: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async cmdHalt(
|
|
args: IToolArguments,
|
|
logger: IAiLogger,
|
|
): Promise<string> {
|
|
const pid = args.pid as number | undefined;
|
|
if (typeof pid !== "number" || !Number.isFinite(pid)) {
|
|
return formatError({
|
|
code: "MISSING_PARAMETER",
|
|
message: "The 'pid' parameter is required for cmd=halt.",
|
|
parameter: "pid",
|
|
recoveryHint: "Provide the numeric process ID to halt.",
|
|
});
|
|
}
|
|
|
|
try {
|
|
await SubProcessService.haltPid(pid);
|
|
return [
|
|
"SUBPROCESS HALTED",
|
|
`pid: ${pid}`,
|
|
"The process has been stopped. Log files are retained for inspection.",
|
|
].join("\n");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
logger.error("failed to halt subprocess", { pid, error: message });
|
|
return formatError({
|
|
code: "OPERATION_FAILED",
|
|
message: `Failed to halt subprocess: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async cmdLog(
|
|
args: IToolArguments,
|
|
logger: IAiLogger,
|
|
): Promise<string> {
|
|
const pid = args.pid as number | undefined;
|
|
if (typeof pid !== "number" || !Number.isFinite(pid)) {
|
|
return formatError({
|
|
code: "MISSING_PARAMETER",
|
|
message: "The 'pid' parameter is required for cmd=log.",
|
|
parameter: "pid",
|
|
recoveryHint: "Provide the numeric process ID to read logs from.",
|
|
});
|
|
}
|
|
|
|
const which = args.which as string | undefined;
|
|
if (!which || (which !== "stdout" && which !== "stderr")) {
|
|
return formatError({
|
|
code: "MISSING_PARAMETER",
|
|
message: "The 'which' parameter is required for cmd=log.",
|
|
parameter: "which",
|
|
expected: "'stdout' or 'stderr'",
|
|
recoveryHint: "Specify 'stdout' to read standard output or 'stderr' to read standard error.",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const content = await SubProcessService.getLog(pid, which);
|
|
return [
|
|
`SUBPROCESS LOG (${which})`,
|
|
`pid: ${pid}`,
|
|
"---",
|
|
content || "(log is empty)",
|
|
].join("\n");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
logger.error("failed to read subprocess log", {
|
|
pid,
|
|
which,
|
|
error: message,
|
|
});
|
|
return formatError({
|
|
code: "OPERATION_FAILED",
|
|
message: `Failed to read subprocess log: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
}
|