gadget/gadget-drone/src/tools/system/subprocess.ts

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}`,
});
}
}
}