Merge Phase 2: SubProcess observability

This commit is contained in:
Rob Colbert 2026-05-14 13:53:02 -04:00
commit 9296ed4198
27 changed files with 2306 additions and 287 deletions

View File

@ -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

View File

@ -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:

View 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>
);
}

View 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"
/>
);
}

View File

@ -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>
);
}

View 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;

View 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>
);
}

View File

@ -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;

View File

@ -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

View File

@ -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>

View File

@ -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>
);

View File

@ -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.

View File

@ -81,18 +81,14 @@ 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) {
// Routing failed - queue to Redis
await MessageQueue.enqueue(this.chatSessionId, {
type: 'log',
args: [timestamp, component, level, message, metadata],
@ -102,19 +98,29 @@ export class DroneSession extends SocketSession {
}
}
async onStatus(message: string): Promise<void> {
if (!this.chatSessionId) {
this.log.warn("status event received but no chat session is active");
return;
// 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> {
// 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) {
// Routing failed - queue to Redis
await MessageQueue.enqueue(this.chatSessionId, {
type: 'status',
args: [message],
@ -124,6 +130,17 @@ export class DroneSession extends SocketSession {
}
}
// Broadcast to monitoring sessions
SocketService.broadcastToMonitors(
"drone:status",
this.registration._id,
{
timestamp: new Date().toISOString(),
message,
},
);
}
/**
* Called when the drone emits thinking content from the agent.
* Aggregates thinking tokens in memory and persists at mode changes.

View File

@ -369,6 +369,7 @@ class ChatSessionService extends DtpService {
);
let prompt = promptTemplate
.replace("{{process_management_block}}", common.processManagementBlock)
.replace("{{scope_block}}", common.scopeBlock)
.replace("{{tools}}", common.toolsBlock)
.replace("{{subagent_section}}", common.subagentsBlock)

View File

@ -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.
*/

View File

@ -1,50 +1,231 @@
# Gadget Drone Sub-Processes
The Gadget Code agent wants to manage child processes, and give them a fancier name. Because we're fancy.
The Gadget Code agent manages child processes through gadget-drone's SubProcess system. In the Gadget ecosystem, we refer to a managed child process spawned by gadget-drone as a _SubProcess_.
In the Gadget ecosystem, we refer to a child process spawned by gadget-drone as a [SubProcess](../src/services/subprocess.ts). A `SubProcess` is just a child process, but it's also one that's being managed and controlled by gadget-drone based on commands received from the Agentic Workflow Loop using the `subprocess` tool (coming soon).
## Phase Status
## Intent
This document is currently more of a specification to calibrate the model for working on Phase 1 of the SubProcess task.
Phase 1: Basic Services, internal to drone.
Phase 2: Expose SubProcess to gadget-code:frontend (IDE) via gadget-code:backend.
We will work together through Phase 1 now in this session. At the end of this session, with the knowledge we gain by implementing the agent tool, we will refine this document. And that will be the end of Phase 1.
We will then start a new session, and expose SubProcess to the IDE for User observability of the subprocesses.
- **Phase 1 (Complete):** SubProcessService + SubprocessTool internal to drone.
- **Phase 2 (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
[SubProcess](../src/services/subprocess.ts) is a service implemented by Gadget Drone for managing child processes. It offers methods for creating and managing child processes on behalf of Gadget Drone.
[Source](../src/services/subprocess.ts)
This is a service, and not entirely implemented within an Agent tool in the toolbox, because the gadget-drone process will also implement TypeScript code to manage and monitor the processes.
`SubProcessService` is a singleton service that manages child processes on behalf of Gadget Drone. It is started and stopped as part of the standard service lifecycle in `gadget-drone.ts`. On stop, all managed subprocesses are killed (SIGINT with graceful timeout, falling back to SIGKILL) and their log files are removed.
In the near future, `gadget-drone` will use this service to provide child process listing, status, and log services to `gadget-code:frontend` via `gadget-code:backend`. You can ignore this paragraph for this session because we will implement this in a different session. I mention it so you can include this detail in the refined version of this document at the end of this session.
### API
## SubProcess Tool
| Method | Signature | Description |
|---|---|---|
| `create` | `(project: IProject, cmd: string, args?: string[]) => Promise<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. |
A tool that is needed in the Agent's toolbox is the `subprocess` tool. This is what we are building in this session.
### Lifecycle
The `subprocess` tool will offer the agent `create` (spawn), `list` (ps), `kill(pid:number)`, `killAll`, and `log(which: "stdout" | "stderr")`. We will define a standard Gadget tool that the agent can use to manage child processes.
The service is registered in `GadgetDrone.startServices()` / `stopServices()`. During `stop()`, `killAll()` is called, ensuring no orphaned processes remain when the drone shuts down.
The tool makes use of methods on the `SubProcessService` service to execute tool function intent.
### Process Termination
## Process Management
Termination follows a graceful escalation:
1. Send `SIGINT` to the process.
2. Wait up to 5 seconds for the process to exit.
3. If still alive, send `SIGKILL`.
4. Wait up to another 5 seconds.
5. Remove stdout/stderr listeners.
6. Close log file streams.
`gadget-drone` itself will need to close all running child processes when terminating. I have already added this logic to the gadget-drone main process during normal shutdown by registering the service, starting it, and stopping it using the standard Gadget service pattern.
For `kill`, log files are deleted after termination. For `halt`, log files are preserved.
I have extended the system prompts for relevant modes with information about the `subprocess` tool using [process-management-block.md](../../gadget-code/data/prompts/common/process-management-block.md). That block is now being integrated into the system prompt for all modes (this work is already completed). Please ensure that the service and tool offer a clearly-defined and well-described path for the agent to take for managing processes related to the project.
## SubprocessTool
## Acceptance Criteria
[Source](../src/tools/system/subprocess.ts)
Let this section guide the creation of your TODO list.
The `subprocess` tool is registered in the agent's toolbox for modes: `Build`, `Test`, `Ship`, `Develop`. It provides a single function with a `cmd` parameter that dispatches to the appropriate operation.
[ ] SubProcessService feature-complete
[ ] Unit tests for SubProcessService
[ ] `subprocess` agent tool feature-complete in agent toolbox
[ ] Unit tests for `subprocess` tool
[ ] gadget-drone documentation updated with the knowledge gained from this session
### Tool Definition
When done, these become documentation describing what's here in the subprocess feature suite, and how to use it.
**Name:** `subprocess`
**Category:** `system`
**Parameters:**
| Parameter | Type | Required | Description |
|---|---|---|---|
| `cmd` | string (enum) | yes | One of: `create`, `list`, `kill`, `killAll`, `halt`, `log` |
| `command` | string | for `create` | The executable to spawn (e.g., `node`, `npm`, `python`) |
| `args` | string[] | for `create` | Command-line arguments for the spawned process |
| `pid` | number | for `kill`, `halt`, `log` | The managed process ID |
| `which` | string (enum) | for `log` | `"stdout"` or `"stderr"` |
### Commands
#### `create`
Spawns a new child process in the project directory. Logs stdout and stderr to `<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

View File

@ -20,6 +20,7 @@ import {
GadgetLog,
GadgetLogTransportSocket,
IUser,
SubProcessStat,
} from "@gadget/api";
import { GadgetProcess } from "./lib/process.ts";
@ -263,6 +264,10 @@ class GadgetDrone extends GadgetProcess {
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),
);
/*
* Handle socket disconnect: clear the heartbeat timer to prevent
@ -1030,6 +1035,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,

View File

@ -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]);

View 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);
});
});

View File

@ -2,7 +2,6 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import env from "../config/env.ts";
import assert from "node:assert";
import path from "node:path";
@ -23,13 +22,22 @@ export interface SubProcess {
process: ChildProcess;
pid: number;
stdoutFileName: string;
stdoutFile: fs.WriteStream;
stderrFileName: string;
stderrFile: fs.WriteStream;
}
export interface SubProcessSummary {
pid: number;
project: IProject;
createdAt: Date;
updatedAt: Date;
stdoutFileName: string;
stderrFileName: string;
}
type SubProcessMap = Map<number, SubProcess>;
const GRACEFUL_SHUTDOWN_MS = 5_000;
class SubProcessService extends GadgetService {
private processMap: SubProcessMap = new Map<number, SubProcess>();
@ -60,18 +68,19 @@ class SubProcessService extends GadgetService {
"Gadget workspace directory not initialized",
);
const logDir = path.join(WorkspaceService.gadgetDir, "subprocess-logs");
await fs.promises.mkdir(logDir, { recursive: true });
const projectDir = WorkspaceService.getProjectDirectory(project.slug);
const child = spawn(cmd, args, { cwd: projectDir });
assert(child.pid, "subprocess did not define a process id");
const pid = child.pid;
const stdoutFileName = path.join(logDir, `subproc-${pid}.stdout.log`);
const stderrFileName = path.join(logDir, `subproc-${pid}.stderr.log`);
const logFilePath = path.join(
WorkspaceService.gadgetDir,
"subprocess-logs",
);
const stdoutFileName = path.join(logFilePath, `subproc-${pid}.stdout.log`);
const stderrFileName = path.join(logFilePath, `subproc-${pid}.stderr.log`);
const stdoutFile = fs.createWriteStream(stdoutFileName, "utf-8");
const stderrFile = fs.createWriteStream(stderrFileName, "utf-8");
const sp: SubProcess = {
project,
@ -80,38 +89,41 @@ class SubProcessService extends GadgetService {
process: child,
pid,
stdoutFileName,
stdoutFile: fs.createWriteStream(stdoutFileName, "utf-8"),
stderrFileName,
stderrFile: fs.createWriteStream(stderrFileName, "utf-8"),
};
child.stdout.on("data", (data: any) => {
sp.updatedAt = new Date();
sp.stdoutFile.write(data);
stdoutFile.write(data);
});
child.stderr.on("data", (data: any) => {
sp.updatedAt = new Date();
sp.stderrFile.write(data);
stderrFile.write(data);
});
this.processMap.set(pid, sp);
/*
* AGENT: This is how to create the tool function response that spawns a new
* child process. This lets the agent know the pid of the process so it can
* manage it. It also lets the agent know where stdout and stderr are being
* written.
*/
const _response = [
"SUBPROCESS CREATED",
`pid: ${pid}`,
`stdout: ${stdoutFileName}`,
`stderr: ${stderrFileName}`,
].join("\n");
child.on("exit", () => {
stdoutFile.close();
stderrFile.close();
});
this.processMap.set(pid, sp);
return sp;
}
ps(): MapIterator<SubProcess> {
return this.processMap.values();
ps(): SubProcess[] {
return Array.from(this.processMap.values());
}
summarize(sp: SubProcess): SubProcessSummary {
return {
pid: sp.pid,
project: sp.project,
createdAt: sp.createdAt,
updatedAt: sp.updatedAt,
stdoutFileName: sp.stdoutFileName,
stderrFileName: sp.stderrFileName,
};
}
async killPid(pid: number): Promise<boolean> {
@ -127,39 +139,103 @@ class SubProcessService extends GadgetService {
if (sp.project._id !== projectId) {
continue;
}
this.processMap.delete(sp.pid);
await this.killSubProcess(sp);
}
}
async killSubProcess(sp: SubProcess): Promise<boolean> {
const pid = sp.pid;
this.log.info("killing subprocess", { pid });
if (!sp.process.kill("SIGINT")) {
return false;
await this.terminateProcess(sp);
await this.removeLogFiles(sp);
this.processMap.delete(sp.pid);
this.log.info("subprocess killed", { pid: sp.pid });
return true;
}
sp.process.stdout?.removeAllListeners();
sp.process.stderr?.removeAllListeners();
this.log.info("closing subprocess logs", { pid });
sp.stdoutFile.close();
await fs.promises.rm(sp.stdoutFileName, { force: true });
sp.stderrFile.close();
await fs.promises.rm(sp.stderrFileName, { force: true });
this.processMap.delete(pid);
async haltSubProcess(sp: SubProcess): Promise<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> {
for (const [pid, sp] of this.processMap) {
if (!(await this.killSubProcess(sp))) {
return false;
const entries = Array.from(this.processMap.entries());
for (const [pid] of entries) {
const sp = this.processMap.get(pid);
if (sp) {
await this.killSubProcess(sp);
}
this.processMap.delete(pid);
}
return true;
}
async haltPid(pid: number): Promise<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();

View File

@ -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";

View 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");
});
});
});

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

View 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/**"],
},
});

View File

@ -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";

View File

@ -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;

View 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;