@pleri/olam-cli 0.1.173 → 0.1.175
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/auth.d.ts +22 -7
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +414 -46
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +45 -1
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/services.d.ts +39 -0
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +64 -9
- package/dist/commands/services.js.map +1 -1
- package/dist/from-manifest.d.ts +53 -0
- package/dist/from-manifest.d.ts.map +1 -0
- package/dist/from-manifest.js +95 -0
- package/dist/from-manifest.js.map +1 -0
- package/dist/image-digests.json +8 -8
- package/dist/index.js +907 -136
- package/dist/lib/auth-remote.d.ts +130 -0
- package/dist/lib/auth-remote.d.ts.map +1 -0
- package/dist/lib/auth-remote.js +307 -0
- package/dist/lib/auth-remote.js.map +1 -0
- package/dist/mcp-server.js +254 -57
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
- package/host-cp/src/boot-reconciler.mjs +238 -0
- package/host-cp/src/port-bridge-manager.mjs +116 -10
- package/host-cp/src/server.mjs +32 -0
- package/host-cp/src/world-activity-tracker.mjs +392 -0
- package/package.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time reconciler — sync worlds.db with live docker state.
|
|
3
|
+
*
|
|
4
|
+
* Problem (issue #963): after Colima / userspace restart, host-cp can
|
|
5
|
+
* start with worlds.db rows that no longer reflect docker reality. The
|
|
6
|
+
* existing `worlds-db-source.mjs` reconciler runs DB→registry (reads
|
|
7
|
+
* 'running' rows and adds them to in-memory WORLDS). It does NOT heal
|
|
8
|
+
* the inverse case: a container is alive on docker but worlds.db has
|
|
9
|
+
* no row (Hazel coral-sky-2478 scenario), or worlds.db says a world is
|
|
10
|
+
* running but the container is gone (orphaned row).
|
|
11
|
+
*
|
|
12
|
+
* This module fills both gaps with a one-shot pass at boot:
|
|
13
|
+
*
|
|
14
|
+
* 1. List `olam-*-devbox` containers via the docker API.
|
|
15
|
+
* 2. For each container, derive the worldId (strip prefix + suffix).
|
|
16
|
+
* 3. Cross-check against worlds.db rows:
|
|
17
|
+
* - container alive, row exists → no-op
|
|
18
|
+
* - container alive, row missing → INSERT (status=reconciled)
|
|
19
|
+
* - row says running/active, container missing → UPDATE status=orphaned
|
|
20
|
+
*
|
|
21
|
+
* Fail-soft: if the docker daemon is unreachable OR better-sqlite3 is
|
|
22
|
+
* not available, the function logs a warning and returns without
|
|
23
|
+
* throwing. Server boot continues.
|
|
24
|
+
*
|
|
25
|
+
* Idempotent: a second invocation against the same docker + DB state
|
|
26
|
+
* produces no further changes (existing rows are skipped at step 3a,
|
|
27
|
+
* already-orphaned rows are skipped at step 3c).
|
|
28
|
+
*
|
|
29
|
+
* Coordination with issue #962: the dedup logic in `olam create` handles
|
|
30
|
+
* per-call deduplication; this reconciler handles boot-time cleanup.
|
|
31
|
+
* They don't conflict — both operate on the worlds.db source-of-truth.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createRequire } from 'node:module';
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
|
|
38
|
+
const CONTAINER_NAME_PATTERN = /^\/?(olam-(.+)-devbox)$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} ReconcileDeps
|
|
42
|
+
* @property {string} dbPath Path to worlds.db
|
|
43
|
+
* @property {() => Promise<string[] | null>} listContainerNames Returns null when docker is unreachable
|
|
44
|
+
* @property {(msg: string) => void} [log] Defaults to console.log
|
|
45
|
+
* @property {() => string} [now] ISO timestamp generator (overridable for tests)
|
|
46
|
+
* @property {(path: string) => unknown | null} [openDb] Overridable DB opener (tests inject fakes)
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {object} ReconcileSummary
|
|
51
|
+
* @property {number} inserted Number of new rows inserted (reconciled containers)
|
|
52
|
+
* @property {number} orphaned Number of rows transitioned to status='orphaned'
|
|
53
|
+
* @property {number} skipped Containers/rows where no change was needed
|
|
54
|
+
* @property {boolean} dockerUnreachable
|
|
55
|
+
* @property {boolean} dbUnavailable
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract a worldId from a docker container name.
|
|
60
|
+
* Accepts either `olam-foo-bar-1234-devbox` or `/olam-foo-bar-1234-devbox`
|
|
61
|
+
* (the docker API prefixes container names with a slash).
|
|
62
|
+
*
|
|
63
|
+
* @param {string} name
|
|
64
|
+
* @returns {string | null}
|
|
65
|
+
*/
|
|
66
|
+
export function extractWorldIdFromContainerName(name) {
|
|
67
|
+
if (typeof name !== 'string') return null;
|
|
68
|
+
const match = CONTAINER_NAME_PATTERN.exec(name);
|
|
69
|
+
if (!match) return null;
|
|
70
|
+
const worldId = match[2];
|
|
71
|
+
if (!worldId || worldId.length === 0) return null;
|
|
72
|
+
return worldId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default docker container lister. Hits the Docker Engine API.
|
|
77
|
+
* Returns null on any failure (fail-soft).
|
|
78
|
+
*
|
|
79
|
+
* @param {string} dockerApiBase e.g. 'http://docker-socket-proxy:2375'
|
|
80
|
+
* @param {(msg: string) => void} log
|
|
81
|
+
* @returns {Promise<string[] | null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function defaultListContainerNames(dockerApiBase, log) {
|
|
84
|
+
if (!dockerApiBase || dockerApiBase === 'http://localhost:2375') {
|
|
85
|
+
// 'docker-cli' sentinel; no API available in this deployment mode.
|
|
86
|
+
log('[boot-reconciler] docker API unavailable (bare-node mode); skipping');
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const filters = encodeURIComponent(JSON.stringify({ name: ['olam-'] }));
|
|
91
|
+
const url = `${dockerApiBase}/containers/json?filters=${filters}`;
|
|
92
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
log(`[boot-reconciler] docker /containers/json returned ${res.status}; skipping`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
if (!Array.isArray(data)) return [];
|
|
99
|
+
const names = [];
|
|
100
|
+
for (const container of data) {
|
|
101
|
+
const list = container?.Names;
|
|
102
|
+
if (!Array.isArray(list)) continue;
|
|
103
|
+
for (const n of list) {
|
|
104
|
+
if (typeof n === 'string') names.push(n);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return names;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
log(`[boot-reconciler] docker query failed: ${err.message}; skipping`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Default DB opener. Loads better-sqlite3 dynamically so a missing
|
|
116
|
+
* native build degrades gracefully instead of crashing host-cp boot.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} dbPath
|
|
119
|
+
* @param {(msg: string) => void} log
|
|
120
|
+
* @returns {unknown | null}
|
|
121
|
+
*/
|
|
122
|
+
export function defaultOpenDb(dbPath, log) {
|
|
123
|
+
try {
|
|
124
|
+
const Database = require('better-sqlite3');
|
|
125
|
+
return new Database(dbPath, { fileMustExist: true });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err && err.code === 'MODULE_NOT_FOUND') {
|
|
128
|
+
log('[boot-reconciler] better-sqlite3 not available; skipping');
|
|
129
|
+
} else if (err && err.code === 'SQLITE_CANTOPEN') {
|
|
130
|
+
log(`[boot-reconciler] ${dbPath} not found; nothing to reconcile`);
|
|
131
|
+
} else {
|
|
132
|
+
log(`[boot-reconciler] failed to open ${dbPath}: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run a single boot-time reconciliation pass. Pure and dep-injected
|
|
140
|
+
* for testability.
|
|
141
|
+
*
|
|
142
|
+
* @param {ReconcileDeps} deps
|
|
143
|
+
* @returns {Promise<ReconcileSummary>}
|
|
144
|
+
*/
|
|
145
|
+
export async function reconcileWorldsWithDocker(deps) {
|
|
146
|
+
const log = deps.log ?? console.log;
|
|
147
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
148
|
+
const openDb = deps.openDb ?? ((p) => defaultOpenDb(p, log));
|
|
149
|
+
|
|
150
|
+
const summary = {
|
|
151
|
+
inserted: 0,
|
|
152
|
+
orphaned: 0,
|
|
153
|
+
skipped: 0,
|
|
154
|
+
dockerUnreachable: false,
|
|
155
|
+
dbUnavailable: false,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const containerNames = await deps.listContainerNames();
|
|
159
|
+
if (containerNames === null) {
|
|
160
|
+
summary.dockerUnreachable = true;
|
|
161
|
+
return summary;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const liveWorldIds = new Set();
|
|
165
|
+
for (const name of containerNames) {
|
|
166
|
+
const worldId = extractWorldIdFromContainerName(name);
|
|
167
|
+
if (worldId) liveWorldIds.add(worldId);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const db = openDb(deps.dbPath);
|
|
171
|
+
if (!db) {
|
|
172
|
+
summary.dbUnavailable = true;
|
|
173
|
+
return summary;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
/** @type {Array<{ id: string, status: string }>} */
|
|
178
|
+
let rows;
|
|
179
|
+
try {
|
|
180
|
+
rows = db.prepare('SELECT id, status FROM worlds').all();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
log(`[boot-reconciler] query failed: ${err.message}; skipping`);
|
|
183
|
+
summary.dbUnavailable = true;
|
|
184
|
+
return summary;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const dbWorlds = new Map(rows.map((r) => [r.id, r.status]));
|
|
188
|
+
|
|
189
|
+
// Pass 1: containers alive but missing from DB → insert.
|
|
190
|
+
const insertStmt = db.prepare(
|
|
191
|
+
`INSERT INTO worlds
|
|
192
|
+
(id, name, status, repos, branch, port_offset, workspace_path,
|
|
193
|
+
compute_provider, total_cost_usd, thought_count, created_at, updated_at)
|
|
194
|
+
VALUES (?, ?, 'reconciled', '[]', 'main', 0, ?, 'docker', 0, 0, ?, ?)`,
|
|
195
|
+
);
|
|
196
|
+
for (const worldId of liveWorldIds) {
|
|
197
|
+
if (dbWorlds.has(worldId)) {
|
|
198
|
+
summary.skipped += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const ts = now();
|
|
202
|
+
const workspacePath = `~/.olam/worlds/${worldId}`;
|
|
203
|
+
try {
|
|
204
|
+
insertStmt.run(worldId, worldId, workspacePath, ts, ts);
|
|
205
|
+
summary.inserted += 1;
|
|
206
|
+
log(`[boot-reconciler] inserted reconciled row for ${worldId} (container alive, no DB row)`);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
log(`[boot-reconciler] failed to insert ${worldId}: ${err.message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Pass 2: DB says alive but container missing → mark orphaned.
|
|
213
|
+
const orphanStmt = db.prepare(
|
|
214
|
+
`UPDATE worlds SET status = 'orphaned', updated_at = ? WHERE id = ?`,
|
|
215
|
+
);
|
|
216
|
+
const aliveStatuses = new Set(['running', 'active', 'creating']);
|
|
217
|
+
for (const [worldId, status] of dbWorlds) {
|
|
218
|
+
if (liveWorldIds.has(worldId)) continue;
|
|
219
|
+
if (!aliveStatuses.has(status)) continue;
|
|
220
|
+
try {
|
|
221
|
+
orphanStmt.run(now(), worldId);
|
|
222
|
+
summary.orphaned += 1;
|
|
223
|
+
log(`[boot-reconciler] marked ${worldId} as orphaned (was '${status}', container missing)`);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
log(`[boot-reconciler] failed to mark ${worldId} orphaned: ${err.message}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
log(
|
|
230
|
+
`[boot-reconciler] complete: inserted=${summary.inserted} orphaned=${summary.orphaned} ` +
|
|
231
|
+
`skipped=${summary.skipped} live-containers=${liveWorldIds.size}`,
|
|
232
|
+
);
|
|
233
|
+
} finally {
|
|
234
|
+
try { db.close?.(); } catch { /* ignore */ }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return summary;
|
|
238
|
+
}
|
|
@@ -10,6 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
|
|
11
11
|
const DOCKER_HOST = process.env.DOCKER_HOST ?? 'docker-cli';
|
|
12
12
|
const SOCAT_IMAGE = 'alpine/socat';
|
|
13
|
+
const SOCAT_IMAGE_TAGGED = 'alpine/socat:latest';
|
|
13
14
|
const HOST_PORT_MIN = 25000;
|
|
14
15
|
const HOST_PORT_MAX = 25999;
|
|
15
16
|
const INFRA_PORTS = new Set([8080, 7681, 7682]);
|
|
@@ -83,11 +84,73 @@ async function dockerApiBase() {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
|
-
*
|
|
87
|
+
* Detect whether a docker error message indicates the image is missing
|
|
88
|
+
* (and therefore a `docker pull` retry would help). Docker uses a handful
|
|
89
|
+
* of phrasings across CLI + HTTP API surfaces.
|
|
90
|
+
*/
|
|
91
|
+
function isImageMissingError(message) {
|
|
92
|
+
if (!message) return false;
|
|
93
|
+
return /Unable to find image|pull access denied|manifest unknown|No such image|not found in (the )?(repository|registry)/i.test(
|
|
94
|
+
message,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pull alpine/socat:latest via docker CLI. Used by the bare-node bridge
|
|
100
|
+
* create path's fallback retry. 60s budget — image is ~5MB; real pull
|
|
101
|
+
* is typically <2s.
|
|
102
|
+
*
|
|
103
|
+
* @returns {{ok: boolean, stderr: string}}
|
|
104
|
+
*/
|
|
105
|
+
function pullSocatViaCli() {
|
|
106
|
+
const r = spawnSync('docker', ['pull', SOCAT_IMAGE_TAGGED], {
|
|
107
|
+
encoding: 'utf-8',
|
|
108
|
+
timeout: 60_000,
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
ok: r.status === 0,
|
|
112
|
+
stderr: (r.stderr ?? '').trim() || (r.stdout ?? '').trim(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Pull alpine/socat:latest via Docker HTTP API. Used by the container-mode
|
|
118
|
+
* bridge create path's fallback retry. Streams the pull progress body so
|
|
119
|
+
* Docker actually performs the pull (it's a streaming endpoint).
|
|
120
|
+
*
|
|
121
|
+
* @param {string} apiBase — Docker HTTP API base URL
|
|
122
|
+
* @returns {Promise<{ok: boolean, stderr: string}>}
|
|
123
|
+
*/
|
|
124
|
+
async function pullSocatViaHttpApi(apiBase) {
|
|
125
|
+
try {
|
|
126
|
+
const resp = await fetch(
|
|
127
|
+
`${apiBase}/images/create?fromImage=${encodeURIComponent(SOCAT_IMAGE)}&tag=latest`,
|
|
128
|
+
{ method: 'POST', signal: AbortSignal.timeout(60_000) },
|
|
129
|
+
);
|
|
130
|
+
if (!resp.ok) {
|
|
131
|
+
const body = await resp.text().catch(() => '');
|
|
132
|
+
return { ok: false, stderr: `pull failed: ${resp.status} ${body}` };
|
|
133
|
+
}
|
|
134
|
+
// Drain the streaming progress body — Docker only completes the pull
|
|
135
|
+
// when the response is consumed.
|
|
136
|
+
await resp.text();
|
|
137
|
+
return { ok: true, stderr: '' };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, stderr: err?.message ?? String(err) };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create and start a socat bridge container.
|
|
145
|
+
*
|
|
146
|
+
* Returns `{ containerId, pulledImage }` — `pulledImage: true` indicates the
|
|
147
|
+
* function had to fall back to `docker pull alpine/socat:latest` (issue #964
|
|
148
|
+
* — preflight in `olam services up` should normally have already pulled it).
|
|
149
|
+
*
|
|
87
150
|
* @param {string} worldId
|
|
88
151
|
* @param {number} containerPort
|
|
89
152
|
* @param {number} hostPort
|
|
90
|
-
* @returns {Promise<string>}
|
|
153
|
+
* @returns {Promise<{containerId: string, pulledImage: boolean}>}
|
|
91
154
|
*/
|
|
92
155
|
async function createBridgeContainer(worldId, containerPort, hostPort) {
|
|
93
156
|
const name = bridgeContainerName(worldId, containerPort);
|
|
@@ -111,11 +174,28 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
|
|
|
111
174
|
'TCP-LISTEN:' + containerPort + ',fork,reuseaddr',
|
|
112
175
|
'TCP:' + devboxName + ':' + containerPort,
|
|
113
176
|
];
|
|
114
|
-
|
|
177
|
+
let result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
|
|
178
|
+
let pulledImage = false;
|
|
179
|
+
|
|
180
|
+
// Issue #964 fallback: if docker run failed because the image is missing,
|
|
181
|
+
// pull it and retry once. This covers hosts where `olam services up`
|
|
182
|
+
// didn't run the preflight (e.g. fresh Hazel install, docker restart
|
|
183
|
+
// pruned the image, etc.).
|
|
184
|
+
if (result.status !== 0 && isImageMissingError(result.stderr ?? '')) {
|
|
185
|
+
const pull = pullSocatViaCli();
|
|
186
|
+
if (!pull.ok) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`alpine/socat image missing and pull failed: ${pull.stderr || 'unknown error'}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
pulledImage = true;
|
|
192
|
+
result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
|
|
193
|
+
}
|
|
194
|
+
|
|
115
195
|
if (result.status !== 0) {
|
|
116
196
|
throw new Error(result.stderr?.trim() || 'docker run failed');
|
|
117
197
|
}
|
|
118
|
-
return result.stdout.trim()
|
|
198
|
+
return { containerId: result.stdout.trim(), pulledImage };
|
|
119
199
|
}
|
|
120
200
|
|
|
121
201
|
// container mode: Docker HTTP API
|
|
@@ -135,7 +215,7 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
|
|
|
135
215
|
},
|
|
136
216
|
};
|
|
137
217
|
|
|
138
|
-
const
|
|
218
|
+
const doCreate = () => fetch(
|
|
139
219
|
`${apiBase}/containers/create?name=${encodeURIComponent(name)}`,
|
|
140
220
|
{
|
|
141
221
|
method: 'POST',
|
|
@@ -145,6 +225,28 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
|
|
|
145
225
|
},
|
|
146
226
|
);
|
|
147
227
|
|
|
228
|
+
let createResp = await doCreate();
|
|
229
|
+
let pulledImage = false;
|
|
230
|
+
|
|
231
|
+
// Issue #964 fallback for HTTP API path. Docker returns 404 with a body
|
|
232
|
+
// like {"message":"No such image: alpine/socat:latest"} when the image
|
|
233
|
+
// is missing.
|
|
234
|
+
if (!createResp.ok && createResp.status === 404) {
|
|
235
|
+
const body = await createResp.text().catch(() => '');
|
|
236
|
+
if (isImageMissingError(body)) {
|
|
237
|
+
const pull = await pullSocatViaHttpApi(apiBase);
|
|
238
|
+
if (!pull.ok) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`alpine/socat image missing and pull failed: ${pull.stderr || 'unknown error'}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
pulledImage = true;
|
|
244
|
+
createResp = await doCreate();
|
|
245
|
+
} else {
|
|
246
|
+
throw new Error(`container create failed: 404 ${body}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
148
250
|
if (!createResp.ok) {
|
|
149
251
|
const body = await createResp.text().catch(() => '');
|
|
150
252
|
// If container already exists (409), try to get its ID
|
|
@@ -155,7 +257,7 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
|
|
|
155
257
|
);
|
|
156
258
|
if (inspectResp.ok) {
|
|
157
259
|
const info = await inspectResp.json();
|
|
158
|
-
return info.Id;
|
|
260
|
+
return { containerId: info.Id, pulledImage };
|
|
159
261
|
}
|
|
160
262
|
}
|
|
161
263
|
throw new Error(`container create failed: ${createResp.status} ${body}`);
|
|
@@ -171,7 +273,7 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
|
|
|
171
273
|
throw new Error(`container start failed: ${startResp.status}`);
|
|
172
274
|
}
|
|
173
275
|
|
|
174
|
-
return containerId;
|
|
276
|
+
return { containerId, pulledImage };
|
|
175
277
|
}
|
|
176
278
|
|
|
177
279
|
async function removeBridgeContainer(containerName, containerId) {
|
|
@@ -196,7 +298,7 @@ async function removeBridgeContainer(containerName, containerId) {
|
|
|
196
298
|
*
|
|
197
299
|
* @param {string} worldId
|
|
198
300
|
* @param {number} containerPort
|
|
199
|
-
* @returns {Promise<{hostPort: number, containerPort: number, url: string, containerId: string}>}
|
|
301
|
+
* @returns {Promise<{hostPort: number, containerPort: number, url: string, containerId: string, pulledImage?: boolean}>}
|
|
200
302
|
*/
|
|
201
303
|
export async function exposePort(worldId, containerPort) {
|
|
202
304
|
if (INFRA_PORTS.has(containerPort)) {
|
|
@@ -220,18 +322,22 @@ export async function exposePort(worldId, containerPort) {
|
|
|
220
322
|
}
|
|
221
323
|
|
|
222
324
|
const containerName = bridgeContainerName(worldId, containerPort);
|
|
223
|
-
const containerId = await createBridgeContainer(worldId, containerPort, hostPort);
|
|
325
|
+
const { containerId, pulledImage } = await createBridgeContainer(worldId, containerPort, hostPort);
|
|
224
326
|
|
|
225
327
|
const entry = { worldId, containerPort, hostPort, containerId, containerName };
|
|
226
328
|
registry.set(key, entry);
|
|
227
329
|
saveState();
|
|
228
330
|
|
|
229
|
-
|
|
331
|
+
const result = {
|
|
230
332
|
hostPort,
|
|
231
333
|
containerPort,
|
|
232
334
|
url: `http://${HOST_IP}:${hostPort}`,
|
|
233
335
|
containerId,
|
|
234
336
|
};
|
|
337
|
+
// Only attach pulledImage when true so existing callers/tests don't see
|
|
338
|
+
// an unexpected key when the preflight succeeded.
|
|
339
|
+
if (pulledImage) result.pulledImage = true;
|
|
340
|
+
return result;
|
|
235
341
|
}
|
|
236
342
|
|
|
237
343
|
/**
|
package/host-cp/src/server.mjs
CHANGED
|
@@ -76,6 +76,11 @@ import { readSecret as readPlanChatSecret, SECRET_PATH as PLAN_CHAT_SECRET_PATH
|
|
|
76
76
|
import { createPrMergePoller } from './pr-merge-poller.mjs';
|
|
77
77
|
import { parse as parseYaml } from 'yaml';
|
|
78
78
|
import { startWorldsDbReconciler } from './worlds-db-source.mjs';
|
|
79
|
+
import {
|
|
80
|
+
reconcileWorldsWithDocker,
|
|
81
|
+
defaultListContainerNames,
|
|
82
|
+
} from './boot-reconciler.mjs';
|
|
83
|
+
import { startWorldActivityTracker } from './world-activity-tracker.mjs';
|
|
79
84
|
import { authSecretHint } from './auth-secret-hint.mjs';
|
|
80
85
|
import * as tunnelManager from './world-tunnel-manager.mjs';
|
|
81
86
|
import * as bridgeManager from './port-bridge-manager.mjs';
|
|
@@ -3270,6 +3275,16 @@ startWorldsSnapshotLoop();
|
|
|
3270
3275
|
startTunnelsSnapshotLoop();
|
|
3271
3276
|
startListeningSnapshotLoop();
|
|
3272
3277
|
|
|
3278
|
+
// Closes #965: live thought_count + total_cost_usd updates from each
|
|
3279
|
+
// active world's Claude session JSONL. Periodic (60s default) so Rico's
|
|
3280
|
+
// scheduling loop can read fresh values from the `worlds` table and
|
|
3281
|
+
// SPAs can subscribe to the `world.activity.tick` event. Fail-soft per
|
|
3282
|
+
// world: missing/malformed JSONL never crashes the loop.
|
|
3283
|
+
const worldActivityTracker = startWorldActivityTracker({
|
|
3284
|
+
dbPath: WORLDS_DB_PATH,
|
|
3285
|
+
broadcaster: hostStream,
|
|
3286
|
+
});
|
|
3287
|
+
|
|
3273
3288
|
// ── Phase 1a / B1 (PR3): engine-select + await-before-listen ─────
|
|
3274
3289
|
//
|
|
3275
3290
|
// Decision 15: the async KubernetesEngine factory MUST be fully awaited
|
|
@@ -3296,6 +3311,22 @@ const hostCpEngine = await (async () => {
|
|
|
3296
3311
|
return createDockerEngine({ dockerHost: DOCKER_HOST });
|
|
3297
3312
|
})();
|
|
3298
3313
|
|
|
3314
|
+
// ── Boot-time worlds.db ↔ docker reconciler (issue #963) ─────────────
|
|
3315
|
+
//
|
|
3316
|
+
// One-shot pass: if a container is alive but worlds.db has no row, insert
|
|
3317
|
+
// a status='reconciled' row so host-cp can see it. If worlds.db says a
|
|
3318
|
+
// world is running/active but the container is gone, mark it 'orphaned'.
|
|
3319
|
+
// Fail-soft: docker unreachable or DB unavailable → log + continue boot.
|
|
3320
|
+
// Runs BEFORE server.listen() so the first request sees reconciled state.
|
|
3321
|
+
try {
|
|
3322
|
+
await reconcileWorldsWithDocker({
|
|
3323
|
+
dbPath: WORLDS_DB_PATH,
|
|
3324
|
+
listContainerNames: () => defaultListContainerNames(DOCKER_API_BASE, console.log),
|
|
3325
|
+
});
|
|
3326
|
+
} catch (err) {
|
|
3327
|
+
console.error(`[boot-reconciler] unexpected error (continuing boot): ${err.message}`);
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3299
3330
|
server.listen(PORT, '0.0.0.0', () => {
|
|
3300
3331
|
console.log(`olam-host-cp B3 listening on :${PORT}`);
|
|
3301
3332
|
console.log(` DOCKER_HOST=${DOCKER_HOST}`);
|
|
@@ -3336,6 +3367,7 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
|
3336
3367
|
stopWorldsSnapshotLoop();
|
|
3337
3368
|
stopTunnelsSnapshotLoop();
|
|
3338
3369
|
stopListeningSnapshotLoop();
|
|
3370
|
+
worldActivityTracker.stop();
|
|
3339
3371
|
if (serversSnapshotTimer) { clearTimeout(serversSnapshotTimer); serversSnapshotTimer = null; }
|
|
3340
3372
|
hostStream.close();
|
|
3341
3373
|
if (ndjsonSpanSink) ndjsonSpanSink.close().catch(() => {});
|