Skip to content

Commit 2377e2a

Browse files
authored
Merge pull request #24 from AgentWorkforce/log-stream-inline-and-hardening
log viewer and better presence
2 parents 6e1c483 + a2cf157 commit 2377e2a

13 files changed

Lines changed: 1194 additions & 103 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Ring buffer for storing recent WebSocket messages.
3+
* Used to replay missed messages when clients reconnect after brief disconnects.
4+
*/
5+
6+
export interface BufferedMessage {
7+
id: number;
8+
timestamp: number;
9+
type: string;
10+
payload: string;
11+
}
12+
13+
export class MessageBuffer {
14+
private buffer: (BufferedMessage | null)[];
15+
private capacity: number;
16+
private writeIndex: number;
17+
private sequenceCounter: number;
18+
19+
constructor(capacity: number = 500) {
20+
this.capacity = capacity;
21+
this.buffer = new Array(capacity).fill(null);
22+
this.writeIndex = 0;
23+
this.sequenceCounter = 0;
24+
}
25+
26+
/**
27+
* Push a new message into the buffer.
28+
* Returns the assigned sequence ID.
29+
*/
30+
push(type: string, payload: string): number {
31+
this.sequenceCounter++;
32+
const message: BufferedMessage = {
33+
id: this.sequenceCounter,
34+
timestamp: Date.now(),
35+
type,
36+
payload,
37+
};
38+
this.buffer[this.writeIndex] = message;
39+
this.writeIndex = (this.writeIndex + 1) % this.capacity;
40+
return this.sequenceCounter;
41+
}
42+
43+
/**
44+
* Get all messages with an ID greater than the given sequence ID.
45+
* Returns messages in chronological order.
46+
*/
47+
getAfter(sequenceId: number): BufferedMessage[] {
48+
const results: BufferedMessage[] = [];
49+
for (let i = 0; i < this.capacity; i++) {
50+
const msg = this.buffer[i];
51+
if (msg && msg.id > sequenceId) {
52+
results.push(msg);
53+
}
54+
}
55+
// Sort by id to ensure chronological order
56+
results.sort((a, b) => a.id - b.id);
57+
return results;
58+
}
59+
60+
/**
61+
* Get all messages with a timestamp greater than the given timestamp.
62+
* Returns messages in chronological order.
63+
*/
64+
getAfterTimestamp(ts: number): BufferedMessage[] {
65+
const results: BufferedMessage[] = [];
66+
for (let i = 0; i < this.capacity; i++) {
67+
const msg = this.buffer[i];
68+
if (msg && msg.timestamp > ts) {
69+
results.push(msg);
70+
}
71+
}
72+
// Sort by id to ensure chronological order
73+
results.sort((a, b) => a.id - b.id);
74+
return results;
75+
}
76+
77+
/**
78+
* Get the current sequence ID (the ID of the last pushed message).
79+
* Returns 0 if no messages have been pushed.
80+
*/
81+
currentId(): number {
82+
return this.sequenceCounter;
83+
}
84+
}

packages/dashboard-server/src/server.ts

Lines changed: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
getSupportedProviders,
7171
} from '@agent-relay/daemon';
7272
import { HealthWorkerManager, getHealthPort } from './services/health-worker-manager.js';
73+
import { MessageBuffer } from './messageBuffer.js';
7374

7475
/**
7576
* Get the host to bind to.
@@ -655,8 +656,11 @@ export async function startDashboard(
655656
};
656657

657658
// Initialize spawner if enabled
658-
// Use detectWorkspacePath to find the actual repo directory in cloud workspaces
659-
const workspacePath = detectWorkspacePath(projectRoot || dataDir);
659+
// When projectRoot is explicitly provided (e.g., via --project-root), use it directly.
660+
// Only use detectWorkspacePath for cloud workspace auto-detection when no explicit root is given.
661+
// This fixes #380: detectWorkspacePath could re-resolve projectRoot incorrectly when
662+
// tool directories like ~/.nvm contain package.json markers.
663+
const workspacePath = projectRoot || detectWorkspacePath(dataDir);
660664
console.log(`[dashboard] Workspace path: ${workspacePath}`);
661665

662666
// When an external SpawnManager is provided (from the daemon), use it for read operations
@@ -769,16 +773,32 @@ export async function startDashboard(
769773
const fileWatchers = new Map<string, fs.FSWatcher>();
770774
const fileLastSize = new Map<string, number>();
771775

776+
// Message buffers for replay on reconnect
777+
// Main buffer stores broadcast messages for the main dashboard WebSocket
778+
const mainMessageBuffer = new MessageBuffer(500);
779+
// Per-agent log buffers store log output for each agent (smaller capacity since per-agent)
780+
const agentLogBuffers = new Map<string, MessageBuffer>();
781+
782+
/** Get or create a log buffer for an agent */
783+
const getAgentLogBuffer = (agentName: string): MessageBuffer => {
784+
let buffer = agentLogBuffers.get(agentName);
785+
if (!buffer) {
786+
buffer = new MessageBuffer(200);
787+
agentLogBuffers.set(agentName, buffer);
788+
}
789+
return buffer;
790+
};
791+
772792
// Track alive status for ping/pong keepalive on main dashboard connections
773793
// This prevents TCP/proxy timeouts from killing idle workspace connections
774794
const mainClientAlive = new WeakMap<WebSocket, boolean>();
775795

776796
// Track alive status for ping/pong keepalive on bridge connections
777797
const bridgeClientAlive = new WeakMap<WebSocket, boolean>();
778798

779-
// Ping interval for main dashboard WebSocket connections (30 seconds)
780-
// Aligns with heartbeat timeout (5s heartbeat * 6 multiplier = 30s)
781-
const MAIN_PING_INTERVAL_MS = 30000;
799+
// Ping interval for main dashboard WebSocket connections (15 seconds)
800+
// Reduced from 30s to detect disconnects faster and minimize message loss window
801+
const MAIN_PING_INTERVAL_MS = 15000;
782802
const mainPingInterval = setInterval(() => {
783803
wss.clients.forEach((ws) => {
784804
if (mainClientAlive.get(ws) === false) {
@@ -793,8 +813,9 @@ export async function startDashboard(
793813
});
794814
}, MAIN_PING_INTERVAL_MS);
795815

796-
// Ping interval for bridge WebSocket connections (30 seconds)
797-
const BRIDGE_PING_INTERVAL_MS = 30000;
816+
// Ping interval for bridge WebSocket connections (15 seconds)
817+
// Reduced from 30s to detect disconnects faster and minimize message loss window
818+
const BRIDGE_PING_INTERVAL_MS = 15000;
798819
const bridgePingInterval = setInterval(() => {
799820
wssBridge.clients.forEach((ws) => {
800821
if (bridgeClientAlive.get(ws) === false) {
@@ -2141,7 +2162,8 @@ export async function startDashboard(
21412162
}
21422163
// Extract model from spawn command (e.g., "codex --model gpt-5.2-codex" → "gpt-5.2-codex")
21432164
if (worker.cli) {
2144-
const modelMatch = worker.cli.match(/--model\s+(\S+)/);
2165+
// Support both `--model foo` and `--model=foo`
2166+
const modelMatch = worker.cli.match(/--model[=\s]+(\S+)/);
21452167
if (modelMatch) {
21462168
agent.model = modelMatch[1];
21472169
}
@@ -2251,14 +2273,18 @@ export async function startDashboard(
22512273
const broadcastData = async () => {
22522274
try {
22532275
const data = await getAllData();
2254-
const payload = JSON.stringify(data);
2276+
const rawPayload = JSON.stringify(data);
22552277

22562278
// Guard against empty/invalid payloads
2257-
if (!payload || payload.length === 0) {
2279+
if (!rawPayload || rawPayload.length === 0) {
22582280
console.warn('[dashboard] Skipping broadcast - empty payload');
22592281
return;
22602282
}
22612283

2284+
// Push into buffer and wrap with sequence ID for replay support
2285+
const seq = mainMessageBuffer.push('data', rawPayload);
2286+
const payload = JSON.stringify({ seq, ...data });
2287+
22622288
wss.clients.forEach(client => {
22632289
// Skip clients that are still being initialized by the connection handler
22642290
if (initializingClients.has(client)) {
@@ -2375,6 +2401,11 @@ export async function startDashboard(
23752401
mainClientAlive.set(ws, true);
23762402
});
23772403

2404+
// Send current sequence ID so client can track its position
2405+
if (ws.readyState === WebSocket.OPEN) {
2406+
ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
2407+
}
2408+
23782409
// Mark as initializing to prevent broadcastData from sending before we do
23792410
initializingClients.add(ws);
23802411

@@ -2402,6 +2433,42 @@ export async function startDashboard(
24022433
initializingClients.delete(ws);
24032434
}
24042435

2436+
// Handle messages from client (replay requests, etc.)
2437+
ws.on('message', (data) => {
2438+
try {
2439+
const msg = JSON.parse(data.toString());
2440+
2441+
// Handle replay request: client sends { type: "replay", lastSequenceId: N }
2442+
if (msg.type === 'replay' && typeof msg.lastSequenceId === 'number') {
2443+
const missed = mainMessageBuffer.getAfter(msg.lastSequenceId);
2444+
const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
2445+
2446+
console.log(`[dashboard] Client replaying ${missed.length} missed messages (gap: ${gapMs}ms)`);
2447+
2448+
// Send each missed message with its original sequence ID
2449+
for (const buffered of missed) {
2450+
if (ws.readyState === WebSocket.OPEN) {
2451+
try {
2452+
// Reconstruct the payload with the seq wrapper
2453+
const original = JSON.parse(buffered.payload);
2454+
ws.send(JSON.stringify({ seq: buffered.id, ...original }));
2455+
} catch (err) {
2456+
console.error('[dashboard] Failed to replay message:', err);
2457+
}
2458+
}
2459+
}
2460+
2461+
// Send current sync position after replay
2462+
if (ws.readyState === WebSocket.OPEN) {
2463+
ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
2464+
}
2465+
}
2466+
} catch (err) {
2467+
// Non-JSON messages are ignored (binary, etc.)
2468+
debug(`[dashboard] Unhandled main WebSocket message: ${err}`);
2469+
}
2470+
});
2471+
24052472
ws.on('error', (err) => {
24062473
console.error('[dashboard] WebSocket client error:', err);
24072474
});
@@ -2445,9 +2512,9 @@ export async function startDashboard(
24452512
// Track alive status for ping/pong keepalive on log connections
24462513
const logClientAlive = new WeakMap<WebSocket, boolean>();
24472514

2448-
// Ping interval for log WebSocket connections (30 seconds)
2449-
// This prevents TCP/proxy timeouts from killing idle connections
2450-
const LOG_PING_INTERVAL_MS = 30000;
2515+
// Ping interval for log WebSocket connections (15 seconds)
2516+
// Reduced from 30s to detect disconnects faster and minimize message loss window
2517+
const LOG_PING_INTERVAL_MS = 15000;
24512518
const logPingInterval = setInterval(() => {
24522519
wssLogs.clients.forEach((ws) => {
24532520
if (logClientAlive.get(ws) === false) {
@@ -2480,6 +2547,11 @@ export async function startDashboard(
24802547
logClientAlive.set(ws, true);
24812548
});
24822549

2550+
// Send sync message with current server timestamp so client can track its position
2551+
if (ws.readyState === WebSocket.OPEN) {
2552+
ws.send(JSON.stringify({ type: 'sync', serverTimestamp: Date.now() }));
2553+
}
2554+
24832555
// Helper to check if agent is daemon-connected (from agents.json)
24842556
const isDaemonConnected = (agentName: string): boolean => {
24852557
const agentsPath = path.join(teamDir, 'agents.json');
@@ -2572,6 +2644,9 @@ export async function startDashboard(
25722644
timestamp: new Date().toISOString(),
25732645
});
25742646

2647+
// Push into per-agent log buffer for replay on reconnect
2648+
getAgentLogBuffer(agentName).push('output', payload);
2649+
25752650
for (const client of clients) {
25762651
if (client.readyState === WebSocket.OPEN) {
25772652
client.send(payload);
@@ -2763,6 +2838,31 @@ export async function startDashboard(
27632838
}));
27642839
}
27652840
}
2841+
2842+
// Handle replay request: client sends { type: "replay", agent: "name", lastTimestamp: N }
2843+
// Logs use timestamps instead of sequence IDs since the data is raw text
2844+
if (msg.type === 'replay' && typeof msg.agent === 'string' && typeof msg.lastTimestamp === 'number') {
2845+
const logBuffer = agentLogBuffers.get(msg.agent);
2846+
if (logBuffer) {
2847+
const missed = logBuffer.getAfterTimestamp(msg.lastTimestamp);
2848+
const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
2849+
2850+
console.log(`[dashboard] Client replaying ${missed.length} missed log messages for ${msg.agent} (gap: ${gapMs}ms)`);
2851+
2852+
// Send replay as a structured response the client expects
2853+
if (ws.readyState === WebSocket.OPEN) {
2854+
try {
2855+
const entries = missed.map(m => ({
2856+
content: m.payload,
2857+
timestamp: m.timestamp,
2858+
}));
2859+
ws.send(JSON.stringify({ type: 'replay', entries }));
2860+
} catch (err) {
2861+
console.error('[dashboard] Failed to replay log messages:', err);
2862+
}
2863+
}
2864+
}
2865+
}
27662866
} catch (err) {
27672867
console.error('[dashboard] Invalid logs WebSocket message:', err);
27682868
}
@@ -2784,6 +2884,7 @@ export async function startDashboard(
27842884
watcher.close();
27852885
fileWatchers.delete(agentName);
27862886
fileLastSize.delete(agentName);
2887+
agentLogBuffers.delete(agentName);
27872888
console.log(`[dashboard] Stopped watching log file for: ${agentName}`);
27882889
}
27892890
}
@@ -2843,12 +2944,17 @@ export async function startDashboard(
28432944
}
28442945
}
28452946

2846-
const payload = JSON.stringify({
2947+
const logPayload = {
28472948
type: 'output',
28482949
agent: agentName,
28492950
data: output,
28502951
timestamp: new Date().toISOString(),
2851-
});
2952+
};
2953+
const payload = JSON.stringify(logPayload);
2954+
2955+
// Push into per-agent log buffer for replay on reconnect
2956+
// Logs use timestamps instead of sequence IDs since the data is raw text
2957+
getAgentLogBuffer(agentName).push('output', payload);
28522958

28532959
for (const client of clients) {
28542960
if (client.readyState === WebSocket.OPEN) {
@@ -2886,7 +2992,10 @@ export async function startDashboard(
28862992
mentions?: string[];
28872993
timestamp: string;
28882994
}) => {
2889-
const payload = JSON.stringify(message);
2995+
// Push into buffer and wrap with sequence ID for replay support
2996+
const rawPayload = JSON.stringify(message);
2997+
const seq = mainMessageBuffer.push('channel_message', rawPayload);
2998+
const payload = JSON.stringify({ seq, ...message });
28902999
// Broadcast to main WebSocket clients (local mode)
28913000
wss.clients.forEach((client) => {
28923001
if (client.readyState === WebSocket.OPEN) {
@@ -2914,7 +3023,10 @@ export async function startDashboard(
29143023
messageId: string;
29153024
timestamp: string;
29163025
}) => {
2917-
const payload = JSON.stringify(message);
3026+
// Push into buffer and wrap with sequence ID for replay support
3027+
const rawPayload = JSON.stringify(message);
3028+
const seq = mainMessageBuffer.push('direct_message', rawPayload);
3029+
const payload = JSON.stringify({ seq, ...message });
29183030

29193031
// Broadcast to main WebSocket clients (local mode)
29203032
const mainClients = Array.from(wss.clients).filter(c => c.readyState === WebSocket.OPEN);

0 commit comments

Comments
 (0)