opencode-dashboard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/routes.ts CHANGED
@@ -21,9 +21,16 @@ import {
21
21
  createSSEResponse,
22
22
  closeAllClients,
23
23
  clientCount,
24
+ setClientCountChangeCallback,
24
25
  reset as resetSSE,
25
26
  } from "./sse";
26
27
  import { join } from "path";
28
+ import { computeBuildHash } from "../shared/version";
29
+
30
+ // --- Build Hash ---
31
+
32
+ /** Computed once at module load time (same process as server/index.ts) */
33
+ const buildHash = computeBuildHash();
27
34
 
28
35
  // --- Static File Serving ---
29
36
 
@@ -51,6 +58,36 @@ const MIME_TYPES: Record<string, string> = {
51
58
  /** Central state manager — processes events and persists to disk */
52
59
  export const stateManager = new StateManager();
53
60
 
61
+ // --- Column Visibility → SSE Bridge ---
62
+
63
+ /**
64
+ * Listen for column visibility changes from the StateManager and broadcast
65
+ * them as `columns:update` SSE events to all connected dashboard frontends.
66
+ *
67
+ * The StateManager emits `columns:visibility` events (via its onEvent listener
68
+ * mechanism) whenever broadcastColumnsUpdate() detects that the visible column
69
+ * set has changed. This bridge forwards those events to SSE clients so the
70
+ * frontend can update its column layout in real time.
71
+ *
72
+ * The StateManager already handles:
73
+ * - Deduplication (only emits when visible set actually changes)
74
+ * - Grace period timers for pipeline column hiding
75
+ * - Only triggering on column-affecting events (bead:stage, bead:done, etc.)
76
+ */
77
+ const _unsubscribeColumnsListener = stateManager.onEvent((event, data) => {
78
+ if (event === "columns:visibility") {
79
+ const { projectPath, visibleColumns } = data as {
80
+ projectPath: string;
81
+ visibleColumns: unknown[];
82
+ };
83
+ broadcast("columns:update", {
84
+ projectPath,
85
+ visibleColumns,
86
+ _serverTimestamp: Date.now(),
87
+ });
88
+ }
89
+ });
90
+
54
91
  // --- Plugin Registry ---
55
92
 
56
93
  interface PluginRecord {
@@ -67,6 +104,91 @@ const plugins = new Map<string, PluginRecord>();
67
104
  /** Server start time for uptime calculation */
68
105
  const startTime = Date.now();
69
106
 
107
+ // --- Idle Auto-Shutdown ---
108
+
109
+ /** Handle for the idle shutdown timer (null when not ticking) */
110
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
111
+
112
+ /** Idle timeout in milliseconds; 0 or negative disables the feature */
113
+ let idleTimeoutMs: number = 300_000;
114
+
115
+ /**
116
+ * Configure the idle auto-shutdown feature.
117
+ *
118
+ * Sets the idle timeout value and registers a callback on SSE client
119
+ * count changes so that the idle timer is started/cleared automatically
120
+ * when all dashboard browser tabs connect or disconnect.
121
+ */
122
+ export function configureIdleShutdown(timeoutMs: number): void {
123
+ idleTimeoutMs = timeoutMs;
124
+
125
+ setClientCountChangeCallback(() => {
126
+ if (clientCount() === 0) {
127
+ checkAndStartIdleTimer();
128
+ } else {
129
+ clearIdleTimer();
130
+ }
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Start the idle shutdown timer if the server is truly idle
136
+ * (no registered plugins AND no connected SSE clients).
137
+ *
138
+ * Does nothing if:
139
+ * - The feature is disabled (idleTimeoutMs <= 0)
140
+ * - There are still plugins or clients connected
141
+ * - A timer is already ticking
142
+ */
143
+ export function checkAndStartIdleTimer(): void {
144
+ if (idleTimeoutMs <= 0) return;
145
+
146
+ if (plugins.size > 0 || clientCount() > 0) {
147
+ clearIdleTimer();
148
+ return;
149
+ }
150
+
151
+ if (idleTimer !== null) return; // already ticking
152
+
153
+ const timeoutSec = Math.round(idleTimeoutMs / 1000);
154
+ console.log(
155
+ `[server] No plugins or clients connected. Auto-shutdown in ${timeoutSec}s...`
156
+ );
157
+
158
+ idleTimer = setTimeout(() => {
159
+ idleTimer = null;
160
+ // Race-condition guard: re-check conditions before pulling the trigger
161
+ if (plugins.size > 0 || clientCount() > 0) {
162
+ console.log(
163
+ "[server] Idle timer fired but activity detected — shutdown aborted."
164
+ );
165
+ return;
166
+ }
167
+ console.log("[server] Idle timeout reached — shutting down.");
168
+ // Dynamic require to avoid circular dependency at module load time:
169
+ // routes.ts ↔ index.ts both import from each other, but shutdown()
170
+ // is only needed at runtime (inside a setTimeout callback), so lazy
171
+ // resolution via require() defers the import until it's actually called.
172
+ const { shutdown: performShutdown } = require("./index");
173
+ performShutdown();
174
+ }, idleTimeoutMs);
175
+
176
+ // Don't let the timer keep the process alive on its own
177
+ idleTimer.unref();
178
+ }
179
+
180
+ /**
181
+ * Cancel a running idle shutdown timer (e.g. when a plugin registers
182
+ * or an SSE client connects).
183
+ */
184
+ export function clearIdleTimer(): void {
185
+ if (idleTimer !== null) {
186
+ clearTimeout(idleTimer);
187
+ idleTimer = null;
188
+ console.log("[server] Idle timer cancelled — activity detected.");
189
+ }
190
+ }
191
+
70
192
  // --- Helpers ---
71
193
 
72
194
  /** Add CORS headers for localhost origins */
@@ -171,6 +293,9 @@ async function handleRegister(
171
293
  pluginId,
172
294
  });
173
295
 
296
+ // A new plugin means activity — cancel any pending idle shutdown
297
+ clearIdleTimer();
298
+
174
299
  return json({ pluginId }, 200, origin);
175
300
  }
176
301
 
@@ -334,6 +459,9 @@ function handleDeregister(
334
459
  pluginId,
335
460
  });
336
461
 
462
+ // Plugin removed — check if the server is now idle
463
+ checkAndStartIdleTimer();
464
+
337
465
  return json({ ok: true }, 200, origin);
338
466
  }
339
467
 
@@ -378,6 +506,7 @@ function handleHealth(origin?: string | null): Response {
378
506
  uptime: Math.floor((Date.now() - startTime) / 1000),
379
507
  plugins: plugins.size,
380
508
  sseClients: clientCount(),
509
+ buildHash,
381
510
  },
382
511
  200,
383
512
  origin
@@ -506,6 +635,8 @@ const pluginHealthInterval = setInterval(() => {
506
635
  pluginId,
507
636
  reason: "heartbeat_timeout",
508
637
  });
638
+ // Plugin timed out — check if server is now idle
639
+ checkAndStartIdleTimer();
509
640
  }
510
641
  }
511
642
  }, PLUGIN_HEALTH_CHECK_INTERVAL_MS);
package/server/sse.ts CHANGED
@@ -27,6 +27,17 @@ const clients = new Set<SSEClient>();
27
27
 
28
28
  const encoder = new TextEncoder();
29
29
 
30
+ /** Optional callback invoked whenever the connected client count changes */
31
+ let onClientCountChange: (() => void) | null = null;
32
+
33
+ /**
34
+ * Register a callback that fires whenever the SSE client count changes
35
+ * (connect, disconnect, or failed send/heartbeat removal).
36
+ */
37
+ export function setClientCountChangeCallback(cb: () => void): void {
38
+ onClientCountChange = cb;
39
+ }
40
+
30
41
  /**
31
42
  * Get the current number of connected SSE clients.
32
43
  */
@@ -61,6 +72,7 @@ export function broadcast(eventName: string, data: unknown): void {
61
72
  client.controller.enqueue(encoded);
62
73
  } catch {
63
74
  clients.delete(client);
75
+ try { onClientCountChange?.(); } catch { /* callback error must not disrupt broadcast loop */ }
64
76
  }
65
77
  }
66
78
  }
@@ -97,6 +109,7 @@ export function createSSEResponse(
97
109
  connectedAt: Date.now(),
98
110
  };
99
111
  clients.add(sseClient);
112
+ onClientCountChange?.();
100
113
 
101
114
  // Set reconnect interval
102
115
  controller.enqueue(encoder.encode("retry: 3000\n\n"));
@@ -122,6 +135,7 @@ export function createSSEResponse(
122
135
  cancel() {
123
136
  if (sseClient) {
124
137
  clients.delete(sseClient);
138
+ onClientCountChange?.();
125
139
  }
126
140
  },
127
141
  });
@@ -152,6 +166,7 @@ const sseHeartbeatInterval = setInterval(() => {
152
166
  client.controller.enqueue(heartbeat);
153
167
  } catch {
154
168
  clients.delete(client);
169
+ try { onClientCountChange?.(); } catch { /* callback error must not disrupt heartbeat loop */ }
155
170
  }
156
171
  }
157
172
  }, SSE_HEARTBEAT_INTERVAL_MS);
@@ -189,6 +204,7 @@ export function reset(): void {
189
204
  }
190
205
  clients.clear();
191
206
  messageId = 0;
207
+ onClientCountChange = null;
192
208
  }
193
209
 
194
210
  // --- Exports for testing ---