@pleri/olam-cli 0.1.7
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/__tests__/auth-status.test.d.ts +2 -0
- package/dist/__tests__/auth-status.test.d.ts.map +1 -0
- package/dist/__tests__/auth-status.test.js +290 -0
- package/dist/__tests__/auth-status.test.js.map +1 -0
- package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
- package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/auth-upgrade.test.js +161 -0
- package/dist/__tests__/auth-upgrade.test.js.map +1 -0
- package/dist/__tests__/create-app-urls.test.d.ts +2 -0
- package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-urls.test.js +102 -0
- package/dist/__tests__/create-app-urls.test.js.map +1 -0
- package/dist/__tests__/enter.test.d.ts +2 -0
- package/dist/__tests__/enter.test.d.ts.map +1 -0
- package/dist/__tests__/enter.test.js +90 -0
- package/dist/__tests__/enter.test.js.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.js +119 -0
- package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
- package/dist/__tests__/host-cp.test.d.ts +9 -0
- package/dist/__tests__/host-cp.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp.test.js +254 -0
- package/dist/__tests__/host-cp.test.js.map +1 -0
- package/dist/__tests__/keys.test.d.ts +9 -0
- package/dist/__tests__/keys.test.d.ts.map +1 -0
- package/dist/__tests__/keys.test.js +145 -0
- package/dist/__tests__/keys.test.js.map +1 -0
- package/dist/__tests__/logs.test.d.ts +9 -0
- package/dist/__tests__/logs.test.d.ts.map +1 -0
- package/dist/__tests__/logs.test.js +124 -0
- package/dist/__tests__/logs.test.js.map +1 -0
- package/dist/__tests__/ps.test.d.ts +2 -0
- package/dist/__tests__/ps.test.d.ts.map +1 -0
- package/dist/__tests__/ps.test.js +172 -0
- package/dist/__tests__/ps.test.js.map +1 -0
- package/dist/__tests__/status-app-urls.test.d.ts +2 -0
- package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/status-app-urls.test.js +125 -0
- package/dist/__tests__/status-app-urls.test.js.map +1 -0
- package/dist/__tests__/upgrade.test.d.ts +9 -0
- package/dist/__tests__/upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/upgrade.test.js +262 -0
- package/dist/__tests__/upgrade.test.js.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
- package/dist/commands/__tests__/refresh.test.d.ts +13 -0
- package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
- package/dist/commands/__tests__/refresh.test.js +170 -0
- package/dist/commands/__tests__/refresh.test.js.map +1 -0
- package/dist/commands/auth-status.d.ts +43 -0
- package/dist/commands/auth-status.d.ts.map +1 -0
- package/dist/commands/auth-status.js +208 -0
- package/dist/commands/auth-status.js.map +1 -0
- package/dist/commands/auth-upgrade.d.ts +47 -0
- package/dist/commands/auth-upgrade.d.ts.map +1 -0
- package/dist/commands/auth-upgrade.js +277 -0
- package/dist/commands/auth-upgrade.js.map +1 -0
- package/dist/commands/auth.d.ts +16 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +283 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +512 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/crystallize.d.ts +8 -0
- package/dist/commands/crystallize.d.ts.map +1 -0
- package/dist/commands/crystallize.js +101 -0
- package/dist/commands/crystallize.js.map +1 -0
- package/dist/commands/destroy.d.ts +6 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/destroy.js +54 -0
- package/dist/commands/destroy.js.map +1 -0
- package/dist/commands/dispatch.d.ts +9 -0
- package/dist/commands/dispatch.d.ts.map +1 -0
- package/dist/commands/dispatch.js +94 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/enter.d.ts +63 -0
- package/dist/commands/enter.d.ts.map +1 -0
- package/dist/commands/enter.js +206 -0
- package/dist/commands/enter.js.map +1 -0
- package/dist/commands/host-cp.d.ts +191 -0
- package/dist/commands/host-cp.d.ts.map +1 -0
- package/dist/commands/host-cp.js +797 -0
- package/dist/commands/host-cp.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +143 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +22 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +203 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/keys.d.ts +26 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +151 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/lanes.d.ts +18 -0
- package/dist/commands/lanes.d.ts.map +1 -0
- package/dist/commands/lanes.js +122 -0
- package/dist/commands/lanes.js.map +1 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +39 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +38 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +177 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/observe.d.ts +9 -0
- package/dist/commands/observe.d.ts.map +1 -0
- package/dist/commands/observe.js +34 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/commands/policy-check.d.ts +14 -0
- package/dist/commands/policy-check.d.ts.map +1 -0
- package/dist/commands/policy-check.js +76 -0
- package/dist/commands/policy-check.js.map +1 -0
- package/dist/commands/pr.d.ts +17 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +148 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/ps.d.ts +25 -0
- package/dist/commands/ps.d.ts.map +1 -0
- package/dist/commands/ps.js +164 -0
- package/dist/commands/ps.js.map +1 -0
- package/dist/commands/refresh-helpers.d.ts +25 -0
- package/dist/commands/refresh-helpers.d.ts.map +1 -0
- package/dist/commands/refresh-helpers.js +56 -0
- package/dist/commands/refresh-helpers.js.map +1 -0
- package/dist/commands/refresh.d.ts +23 -0
- package/dist/commands/refresh.d.ts.map +1 -0
- package/dist/commands/refresh.js +237 -0
- package/dist/commands/refresh.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/upgrade.d.ts +67 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +358 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/workspace.d.ts +23 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +198 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/commands/world-snapshot.d.ts +18 -0
- package/dist/commands/world-snapshot.d.ts.map +1 -0
- package/dist/commands/world-snapshot.js +327 -0
- package/dist/commands/world-snapshot.js.map +1 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +51 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18007 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.js +32236 -0
- package/dist/output.d.ts +10 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +31 -0
- package/dist/output.js.map +1 -0
- package/host-cp/compose.yaml +126 -0
- package/host-cp/src/auth-secret-hint.mjs +45 -0
- package/host-cp/src/auth.mjs +155 -0
- package/host-cp/src/compose-worlds-sources.mjs +170 -0
- package/host-cp/src/container-secret-fetcher.mjs +163 -0
- package/host-cp/src/docker-events.mjs +184 -0
- package/host-cp/src/local-worlds-source.mjs +83 -0
- package/host-cp/src/plan-orchestrator.mjs +829 -0
- package/host-cp/src/plan-progress.mjs +282 -0
- package/host-cp/src/pr-cache.mjs +201 -0
- package/host-cp/src/pr-merge-poller.mjs +154 -0
- package/host-cp/src/process-poller.mjs +250 -0
- package/host-cp/src/proxy.mjs +245 -0
- package/host-cp/src/pylon-worlds-source.mjs +68 -0
- package/host-cp/src/redact.mjs +67 -0
- package/host-cp/src/secret-cache.mjs +104 -0
- package/host-cp/src/server.mjs +2215 -0
- package/host-cp/src/sse-gate.mjs +117 -0
- package/host-cp/src/version-status.mjs +209 -0
- package/host-cp/src/workspace-catalog.mjs +149 -0
- package/host-cp/src/world-names-store.mjs +176 -0
- package/host-cp/src/world-pr-state.mjs +97 -0
- package/host-cp/src/world-progress.mjs +322 -0
- package/host-cp/src/world-tunnel-manager.mjs +288 -0
- package/host-cp/src/worlds-db-source.mjs +191 -0
- package/host-cp/src/worlds-source.mjs +59 -0
- package/package.json +38 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Phase F-2-B (B5): SSE concurrent-connection gate + path detection.
|
|
2
|
+
//
|
|
3
|
+
// Background. Each open SSE proxy holds:
|
|
4
|
+
// - A Node http.ClientRequest to the per-world CP (one fd)
|
|
5
|
+
// - The browser's incoming socket (one fd)
|
|
6
|
+
// Plus the Node event loop wakes on every chunk. With N worlds × M tabs
|
|
7
|
+
// × Sse-per-tab, the FD budget grows linearly. P3 budgets ≤100 concurrent
|
|
8
|
+
// SSE proxies; P4 caps at 50 + returns 503 with Retry-After: 30 above
|
|
9
|
+
// that. Below the cap there's no impact.
|
|
10
|
+
//
|
|
11
|
+
// Cap semantics:
|
|
12
|
+
// - increment() returns true if we're allowed to open; false → reject.
|
|
13
|
+
// - decrement() is idempotent + fire-once via the FiredFlag pattern
|
|
14
|
+
// because Node emits both 'close' and 'finish' on a normal stream
|
|
15
|
+
// end. Without idempotency the counter would underflow.
|
|
16
|
+
//
|
|
17
|
+
// SSE detection is path-based (cheap; runs before opening upstream).
|
|
18
|
+
// Two patterns are SSE today:
|
|
19
|
+
// /api/stream — per-world CP's existing SSE feed
|
|
20
|
+
// /api/world/<id>/bootstrap-progress — placeholder for B7's UI strip
|
|
21
|
+
// (per-world CP route lands later)
|
|
22
|
+
|
|
23
|
+
const SSE_PATH_PATTERNS = [
|
|
24
|
+
/\/api\/stream(?:\/|$|\?)/,
|
|
25
|
+
/\/bootstrap-progress(?:\/|$|\?)/,
|
|
26
|
+
/\/api\/logs(?:\/|$|\?)/,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect whether an upstream subPath represents an SSE stream. The
|
|
31
|
+
* subPath is the value emitted by `parseProxyPath()` — i.e., everything
|
|
32
|
+
* AFTER `/api/world/<id>`. So we match on the inner route, not the
|
|
33
|
+
* `/api/world/<id>` prefix.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} subPath
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function isSsePath(subPath) {
|
|
39
|
+
return SSE_PATH_PATTERNS.some((re) => re.test(subPath));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class SseGate {
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} opts
|
|
45
|
+
* @param {number} [opts.maxConcurrent] default 50 (P4 cap)
|
|
46
|
+
* @param {(message: string) => void} [opts.log]
|
|
47
|
+
*/
|
|
48
|
+
constructor({ maxConcurrent = 50, log = console.log } = {}) {
|
|
49
|
+
if (maxConcurrent < 1) {
|
|
50
|
+
throw new Error('SseGate: maxConcurrent must be >= 1');
|
|
51
|
+
}
|
|
52
|
+
this.maxConcurrent = maxConcurrent;
|
|
53
|
+
this.active = 0;
|
|
54
|
+
this.log = log;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Try to acquire a slot. If at cap, returns null + writes a 503 to
|
|
59
|
+
* res. Caller MUST check the return value.
|
|
60
|
+
*
|
|
61
|
+
* @param {import('node:http').ServerResponse} res
|
|
62
|
+
* @returns {{ release: () => void } | null}
|
|
63
|
+
*/
|
|
64
|
+
acquire(res) {
|
|
65
|
+
if (this.active >= this.maxConcurrent) {
|
|
66
|
+
res.writeHead(503, {
|
|
67
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
68
|
+
'Retry-After': '30',
|
|
69
|
+
});
|
|
70
|
+
res.end(JSON.stringify({
|
|
71
|
+
error: 'sse_capacity_reached',
|
|
72
|
+
active: this.active,
|
|
73
|
+
cap: this.maxConcurrent,
|
|
74
|
+
retry_after_sec: 30,
|
|
75
|
+
message: 'host CP has reached the SSE concurrent-connection cap. Retry after the indicated delay or close idle SPA tabs.',
|
|
76
|
+
}));
|
|
77
|
+
this.log(`sse-gate: 503 — cap reached (active=${this.active}, cap=${this.maxConcurrent})`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
this.active++;
|
|
81
|
+
let released = false;
|
|
82
|
+
const release = () => {
|
|
83
|
+
if (released) return;
|
|
84
|
+
released = true;
|
|
85
|
+
this.active--;
|
|
86
|
+
};
|
|
87
|
+
return { release };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Diagnostics for /health. */
|
|
91
|
+
stats() {
|
|
92
|
+
return {
|
|
93
|
+
active: this.active,
|
|
94
|
+
cap: this.maxConcurrent,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Wire SSE-gate teardown to a ServerResponse's lifecycle. Node's
|
|
101
|
+
* http response emits 'close' (client disconnected) AND 'finish'
|
|
102
|
+
* (response.end() called) on different code paths. We want decrement
|
|
103
|
+
* exactly once per acquire(), regardless of which event fires first.
|
|
104
|
+
*
|
|
105
|
+
* The release closure is already idempotent (released flag). Wiring
|
|
106
|
+
* both events covers every termination path:
|
|
107
|
+
* - browser closes tab → 'close' on res
|
|
108
|
+
* - upstream EOF + res.end → 'finish' on res
|
|
109
|
+
* - error in proxy → 'close' on res (Node fires close on errors)
|
|
110
|
+
*
|
|
111
|
+
* @param {import('node:http').ServerResponse} res
|
|
112
|
+
* @param {() => void} release
|
|
113
|
+
*/
|
|
114
|
+
export function wireRelease(res, release) {
|
|
115
|
+
res.on('close', release);
|
|
116
|
+
res.on('finish', release);
|
|
117
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Version detection for Phase 1 of self-upgrade.
|
|
2
|
+
//
|
|
3
|
+
// Compares each component's baked OLAM_BUILD_SHA against the operator's
|
|
4
|
+
// local repo HEAD (mounted read-only at /operator-repo). Reports upgrade
|
|
5
|
+
// availability without triggering any automatic action — Phase 1 is
|
|
6
|
+
// detection only.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** @typedef {'ok' | 'behind' | 'unknown'} VersionState */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} ComponentVersion
|
|
15
|
+
* @property {string} running - SHA baked into the running image
|
|
16
|
+
* @property {string} latest - SHA of operator's local HEAD (or 'unknown')
|
|
17
|
+
* @property {boolean} upgradeAvailable
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} VersionSnapshot
|
|
22
|
+
* @property {ComponentVersion} hostCp
|
|
23
|
+
* @property {ComponentVersion} authService
|
|
24
|
+
* @property {ComponentVersion} devbox
|
|
25
|
+
* @property {string} operatorHead - resolved HEAD or 'unknown'
|
|
26
|
+
* @property {string} checkedAt - ISO timestamp
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the operator's local repo HEAD.
|
|
31
|
+
*
|
|
32
|
+
* Tries OLAM_REPO_PATH env var first, then /operator-repo (the compose-
|
|
33
|
+
* mounted path), then $HOME/Projects/ein-sof/olam as a bare-node fallback.
|
|
34
|
+
*
|
|
35
|
+
* Returns 'unknown' on any read error.
|
|
36
|
+
*
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function readOperatorHead() {
|
|
40
|
+
const candidates = [
|
|
41
|
+
process.env.OLAM_REPO_PATH,
|
|
42
|
+
'/operator-repo',
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
|
|
45
|
+
for (const repoPath of candidates) {
|
|
46
|
+
try {
|
|
47
|
+
// Read HEAD to find the current branch ref (e.g. "ref: refs/heads/main")
|
|
48
|
+
// then resolve to the SHA.
|
|
49
|
+
const headFile = path.join(repoPath, '.git', 'HEAD');
|
|
50
|
+
if (!fs.existsSync(headFile)) continue;
|
|
51
|
+
|
|
52
|
+
const headContent = fs.readFileSync(headFile, 'utf-8').trim();
|
|
53
|
+
|
|
54
|
+
if (headContent.startsWith('ref: ')) {
|
|
55
|
+
// Symbolic ref → resolve to SHA via the packed-refs or loose ref.
|
|
56
|
+
const refPath = headContent.slice('ref: '.length);
|
|
57
|
+
const looseRef = path.join(repoPath, '.git', refPath);
|
|
58
|
+
if (fs.existsSync(looseRef)) {
|
|
59
|
+
return fs.readFileSync(looseRef, 'utf-8').trim();
|
|
60
|
+
}
|
|
61
|
+
// Try packed-refs fallback.
|
|
62
|
+
const packedRefs = path.join(repoPath, '.git', 'packed-refs');
|
|
63
|
+
if (fs.existsSync(packedRefs)) {
|
|
64
|
+
const lines = fs.readFileSync(packedRefs, 'utf-8').split('\n');
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (line.startsWith('#')) continue;
|
|
67
|
+
const [sha, ref] = line.trim().split(' ');
|
|
68
|
+
if (ref === refPath) return sha;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else if (/^[0-9a-f]{40}$/i.test(headContent)) {
|
|
72
|
+
// Detached HEAD — use the SHA directly.
|
|
73
|
+
return headContent;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// silently try next candidate
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return 'unknown';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compare two SHAs. Returns true when they differ and both are known.
|
|
84
|
+
* If either is 'unknown' we cannot assert an upgrade is available.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} running
|
|
87
|
+
* @param {string} latest
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function isUpgradeAvailable(running, latest) {
|
|
91
|
+
if (running === 'unknown' || latest === 'unknown') return false;
|
|
92
|
+
// SHAs may be full (40 hex chars) or short (7+ hex chars from --short).
|
|
93
|
+
// Compare by checking if one is a prefix of the other.
|
|
94
|
+
const a = running.toLowerCase();
|
|
95
|
+
const b = latest.toLowerCase();
|
|
96
|
+
return !a.startsWith(b) && !b.startsWith(a);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fetch the auth-service's /health endpoint and extract buildSha.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} authServiceUrl
|
|
103
|
+
* @returns {Promise<string>}
|
|
104
|
+
*/
|
|
105
|
+
export async function fetchAuthServiceSha(authServiceUrl) {
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(`${authServiceUrl}/health`, {
|
|
108
|
+
signal: AbortSignal.timeout(5000),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) return 'unknown';
|
|
111
|
+
const data = /** @type {unknown} */ (await res.json());
|
|
112
|
+
if (data && typeof data === 'object' && 'buildSha' in data) {
|
|
113
|
+
const sha = /** @type {Record<string, unknown>} */ (data)['buildSha'];
|
|
114
|
+
return typeof sha === 'string' ? sha : 'unknown';
|
|
115
|
+
}
|
|
116
|
+
return 'unknown';
|
|
117
|
+
} catch {
|
|
118
|
+
return 'unknown';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fetch the devbox image SHA. We check the running devbox container's
|
|
124
|
+
* OLAM_BUILD_SHA env var via the docker socket proxy (inspect endpoint).
|
|
125
|
+
* Returns 'unknown' if any step fails.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} dockerApiBase e.g. "http://docker-socket-proxy:2375" or "http://localhost:2375"
|
|
128
|
+
* @returns {Promise<string>}
|
|
129
|
+
*/
|
|
130
|
+
export async function fetchDevboxImageSha(dockerApiBase) {
|
|
131
|
+
try {
|
|
132
|
+
// List containers named olam-*-devbox and grab the first one.
|
|
133
|
+
const listRes = await fetch(
|
|
134
|
+
`${dockerApiBase}/containers/json?filters=${encodeURIComponent(JSON.stringify({ name: ['olam-devbox'] }))}`,
|
|
135
|
+
{ signal: AbortSignal.timeout(5000) },
|
|
136
|
+
);
|
|
137
|
+
if (!listRes.ok) return 'unknown';
|
|
138
|
+
const containers = /** @type {unknown} */ (await listRes.json());
|
|
139
|
+
if (!Array.isArray(containers) || containers.length === 0) return 'unknown';
|
|
140
|
+
|
|
141
|
+
// Use the most recently-created devbox container's image ID.
|
|
142
|
+
// Inspect the image for OLAM_BUILD_SHA label or env.
|
|
143
|
+
const container = /** @type {Record<string, unknown>} */ (containers[0]);
|
|
144
|
+
const imageId = typeof container['ImageID'] === 'string' ? container['ImageID'] : null;
|
|
145
|
+
if (!imageId) return 'unknown';
|
|
146
|
+
|
|
147
|
+
const inspectRes = await fetch(
|
|
148
|
+
`${dockerApiBase}/images/${encodeURIComponent(imageId)}/json`,
|
|
149
|
+
{ signal: AbortSignal.timeout(5000) },
|
|
150
|
+
);
|
|
151
|
+
if (!inspectRes.ok) return 'unknown';
|
|
152
|
+
const image = /** @type {unknown} */ (await inspectRes.json());
|
|
153
|
+
if (!image || typeof image !== 'object') return 'unknown';
|
|
154
|
+
|
|
155
|
+
const config = /** @type {Record<string, unknown>} */ (image)['Config'];
|
|
156
|
+
if (!config || typeof config !== 'object') return 'unknown';
|
|
157
|
+
const env = /** @type {Record<string, unknown>} */ (config)['Env'];
|
|
158
|
+
if (!Array.isArray(env)) return 'unknown';
|
|
159
|
+
|
|
160
|
+
for (const e of env) {
|
|
161
|
+
if (typeof e === 'string' && e.startsWith('OLAM_BUILD_SHA=')) {
|
|
162
|
+
return e.slice('OLAM_BUILD_SHA='.length);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return 'unknown';
|
|
166
|
+
} catch {
|
|
167
|
+
return 'unknown';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build a full VersionSnapshot from all available sources.
|
|
173
|
+
*
|
|
174
|
+
* @param {{
|
|
175
|
+
* authServiceUrl: string;
|
|
176
|
+
* dockerApiBase: string;
|
|
177
|
+
* }} opts
|
|
178
|
+
* @returns {Promise<VersionSnapshot>}
|
|
179
|
+
*/
|
|
180
|
+
export async function buildVersionSnapshot({ authServiceUrl, dockerApiBase }) {
|
|
181
|
+
const operatorHead = readOperatorHead();
|
|
182
|
+
|
|
183
|
+
const [authSha, devboxSha] = await Promise.all([
|
|
184
|
+
fetchAuthServiceSha(authServiceUrl),
|
|
185
|
+
fetchDevboxImageSha(dockerApiBase),
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
const hostCpRunning = process.env.OLAM_BUILD_SHA ?? 'unknown';
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
hostCp: {
|
|
192
|
+
running: hostCpRunning,
|
|
193
|
+
latest: operatorHead,
|
|
194
|
+
upgradeAvailable: isUpgradeAvailable(hostCpRunning, operatorHead),
|
|
195
|
+
},
|
|
196
|
+
authService: {
|
|
197
|
+
running: authSha,
|
|
198
|
+
latest: operatorHead,
|
|
199
|
+
upgradeAvailable: isUpgradeAvailable(authSha, operatorHead),
|
|
200
|
+
},
|
|
201
|
+
devbox: {
|
|
202
|
+
running: devboxSha,
|
|
203
|
+
latest: operatorHead,
|
|
204
|
+
upgradeAvailable: isUpgradeAvailable(devboxSha, operatorHead),
|
|
205
|
+
},
|
|
206
|
+
operatorHead,
|
|
207
|
+
checkedAt: new Date().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Phase F-2-B (B6): workspace + project catalog for host CP.
|
|
2
|
+
//
|
|
3
|
+
// Reads workspace YAML files from `~/.olam/workspaces/*.yaml` (mounted
|
|
4
|
+
// at `/data/workspaces` inside the host-cp container per compose.yaml).
|
|
5
|
+
// Provides three endpoints' worth of data:
|
|
6
|
+
//
|
|
7
|
+
// 1. /api/workspaces — list all workspaces (redacted)
|
|
8
|
+
// 2. /api/projects — deduplicated project union
|
|
9
|
+
// 3. POST /api/workspaces/match — exact set-equality matching
|
|
10
|
+
// for D13's project-first
|
|
11
|
+
// create-world flow
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import YAML from 'yaml';
|
|
16
|
+
import { redactSensitive } from './redact.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Project
|
|
20
|
+
* @property {string} name
|
|
21
|
+
* @property {string} [url]
|
|
22
|
+
* @property {string} [path]
|
|
23
|
+
* @property {string} [branch]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} Workspace
|
|
28
|
+
* @property {string} name
|
|
29
|
+
* @property {Project[]} repos project list (called `repos` in YAML)
|
|
30
|
+
* @property {Record<string, unknown>} [defaults]
|
|
31
|
+
* @property {Record<string, unknown>} [services]
|
|
32
|
+
* @property {Record<string, unknown>} [image]
|
|
33
|
+
* @property {Record<string, unknown>} [host_ui]
|
|
34
|
+
* @property {number} [updatedAt]
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load all workspace YAMLs from a directory. Returns an array, sorted
|
|
39
|
+
* by name. Invalid YAMLs are logged + skipped (don't bring down the
|
|
40
|
+
* whole list because one file is malformed).
|
|
41
|
+
*
|
|
42
|
+
* @param {string} dir
|
|
43
|
+
* @param {(message: string) => void} [log]
|
|
44
|
+
* @returns {Workspace[]}
|
|
45
|
+
*/
|
|
46
|
+
export function loadWorkspaces(dir, log = console.log) {
|
|
47
|
+
if (!fs.existsSync(dir)) {
|
|
48
|
+
log(`workspace-catalog: directory ${dir} does not exist`);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
/** @type {Workspace[]} */
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
54
|
+
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
|
|
55
|
+
const filePath = path.join(dir, entry);
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
58
|
+
const parsed = YAML.parse(raw);
|
|
59
|
+
if (parsed && typeof parsed === 'object' && parsed.name) {
|
|
60
|
+
// Normalize: ensure `repos` is at least an empty array.
|
|
61
|
+
out.push({ ...parsed, repos: parsed.repos ?? [] });
|
|
62
|
+
} else {
|
|
63
|
+
log(`workspace-catalog: skipping ${entry} (no .name field)`);
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log(`workspace-catalog: failed to parse ${entry}: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* /api/workspaces response: redacted workspace list.
|
|
74
|
+
*
|
|
75
|
+
* @param {Workspace[]} workspaces
|
|
76
|
+
* @returns {Workspace[]}
|
|
77
|
+
*/
|
|
78
|
+
export function workspacesForApi(workspaces) {
|
|
79
|
+
return /** @type {Workspace[]} */ (redactSensitive(workspaces));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* /api/projects response: deduplicated project union across all
|
|
84
|
+
* workspaces. Dedup key is project name (case-sensitive — Atlas Core
|
|
85
|
+
* and atlas-core would be distinct, which matches the workspace YAML
|
|
86
|
+
* convention of using kebab-case throughout).
|
|
87
|
+
*
|
|
88
|
+
* Per-project metadata: takes the FIRST occurrence's url/path/branch.
|
|
89
|
+
* Subsequent occurrences with the same name are ignored. This keeps
|
|
90
|
+
* the response stable across reorderings within individual workspace
|
|
91
|
+
* YAMLs.
|
|
92
|
+
*
|
|
93
|
+
* @param {Workspace[]} workspaces
|
|
94
|
+
* @returns {Project[]}
|
|
95
|
+
*/
|
|
96
|
+
export function projectsFromWorkspaces(workspaces) {
|
|
97
|
+
/** @type {Map<string, Project>} */
|
|
98
|
+
const byName = new Map();
|
|
99
|
+
for (const ws of workspaces) {
|
|
100
|
+
for (const repo of ws.repos ?? []) {
|
|
101
|
+
if (!repo?.name) continue;
|
|
102
|
+
if (!byName.has(repo.name)) {
|
|
103
|
+
byName.set(repo.name, { ...repo });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* POST /api/workspaces/match request body: { projects: string[] }.
|
|
112
|
+
* Returns workspaces whose project-name set EXACTLY equals the input
|
|
113
|
+
* set (no subset, no superset). Sorted by name for response stability.
|
|
114
|
+
*
|
|
115
|
+
* Algorithm: O(W × P) where W = #workspaces, P = average projects per
|
|
116
|
+
* workspace. Workspaces are small (<10 projects each); fine for direct
|
|
117
|
+
* iteration.
|
|
118
|
+
*
|
|
119
|
+
* @param {Workspace[]} workspaces
|
|
120
|
+
* @param {string[]} projectNames
|
|
121
|
+
* @returns {Workspace[]}
|
|
122
|
+
*/
|
|
123
|
+
export function matchWorkspacesByProjects(workspaces, projectNames) {
|
|
124
|
+
const target = new Set(projectNames);
|
|
125
|
+
/** @type {Workspace[]} */
|
|
126
|
+
const matches = [];
|
|
127
|
+
for (const ws of workspaces) {
|
|
128
|
+
const wsNames = new Set((ws.repos ?? []).map((r) => r.name).filter(Boolean));
|
|
129
|
+
if (setsEqual(target, wsNames)) {
|
|
130
|
+
matches.push(ws);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return matches.sort((a, b) => a.name.localeCompare(b.name));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Set equality. Two sets are equal iff same size + same members.
|
|
138
|
+
*
|
|
139
|
+
* @param {Set<string>} a
|
|
140
|
+
* @param {Set<string>} b
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
function setsEqual(a, b) {
|
|
144
|
+
if (a.size !== b.size) return false;
|
|
145
|
+
for (const x of a) {
|
|
146
|
+
if (!b.has(x)) return false;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Phase F-2-D follow-up: persistent world-name store.
|
|
2
|
+
//
|
|
3
|
+
// Background: world.id is the docker container suffix (e.g. `gold-arc-1454`)
|
|
4
|
+
// and is immutable. Operators want a separate human-friendly `name`
|
|
5
|
+
// (e.g. "Refactor the auth module") so the worlds list reads like a
|
|
6
|
+
// task board instead of a string of CSS-color-words.
|
|
7
|
+
//
|
|
8
|
+
// Storage: a single JSON file at /data/world-names.json (mounted from
|
|
9
|
+
// ~/.olam/world-names.json on the host). Atomic write via tmp+rename so
|
|
10
|
+
// concurrent PATCHes can't half-write the file. Read-on-demand with a
|
|
11
|
+
// tiny in-process cache keyed off mtime so steady-state GET /api/worlds
|
|
12
|
+
// doesn't reread the file every poll.
|
|
13
|
+
//
|
|
14
|
+
// Schema:
|
|
15
|
+
// { "<worldId>": "<name>", ... }
|
|
16
|
+
//
|
|
17
|
+
// Names are arbitrary UTF-8 strings, capped at NAME_MAX_LEN to keep
|
|
18
|
+
// the file small + the UI sane.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const NAME_MAX_LEN = 120;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} WorldNamesStore
|
|
27
|
+
* @property {() => Record<string, string>} all
|
|
28
|
+
* @property {(id: string) => string | null} get
|
|
29
|
+
* @property {(id: string, name: string) => string} set
|
|
30
|
+
* @property {(id: string) => void} remove
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a JSON-backed world-names store rooted at `filePath`.
|
|
35
|
+
* Resilient to a missing file (treats as empty); resilient to a
|
|
36
|
+
* malformed file (logs + treats as empty).
|
|
37
|
+
*
|
|
38
|
+
* @param {string} filePath
|
|
39
|
+
* @returns {WorldNamesStore}
|
|
40
|
+
*/
|
|
41
|
+
export function createWorldNamesStore(filePath) {
|
|
42
|
+
/** @type {Record<string, string>} */
|
|
43
|
+
let cache = {};
|
|
44
|
+
let cacheMtimeMs = -1;
|
|
45
|
+
|
|
46
|
+
function readFromDisk() {
|
|
47
|
+
if (!fs.existsSync(filePath)) {
|
|
48
|
+
cache = {};
|
|
49
|
+
cacheMtimeMs = 0;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.statSync(filePath);
|
|
54
|
+
if (stat.mtimeMs === cacheMtimeMs) return; // cache hit
|
|
55
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
58
|
+
const next = {};
|
|
59
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
60
|
+
if (typeof v === 'string') next[k] = v;
|
|
61
|
+
}
|
|
62
|
+
cache = next;
|
|
63
|
+
} else {
|
|
64
|
+
cache = {};
|
|
65
|
+
}
|
|
66
|
+
cacheMtimeMs = stat.mtimeMs;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`world-names-store: failed to read ${filePath}: ${err.message}`);
|
|
69
|
+
cache = {};
|
|
70
|
+
cacheMtimeMs = 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeToDisk() {
|
|
75
|
+
const dir = path.dirname(filePath);
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
78
|
+
fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), 'utf-8');
|
|
79
|
+
fs.renameSync(tmp, filePath);
|
|
80
|
+
try {
|
|
81
|
+
const stat = fs.statSync(filePath);
|
|
82
|
+
cacheMtimeMs = stat.mtimeMs;
|
|
83
|
+
} catch {
|
|
84
|
+
cacheMtimeMs = 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @returns {Record<string, string>} */
|
|
89
|
+
function all() {
|
|
90
|
+
readFromDisk();
|
|
91
|
+
return { ...cache };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} id
|
|
96
|
+
* @returns {string | null}
|
|
97
|
+
*/
|
|
98
|
+
function get(id) {
|
|
99
|
+
readFromDisk();
|
|
100
|
+
return cache[id] ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} id
|
|
105
|
+
* @param {string} name
|
|
106
|
+
* @returns {string} the normalized name actually stored
|
|
107
|
+
*/
|
|
108
|
+
function set(id, name) {
|
|
109
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
110
|
+
throw new Error('worldId must be a non-empty string');
|
|
111
|
+
}
|
|
112
|
+
const normalized = normalizeName(name);
|
|
113
|
+
if (normalized === null) {
|
|
114
|
+
throw new Error('name must be a non-empty string (after trim)');
|
|
115
|
+
}
|
|
116
|
+
readFromDisk();
|
|
117
|
+
cache = { ...cache, [id]: normalized };
|
|
118
|
+
writeToDisk();
|
|
119
|
+
return normalized;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} id
|
|
124
|
+
*/
|
|
125
|
+
function remove(id) {
|
|
126
|
+
readFromDisk();
|
|
127
|
+
if (!(id in cache)) return;
|
|
128
|
+
const next = { ...cache };
|
|
129
|
+
delete next[id];
|
|
130
|
+
cache = next;
|
|
131
|
+
writeToDisk();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { all, get, set, remove };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Normalize a name input. Trims, collapses internal whitespace, caps
|
|
139
|
+
* length. Returns null for empty/whitespace-only input.
|
|
140
|
+
*
|
|
141
|
+
* @param {unknown} input
|
|
142
|
+
* @returns {string | null}
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeName(input) {
|
|
145
|
+
if (typeof input !== 'string') return null;
|
|
146
|
+
const trimmed = input.replace(/\s+/g, ' ').trim();
|
|
147
|
+
if (trimmed.length === 0) return null;
|
|
148
|
+
return trimmed.length > NAME_MAX_LEN
|
|
149
|
+
? trimmed.slice(0, NAME_MAX_LEN).trimEnd()
|
|
150
|
+
: trimmed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Derive a human-friendly name from an initial task / dispatch text.
|
|
155
|
+
* Takes the first sentence (split on `.`/`?`/`!`/newline), trims, caps
|
|
156
|
+
* at ~60 chars at a word boundary so the UI doesn't truncate mid-word.
|
|
157
|
+
* Returns null for empty input — caller falls back to id.
|
|
158
|
+
*
|
|
159
|
+
* @param {unknown} taskText
|
|
160
|
+
* @returns {string | null}
|
|
161
|
+
*/
|
|
162
|
+
export function inferNameFromTask(taskText) {
|
|
163
|
+
if (typeof taskText !== 'string') return null;
|
|
164
|
+
const cleaned = taskText.replace(/\s+/g, ' ').trim();
|
|
165
|
+
if (cleaned.length === 0) return null;
|
|
166
|
+
// First sentence terminator wins; otherwise the whole string.
|
|
167
|
+
const firstSentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? cleaned;
|
|
168
|
+
const SOFT_CAP = 60;
|
|
169
|
+
if (firstSentence.length <= SOFT_CAP) return firstSentence || null;
|
|
170
|
+
// Cap at a word boundary close to SOFT_CAP so we don't dangle
|
|
171
|
+
// half a word + an ellipsis.
|
|
172
|
+
const head = firstSentence.slice(0, SOFT_CAP);
|
|
173
|
+
const lastSpace = head.lastIndexOf(' ');
|
|
174
|
+
const truncated = lastSpace > 30 ? head.slice(0, lastSpace) : head;
|
|
175
|
+
return truncated.replace(/[\s,;:—–-]+$/u, '');
|
|
176
|
+
}
|