@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.
- package/dist/commands/bootstrap.d.ts.map +1 -1
- package/dist/commands/bootstrap.js +29 -1
- package/dist/commands/bootstrap.js.map +1 -1
- package/dist/commands/kg-build.d.ts.map +1 -1
- package/dist/commands/kg-build.js +5 -0
- package/dist/commands/kg-build.js.map +1 -1
- package/dist/commands/kg-status.d.ts +59 -0
- package/dist/commands/kg-status.d.ts.map +1 -0
- package/dist/commands/kg-status.js +345 -0
- package/dist/commands/kg-status.js.map +1 -0
- package/dist/commands/kg-watch.d.ts +49 -0
- package/dist/commands/kg-watch.d.ts.map +1 -0
- package/dist/commands/kg-watch.js +172 -0
- package/dist/commands/kg-watch.js.map +1 -0
- package/dist/image-digests.json +4 -4
- package/dist/index.js +484 -83
- package/host-cp/src/host-stream.mjs +443 -0
- package/host-cp/src/server.mjs +252 -1
- package/host-cp/src/world-tunnel-manager.mjs +23 -0
- package/package.json +1 -1
package/host-cp/src/server.mjs
CHANGED
|
@@ -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) =>
|
|
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
|