From d04453016d84fd5b5f5ebe25c4da138bef7d0491 Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Thu, 14 May 2026 12:59:05 -0400 Subject: [PATCH] Phase 1: SubProcess service and agent tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Phase 1 of subprocess management in gadget-drone: - SubProcessService with graceful shutdown (SIGINT→SIGKILL), halt, getLog - SubprocessTool with create/list/kill/killAll/halt/log commands - Registered in agent toolbox for build/test/ship/develop modes - Fixed missing {{process_management_block}} replacement in chat-session.ts - 34 unit tests across service and tool - Comprehensive documentation in docs/subprocess.md --- gadget-code/src/services/chat-session.ts | 1 + gadget-drone/docs/subprocess.md | 154 +++++++-- gadget-drone/src/services/agent.ts | 2 + gadget-drone/src/services/subprocess.test.ts | 153 +++++++++ gadget-drone/src/services/subprocess.ts | 172 +++++++--- gadget-drone/src/tools/system/index.ts | 1 + .../src/tools/system/subprocess.test.ts | 261 +++++++++++++++ gadget-drone/src/tools/system/subprocess.ts | 315 ++++++++++++++++++ gadget-drone/vitest.config.ts | 10 + 9 files changed, 991 insertions(+), 78 deletions(-) create mode 100644 gadget-drone/src/services/subprocess.test.ts create mode 100644 gadget-drone/src/tools/system/subprocess.test.ts create mode 100644 gadget-drone/src/tools/system/subprocess.ts create mode 100644 gadget-drone/vitest.config.ts diff --git a/gadget-code/src/services/chat-session.ts b/gadget-code/src/services/chat-session.ts index 17c4884..01513da 100644 --- a/gadget-code/src/services/chat-session.ts +++ b/gadget-code/src/services/chat-session.ts @@ -369,6 +369,7 @@ class ChatSessionService extends DtpService { ); let prompt = promptTemplate + .replace("{{process_management_block}}", common.processManagementBlock) .replace("{{scope_block}}", common.scopeBlock) .replace("{{tools}}", common.toolsBlock) .replace("{{subagent_section}}", common.subagentsBlock) diff --git a/gadget-drone/docs/subprocess.md b/gadget-drone/docs/subprocess.md index d0dd657..c0fdc38 100644 --- a/gadget-drone/docs/subprocess.md +++ b/gadget-drone/docs/subprocess.md @@ -1,50 +1,144 @@ # Gadget Drone Sub-Processes -The Gadget Code agent wants to manage child processes, and give them a fancier name. Because we're fancy. +The Gadget Code agent manages child processes through gadget-drone's SubProcess system. In the Gadget ecosystem, we refer to a managed child process spawned by gadget-drone as a _SubProcess_. -In the Gadget ecosystem, we refer to a child process spawned by gadget-drone as a [SubProcess](../src/services/subprocess.ts). A `SubProcess` is just a child process, but it's also one that's being managed and controlled by gadget-drone based on commands received from the Agentic Workflow Loop using the `subprocess` tool (coming soon). +## Phase Status -## Intent - -This document is currently more of a specification to calibrate the model for working on Phase 1 of the SubProcess task. - -Phase 1: Basic Services, internal to drone. -Phase 2: Expose SubProcess to gadget-code:frontend (IDE) via gadget-code:backend. - -We will work together through Phase 1 now in this session. At the end of this session, with the knowledge we gain by implementing the agent tool, we will refine this document. And that will be the end of Phase 1. - -We will then start a new session, and expose SubProcess to the IDE for User observability of the subprocesses. +- **Phase 1 (Complete):** SubProcessService + SubprocessTool internal to drone. +- **Phase 2 (Planned):** Expose SubProcess status and logs to gadget-code:frontend (IDE) via gadget-code:backend. ## SubProcessService -[SubProcess](../src/services/subprocess.ts) is a service implemented by Gadget Drone for managing child processes. It offers methods for creating and managing child processes on behalf of Gadget Drone. +[Source](../src/services/subprocess.ts) -This is a service, and not entirely implemented within an Agent tool in the toolbox, because the gadget-drone process will also implement TypeScript code to manage and monitor the processes. +`SubProcessService` is a singleton service that manages child processes on behalf of Gadget Drone. It is started and stopped as part of the standard service lifecycle in `gadget-drone.ts`. On stop, all managed subprocesses are killed (SIGINT with graceful timeout, falling back to SIGKILL) and their log files are removed. -In the near future, `gadget-drone` will use this service to provide child process listing, status, and log services to `gadget-code:frontend` via `gadget-code:backend`. You can ignore this paragraph for this session because we will implement this in a different session. I mention it so you can include this detail in the refined version of this document at the end of this session. +### API -## SubProcess Tool +| Method | Signature | Description | +|---|---|---| +| `create` | `(project: IProject, cmd: string, args?: string[]) => Promise` | Spawns a child process, starts writing stdout/stderr to log files under `/subprocess-logs/`. | +| `ps` | `() => SubProcess[]` | Returns an array of all managed subprocesses. | +| `summarize` | `(sp: SubProcess) => SubProcessSummary` | Returns a serializable summary (without ChildProcess/WriteStream refs). | +| `killPid` | `(pid: number) => Promise` | Kills a specific subprocess by PID and removes its log files. | +| `killProject` | `(projectId: GadgetId) => Promise` | Kills all subprocesses for a given project. | +| `killSubProcess` | `(sp: SubProcess) => Promise` | Kills a subprocess and removes its log files. | +| `killAll` | `() => Promise` | Kills all managed subprocesses and removes all log files. | +| `haltPid` | `(pid: number) => Promise` | Stops a subprocess by PID but **retains its log files** for inspection. | +| `haltSubProcess` | `(sp: SubProcess) => Promise` | Stops a subprocess but retains its log files. | +| `getLog` | `(pid: number, stream: "stdout" \| "stderr") => Promise` | Reads the full content of a subprocess's stdout or stderr log file. | -A tool that is needed in the Agent's toolbox is the `subprocess` tool. This is what we are building in this session. +### Lifecycle -The `subprocess` tool will offer the agent `create` (spawn), `list` (ps), `kill(pid:number)`, `killAll`, and `log(which: "stdout" | "stderr")`. We will define a standard Gadget tool that the agent can use to manage child processes. +The service is registered in `GadgetDrone.startServices()` / `stopServices()`. During `stop()`, `killAll()` is called, ensuring no orphaned processes remain when the drone shuts down. -The tool makes use of methods on the `SubProcessService` service to execute tool function intent. +### Process Termination -## Process Management +Termination follows a graceful escalation: +1. Send `SIGINT` to the process. +2. Wait up to 5 seconds for the process to exit. +3. If still alive, send `SIGKILL`. +4. Wait up to another 5 seconds. +5. Remove stdout/stderr listeners. +6. Close log file streams. -`gadget-drone` itself will need to close all running child processes when terminating. I have already added this logic to the gadget-drone main process during normal shutdown by registering the service, starting it, and stopping it using the standard Gadget service pattern. +For `kill`, log files are deleted after termination. For `halt`, log files are preserved. -I have extended the system prompts for relevant modes with information about the `subprocess` tool using [process-management-block.md](../../gadget-code/data/prompts/common/process-management-block.md). That block is now being integrated into the system prompt for all modes (this work is already completed). Please ensure that the service and tool offer a clearly-defined and well-described path for the agent to take for managing processes related to the project. +## SubprocessTool -## Acceptance Criteria +[Source](../src/tools/system/subprocess.ts) -Let this section guide the creation of your TODO list. +The `subprocess` tool is registered in the agent's toolbox for modes: `Build`, `Test`, `Ship`, `Develop`. It provides a single function with a `cmd` parameter that dispatches to the appropriate operation. -[ ] SubProcessService feature-complete -[ ] Unit tests for SubProcessService -[ ] `subprocess` agent tool feature-complete in agent toolbox -[ ] Unit tests for `subprocess` tool -[ ] gadget-drone documentation updated with the knowledge gained from this session +### Tool Definition -When done, these become documentation describing what's here in the subprocess feature suite, and how to use it. +**Name:** `subprocess` +**Category:** `system` + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `cmd` | string (enum) | yes | One of: `create`, `list`, `kill`, `killAll`, `halt`, `log` | +| `command` | string | for `create` | The executable to spawn (e.g., `node`, `npm`, `python`) | +| `args` | string[] | for `create` | Command-line arguments for the spawned process | +| `pid` | number | for `kill`, `halt`, `log` | The managed process ID | +| `which` | string (enum) | for `log` | `"stdout"` or `"stderr"` | + +### Commands + +#### `create` +Spawns a new child process in the project directory. Logs stdout and stderr to `/subprocess-logs/`. + +Response: +``` +SUBPROCESS CREATED +pid: 12345 +stdout: /path/to/.gadget/subprocess-logs/subproc-12345.stdout.log +stderr: /path/to/.gadget/subprocess-logs/subproc-12345.stderr.log +``` + +#### `list` +Returns all currently managed subprocesses with their PIDs, project slugs, and timestamps. + +Response: +``` +SUBPROCESS LIST +pid: 12345 | project: my-project | created: ... | updated: ... +``` + +#### `kill` +Terminates a subprocess and removes its log files. Uses SIGINT with graceful escalation. + +Response: +``` +SUBPROCESS KILLED +pid: 12345 +``` + +#### `killAll` +Terminates all managed subprocesses and removes all log files. + +Response: +``` +SUBPROCESS KILLALL +All managed subprocesses have been terminated and their logs removed. +``` + +#### `halt` +Terminates a subprocess but retains its log files. Useful for inspecting logs after process exit. + +Response: +``` +SUBPROCESS HALTED +pid: 12345 +The process has been stopped. Log files are retained for inspection. +``` + +#### `log` +Reads stdout or stderr output from a subprocess's log file. + +Response: +``` +SUBPROCESS LOG (stdout) +pid: 12345 +--- + +``` + +## Integration Points + +### System Prompts + +All agent modes include a **Process Management** block in their system prompt. The block is loaded from [process-management-block.md](../../gadget-code/data/prompts/common/process-management-block.md) and rendered via `{{process_management_block}}` in the mode templates. This tells the agent about the `subprocess` tool and how to use it. + +### Shutdown + +`gadget-drone.ts` calls `SubProcessService.stop()` during shutdown, which triggers `killAll()` to clean up any remaining child processes. + +## Tests + +| File | What it covers | +|---|---| +| `src/services/subprocess.test.ts` | SubProcessService: create, ps, killPid, killAll, haltPid, getLog, error cases | +| `src/tools/system/subprocess.test.ts` | SubprocessTool: all 6 commands, parameter validation, service error handling | diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index 028da56..4a49c74 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -51,6 +51,7 @@ import { PlanListTool, ShellExecTool, SubagentTool, + SubprocessTool, type DroneToolboxEnvironment, } from "../tools/index.ts"; @@ -117,6 +118,7 @@ class AgentService extends GadgetService { this.toolbox.register(new FileWriteTool(this.toolbox), writeModes); this.toolbox.register(new FileEditTool(this.toolbox), writeModes); this.toolbox.register(new ShellExecTool(this.toolbox), writeModes); + this.toolbox.register(new SubprocessTool(this.toolbox), writeModes); // Plan tools — Gadget's own .gadget directory: only available in Plan mode this.toolbox.register(new PlanFileReadTool(this.toolbox), [ChatSessionMode.Plan]); diff --git a/gadget-drone/src/services/subprocess.test.ts b/gadget-drone/src/services/subprocess.test.ts new file mode 100644 index 0000000..e5a42ab --- /dev/null +++ b/gadget-drone/src/services/subprocess.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IProject } from "@gadget/api"; +import SubProcessService, { type SubProcess } from "./subprocess.ts"; + +vi.mock("./workspace.js", () => ({ + default: { + gadgetDir: "/tmp/.gadget", + getProjectDirectory: vi.fn((slug: string) => process.cwd()), + }, +})); + +function makeProject(): IProject { + return { + _id: "proj-test", + slug: "test-project", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "system", + } as unknown as IProject; +} + +const NODE = process.execPath; + +describe("SubProcessService", () => { + beforeEach(async () => { + await SubProcessService.start(); + }); + + it("starts and stops cleanly", async () => { + await SubProcessService.stop(); + expect(true).toBe(true); + }); + + it("creates a subprocess and tracks it in ps", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, ["-e", "process.exit(0)"]); + + expect(sp.pid).toBeGreaterThan(0); + expect(sp.project.slug).toBe("test-project"); + expect(sp.stdoutFileName).toContain("subproc-"); + expect(sp.stdoutFileName).toContain(".stdout.log"); + expect(sp.stderrFileName).toContain(".stderr.log"); + + const list = SubProcessService.ps(); + expect(list.some((p) => p.pid === sp.pid)).toBe(true); + + await SubProcessService.killPid(sp.pid); + }); + + it("create throws if workspace is not initialized", async () => { + const wsModule = await import("./workspace.js"); + const ws = wsModule.default as { gadgetDir: string | undefined }; + const original = ws.gadgetDir; + ws.gadgetDir = undefined; + + const project = makeProject(); + await expect( + SubProcessService.create(project, NODE, ["-e", "process.exit(0)"]), + ).rejects.toThrow("Gadget workspace directory not initialized"); + + ws.gadgetDir = original; + }); + + it("ps returns empty array when no processes exist", () => { + const list = SubProcessService.ps(); + expect(Array.isArray(list)).toBe(true); + expect(list.length).toBe(0); + }); + + it("killPid terminates a running subprocess", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]); + + expect(SubProcessService.ps().length).toBe(1); + + const killed = await SubProcessService.killPid(sp.pid); + expect(killed).toBe(true); + + expect(SubProcessService.ps().length).toBe(0); + }); + + it("killPid throws for unknown pid", async () => { + await expect(SubProcessService.killPid(99999)).rejects.toThrow("process not found"); + }); + + it("killAll terminates all subprocesses", async () => { + const project = makeProject(); + await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]); + await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]); + + expect(SubProcessService.ps().length).toBe(2); + + await SubProcessService.killAll(); + + expect(SubProcessService.ps().length).toBe(0); + }); + + it("haltPid stops a process but preserves log files", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, [ + "-e", + "setInterval(() => console.log('tick'), 1000)", + ]); + + expect(SubProcessService.ps().length).toBe(1); + + const halted = await SubProcessService.haltPid(sp.pid); + expect(halted).toBe(true); + + expect(SubProcessService.ps().length).toBe(0); + }); + + it("haltPid throws for unknown pid", async () => { + await expect(SubProcessService.haltPid(99999)).rejects.toThrow("process not found"); + }); + + it("getLog returns stdout content", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, [ + "-e", + "console.log('hello from subprocess'); process.exit(0)", + ]); + + await new Promise((resolve) => sp.process.on("exit", () => resolve())); + + const stdout = await SubProcessService.getLog(sp.pid, "stdout"); + expect(stdout).toContain("hello from subprocess"); + }); + + it("summarize returns serializable summary without ChildProcess/WriteStream", () => { + const pid = 12345; + const project = makeProject(); + const now = new Date(); + const sp: SubProcess = { + project, + createdAt: now, + updatedAt: now, + process: null as any, + pid, + stdoutFileName: "/tmp/.gadget/subprocess-logs/subproc-12345.stdout.log", + stderrFileName: "/tmp/.gadget/subprocess-logs/subproc-12345.stderr.log", + }; + + const summary = SubProcessService.summarize(sp); + expect(summary.pid).toBe(pid); + expect(summary.project.slug).toBe("test-project"); + expect(summary.stdoutFileName).toBe(sp.stdoutFileName); + expect(summary.stderrFileName).toBe(sp.stderrFileName); + expect(summary.createdAt).toBe(now); + expect(summary.updatedAt).toBe(now); + }); +}); diff --git a/gadget-drone/src/services/subprocess.ts b/gadget-drone/src/services/subprocess.ts index 0c110f6..bb807a0 100644 --- a/gadget-drone/src/services/subprocess.ts +++ b/gadget-drone/src/services/subprocess.ts @@ -2,7 +2,6 @@ // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 -import env from "../config/env.ts"; import assert from "node:assert"; import path from "node:path"; @@ -23,13 +22,22 @@ export interface SubProcess { process: ChildProcess; pid: number; stdoutFileName: string; - stdoutFile: fs.WriteStream; stderrFileName: string; - stderrFile: fs.WriteStream; +} + +export interface SubProcessSummary { + pid: number; + project: IProject; + createdAt: Date; + updatedAt: Date; + stdoutFileName: string; + stderrFileName: string; } type SubProcessMap = Map; +const GRACEFUL_SHUTDOWN_MS = 5_000; + class SubProcessService extends GadgetService { private processMap: SubProcessMap = new Map(); @@ -60,18 +68,19 @@ class SubProcessService extends GadgetService { "Gadget workspace directory not initialized", ); + const logDir = path.join(WorkspaceService.gadgetDir, "subprocess-logs"); + await fs.promises.mkdir(logDir, { recursive: true }); + const projectDir = WorkspaceService.getProjectDirectory(project.slug); const child = spawn(cmd, args, { cwd: projectDir }); assert(child.pid, "subprocess did not define a process id"); const pid = child.pid; + const stdoutFileName = path.join(logDir, `subproc-${pid}.stdout.log`); + const stderrFileName = path.join(logDir, `subproc-${pid}.stderr.log`); - const logFilePath = path.join( - WorkspaceService.gadgetDir, - "subprocess-logs", - ); - const stdoutFileName = path.join(logFilePath, `subproc-${pid}.stdout.log`); - const stderrFileName = path.join(logFilePath, `subproc-${pid}.stderr.log`); + const stdoutFile = fs.createWriteStream(stdoutFileName, "utf-8"); + const stderrFile = fs.createWriteStream(stderrFileName, "utf-8"); const sp: SubProcess = { project, @@ -80,38 +89,41 @@ class SubProcessService extends GadgetService { process: child, pid, stdoutFileName, - stdoutFile: fs.createWriteStream(stdoutFileName, "utf-8"), stderrFileName, - stderrFile: fs.createWriteStream(stderrFileName, "utf-8"), }; + child.stdout.on("data", (data: any) => { sp.updatedAt = new Date(); - sp.stdoutFile.write(data); + stdoutFile.write(data); }); child.stderr.on("data", (data: any) => { sp.updatedAt = new Date(); - sp.stderrFile.write(data); + stderrFile.write(data); }); - this.processMap.set(pid, sp); - /* - * AGENT: This is how to create the tool function response that spawns a new - * child process. This lets the agent know the pid of the process so it can - * manage it. It also lets the agent know where stdout and stderr are being - * written. - */ - const _response = [ - "SUBPROCESS CREATED", - `pid: ${pid}`, - `stdout: ${stdoutFileName}`, - `stderr: ${stderrFileName}`, - ].join("\n"); + child.on("exit", () => { + stdoutFile.close(); + stderrFile.close(); + }); + + this.processMap.set(pid, sp); return sp; } - ps(): MapIterator { - return this.processMap.values(); + ps(): SubProcess[] { + return Array.from(this.processMap.values()); + } + + summarize(sp: SubProcess): SubProcessSummary { + return { + pid: sp.pid, + project: sp.project, + createdAt: sp.createdAt, + updatedAt: sp.updatedAt, + stdoutFileName: sp.stdoutFileName, + stderrFileName: sp.stderrFileName, + }; } async killPid(pid: number): Promise { @@ -127,39 +139,103 @@ class SubProcessService extends GadgetService { if (sp.project._id !== projectId) { continue; } - this.processMap.delete(sp.pid); + await this.killSubProcess(sp); } } async killSubProcess(sp: SubProcess): Promise { - const pid = sp.pid; - this.log.info("killing subprocess", { pid }); - if (!sp.process.kill("SIGINT")) { - return false; - } + await this.terminateProcess(sp); + await this.removeLogFiles(sp); + this.processMap.delete(sp.pid); + this.log.info("subprocess killed", { pid: sp.pid }); + return true; + } - sp.process.stdout?.removeAllListeners(); - sp.process.stderr?.removeAllListeners(); - - this.log.info("closing subprocess logs", { pid }); - sp.stdoutFile.close(); - await fs.promises.rm(sp.stdoutFileName, { force: true }); - sp.stderrFile.close(); - await fs.promises.rm(sp.stderrFileName, { force: true }); - - this.processMap.delete(pid); + async haltSubProcess(sp: SubProcess): Promise { + await this.terminateProcess(sp); + this.processMap.delete(sp.pid); + this.log.info("subprocess halted, logs retained", { pid: sp.pid }); return true; } async killAll(): Promise { - for (const [pid, sp] of this.processMap) { - if (!(await this.killSubProcess(sp))) { - return false; + const entries = Array.from(this.processMap.entries()); + for (const [pid] of entries) { + const sp = this.processMap.get(pid); + if (sp) { + await this.killSubProcess(sp); } - this.processMap.delete(pid); } return true; } + + async haltPid(pid: number): Promise { + const sp = this.processMap.get(pid); + if (!sp) { + throw new Error("process not found"); + } + return this.haltSubProcess(sp); + } + + async getLog( + pid: number, + stream: "stdout" | "stderr", + ): Promise { + const sp = this.processMap.get(pid); + if (!sp) { + throw new Error("process not found"); + } + + const filePath = + stream === "stdout" ? sp.stdoutFileName : sp.stderrFileName; + try { + return await fs.promises.readFile(filePath, "utf-8"); + } catch { + return ""; + } + } + + private async terminateProcess(sp: SubProcess): Promise { + const pid = sp.pid; + this.log.info("terminating subprocess", { pid }); + + const exited = new Promise((resolve) => { + sp.process.on("exit", () => resolve()); + }); + + sp.process.stdout?.removeAllListeners(); + sp.process.stderr?.removeAllListeners(); + + sp.process.kill("SIGINT"); + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("graceful shutdown timeout")), GRACEFUL_SHUTDOWN_MS), + ); + + try { + await Promise.race([exited, timeout]); + } catch { + this.log.warn("subprocess did not exit gracefully, sending SIGKILL", { + pid, + }); + sp.process.kill("SIGKILL"); + try { + await Promise.race([ + exited, + new Promise((_, reject) => + setTimeout(() => reject(new Error("SIGKILL timeout")), GRACEFUL_SHUTDOWN_MS), + ), + ]); + } catch { + this.log.warn("subprocess did not respond to SIGKILL", { pid }); + } + } + } + + private async removeLogFiles(sp: SubProcess): Promise { + await fs.promises.rm(sp.stdoutFileName, { force: true }); + await fs.promises.rm(sp.stderrFileName, { force: true }); + } } export default new SubProcessService(); diff --git a/gadget-drone/src/tools/system/index.ts b/gadget-drone/src/tools/system/index.ts index 57f450d..3ebc4b8 100644 --- a/gadget-drone/src/tools/system/index.ts +++ b/gadget-drone/src/tools/system/index.ts @@ -8,3 +8,4 @@ export { ShellExecTool } from "./shell.ts"; export { ListTool } from "./list.ts"; export { GrepTool } from "./grep.ts"; export { GlobTool } from "./glob.ts"; +export { SubprocessTool } from "./subprocess.ts"; diff --git a/gadget-drone/src/tools/system/subprocess.test.ts b/gadget-drone/src/tools/system/subprocess.test.ts new file mode 100644 index 0000000..72f22f6 --- /dev/null +++ b/gadget-drone/src/tools/system/subprocess.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IAiLogger } from "@gadget/ai"; +import { AiToolbox, type DroneToolboxEnvironment } from "../toolbox.ts"; +import { SubprocessTool } from "./subprocess.ts"; + +vi.mock("../../services/subprocess.ts", () => ({ + default: { + create: vi.fn(), + ps: vi.fn(), + killPid: vi.fn(), + killAll: vi.fn(), + haltPid: vi.fn(), + getLog: vi.fn(), + }, +})); + +const mockLogger: IAiLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +const env: DroneToolboxEnvironment = { + NODE_ENV: "test", + workspace: { + workspaceDir: "/tmp/workspace", + projectDir: "/tmp/workspace/test-project", + cacheDir: "/tmp/workspace/.gadget/cache", + }, +}; + +describe("SubprocessTool", () => { + let toolbox: AiToolbox; + let tool: SubprocessTool; + + beforeEach(() => { + toolbox = new AiToolbox(env); + tool = new SubprocessTool(toolbox); + vi.clearAllMocks(); + }); + + it("has the correct name and category", () => { + expect(tool.name).toBe("subprocess"); + expect(tool.category).toBe("system"); + }); + + it("defines the subprocess tool definition with all commands", () => { + const params = tool.definition.function.parameters as Record; + const cmd = params.properties?.cmd as Record | undefined; + const cmds: string[] = cmd?.enum ?? []; + + expect(tool.definition.type).toBe("function"); + expect(tool.definition.function.name).toBe("subprocess"); + expect(cmds).toContain("create"); + expect(cmds).toContain("list"); + expect(cmds).toContain("kill"); + expect(cmds).toContain("killAll"); + expect(cmds).toContain("halt"); + expect(cmds).toContain("log"); + }); + + it("returns error when cmd is missing", async () => { + const result = await tool.execute({}, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("cmd"); + }); + + it("returns error when cmd is invalid", async () => { + const result = await tool.execute({ cmd: "invalid" }, mockLogger); + expect(result).toContain("INVALID_PARAMETER"); + }); + + describe("cmd=create", () => { + it("returns error when command is missing", async () => { + const result = await tool.execute({ cmd: "create" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("command"); + }); + + it("calls SubProcessService.create with args", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.create as ReturnType).mockResolvedValue({ + pid: 42, + stdoutFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stdout.log", + stderrFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stderr.log", + }); + + const result = await tool.execute( + { cmd: "create", command: "node", args: ["server.js"] }, + mockLogger, + ); + + expect(SubProcessService.create).toHaveBeenCalledWith( + expect.objectContaining({ slug: "test-project" }), + "node", + ["server.js"], + ); + + expect(result).toContain("SUBPROCESS CREATED"); + expect(result).toContain("pid: 42"); + }); + + it("handles args as non-array gracefully", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.create as ReturnType).mockResolvedValue({ + pid: 7, + stdoutFileName: "/tmp/subproc-7.stdout.log", + stderrFileName: "/tmp/subproc-7.stderr.log", + }); + + const result = await tool.execute( + { cmd: "create", command: "echo", args: "hello" }, + mockLogger, + ); + + expect(result).toContain("pid: 7"); + }); + }); + + describe("cmd=list", () => { + it("returns empty list message when no processes", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.ps as ReturnType).mockReturnValue([]); + + const result = await tool.execute({ cmd: "list" }, mockLogger); + expect(result).toContain("(no running subprocesses)"); + }); + + it("lists running processes", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.ps as ReturnType).mockReturnValue([ + { + pid: 1, + project: { slug: "project-a" }, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-02"), + }, + { + pid: 2, + project: { slug: "project-b" }, + createdAt: new Date("2026-01-03"), + updatedAt: new Date("2026-01-04"), + }, + ]); + + const result = await tool.execute({ cmd: "list" }, mockLogger); + expect(result).toContain("SUBPROCESS LIST"); + expect(result).toContain("pid: 1"); + expect(result).toContain("pid: 2"); + expect(result).toContain("project-a"); + expect(result).toContain("project-b"); + }); + }); + + describe("cmd=kill", () => { + it("returns error when pid is missing", async () => { + const result = await tool.execute({ cmd: "kill" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pid"); + }); + + it("calls killPid and returns success", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.killPid as ReturnType).mockResolvedValue(true); + + const result = await tool.execute({ cmd: "kill", pid: 42 }, mockLogger); + expect(SubProcessService.killPid).toHaveBeenCalledWith(42); + expect(result).toContain("SUBPROCESS KILLED"); + expect(result).toContain("pid: 42"); + }); + + it("handles service errors", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.killPid as ReturnType).mockRejectedValue(new Error("not found")); + + const result = await tool.execute({ cmd: "kill", pid: 99 }, mockLogger); + expect(result).toContain("OPERATION_FAILED"); + }); + }); + + describe("cmd=killAll", () => { + it("calls killAll and returns success", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.killAll as ReturnType).mockResolvedValue(true); + + const result = await tool.execute({ cmd: "killAll" }, mockLogger); + expect(SubProcessService.killAll).toHaveBeenCalledOnce(); + expect(result).toContain("SUBPROCESS KILLALL"); + }); + }); + + describe("cmd=halt", () => { + it("returns error when pid is missing", async () => { + const result = await tool.execute({ cmd: "halt" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pid"); + }); + + it("calls haltPid and returns success", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.haltPid as ReturnType).mockResolvedValue(true); + + const result = await tool.execute({ cmd: "halt", pid: 42 }, mockLogger); + expect(SubProcessService.haltPid).toHaveBeenCalledWith(42); + expect(result).toContain("SUBPROCESS HALTED"); + expect(result).toContain("pid: 42"); + }); + }); + + describe("cmd=log", () => { + it("returns error when pid is missing", async () => { + const result = await tool.execute({ cmd: "log", which: "stdout" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pid"); + }); + + it("returns error when which is missing", async () => { + const result = await tool.execute({ cmd: "log", pid: 42 }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("which"); + }); + + it("returns error when which is invalid", async () => { + const result = await tool.execute({ cmd: "log", pid: 42, which: "invalid" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("which"); + }); + + it("reads stdout log content", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.getLog as ReturnType).mockResolvedValue("line1\nline2\nline3"); + + const result = await tool.execute( + { cmd: "log", pid: 42, which: "stdout" }, + mockLogger, + ); + + expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stdout"); + expect(result).toContain("SUBPROCESS LOG (stdout)"); + expect(result).toContain("pid: 42"); + expect(result).toContain("line1"); + expect(result).toContain("line2"); + expect(result).toContain("line3"); + }); + + it("reads stderr log content", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.getLog as ReturnType).mockResolvedValue("error: something broke"); + + const result = await tool.execute( + { cmd: "log", pid: 42, which: "stderr" }, + mockLogger, + ); + + expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stderr"); + expect(result).toContain("SUBPROCESS LOG (stderr)"); + expect(result).toContain("error: something broke"); + }); + }); +}); diff --git a/gadget-drone/src/tools/system/subprocess.ts b/gadget-drone/src/tools/system/subprocess.ts new file mode 100644 index 0000000..a4da928 --- /dev/null +++ b/gadget-drone/src/tools/system/subprocess.ts @@ -0,0 +1,315 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { formatError } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; +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 DroneTool { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`, + }); + } + } +} diff --git a/gadget-drone/vitest.config.ts b/gadget-drone/vitest.config.ts new file mode 100644 index 0000000..5e398e4 --- /dev/null +++ b/gadget-drone/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +});