@pleri/olam-cli 0.1.103 → 0.1.105

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.
@@ -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.103",
3
+ "version": "0.1.105",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"