@pleri/olam-cli 0.1.102 → 0.1.104

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.
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "auth": "sha256:4853cf21c4f63ffcc0e44bf1b1e1240aa5bd33348cbced2e1e2960aca0744484",
3
- "devbox": "sha256:90fc750afe69e792042809276546b9fdd372691561776dfbfc4c9ad835b985f2",
4
- "devbox-base": "sha256:473ff96011309dccefefba830f332bbab394950c774f7bcd75d18751b79797d2",
5
- "host-cp": "sha256:9af7f646a39eac1d68bf8de80a450fa531da0c73f082d2724429a01e2704e796",
3
+ "devbox": "sha256:99529690b25e22f1819366ee001d84979a9d4de714ba1b0353b1b1a7f9eb8805",
4
+ "devbox-base": "sha256:5e4bc98029f99eeab7fb542b333899675884c2404a1925eafa19768d31451e23",
5
+ "host-cp": "sha256:82de19cb665e7e368e5133e263bc3a837da03ac614f4687f05bf4a72dccaf0c1",
6
6
  "mcp-auth": "sha256:5da67023e96f685589676b191105fa260eaa0199ef1af43e7a63f982880c7bf7",
7
7
  "$schema_version": 1,
8
- "$published_version": "0.1.102",
8
+ "$published_version": "0.1.104",
9
9
  "$registry": "ghcr.io/pleri"
10
10
  }
package/dist/index.js CHANGED
@@ -10218,6 +10218,34 @@ var init_bootstrap_hooks = __esm({
10218
10218
  });
10219
10219
 
10220
10220
  // ../core/dist/world/tmux-supervisor.js
10221
+ function injectBindAll(start) {
10222
+ let result = start;
10223
+ const userBoundRails = /(^|\s)(-b|--binding)(\s+|=)\S+/.test(start);
10224
+ const userBoundNext = /(^|\s)(-H|--hostname)(\s+|=)\S+/.test(start);
10225
+ const userBoundVite = /(^|\s)--host(\s+\S+|=\S+)/.test(start);
10226
+ const userBoundGunicorn = /(^|\s)(-h|--bind)(\s+|=)\S+/.test(start);
10227
+ const userHostEnv = /(^|\s)HOST=\S+/.test(start);
10228
+ const anyUserBound = userBoundRails || userBoundNext || userBoundVite || userBoundGunicorn;
10229
+ if (!userHostEnv && !anyUserBound) {
10230
+ result = `HOST=0.0.0.0 ${result}`;
10231
+ }
10232
+ if (!userBoundRails && /\brails\s+s(?:erver)?\b/.test(start)) {
10233
+ result = `${result} -b 0.0.0.0`;
10234
+ }
10235
+ if (!userBoundNext && /\bnext\s+dev\b/.test(start)) {
10236
+ result = `${result} -H 0.0.0.0`;
10237
+ }
10238
+ if (!userBoundVite && /\b(?:^|\s|=)vite\b/.test(start)) {
10239
+ result = `${result} --host 0.0.0.0`;
10240
+ }
10241
+ if (!userBoundVite && /\b(?:flask\s+run|uvicorn|hypercorn)\b/.test(start)) {
10242
+ result = `${result} --host 0.0.0.0`;
10243
+ }
10244
+ if (!userBoundGunicorn && /\bgunicorn\b/.test(start)) {
10245
+ result = `${result} --bind 0.0.0.0`;
10246
+ }
10247
+ return result;
10248
+ }
10221
10249
  async function startSupervisedApps(containerName, worldId, repos, exec, options = {}) {
10222
10250
  if (!SAFE_IDENT2.test(containerName)) {
10223
10251
  throw new Error(`containerName "${containerName}" must match ${SAFE_IDENT2} (defensive guard)`);
@@ -10253,7 +10281,7 @@ async function startSupervisedApps(containerName, worldId, repos, exec, options
10253
10281
  windowsExisting.push(repo.name);
10254
10282
  continue;
10255
10283
  }
10256
- const expandedStart = repo.start.replace(/\$\{?PORT\}?/g, String(repo.hostPort));
10284
+ const expandedStart = injectBindAll(repo.start).replace(/\$\{?PORT\}?/g, String(repo.hostPort));
10257
10285
  const startCmd = `cd ${shellQuote2(repo.dir)} && exec ${expandedStart}`;
10258
10286
  exec(containerName, `tmux new-window -t ${sessionName} -n ${repo.name} -d ${shellQuote2(startCmd)}`);
10259
10287
  windowsCreated.push(repo.name);
@@ -31318,6 +31318,34 @@ async function runSeedHooks(seeds, containerName, servicePortMap, exec) {
31318
31318
  }
31319
31319
 
31320
31320
  // ../core/dist/world/tmux-supervisor.js
31321
+ function injectBindAll(start) {
31322
+ let result = start;
31323
+ const userBoundRails = /(^|\s)(-b|--binding)(\s+|=)\S+/.test(start);
31324
+ const userBoundNext = /(^|\s)(-H|--hostname)(\s+|=)\S+/.test(start);
31325
+ const userBoundVite = /(^|\s)--host(\s+\S+|=\S+)/.test(start);
31326
+ const userBoundGunicorn = /(^|\s)(-h|--bind)(\s+|=)\S+/.test(start);
31327
+ const userHostEnv = /(^|\s)HOST=\S+/.test(start);
31328
+ const anyUserBound = userBoundRails || userBoundNext || userBoundVite || userBoundGunicorn;
31329
+ if (!userHostEnv && !anyUserBound) {
31330
+ result = `HOST=0.0.0.0 ${result}`;
31331
+ }
31332
+ if (!userBoundRails && /\brails\s+s(?:erver)?\b/.test(start)) {
31333
+ result = `${result} -b 0.0.0.0`;
31334
+ }
31335
+ if (!userBoundNext && /\bnext\s+dev\b/.test(start)) {
31336
+ result = `${result} -H 0.0.0.0`;
31337
+ }
31338
+ if (!userBoundVite && /\b(?:^|\s|=)vite\b/.test(start)) {
31339
+ result = `${result} --host 0.0.0.0`;
31340
+ }
31341
+ if (!userBoundVite && /\b(?:flask\s+run|uvicorn|hypercorn)\b/.test(start)) {
31342
+ result = `${result} --host 0.0.0.0`;
31343
+ }
31344
+ if (!userBoundGunicorn && /\bgunicorn\b/.test(start)) {
31345
+ result = `${result} --bind 0.0.0.0`;
31346
+ }
31347
+ return result;
31348
+ }
31321
31349
  var PortInUseError = class extends Error {
31322
31350
  port;
31323
31351
  repo;
@@ -31366,7 +31394,7 @@ async function startSupervisedApps(containerName, worldId, repos, exec, options
31366
31394
  windowsExisting.push(repo.name);
31367
31395
  continue;
31368
31396
  }
31369
- const expandedStart = repo.start.replace(/\$\{?PORT\}?/g, String(repo.hostPort));
31397
+ const expandedStart = injectBindAll(repo.start).replace(/\$\{?PORT\}?/g, String(repo.hostPort));
31370
31398
  const startCmd = `cd ${shellQuote2(repo.dir)} && exec ${expandedStart}`;
31371
31399
  exec(containerName, `tmux new-window -t ${sessionName} -n ${repo.name} -d ${shellQuote2(startCmd)}`);
31372
31400
  windowsCreated.push(repo.name);
@@ -0,0 +1,443 @@
1
+ // Phase A → E (sse-consolidation): server-side multiplexed-SSE broadcaster.
2
+ //
3
+ // Single endpoint /api/host-stream replaces ~20 SPA polling loops. Hooks
4
+ // subscribe to typed events on one connection instead of opening one
5
+ // setInterval-loop per resource.
6
+ //
7
+ // Mirrors planOrchestrator.addEventSink fanout pattern verbatim — same
8
+ // per-sink ServerResponse Set, same `event: <name>\ndata: <json>\n\n`
9
+ // wire format, same cleanup-on-disconnect contract. Differences:
10
+ //
11
+ // - Keyed by event TYPE rather than conversationId (the broadcaster is
12
+ // global to the host-cp, not per-conversation).
13
+ // - Caches last-known payload per event type so reconnecting clients
14
+ // receive an immediate snapshot replay before live updates resume.
15
+ // - No turn-buffering — snapshots are idempotent so reconnect == latest.
16
+ //
17
+ // Phase E adds operational polish:
18
+ // - E1: per-event-type trailing-edge debounce (default 100ms).
19
+ // Coalesces broadcast storms during world boot.
20
+ // - E2: per-sink 25s heartbeat (`:\n\n` comment) to keep idle SSE
21
+ // connections alive across most proxy 60s timeouts.
22
+ // - E3: backpressure-aware writes — slow sinks queue up to a bounded
23
+ // in-memory buffer; overflow drops oldest events with an
24
+ // `:overflow` comment so consumers know they missed updates.
25
+ // - E4: per-event-type broadcast counter + sink count metric line.
26
+ //
27
+ // Pure module: no docker, no DB, no global clock except `setInterval`
28
+ // for the heartbeat/metrics timers (injectable in tests). Wiring those
29
+ // sources to broadcast(...) lives in server.mjs (A4 + A5).
30
+ //
31
+ // References:
32
+ // - packages/host-cp/src/server.mjs:1531 SSE writer template
33
+ // - packages/host-cp/src/plan-orchestrator.mjs:967 addEventSink shape
34
+ // - docs/plans/sse-consolidation/plan-source.md full design
35
+ // - docs/plans/sse-consolidation/phase-e-tasks.md E1-E4 acceptance
36
+
37
+ import crypto from 'node:crypto';
38
+
39
+ /**
40
+ * @typedef {object} HostStreamDeps
41
+ * @property {(message: string) => void} [log] defaults to no-op
42
+ * @property {object} [debounceMs] per-event-type debounce override
43
+ * @property {number} [debounceMs.default] default trailing-edge ms (Phase E1)
44
+ * @property {number} [heartbeatMs] per-sink heartbeat interval (Phase E2)
45
+ * @property {number} [metricsMs] per-broadcaster metrics tick (Phase E4)
46
+ * @property {number} [maxQueuedPerSink] bounded queue size (Phase E3)
47
+ * @property {(cb: () => void, ms: number) => any} [setTimer] injectable setInterval (tests)
48
+ * @property {(handle: any) => void} [clearTimer] injectable clearInterval (tests)
49
+ */
50
+
51
+ /**
52
+ * @typedef {object} HostStream
53
+ * @property {(res: import('node:http').ServerResponse) => () => void} addSink
54
+ * @property {(eventType: string, payload: unknown) => number} broadcast
55
+ * @property {() => Record<string, unknown>} snapshot
56
+ * @property {() => void} close
57
+ * @property {() => number} sinkCount
58
+ * @property {() => HostStreamMetrics} metrics
59
+ * @property {() => void} flushDebounced test-only — fire all pending coalesced broadcasts immediately
60
+ */
61
+
62
+ /**
63
+ * @typedef {object} HostStreamMetrics
64
+ * @property {Record<string, number>} events per-event-type broadcasts since last reset
65
+ * @property {number} sinks current active-sink count
66
+ * @property {number} overflows total `:overflow` drops since last reset
67
+ */
68
+
69
+ const DEFAULT_DEBOUNCE_MS = 100;
70
+ const DEFAULT_HEARTBEAT_MS = 25_000;
71
+ const DEFAULT_METRICS_MS = 60_000;
72
+ const DEFAULT_MAX_QUEUED = 64;
73
+
74
+ /**
75
+ * Event types that opt INTO the trailing-edge debounce (Phase E1). The
76
+ * default callers — `world.snapshot`, `tunnels.snapshot`, `servers.snapshot`,
77
+ * `listening.snapshot` — are all idempotent state-replay events where
78
+ * "last writer wins" is correct and a 100ms cap on update propagation
79
+ * is acceptable. Latency-sensitive events (`question.pending`) and
80
+ * connect-only events (`ready`) stay immediate by NOT being in this set.
81
+ *
82
+ * Per-event-type overrides via `deps.debounceMs[type] = 0` force any
83
+ * event off the debounce path; non-zero override flips it on with a
84
+ * custom window. Callers should not need to opt anything new into
85
+ * debouncing — adding a new snapshot event implies adding to this set.
86
+ */
87
+ const DEFAULT_DEBOUNCED_EVENTS = new Set([
88
+ 'world.snapshot',
89
+ 'tunnels.snapshot',
90
+ 'servers.snapshot',
91
+ 'listening.snapshot',
92
+ ]);
93
+
94
+ /**
95
+ * Create a host-stream broadcaster. Stateless w.r.t. the request — all
96
+ * source-of-truth wiring (docker events, worlds.db, etc.) is done by
97
+ * the caller via repeated `broadcast()` invocations.
98
+ *
99
+ * @param {HostStreamDeps} [deps]
100
+ * @returns {HostStream}
101
+ */
102
+ export function createHostStream(deps = {}) {
103
+ const log = deps.log ?? (() => {});
104
+ const defaultDebounceMs = deps.debounceMs?.default ?? DEFAULT_DEBOUNCE_MS;
105
+ const heartbeatMs = deps.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
106
+ const metricsMs = deps.metricsMs ?? DEFAULT_METRICS_MS;
107
+ const maxQueuedPerSink = deps.maxQueuedPerSink ?? DEFAULT_MAX_QUEUED;
108
+ const setTimer = deps.setTimer ?? ((cb, ms) => setInterval(cb, ms));
109
+ const clearTimer = deps.clearTimer ?? ((h) => clearInterval(h));
110
+
111
+ /**
112
+ * @typedef {object} SinkState
113
+ * @property {import('node:http').ServerResponse} res
114
+ * @property {string[]} queue
115
+ * @property {boolean} paused true while waiting for a `drain` event
116
+ * @property {boolean} draining true while flushQueue is iterating
117
+ * @property {boolean} drainListenerAttached
118
+ * @property {any | null} heartbeatHandle
119
+ * @property {number} overflows
120
+ */
121
+
122
+ /** @type {Map<import('node:http').ServerResponse, SinkState>} */
123
+ const sinks = new Map();
124
+
125
+ /** @type {Map<string, unknown>} last-known payload per event type */
126
+ const snapshots = new Map();
127
+
128
+ /** @type {Map<string, any>} pending debounce timers per event type */
129
+ const debounceTimers = new Map();
130
+
131
+ /** Per-event-type broadcast counters since last metrics flush. */
132
+ const eventCounters = new Map();
133
+ let overflowCounter = 0;
134
+
135
+ let closed = false;
136
+ let metricsHandle = null;
137
+
138
+ function formatEvent(eventType, payload) {
139
+ return `event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`;
140
+ }
141
+
142
+ /**
143
+ * Queue-aware write. If the underlying socket's `res.write` returns
144
+ * `false` we buffer the chunk in the per-sink queue and register a
145
+ * one-shot `drain` listener to flush it when the kernel reports the
146
+ * socket is writable again. On overflow we emit `:overflow` so
147
+ * consumers know they missed updates and drop oldest.
148
+ *
149
+ * @returns {boolean} true if the chunk was accepted (synchronously or
150
+ * queued) — false only when the sink is dead and was removed.
151
+ */
152
+ function writeSafe(state, chunk) {
153
+ const { res } = state;
154
+ if (res.writableEnded || res.destroyed) return false;
155
+
156
+ // If a previous write reported backpressure (returned false), queue
157
+ // unconditionally — preserves event ordering. The drain handler
158
+ // flushes the queue in FIFO order.
159
+ if (state.paused) {
160
+ enqueue(state, chunk);
161
+ return true;
162
+ }
163
+
164
+ try {
165
+ const ok = res.write(chunk);
166
+ if (ok) return true;
167
+ // Returned false — kernel buffer is full. Switch to queue mode so
168
+ // subsequent writes don't race past this one.
169
+ state.paused = true;
170
+ attachDrain(state);
171
+ return true;
172
+ } catch {
173
+ // Sink already closed — drop it; further writes would throw.
174
+ teardownSink(res);
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function enqueue(state, chunk) {
180
+ if (state.queue.length >= maxQueuedPerSink) {
181
+ // Drop oldest, emit :overflow comment when the drain eventually
182
+ // flushes. The overflow comment is enqueued (not written directly)
183
+ // so consumers see it inline with surrounding events.
184
+ state.queue.shift();
185
+ state.overflows += 1;
186
+ overflowCounter += 1;
187
+ if (!state.queue.some((s) => s === ':overflow\n\n')) {
188
+ state.queue.unshift(':overflow\n\n');
189
+ }
190
+ }
191
+ state.queue.push(chunk);
192
+ attachDrain(state);
193
+ }
194
+
195
+ function attachDrain(state) {
196
+ if (state.drainListenerAttached) return;
197
+ const { res } = state;
198
+ if (typeof res.once !== 'function') return; // testing-sink fallback
199
+ state.drainListenerAttached = true;
200
+ res.once('drain', () => {
201
+ state.drainListenerAttached = false;
202
+ flushQueue(state);
203
+ });
204
+ }
205
+
206
+ function flushQueue(state) {
207
+ const { res } = state;
208
+ if (state.draining) return;
209
+ state.draining = true;
210
+ state.paused = false;
211
+ try {
212
+ while (state.queue.length > 0) {
213
+ if (res.writableEnded || res.destroyed) {
214
+ state.queue.length = 0;
215
+ break;
216
+ }
217
+ const next = state.queue[0];
218
+ let ok = false;
219
+ try {
220
+ ok = res.write(next);
221
+ } catch {
222
+ teardownSink(res);
223
+ return;
224
+ }
225
+ state.queue.shift();
226
+ if (!ok) {
227
+ state.paused = true;
228
+ attachDrain(state);
229
+ break;
230
+ }
231
+ }
232
+ } finally {
233
+ state.draining = false;
234
+ }
235
+ }
236
+
237
+ function teardownSink(res) {
238
+ const state = sinks.get(res);
239
+ if (!state) return;
240
+ if (state.heartbeatHandle) {
241
+ try { clearTimer(state.heartbeatHandle); } catch { /* ignore */ }
242
+ state.heartbeatHandle = null;
243
+ }
244
+ state.queue.length = 0;
245
+ sinks.delete(res);
246
+ }
247
+
248
+ function doBroadcast(eventType, payload) {
249
+ if (closed) return 0;
250
+ snapshots.set(eventType, payload);
251
+ eventCounters.set(eventType, (eventCounters.get(eventType) ?? 0) + 1);
252
+ const chunk = formatEvent(eventType, payload);
253
+ let reached = 0;
254
+ // Snapshot the iteration order so concurrent sink removal during
255
+ // a write doesn't skip a sibling sink.
256
+ for (const state of [...sinks.values()]) {
257
+ if (writeSafe(state, chunk)) reached += 1;
258
+ }
259
+ return reached;
260
+ }
261
+
262
+ function flushDebounced() {
263
+ for (const [type, info] of debounceTimers) {
264
+ clearTimeout(info.handle);
265
+ debounceTimers.delete(type);
266
+ doBroadcast(type, info.payload);
267
+ }
268
+ }
269
+
270
+ function logMetrics() {
271
+ if (eventCounters.size === 0 && sinks.size === 0 && overflowCounter === 0) return;
272
+ /** @type {Record<string, number>} */
273
+ const events = {};
274
+ for (const [type, count] of eventCounters) events[type] = count;
275
+ log(`events=${JSON.stringify(events)} sinks=${sinks.size}${overflowCounter > 0 ? ` overflows=${overflowCounter}` : ''}`);
276
+ eventCounters.clear();
277
+ overflowCounter = 0;
278
+ }
279
+
280
+ // Start the metrics tick eagerly — operators want visibility from
281
+ // boot, not just after the first event lands.
282
+ if (metricsMs > 0) {
283
+ metricsHandle = setTimer(logMetrics, metricsMs);
284
+ // Don't pin the event loop just for metrics in tests / shutdown paths.
285
+ if (metricsHandle && typeof metricsHandle.unref === 'function') metricsHandle.unref();
286
+ }
287
+
288
+ return {
289
+ addSink(res) {
290
+ if (closed) {
291
+ // Best-effort: end the response so the client sees the channel
292
+ // closing instead of hanging on an empty stream.
293
+ try { res.end(); } catch { /* ignore */ }
294
+ return () => {};
295
+ }
296
+
297
+ const state = /** @type {SinkState} */ ({
298
+ res,
299
+ queue: [],
300
+ paused: false,
301
+ draining: false,
302
+ drainListenerAttached: false,
303
+ heartbeatHandle: null,
304
+ overflows: 0,
305
+ });
306
+ sinks.set(res, state);
307
+
308
+ // Replay last-known snapshot for every event type so the new
309
+ // subscriber gets current state without waiting for the next change.
310
+ // Sorting keeps test assertions deterministic.
311
+ const types = [...snapshots.keys()].sort();
312
+ for (const type of types) {
313
+ writeSafe(state, formatEvent(type, snapshots.get(type)));
314
+ }
315
+
316
+ // Phase E2: per-sink heartbeat. Write a comment line every
317
+ // `heartbeatMs` so the SSE channel survives idle proxies. The
318
+ // comment is invisible to client EventSource listeners (the
319
+ // browser passes only `event:`/`data:` lines through), so this
320
+ // does NOT trigger any handler — it's pure connection-keepalive.
321
+ if (heartbeatMs > 0) {
322
+ state.heartbeatHandle = setTimer(() => {
323
+ // Use writeSafe so backpressure / overflow handling applies
324
+ // uniformly. Heartbeats that fail to flush are uninteresting
325
+ // — the regular broadcast loop will discover the dead sink.
326
+ writeSafe(state, ':\n\n');
327
+ }, heartbeatMs);
328
+ if (state.heartbeatHandle && typeof state.heartbeatHandle.unref === 'function') {
329
+ state.heartbeatHandle.unref();
330
+ }
331
+ }
332
+
333
+ return () => {
334
+ teardownSink(res);
335
+ };
336
+ },
337
+
338
+ broadcast(eventType, payload) {
339
+ if (closed) return 0;
340
+ if (typeof eventType !== 'string' || eventType.length === 0) {
341
+ throw new TypeError('broadcast: eventType must be a non-empty string');
342
+ }
343
+
344
+ // Phase E1: opt-in trailing-edge debounce.
345
+ // - DEFAULT_DEBOUNCED_EVENTS opts the canonical snapshot events
346
+ // into trailing-edge coalescing. Last writer wins because those
347
+ // events are idempotent state replays.
348
+ // - Every other event type bypasses the timer and writes
349
+ // immediately — preserves the Phase A synchronous broadcast
350
+ // contract that existing tests / consumers depend on.
351
+ // - Per-event-type overrides via `deps.debounceMs[eventType]`
352
+ // win in both directions (set to 0 to disable, or specify a
353
+ // custom window).
354
+ // - `flushDebounced()` is exposed for tests that want to assert
355
+ // immediate effects without waiting for the timer.
356
+ let debounceFor;
357
+ const override = deps.debounceMs?.[eventType];
358
+ if (override !== undefined) {
359
+ debounceFor = override;
360
+ } else if (DEFAULT_DEBOUNCED_EVENTS.has(eventType)) {
361
+ debounceFor = defaultDebounceMs;
362
+ } else {
363
+ debounceFor = 0;
364
+ }
365
+
366
+ if (debounceFor <= 0) {
367
+ // Take the immediate path; flush any pending coalesce for this
368
+ // type first so order is preserved.
369
+ const pending = debounceTimers.get(eventType);
370
+ if (pending) {
371
+ clearTimeout(pending.handle);
372
+ debounceTimers.delete(eventType);
373
+ }
374
+ return doBroadcast(eventType, payload);
375
+ }
376
+
377
+ // Coalesce: keep the latest payload, restart the trailing timer.
378
+ const pending = debounceTimers.get(eventType);
379
+ if (pending) clearTimeout(pending.handle);
380
+ const handle = setTimeout(() => {
381
+ debounceTimers.delete(eventType);
382
+ doBroadcast(eventType, payload);
383
+ }, debounceFor);
384
+ if (typeof handle.unref === 'function') handle.unref();
385
+ debounceTimers.set(eventType, { handle, payload });
386
+ // Returns sinks.size as an approximation; the actual broadcast
387
+ // will happen after the trailing-edge delay. Tests assert via the
388
+ // sink writes anyway.
389
+ return sinks.size;
390
+ },
391
+
392
+ snapshot() {
393
+ /** @type {Record<string, unknown>} */
394
+ const out = {};
395
+ for (const [type, payload] of snapshots) out[type] = payload;
396
+ return out;
397
+ },
398
+
399
+ close() {
400
+ if (closed) return;
401
+ closed = true;
402
+ // Cancel pending debounce timers — anything still queued is
403
+ // discarded; we don't write to sinks during shutdown.
404
+ for (const [, info] of debounceTimers) clearTimeout(info.handle);
405
+ debounceTimers.clear();
406
+ if (metricsHandle) {
407
+ try { clearTimer(metricsHandle); } catch { /* ignore */ }
408
+ metricsHandle = null;
409
+ }
410
+ for (const [res, state] of [...sinks.entries()]) {
411
+ if (state.heartbeatHandle) {
412
+ try { clearTimer(state.heartbeatHandle); } catch { /* ignore */ }
413
+ }
414
+ try { res.end(); } catch { /* ignore */ }
415
+ sinks.delete(res);
416
+ }
417
+ log('closed');
418
+ },
419
+
420
+ sinkCount() {
421
+ return sinks.size;
422
+ },
423
+
424
+ metrics() {
425
+ /** @type {Record<string, number>} */
426
+ const events = {};
427
+ for (const [type, count] of eventCounters) events[type] = count;
428
+ return { events, sinks: sinks.size, overflows: overflowCounter };
429
+ },
430
+
431
+ flushDebounced,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Generate a fresh streamId for the `ready` event payload. Exposed so
437
+ * route handlers can attach the same id to log lines and the wire.
438
+ *
439
+ * @returns {string}
440
+ */
441
+ export function newStreamId() {
442
+ return crypto.randomBytes(8).toString('hex');
443
+ }
@@ -34,6 +34,7 @@ import { computeProgress } from './world-progress.mjs';
34
34
  import { createPrCache } from './pr-cache.mjs';
35
35
  import { fetchContainerSecret } from './container-secret-fetcher.mjs';
36
36
  import { subscribeDockerEvents } from './docker-events.mjs';
37
+ import { createHostStream, newStreamId } from './host-stream.mjs';
37
38
  import { spawnUpgraderContainer } from './upgrade-spawner.mjs';
38
39
  import { parseProxyPath, perWorldBase, proxyToWorld } from './proxy.mjs';
39
40
  import { StartupToken } from './auth.mjs';
@@ -422,11 +423,219 @@ let prListCacheEntry = null; // /api/prs — 60s TTL global PR list for Cmd+K
422
423
  const sseGate = new SseGate({ maxConcurrent: SSE_CAP });
423
424
 
424
425
  // Subscribe to docker events on boot. Unsubscribed at shutdown.
426
+ //
427
+ // Two consumers now: the legacy secret-cache invalidator (Phase F-2-B B3)
428
+ // AND the host-stream broadcaster (sse-consolidation Phase A4). The
429
+ // broadcaster pushes a debounced `servers.snapshot` whenever an
430
+ // `olam-*-devbox` container starts/stops so the SPA can drop its
431
+ // poll-every-2s `useListeningServers` loop.
432
+ const hostStream = createHostStream({ log: (m) => console.log(`[host-stream] ${m}`) });
433
+
434
+ // A4: coalesce docker-event bursts into a single servers.snapshot. World
435
+ // boot fires `create` + `start` + healthcheck transitions in <100ms; we
436
+ // don't want a broadcast storm. Window matches plan-source.md P3 target.
437
+ const SERVERS_SNAPSHOT_DEBOUNCE_MS = 100;
438
+ let serversSnapshotTimer = null;
439
+ function scheduleServersSnapshot() {
440
+ if (serversSnapshotTimer) return;
441
+ serversSnapshotTimer = setTimeout(() => {
442
+ serversSnapshotTimer = null;
443
+ const ids = Object.keys(WORLDS).sort();
444
+ hostStream.broadcast('servers.snapshot', {
445
+ servers: ids.map((id) => ({ id, port: WORLDS[id] })),
446
+ snapshotAt: new Date().toISOString(),
447
+ });
448
+ }, SERVERS_SNAPSHOT_DEBOUNCE_MS);
449
+ }
450
+
425
451
  const stopEvents = subscribeDockerEvents({
426
452
  dockerHost: DOCKER_HOST,
427
- onWorldRestart: (worldId) => cache.invalidate(worldId),
453
+ onWorldRestart: (worldId) => {
454
+ cache.invalidate(worldId);
455
+ // Only push the snapshot for olam-* world containers; the
456
+ // onWorldRestart handler is already filtered to `olam-<id>-devbox`
457
+ // by handleEvent in docker-events.mjs, so any worldId reaching
458
+ // this callback is by construction an olam world.
459
+ scheduleServersSnapshot();
460
+ },
428
461
  });
429
462
 
463
+ // Initial servers.snapshot so subscribers connecting before any docker
464
+ // event have a current snapshot to replay from.
465
+ scheduleServersSnapshot();
466
+
467
+ // A5: 1-Hz worlds.db hash-diff loop. The worlds-db reconciler above
468
+ // reacts to fs.watch + every 30s; that's enough to keep the WORLDS
469
+ // registry consistent, but the SPA wants sub-second freshness on
470
+ // create/destroy. Hash the composed worlds list each tick, broadcast
471
+ // only on change. Pure-server-side polling — never touches the network.
472
+ const WORLDS_SNAPSHOT_TICK_MS = 1000;
473
+ let lastWorldsHash = '';
474
+ let worldsSnapshotTimer = null;
475
+ let worldsSnapshotInFlight = false;
476
+
477
+ async function tickWorldsSnapshot() {
478
+ if (worldsSnapshotInFlight) return;
479
+ worldsSnapshotInFlight = true;
480
+ try {
481
+ const worlds = await composeWorldsSources(worldsSources);
482
+ // Stable JSON for hashing — JSON.stringify is order-sensitive, so
483
+ // sort the array by id before serialising. Sources already return
484
+ // stable shape per world.
485
+ const sorted = [...worlds].sort((a, b) =>
486
+ String(a?.id ?? '').localeCompare(String(b?.id ?? '')),
487
+ );
488
+ const json = JSON.stringify(sorted);
489
+ const { createHash } = await import('node:crypto');
490
+ const hash = createHash('sha1').update(json).digest('hex');
491
+ if (hash === lastWorldsHash) return;
492
+ lastWorldsHash = hash;
493
+ hostStream.broadcast('world.snapshot', {
494
+ worlds: sorted,
495
+ snapshotAt: new Date().toISOString(),
496
+ });
497
+ } catch (err) {
498
+ // Don't kill the loop on transient compose errors. Worst case the
499
+ // next tick succeeds and the diff is detected then.
500
+ console.warn(`[host-stream] worlds.snapshot tick failed: ${err?.message ?? err}`);
501
+ } finally {
502
+ worldsSnapshotInFlight = false;
503
+ }
504
+ }
505
+
506
+ function startWorldsSnapshotLoop() {
507
+ // Initial broadcast on host-cp start so the first subscriber gets
508
+ // current state before the next tick fires.
509
+ void tickWorldsSnapshot();
510
+ worldsSnapshotTimer = setInterval(() => { void tickWorldsSnapshot(); }, WORLDS_SNAPSHOT_TICK_MS);
511
+ }
512
+
513
+ function stopWorldsSnapshotLoop() {
514
+ if (worldsSnapshotTimer) {
515
+ clearInterval(worldsSnapshotTimer);
516
+ worldsSnapshotTimer = null;
517
+ }
518
+ }
519
+
520
+ // ── Phase B-bonus: tunnels.snapshot + listening.snapshot ─────────────
521
+ //
522
+ // Phase A wired `world.snapshot` (worlds.db) and `servers.snapshot`
523
+ // (docker events for the host worlds-port map). The remaining Phase-B
524
+ // hooks need additional broadcasters so they can drop their poll loops:
525
+ //
526
+ // - usePublishedTunnels → `tunnels.snapshot` (per-row, was 4 req/min × N rows on /api/worlds/<id>/tunnels)
527
+ // - useListeningServers → `listening.snapshot` (per-world, scoped via filter)
528
+ //
529
+ // Both broadcast a SINGLE aggregate event with all worlds' data; hooks
530
+ // filter by worldId client-side. host-stream caches by event type so a
531
+ // reconnecting tab gets the full set on connect (replay).
532
+ //
533
+ // Cadence: 2s for tunnels (state changes on startTunnel/stopTunnel are
534
+ // fast, polling catches missed updates), 5s for listening servers
535
+ // (matches existing per-world poll cadence). The poller is server-side
536
+ // so the network never carries N polls per refresh window.
537
+ //
538
+ // Note: `tunnels.snapshot` is hash-debounced so idle windows produce
539
+ // zero broadcasts. Same pattern as world.snapshot.
540
+
541
+ const TUNNELS_SNAPSHOT_TICK_MS = 2_000;
542
+ const LISTENING_SNAPSHOT_TICK_MS = 5_000;
543
+ let tunnelsSnapshotTimer = null;
544
+ let listeningSnapshotTimer = null;
545
+ let lastTunnelsHash = '';
546
+ let lastListeningHash = '';
547
+
548
+ async function tickTunnelsSnapshot() {
549
+ try {
550
+ const byWorld = tunnelManager.getAllTunnels();
551
+ // Stable JSON for hashing — sort worldIds + service names so
552
+ // identical state produces identical hash.
553
+ const ids = Object.keys(byWorld).sort();
554
+ const stable = ids.map((id) => ({
555
+ worldId: id,
556
+ tunnels: [...byWorld[id]].sort((a, b) => a.name.localeCompare(b.name)),
557
+ }));
558
+ const json = JSON.stringify(stable);
559
+ const { createHash } = await import('node:crypto');
560
+ const hash = createHash('sha1').update(json).digest('hex');
561
+ if (hash === lastTunnelsHash) return;
562
+ lastTunnelsHash = hash;
563
+ hostStream.broadcast('tunnels.snapshot', {
564
+ worlds: stable,
565
+ snapshotAt: new Date().toISOString(),
566
+ });
567
+ } catch (err) {
568
+ console.warn(`[host-stream] tunnels.snapshot tick failed: ${err?.message ?? err}`);
569
+ }
570
+ }
571
+
572
+ async function tickListeningSnapshot() {
573
+ try {
574
+ // Lazy import to keep the boot path light; the poller module also
575
+ // owns the docker exec details.
576
+ const { getListeningServers } = await import('./listening-server-poller.mjs');
577
+ const bridgeManager = await import('./port-bridge-manager.mjs');
578
+ const ids = Object.keys(WORLDS).sort();
579
+ if (ids.length === 0) return;
580
+ // Per-world fetch in parallel; failures yield empty array for that world.
581
+ const perWorld = await Promise.all(
582
+ ids.map(async (id) => {
583
+ try {
584
+ const snapshot = await getListeningServers(id);
585
+ const bridges = bridgeManager.getWorldBridges(id);
586
+ const bridgeByPort = new Map(bridges.map((b) => [b.containerPort, b]));
587
+ const servers = (snapshot?.servers ?? []).map((s) => ({
588
+ ...s,
589
+ bridge: bridgeByPort.get(s.port) ?? null,
590
+ }));
591
+ return { worldId: id, servers };
592
+ } catch {
593
+ return { worldId: id, servers: [] };
594
+ }
595
+ }),
596
+ );
597
+ const stable = perWorld.map((w) => ({
598
+ worldId: w.worldId,
599
+ servers: [...w.servers].sort((a, b) => a.port - b.port),
600
+ }));
601
+ const json = JSON.stringify(stable);
602
+ const { createHash } = await import('node:crypto');
603
+ const hash = createHash('sha1').update(json).digest('hex');
604
+ if (hash === lastListeningHash) return;
605
+ lastListeningHash = hash;
606
+ hostStream.broadcast('listening.snapshot', {
607
+ worlds: stable,
608
+ snapshotAt: new Date().toISOString(),
609
+ });
610
+ } catch (err) {
611
+ console.warn(`[host-stream] listening.snapshot tick failed: ${err?.message ?? err}`);
612
+ }
613
+ }
614
+
615
+ function startTunnelsSnapshotLoop() {
616
+ void tickTunnelsSnapshot();
617
+ tunnelsSnapshotTimer = setInterval(() => { void tickTunnelsSnapshot(); }, TUNNELS_SNAPSHOT_TICK_MS);
618
+ }
619
+
620
+ function stopTunnelsSnapshotLoop() {
621
+ if (tunnelsSnapshotTimer) {
622
+ clearInterval(tunnelsSnapshotTimer);
623
+ tunnelsSnapshotTimer = null;
624
+ }
625
+ }
626
+
627
+ function startListeningSnapshotLoop() {
628
+ void tickListeningSnapshot();
629
+ listeningSnapshotTimer = setInterval(() => { void tickListeningSnapshot(); }, LISTENING_SNAPSHOT_TICK_MS);
630
+ }
631
+
632
+ function stopListeningSnapshotLoop() {
633
+ if (listeningSnapshotTimer) {
634
+ clearInterval(listeningSnapshotTimer);
635
+ listeningSnapshotTimer = null;
636
+ }
637
+ }
638
+
430
639
  /**
431
640
  * Resolve worldId → secret. Cache hit returns immediately; miss fetches
432
641
  * from the docker-socket-proxy + caches.
@@ -1117,6 +1326,35 @@ const server = http.createServer(async (req, res) => {
1117
1326
  return;
1118
1327
  }
1119
1328
 
1329
+ // sse-consolidation Phase A2: multiplexed host-stream endpoint.
1330
+ //
1331
+ // One long-lived SSE per SPA tab replaces 20+ setInterval polls. Event
1332
+ // types include `world.snapshot`, `servers.snapshot`, etc. — wiring
1333
+ // for each lives in the broadcaster + its subscribers. This handler
1334
+ // just opens the channel, emits `ready`, replays cached snapshots,
1335
+ // and registers for future broadcasts. Auth is the global gate above
1336
+ // (cookie + Bearer matching auth.isAuthorized).
1337
+ if (url.pathname === '/api/host-stream' && req.method === 'GET') {
1338
+ res.writeHead(200, {
1339
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1340
+ 'Cache-Control': 'no-cache, no-transform',
1341
+ 'Connection': 'keep-alive',
1342
+ 'X-Accel-Buffering': 'no',
1343
+ });
1344
+ res.write(':\n\n'); // initial heartbeat — keeps proxies from buffering
1345
+
1346
+ const streamId = newStreamId();
1347
+ res.write(`event: ready\ndata: ${JSON.stringify({
1348
+ streamId,
1349
+ serverTime: new Date().toISOString(),
1350
+ })}\n\n`);
1351
+
1352
+ const cleanup = hostStream.addSink(res);
1353
+ req.on('close', cleanup);
1354
+ req.on('error', cleanup);
1355
+ return;
1356
+ }
1357
+
1120
1358
  // ── Per-world credential telemetry routes ─────────────────────────────
1121
1359
  //
1122
1360
  // These are called by the in-world HTTPS proxy when it intercepts
@@ -2382,6 +2620,14 @@ tunnelManager.probeAllOnStartup().catch((err) => {
2382
2620
  console.error(`tunnel startup probe failed: ${err.message}`);
2383
2621
  });
2384
2622
 
2623
+ // Start the 1-Hz worlds.db hash-diff loop after the server boots so
2624
+ // the initial broadcast happens once the route is reachable.
2625
+ startWorldsSnapshotLoop();
2626
+ // Phase B-bonus: start tunnel + listening snapshot loops. Both
2627
+ // hash-debounce so idle windows produce zero broadcasts.
2628
+ startTunnelsSnapshotLoop();
2629
+ startListeningSnapshotLoop();
2630
+
2385
2631
  server.listen(PORT, '0.0.0.0', () => {
2386
2632
  console.log(`olam-host-cp B3 listening on :${PORT}`);
2387
2633
  console.log(` DOCKER_HOST=${DOCKER_HOST}`);
@@ -2411,6 +2657,11 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
2411
2657
  stopEvents();
2412
2658
  prPoller.stop();
2413
2659
  worldsDbReconciler.stop();
2660
+ stopWorldsSnapshotLoop();
2661
+ stopTunnelsSnapshotLoop();
2662
+ stopListeningSnapshotLoop();
2663
+ if (serversSnapshotTimer) { clearTimeout(serversSnapshotTimer); serversSnapshotTimer = null; }
2664
+ hostStream.close();
2414
2665
  clearInterval(versionPollTimer);
2415
2666
  cache.clear();
2416
2667
  server.close(() => process.exit(0));
@@ -201,6 +201,29 @@ export function stopTunnel(worldId, serviceName) {
201
201
  saveState();
202
202
  }
203
203
 
204
+ /**
205
+ * Return tunnel state for ALL worlds, keyed by worldId. Used by the
206
+ * host-stream broadcaster (sse-consolidation Phase B-bonus) to push a
207
+ * `tunnels.snapshot` whenever the registry changes — replaces the
208
+ * SPA's per-row `usePublishedTunnels` poll loop.
209
+ *
210
+ * @returns {{ [worldId: string]: Array<{name: string, port: number, url: string|null, status: string}> }}
211
+ */
212
+ export function getAllTunnels() {
213
+ /** @type {Record<string, Array<{name: string, port: number, url: string|null, status: string}>>} */
214
+ const byWorld = {};
215
+ for (const entry of registry.values()) {
216
+ if (!byWorld[entry.worldId]) byWorld[entry.worldId] = [];
217
+ byWorld[entry.worldId].push({
218
+ name: entry.serviceName,
219
+ port: entry.port,
220
+ url: entry.url,
221
+ status: entry.status,
222
+ });
223
+ }
224
+ return byWorld;
225
+ }
226
+
204
227
  /**
205
228
  * Return the current tunnel state for all services in a world.
206
229
  * @param {string} worldId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.102",
3
+ "version": "0.1.104",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"