Merge branch 'develop' of git.digitaltelepresence.com:rob/gadget into develop
This commit is contained in:
commit
96663f7c88
@ -6,6 +6,22 @@ The Landing Page project will be building a static website with a sales and mark
|
||||
|
||||
Gadget Code is free and open source software licensed under the Apache 2.0 open source license. There is no cost to obtain Gadget Code. We will be providing a link directly to our self-hosted gitea-backed server. It is not on GitHub. It prefers to stay away from GitHub.
|
||||
|
||||
## Landing Page Project
|
||||
|
||||
The landing page project will be named `gadget-public`, and will be added to the monorepo as a peer of `gadget-code` and `gadget-drone`. Eventually, the `gadget-extension` project will be migrated into this monorepo. And that will happen very soon.
|
||||
|
||||
For now, you will build the `gadget-public` project in the monorepo patterned after the [](../gadget-code/frontend/) project.
|
||||
|
||||
Take note of how dependencies are managed from the root directory using commands such as `pnpm --filter=gadget-code add [package] [package] ...`. We should strive for parity with the gadget-code:frontend in terms of look, feel, theme, presentation, design, and functionality. BUT, this is a static page. There is no sign-up/sign-in. People get the software product. And they will sign up/subscribe from within the application.
|
||||
|
||||
The Landing Page project is only leading people to the software. They will either want to install the Gadget browser extension, or install and configure Gadget Code. And yes, this Landing Page public project will host our documentation.
|
||||
|
||||
The site will have a placeholder for a video that the visitor can play. I have provided a [placeholder video file](./temp/gadget-home.mp4). You can move that file into place when ready for it. I'm using ./temp as a dropbox for you.
|
||||
|
||||
## Gadget (Browser Sidebar Extension)
|
||||
|
||||
The Gadget browser sidebar extension is documented in [Gadget Extension](./temp/gadget-design.md). Refer to that document for a complete description of the extension.
|
||||
|
||||
## The Status of GitHub and Why We Avoid It
|
||||
|
||||
GitHub is experiencing a severe platform reliability and security crisis in 2026, driven by a massive surge in AI agent traffic and infrastructure strain. Third-party monitoring services report that GitHub's actual uptime plummeted to roughly 90.21% over a 90-day window, experiencing 37 service incidents in February and 48 major outages between mid-2025 and April 2026. [1, 2, 3]
|
||||
|
||||
@ -14,12 +14,15 @@ This document serves as a "Cheat Sheet" for AI agents and developers working on
|
||||
|
||||
## 2. Event Map Overview
|
||||
|
||||
Defined in `packages/api/src/messages/socket.ts`.
|
||||
Defined in `packages/api/src/messages/socket.ts`. Additional types in `packages/api/src/messages/subprocess.ts`.
|
||||
|
||||
### IDE -> Web (Client to Server)
|
||||
* `requestSessionLock`: Request to exclusive-lock a drone for a project session.
|
||||
* `requestWorkspaceMode`: Request a mode change (Idle, User, Agent).
|
||||
* `submitPrompt`: Submit a user prompt for agent processing.
|
||||
* `requestProcessStats`: Request subprocess stats from a drone by registration ID (no session lock required).
|
||||
* `subscribeDrone`: Subscribe to live log/status events from a drone (used by DroneManager/DroneInspector).
|
||||
* `unsubscribeDrone`: Unsubscribe from live drone events.
|
||||
|
||||
### Drone -> Web (Client to Server)
|
||||
* `thinking`: Stream reasoning/thought process text.
|
||||
@ -33,9 +36,12 @@ Defined in `packages/api/src/messages/socket.ts`.
|
||||
* `processWorkOrder`: Command to start processing a specific prompt/turn.
|
||||
* `crashRecoveryResponse`: Command to `discard` or `retry` a stalled work order.
|
||||
* `requestTermination`: Command to immediately terminate the drone process.
|
||||
* `requestProcessStats`: Request subprocess stats from the drone (responds with `SubProcessStat[]` via callback).
|
||||
|
||||
### Web -> IDE (Server to Client)
|
||||
* `sessionUpdated`: Notify the IDE that a chat session property has changed (e.g. auto-generated name).
|
||||
* `drone:log`: Broadcast log entry from a drone to monitoring IDE sessions (DroneManager, DroneInspector).
|
||||
* `drone:status`: Broadcast status update from a drone to monitoring IDE sessions.
|
||||
|
||||
---
|
||||
|
||||
@ -71,7 +77,25 @@ Defined in `packages/api/src/messages/socket.ts`.
|
||||
* Forwards event to **IDE**.
|
||||
* Clears `currentTurnId` from the drone session.
|
||||
|
||||
### 3.4 Drone Termination Flow
|
||||
### 3.4 SubProcess Monitoring Flow
|
||||
|
||||
1. **IDE** selects a drone in DroneManager or DroneInspector.
|
||||
2. **IDE** emits `subscribeDrone(registrationId)` → backend registers socket in `SocketService.droneMonitorIndex`.
|
||||
3. **IDE** starts a 1-second `setInterval` emitting `requestProcessStats(registrationId, cb)`.
|
||||
4. **Web (`CodeSession.ts`)**:
|
||||
* Looks up `DroneSession` via `SocketService.getDroneSessionByRegistrationId()` (no chat session required).
|
||||
* Forwards to drone: `droneSession.socket.emit("requestProcessStats", cb)`.
|
||||
5. **Drone (`gadget-drone.ts`)**:
|
||||
* Calls `SubProcessService.ps()` → `summarize()` → builds `SubProcessStat[]`.
|
||||
* Calls callback `cb(true, { processes })`.
|
||||
6. **IDE** receives `SubProcessStat[]` → renders `<SubProcessTable>` + updates process status.
|
||||
7. **Log streaming** (automatic, no polling):
|
||||
* Drone emits `log(timestamp, component, level, message)` as usual.
|
||||
* `DroneSession.onLog()` also broadcasts `drone:log` to all monitors via `SocketService.broadcastToMonitors()`.
|
||||
* IDE receives `drone:log` events → appends to log state → renders via `<LogRenderer>`.
|
||||
8. **Cleanup**: IDE emits `unsubscribeDrone(registrationId)` on unmount/deselect; interval cleared.
|
||||
|
||||
### 3.5 Drone Termination Flow
|
||||
1. **User** clicks "Terminate" button in Drone Manager UI.
|
||||
2. **IDE** calls `POST /api/v1/drone/registration/:id/terminate`.
|
||||
3. **Web (`DroneService.ts`)**:
|
||||
@ -151,6 +175,60 @@ type RequestTerminationMessage = (
|
||||
) => void;
|
||||
```
|
||||
|
||||
### Drone Monitor Events (Phase 2)
|
||||
```typescript
|
||||
// Defined in packages/api/src/messages/subprocess.ts
|
||||
interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
// IDE -> Web (request/response with callback)
|
||||
type RequestProcessStatsMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => void
|
||||
) => void;
|
||||
|
||||
// Web -> Drone
|
||||
type RequestProcessStatsMessageDrone = (
|
||||
cb: (success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => void
|
||||
) => void;
|
||||
|
||||
// Monitor subscribe/unsubscribe
|
||||
type SubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void
|
||||
) => void;
|
||||
|
||||
type UnsubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void
|
||||
) => void;
|
||||
|
||||
// Web -> IDE (broadcast)
|
||||
type DroneLogBroadcast = (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
|
||||
type DroneStatusBroadcast = (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Session Implementation Guide (Web Server)
|
||||
@ -177,6 +255,9 @@ The `SocketService` maintains multiple indexes for efficient session lookup:
|
||||
3. **`codeSessions`**: Map<socket.id, CodeSession> - Primary storage by socket ID
|
||||
4. **`codeSessionUserIndex`**: Map<user._id, CodeSession> - Lookup by user ID
|
||||
5. **`chatSessionIndex`**: Map<chatSessionId, CodeSession> - Reverse lookup from chat session to IDE
|
||||
6. **`droneMonitorIndex`**: Map<registration._id, Set<socket.id>> - Monitor subscriptions for log/status broadcast (Phase 2)
|
||||
|
||||
The `droneMonitorIndex` is maintained independently of chat sessions — it enables the DroneManager and DroneInspector to receive live log and status events from a drone without requiring a session lock. Cleanup happens automatically on socket disconnect.
|
||||
|
||||
All indexes are kept in sync during connection and disconnection.
|
||||
|
||||
@ -197,13 +278,19 @@ All indexes are kept in sync during connection and disconnection.
|
||||
## 7. Extending the Protocol
|
||||
|
||||
To add a new message:
|
||||
1. Add the message type to `packages/api/src/messages/ide.ts`, `drone.ts`, or `web.ts`.
|
||||
1. Add the message type to `packages/api/src/messages/ide.ts`, `drone.ts`, `web.ts`, or **create a new file** for a related group of types (e.g. `subprocess.ts`).
|
||||
2. Register it in `ClientToServerEvents` or `ServerToClientEvents` in `packages/api/src/messages/socket.ts`.
|
||||
3. Re-export from `packages/api/src/index.ts`.
|
||||
4. Implement the sender (emit) in the Client (`ide` or `drone`) or Server (`CodeSession`/`DroneSession`).
|
||||
5. Implement the handler in the corresponding class or frontend component.
|
||||
6. Implement the forward-path routing if needed.
|
||||
|
||||
### Patterns
|
||||
|
||||
**Request/Response (callback chain):** For "ask and answer" operations (file reads, process stats), use Socket.IO's built-in acknowledgement callback. The client emits with a callback function; the server receives it and calls the callback with the response. The backend proxies both directions.
|
||||
|
||||
**Subscribe/Broadcast:** For streaming events (logs, status), use a subscribe/unsubscribe pattern. The IDE registers interest in a drone's events via `subscribeDrone`. The backend maintains a monitor index and broadcasts events to all subscribers as they arrive from the drone. No polling needed for logs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Reconnection & Message Queuing
|
||||
|
||||
117
docs/temp/gadget-design.md
Normal file
117
docs/temp/gadget-design.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Gadget
|
||||
|
||||
An AI-fueled browser extension and web application for the next generation of social media.
|
||||
|
||||
## Overview
|
||||
|
||||
Gadget is a browser extension - and web application - that implements a chat with an AI agent named Gadget.
|
||||
|
||||
Gadget, by calling tools, will help the user use the web. Gadget will be able to navigate to URls either in a new tab or the current one. Gadget will be able to read the contents of a web page or snap a screenshot to view an image, etc., to help summarize the contents, help the user understand the contents, find related content, etc. Gadget will, on some sites, be able to fill out forms for the User (if they implement the Gadget interface), and assist the user with basic online tasks and automations.
|
||||
|
||||
This project is only at the proof-of-concept stage, and we are just getting started now. A backend microservice will be created built in TypeScript on NodeJS using ExpressJS, Socket.io, MongoDB, Qdrant (vector database) and the [ollama](https://www.npmjs.com/package/ollama) API. The front-end will POST chat messages from the sidebar browser extension, and receive streaming responses for the display to the user in the scrolling message list in the sidebar extension.
|
||||
|
||||
We are only concerned with supporting the Google Chromium browser, but we will be using the [Vite](https://vitejs.dev/) build tool for both projects to make them as lightweight and performant as possible. We will also use ReactJS for the front-end projects, and TailwindCSS for styling, sharing as much as possible in ./src/lib when appropriate.
|
||||
|
||||
## Sidebar Chat
|
||||
|
||||
The design of the sidebar chat component is deceptively basic at first, and by default. Complexity lives in slide-out panels. All the user sees normally is a header bar, scrolling chat message list (with styled scrollbar), and their input bar at the bottom.
|
||||
|
||||
The header of the sidebar is 24px in height, and will present the Gadget icon (opens gadget.gab.com in a named "\_gadget" tab). The Gadget icon is followed a basic menu of simple selections that will be square icons.
|
||||
|
||||
To interact with Gadget, people generally don't use a "menu" or click-style actions. They enter a chat message, ask questions, or just tell Gadget the settings they want. "Gadget, please change to dark mode," will do that. The chat message is sent to the backend, where it is passed to the Ollama `chat` API for processing and for Tool calls per the Ollama API standard. Socket.io is used to stream the response back to the sidebar widget for display in real-time. As thinking messages arrive, their content is added to the "Thinking..." region of the current turn. As response messages arrive, they are added to the Response area of the current turn.
|
||||
|
||||
What the user can expect Gadget to be able to do for them depends on whether they are accessing the mobile or Desktop version. The mobile version is NOT a browser extension. It is just a web page, installable as an app, that the User can open to FETCH information and have conversations with Gadget. Gadget will not, however, be able to control their browser and provide browser-based automations when being accessed from the mobile interface.
|
||||
|
||||
## Turns
|
||||
|
||||
A turn is a "block" in the scrolling chat message list, and will contain the following information:
|
||||
|
||||
- User's prompt (chat input text)
|
||||
- LLM's thinking messages in a Thinking block (expandable/collapsable, default collapsed)
|
||||
- LLM's response messages in a Response block
|
||||
- Tool calls in a Tools block (collapsed by default, opens the Tool Shelf slide-out panel when selected)
|
||||
|
||||
Turns are the history of the chat. They are stored on the server. `/api/v1/chat/:sessionId` is their endpoint. The User will POST chat input to `/api/v1/chat/:sessionId` to send a chat message, and the response will be streamed to them over Socket.io to a channel named for the chat session ID that the User joins when connecting to that chat session.
|
||||
|
||||
## Sessions
|
||||
|
||||
The first icon after the Gadget icon in the header bar is the Sessions icon. This will toggle open the Session panel, where the User can scroll through their sessions with Gadget, and search them. These are all endpoints at and under `/api/v1/chat`.
|
||||
|
||||
Justified right in the header is a + plus icon, which creates a new session and immediate connects to it by POSTing to `/api/v1/chat` specifying session type of `mobile` (app), `desktop` (app), or `extension` (browser extension), to indicate which experience and set of capabilities is to be delivered to the User.
|
||||
|
||||
This will control the selection of separate system prompts, which are defined in `./data/prompts/gadget/system.desktop.md` and `./data/prompts/gadget/system.mobile.md` and `./data/prompts/gadget/system.extension.md`. A starter system prompt is provided in each of those files to get the project started. They are already being loaded in the AgentService when building the Agent's system prompt, merging with runtime information, etc.
|
||||
|
||||
The `Session` object (Mongoose Model, etc.) **is** the session context. The `ChatMessage` model will have a `session` field that references the `Session` by `Session._id`.
|
||||
|
||||
## Pinboard
|
||||
|
||||
The Pinboard is a persistent note-taking mechanism within a chat session. It allows the LLM agent (Gadget) to store and remove notes directly in the session context/system prompt, giving the model a way to edit its own context and maintain important information across turns without relying on conversation history.
|
||||
|
||||
### How It Works
|
||||
|
||||
- Pins are stored as a `pins` array on the `ChatSession` model, each with an auto-generated `_id` and `content` string
|
||||
- When building the system prompt for each chat turn, all pins are appended under a `## PINBOARD` section
|
||||
- The agent uses two tools to manage pins:
|
||||
- `pin_add`: Adds a new pin with text content
|
||||
- `pin_remove`: Removes a pin by its `_id`
|
||||
- Total pinboard content is limited to 8192 characters across all pins. When the limit is reached, the agent must remove existing pins before adding new ones
|
||||
|
||||
### Why It Matters
|
||||
|
||||
The pinboard gives the agent agency over its own context. Instead of the agent having to re-read conversation history or repeat important information in every response, it can pin key facts — user preferences, task specifications, research findings, URLs — and have them automatically included in the system prompt for every subsequent turn. This is more efficient than writing notes in response text, keeps responses clean, and ensures critical context is always available.
|
||||
|
||||
### Implementation
|
||||
|
||||
- Model: `IChatSessionPin` subdocument schema with `_id` and `content` fields (`src/models/chat-session.ts`)
|
||||
- Tools: `PinAddTool` and `PinRemoveTool` in `src/tools/storage/`
|
||||
- System prompt: Pins appended in `buildAgentSystemPrompt()` in `src/services/agent.ts`
|
||||
- Prompts: Pinboard section added to all three system prompts (`data/prompts/gadget/system.*.md`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
Generally, the client projects will call into the backend project to receive backend data and information services such as AI inference over HTTP. The backend will usually respond quickly with an asynchronous HTTP 200 indicating, "I got your chat input." It will then create a Bull Queue job, and submit the session (which contains the User) and prompt as job data to the `gadget-chat` job queue.
|
||||
|
||||
A worker process will pick up the job, and perform the AI inference. The response is streamed in real-time back to the User over Socket.io, where it is added to the current turn in the chat message list in the appropriate areas of the current processing turn. Thinking messages go into the Thinking... area. Response messages append to the Response area. Tool calls (their parameters and response, errors included) are added to the Tools slide-out panel.
|
||||
|
||||
### Common
|
||||
|
||||
./src/lib - common code to be shared by all, such as database/redis connections, the Ollama API instance, etc.
|
||||
|
||||
### Backend
|
||||
|
||||
./src/models - the Mongoose TypeScript interface, schema, and model definitions/implementations
|
||||
./src/services - the service layer for the application
|
||||
./src/tools - Ollama API Tool definitions, and their implementations
|
||||
./src/controllers - the ExpressJS controllers that implement the HTTP interface
|
||||
./src/workers - the background workers that perform tasks such as AI inference, etc.
|
||||
|
||||
./src/web-app.ts - the main web app/backend server
|
||||
./src/web-cli.ts - the command-line interface for performing low-level admin functions
|
||||
|
||||
### Front-End (HTML5 PWA/SPA)
|
||||
|
||||
./src/client - the ReactJS/Tailwind/Vite client used to build the browser application (PWA/SPA)
|
||||
./src/extension - the ReactJS/Tailwind/Vite client project used to build the browser sidebar extension
|
||||
|
||||
These are separate projects that let us implement the separate concerns of the Desktop App and the Mobile app with better focus and results. They can share common logic and interfaces through `./src/lib`, but can then implement their own UI/UX components and make full use of the device they are running on.
|
||||
|
||||
#### Backend
|
||||
|
||||
- NodeJS
|
||||
- MongoDB
|
||||
- Redis
|
||||
- Qdrant
|
||||
- TypeScript
|
||||
- ExpressJS
|
||||
- Socket.io
|
||||
- Ollama API
|
||||
- BullMQ
|
||||
- Mongoose
|
||||
- tsx
|
||||
- Vite
|
||||
- Tailwind
|
||||
- ReactJS
|
||||
- Jest 26+
|
||||
- SuperTest 5+
|
||||
- ESLint 8+
|
||||
- Prettier 2+
|
||||
@ -36,6 +36,8 @@ You must remain within the project directory, which is the current working direc
|
||||
|
||||
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
You always provide regular updates to explain your thinking and reasoning while working and calling tools. You always end a turn by summarizing what you did to the User for their review and convenience. When the user sends you a prompt:
|
||||
|
||||
@ -32,6 +32,8 @@ Use your tools proactively. When working on the Gadget Code codebase, immediatel
|
||||
|
||||
NOTICE: IF YOU EXPERIENCE DIFFICULTY USING ANY TOOLS OR RECEIVE A RESPONSE THAT IS UNEXPECTED OR SEEMS ERRONEOUS (TOOL MALFUNCTION), PLEASE **IMMEDIATELY** DOCUMENT WHAT THE TOOL DID THAT YOU DIDN'T EXPECT - AND STOP. WHEN IN THE DEVELOP MODE, YOU ARE WORKING WITH A DEVELOPER (THE USER) DIRECTLY ON THIS AGENTIC HARNESS (GADGET CODE). WE MAY NEED TO DEBUG OR DIAGNOSE A PROBLEM WITH A TOOL AS WE WORK.
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
Work in a loop through the User's request for this turn, resolving the work items that need done until finished, explaining your thinking and reasoning while calling tools and doing your work.
|
||||
|
||||
@ -40,6 +40,8 @@ Don't announce tool usage, just execute and use findings to inform your planning
|
||||
|
||||
{{tool_block}}
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
When the user sends you a prompt:
|
||||
|
||||
@ -22,6 +22,8 @@ Use your tools decisively. Run tests, check builds, verify deployments, and exec
|
||||
|
||||
{{tool_block}}
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
When the user sends you a prompt:
|
||||
|
||||
@ -22,6 +22,8 @@ Use your tools aggressively for testing. Run tests frequently, read test files,
|
||||
|
||||
{{tool_block}}
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
When the user sends you a prompt:
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
## PROCESS MANAGEMENT
|
||||
|
||||
The `subprocess` tool is for managing subprocesses related to the current project. Your project may produce servers and other background processes that should be started, stopped, and managed differently than what `shell_cmd` is designed for.
|
||||
|
||||
You can create, list, and kill child processes using the `subprocess` tool. You can also read their `stdout` and `stderr` logs, which are being written to disk (and managed) as a convenience. When you stop a process, it's log file is closed and removed.
|
||||
|
||||
If you want to stop a process _without_ removing the log, you use the `halt` subprocess command (cmd=halt, pid=###). You can then examine the logs. When you are done examining the logs, you use [cmd=kill, pid=###] to remove the process entry and logs.
|
||||
@ -140,6 +140,169 @@ Components:
|
||||
|
||||
Implementation: `frontend/src/pages/Home.tsx` - DashboardSidebar component
|
||||
|
||||
### Drone Inspector (Dashboard Inline)
|
||||
|
||||
When a user clicks a drone in the sidebar's Drones list, the main content area switches to the **Drone Inspector**:
|
||||
|
||||
```
|
||||
+-----------------------------------------------------+
|
||||
| Drone Inspector ← Back to |
|
||||
| Dashboard |
|
||||
+-----------------------------------------------------+
|
||||
| +-------------------------------------------------+ |
|
||||
| | Hostname | |
|
||||
| | drone-alpha (mono) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Workspace | |
|
||||
| | /path/to/workspace (mono) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Status | |
|
||||
| | ● available (or ● busy, ● offline) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Registered | |
|
||||
| | 5/14/2026, 10:00:00 AM | |
|
||||
| +-------------------------------------------------+ |
|
||||
+-----------------------------------------------------+
|
||||
```
|
||||
|
||||
Implementation: `frontend/src/pages/Home.tsx` - DroneInspector component (lines 36-93)
|
||||
|
||||
The Drone Inspector is a simple read-only card view. For full drone operations (terminate, logs, monitoring), users navigate to the Drone Manager at `/drones` via the gear icon in the Drones sidebar header.
|
||||
|
||||
## Drone Manager View
|
||||
|
||||
Route: `/drones`
|
||||
|
||||
The Drone Manager is a full-page view for detailed drone inspection and operations. It replaces the main content area entirely.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
+----------------------------------+----------------------------------------+
|
||||
| Drone Manager | Drone Details [Terminate] |
|
||||
+----------------------------------+----------------------------------------+
|
||||
| Online Drones (N) | +-----------+ +-----------+ |
|
||||
| | | Hostname | | Status | |
|
||||
| +----------------------------+ | | drone-1 | | ● busy | |
|
||||
| | ● drone-1 available | | +-----------+ +-----------+ |
|
||||
| | /path/to/workspace | | +-----------+ +-----------+ |
|
||||
| +----------------------------+ | | Workspace | | Registered| |
|
||||
| +----------------------------+ | | /path/... | | 5/14/2026 | |
|
||||
| | ● drone-2 busy | | +-----------+ +-----------+ |
|
||||
| | /path/to/workspace | | |
|
||||
| +----------------------------+ | Drone Monitor |
|
||||
| | +--------------------------------------+ |
|
||||
| Offline Drones (N) | | Monitor charts coming soon. | |
|
||||
| | | Memory usage, AI operations, and log | |
|
||||
| +----------------------------+ | | production metrics will be displayed | |
|
||||
| | ○ drone-3 offline | | | here. | |
|
||||
| | /path/to/workspace | | +--------------------------------------+ |
|
||||
| +----------------------------+ | |
|
||||
| | Drone Log (Live) |
|
||||
| | +--------------------------------------+ |
|
||||
| | | [10:00:01] [PLACEHOLDER] Log entry 1 | |
|
||||
| | | [10:00:02] [PLACEHOLDER] Log entry 2 | |
|
||||
| | | [10:00:03] [PLACEHOLDER] Log entry 3 | |
|
||||
| | | ... | |
|
||||
| | +--------------------------------------+ |
|
||||
+----------------------------------+----------------------------------------+
|
||||
```
|
||||
|
||||
Current features (Phase 1):
|
||||
|
||||
- **Drone list** (left sidebar): Split into Online and Offline sections. Each list item shows status dot, hostname, and workspace path. Click to inspect.
|
||||
- **Drone details** (right panel, top): 2x2 grid showing hostname, status with dot, workspace, registration date. Terminate button (red) for non-offline drones.
|
||||
- **Drone Monitor** (right panel, middle): Placeholder for future monitoring charts.
|
||||
- **Drone Log** (right panel, bottom): Collapsible panel with auto-scrolling log viewer. Log entries show timestamp and message. Auto-scroll pauses when user scrolls up, resumes when scrolled to bottom. Max 200 entries shown. Placeholder entries currently used, awaiting live log streaming.
|
||||
|
||||
Implementation: `frontend/src/pages/DroneManager.tsx`
|
||||
|
||||
### Phase 2: SubProcess Monitor (Complete)
|
||||
|
||||
Phase 2 was implemented on the `feature/process-management` branch. The Drone Manager and Drone Inspector now show live SubProcess data and drone logs via Socket.IO.
|
||||
|
||||
#### Socket Protocol
|
||||
|
||||
Three new socket events added (defined in `packages/api/src/messages/subprocess.ts`):
|
||||
|
||||
| Event | Direction | Purpose | Mechanism |
|
||||
|-------|-----------|---------|-----------|
|
||||
| `requestProcessStats` | IDE → Backend → Drone | Request typed subprocess list | Callback-chain (like `fileTreeRequest`) |
|
||||
| `subscribeDrone` | IDE → Backend | Subscribe to live drone events (log, status) | Registers socket in monitor index |
|
||||
| `unsubscribeDrone` | IDE → Backend | Unsubscribe from live drone events | Removes socket from monitor index |
|
||||
|
||||
Broadcast events (backend → IDE, no request needed):
|
||||
|
||||
| Event | Payload | When |
|
||||
|-------|---------|------|
|
||||
| `drone:log` | `{ timestamp, level, component, message, metadata? }` | On every drone `log` emission |
|
||||
| `drone:status` | `{ timestamp, message }` | On every drone `status` emission |
|
||||
|
||||
#### SubProcess Table
|
||||
|
||||
Rendered by `frontend/src/components/SubProcessTable.tsx`. Features:
|
||||
- Monospace table: PID, CMD, PROJECT, STATUS, UPDATED
|
||||
- Status dots: green for running, yellow for halted, red for error
|
||||
- Clickable rows with selection highlight
|
||||
- Header shows running count: "SubProcess Monitor (3 running)"
|
||||
- Empty state: "No managed subprocesses. The agent has not spawned any processes for this session."
|
||||
|
||||
#### Resource Gauges (Canvas)
|
||||
|
||||
Rendered by `frontend/src/components/DroneMonitor.tsx` + `DroneMonitorGauge.tsx`:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ CPU │ │ NETWORK │ │ FILE I/O │
|
||||
│ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │
|
||||
│ 42% │ │ 27% │ │ 15% │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
- Canvas-based waveform with glow effect (studio/scientific equipment aesthetic)
|
||||
- Brand red for CPU, cyan for NETWORK, green for FILE I/O
|
||||
- Grid lines, numeric readout, gradient fill under trace
|
||||
- Data is currently simulated (random walk) — placeholder for real metrics
|
||||
|
||||
#### Live Log Streaming
|
||||
|
||||
The Drone Log viewer now receives real log events via socket instead of placeholders:
|
||||
- Log events flow: `drone` → `DroneSession.onLog()` → broadcast to monitor sessions via `drone:log` event
|
||||
- Uses the same `LogRenderer` component as the ChatSession `LogPanel` (refactored in Phase 2)
|
||||
- Auto-scroll with pause-on-scroll-up behavior preserved
|
||||
- Max 200 entries maintained
|
||||
|
||||
#### DroneInspector (Dashboard)
|
||||
|
||||
The inline Drone Inspector in the Home page (`frontend/src/pages/Home.tsx`):
|
||||
- Shows SubProcess count summary ("3 running" or "No managed processes")
|
||||
- Contains a compact `LogRenderer` (last 50 entries, max-h-48)
|
||||
- "Open in Drone Manager →" link navigates to `/drones` with route state for auto-selection
|
||||
|
||||
#### Polling Behavior
|
||||
|
||||
- `requestProcessStats` polled every **1 second** while a drone is selected (both DroneManager and DroneInspector)
|
||||
- Subscribe/unsubscribe lifecycle tied to component mount/unmount
|
||||
- All timers and socket listeners cleaned up on unmount or drone deselection
|
||||
|
||||
#### Supporting Components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `SubProcessTable` | `frontend/src/components/SubProcessTable.tsx` | Process list table |
|
||||
| `DroneMonitor` | `frontend/src/components/DroneMonitor.tsx` | 3-gauge resource monitor container |
|
||||
| `DroneMonitorGauge` | `frontend/src/components/DroneMonitorGauge.tsx` | Canvas waveform gauge |
|
||||
| `LogRenderer` | `frontend/src/components/LogRenderer.tsx` | Reusable log rendering core |
|
||||
|
||||
#### Backend Routing
|
||||
|
||||
- `SocketService.addDroneMonitor()` / `removeDroneMonitor()` — manages monitor index (`Map<registrationId, Set<socketId>>`)
|
||||
- `SocketService.broadcastToMonitors()` — broadcasts events to all monitoring sockets
|
||||
- `SocketService.getDroneSessionByRegistrationId()` — lookup for non-chat-session drone routing
|
||||
- `DroneSession.onLog()` / `onStatus()` — extended to broadcast to monitors after chat-session routing
|
||||
- `CodeSession.onRequestProcessStats()` — proxies to drone with callback
|
||||
- `CodeSession.onSubscribeDrone()` / `onUnsubscribeDrone()` — registers/unregisters monitor
|
||||
|
||||
## Project Manager View
|
||||
|
||||
The Project Manager presents:
|
||||
|
||||
83
gadget-code/frontend/src/components/DroneMonitor.tsx
Normal file
83
gadget-code/frontend/src/components/DroneMonitor.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import DroneMonitorGauge from './DroneMonitorGauge';
|
||||
|
||||
interface DroneMonitorProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function generateWave(base: number, variance: number, length: number): number[] {
|
||||
const wave: number[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
wave.push(base + Math.sin(i * 0.1) * variance + (Math.random() - 0.5) * variance * 0.5);
|
||||
}
|
||||
return wave;
|
||||
}
|
||||
|
||||
export default function DroneMonitor({ visible }: DroneMonitorProps) {
|
||||
const [cpuData, setCpuData] = useState<number[]>(() => generateWave(35, 20, 60));
|
||||
const [netData, setNetData] = useState<number[]>(() => generateWave(20, 15, 60));
|
||||
const [ioData, setIoData] = useState<number[]>(() => generateWave(10, 10, 60));
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCpuData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 10))];
|
||||
return next;
|
||||
});
|
||||
setNetData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 8))];
|
||||
return next;
|
||||
});
|
||||
setIoData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 6))];
|
||||
return next;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
|
||||
<div className="p-2 border-b border-border-subtle bg-bg-tertiary">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Resource Monitor
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex gap-1 p-1 justify-center">
|
||||
<DroneMonitorGauge
|
||||
label="CPU"
|
||||
data={cpuData}
|
||||
unit="%"
|
||||
color="#c20600"
|
||||
/>
|
||||
<DroneMonitorGauge
|
||||
label="NETWORK"
|
||||
data={netData}
|
||||
unit="%"
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<DroneMonitorGauge
|
||||
label="FILE I/O"
|
||||
data={ioData}
|
||||
unit="%"
|
||||
color="#22c55e"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
gadget-code/frontend/src/components/DroneMonitorGauge.tsx
Normal file
116
gadget-code/frontend/src/components/DroneMonitorGauge.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface DroneMonitorGaugeProps {
|
||||
label: string;
|
||||
data: number[];
|
||||
unit: string;
|
||||
color: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function DroneMonitorGauge({
|
||||
label,
|
||||
data,
|
||||
unit,
|
||||
color,
|
||||
width = 320,
|
||||
height = 160,
|
||||
}: DroneMonitorGaugeProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#1a1a1a';
|
||||
ctx.lineWidth = 1;
|
||||
for (let y = 0; y < 4; y++) {
|
||||
const yy = (height / 4) * y;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, yy);
|
||||
ctx.lineTo(width, yy);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Border frame — sharp edge like studio equipment
|
||||
ctx.strokeStyle = '#2a2a2a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
// Plot the waveform
|
||||
const padX = 8;
|
||||
const padY = 8;
|
||||
const plotW = width - padX * 2;
|
||||
const plotH = height - padY * 2;
|
||||
const maxVal = 100;
|
||||
|
||||
// Gradient fill under the trace
|
||||
const gradient = ctx.createLinearGradient(0, padY, 0, padY + plotH);
|
||||
gradient.addColorStop(0, color + '40');
|
||||
gradient.addColorStop(1, color + '05');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padX, padY + plotH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padX + (i / (data.length - 1)) * plotW;
|
||||
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padX + plotW, padY + plotH);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Trace line with glow
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padX + (i / (data.length - 1)) * plotW;
|
||||
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Current value readout
|
||||
const latest = data[data.length - 1] ?? 0;
|
||||
ctx.fillStyle = '#d4d4d4';
|
||||
ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${Math.round(latest)}${unit}`, width / 2, height - 12);
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#737373';
|
||||
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, width / 2, 16);
|
||||
}, [data, label, unit, color, width, height]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width, height }}
|
||||
className="block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import LogRenderer from './LogRenderer';
|
||||
import type { LogRendererEntry } from './LogRenderer';
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
@ -15,48 +17,8 @@ interface LogPanelProps {
|
||||
onToggleExpand: () => void;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'text-gray-500',
|
||||
info: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
alert: 'text-red-400',
|
||||
error: 'bg-red-800 text-white',
|
||||
crit: 'bg-red-800 text-yellow-300',
|
||||
fatal: 'bg-red-800 text-gray-400',
|
||||
};
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const toggleMetadata = (id: string) => {
|
||||
setExpandedMetadata(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -76,50 +38,7 @@ export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelPro
|
||||
{expanded ? '▾' : '▴'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const levelClass = levelColors[entry.level] || 'text-text-secondary';
|
||||
return (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
|
||||
<span className="text-gray-600 shrink-0 select-none">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`shrink-0 font-bold ${levelClass}`}>
|
||||
{entry.level.toUpperCase().padEnd(5)}
|
||||
</span>
|
||||
<span className="text-cyan-400 shrink-0">
|
||||
{entry.component}
|
||||
</span>
|
||||
<span className="text-text-secondary break-all min-w-0">
|
||||
{entry.message}
|
||||
</span>
|
||||
{entry.metadata != null && (
|
||||
<button
|
||||
onClick={() => toggleMetadata(entry.id)}
|
||||
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
|
||||
>
|
||||
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
|
||||
</button>
|
||||
)}
|
||||
{entry.metadata != null && expandedMetadata.has(entry.id) && (
|
||||
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
|
||||
<pre className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(entry.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<LogRenderer ref={scrollRef} logs={logs as LogRendererEntry[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
114
gadget-code/frontend/src/components/LogRenderer.tsx
Normal file
114
gadget-code/frontend/src/components/LogRenderer.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
|
||||
export interface LogRendererEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
interface LogRendererProps {
|
||||
logs: LogRendererEntry[];
|
||||
maxEntries?: number;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'text-gray-500',
|
||||
info: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
alert: 'text-red-400',
|
||||
error: 'bg-red-800 text-white',
|
||||
crit: 'bg-red-800 text-yellow-300',
|
||||
fatal: 'bg-red-800 text-gray-400',
|
||||
};
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
const LogRenderer = forwardRef<HTMLDivElement, LogRendererProps>(
|
||||
({ logs, containerClassName }, ref) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const target = ref && 'current' in ref ? ref : scrollRef;
|
||||
if (target.current) {
|
||||
target.current.scrollTop = target.current.scrollHeight;
|
||||
}
|
||||
}, [logs, ref]);
|
||||
|
||||
const toggleMetadata = (id: string) => {
|
||||
setExpandedMetadata(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref || scrollRef}
|
||||
className={`flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed ${containerClassName ?? ''}`}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const levelClass = levelColors[entry.level] || 'text-text-secondary';
|
||||
return (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
|
||||
<span className="text-gray-600 shrink-0 select-none">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`shrink-0 font-bold ${levelClass}`}>
|
||||
{entry.level.toUpperCase().padEnd(5)}
|
||||
</span>
|
||||
<span className="text-cyan-400 shrink-0">
|
||||
{entry.component}
|
||||
</span>
|
||||
<span className="text-text-secondary break-all min-w-0">
|
||||
{entry.message}
|
||||
</span>
|
||||
{entry.metadata != null && (
|
||||
<button
|
||||
onClick={() => toggleMetadata(entry.id)}
|
||||
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
|
||||
>
|
||||
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
|
||||
</button>
|
||||
)}
|
||||
{entry.metadata != null && expandedMetadata.has(entry.id) && (
|
||||
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
|
||||
<pre className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(entry.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LogRenderer.displayName = 'LogRenderer';
|
||||
|
||||
export default LogRenderer;
|
||||
84
gadget-code/frontend/src/components/SubProcessTable.tsx
Normal file
84
gadget-code/frontend/src/components/SubProcessTable.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import type { SubProcessStat } from '../lib/api';
|
||||
|
||||
interface SubProcessTableProps {
|
||||
processes: SubProcessStat[];
|
||||
onSelect?: (pid: number) => void;
|
||||
selectedPid?: number | null;
|
||||
}
|
||||
|
||||
const statusDot: Record<string, string> = {
|
||||
running: 'bg-green-500',
|
||||
stopped: 'bg-yellow-500',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
running: '● running',
|
||||
stopped: '● halted',
|
||||
error: '● error',
|
||||
};
|
||||
|
||||
export default function SubProcessTable({ processes, onSelect, selectedPid }: SubProcessTableProps) {
|
||||
if (processes.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded text-center">
|
||||
<p className="text-text-muted">No managed subprocesses.</p>
|
||||
<p className="text-text-muted text-sm mt-1">
|
||||
The agent has not spawned any processes for this session.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runningCount = processes.filter((p) => p.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
|
||||
<div className="p-2 border-b border-border-subtle bg-bg-tertiary flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
SubProcess Monitor ({runningCount} running)
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">{processes.length} total</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-text-muted">
|
||||
<th className="text-left p-2 font-semibold">PID</th>
|
||||
<th className="text-left p-2 font-semibold">CMD</th>
|
||||
<th className="text-left p-2 font-semibold">PROJECT</th>
|
||||
<th className="text-left p-2 font-semibold">STATUS</th>
|
||||
<th className="text-right p-2 font-semibold">UPDATED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processes.map((proc) => (
|
||||
<tr
|
||||
key={proc.pid}
|
||||
onClick={() => onSelect?.(proc.pid)}
|
||||
className={`border-b border-border-subtle last:border-0 transition-colors ${
|
||||
selectedPid === proc.pid
|
||||
? 'bg-bg-elevated'
|
||||
: onSelect
|
||||
? 'cursor-pointer hover:bg-bg-elevated/50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="p-2 text-text-primary">{proc.pid}</td>
|
||||
<td className="p-2 text-text-primary">{proc.command}</td>
|
||||
<td className="p-2 text-text-secondary">{proc.projectSlug}</td>
|
||||
<td className="p-2">
|
||||
<span className={`${statusDot[proc.status] ?? 'bg-gray-500'} inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle`} />
|
||||
<span className="text-text-secondary align-middle">{statusLabel[proc.status] ?? proc.status}</span>
|
||||
</td>
|
||||
<td className="p-2 text-text-muted text-right whitespace-nowrap">
|
||||
{new Date(proc.updatedAt).toLocaleTimeString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -258,6 +258,20 @@ export const projectApi = {
|
||||
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||
};
|
||||
|
||||
export interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: string;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
export interface DroneRegistration {
|
||||
_id: string;
|
||||
hostname: string;
|
||||
|
||||
@ -135,6 +135,17 @@ export interface SocketEvents {
|
||||
fileReadResponse: (path: string, content: string | null, error?: string) => void;
|
||||
fileWriteResponse: (path: string, success: boolean, error?: string) => void;
|
||||
status: (content: string) => void;
|
||||
"drone:log": (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
"drone:status": (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
reconnect_attempt: (attempt: number) => void;
|
||||
reconnect_failed: () => void;
|
||||
reconnect: (attempt: number) => void;
|
||||
@ -303,6 +314,14 @@ class SocketClient {
|
||||
this.emit("status", content);
|
||||
});
|
||||
|
||||
this.socket.on("drone:log", (data: unknown) => {
|
||||
this.emit("drone:log", data as SocketEvents["drone:log"] extends (data: infer T) => void ? T : never);
|
||||
});
|
||||
|
||||
this.socket.on("drone:status", (data: unknown) => {
|
||||
this.emit("drone:status", data as SocketEvents["drone:status"] extends (data: infer T) => void ? T : never);
|
||||
});
|
||||
|
||||
this._socket.on("reconnect_attempt", (attempt: number) => {
|
||||
this.emit("reconnect_attempt", attempt);
|
||||
});
|
||||
@ -505,6 +524,48 @@ class SocketClient {
|
||||
});
|
||||
}
|
||||
|
||||
requestProcessStats(
|
||||
registrationId: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
processes?: { pid: number; command: string; args: string[]; projectId: string; projectSlug: string; projectName: string; status: string; createdAt: string; updatedAt: string; stdoutFileName: string; stderrFileName: string }[];
|
||||
message?: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(
|
||||
"requestProcessStats",
|
||||
registrationId,
|
||||
(success: boolean, data?: { processes?: any[]; message?: string }) => {
|
||||
resolve({ success, processes: data?.processes, message: data?.message });
|
||||
},
|
||||
);
|
||||
} else {
|
||||
resolve({ success: false, message: "Socket not connected" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribeDrone(registrationId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit("subscribeDrone", registrationId, resolve);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribeDrone(registrationId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit("unsubscribeDrone", registrationId, resolve);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a single sessionHeartbeat event to the server (which relays
|
||||
* it to the drone). The drone resets its 120-second timeout timer
|
||||
|
||||
@ -1,30 +1,46 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { User, DroneRegistration } from '../lib/api';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { User, DroneRegistration, SubProcessStat } from '../lib/api';
|
||||
import { droneApi } from '../lib/api';
|
||||
import { socketClient } from '../lib/socket';
|
||||
import SubProcessTable from '../components/SubProcessTable';
|
||||
import DroneMonitor from '../components/DroneMonitor';
|
||||
import LogRenderer from '../components/LogRenderer';
|
||||
import type { LogRendererEntry } from '../components/LogRenderer';
|
||||
|
||||
interface DroneManagerProps {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
interface ToastState {
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
let logIdCounter = 0;
|
||||
|
||||
export default function DroneManager({ user }: DroneManagerProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [allDrones, setAllDrones] = useState<DroneRegistration[]>([]);
|
||||
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [terminating, setTerminating] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
|
||||
// Placeholder log entries for testing scroll behavior
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
// SubProcess state
|
||||
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
|
||||
const [selectedPid, setSelectedPid] = useState<number | null>(null);
|
||||
|
||||
// Live log state
|
||||
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track cleanup
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const subscribedDroneRef = useRef<string | null>(null);
|
||||
const droneChangeGeneration = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
@ -34,43 +50,81 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
loadDrones();
|
||||
}, [user]);
|
||||
|
||||
// Mock log generator for testing scroll behavior
|
||||
// TODO: Replace with live log streaming when backend is implemented
|
||||
// Socket lifecycle — subscribe, poll, listen when selectedDrone changes
|
||||
useEffect(() => {
|
||||
if (!selectedDrone) {
|
||||
const gen = ++droneChangeGeneration.current;
|
||||
|
||||
// Cleanup previous subscription
|
||||
if (subscribedDroneRef.current) {
|
||||
socketClient.unsubscribeDrone(subscribedDroneRef.current);
|
||||
subscribedDroneRef.current = null;
|
||||
}
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (!selectedDrone || selectedDrone.status === 'offline') {
|
||||
setProcessStats([]);
|
||||
setLogEntries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize with some placeholder entries
|
||||
const initialEntries: LogEntry[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i,
|
||||
timestamp: new Date(Date.now() - (50 - i) * 1000).toLocaleTimeString(),
|
||||
message: `[PLACEHOLDER] Log entry ${i + 1} - This is mock data for testing scroll behavior`,
|
||||
}));
|
||||
setLogEntries(initialEntries);
|
||||
const regId = selectedDrone._id;
|
||||
subscribedDroneRef.current = regId;
|
||||
setProcessStats([]);
|
||||
setLogEntries([]);
|
||||
|
||||
// Add a new entry every second for testing auto-scroll
|
||||
const interval = setInterval(() => {
|
||||
setLogEntries(prev => {
|
||||
const newEntry: LogEntry = {
|
||||
id: prev.length > 0 ? prev[prev.length - 1]!.id + 1 : 0,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `[PLACEHOLDER] New log entry at ${new Date().toLocaleTimeString()} - Auto-generated for testing`,
|
||||
// Subscribe to live drone events
|
||||
socketClient.subscribeDrone(regId);
|
||||
|
||||
// Listen for log events
|
||||
const handleLog = (data: { timestamp: string; level: string; component: string; message: string; metadata?: unknown }) => {
|
||||
if (droneChangeGeneration.current !== gen) return;
|
||||
logIdCounter++;
|
||||
setLogEntries((prev) => {
|
||||
const entry: LogRendererEntry = {
|
||||
id: `log-${logIdCounter}`,
|
||||
timestamp: new Date(data.timestamp),
|
||||
level: data.level,
|
||||
component: data.component,
|
||||
message: data.message,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
const updated = [...prev, newEntry];
|
||||
// Keep only last 200 entries as per spec
|
||||
if (updated.length > 200) {
|
||||
return updated.slice(updated.length - 200);
|
||||
const next = [...prev, entry];
|
||||
if (next.length > 200) {
|
||||
return next.slice(next.length - 200);
|
||||
}
|
||||
return updated;
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return () => clearInterval(interval);
|
||||
socketClient.on('drone:log', handleLog);
|
||||
|
||||
// Poll process stats every 1s
|
||||
const poll = async () => {
|
||||
if (droneChangeGeneration.current !== gen) return;
|
||||
const result = await socketClient.requestProcessStats(regId);
|
||||
if (result.success && result.processes && droneChangeGeneration.current === gen) {
|
||||
setProcessStats(result.processes as SubProcessStat[]);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
pollIntervalRef.current = setInterval(poll, 1000);
|
||||
|
||||
return () => {
|
||||
// Mark this effect cycle as stale so async callbacks are no-ops
|
||||
droneChangeGeneration.current++;
|
||||
clearInterval(pollIntervalRef.current!);
|
||||
pollIntervalRef.current = null;
|
||||
socketClient.off('drone:log', handleLog);
|
||||
socketClient.unsubscribeDrone(regId);
|
||||
subscribedDroneRef.current = null;
|
||||
};
|
||||
}, [selectedDrone]);
|
||||
|
||||
// Auto-scroll log to bottom when new entries arrive (if user hasn't scrolled up)
|
||||
// Auto-scroll log to bottom when new entries arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
@ -80,7 +134,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
// Check if user is near the bottom (within 100px)
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
setAutoScroll(isNearBottom);
|
||||
}
|
||||
@ -90,6 +143,17 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
try {
|
||||
const data = await droneApi.getForManager();
|
||||
setAllDrones(data);
|
||||
|
||||
// Auto-select from route state if provided
|
||||
const state = location.state as { droneId?: string } | null;
|
||||
if (state?.droneId) {
|
||||
const match = data.find((d) => d._id === state.droneId);
|
||||
if (match) {
|
||||
setSelectedDrone(match);
|
||||
}
|
||||
// Clear route state to avoid re-selecting on re-render
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load drones', err);
|
||||
showToast('Failed to load drones', 'error');
|
||||
@ -105,13 +169,12 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
|
||||
const handleSelectDrone = (drone: DroneRegistration) => {
|
||||
setSelectedDrone(drone);
|
||||
setLogEntries([]); // Clear log when switching drones
|
||||
setSelectedPid(null);
|
||||
};
|
||||
|
||||
const handleTerminate = async () => {
|
||||
if (!selectedDrone) return;
|
||||
|
||||
// Show confirmation if drone is busy
|
||||
if (selectedDrone.status === 'busy') {
|
||||
const confirmed = confirm('Are you sure you want to terminate the drone? Any work or operations currently in progress may be lost.');
|
||||
if (!confirmed) return;
|
||||
@ -127,10 +190,8 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
showToast('Drone terminated successfully', 'success');
|
||||
}
|
||||
|
||||
// Refresh drone list
|
||||
await loadDrones();
|
||||
|
||||
// Clear selection if drone is now offline
|
||||
if (selectedDrone.status !== 'offline') {
|
||||
setSelectedDrone(null);
|
||||
}
|
||||
@ -155,7 +216,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex bg-bg-primary overflow-hidden">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className={`fixed top-16 right-4 z-50 px-4 py-3 rounded border ${
|
||||
toast.type === 'success'
|
||||
@ -166,13 +226,11 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left Panel - Drone Lists */}
|
||||
<aside className="w-80 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
|
||||
<div className="p-3 border-b border-border-subtle flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Drone Manager</h2>
|
||||
</div>
|
||||
|
||||
{/* Online Drones Section - 50% of list area */}
|
||||
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
|
||||
<div className="p-2 border-b border-border-subtle flex-shrink-0 bg-bg-tertiary">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
@ -227,7 +285,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline Drones Section - 50% of list area */}
|
||||
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
|
||||
<div className="p-2 border-t border-border-subtle flex-shrink-0 bg-bg-tertiary">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
@ -273,11 +330,9 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Panel - Drone Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
|
||||
{selectedDrone ? (
|
||||
<>
|
||||
{/* Drone Info Card */}
|
||||
<div className="p-6 border-b border-border-subtle flex-shrink-0">
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@ -329,23 +384,16 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drone Monitor Placeholder */}
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||
<div className="max-w-3xl mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
Drone Monitor
|
||||
</h3>
|
||||
<div className="p-8 bg-bg-secondary border border-border-default rounded text-center">
|
||||
<p className="text-text-muted">
|
||||
Monitor charts coming soon.
|
||||
</p>
|
||||
<p className="text-text-muted text-sm mt-2">
|
||||
Memory usage, AI operations, and log production metrics will be displayed here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0 space-y-4">
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<SubProcessTable
|
||||
processes={processStats}
|
||||
onSelect={setSelectedPid}
|
||||
selectedPid={selectedPid}
|
||||
/>
|
||||
<DroneMonitor visible={!!selectedDrone && selectedDrone.status !== 'offline'} />
|
||||
</div>
|
||||
|
||||
{/* Log Viewer - Collapsible panel at bottom */}
|
||||
<div className="flex flex-col min-h-[300px] max-h-[400px] border-t border-border-subtle">
|
||||
<div className="p-2 bg-bg-secondary border-b border-border-subtle flex-shrink-0">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
@ -355,24 +403,9 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto p-3 bg-bg-primary font-mono text-xs min-h-0"
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{logEntries.length === 0 ? (
|
||||
<div className="text-text-muted">
|
||||
<p>No log entries yet.</p>
|
||||
<p className="mt-2 text-text-secondary">
|
||||
{/* TODO: Remove this placeholder comment when live logs are implemented */}
|
||||
[PLACEHOLDER] Log entries will appear here when the drone is running.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
logEntries.map((entry) => (
|
||||
<div key={entry.id} className="py-1 border-b border-border-subtle last:border-0">
|
||||
<span className="text-text-muted mr-3">[{entry.timestamp}]</span>
|
||||
<span className="text-text-secondary">{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<LogRenderer logs={logEntries} containerClassName="min-h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import type { User, Project, DroneRegistration } from "../lib/api";
|
||||
import type { User, Project, DroneRegistration, SubProcessStat } from "../lib/api";
|
||||
import { projectApi, droneApi } from "../lib/api";
|
||||
import { socketClient } from "../lib/socket";
|
||||
import Clock from "../components/Clock";
|
||||
import GadgetGrid from "../components/GadgetGrid";
|
||||
import LogRenderer from "../components/LogRenderer";
|
||||
import type { LogRendererEntry } from "../components/LogRenderer";
|
||||
|
||||
function SystemReady() {
|
||||
return (
|
||||
@ -40,8 +43,59 @@ function DroneInspector({
|
||||
drone: DroneRegistration;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
|
||||
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const regId = drone._id;
|
||||
|
||||
if (drone.status === 'offline') return;
|
||||
|
||||
socketClient.subscribeDrone(regId);
|
||||
|
||||
const handleLog = (data: { timestamp: string; level: string; component: string; message: string }) => {
|
||||
if (!mountedRef.current) return;
|
||||
setLogEntries((prev) => {
|
||||
const next = [...prev, {
|
||||
id: `insp-log-${prev.length}`,
|
||||
timestamp: new Date(data.timestamp),
|
||||
level: data.level,
|
||||
component: data.component,
|
||||
message: data.message,
|
||||
}];
|
||||
return next.length > 100 ? next.slice(next.length - 100) : next;
|
||||
});
|
||||
};
|
||||
|
||||
socketClient.on('drone:log', handleLog);
|
||||
|
||||
const poll = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
const result = await socketClient.requestProcessStats(regId);
|
||||
if (result.success && result.processes && mountedRef.current) {
|
||||
setProcessStats(result.processes as SubProcessStat[]);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
pollRef.current = setInterval(poll, 1000);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
socketClient.off('drone:log', handleLog);
|
||||
socketClient.unsubscribeDrone(regId);
|
||||
};
|
||||
}, [drone._id, drone.status]);
|
||||
|
||||
const runningCount = processStats.filter((p) => p.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="flex-1 flex items-start justify-center p-8 overflow-y-auto">
|
||||
<div className="max-w-lg w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">Drone Inspector</h2>
|
||||
@ -80,6 +134,16 @@ function DroneInspector({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-text-muted">SubProcesses</div>
|
||||
<div className="text-text-primary text-sm">
|
||||
{runningCount > 0
|
||||
? `${runningCount} running`
|
||||
: processStats.length > 0
|
||||
? 'All stopped'
|
||||
: 'No managed processes'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-text-muted">Registered</div>
|
||||
<div className="text-text-primary text-sm">
|
||||
@ -87,6 +151,30 @@ function DroneInspector({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drone.status !== 'offline' && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => navigate('/drones', { state: { droneId: drone._id } })}
|
||||
className="w-full px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors text-sm"
|
||||
>
|
||||
Open in Drone Manager →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drone.status !== 'offline' && logEntries.length > 0 && (
|
||||
<div className="mt-4 border border-border-default rounded overflow-hidden max-h-48">
|
||||
<div className="p-2 bg-bg-tertiary border-b border-border-subtle">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Live Log
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-40 overflow-y-auto">
|
||||
<LogRenderer logs={logEntries.slice(-50)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
WorkspaceMode,
|
||||
SubmitPromptCallback,
|
||||
AbortWorkOrderCallback,
|
||||
SubProcessStat,
|
||||
} from "@gadget/api";
|
||||
|
||||
import ChatSession from "../models/chat-session.ts";
|
||||
@ -64,6 +65,12 @@ export class CodeSession extends SocketSession {
|
||||
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
|
||||
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
|
||||
this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
|
||||
this.socket.on(
|
||||
"requestProcessStats",
|
||||
this.onRequestProcessStats.bind(this),
|
||||
);
|
||||
this.socket.on("subscribeDrone", this.onSubscribeDrone.bind(this));
|
||||
this.socket.on("unsubscribeDrone", this.onUnsubscribeDrone.bind(this));
|
||||
|
||||
// Check for active session on connect
|
||||
this.checkAndReestablishActiveSession();
|
||||
@ -501,6 +508,62 @@ export class CodeSession extends SocketSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE sends a requestProcessStats event to request
|
||||
* subprocess status from a drone. Routes by registration ID (not by
|
||||
* selectedDrone) so the DroneManager can query without a session lock.
|
||||
*/
|
||||
onRequestProcessStats(
|
||||
registrationId: string,
|
||||
cb: (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void,
|
||||
): void {
|
||||
try {
|
||||
const droneSession =
|
||||
SocketService.getDroneSessionByRegistrationId(registrationId);
|
||||
droneSession.socket.emit(
|
||||
"requestProcessStats",
|
||||
(success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => {
|
||||
cb(success, data);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.error("failed to forward requestProcessStats to drone", {
|
||||
error,
|
||||
});
|
||||
cb(false, { message: "Drone not connected" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE wants to subscribe to live events (log, status)
|
||||
* from a drone for monitoring purposes (no chat session lock required).
|
||||
*/
|
||||
onSubscribeDrone(registrationId: string, cb: (success: boolean) => void): void {
|
||||
try {
|
||||
SocketService.addDroneMonitor(registrationId, this.socket.id);
|
||||
cb(true);
|
||||
} catch (error) {
|
||||
this.log.error("failed to subscribe to drone events", { error });
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE wants to unsubscribe from live drone events.
|
||||
*/
|
||||
onUnsubscribeDrone(registrationId: string, cb: (success: boolean) => void): void {
|
||||
try {
|
||||
SocketService.removeDroneMonitor(registrationId, this.socket.id);
|
||||
cb(true);
|
||||
} catch (error) {
|
||||
this.log.error("failed to unsubscribe from drone events", { error });
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE sends a releaseSessionLock event to release a
|
||||
* previously-acquired session lock on a gadget-drone instance.
|
||||
|
||||
@ -81,47 +81,64 @@ export class DroneSession extends SocketSession {
|
||||
message: string,
|
||||
metadata?: unknown,
|
||||
): Promise<void> {
|
||||
if (!this.chatSessionId) {
|
||||
this.log.warn("log event received but no chat session is active");
|
||||
return;
|
||||
// Route to chat session if one is active
|
||||
if (this.chatSessionId) {
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.onLog(timestamp, component, level, message, metadata);
|
||||
} catch (error) {
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'log',
|
||||
args: [timestamp, component, level, message, metadata],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.onLog(timestamp, component, level, message, metadata);
|
||||
} catch (error) {
|
||||
// Routing failed - queue to Redis
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'log',
|
||||
args: [timestamp, component, level, message, metadata],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
// Broadcast to monitoring sessions (DroneManager, DroneInspector, etc.)
|
||||
SocketService.broadcastToMonitors(
|
||||
"drone:log",
|
||||
this.registration._id,
|
||||
{
|
||||
timestamp: timestamp instanceof Date ? timestamp.toISOString() : String(timestamp),
|
||||
level: String(level),
|
||||
component: typeof component === 'object' && component !== null ? (component as any).name ?? String(component) : String(component),
|
||||
message,
|
||||
metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onStatus(message: string): Promise<void> {
|
||||
if (!this.chatSessionId) {
|
||||
this.log.warn("status event received but no chat session is active");
|
||||
return;
|
||||
// Route to chat session if one is active
|
||||
if (this.chatSessionId) {
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.socket.emit("status", message);
|
||||
} catch (error) {
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'status',
|
||||
args: [message],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.socket.emit("status", message);
|
||||
} catch (error) {
|
||||
// Routing failed - queue to Redis
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'status',
|
||||
args: [message],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
// Broadcast to monitoring sessions
|
||||
SocketService.broadcastToMonitors(
|
||||
"drone:status",
|
||||
this.registration._id,
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -317,18 +317,22 @@ class ChatSessionService extends DtpService {
|
||||
);
|
||||
|
||||
const common = {
|
||||
processManagementBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "process-management-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
scopeBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "scope-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
toolsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "tools-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
subagentsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "subagents.md"),
|
||||
"utf-8",
|
||||
),
|
||||
toolsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "tools-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
};
|
||||
|
||||
/*
|
||||
@ -365,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)
|
||||
|
||||
@ -40,6 +40,13 @@ class SocketService extends DtpService {
|
||||
>();
|
||||
private codeSessionUserIndex: CodeSessionMap = new Map<string, CodeSession>();
|
||||
|
||||
/**
|
||||
* Tracks which code sessions (by socket ID) are monitoring which drones
|
||||
* (by registration ID). Used for broadcasting log/status events outside
|
||||
* the chat session routing path.
|
||||
*/
|
||||
private droneMonitorIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
private io?: Server<
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
@ -275,6 +282,62 @@ class SocketService extends DtpService {
|
||||
return session;
|
||||
}
|
||||
|
||||
getDroneSessionByRegistrationId(registrationId: string): DroneSession {
|
||||
const session = this.droneRegistrationIndex.get(registrationId);
|
||||
if (!session) {
|
||||
const error = new Error("drone session not found");
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
addDroneMonitor(registrationId: string, socketId: string): void {
|
||||
let monitors = this.droneMonitorIndex.get(registrationId);
|
||||
if (!monitors) {
|
||||
monitors = new Set();
|
||||
this.droneMonitorIndex.set(registrationId, monitors);
|
||||
}
|
||||
monitors.add(socketId);
|
||||
}
|
||||
|
||||
removeDroneMonitor(registrationId: string, socketId: string): void {
|
||||
const monitors = this.droneMonitorIndex.get(registrationId);
|
||||
if (monitors) {
|
||||
monitors.delete(socketId);
|
||||
if (monitors.size === 0) {
|
||||
this.droneMonitorIndex.delete(registrationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToMonitors(
|
||||
event: string,
|
||||
registrationId: string,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
const monitorSocketIds = this.droneMonitorIndex.get(registrationId);
|
||||
if (!monitorSocketIds || monitorSocketIds.size === 0) return;
|
||||
|
||||
for (const socketId of monitorSocketIds) {
|
||||
const codeSession = this.codeSessions.get(socketId);
|
||||
if (!codeSession) {
|
||||
// Stale entry — clean up
|
||||
monitorSocketIds.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
if (!codeSession.socket.connected) {
|
||||
monitorSocketIds.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
(codeSession.socket as any).emit(event, ...args);
|
||||
}
|
||||
|
||||
if (monitorSocketIds.size === 0) {
|
||||
this.droneMonitorIndex.delete(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a code session by its chat session ID for reverse lookup.
|
||||
*/
|
||||
|
||||
231
gadget-drone/docs/subprocess.md
Normal file
231
gadget-drone/docs/subprocess.md
Normal file
@ -0,0 +1,231 @@
|
||||
# Gadget Drone Sub-Processes
|
||||
|
||||
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_.
|
||||
|
||||
## Phase Status
|
||||
|
||||
- **Phase 1 (Complete):** SubProcessService + SubprocessTool internal to drone.
|
||||
- **Phase 2 (Complete):** SubProcess observability in DroneManager and DroneInspector. Live log streaming via subscribe/broadcast pattern. Canvas-based resource gauges (CPU, Network, File I/O — mocked). See [Phase 2 Details](#phase-2-subprocess-observability) below.
|
||||
|
||||
## SubProcessService
|
||||
|
||||
[Source](../src/services/subprocess.ts)
|
||||
|
||||
`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.
|
||||
|
||||
### API
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `create` | `(project: IProject, cmd: string, args?: string[]) => Promise<SubProcess>` | Spawns a child process, starts writing stdout/stderr to log files under `<gadgetDir>/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<boolean>` | Kills a specific subprocess by PID and removes its log files. |
|
||||
| `killProject` | `(projectId: GadgetId) => Promise<void>` | Kills all subprocesses for a given project. |
|
||||
| `killSubProcess` | `(sp: SubProcess) => Promise<boolean>` | Kills a subprocess and removes its log files. |
|
||||
| `killAll` | `() => Promise<boolean>` | Kills all managed subprocesses and removes all log files. |
|
||||
| `haltPid` | `(pid: number) => Promise<boolean>` | Stops a subprocess by PID but **retains its log files** for inspection. |
|
||||
| `haltSubProcess` | `(sp: SubProcess) => Promise<boolean>` | Stops a subprocess but retains its log files. |
|
||||
| `getLog` | `(pid: number, stream: "stdout" \| "stderr") => Promise<string>` | Reads the full content of a subprocess's stdout or stderr log file. |
|
||||
|
||||
### Lifecycle
|
||||
|
||||
The service is registered in `GadgetDrone.startServices()` / `stopServices()`. During `stop()`, `killAll()` is called, ensuring no orphaned processes remain when the drone shuts down.
|
||||
|
||||
### Process Termination
|
||||
|
||||
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.
|
||||
|
||||
For `kill`, log files are deleted after termination. For `halt`, log files are preserved.
|
||||
|
||||
## SubprocessTool
|
||||
|
||||
[Source](../src/tools/system/subprocess.ts)
|
||||
|
||||
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.
|
||||
|
||||
### Tool Definition
|
||||
|
||||
**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 `<gadgetDir>/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
|
||||
---
|
||||
<log content>
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: SubProcess Observability
|
||||
|
||||
### Architecture
|
||||
|
||||
Phase 2 exposes the drone's managed subprocesses to the frontend via a **callback-chain pattern** over Socket.IO, identical to the existing `fileTreeRequest`/`fileReadRequest` flow:
|
||||
|
||||
```
|
||||
IDE (DroneManager.tsx)
|
||||
──emit("requestProcessStats", registrationId, cb)──▶
|
||||
│
|
||||
CodeSession.onRequestProcessStats()
|
||||
──droneSession.socket.emit("requestProcessStats", cb)──▶
|
||||
│
|
||||
GadgetDrone.onRequestProcessStats()
|
||||
│ SubProcessService.ps()
|
||||
│ .summarize() each
|
||||
│ → SubProcessStat[]
|
||||
│
|
||||
◀──cb(true, { processes })──
|
||||
◀──ack callback fires──
|
||||
◀──cb(success, data)──
|
||||
```
|
||||
|
||||
### Socket Protocol
|
||||
|
||||
| Direction | Event | Signature |
|
||||
|-----------|-------|-----------|
|
||||
| IDE → Backend | `requestProcessStats` | `(registrationId: string, cb: RequestProcessStatsCallback) => void` |
|
||||
| Backend → Drone | `requestProcessStats` | `(cb: RequestProcessStatsCallback) => void` (no registrationId — drone identifies itself) |
|
||||
|
||||
**Callback type:**
|
||||
```typescript
|
||||
type RequestProcessStatsCallback = (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string }
|
||||
) => void;
|
||||
```
|
||||
|
||||
### `SubProcessStat` Type
|
||||
|
||||
Defined in `packages/api/src/messages/subprocess.ts`:
|
||||
|
||||
```typescript
|
||||
interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Behavior
|
||||
|
||||
The `onRequestProcessStats` handler in `gadget-drone.ts`:
|
||||
1. Calls `SubProcessService.ps()` to get all tracked subprocesses
|
||||
2. For each, calls `summarize()` for serializable fields
|
||||
3. Determines status: checks `sp.process.exitCode !== null || sp.process.killed`
|
||||
4. Extracts `command` and `args` from `sp.process.spawnargs`
|
||||
5. Returns `cb(true, { processes })` on success, `cb(false, { message })` on error
|
||||
|
||||
### Live Log Streaming (Subscribe/Broadcast)
|
||||
|
||||
In addition to request/response stats, the drone continuously emits `log` events through its `GadgetLogTransportSocket`. Phase 2 adds a **monitor broadcast** path:
|
||||
|
||||
1. **IDE sends** `subscribeDrone(registrationId)` → Backend adds IDE's socket to a monitor set
|
||||
2. **IDE sends** `unsubscribeDrone(registrationId)` → Backend removes from monitor set
|
||||
3. **Drone emits** `log` → `DroneSession.onLog()` forwards to chat session AND broadcasts `drone:log` to all monitoring sockets
|
||||
4. **Frontend** receives `drone:log` events and renders via `LogRenderer`
|
||||
|
||||
The monitor tracking lives in `SocketService.droneMonitorIndex` — a `Map<registrationId, Set<socketId>>`. Cleanup happens automatically on socket disconnect.
|
||||
|
||||
### Polling Interval
|
||||
|
||||
- **DroneManager**: 1-second interval polling `requestProcessStats`
|
||||
- **DroneInspector (dashboard)**: 1-second interval polling
|
||||
- Resource gauges (CPU, Network, File I/O): 200ms internal animation (mocked data)
|
||||
- All timers are killed on unmount or drone deselection
|
||||
@ -13,12 +13,14 @@ import { input as inqInput, password as inqPassword } from "@inquirer/prompts";
|
||||
import AgentService, { IAgentWorkOrder } from "./services/agent.ts";
|
||||
import AiService from "./services/ai.ts";
|
||||
import PlatformService from "./services/platform.ts";
|
||||
import SubProcessService from "./services/subprocess.ts";
|
||||
import WorkspaceService from "./services/workspace.ts";
|
||||
import {
|
||||
DroneStatus,
|
||||
GadgetLog,
|
||||
GadgetLogTransportSocket,
|
||||
IUser,
|
||||
SubProcessStat,
|
||||
} from "@gadget/api";
|
||||
|
||||
import { GadgetProcess } from "./lib/process.ts";
|
||||
@ -177,6 +179,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
await AgentService.start();
|
||||
await AiService.start();
|
||||
await PlatformService.start();
|
||||
await SubProcessService.start();
|
||||
|
||||
this.log.info("services started");
|
||||
}
|
||||
@ -187,6 +190,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
await AgentService.stop();
|
||||
await AiService.stop();
|
||||
await PlatformService.stop();
|
||||
await SubProcessService.stop();
|
||||
|
||||
this.log.info("services stopped");
|
||||
}
|
||||
@ -252,25 +256,17 @@ class GadgetDrone extends GadgetProcess {
|
||||
this.onReleaseSessionLock.bind(this),
|
||||
);
|
||||
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
|
||||
this.socket.on(
|
||||
"abortWorkOrder",
|
||||
this.onAbortWorkOrder.bind(this),
|
||||
);
|
||||
this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this));
|
||||
this.socket.on(
|
||||
"requestTermination",
|
||||
this.onRequestTermination.bind(this),
|
||||
);
|
||||
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
|
||||
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
|
||||
this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
|
||||
this.socket.on(
|
||||
"fileTreeRequest",
|
||||
this.onFileTreeRequest.bind(this),
|
||||
);
|
||||
this.socket.on(
|
||||
"fileReadRequest",
|
||||
this.onFileReadRequest.bind(this),
|
||||
);
|
||||
this.socket.on(
|
||||
"fileWriteRequest",
|
||||
this.onFileWriteRequest.bind(this),
|
||||
"requestProcessStats",
|
||||
this.onRequestProcessStats.bind(this),
|
||||
);
|
||||
|
||||
/*
|
||||
@ -375,9 +371,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
/*
|
||||
* Check if this project is already deployed (by ID, not slug).
|
||||
*/
|
||||
const haveProjectInWorkspace = WorkspaceService.hasProjectById(
|
||||
project._id,
|
||||
);
|
||||
const haveProjectInWorkspace = WorkspaceService.hasProjectById(project._id);
|
||||
if (!haveProjectInWorkspace) {
|
||||
this.socket.emit("status", `deploying project [slug=${project.slug}]`);
|
||||
await WorkspaceService.deployProject(project);
|
||||
@ -727,7 +721,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
async onFileTreeRequest(
|
||||
args: FileTreeRequestArgs,
|
||||
cb: (success: boolean, data: { entries?: FileTreeEntry[]; error?: string }) => void,
|
||||
cb: (
|
||||
success: boolean,
|
||||
data: { entries?: FileTreeEntry[]; error?: string },
|
||||
) => void,
|
||||
): Promise<void> {
|
||||
if (!this.sessionLock) {
|
||||
return cb(false, { error: "No session lock active" });
|
||||
@ -744,7 +741,9 @@ class GadgetDrone extends GadgetProcess {
|
||||
});
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
return cb(false, {
|
||||
error: `Project directory not found for slug: ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
|
||||
// If no path specified, list from project root
|
||||
@ -756,7 +755,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
if (
|
||||
!normalizedTarget.startsWith(normalizedRoot + path.sep) &&
|
||||
normalizedTarget !== normalizedRoot
|
||||
) {
|
||||
this.log.warn("fileTreeRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
@ -786,12 +788,13 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
this.log.debug("fileTreeRequest completed", {
|
||||
entryCount: entries.length,
|
||||
entries: entries.slice(0, 10).map(e => e.name), // Log first 10 entries
|
||||
entries: entries.slice(0, 10).map((e) => e.name), // Log first 10 entries
|
||||
});
|
||||
|
||||
cb(true, { entries });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to list directory for file tree", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
@ -820,7 +823,9 @@ class GadgetDrone extends GadgetProcess {
|
||||
});
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
return cb(false, {
|
||||
error: `Project directory not found for slug: ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve path relative to project root
|
||||
@ -829,7 +834,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
if (
|
||||
!normalizedTarget.startsWith(normalizedRoot + path.sep) &&
|
||||
normalizedTarget !== normalizedRoot
|
||||
) {
|
||||
this.log.warn("fileReadRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
@ -851,14 +859,16 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Check file size (limit to 1MB)
|
||||
const maxSize = 1 * 1024 * 1024; // 1MB
|
||||
if (stat.size > maxSize) {
|
||||
return cb(false, { error: `File too large (${(stat.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB.` });
|
||||
return cb(false, {
|
||||
error: `File too large (${(stat.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(targetPath, "utf-8");
|
||||
|
||||
// Check for binary file (null bytes)
|
||||
if (content.includes('\0')) {
|
||||
if (content.includes("\0")) {
|
||||
return cb(false, { error: "Cannot edit binary files" });
|
||||
}
|
||||
|
||||
@ -869,7 +879,8 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
cb(true, { content });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to read file", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
@ -906,7 +917,9 @@ class GadgetDrone extends GadgetProcess {
|
||||
});
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
return cb(false, {
|
||||
error: `Project directory not found for slug: ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve path relative to project root
|
||||
@ -915,7 +928,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
if (
|
||||
!normalizedTarget.startsWith(normalizedRoot + path.sep) &&
|
||||
normalizedTarget !== normalizedRoot
|
||||
) {
|
||||
this.log.warn("fileWriteRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
@ -943,7 +959,8 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
cb(true, { success: true });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to write file", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
@ -988,7 +1005,11 @@ class GadgetDrone extends GadgetProcess {
|
||||
const fileTreeEntry: FileTreeEntry = {
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : "file",
|
||||
type: entry.isSymbolicLink()
|
||||
? "symlink"
|
||||
: entry.isDirectory()
|
||||
? "directory"
|
||||
: "file",
|
||||
size: stat.size,
|
||||
modified: stat.mtime.toISOString(),
|
||||
isHidden: entry.name.startsWith("."),
|
||||
@ -1003,7 +1024,9 @@ class GadgetDrone extends GadgetProcess {
|
||||
return results;
|
||||
}
|
||||
|
||||
async onAbortWorkOrder(cb: (success: boolean, message?: string) => void): Promise<void> {
|
||||
async onAbortWorkOrder(
|
||||
cb: (success: boolean, message?: string) => void,
|
||||
): Promise<void> {
|
||||
this.log.info("abortWorkOrder received from platform", {
|
||||
registrationId: this.registration?._id,
|
||||
isProcessing: this.isProcessingWorkOrder,
|
||||
@ -1013,6 +1036,39 @@ class GadgetDrone extends GadgetProcess {
|
||||
cb(aborted, aborted ? "Abort signaled" : "No active work order to abort");
|
||||
}
|
||||
|
||||
async onRequestProcessStats(
|
||||
cb: (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const processes = SubProcessService.ps().map((sp) => {
|
||||
const summary = SubProcessService.summarize(sp);
|
||||
const hasExited = sp.process.exitCode !== null || sp.process.killed;
|
||||
const stat: SubProcessStat = {
|
||||
pid: summary.pid,
|
||||
command: sp.process.spawnargs[0] ?? "unknown",
|
||||
args: sp.process.spawnargs.slice(1),
|
||||
projectId: summary.project._id,
|
||||
projectSlug: summary.project.slug,
|
||||
projectName: summary.project.name,
|
||||
status: hasExited ? "stopped" : "running",
|
||||
createdAt: summary.createdAt.toISOString(),
|
||||
updatedAt: summary.updatedAt.toISOString(),
|
||||
stdoutFileName: summary.stdoutFileName,
|
||||
stderrFileName: summary.stderrFileName,
|
||||
};
|
||||
return stat;
|
||||
});
|
||||
|
||||
cb(true, { processes });
|
||||
} catch (error) {
|
||||
this.log.error("failed to collect process stats", { error });
|
||||
cb(false, { message: "Failed to collect process stats" });
|
||||
}
|
||||
}
|
||||
|
||||
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
|
||||
this.log.info("requestTermination received from platform", {
|
||||
registrationId: this.registration?._id,
|
||||
|
||||
@ -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]);
|
||||
|
||||
153
gadget-drone/src/services/subprocess.test.ts
Normal file
153
gadget-drone/src/services/subprocess.test.ts
Normal file
@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
241
gadget-drone/src/services/subprocess.ts
Normal file
241
gadget-drone/src/services/subprocess.ts
Normal file
@ -0,0 +1,241 @@
|
||||
// src/services/subprocess.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import assert from "node:assert";
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { ChildProcess, spawn } from "node:child_process";
|
||||
|
||||
import { GadgetId, IProject } from "@gadget/api";
|
||||
|
||||
import WorkspaceService from "./workspace.js";
|
||||
|
||||
import { GadgetService } from "../lib/service.ts";
|
||||
|
||||
export interface SubProcess {
|
||||
project: IProject;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
process: ChildProcess;
|
||||
pid: number;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
export interface SubProcessSummary {
|
||||
pid: number;
|
||||
project: IProject;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
type SubProcessMap = Map<number, SubProcess>;
|
||||
|
||||
const GRACEFUL_SHUTDOWN_MS = 5_000;
|
||||
|
||||
class SubProcessService extends GadgetService {
|
||||
private processMap: SubProcessMap = new Map<number, SubProcess>();
|
||||
|
||||
get name(): string {
|
||||
return "SubprocessService";
|
||||
}
|
||||
get slug(): string {
|
||||
return "svc:subprocess";
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.log.info("started");
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.killAll();
|
||||
this.log.info("stopped");
|
||||
}
|
||||
|
||||
async create(
|
||||
project: IProject,
|
||||
cmd: string,
|
||||
args?: string[],
|
||||
): Promise<SubProcess> {
|
||||
const NOW = new Date();
|
||||
assert(
|
||||
WorkspaceService.gadgetDir,
|
||||
"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 stdoutFile = fs.createWriteStream(stdoutFileName, "utf-8");
|
||||
const stderrFile = fs.createWriteStream(stderrFileName, "utf-8");
|
||||
|
||||
const sp: SubProcess = {
|
||||
project,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
process: child,
|
||||
pid,
|
||||
stdoutFileName,
|
||||
stderrFileName,
|
||||
};
|
||||
|
||||
child.stdout.on("data", (data: any) => {
|
||||
sp.updatedAt = new Date();
|
||||
stdoutFile.write(data);
|
||||
});
|
||||
child.stderr.on("data", (data: any) => {
|
||||
sp.updatedAt = new Date();
|
||||
stderrFile.write(data);
|
||||
});
|
||||
|
||||
child.on("exit", () => {
|
||||
stdoutFile.close();
|
||||
stderrFile.close();
|
||||
});
|
||||
|
||||
this.processMap.set(pid, sp);
|
||||
|
||||
return sp;
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
const sp = this.processMap.get(pid);
|
||||
if (!sp) {
|
||||
throw new Error("process not found");
|
||||
}
|
||||
return this.killSubProcess(sp);
|
||||
}
|
||||
|
||||
async killProject(projectId: GadgetId): Promise<void> {
|
||||
for (const sp of this.processMap.values()) {
|
||||
if (sp.project._id !== projectId) {
|
||||
continue;
|
||||
}
|
||||
await this.killSubProcess(sp);
|
||||
}
|
||||
}
|
||||
|
||||
async killSubProcess(sp: SubProcess): Promise<boolean> {
|
||||
await this.terminateProcess(sp);
|
||||
await this.removeLogFiles(sp);
|
||||
this.processMap.delete(sp.pid);
|
||||
this.log.info("subprocess killed", { pid: sp.pid });
|
||||
return true;
|
||||
}
|
||||
|
||||
async haltSubProcess(sp: SubProcess): Promise<boolean> {
|
||||
await this.terminateProcess(sp);
|
||||
this.processMap.delete(sp.pid);
|
||||
this.log.info("subprocess halted, logs retained", { pid: sp.pid });
|
||||
return true;
|
||||
}
|
||||
|
||||
async killAll(): Promise<boolean> {
|
||||
const entries = Array.from(this.processMap.entries());
|
||||
for (const [pid] of entries) {
|
||||
const sp = this.processMap.get(pid);
|
||||
if (sp) {
|
||||
await this.killSubProcess(sp);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async haltPid(pid: number): Promise<boolean> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
const pid = sp.pid;
|
||||
this.log.info("terminating subprocess", { pid });
|
||||
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
sp.process.on("exit", () => resolve());
|
||||
});
|
||||
|
||||
sp.process.stdout?.removeAllListeners();
|
||||
sp.process.stderr?.removeAllListeners();
|
||||
|
||||
sp.process.kill("SIGINT");
|
||||
|
||||
const timeout = new Promise<never>((_, 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<never>((_, 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<void> {
|
||||
await fs.promises.rm(sp.stdoutFileName, { force: true });
|
||||
await fs.promises.rm(sp.stderrFileName, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new SubProcessService();
|
||||
@ -68,7 +68,7 @@ export interface WorkOrderCache {
|
||||
}
|
||||
|
||||
class WorkspaceService extends GadgetService {
|
||||
private gadgetDir: string = "";
|
||||
private _gadgetDir: string = "";
|
||||
private cacheDir: string = "";
|
||||
|
||||
private workspaceFile: string = "";
|
||||
@ -84,6 +84,10 @@ class WorkspaceService extends GadgetService {
|
||||
return "svc:workspace";
|
||||
}
|
||||
|
||||
get gadgetDir(): string | undefined {
|
||||
return this._gadgetDir || undefined;
|
||||
}
|
||||
|
||||
get workspaceData(): WorkspaceData | null {
|
||||
return this._workspaceData;
|
||||
}
|
||||
@ -114,12 +118,12 @@ class WorkspaceService extends GadgetService {
|
||||
* Validates or creates workspace.json with persistent identity.
|
||||
*/
|
||||
async initialize(workspaceDir: string): Promise<void> {
|
||||
this.gadgetDir = path.join(workspaceDir, ".gadget");
|
||||
this.cacheDir = path.join(this.gadgetDir, "cache");
|
||||
this.workspaceFile = path.join(this.gadgetDir, "workspace.json");
|
||||
this._gadgetDir = path.join(workspaceDir, ".gadget");
|
||||
this.cacheDir = path.join(this._gadgetDir, "cache");
|
||||
this.workspaceFile = path.join(this._gadgetDir, "workspace.json");
|
||||
|
||||
// Create directory structure
|
||||
await fs.promises.mkdir(this.gadgetDir, { recursive: true });
|
||||
await fs.promises.mkdir(this._gadgetDir, { recursive: true });
|
||||
await fs.promises.mkdir(this.cacheDir, { recursive: true });
|
||||
|
||||
// Load or create workspace data
|
||||
@ -311,9 +315,7 @@ class WorkspaceService extends GadgetService {
|
||||
*/
|
||||
markProjectDeployed(slug: string): void {
|
||||
if (!this._workspaceData) return;
|
||||
const project = this._workspaceData.projects.find(
|
||||
(p) => p.slug === slug,
|
||||
);
|
||||
const project = this._workspaceData.projects.find((p) => p.slug === slug);
|
||||
if (project) {
|
||||
const now = new Date().toISOString();
|
||||
project.clonedAt = now;
|
||||
|
||||
@ -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";
|
||||
|
||||
261
gadget-drone/src/tools/system/subprocess.test.ts
Normal file
261
gadget-drone/src/tools/system/subprocess.test.ts
Normal file
@ -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<string, any>;
|
||||
const cmd = params.properties?.cmd as Record<string, any> | 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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");
|
||||
});
|
||||
});
|
||||
});
|
||||
315
gadget-drone/src/tools/system/subprocess.ts
Normal file
315
gadget-drone/src/tools/system/subprocess.ts
Normal file
@ -0,0 +1,315 @@
|
||||
// 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 { 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<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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
10
gadget-drone/vitest.config.ts
Normal file
10
gadget-drone/vitest.config.ts
Normal file
@ -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/**"],
|
||||
},
|
||||
});
|
||||
@ -31,6 +31,7 @@ export * from "./interfaces/workspace.ts";
|
||||
|
||||
export * from "./messages/ide.ts";
|
||||
export * from "./messages/drone.ts";
|
||||
export * from "./messages/subprocess.ts";
|
||||
export * from "./messages/web.ts";
|
||||
export * from "./messages/socket.ts";
|
||||
|
||||
|
||||
@ -30,6 +30,12 @@ import {
|
||||
FileWriteRequestMessage,
|
||||
FileWriteResponseMessage,
|
||||
} from "./ide.ts";
|
||||
import {
|
||||
RequestProcessStatsMessage,
|
||||
RequestProcessStatsCallback,
|
||||
SubscribeDroneMessage,
|
||||
UnsubscribeDroneMessage,
|
||||
} from "./subprocess.ts";
|
||||
|
||||
/*
|
||||
There are two different kinds of clients that connect to the gadget-code:web
|
||||
@ -64,6 +70,14 @@ export interface ClientToServerEvents {
|
||||
fileReadRequest: FileReadRequestMessage;
|
||||
fileWriteRequest: FileWriteRequestMessage;
|
||||
|
||||
/*
|
||||
* Drone Monitor events (IDE => web => drone)
|
||||
*/
|
||||
|
||||
requestProcessStats: RequestProcessStatsMessage;
|
||||
subscribeDrone: SubscribeDroneMessage;
|
||||
unsubscribeDrone: UnsubscribeDroneMessage;
|
||||
|
||||
/*
|
||||
* gadget-drone => gadget-code:web
|
||||
*/
|
||||
@ -130,10 +144,32 @@ export interface ServerToClientEvents {
|
||||
fileReadRequest: FileReadRequestMessage;
|
||||
fileWriteRequest: FileWriteRequestMessage;
|
||||
|
||||
/*
|
||||
* Drone Monitor events (web => drone)
|
||||
* Note: no registrationId param — the drone socket already knows its identity
|
||||
*/
|
||||
|
||||
requestProcessStats: (
|
||||
cb: RequestProcessStatsCallback,
|
||||
) => void;
|
||||
|
||||
/*
|
||||
* gadget-code:web => gadget-code:ide
|
||||
*/
|
||||
|
||||
"drone:log": (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
|
||||
"drone:status": (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
|
||||
log: LogMessage;
|
||||
status: StatusMessage;
|
||||
thinking: ThinkingMessage;
|
||||
|
||||
39
packages/api/src/messages/subprocess.ts
Normal file
39
packages/api/src/messages/subprocess.ts
Normal file
@ -0,0 +1,39 @@
|
||||
// src/messages/subprocess.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import { GadgetId } from "../lib/gadget-id.ts";
|
||||
|
||||
export interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
export type RequestProcessStatsCallback = (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void;
|
||||
|
||||
export type RequestProcessStatsMessage = (
|
||||
registrationId: string,
|
||||
cb: RequestProcessStatsCallback,
|
||||
) => void;
|
||||
|
||||
export type SubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void,
|
||||
) => void;
|
||||
|
||||
export type UnsubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void,
|
||||
) => void;
|
||||
Loading…
Reference in New Issue
Block a user