Phase 2: SubProcess observability in DroneManager and DroneInspector

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
This commit is contained in:
Rob Colbert 2026-05-14 13:52:59 -04:00
parent d04453016d
commit e155a7ffcf
19 changed files with 1316 additions and 210 deletions

View File

@ -14,12 +14,15 @@ This document serves as a "Cheat Sheet" for AI agents and developers working on
## 2. Event Map Overview ## 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) ### IDE -> Web (Client to Server)
* `requestSessionLock`: Request to exclusive-lock a drone for a project session. * `requestSessionLock`: Request to exclusive-lock a drone for a project session.
* `requestWorkspaceMode`: Request a mode change (Idle, User, Agent). * `requestWorkspaceMode`: Request a mode change (Idle, User, Agent).
* `submitPrompt`: Submit a user prompt for agent processing. * `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) ### Drone -> Web (Client to Server)
* `thinking`: Stream reasoning/thought process text. * `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. * `processWorkOrder`: Command to start processing a specific prompt/turn.
* `crashRecoveryResponse`: Command to `discard` or `retry` a stalled work order. * `crashRecoveryResponse`: Command to `discard` or `retry` a stalled work order.
* `requestTermination`: Command to immediately terminate the drone process. * `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) ### Web -> IDE (Server to Client)
* `sessionUpdated`: Notify the IDE that a chat session property has changed (e.g. auto-generated name). * `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**. * Forwards event to **IDE**.
* Clears `currentTurnId` from the drone session. * Clears `currentTurnId` from the drone session.
### 3.4 Drone Termination Flow ### 3.4 SubProcess Monitoring Flow
1. **IDE** selects a drone in DroneManager or DroneInspector.
2. **IDE** emits `subscribeDrone(registrationId)` → backend registers socket in `SocketService.droneMonitorIndex`.
3. **IDE** starts a 1-second `setInterval` emitting `requestProcessStats(registrationId, cb)`.
4. **Web (`CodeSession.ts`)**:
* Looks up `DroneSession` via `SocketService.getDroneSessionByRegistrationId()` (no chat session required).
* Forwards to drone: `droneSession.socket.emit("requestProcessStats", cb)`.
5. **Drone (`gadget-drone.ts`)**:
* Calls `SubProcessService.ps()``summarize()` → builds `SubProcessStat[]`.
* Calls callback `cb(true, { processes })`.
6. **IDE** receives `SubProcessStat[]` → renders `<SubProcessTable>` + updates process status.
7. **Log streaming** (automatic, no polling):
* Drone emits `log(timestamp, component, level, message)` as usual.
* `DroneSession.onLog()` also broadcasts `drone:log` to all monitors via `SocketService.broadcastToMonitors()`.
* IDE receives `drone:log` events → appends to log state → renders via `<LogRenderer>`.
8. **Cleanup**: IDE emits `unsubscribeDrone(registrationId)` on unmount/deselect; interval cleared.
### 3.5 Drone Termination Flow
1. **User** clicks "Terminate" button in Drone Manager UI. 1. **User** clicks "Terminate" button in Drone Manager UI.
2. **IDE** calls `POST /api/v1/drone/registration/:id/terminate`. 2. **IDE** calls `POST /api/v1/drone/registration/:id/terminate`.
3. **Web (`DroneService.ts`)**: 3. **Web (`DroneService.ts`)**:
@ -151,6 +175,60 @@ type RequestTerminationMessage = (
) => void; ) => 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) ## 5. Session Implementation Guide (Web Server)
@ -177,6 +255,9 @@ The `SocketService` maintains multiple indexes for efficient session lookup:
3. **`codeSessions`**: Map<socket.id, CodeSession> - Primary storage by socket ID 3. **`codeSessions`**: Map<socket.id, CodeSession> - Primary storage by socket ID
4. **`codeSessionUserIndex`**: Map<user._id, CodeSession> - Lookup by user ID 4. **`codeSessionUserIndex`**: Map<user._id, CodeSession> - Lookup by user ID
5. **`chatSessionIndex`**: Map<chatSessionId, CodeSession> - Reverse lookup from chat session to IDE 5. **`chatSessionIndex`**: Map<chatSessionId, CodeSession> - Reverse lookup from chat session to IDE
6. **`droneMonitorIndex`**: Map<registration._id, Set<socket.id>> - Monitor subscriptions for log/status broadcast (Phase 2)
The `droneMonitorIndex` is maintained independently of chat sessions — it enables the DroneManager and DroneInspector to receive live log and status events from a drone without requiring a session lock. Cleanup happens automatically on socket disconnect.
All indexes are kept in sync during connection and disconnection. 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 ## 7. Extending the Protocol
To add a new message: 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`. 2. Register it in `ClientToServerEvents` or `ServerToClientEvents` in `packages/api/src/messages/socket.ts`.
3. Re-export from `packages/api/src/index.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`). 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. 5. Implement the handler in the corresponding class or frontend component.
6. Implement the forward-path routing if needed. 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 ## 8. Reconnection & Message Queuing

View File

@ -140,6 +140,169 @@ Components:
Implementation: `frontend/src/pages/Home.tsx` - DashboardSidebar component Implementation: `frontend/src/pages/Home.tsx` - DashboardSidebar component
### Drone Inspector (Dashboard Inline)
When a user clicks a drone in the sidebar's Drones list, the main content area switches to the **Drone Inspector**:
```
+-----------------------------------------------------+
| Drone Inspector ← Back to |
| Dashboard |
+-----------------------------------------------------+
| +-------------------------------------------------+ |
| | Hostname | |
| | drone-alpha (mono) | |
| +-------------------------------------------------+ |
| | Workspace | |
| | /path/to/workspace (mono) | |
| +-------------------------------------------------+ |
| | Status | |
| | ● available (or ● busy, ● offline) | |
| +-------------------------------------------------+ |
| | Registered | |
| | 5/14/2026, 10:00:00 AM | |
| +-------------------------------------------------+ |
+-----------------------------------------------------+
```
Implementation: `frontend/src/pages/Home.tsx` - DroneInspector component (lines 36-93)
The Drone Inspector is a simple read-only card view. For full drone operations (terminate, logs, monitoring), users navigate to the Drone Manager at `/drones` via the gear icon in the Drones sidebar header.
## Drone Manager View
Route: `/drones`
The Drone Manager is a full-page view for detailed drone inspection and operations. It replaces the main content area entirely.
Layout:
```
+----------------------------------+----------------------------------------+
| Drone Manager | Drone Details [Terminate] |
+----------------------------------+----------------------------------------+
| Online Drones (N) | +-----------+ +-----------+ |
| | | Hostname | | Status | |
| +----------------------------+ | | drone-1 | | ● busy | |
| | ● drone-1 available | | +-----------+ +-----------+ |
| | /path/to/workspace | | +-----------+ +-----------+ |
| +----------------------------+ | | Workspace | | Registered| |
| +----------------------------+ | | /path/... | | 5/14/2026 | |
| | ● drone-2 busy | | +-----------+ +-----------+ |
| | /path/to/workspace | | |
| +----------------------------+ | Drone Monitor |
| | +--------------------------------------+ |
| Offline Drones (N) | | Monitor charts coming soon. | |
| | | Memory usage, AI operations, and log | |
| +----------------------------+ | | production metrics will be displayed | |
| | ○ drone-3 offline | | | here. | |
| | /path/to/workspace | | +--------------------------------------+ |
| +----------------------------+ | |
| | Drone Log (Live) |
| | +--------------------------------------+ |
| | | [10:00:01] [PLACEHOLDER] Log entry 1 | |
| | | [10:00:02] [PLACEHOLDER] Log entry 2 | |
| | | [10:00:03] [PLACEHOLDER] Log entry 3 | |
| | | ... | |
| | +--------------------------------------+ |
+----------------------------------+----------------------------------------+
```
Current features (Phase 1):
- **Drone list** (left sidebar): Split into Online and Offline sections. Each list item shows status dot, hostname, and workspace path. Click to inspect.
- **Drone details** (right panel, top): 2x2 grid showing hostname, status with dot, workspace, registration date. Terminate button (red) for non-offline drones.
- **Drone Monitor** (right panel, middle): Placeholder for future monitoring charts.
- **Drone Log** (right panel, bottom): Collapsible panel with auto-scrolling log viewer. Log entries show timestamp and message. Auto-scroll pauses when user scrolls up, resumes when scrolled to bottom. Max 200 entries shown. Placeholder entries currently used, awaiting live log streaming.
Implementation: `frontend/src/pages/DroneManager.tsx`
### Phase 2: SubProcess Monitor (Complete)
Phase 2 was implemented on the `feature/process-management` branch. The Drone Manager and Drone Inspector now show live SubProcess data and drone logs via Socket.IO.
#### Socket Protocol
Three new socket events added (defined in `packages/api/src/messages/subprocess.ts`):
| Event | Direction | Purpose | Mechanism |
|-------|-----------|---------|-----------|
| `requestProcessStats` | IDE → Backend → Drone | Request typed subprocess list | Callback-chain (like `fileTreeRequest`) |
| `subscribeDrone` | IDE → Backend | Subscribe to live drone events (log, status) | Registers socket in monitor index |
| `unsubscribeDrone` | IDE → Backend | Unsubscribe from live drone events | Removes socket from monitor index |
Broadcast events (backend → IDE, no request needed):
| Event | Payload | When |
|-------|---------|------|
| `drone:log` | `{ timestamp, level, component, message, metadata? }` | On every drone `log` emission |
| `drone:status` | `{ timestamp, message }` | On every drone `status` emission |
#### SubProcess Table
Rendered by `frontend/src/components/SubProcessTable.tsx`. Features:
- Monospace table: PID, CMD, PROJECT, STATUS, UPDATED
- Status dots: green for running, yellow for halted, red for error
- Clickable rows with selection highlight
- Header shows running count: "SubProcess Monitor (3 running)"
- Empty state: "No managed subprocesses. The agent has not spawned any processes for this session."
#### Resource Gauges (Canvas)
Rendered by `frontend/src/components/DroneMonitor.tsx` + `DroneMonitorGauge.tsx`:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ CPU │ │ NETWORK │ │ FILE I/O │
│ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │
│ 42% │ │ 27% │ │ 15% │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
- Canvas-based waveform with glow effect (studio/scientific equipment aesthetic)
- Brand red for CPU, cyan for NETWORK, green for FILE I/O
- Grid lines, numeric readout, gradient fill under trace
- Data is currently simulated (random walk) — placeholder for real metrics
#### Live Log Streaming
The Drone Log viewer now receives real log events via socket instead of placeholders:
- Log events flow: `drone``DroneSession.onLog()` → broadcast to monitor sessions via `drone:log` event
- Uses the same `LogRenderer` component as the ChatSession `LogPanel` (refactored in Phase 2)
- Auto-scroll with pause-on-scroll-up behavior preserved
- Max 200 entries maintained
#### DroneInspector (Dashboard)
The inline Drone Inspector in the Home page (`frontend/src/pages/Home.tsx`):
- Shows SubProcess count summary ("3 running" or "No managed processes")
- Contains a compact `LogRenderer` (last 50 entries, max-h-48)
- "Open in Drone Manager →" link navigates to `/drones` with route state for auto-selection
#### Polling Behavior
- `requestProcessStats` polled every **1 second** while a drone is selected (both DroneManager and DroneInspector)
- Subscribe/unsubscribe lifecycle tied to component mount/unmount
- All timers and socket listeners cleaned up on unmount or drone deselection
#### Supporting Components
| Component | File | Purpose |
|-----------|------|---------|
| `SubProcessTable` | `frontend/src/components/SubProcessTable.tsx` | Process list table |
| `DroneMonitor` | `frontend/src/components/DroneMonitor.tsx` | 3-gauge resource monitor container |
| `DroneMonitorGauge` | `frontend/src/components/DroneMonitorGauge.tsx` | Canvas waveform gauge |
| `LogRenderer` | `frontend/src/components/LogRenderer.tsx` | Reusable log rendering core |
#### Backend Routing
- `SocketService.addDroneMonitor()` / `removeDroneMonitor()` — manages monitor index (`Map<registrationId, Set<socketId>>`)
- `SocketService.broadcastToMonitors()` — broadcasts events to all monitoring sockets
- `SocketService.getDroneSessionByRegistrationId()` — lookup for non-chat-session drone routing
- `DroneSession.onLog()` / `onStatus()` — extended to broadcast to monitors after chat-session routing
- `CodeSession.onRequestProcessStats()` — proxies to drone with callback
- `CodeSession.onSubscribeDrone()` / `onUnsubscribeDrone()` — registers/unregisters monitor
## Project Manager View ## Project Manager View
The Project Manager presents: The Project Manager presents:

View File

@ -0,0 +1,83 @@
import { useState, useEffect, useRef } from 'react';
import DroneMonitorGauge from './DroneMonitorGauge';
interface DroneMonitorProps {
visible: boolean;
}
function generateWave(base: number, variance: number, length: number): number[] {
const wave: number[] = [];
for (let i = 0; i < length; i++) {
wave.push(base + Math.sin(i * 0.1) * variance + (Math.random() - 0.5) * variance * 0.5);
}
return wave;
}
export default function DroneMonitor({ visible }: DroneMonitorProps) {
const [cpuData, setCpuData] = useState<number[]>(() => generateWave(35, 20, 60));
const [netData, setNetData] = useState<number[]>(() => generateWave(20, 15, 60));
const [ioData, setIoData] = useState<number[]>(() => generateWave(10, 10, 60));
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!visible) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
intervalRef.current = setInterval(() => {
setCpuData((prev) => {
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 10))];
return next;
});
setNetData((prev) => {
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 8))];
return next;
});
setIoData((prev) => {
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 6))];
return next;
});
}, 200);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [visible]);
return (
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
<div className="p-2 border-b border-border-subtle bg-bg-tertiary">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Resource Monitor
</h3>
</div>
<div className="flex gap-1 p-1 justify-center">
<DroneMonitorGauge
label="CPU"
data={cpuData}
unit="%"
color="#c20600"
/>
<DroneMonitorGauge
label="NETWORK"
data={netData}
unit="%"
color="#06b6d4"
/>
<DroneMonitorGauge
label="FILE I/O"
data={ioData}
unit="%"
color="#22c55e"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,116 @@
import { useRef, useEffect } from 'react';
interface DroneMonitorGaugeProps {
label: string;
data: number[];
unit: string;
color: string;
width?: number;
height?: number;
}
export default function DroneMonitorGauge({
label,
data,
unit,
color,
width = 320,
height = 160,
}: DroneMonitorGaugeProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
// Background
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, width, height);
// Grid lines
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 1;
for (let y = 0; y < 4; y++) {
const yy = (height / 4) * y;
ctx.beginPath();
ctx.moveTo(0, yy);
ctx.lineTo(width, yy);
ctx.stroke();
}
// Border frame — sharp edge like studio equipment
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
if (data.length < 2) return;
// Plot the waveform
const padX = 8;
const padY = 8;
const plotW = width - padX * 2;
const plotH = height - padY * 2;
const maxVal = 100;
// Gradient fill under the trace
const gradient = ctx.createLinearGradient(0, padY, 0, padY + plotH);
gradient.addColorStop(0, color + '40');
gradient.addColorStop(1, color + '05');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(padX, padY + plotH);
for (let i = 0; i < data.length; i++) {
const x = padX + (i / (data.length - 1)) * plotW;
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
if (i === 0) ctx.lineTo(x, y);
else ctx.lineTo(x, y);
}
ctx.lineTo(padX + plotW, padY + plotH);
ctx.closePath();
ctx.fill();
// Trace line with glow
ctx.shadowColor = color;
ctx.shadowBlur = 6;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = padX + (i / (data.length - 1)) * plotW;
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Current value readout
const latest = data[data.length - 1] ?? 0;
ctx.fillStyle = '#d4d4d4';
ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${Math.round(latest)}${unit}`, width / 2, height - 12);
// Label
ctx.fillStyle = '#737373';
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(label, width / 2, 16);
}, [data, label, unit, color, width, height]);
return (
<canvas
ref={canvasRef}
style={{ width, height }}
className="block"
/>
);
}

View File

@ -1,4 +1,6 @@
import { useRef, useEffect, useState } from 'react'; import { useRef } from 'react';
import LogRenderer from './LogRenderer';
import type { LogRendererEntry } from './LogRenderer';
interface LogEntry { interface LogEntry {
id: string; id: string;
@ -15,48 +17,8 @@ interface LogPanelProps {
onToggleExpand: () => void; onToggleExpand: () => void;
} }
const levelColors: Record<string, string> = {
debug: 'text-gray-500',
info: 'text-green-400',
warn: 'text-yellow-400',
alert: 'text-red-400',
error: 'bg-red-800 text-white',
crit: 'bg-red-800 text-yellow-300',
fatal: 'bg-red-800 text-gray-400',
};
function formatTimestamp(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
}
export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelProps) { export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const toggleMetadata = (id: string) => {
setExpandedMetadata(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return ( return (
<div <div
@ -76,50 +38,7 @@ export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelPro
{expanded ? '▾' : '▴'} {expanded ? '▾' : '▴'}
</button> </button>
</div> </div>
<div <LogRenderer ref={scrollRef} logs={logs as LogRendererEntry[]} />
ref={scrollRef}
className="flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed"
>
{logs.length === 0 ? (
<div className="text-text-muted">No log entries</div>
) : (
logs.map((entry) => {
const levelClass = levelColors[entry.level] || 'text-text-secondary';
return (
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
<span className="text-gray-600 shrink-0 select-none">
{formatTimestamp(entry.timestamp)}
</span>
<span className={`shrink-0 font-bold ${levelClass}`}>
{entry.level.toUpperCase().padEnd(5)}
</span>
<span className="text-cyan-400 shrink-0">
{entry.component}
</span>
<span className="text-text-secondary break-all min-w-0">
{entry.message}
</span>
{entry.metadata != null && (
<button
onClick={() => toggleMetadata(entry.id)}
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
>
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
</button>
)}
{entry.metadata != null && expandedMetadata.has(entry.id) && (
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
<pre className="whitespace-pre-wrap break-all">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</div>
)}
</div>
);
})
)}
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,114 @@
import { useRef, useEffect, useState, forwardRef } from 'react';
export interface LogRendererEntry {
id: string;
timestamp: Date;
level: string;
component: string;
message: string;
metadata?: unknown;
}
interface LogRendererProps {
logs: LogRendererEntry[];
maxEntries?: number;
containerClassName?: string;
}
const levelColors: Record<string, string> = {
debug: 'text-gray-500',
info: 'text-green-400',
warn: 'text-yellow-400',
alert: 'text-red-400',
error: 'bg-red-800 text-white',
crit: 'bg-red-800 text-yellow-300',
fatal: 'bg-red-800 text-gray-400',
};
function formatTimestamp(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
}
const LogRenderer = forwardRef<HTMLDivElement, LogRendererProps>(
({ logs, containerClassName }, ref) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
useEffect(() => {
const target = ref && 'current' in ref ? ref : scrollRef;
if (target.current) {
target.current.scrollTop = target.current.scrollHeight;
}
}, [logs, ref]);
const toggleMetadata = (id: string) => {
setExpandedMetadata(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<div
ref={ref || scrollRef}
className={`flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed ${containerClassName ?? ''}`}
>
{logs.length === 0 ? (
<div className="text-text-muted">No log entries</div>
) : (
logs.map((entry) => {
const levelClass = levelColors[entry.level] || 'text-text-secondary';
return (
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
<span className="text-gray-600 shrink-0 select-none">
{formatTimestamp(entry.timestamp)}
</span>
<span className={`shrink-0 font-bold ${levelClass}`}>
{entry.level.toUpperCase().padEnd(5)}
</span>
<span className="text-cyan-400 shrink-0">
{entry.component}
</span>
<span className="text-text-secondary break-all min-w-0">
{entry.message}
</span>
{entry.metadata != null && (
<button
onClick={() => toggleMetadata(entry.id)}
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
>
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
</button>
)}
{entry.metadata != null && expandedMetadata.has(entry.id) && (
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
<pre className="whitespace-pre-wrap break-all">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</div>
)}
</div>
);
})
)}
</div>
);
},
);
LogRenderer.displayName = 'LogRenderer';
export default LogRenderer;

View File

@ -0,0 +1,84 @@
import type { SubProcessStat } from '../lib/api';
interface SubProcessTableProps {
processes: SubProcessStat[];
onSelect?: (pid: number) => void;
selectedPid?: number | null;
}
const statusDot: Record<string, string> = {
running: 'bg-green-500',
stopped: 'bg-yellow-500',
error: 'bg-red-500',
};
const statusLabel: Record<string, string> = {
running: '● running',
stopped: '● halted',
error: '● error',
};
export default function SubProcessTable({ processes, onSelect, selectedPid }: SubProcessTableProps) {
if (processes.length === 0) {
return (
<div className="p-4 bg-bg-secondary border border-border-default rounded text-center">
<p className="text-text-muted">No managed subprocesses.</p>
<p className="text-text-muted text-sm mt-1">
The agent has not spawned any processes for this session.
</p>
</div>
);
}
const runningCount = processes.filter((p) => p.status === 'running').length;
return (
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
<div className="p-2 border-b border-border-subtle bg-bg-tertiary flex items-center justify-between">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
SubProcess Monitor ({runningCount} running)
</h3>
<span className="text-xs text-text-muted">{processes.length} total</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-border-subtle text-text-muted">
<th className="text-left p-2 font-semibold">PID</th>
<th className="text-left p-2 font-semibold">CMD</th>
<th className="text-left p-2 font-semibold">PROJECT</th>
<th className="text-left p-2 font-semibold">STATUS</th>
<th className="text-right p-2 font-semibold">UPDATED</th>
</tr>
</thead>
<tbody>
{processes.map((proc) => (
<tr
key={proc.pid}
onClick={() => onSelect?.(proc.pid)}
className={`border-b border-border-subtle last:border-0 transition-colors ${
selectedPid === proc.pid
? 'bg-bg-elevated'
: onSelect
? 'cursor-pointer hover:bg-bg-elevated/50'
: ''
}`}
>
<td className="p-2 text-text-primary">{proc.pid}</td>
<td className="p-2 text-text-primary">{proc.command}</td>
<td className="p-2 text-text-secondary">{proc.projectSlug}</td>
<td className="p-2">
<span className={`${statusDot[proc.status] ?? 'bg-gray-500'} inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle`} />
<span className="text-text-secondary align-middle">{statusLabel[proc.status] ?? proc.status}</span>
</td>
<td className="p-2 text-text-muted text-right whitespace-nowrap">
{new Date(proc.updatedAt).toLocaleTimeString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -258,6 +258,20 @@ export const projectApi = {
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`), delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
}; };
export interface SubProcessStat {
pid: number;
command: string;
args: string[];
projectId: string;
projectSlug: string;
projectName: string;
status: "running" | "stopped" | "error";
createdAt: string;
updatedAt: string;
stdoutFileName: string;
stderrFileName: string;
}
export interface DroneRegistration { export interface DroneRegistration {
_id: string; _id: string;
hostname: string; hostname: string;

View File

@ -135,6 +135,17 @@ export interface SocketEvents {
fileReadResponse: (path: string, content: string | null, error?: string) => void; fileReadResponse: (path: string, content: string | null, error?: string) => void;
fileWriteResponse: (path: string, success: boolean, error?: string) => void; fileWriteResponse: (path: string, success: boolean, error?: string) => void;
status: (content: 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_attempt: (attempt: number) => void;
reconnect_failed: () => void; reconnect_failed: () => void;
reconnect: (attempt: number) => void; reconnect: (attempt: number) => void;
@ -303,6 +314,14 @@ class SocketClient {
this.emit("status", content); 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._socket.on("reconnect_attempt", (attempt: number) => {
this.emit("reconnect_attempt", attempt); this.emit("reconnect_attempt", attempt);
}); });
@ -505,6 +524,48 @@ class SocketClient {
}); });
} }
requestProcessStats(
registrationId: string,
): Promise<{
success: boolean;
processes?: { pid: number; command: string; args: string[]; projectId: string; projectSlug: string; projectName: string; status: string; createdAt: string; updatedAt: string; stdoutFileName: string; stderrFileName: string }[];
message?: string;
}> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"requestProcessStats",
registrationId,
(success: boolean, data?: { processes?: any[]; message?: string }) => {
resolve({ success, processes: data?.processes, message: data?.message });
},
);
} else {
resolve({ success: false, message: "Socket not connected" });
}
});
}
subscribeDrone(registrationId: string): Promise<boolean> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit("subscribeDrone", registrationId, resolve);
} else {
resolve(false);
}
});
}
unsubscribeDrone(registrationId: string): Promise<boolean> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit("unsubscribeDrone", registrationId, resolve);
} else {
resolve(false);
}
});
}
/** /**
* Sends a single sessionHeartbeat event to the server (which relays * Sends a single sessionHeartbeat event to the server (which relays
* it to the drone). The drone resets its 120-second timeout timer * it to the drone). The drone resets its 120-second timeout timer

View File

@ -1,30 +1,46 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import type { User, DroneRegistration } from '../lib/api'; import type { User, DroneRegistration, SubProcessStat } from '../lib/api';
import { droneApi } 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 { interface DroneManagerProps {
user: User | null; user: User | null;
} }
interface LogEntry { interface ToastState {
id: number;
timestamp: string;
message: string; message: string;
type: 'success' | 'error';
} }
let logIdCounter = 0;
export default function DroneManager({ user }: DroneManagerProps) { export default function DroneManager({ user }: DroneManagerProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [allDrones, setAllDrones] = useState<DroneRegistration[]>([]); const [allDrones, setAllDrones] = useState<DroneRegistration[]>([]);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null); const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [terminating, setTerminating] = useState<string | null>(null); const [terminating, setTerminating] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<ToastState | null>(null);
// Placeholder log entries for testing scroll behavior // SubProcess state
const [logEntries, setLogEntries] = useState<LogEntry[]>([]); const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
const logContainerRef = useRef<HTMLDivElement>(null); const [selectedPid, setSelectedPid] = useState<number | null>(null);
// Live log state
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const logContainerRef = useRef<HTMLDivElement>(null);
// Track cleanup
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const subscribedDroneRef = useRef<string | null>(null);
const droneChangeGeneration = useRef(0);
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
@ -34,43 +50,81 @@ export default function DroneManager({ user }: DroneManagerProps) {
loadDrones(); loadDrones();
}, [user]); }, [user]);
// Mock log generator for testing scroll behavior // Socket lifecycle — subscribe, poll, listen when selectedDrone changes
// TODO: Replace with live log streaming when backend is implemented
useEffect(() => { 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([]); setLogEntries([]);
return; return;
} }
// Initialize with some placeholder entries const regId = selectedDrone._id;
const initialEntries: LogEntry[] = Array.from({ length: 50 }, (_, i) => ({ subscribedDroneRef.current = regId;
id: i, setProcessStats([]);
timestamp: new Date(Date.now() - (50 - i) * 1000).toLocaleTimeString(), setLogEntries([]);
message: `[PLACEHOLDER] Log entry ${i + 1} - This is mock data for testing scroll behavior`,
}));
setLogEntries(initialEntries);
// Add a new entry every second for testing auto-scroll // Subscribe to live drone events
const interval = setInterval(() => { socketClient.subscribeDrone(regId);
setLogEntries(prev => {
const newEntry: LogEntry = { // Listen for log events
id: prev.length > 0 ? prev[prev.length - 1]!.id + 1 : 0, const handleLog = (data: { timestamp: string; level: string; component: string; message: string; metadata?: unknown }) => {
timestamp: new Date().toLocaleTimeString(), if (droneChangeGeneration.current !== gen) return;
message: `[PLACEHOLDER] New log entry at ${new Date().toLocaleTimeString()} - Auto-generated for testing`, 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]; const next = [...prev, entry];
// Keep only last 200 entries as per spec if (next.length > 200) {
if (updated.length > 200) { return next.slice(next.length - 200);
return updated.slice(updated.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]); }, [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(() => { useEffect(() => {
if (autoScroll && logContainerRef.current) { if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
@ -80,7 +134,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
const handleScroll = () => { const handleScroll = () => {
if (logContainerRef.current) { if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current; const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
// Check if user is near the bottom (within 100px)
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
setAutoScroll(isNearBottom); setAutoScroll(isNearBottom);
} }
@ -90,6 +143,17 @@ export default function DroneManager({ user }: DroneManagerProps) {
try { try {
const data = await droneApi.getForManager(); const data = await droneApi.getForManager();
setAllDrones(data); 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) { } catch (err) {
console.error('Failed to load drones', err); console.error('Failed to load drones', err);
showToast('Failed to load drones', 'error'); showToast('Failed to load drones', 'error');
@ -105,13 +169,12 @@ export default function DroneManager({ user }: DroneManagerProps) {
const handleSelectDrone = (drone: DroneRegistration) => { const handleSelectDrone = (drone: DroneRegistration) => {
setSelectedDrone(drone); setSelectedDrone(drone);
setLogEntries([]); // Clear log when switching drones setSelectedPid(null);
}; };
const handleTerminate = async () => { const handleTerminate = async () => {
if (!selectedDrone) return; if (!selectedDrone) return;
// Show confirmation if drone is busy
if (selectedDrone.status === '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.'); 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; if (!confirmed) return;
@ -127,10 +190,8 @@ export default function DroneManager({ user }: DroneManagerProps) {
showToast('Drone terminated successfully', 'success'); showToast('Drone terminated successfully', 'success');
} }
// Refresh drone list
await loadDrones(); await loadDrones();
// Clear selection if drone is now offline
if (selectedDrone.status !== 'offline') { if (selectedDrone.status !== 'offline') {
setSelectedDrone(null); setSelectedDrone(null);
} }
@ -155,7 +216,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
return ( return (
<div className="flex-1 flex bg-bg-primary overflow-hidden"> <div className="flex-1 flex bg-bg-primary overflow-hidden">
{/* Toast Notification */}
{toast && ( {toast && (
<div className={`fixed top-16 right-4 z-50 px-4 py-3 rounded border ${ <div className={`fixed top-16 right-4 z-50 px-4 py-3 rounded border ${
toast.type === 'success' toast.type === 'success'
@ -166,13 +226,11 @@ export default function DroneManager({ user }: DroneManagerProps) {
</div> </div>
)} )}
{/* Left Panel - Drone Lists */}
<aside className="w-80 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden"> <aside className="w-80 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
<div className="p-3 border-b border-border-subtle flex-shrink-0"> <div className="p-3 border-b border-border-subtle flex-shrink-0">
<h2 className="text-lg font-semibold text-text-primary">Drone Manager</h2> <h2 className="text-lg font-semibold text-text-primary">Drone Manager</h2>
</div> </div>
{/* Online Drones Section - 50% of list area */}
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}> <div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
<div className="p-2 border-b border-border-subtle flex-shrink-0 bg-bg-tertiary"> <div className="p-2 border-b border-border-subtle flex-shrink-0 bg-bg-tertiary">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider"> <h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
@ -227,7 +285,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
</div> </div>
</div> </div>
{/* Offline Drones Section - 50% of list area */}
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}> <div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
<div className="p-2 border-t border-border-subtle flex-shrink-0 bg-bg-tertiary"> <div className="p-2 border-t border-border-subtle flex-shrink-0 bg-bg-tertiary">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider"> <h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
@ -273,11 +330,9 @@ export default function DroneManager({ user }: DroneManagerProps) {
</div> </div>
</aside> </aside>
{/* Right Panel - Drone Details */}
<main className="flex-1 flex flex-col overflow-hidden bg-bg-primary"> <main className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
{selectedDrone ? ( {selectedDrone ? (
<> <>
{/* Drone Info Card */}
<div className="p-6 border-b border-border-subtle flex-shrink-0"> <div className="p-6 border-b border-border-subtle flex-shrink-0">
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -329,23 +384,16 @@ export default function DroneManager({ user }: DroneManagerProps) {
</div> </div>
</div> </div>
{/* Drone Monitor Placeholder */} <div className="flex-1 overflow-y-auto p-6 min-h-0 space-y-4">
<div className="flex-1 overflow-y-auto p-6 min-h-0"> <div className="max-w-3xl space-y-4">
<div className="max-w-3xl mb-6"> <SubProcessTable
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4"> processes={processStats}
Drone Monitor onSelect={setSelectedPid}
</h3> selectedPid={selectedPid}
<div className="p-8 bg-bg-secondary border border-border-default rounded text-center"> />
<p className="text-text-muted"> <DroneMonitor visible={!!selectedDrone && selectedDrone.status !== 'offline'} />
Monitor charts coming soon.
</p>
<p className="text-text-muted text-sm mt-2">
Memory usage, AI operations, and log production metrics will be displayed here.
</p>
</div>
</div> </div>
{/* Log Viewer - Collapsible panel at bottom */}
<div className="flex flex-col min-h-[300px] max-h-[400px] border-t border-border-subtle"> <div className="flex flex-col min-h-[300px] max-h-[400px] border-t border-border-subtle">
<div className="p-2 bg-bg-secondary border-b border-border-subtle flex-shrink-0"> <div className="p-2 bg-bg-secondary border-b border-border-subtle flex-shrink-0">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider"> <h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
@ -355,24 +403,9 @@ export default function DroneManager({ user }: DroneManagerProps) {
<div <div
ref={logContainerRef} ref={logContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto p-3 bg-bg-primary font-mono text-xs min-h-0" className="flex-1 overflow-y-auto"
> >
{logEntries.length === 0 ? ( <LogRenderer logs={logEntries} containerClassName="min-h-full" />
<div className="text-text-muted">
<p>No log entries yet.</p>
<p className="mt-2 text-text-secondary">
{/* TODO: Remove this placeholder comment when live logs are implemented */}
[PLACEHOLDER] Log entries will appear here when the drone is running.
</p>
</div>
) : (
logEntries.map((entry) => (
<div key={entry.id} className="py-1 border-b border-border-subtle last:border-0">
<span className="text-text-muted mr-3">[{entry.timestamp}]</span>
<span className="text-text-secondary">{entry.message}</span>
</div>
))
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { Link, useNavigate } from "react-router-dom"; import { 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 { projectApi, droneApi } from "../lib/api";
import { socketClient } from "../lib/socket";
import Clock from "../components/Clock"; import Clock from "../components/Clock";
import GadgetGrid from "../components/GadgetGrid"; import GadgetGrid from "../components/GadgetGrid";
import LogRenderer from "../components/LogRenderer";
import type { LogRendererEntry } from "../components/LogRenderer";
function SystemReady() { function SystemReady() {
return ( return (
@ -40,8 +43,59 @@ function DroneInspector({
drone: DroneRegistration; drone: DroneRegistration;
onClose: () => void; onClose: () => void;
}) { }) {
const navigate = useNavigate();
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
const mountedRef = useRef(true);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
mountedRef.current = true;
const regId = drone._id;
if (drone.status === 'offline') return;
socketClient.subscribeDrone(regId);
const handleLog = (data: { timestamp: string; level: string; component: string; message: string }) => {
if (!mountedRef.current) return;
setLogEntries((prev) => {
const next = [...prev, {
id: `insp-log-${prev.length}`,
timestamp: new Date(data.timestamp),
level: data.level,
component: data.component,
message: data.message,
}];
return next.length > 100 ? next.slice(next.length - 100) : next;
});
};
socketClient.on('drone:log', handleLog);
const poll = async () => {
if (!mountedRef.current) return;
const result = await socketClient.requestProcessStats(regId);
if (result.success && result.processes && mountedRef.current) {
setProcessStats(result.processes as SubProcessStat[]);
}
};
poll();
pollRef.current = setInterval(poll, 1000);
return () => {
mountedRef.current = false;
if (pollRef.current) clearInterval(pollRef.current);
socketClient.off('drone:log', handleLog);
socketClient.unsubscribeDrone(regId);
};
}, [drone._id, drone.status]);
const runningCount = processStats.filter((p) => p.status === 'running').length;
return ( return (
<div className="flex-1 flex items-center justify-center p-8"> <div className="flex-1 flex items-start justify-center p-8 overflow-y-auto">
<div className="max-w-lg w-full"> <div className="max-w-lg w-full">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Drone Inspector</h2> <h2 className="text-xl font-semibold">Drone Inspector</h2>
@ -80,6 +134,16 @@ function DroneInspector({
</span> </span>
</div> </div>
</div> </div>
<div>
<div className="text-sm text-text-muted">SubProcesses</div>
<div className="text-text-primary text-sm">
{runningCount > 0
? `${runningCount} running`
: processStats.length > 0
? 'All stopped'
: 'No managed processes'}
</div>
</div>
<div> <div>
<div className="text-sm text-text-muted">Registered</div> <div className="text-sm text-text-muted">Registered</div>
<div className="text-text-primary text-sm"> <div className="text-text-primary text-sm">
@ -87,6 +151,30 @@ function DroneInspector({
</div> </div>
</div> </div>
</div> </div>
{drone.status !== 'offline' && (
<div className="mt-4">
<button
onClick={() => navigate('/drones', { state: { droneId: drone._id } })}
className="w-full px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors text-sm"
>
Open in Drone Manager
</button>
</div>
)}
{drone.status !== 'offline' && logEntries.length > 0 && (
<div className="mt-4 border border-border-default rounded overflow-hidden max-h-48">
<div className="p-2 bg-bg-tertiary border-b border-border-subtle">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Live Log
</h3>
</div>
<div className="h-40 overflow-y-auto">
<LogRenderer logs={logEntries.slice(-50)} />
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -22,6 +22,7 @@ import {
WorkspaceMode, WorkspaceMode,
SubmitPromptCallback, SubmitPromptCallback,
AbortWorkOrderCallback, AbortWorkOrderCallback,
SubProcessStat,
} from "@gadget/api"; } from "@gadget/api";
import ChatSession from "../models/chat-session.ts"; 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("fileTreeRequest", this.onFileTreeRequest.bind(this));
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this)); this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
this.socket.on("fileWriteRequest", this.onFileWriteRequest.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 // Check for active session on connect
this.checkAndReestablishActiveSession(); 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 * Called when the IDE sends a releaseSessionLock event to release a
* previously-acquired session lock on a gadget-drone instance. * previously-acquired session lock on a gadget-drone instance.

View File

@ -81,47 +81,64 @@ export class DroneSession extends SocketSession {
message: string, message: string,
metadata?: unknown, metadata?: unknown,
): Promise<void> { ): Promise<void> {
if (!this.chatSessionId) { // Route to chat session if one is active
this.log.warn("log event received but no chat session is active"); if (this.chatSessionId) {
return; try {
const codeSession = SocketService.getCodeSessionByChatSessionId(
this.chatSessionId,
);
codeSession.onLog(timestamp, component, level, message, metadata);
} catch (error) {
await MessageQueue.enqueue(this.chatSessionId, {
type: 'log',
args: [timestamp, component, level, message, metadata],
timestamp: Date.now(),
});
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
}
} }
try { // Broadcast to monitoring sessions (DroneManager, DroneInspector, etc.)
const codeSession = SocketService.getCodeSessionByChatSessionId( SocketService.broadcastToMonitors(
this.chatSessionId, "drone:log",
); this.registration._id,
codeSession.onLog(timestamp, component, level, message, metadata); {
} catch (error) { timestamp: timestamp instanceof Date ? timestamp.toISOString() : String(timestamp),
// Routing failed - queue to Redis level: String(level),
await MessageQueue.enqueue(this.chatSessionId, { component: typeof component === 'object' && component !== null ? (component as any).name ?? String(component) : String(component),
type: 'log', message,
args: [timestamp, component, level, message, metadata], metadata,
timestamp: Date.now(), },
}); );
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
}
} }
async onStatus(message: string): Promise<void> { async onStatus(message: string): Promise<void> {
if (!this.chatSessionId) { // Route to chat session if one is active
this.log.warn("status event received but no chat session is active"); if (this.chatSessionId) {
return; try {
const codeSession = SocketService.getCodeSessionByChatSessionId(
this.chatSessionId,
);
codeSession.socket.emit("status", message);
} catch (error) {
await MessageQueue.enqueue(this.chatSessionId, {
type: 'status',
args: [message],
timestamp: Date.now(),
});
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
}
} }
try { // Broadcast to monitoring sessions
const codeSession = SocketService.getCodeSessionByChatSessionId( SocketService.broadcastToMonitors(
this.chatSessionId, "drone:status",
); this.registration._id,
codeSession.socket.emit("status", message); {
} catch (error) { timestamp: new Date().toISOString(),
// Routing failed - queue to Redis message,
await MessageQueue.enqueue(this.chatSessionId, { },
type: 'status', );
args: [message],
timestamp: Date.now(),
});
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
}
} }
/** /**

View File

@ -40,6 +40,13 @@ class SocketService extends DtpService {
>(); >();
private codeSessionUserIndex: CodeSessionMap = new Map<string, CodeSession>(); private codeSessionUserIndex: CodeSessionMap = new Map<string, CodeSession>();
/**
* Tracks which code sessions (by socket ID) are monitoring which drones
* (by registration ID). Used for broadcasting log/status events outside
* the chat session routing path.
*/
private droneMonitorIndex: Map<string, Set<string>> = new Map();
private io?: Server< private io?: Server<
ClientToServerEvents, ClientToServerEvents,
ServerToClientEvents, ServerToClientEvents,
@ -275,6 +282,62 @@ class SocketService extends DtpService {
return session; 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. * Registers a code session by its chat session ID for reverse lookup.
*/ */

View File

@ -5,7 +5,7 @@ The Gadget Code agent manages child processes through gadget-drone's SubProcess
## Phase Status ## Phase Status
- **Phase 1 (Complete):** SubProcessService + SubprocessTool internal to drone. - **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 ## 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/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 | | `src/tools/system/subprocess.test.ts` | SubprocessTool: all 6 commands, parameter validation, service error handling |
---
## Phase 2: SubProcess Observability
### Architecture
Phase 2 exposes the drone's managed subprocesses to the frontend via a **callback-chain pattern** over Socket.IO, identical to the existing `fileTreeRequest`/`fileReadRequest` flow:
```
IDE (DroneManager.tsx)
──emit("requestProcessStats", registrationId, cb)──▶
CodeSession.onRequestProcessStats()
──droneSession.socket.emit("requestProcessStats", cb)──▶
GadgetDrone.onRequestProcessStats()
│ SubProcessService.ps()
│ .summarize() each
│ → SubProcessStat[]
◀──cb(true, { processes })──
◀──ack callback fires──
◀──cb(success, data)──
```
### Socket Protocol
| Direction | Event | Signature |
|-----------|-------|-----------|
| IDE → Backend | `requestProcessStats` | `(registrationId: string, cb: RequestProcessStatsCallback) => void` |
| Backend → Drone | `requestProcessStats` | `(cb: RequestProcessStatsCallback) => void` (no registrationId — drone identifies itself) |
**Callback type:**
```typescript
type RequestProcessStatsCallback = (
success: boolean,
data?: { processes?: SubProcessStat[]; message?: string }
) => void;
```
### `SubProcessStat` Type
Defined in `packages/api/src/messages/subprocess.ts`:
```typescript
interface SubProcessStat {
pid: number;
command: string;
args: string[];
projectId: GadgetId;
projectSlug: string;
projectName: string;
status: "running" | "stopped" | "error";
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
stdoutFileName: string;
stderrFileName: string;
}
```
### Handler Behavior
The `onRequestProcessStats` handler in `gadget-drone.ts`:
1. Calls `SubProcessService.ps()` to get all tracked subprocesses
2. For each, calls `summarize()` for serializable fields
3. Determines status: checks `sp.process.exitCode !== null || sp.process.killed`
4. Extracts `command` and `args` from `sp.process.spawnargs`
5. Returns `cb(true, { processes })` on success, `cb(false, { message })` on error
### Live Log Streaming (Subscribe/Broadcast)
In addition to request/response stats, the drone continuously emits `log` events through its `GadgetLogTransportSocket`. Phase 2 adds a **monitor broadcast** path:
1. **IDE sends** `subscribeDrone(registrationId)` → Backend adds IDE's socket to a monitor set
2. **IDE sends** `unsubscribeDrone(registrationId)` → Backend removes from monitor set
3. **Drone emits** `log``DroneSession.onLog()` forwards to chat session AND broadcasts `drone:log` to all monitoring sockets
4. **Frontend** receives `drone:log` events and renders via `LogRenderer`
The monitor tracking lives in `SocketService.droneMonitorIndex` — a `Map<registrationId, Set<socketId>>`. Cleanup happens automatically on socket disconnect.
### Polling Interval
- **DroneManager**: 1-second interval polling `requestProcessStats`
- **DroneInspector (dashboard)**: 1-second interval polling
- Resource gauges (CPU, Network, File I/O): 200ms internal animation (mocked data)
- All timers are killed on unmount or drone deselection

View File

@ -20,6 +20,7 @@ import {
GadgetLog, GadgetLog,
GadgetLogTransportSocket, GadgetLogTransportSocket,
IUser, IUser,
SubProcessStat,
} from "@gadget/api"; } from "@gadget/api";
import { GadgetProcess } from "./lib/process.ts"; 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("fileTreeRequest", this.onFileTreeRequest.bind(this));
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this)); this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
this.socket.on("fileWriteRequest", this.onFileWriteRequest.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 * 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"); cb(aborted, aborted ? "Abort signaled" : "No active work order to abort");
} }
async onRequestProcessStats(
cb: (
success: boolean,
data?: { processes?: SubProcessStat[]; message?: string },
) => void,
): Promise<void> {
try {
const processes = SubProcessService.ps().map((sp) => {
const summary = SubProcessService.summarize(sp);
const hasExited = sp.process.exitCode !== null || sp.process.killed;
const stat: SubProcessStat = {
pid: summary.pid,
command: sp.process.spawnargs[0] ?? "unknown",
args: sp.process.spawnargs.slice(1),
projectId: summary.project._id,
projectSlug: summary.project.slug,
projectName: summary.project.name,
status: hasExited ? "stopped" : "running",
createdAt: summary.createdAt.toISOString(),
updatedAt: summary.updatedAt.toISOString(),
stdoutFileName: summary.stdoutFileName,
stderrFileName: summary.stderrFileName,
};
return stat;
});
cb(true, { processes });
} catch (error) {
this.log.error("failed to collect process stats", { error });
cb(false, { message: "Failed to collect process stats" });
}
}
async onRequestTermination(cb: (success: boolean) => void): Promise<void> { async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
this.log.info("requestTermination received from platform", { this.log.info("requestTermination received from platform", {
registrationId: this.registration?._id, registrationId: this.registration?._id,

View File

@ -31,6 +31,7 @@ export * from "./interfaces/workspace.ts";
export * from "./messages/ide.ts"; export * from "./messages/ide.ts";
export * from "./messages/drone.ts"; export * from "./messages/drone.ts";
export * from "./messages/subprocess.ts";
export * from "./messages/web.ts"; export * from "./messages/web.ts";
export * from "./messages/socket.ts"; export * from "./messages/socket.ts";

View File

@ -30,6 +30,12 @@ import {
FileWriteRequestMessage, FileWriteRequestMessage,
FileWriteResponseMessage, FileWriteResponseMessage,
} from "./ide.ts"; } 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 There are two different kinds of clients that connect to the gadget-code:web
@ -64,6 +70,14 @@ export interface ClientToServerEvents {
fileReadRequest: FileReadRequestMessage; fileReadRequest: FileReadRequestMessage;
fileWriteRequest: FileWriteRequestMessage; fileWriteRequest: FileWriteRequestMessage;
/*
* Drone Monitor events (IDE => web => drone)
*/
requestProcessStats: RequestProcessStatsMessage;
subscribeDrone: SubscribeDroneMessage;
unsubscribeDrone: UnsubscribeDroneMessage;
/* /*
* gadget-drone => gadget-code:web * gadget-drone => gadget-code:web
*/ */
@ -130,10 +144,32 @@ export interface ServerToClientEvents {
fileReadRequest: FileReadRequestMessage; fileReadRequest: FileReadRequestMessage;
fileWriteRequest: FileWriteRequestMessage; 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 * 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; log: LogMessage;
status: StatusMessage; status: StatusMessage;
thinking: ThinkingMessage; thinking: ThinkingMessage;

View File

@ -0,0 +1,39 @@
// src/messages/subprocess.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { GadgetId } from "../lib/gadget-id.ts";
export interface SubProcessStat {
pid: number;
command: string;
args: string[];
projectId: GadgetId;
projectSlug: string;
projectName: string;
status: "running" | "stopped" | "error";
createdAt: string;
updatedAt: string;
stdoutFileName: string;
stderrFileName: string;
}
export type RequestProcessStatsCallback = (
success: boolean,
data?: { processes?: SubProcessStat[]; message?: string },
) => void;
export type RequestProcessStatsMessage = (
registrationId: string,
cb: RequestProcessStatsCallback,
) => void;
export type SubscribeDroneMessage = (
registrationId: string,
cb: (success: boolean) => void,
) => void;
export type UnsubscribeDroneMessage = (
registrationId: string,
cb: (success: boolean) => void,
) => void;