From e155a7ffcf572cf5736b58854f06a4be878ad659 Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Thu, 14 May 2026 13:52:59 -0400 Subject: [PATCH] Phase 2: SubProcess observability in DroneManager and DroneInspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds real-time subprocess monitoring to the IDE via the callback-chain and subscribe/broadcast socket patterns: Shared types (packages/api): - New subprocess.ts message types: SubProcessStat, RequestProcessStatsMessage, SubscribeDroneMessage, UnsubscribeDroneMessage - New socket events in socket.ts: requestProcessStats, subscribeDrone, unsubscribeDrone (ClientToServer); drone:log, drone:status (ServerToClient) Drone (gadget-drone): - onRequestProcessStats handler calls SubProcessService.ps() + summarize(), returns typed SubProcessStat[] with status detection (exitCode/killed) Backend routing (gadget-code): - SocketService: getDroneSessionByRegistrationId(), droneMonitorIndex map, addDroneMonitor()/removeDroneMonitor()/broadcastToMonitors() - CodeSession: requestProcessStats proxy, subscribeDrone/unsubscribeDrone - DroneSession: broadcast drone:log + drone:status to monitor subscribers in addition to existing chat-session routing Frontend socket layer: - SocketClient: requestProcessStats(), subscribeDrone(), unsubscribeDrone() - drone:log + drone:status events forwarded to event bus Frontend components: - LogRenderer — reusable log rendering core extracted from LogPanel - SubProcessTable — btop-style process table with status dots - DroneMonitorGauge — canvas oscilloscope waveform (studio-equipment aesthetic) - DroneMonitor — 3-gauge container (CPU/red, NETWORK/cyan, FILE I/O/green) Frontend pages: - DroneManager: live log streaming, SubProcessTable, DroneMonitor gauges, 1-second polling, auto-scroll log with pause-on-scroll-up - Home/DroneInspector: process count summary, mini LogRenderer, navigate-to-manager link Documentation: - Updated gadget-drone/docs/subprocess.md Phase 2 section - Updated gadget-code/docs/ui-design-guide.md Phase 2 section - Updated docs/socket-protocol.md with new events, sequences, and patterns --- docs/socket-protocol.md | 93 ++++++++- gadget-code/docs/ui-design-guide.md | 163 +++++++++++++++ .../frontend/src/components/DroneMonitor.tsx | 83 ++++++++ .../src/components/DroneMonitorGauge.tsx | 116 +++++++++++ .../frontend/src/components/LogPanel.tsx | 89 +------- .../frontend/src/components/LogRenderer.tsx | 114 ++++++++++ .../src/components/SubProcessTable.tsx | 84 ++++++++ gadget-code/frontend/src/lib/api.ts | 14 ++ gadget-code/frontend/src/lib/socket.ts | 61 ++++++ .../frontend/src/pages/DroneManager.tsx | 197 ++++++++++-------- gadget-code/frontend/src/pages/Home.tsx | 94 ++++++++- gadget-code/src/lib/code-session.ts | 63 ++++++ gadget-code/src/lib/drone-session.ts | 89 ++++---- gadget-code/src/services/socket.ts | 63 ++++++ gadget-drone/docs/subprocess.md | 89 +++++++- gadget-drone/src/gadget-drone.ts | 38 ++++ packages/api/src/index.ts | 1 + packages/api/src/messages/socket.ts | 36 ++++ packages/api/src/messages/subprocess.ts | 39 ++++ 19 files changed, 1316 insertions(+), 210 deletions(-) create mode 100644 gadget-code/frontend/src/components/DroneMonitor.tsx create mode 100644 gadget-code/frontend/src/components/DroneMonitorGauge.tsx create mode 100644 gadget-code/frontend/src/components/LogRenderer.tsx create mode 100644 gadget-code/frontend/src/components/SubProcessTable.tsx create mode 100644 packages/api/src/messages/subprocess.ts 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/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 c0fdc38..3b4342b 100644 --- a/gadget-drone/docs/subprocess.md +++ b/gadget-drone/docs/subprocess.md @@ -5,7 +5,7 @@ The Gadget Code agent manages child processes through gadget-drone's SubProcess ## Phase Status - **Phase 1 (Complete):** SubProcessService + SubprocessTool internal to drone. -- **Phase 2 (Planned):** Expose SubProcess status and logs to gadget-code:frontend (IDE) via gadget-code:backend. +- **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 @@ -142,3 +142,90 @@ All agent modes include a **Process Management** block in their system prompt. T |---|---| | `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/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;