diff --git a/docs/socket-protocol.md b/docs/socket-protocol.md index e7b6140..2462264 100644 --- a/docs/socket-protocol.md +++ b/docs/socket-protocol.md @@ -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 `` + 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 ``. +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 - Primary storage by socket ID 4. **`codeSessionUserIndex`**: Map - Lookup by user ID 5. **`chatSessionIndex`**: Map - Reverse lookup from chat session to IDE +6. **`droneMonitorIndex`**: Map> - 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 diff --git a/gadget-code/docs/ui-design-guide.md b/gadget-code/docs/ui-design-guide.md index 2e1ff6d..4be0864 100644 --- a/gadget-code/docs/ui-design-guide.md +++ b/gadget-code/docs/ui-design-guide.md @@ -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>`) +- `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: diff --git a/gadget-code/frontend/src/components/DroneMonitor.tsx b/gadget-code/frontend/src/components/DroneMonitor.tsx new file mode 100644 index 0000000..b1e3a97 --- /dev/null +++ b/gadget-code/frontend/src/components/DroneMonitor.tsx @@ -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(() => generateWave(35, 20, 60)); + const [netData, setNetData] = useState(() => generateWave(20, 15, 60)); + const [ioData, setIoData] = useState(() => generateWave(10, 10, 60)); + const intervalRef = useRef | 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 ( +
+
+

+ Resource Monitor +

+
+
+ + + +
+
+ ); +} diff --git a/gadget-code/frontend/src/components/DroneMonitorGauge.tsx b/gadget-code/frontend/src/components/DroneMonitorGauge.tsx new file mode 100644 index 0000000..65ffe2a --- /dev/null +++ b/gadget-code/frontend/src/components/DroneMonitorGauge.tsx @@ -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(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 ( + + ); +} diff --git a/gadget-code/frontend/src/components/LogPanel.tsx b/gadget-code/frontend/src/components/LogPanel.tsx index 4606cbb..1ae4d0f 100644 --- a/gadget-code/frontend/src/components/LogPanel.tsx +++ b/gadget-code/frontend/src/components/LogPanel.tsx @@ -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 = { - 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(null); - const [expandedMetadata, setExpandedMetadata] = useState>(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 (
-
- {logs.length === 0 ? ( -
No log entries
- ) : ( - logs.map((entry) => { - const levelClass = levelColors[entry.level] || 'text-text-secondary'; - return ( -
- - {formatTimestamp(entry.timestamp)} - - - {entry.level.toUpperCase().padEnd(5)} - - - {entry.component} - - - {entry.message} - - {entry.metadata != null && ( - - )} - {entry.metadata != null && expandedMetadata.has(entry.id) && ( -
-
-                      {JSON.stringify(entry.metadata, null, 2)}
-                    
-
- )} -
- ); - }) - )} -
+ ); } diff --git a/gadget-code/frontend/src/components/LogRenderer.tsx b/gadget-code/frontend/src/components/LogRenderer.tsx new file mode 100644 index 0000000..f0ab5c5 --- /dev/null +++ b/gadget-code/frontend/src/components/LogRenderer.tsx @@ -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 = { + 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( + ({ logs, containerClassName }, ref) => { + const scrollRef = useRef(null); + const [expandedMetadata, setExpandedMetadata] = useState>(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 ( +
+ {logs.length === 0 ? ( +
No log entries
+ ) : ( + logs.map((entry) => { + const levelClass = levelColors[entry.level] || 'text-text-secondary'; + return ( +
+ + {formatTimestamp(entry.timestamp)} + + + {entry.level.toUpperCase().padEnd(5)} + + + {entry.component} + + + {entry.message} + + {entry.metadata != null && ( + + )} + {entry.metadata != null && expandedMetadata.has(entry.id) && ( +
+
+                      {JSON.stringify(entry.metadata, null, 2)}
+                    
+
+ )} +
+ ); + }) + )} +
+ ); + }, +); + +LogRenderer.displayName = 'LogRenderer'; + +export default LogRenderer; diff --git a/gadget-code/frontend/src/components/SubProcessTable.tsx b/gadget-code/frontend/src/components/SubProcessTable.tsx new file mode 100644 index 0000000..520290a --- /dev/null +++ b/gadget-code/frontend/src/components/SubProcessTable.tsx @@ -0,0 +1,84 @@ +import type { SubProcessStat } from '../lib/api'; + +interface SubProcessTableProps { + processes: SubProcessStat[]; + onSelect?: (pid: number) => void; + selectedPid?: number | null; +} + +const statusDot: Record = { + running: 'bg-green-500', + stopped: 'bg-yellow-500', + error: 'bg-red-500', +}; + +const statusLabel: Record = { + running: '● running', + stopped: '● halted', + error: '● error', +}; + +export default function SubProcessTable({ processes, onSelect, selectedPid }: SubProcessTableProps) { + if (processes.length === 0) { + return ( +
+

No managed subprocesses.

+

+ The agent has not spawned any processes for this session. +

+
+ ); + } + + const runningCount = processes.filter((p) => p.status === 'running').length; + + return ( +
+
+

+ SubProcess Monitor ({runningCount} running) +

+ {processes.length} total +
+
+ + + + + + + + + + + + {processes.map((proc) => ( + 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' + : '' + }`} + > + + + + + + + ))} + +
PIDCMDPROJECTSTATUSUPDATED
{proc.pid}{proc.command}{proc.projectSlug} + + {statusLabel[proc.status] ?? proc.status} + + {new Date(proc.updatedAt).toLocaleTimeString()} +
+
+
+ ); +} diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts index 9720604..dbe0bb5 100644 --- a/gadget-code/frontend/src/lib/api.ts +++ b/gadget-code/frontend/src/lib/api.ts @@ -258,6 +258,20 @@ export const projectApi = { delete: (id: string) => api.delete(`/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; diff --git a/gadget-code/frontend/src/lib/socket.ts b/gadget-code/frontend/src/lib/socket.ts index 88b1862..f1eb4f9 100644 --- a/gadget-code/frontend/src/lib/socket.ts +++ b/gadget-code/frontend/src/lib/socket.ts @@ -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 { + return new Promise((resolve) => { + if (this._socket?.connected) { + this._socket.emit("subscribeDrone", registrationId, resolve); + } else { + resolve(false); + } + }); + } + + unsubscribeDrone(registrationId: string): Promise { + 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 diff --git a/gadget-code/frontend/src/pages/DroneManager.tsx b/gadget-code/frontend/src/pages/DroneManager.tsx index 0abd41c..a9a2e93 100644 --- a/gadget-code/frontend/src/pages/DroneManager.tsx +++ b/gadget-code/frontend/src/pages/DroneManager.tsx @@ -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([]); const [selectedDrone, setSelectedDrone] = useState(null); const [loading, setLoading] = useState(true); const [terminating, setTerminating] = useState(null); - const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + const [toast, setToast] = useState(null); - // Placeholder log entries for testing scroll behavior - const [logEntries, setLogEntries] = useState([]); - const logContainerRef = useRef(null); + // SubProcess state + const [processStats, setProcessStats] = useState([]); + const [selectedPid, setSelectedPid] = useState(null); + + // Live log state + const [logEntries, setLogEntries] = useState([]); const [autoScroll, setAutoScroll] = useState(true); + const logContainerRef = useRef(null); + + // Track cleanup + const pollIntervalRef = useRef | null>(null); + const subscribedDroneRef = useRef(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; @@ -120,17 +183,15 @@ export default function DroneManager({ user }: DroneManagerProps) { setTerminating(selectedDrone._id); try { const result = await droneApi.terminate(selectedDrone._id); - + if (result.message) { showToast(result.message, result.success ? 'success' : 'error'); } else { showToast('Drone terminated successfully', 'success'); } - // Refresh drone list await loadDrones(); - - // Clear selection if drone is now offline + if (selectedDrone.status !== 'offline') { setSelectedDrone(null); } @@ -155,24 +216,21 @@ export default function DroneManager({ user }: DroneManagerProps) { return (
- {/* Toast Notification */} {toast && (
{toast.message}
)} - {/* Left Panel - Drone Lists */} - {/* Right Panel - Drone Details */}
{selectedDrone ? ( <> - {/* Drone Info Card */}
@@ -329,23 +384,16 @@ export default function DroneManager({ user }: DroneManagerProps) {
- {/* Drone Monitor Placeholder */} -
-
-

- Drone Monitor -

-
-

- Monitor charts coming soon. -

-

- Memory usage, AI operations, and log production metrics will be displayed here. -

-
+
+
+ +
- {/* Log Viewer - Collapsible panel at bottom */}

@@ -355,24 +403,9 @@ export default function DroneManager({ user }: DroneManagerProps) {
- {logEntries.length === 0 ? ( -
-

No log entries yet.

-

- {/* TODO: Remove this placeholder comment when live logs are implemented */} - [PLACEHOLDER] Log entries will appear here when the drone is running. -

-
- ) : ( - logEntries.map((entry) => ( -
- [{entry.timestamp}] - {entry.message} -
- )) - )} +

diff --git a/gadget-code/frontend/src/pages/Home.tsx b/gadget-code/frontend/src/pages/Home.tsx index 013a770..80c6787 100644 --- a/gadget-code/frontend/src/pages/Home.tsx +++ b/gadget-code/frontend/src/pages/Home.tsx @@ -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([]); + const [logEntries, setLogEntries] = useState([]); + const mountedRef = useRef(true); + const pollRef = useRef | 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 ( -
+

Drone Inspector

@@ -80,6 +134,16 @@ function DroneInspector({
+
+
SubProcesses
+
+ {runningCount > 0 + ? `${runningCount} running` + : processStats.length > 0 + ? 'All stopped' + : 'No managed processes'} +
+
Registered
@@ -87,6 +151,30 @@ function DroneInspector({
+ + {drone.status !== 'offline' && ( +
+ +
+ )} + + {drone.status !== 'offline' && logEntries.length > 0 && ( +
+
+

+ Live Log +

+
+
+ +
+
+ )}
); diff --git a/gadget-code/src/lib/code-session.ts b/gadget-code/src/lib/code-session.ts index 3ec55cc..2ef7510 100644 --- a/gadget-code/src/lib/code-session.ts +++ b/gadget-code/src/lib/code-session.ts @@ -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. diff --git a/gadget-code/src/lib/drone-session.ts b/gadget-code/src/lib/drone-session.ts index 8b3efe7..efad863 100644 --- a/gadget-code/src/lib/drone-session.ts +++ b/gadget-code/src/lib/drone-session.ts @@ -81,47 +81,64 @@ export class DroneSession extends SocketSession { message: string, metadata?: unknown, ): Promise { - if (!this.chatSessionId) { - this.log.warn("log event received but no chat session is active"); - return; - } - - try { - const codeSession = SocketService.getCodeSessionByChatSessionId( - this.chatSessionId, - ); - codeSession.onLog(timestamp, component, level, message, metadata); - } catch (error) { - // Routing failed - queue to Redis - await MessageQueue.enqueue(this.chatSessionId, { - type: 'log', - args: [timestamp, component, level, message, metadata], - timestamp: Date.now(), - }); - this.log.debug("queued log message", { chatSessionId: this.chatSessionId }); + // Route to chat session if one is active + if (this.chatSessionId) { + try { + const codeSession = SocketService.getCodeSessionByChatSessionId( + this.chatSessionId, + ); + codeSession.onLog(timestamp, component, level, message, metadata); + } catch (error) { + await MessageQueue.enqueue(this.chatSessionId, { + type: 'log', + args: [timestamp, component, level, message, metadata], + timestamp: Date.now(), + }); + this.log.debug("queued log message", { chatSessionId: this.chatSessionId }); + } } + + // 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 { - if (!this.chatSessionId) { - this.log.warn("status event received but no chat session is active"); - return; - } - - try { - const codeSession = SocketService.getCodeSessionByChatSessionId( - this.chatSessionId, - ); - codeSession.socket.emit("status", message); - } catch (error) { - // Routing failed - queue to Redis - await MessageQueue.enqueue(this.chatSessionId, { - type: 'status', - args: [message], - timestamp: Date.now(), - }); - this.log.debug("queued status message", { chatSessionId: this.chatSessionId }); + // Route to chat session if one is active + if (this.chatSessionId) { + try { + const codeSession = SocketService.getCodeSessionByChatSessionId( + this.chatSessionId, + ); + codeSession.socket.emit("status", message); + } catch (error) { + await MessageQueue.enqueue(this.chatSessionId, { + type: 'status', + args: [message], + timestamp: Date.now(), + }); + this.log.debug("queued status message", { chatSessionId: this.chatSessionId }); + } } + + // Broadcast to monitoring sessions + SocketService.broadcastToMonitors( + "drone:status", + this.registration._id, + { + timestamp: new Date().toISOString(), + message, + }, + ); } /** diff --git a/gadget-code/src/services/chat-session.ts b/gadget-code/src/services/chat-session.ts index 17c4884..01513da 100644 --- a/gadget-code/src/services/chat-session.ts +++ b/gadget-code/src/services/chat-session.ts @@ -369,6 +369,7 @@ class ChatSessionService extends DtpService { ); let prompt = promptTemplate + .replace("{{process_management_block}}", common.processManagementBlock) .replace("{{scope_block}}", common.scopeBlock) .replace("{{tools}}", common.toolsBlock) .replace("{{subagent_section}}", common.subagentsBlock) diff --git a/gadget-code/src/services/socket.ts b/gadget-code/src/services/socket.ts index 13ef357..ce1ec9a 100644 --- a/gadget-code/src/services/socket.ts +++ b/gadget-code/src/services/socket.ts @@ -40,6 +40,13 @@ class SocketService extends DtpService { >(); private codeSessionUserIndex: CodeSessionMap = new Map(); + /** + * 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> = 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. */ diff --git a/gadget-drone/docs/subprocess.md b/gadget-drone/docs/subprocess.md index d0dd657..3b4342b 100644 --- a/gadget-drone/docs/subprocess.md +++ b/gadget-drone/docs/subprocess.md @@ -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` | Spawns a child process, starts writing stdout/stderr to log files under `/subprocess-logs/`. | +| `ps` | `() => SubProcess[]` | Returns an array of all managed subprocesses. | +| `summarize` | `(sp: SubProcess) => SubProcessSummary` | Returns a serializable summary (without ChildProcess/WriteStream refs). | +| `killPid` | `(pid: number) => Promise` | Kills a specific subprocess by PID and removes its log files. | +| `killProject` | `(projectId: GadgetId) => Promise` | Kills all subprocesses for a given project. | +| `killSubProcess` | `(sp: SubProcess) => Promise` | Kills a subprocess and removes its log files. | +| `killAll` | `() => Promise` | Kills all managed subprocesses and removes all log files. | +| `haltPid` | `(pid: number) => Promise` | Stops a subprocess by PID but **retains its log files** for inspection. | +| `haltSubProcess` | `(sp: SubProcess) => Promise` | Stops a subprocess but retains its log files. | +| `getLog` | `(pid: number, stream: "stdout" \| "stderr") => Promise` | Reads the full content of a subprocess's stdout or stderr log file. | -A tool that is needed in the Agent's toolbox is the `subprocess` tool. This is what we are building in this session. +### Lifecycle -The `subprocess` tool will offer the agent `create` (spawn), `list` (ps), `kill(pid:number)`, `killAll`, and `log(which: "stdout" | "stderr")`. We will define a standard Gadget tool that the agent can use to manage child processes. +The service is registered in `GadgetDrone.startServices()` / `stopServices()`. During `stop()`, `killAll()` is called, ensuring no orphaned processes remain when the drone shuts down. -The tool makes use of methods on the `SubProcessService` service to execute tool function intent. +### Process Termination -## Process Management +Termination follows a graceful escalation: +1. Send `SIGINT` to the process. +2. Wait up to 5 seconds for the process to exit. +3. If still alive, send `SIGKILL`. +4. Wait up to another 5 seconds. +5. Remove stdout/stderr listeners. +6. Close log file streams. -`gadget-drone` itself will need to close all running child processes when terminating. I have already added this logic to the gadget-drone main process during normal shutdown by registering the service, starting it, and stopping it using the standard Gadget service pattern. +For `kill`, log files are deleted after termination. For `halt`, log files are preserved. -I have extended the system prompts for relevant modes with information about the `subprocess` tool using [process-management-block.md](../../gadget-code/data/prompts/common/process-management-block.md). That block is now being integrated into the system prompt for all modes (this work is already completed). Please ensure that the service and tool offer a clearly-defined and well-described path for the agent to take for managing processes related to the project. +## SubprocessTool -## Acceptance Criteria +[Source](../src/tools/system/subprocess.ts) -Let this section guide the creation of your TODO list. +The `subprocess` tool is registered in the agent's toolbox for modes: `Build`, `Test`, `Ship`, `Develop`. It provides a single function with a `cmd` parameter that dispatches to the appropriate operation. -[ ] SubProcessService feature-complete -[ ] Unit tests for SubProcessService -[ ] `subprocess` agent tool feature-complete in agent toolbox -[ ] Unit tests for `subprocess` tool -[ ] gadget-drone documentation updated with the knowledge gained from this session +### Tool Definition -When done, these become documentation describing what's here in the subprocess feature suite, and how to use it. +**Name:** `subprocess` +**Category:** `system` + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `cmd` | string (enum) | yes | One of: `create`, `list`, `kill`, `killAll`, `halt`, `log` | +| `command` | string | for `create` | The executable to spawn (e.g., `node`, `npm`, `python`) | +| `args` | string[] | for `create` | Command-line arguments for the spawned process | +| `pid` | number | for `kill`, `halt`, `log` | The managed process ID | +| `which` | string (enum) | for `log` | `"stdout"` or `"stderr"` | + +### Commands + +#### `create` +Spawns a new child process in the project directory. Logs stdout and stderr to `/subprocess-logs/`. + +Response: +``` +SUBPROCESS CREATED +pid: 12345 +stdout: /path/to/.gadget/subprocess-logs/subproc-12345.stdout.log +stderr: /path/to/.gadget/subprocess-logs/subproc-12345.stderr.log +``` + +#### `list` +Returns all currently managed subprocesses with their PIDs, project slugs, and timestamps. + +Response: +``` +SUBPROCESS LIST +pid: 12345 | project: my-project | created: ... | updated: ... +``` + +#### `kill` +Terminates a subprocess and removes its log files. Uses SIGINT with graceful escalation. + +Response: +``` +SUBPROCESS KILLED +pid: 12345 +``` + +#### `killAll` +Terminates all managed subprocesses and removes all log files. + +Response: +``` +SUBPROCESS KILLALL +All managed subprocesses have been terminated and their logs removed. +``` + +#### `halt` +Terminates a subprocess but retains its log files. Useful for inspecting logs after process exit. + +Response: +``` +SUBPROCESS HALTED +pid: 12345 +The process has been stopped. Log files are retained for inspection. +``` + +#### `log` +Reads stdout or stderr output from a subprocess's log file. + +Response: +``` +SUBPROCESS LOG (stdout) +pid: 12345 +--- + +``` + +## Integration Points + +### System Prompts + +All agent modes include a **Process Management** block in their system prompt. The block is loaded from [process-management-block.md](../../gadget-code/data/prompts/common/process-management-block.md) and rendered via `{{process_management_block}}` in the mode templates. This tells the agent about the `subprocess` tool and how to use it. + +### Shutdown + +`gadget-drone.ts` calls `SubProcessService.stop()` during shutdown, which triggers `killAll()` to clean up any remaining child processes. + +## Tests + +| File | What it covers | +|---|---| +| `src/services/subprocess.test.ts` | SubProcessService: create, ps, killPid, killAll, haltPid, getLog, error cases | +| `src/tools/system/subprocess.test.ts` | SubprocessTool: all 6 commands, parameter validation, service error handling | + +--- + +## 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>`. 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 diff --git a/gadget-drone/src/gadget-drone.ts b/gadget-drone/src/gadget-drone.ts index 076a769..49f9be5 100644 --- a/gadget-drone/src/gadget-drone.ts +++ b/gadget-drone/src/gadget-drone.ts @@ -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 { + 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 { this.log.info("requestTermination received from platform", { registrationId: this.registration?._id, diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index 028da56..4a49c74 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -51,6 +51,7 @@ import { PlanListTool, ShellExecTool, SubagentTool, + SubprocessTool, type DroneToolboxEnvironment, } from "../tools/index.ts"; @@ -117,6 +118,7 @@ class AgentService extends GadgetService { this.toolbox.register(new FileWriteTool(this.toolbox), writeModes); this.toolbox.register(new FileEditTool(this.toolbox), writeModes); this.toolbox.register(new ShellExecTool(this.toolbox), writeModes); + this.toolbox.register(new SubprocessTool(this.toolbox), writeModes); // Plan tools — Gadget's own .gadget directory: only available in Plan mode this.toolbox.register(new PlanFileReadTool(this.toolbox), [ChatSessionMode.Plan]); diff --git a/gadget-drone/src/services/subprocess.test.ts b/gadget-drone/src/services/subprocess.test.ts new file mode 100644 index 0000000..e5a42ab --- /dev/null +++ b/gadget-drone/src/services/subprocess.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IProject } from "@gadget/api"; +import SubProcessService, { type SubProcess } from "./subprocess.ts"; + +vi.mock("./workspace.js", () => ({ + default: { + gadgetDir: "/tmp/.gadget", + getProjectDirectory: vi.fn((slug: string) => process.cwd()), + }, +})); + +function makeProject(): IProject { + return { + _id: "proj-test", + slug: "test-project", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "system", + } as unknown as IProject; +} + +const NODE = process.execPath; + +describe("SubProcessService", () => { + beforeEach(async () => { + await SubProcessService.start(); + }); + + it("starts and stops cleanly", async () => { + await SubProcessService.stop(); + expect(true).toBe(true); + }); + + it("creates a subprocess and tracks it in ps", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, ["-e", "process.exit(0)"]); + + expect(sp.pid).toBeGreaterThan(0); + expect(sp.project.slug).toBe("test-project"); + expect(sp.stdoutFileName).toContain("subproc-"); + expect(sp.stdoutFileName).toContain(".stdout.log"); + expect(sp.stderrFileName).toContain(".stderr.log"); + + const list = SubProcessService.ps(); + expect(list.some((p) => p.pid === sp.pid)).toBe(true); + + await SubProcessService.killPid(sp.pid); + }); + + it("create throws if workspace is not initialized", async () => { + const wsModule = await import("./workspace.js"); + const ws = wsModule.default as { gadgetDir: string | undefined }; + const original = ws.gadgetDir; + ws.gadgetDir = undefined; + + const project = makeProject(); + await expect( + SubProcessService.create(project, NODE, ["-e", "process.exit(0)"]), + ).rejects.toThrow("Gadget workspace directory not initialized"); + + ws.gadgetDir = original; + }); + + it("ps returns empty array when no processes exist", () => { + const list = SubProcessService.ps(); + expect(Array.isArray(list)).toBe(true); + expect(list.length).toBe(0); + }); + + it("killPid terminates a running subprocess", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]); + + expect(SubProcessService.ps().length).toBe(1); + + const killed = await SubProcessService.killPid(sp.pid); + expect(killed).toBe(true); + + expect(SubProcessService.ps().length).toBe(0); + }); + + it("killPid throws for unknown pid", async () => { + await expect(SubProcessService.killPid(99999)).rejects.toThrow("process not found"); + }); + + it("killAll terminates all subprocesses", async () => { + const project = makeProject(); + await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]); + await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]); + + expect(SubProcessService.ps().length).toBe(2); + + await SubProcessService.killAll(); + + expect(SubProcessService.ps().length).toBe(0); + }); + + it("haltPid stops a process but preserves log files", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, [ + "-e", + "setInterval(() => console.log('tick'), 1000)", + ]); + + expect(SubProcessService.ps().length).toBe(1); + + const halted = await SubProcessService.haltPid(sp.pid); + expect(halted).toBe(true); + + expect(SubProcessService.ps().length).toBe(0); + }); + + it("haltPid throws for unknown pid", async () => { + await expect(SubProcessService.haltPid(99999)).rejects.toThrow("process not found"); + }); + + it("getLog returns stdout content", async () => { + const project = makeProject(); + const sp = await SubProcessService.create(project, NODE, [ + "-e", + "console.log('hello from subprocess'); process.exit(0)", + ]); + + await new Promise((resolve) => sp.process.on("exit", () => resolve())); + + const stdout = await SubProcessService.getLog(sp.pid, "stdout"); + expect(stdout).toContain("hello from subprocess"); + }); + + it("summarize returns serializable summary without ChildProcess/WriteStream", () => { + const pid = 12345; + const project = makeProject(); + const now = new Date(); + const sp: SubProcess = { + project, + createdAt: now, + updatedAt: now, + process: null as any, + pid, + stdoutFileName: "/tmp/.gadget/subprocess-logs/subproc-12345.stdout.log", + stderrFileName: "/tmp/.gadget/subprocess-logs/subproc-12345.stderr.log", + }; + + const summary = SubProcessService.summarize(sp); + expect(summary.pid).toBe(pid); + expect(summary.project.slug).toBe("test-project"); + expect(summary.stdoutFileName).toBe(sp.stdoutFileName); + expect(summary.stderrFileName).toBe(sp.stderrFileName); + expect(summary.createdAt).toBe(now); + expect(summary.updatedAt).toBe(now); + }); +}); diff --git a/gadget-drone/src/services/subprocess.ts b/gadget-drone/src/services/subprocess.ts index 0c110f6..bb807a0 100644 --- a/gadget-drone/src/services/subprocess.ts +++ b/gadget-drone/src/services/subprocess.ts @@ -2,7 +2,6 @@ // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 -import env from "../config/env.ts"; import assert from "node:assert"; import path from "node:path"; @@ -23,13 +22,22 @@ export interface SubProcess { process: ChildProcess; pid: number; stdoutFileName: string; - stdoutFile: fs.WriteStream; stderrFileName: string; - stderrFile: fs.WriteStream; +} + +export interface SubProcessSummary { + pid: number; + project: IProject; + createdAt: Date; + updatedAt: Date; + stdoutFileName: string; + stderrFileName: string; } type SubProcessMap = Map; +const GRACEFUL_SHUTDOWN_MS = 5_000; + class SubProcessService extends GadgetService { private processMap: SubProcessMap = new Map(); @@ -60,18 +68,19 @@ class SubProcessService extends GadgetService { "Gadget workspace directory not initialized", ); + const logDir = path.join(WorkspaceService.gadgetDir, "subprocess-logs"); + await fs.promises.mkdir(logDir, { recursive: true }); + const projectDir = WorkspaceService.getProjectDirectory(project.slug); const child = spawn(cmd, args, { cwd: projectDir }); assert(child.pid, "subprocess did not define a process id"); const pid = child.pid; + const stdoutFileName = path.join(logDir, `subproc-${pid}.stdout.log`); + const stderrFileName = path.join(logDir, `subproc-${pid}.stderr.log`); - const logFilePath = path.join( - WorkspaceService.gadgetDir, - "subprocess-logs", - ); - const stdoutFileName = path.join(logFilePath, `subproc-${pid}.stdout.log`); - const stderrFileName = path.join(logFilePath, `subproc-${pid}.stderr.log`); + const stdoutFile = fs.createWriteStream(stdoutFileName, "utf-8"); + const stderrFile = fs.createWriteStream(stderrFileName, "utf-8"); const sp: SubProcess = { project, @@ -80,38 +89,41 @@ class SubProcessService extends GadgetService { process: child, pid, stdoutFileName, - stdoutFile: fs.createWriteStream(stdoutFileName, "utf-8"), stderrFileName, - stderrFile: fs.createWriteStream(stderrFileName, "utf-8"), }; + child.stdout.on("data", (data: any) => { sp.updatedAt = new Date(); - sp.stdoutFile.write(data); + stdoutFile.write(data); }); child.stderr.on("data", (data: any) => { sp.updatedAt = new Date(); - sp.stderrFile.write(data); + stderrFile.write(data); }); - this.processMap.set(pid, sp); - /* - * AGENT: This is how to create the tool function response that spawns a new - * child process. This lets the agent know the pid of the process so it can - * manage it. It also lets the agent know where stdout and stderr are being - * written. - */ - const _response = [ - "SUBPROCESS CREATED", - `pid: ${pid}`, - `stdout: ${stdoutFileName}`, - `stderr: ${stderrFileName}`, - ].join("\n"); + child.on("exit", () => { + stdoutFile.close(); + stderrFile.close(); + }); + + this.processMap.set(pid, sp); return sp; } - ps(): MapIterator { - return this.processMap.values(); + ps(): SubProcess[] { + return Array.from(this.processMap.values()); + } + + summarize(sp: SubProcess): SubProcessSummary { + return { + pid: sp.pid, + project: sp.project, + createdAt: sp.createdAt, + updatedAt: sp.updatedAt, + stdoutFileName: sp.stdoutFileName, + stderrFileName: sp.stderrFileName, + }; } async killPid(pid: number): Promise { @@ -127,39 +139,103 @@ class SubProcessService extends GadgetService { if (sp.project._id !== projectId) { continue; } - this.processMap.delete(sp.pid); + await this.killSubProcess(sp); } } async killSubProcess(sp: SubProcess): Promise { - const pid = sp.pid; - this.log.info("killing subprocess", { pid }); - if (!sp.process.kill("SIGINT")) { - return false; - } + await this.terminateProcess(sp); + await this.removeLogFiles(sp); + this.processMap.delete(sp.pid); + this.log.info("subprocess killed", { pid: sp.pid }); + return true; + } - sp.process.stdout?.removeAllListeners(); - sp.process.stderr?.removeAllListeners(); - - this.log.info("closing subprocess logs", { pid }); - sp.stdoutFile.close(); - await fs.promises.rm(sp.stdoutFileName, { force: true }); - sp.stderrFile.close(); - await fs.promises.rm(sp.stderrFileName, { force: true }); - - this.processMap.delete(pid); + async haltSubProcess(sp: SubProcess): Promise { + await this.terminateProcess(sp); + this.processMap.delete(sp.pid); + this.log.info("subprocess halted, logs retained", { pid: sp.pid }); return true; } async killAll(): Promise { - for (const [pid, sp] of this.processMap) { - if (!(await this.killSubProcess(sp))) { - return false; + const entries = Array.from(this.processMap.entries()); + for (const [pid] of entries) { + const sp = this.processMap.get(pid); + if (sp) { + await this.killSubProcess(sp); } - this.processMap.delete(pid); } return true; } + + async haltPid(pid: number): Promise { + const sp = this.processMap.get(pid); + if (!sp) { + throw new Error("process not found"); + } + return this.haltSubProcess(sp); + } + + async getLog( + pid: number, + stream: "stdout" | "stderr", + ): Promise { + const sp = this.processMap.get(pid); + if (!sp) { + throw new Error("process not found"); + } + + const filePath = + stream === "stdout" ? sp.stdoutFileName : sp.stderrFileName; + try { + return await fs.promises.readFile(filePath, "utf-8"); + } catch { + return ""; + } + } + + private async terminateProcess(sp: SubProcess): Promise { + const pid = sp.pid; + this.log.info("terminating subprocess", { pid }); + + const exited = new Promise((resolve) => { + sp.process.on("exit", () => resolve()); + }); + + sp.process.stdout?.removeAllListeners(); + sp.process.stderr?.removeAllListeners(); + + sp.process.kill("SIGINT"); + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("graceful shutdown timeout")), GRACEFUL_SHUTDOWN_MS), + ); + + try { + await Promise.race([exited, timeout]); + } catch { + this.log.warn("subprocess did not exit gracefully, sending SIGKILL", { + pid, + }); + sp.process.kill("SIGKILL"); + try { + await Promise.race([ + exited, + new Promise((_, reject) => + setTimeout(() => reject(new Error("SIGKILL timeout")), GRACEFUL_SHUTDOWN_MS), + ), + ]); + } catch { + this.log.warn("subprocess did not respond to SIGKILL", { pid }); + } + } + } + + private async removeLogFiles(sp: SubProcess): Promise { + await fs.promises.rm(sp.stdoutFileName, { force: true }); + await fs.promises.rm(sp.stderrFileName, { force: true }); + } } export default new SubProcessService(); diff --git a/gadget-drone/src/tools/system/index.ts b/gadget-drone/src/tools/system/index.ts index 57f450d..3ebc4b8 100644 --- a/gadget-drone/src/tools/system/index.ts +++ b/gadget-drone/src/tools/system/index.ts @@ -8,3 +8,4 @@ export { ShellExecTool } from "./shell.ts"; export { ListTool } from "./list.ts"; export { GrepTool } from "./grep.ts"; export { GlobTool } from "./glob.ts"; +export { SubprocessTool } from "./subprocess.ts"; diff --git a/gadget-drone/src/tools/system/subprocess.test.ts b/gadget-drone/src/tools/system/subprocess.test.ts new file mode 100644 index 0000000..72f22f6 --- /dev/null +++ b/gadget-drone/src/tools/system/subprocess.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IAiLogger } from "@gadget/ai"; +import { AiToolbox, type DroneToolboxEnvironment } from "../toolbox.ts"; +import { SubprocessTool } from "./subprocess.ts"; + +vi.mock("../../services/subprocess.ts", () => ({ + default: { + create: vi.fn(), + ps: vi.fn(), + killPid: vi.fn(), + killAll: vi.fn(), + haltPid: vi.fn(), + getLog: vi.fn(), + }, +})); + +const mockLogger: IAiLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +const env: DroneToolboxEnvironment = { + NODE_ENV: "test", + workspace: { + workspaceDir: "/tmp/workspace", + projectDir: "/tmp/workspace/test-project", + cacheDir: "/tmp/workspace/.gadget/cache", + }, +}; + +describe("SubprocessTool", () => { + let toolbox: AiToolbox; + let tool: SubprocessTool; + + beforeEach(() => { + toolbox = new AiToolbox(env); + tool = new SubprocessTool(toolbox); + vi.clearAllMocks(); + }); + + it("has the correct name and category", () => { + expect(tool.name).toBe("subprocess"); + expect(tool.category).toBe("system"); + }); + + it("defines the subprocess tool definition with all commands", () => { + const params = tool.definition.function.parameters as Record; + const cmd = params.properties?.cmd as Record | undefined; + const cmds: string[] = cmd?.enum ?? []; + + expect(tool.definition.type).toBe("function"); + expect(tool.definition.function.name).toBe("subprocess"); + expect(cmds).toContain("create"); + expect(cmds).toContain("list"); + expect(cmds).toContain("kill"); + expect(cmds).toContain("killAll"); + expect(cmds).toContain("halt"); + expect(cmds).toContain("log"); + }); + + it("returns error when cmd is missing", async () => { + const result = await tool.execute({}, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("cmd"); + }); + + it("returns error when cmd is invalid", async () => { + const result = await tool.execute({ cmd: "invalid" }, mockLogger); + expect(result).toContain("INVALID_PARAMETER"); + }); + + describe("cmd=create", () => { + it("returns error when command is missing", async () => { + const result = await tool.execute({ cmd: "create" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("command"); + }); + + it("calls SubProcessService.create with args", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.create as ReturnType).mockResolvedValue({ + pid: 42, + stdoutFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stdout.log", + stderrFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stderr.log", + }); + + const result = await tool.execute( + { cmd: "create", command: "node", args: ["server.js"] }, + mockLogger, + ); + + expect(SubProcessService.create).toHaveBeenCalledWith( + expect.objectContaining({ slug: "test-project" }), + "node", + ["server.js"], + ); + + expect(result).toContain("SUBPROCESS CREATED"); + expect(result).toContain("pid: 42"); + }); + + it("handles args as non-array gracefully", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.create as ReturnType).mockResolvedValue({ + pid: 7, + stdoutFileName: "/tmp/subproc-7.stdout.log", + stderrFileName: "/tmp/subproc-7.stderr.log", + }); + + const result = await tool.execute( + { cmd: "create", command: "echo", args: "hello" }, + mockLogger, + ); + + expect(result).toContain("pid: 7"); + }); + }); + + describe("cmd=list", () => { + it("returns empty list message when no processes", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.ps as ReturnType).mockReturnValue([]); + + const result = await tool.execute({ cmd: "list" }, mockLogger); + expect(result).toContain("(no running subprocesses)"); + }); + + it("lists running processes", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.ps as ReturnType).mockReturnValue([ + { + pid: 1, + project: { slug: "project-a" }, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-02"), + }, + { + pid: 2, + project: { slug: "project-b" }, + createdAt: new Date("2026-01-03"), + updatedAt: new Date("2026-01-04"), + }, + ]); + + const result = await tool.execute({ cmd: "list" }, mockLogger); + expect(result).toContain("SUBPROCESS LIST"); + expect(result).toContain("pid: 1"); + expect(result).toContain("pid: 2"); + expect(result).toContain("project-a"); + expect(result).toContain("project-b"); + }); + }); + + describe("cmd=kill", () => { + it("returns error when pid is missing", async () => { + const result = await tool.execute({ cmd: "kill" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pid"); + }); + + it("calls killPid and returns success", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.killPid as ReturnType).mockResolvedValue(true); + + const result = await tool.execute({ cmd: "kill", pid: 42 }, mockLogger); + expect(SubProcessService.killPid).toHaveBeenCalledWith(42); + expect(result).toContain("SUBPROCESS KILLED"); + expect(result).toContain("pid: 42"); + }); + + it("handles service errors", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.killPid as ReturnType).mockRejectedValue(new Error("not found")); + + const result = await tool.execute({ cmd: "kill", pid: 99 }, mockLogger); + expect(result).toContain("OPERATION_FAILED"); + }); + }); + + describe("cmd=killAll", () => { + it("calls killAll and returns success", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.killAll as ReturnType).mockResolvedValue(true); + + const result = await tool.execute({ cmd: "killAll" }, mockLogger); + expect(SubProcessService.killAll).toHaveBeenCalledOnce(); + expect(result).toContain("SUBPROCESS KILLALL"); + }); + }); + + describe("cmd=halt", () => { + it("returns error when pid is missing", async () => { + const result = await tool.execute({ cmd: "halt" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pid"); + }); + + it("calls haltPid and returns success", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.haltPid as ReturnType).mockResolvedValue(true); + + const result = await tool.execute({ cmd: "halt", pid: 42 }, mockLogger); + expect(SubProcessService.haltPid).toHaveBeenCalledWith(42); + expect(result).toContain("SUBPROCESS HALTED"); + expect(result).toContain("pid: 42"); + }); + }); + + describe("cmd=log", () => { + it("returns error when pid is missing", async () => { + const result = await tool.execute({ cmd: "log", which: "stdout" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("pid"); + }); + + it("returns error when which is missing", async () => { + const result = await tool.execute({ cmd: "log", pid: 42 }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("which"); + }); + + it("returns error when which is invalid", async () => { + const result = await tool.execute({ cmd: "log", pid: 42, which: "invalid" }, mockLogger); + expect(result).toContain("MISSING_PARAMETER"); + expect(result).toContain("which"); + }); + + it("reads stdout log content", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.getLog as ReturnType).mockResolvedValue("line1\nline2\nline3"); + + const result = await tool.execute( + { cmd: "log", pid: 42, which: "stdout" }, + mockLogger, + ); + + expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stdout"); + expect(result).toContain("SUBPROCESS LOG (stdout)"); + expect(result).toContain("pid: 42"); + expect(result).toContain("line1"); + expect(result).toContain("line2"); + expect(result).toContain("line3"); + }); + + it("reads stderr log content", async () => { + const SubProcessService = (await import("../../services/subprocess.ts")).default; + (SubProcessService.getLog as ReturnType).mockResolvedValue("error: something broke"); + + const result = await tool.execute( + { cmd: "log", pid: 42, which: "stderr" }, + mockLogger, + ); + + expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stderr"); + expect(result).toContain("SUBPROCESS LOG (stderr)"); + expect(result).toContain("error: something broke"); + }); + }); +}); diff --git a/gadget-drone/src/tools/system/subprocess.ts b/gadget-drone/src/tools/system/subprocess.ts new file mode 100644 index 0000000..a4da928 --- /dev/null +++ b/gadget-drone/src/tools/system/subprocess.ts @@ -0,0 +1,315 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { formatError } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; +import SubProcessService from "../../services/subprocess.ts"; + +const VALID_CMDS = [ + "create", + "list", + "kill", + "killAll", + "halt", + "log", +] as const; + +type SubprocessCmd = (typeof VALID_CMDS)[number]; + +export class SubprocessTool extends DroneTool { + get name(): string { + return "subprocess"; + } + + get category(): string { + return "system"; + } + + get definition(): IToolDefinition { + return { + type: "function", + function: { + name: this.name, + description: + "Manage child processes spawned by this agent. Use 'create' to spawn a new process, 'list' to see running processes, 'kill' to stop a process and remove its log files, 'halt' to stop a process while retaining its log files for later inspection, 'killAll' to stop all running processes, and 'log' to read stdout or stderr output from a running or halted process.", + parameters: { + type: "object", + properties: { + cmd: { + type: "string", + enum: [...VALID_CMDS], + description: + "The subprocess command to execute. create: spawn a new process. list: show all managed processes. kill: terminate a process and remove its logs. halt: terminate a process but keep its logs. killAll: terminate all managed processes. log: read stdout or stderr output from a process.", + }, + command: { + type: "string", + description: + "The shell command to execute (required for cmd=create). Example: 'node', 'npm', 'python', './my-server'.", + }, + args: { + type: "array", + items: { type: "string" }, + description: + "Command-line arguments for the process (optional, used with cmd=create). Example: ['run', 'dev'].", + }, + pid: { + type: "number", + description: + "Process ID of the managed subprocess (required for cmd=kill, cmd=halt, cmd=log).", + }, + which: { + type: "string", + enum: ["stdout", "stderr"], + description: + "Which log stream to read (required for cmd=log). 'stdout' for standard output, 'stderr' for standard error.", + }, + }, + required: ["cmd"], + }, + }, + }; + } + + async execute(args: IToolArguments, logger: IAiLogger): Promise { + const cmd = args.cmd as string | undefined; + + if (!cmd) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'cmd' parameter is required.", + parameter: "cmd", + expected: `One of: ${VALID_CMDS.join(", ")}`, + recoveryHint: `Specify a valid command: ${VALID_CMDS.join(", ")}.`, + }); + } + + if (!VALID_CMDS.includes(cmd as SubprocessCmd)) { + return formatError({ + code: "INVALID_PARAMETER", + message: `Invalid cmd: '${cmd}'. Must be one of: ${VALID_CMDS.join(", ")}`, + parameter: "cmd", + expected: `One of: ${VALID_CMDS.join(", ")}`, + recoveryHint: `Use one of: ${VALID_CMDS.join(", ")}.`, + }); + } + + switch (cmd) { + case "create": + return this.cmdCreate(args, logger); + case "list": + return this.cmdList(args, logger); + case "kill": + return this.cmdKill(args, logger); + case "killAll": + return this.cmdKillAll(args, logger); + case "halt": + return this.cmdHalt(args, logger); + case "log": + return this.cmdLog(args, logger); + default: + return formatError({ + code: "INVALID_PARAMETER", + message: `Unknown subprocess command: '${cmd}'`, + parameter: "cmd", + expected: `One of: ${VALID_CMDS.join(", ")}`, + }); + } + } + + private async cmdCreate( + args: IToolArguments, + logger: IAiLogger, + ): Promise { + const command = args.command as string | undefined; + if (!command || typeof command !== "string" || command.trim().length === 0) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'command' parameter is required for cmd=create.", + parameter: "command", + recoveryHint: + "Provide the command to execute, e.g. 'node', 'npm', 'python'.", + }); + } + + const rawArgs = args.args; + const cmdArgs: string[] | undefined = + Array.isArray(rawArgs) + ? rawArgs.map((a) => String(a)) + : undefined; + + const project = this.toolbox.env.workspace?.projectDir + ? { _id: "workspace", slug: this.toolbox.env.workspace.projectDir.split("/").pop() || "project" } + : { _id: "workspace", slug: "project" }; + + const projectObj = { + _id: project._id, + slug: project.slug, + name: project.slug, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "system", + }; + + try { + const sp = await SubProcessService.create(projectObj as any, command, cmdArgs); + return [ + "SUBPROCESS CREATED", + `pid: ${sp.pid}`, + `stdout: ${sp.stdoutFileName}`, + `stderr: ${sp.stderrFileName}`, + ].join("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("failed to create subprocess", { + command, + args: cmdArgs, + error: message, + }); + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to spawn subprocess: ${message}`, + }); + } + } + + private async cmdList( + _args: IToolArguments, + _logger: IAiLogger, + ): Promise { + const processes = SubProcessService.ps(); + if (processes.length === 0) { + return "SUBPROCESS LIST\n(no running subprocesses)"; + } + + const lines = ["SUBPROCESS LIST"]; + for (const sp of processes) { + lines.push( + `pid: ${sp.pid} | project: ${sp.project.slug} | created: ${sp.createdAt.toISOString()} | updated: ${sp.updatedAt.toISOString()}`, + ); + } + return lines.join("\n"); + } + + private async cmdKill( + args: IToolArguments, + logger: IAiLogger, + ): Promise { + const pid = args.pid as number | undefined; + if (typeof pid !== "number" || !Number.isFinite(pid)) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'pid' parameter is required for cmd=kill.", + parameter: "pid", + recoveryHint: "Provide the numeric process ID to kill.", + }); + } + + try { + await SubProcessService.killPid(pid); + return `SUBPROCESS KILLED\npid: ${pid}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("failed to kill subprocess", { pid, error: message }); + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to kill subprocess: ${message}`, + }); + } + } + + private async cmdKillAll( + _args: IToolArguments, + logger: IAiLogger, + ): Promise { + try { + await SubProcessService.killAll(); + return "SUBPROCESS KILLALL\nAll managed subprocesses have been terminated and their logs removed."; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("failed to kill all subprocesses", { error: message }); + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to kill all subprocesses: ${message}`, + }); + } + } + + private async cmdHalt( + args: IToolArguments, + logger: IAiLogger, + ): Promise { + const pid = args.pid as number | undefined; + if (typeof pid !== "number" || !Number.isFinite(pid)) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'pid' parameter is required for cmd=halt.", + parameter: "pid", + recoveryHint: "Provide the numeric process ID to halt.", + }); + } + + try { + await SubProcessService.haltPid(pid); + return [ + "SUBPROCESS HALTED", + `pid: ${pid}`, + "The process has been stopped. Log files are retained for inspection.", + ].join("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("failed to halt subprocess", { pid, error: message }); + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to halt subprocess: ${message}`, + }); + } + } + + private async cmdLog( + args: IToolArguments, + logger: IAiLogger, + ): Promise { + const pid = args.pid as number | undefined; + if (typeof pid !== "number" || !Number.isFinite(pid)) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'pid' parameter is required for cmd=log.", + parameter: "pid", + recoveryHint: "Provide the numeric process ID to read logs from.", + }); + } + + const which = args.which as string | undefined; + if (!which || (which !== "stdout" && which !== "stderr")) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'which' parameter is required for cmd=log.", + parameter: "which", + expected: "'stdout' or 'stderr'", + recoveryHint: "Specify 'stdout' to read standard output or 'stderr' to read standard error.", + }); + } + + try { + const content = await SubProcessService.getLog(pid, which); + return [ + `SUBPROCESS LOG (${which})`, + `pid: ${pid}`, + "---", + content || "(log is empty)", + ].join("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("failed to read subprocess log", { + pid, + which, + error: message, + }); + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to read subprocess log: ${message}`, + }); + } + } +} diff --git a/gadget-drone/vitest.config.ts b/gadget-drone/vitest.config.ts new file mode 100644 index 0000000..5e398e4 --- /dev/null +++ b/gadget-drone/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index e400509..251017c 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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"; diff --git a/packages/api/src/messages/socket.ts b/packages/api/src/messages/socket.ts index 9bf9df4..c7ba4b0 100644 --- a/packages/api/src/messages/socket.ts +++ b/packages/api/src/messages/socket.ts @@ -30,6 +30,12 @@ import { FileWriteRequestMessage, FileWriteResponseMessage, } from "./ide.ts"; +import { + RequestProcessStatsMessage, + RequestProcessStatsCallback, + SubscribeDroneMessage, + UnsubscribeDroneMessage, +} from "./subprocess.ts"; /* There are two different kinds of clients that connect to the gadget-code:web @@ -64,6 +70,14 @@ export interface ClientToServerEvents { fileReadRequest: FileReadRequestMessage; fileWriteRequest: FileWriteRequestMessage; + /* + * Drone Monitor events (IDE => web => drone) + */ + + requestProcessStats: RequestProcessStatsMessage; + subscribeDrone: SubscribeDroneMessage; + unsubscribeDrone: UnsubscribeDroneMessage; + /* * gadget-drone => gadget-code:web */ @@ -130,10 +144,32 @@ export interface ServerToClientEvents { fileReadRequest: FileReadRequestMessage; fileWriteRequest: FileWriteRequestMessage; + /* + * Drone Monitor events (web => drone) + * Note: no registrationId param — the drone socket already knows its identity + */ + + requestProcessStats: ( + cb: RequestProcessStatsCallback, + ) => void; + /* * gadget-code:web => gadget-code:ide */ + "drone:log": (data: { + timestamp: string; + level: string; + component: string; + message: string; + metadata?: unknown; + }) => void; + + "drone:status": (data: { + timestamp: string; + message: string; + }) => void; + log: LogMessage; status: StatusMessage; thinking: ThinkingMessage; diff --git a/packages/api/src/messages/subprocess.ts b/packages/api/src/messages/subprocess.ts new file mode 100644 index 0000000..170af18 --- /dev/null +++ b/packages/api/src/messages/subprocess.ts @@ -0,0 +1,39 @@ +// src/messages/subprocess.ts +// Copyright (C) 2026 Rob Colbert +// 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;