@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.
Files changed (196) hide show
  1. package/dist/__tests__/auth-status.test.d.ts +2 -0
  2. package/dist/__tests__/auth-status.test.d.ts.map +1 -0
  3. package/dist/__tests__/auth-status.test.js +290 -0
  4. package/dist/__tests__/auth-status.test.js.map +1 -0
  5. package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
  6. package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth-upgrade.test.js +161 -0
  8. package/dist/__tests__/auth-upgrade.test.js.map +1 -0
  9. package/dist/__tests__/create-app-urls.test.d.ts +2 -0
  10. package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
  11. package/dist/__tests__/create-app-urls.test.js +102 -0
  12. package/dist/__tests__/create-app-urls.test.js.map +1 -0
  13. package/dist/__tests__/enter.test.d.ts +2 -0
  14. package/dist/__tests__/enter.test.d.ts.map +1 -0
  15. package/dist/__tests__/enter.test.js +90 -0
  16. package/dist/__tests__/enter.test.js.map +1 -0
  17. package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
  18. package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
  19. package/dist/__tests__/host-cp-gh-token.test.js +119 -0
  20. package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
  21. package/dist/__tests__/host-cp.test.d.ts +9 -0
  22. package/dist/__tests__/host-cp.test.d.ts.map +1 -0
  23. package/dist/__tests__/host-cp.test.js +254 -0
  24. package/dist/__tests__/host-cp.test.js.map +1 -0
  25. package/dist/__tests__/keys.test.d.ts +9 -0
  26. package/dist/__tests__/keys.test.d.ts.map +1 -0
  27. package/dist/__tests__/keys.test.js +145 -0
  28. package/dist/__tests__/keys.test.js.map +1 -0
  29. package/dist/__tests__/logs.test.d.ts +9 -0
  30. package/dist/__tests__/logs.test.d.ts.map +1 -0
  31. package/dist/__tests__/logs.test.js +124 -0
  32. package/dist/__tests__/logs.test.js.map +1 -0
  33. package/dist/__tests__/ps.test.d.ts +2 -0
  34. package/dist/__tests__/ps.test.d.ts.map +1 -0
  35. package/dist/__tests__/ps.test.js +172 -0
  36. package/dist/__tests__/ps.test.js.map +1 -0
  37. package/dist/__tests__/status-app-urls.test.d.ts +2 -0
  38. package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
  39. package/dist/__tests__/status-app-urls.test.js +125 -0
  40. package/dist/__tests__/status-app-urls.test.js.map +1 -0
  41. package/dist/__tests__/upgrade.test.d.ts +9 -0
  42. package/dist/__tests__/upgrade.test.d.ts.map +1 -0
  43. package/dist/__tests__/upgrade.test.js +262 -0
  44. package/dist/__tests__/upgrade.test.js.map +1 -0
  45. package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
  46. package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
  47. package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
  48. package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
  49. package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
  50. package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
  51. package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
  52. package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
  53. package/dist/commands/__tests__/refresh.test.d.ts +13 -0
  54. package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
  55. package/dist/commands/__tests__/refresh.test.js +170 -0
  56. package/dist/commands/__tests__/refresh.test.js.map +1 -0
  57. package/dist/commands/auth-status.d.ts +43 -0
  58. package/dist/commands/auth-status.d.ts.map +1 -0
  59. package/dist/commands/auth-status.js +208 -0
  60. package/dist/commands/auth-status.js.map +1 -0
  61. package/dist/commands/auth-upgrade.d.ts +47 -0
  62. package/dist/commands/auth-upgrade.d.ts.map +1 -0
  63. package/dist/commands/auth-upgrade.js +277 -0
  64. package/dist/commands/auth-upgrade.js.map +1 -0
  65. package/dist/commands/auth.d.ts +16 -0
  66. package/dist/commands/auth.d.ts.map +1 -0
  67. package/dist/commands/auth.js +283 -0
  68. package/dist/commands/auth.js.map +1 -0
  69. package/dist/commands/create.d.ts +8 -0
  70. package/dist/commands/create.d.ts.map +1 -0
  71. package/dist/commands/create.js +512 -0
  72. package/dist/commands/create.js.map +1 -0
  73. package/dist/commands/crystallize.d.ts +8 -0
  74. package/dist/commands/crystallize.d.ts.map +1 -0
  75. package/dist/commands/crystallize.js +101 -0
  76. package/dist/commands/crystallize.js.map +1 -0
  77. package/dist/commands/destroy.d.ts +6 -0
  78. package/dist/commands/destroy.d.ts.map +1 -0
  79. package/dist/commands/destroy.js +54 -0
  80. package/dist/commands/destroy.js.map +1 -0
  81. package/dist/commands/dispatch.d.ts +9 -0
  82. package/dist/commands/dispatch.d.ts.map +1 -0
  83. package/dist/commands/dispatch.js +94 -0
  84. package/dist/commands/dispatch.js.map +1 -0
  85. package/dist/commands/enter.d.ts +63 -0
  86. package/dist/commands/enter.d.ts.map +1 -0
  87. package/dist/commands/enter.js +206 -0
  88. package/dist/commands/enter.js.map +1 -0
  89. package/dist/commands/host-cp.d.ts +191 -0
  90. package/dist/commands/host-cp.d.ts.map +1 -0
  91. package/dist/commands/host-cp.js +797 -0
  92. package/dist/commands/host-cp.js.map +1 -0
  93. package/dist/commands/init.d.ts +9 -0
  94. package/dist/commands/init.d.ts.map +1 -0
  95. package/dist/commands/init.js +143 -0
  96. package/dist/commands/init.js.map +1 -0
  97. package/dist/commands/install.d.ts +22 -0
  98. package/dist/commands/install.d.ts.map +1 -0
  99. package/dist/commands/install.js +203 -0
  100. package/dist/commands/install.js.map +1 -0
  101. package/dist/commands/keys.d.ts +26 -0
  102. package/dist/commands/keys.d.ts.map +1 -0
  103. package/dist/commands/keys.js +151 -0
  104. package/dist/commands/keys.js.map +1 -0
  105. package/dist/commands/lanes.d.ts +18 -0
  106. package/dist/commands/lanes.d.ts.map +1 -0
  107. package/dist/commands/lanes.js +122 -0
  108. package/dist/commands/lanes.js.map +1 -0
  109. package/dist/commands/list.d.ts +6 -0
  110. package/dist/commands/list.d.ts.map +1 -0
  111. package/dist/commands/list.js +39 -0
  112. package/dist/commands/list.js.map +1 -0
  113. package/dist/commands/logs.d.ts +38 -0
  114. package/dist/commands/logs.d.ts.map +1 -0
  115. package/dist/commands/logs.js +177 -0
  116. package/dist/commands/logs.js.map +1 -0
  117. package/dist/commands/observe.d.ts +9 -0
  118. package/dist/commands/observe.d.ts.map +1 -0
  119. package/dist/commands/observe.js +34 -0
  120. package/dist/commands/observe.js.map +1 -0
  121. package/dist/commands/policy-check.d.ts +14 -0
  122. package/dist/commands/policy-check.d.ts.map +1 -0
  123. package/dist/commands/policy-check.js +76 -0
  124. package/dist/commands/policy-check.js.map +1 -0
  125. package/dist/commands/pr.d.ts +17 -0
  126. package/dist/commands/pr.d.ts.map +1 -0
  127. package/dist/commands/pr.js +148 -0
  128. package/dist/commands/pr.js.map +1 -0
  129. package/dist/commands/ps.d.ts +25 -0
  130. package/dist/commands/ps.d.ts.map +1 -0
  131. package/dist/commands/ps.js +164 -0
  132. package/dist/commands/ps.js.map +1 -0
  133. package/dist/commands/refresh-helpers.d.ts +25 -0
  134. package/dist/commands/refresh-helpers.d.ts.map +1 -0
  135. package/dist/commands/refresh-helpers.js +56 -0
  136. package/dist/commands/refresh-helpers.js.map +1 -0
  137. package/dist/commands/refresh.d.ts +23 -0
  138. package/dist/commands/refresh.d.ts.map +1 -0
  139. package/dist/commands/refresh.js +237 -0
  140. package/dist/commands/refresh.js.map +1 -0
  141. package/dist/commands/status.d.ts +6 -0
  142. package/dist/commands/status.d.ts.map +1 -0
  143. package/dist/commands/status.js +51 -0
  144. package/dist/commands/status.js.map +1 -0
  145. package/dist/commands/upgrade.d.ts +67 -0
  146. package/dist/commands/upgrade.d.ts.map +1 -0
  147. package/dist/commands/upgrade.js +358 -0
  148. package/dist/commands/upgrade.js.map +1 -0
  149. package/dist/commands/workspace.d.ts +23 -0
  150. package/dist/commands/workspace.d.ts.map +1 -0
  151. package/dist/commands/workspace.js +198 -0
  152. package/dist/commands/workspace.js.map +1 -0
  153. package/dist/commands/world-snapshot.d.ts +18 -0
  154. package/dist/commands/world-snapshot.d.ts.map +1 -0
  155. package/dist/commands/world-snapshot.js +327 -0
  156. package/dist/commands/world-snapshot.js.map +1 -0
  157. package/dist/context.d.ts +26 -0
  158. package/dist/context.d.ts.map +1 -0
  159. package/dist/context.js +51 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/index.d.ts +9 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +18007 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/mcp-server.js +32236 -0
  166. package/dist/output.d.ts +10 -0
  167. package/dist/output.d.ts.map +1 -0
  168. package/dist/output.js +31 -0
  169. package/dist/output.js.map +1 -0
  170. package/host-cp/compose.yaml +126 -0
  171. package/host-cp/src/auth-secret-hint.mjs +45 -0
  172. package/host-cp/src/auth.mjs +155 -0
  173. package/host-cp/src/compose-worlds-sources.mjs +170 -0
  174. package/host-cp/src/container-secret-fetcher.mjs +163 -0
  175. package/host-cp/src/docker-events.mjs +184 -0
  176. package/host-cp/src/local-worlds-source.mjs +83 -0
  177. package/host-cp/src/plan-orchestrator.mjs +829 -0
  178. package/host-cp/src/plan-progress.mjs +282 -0
  179. package/host-cp/src/pr-cache.mjs +201 -0
  180. package/host-cp/src/pr-merge-poller.mjs +154 -0
  181. package/host-cp/src/process-poller.mjs +250 -0
  182. package/host-cp/src/proxy.mjs +245 -0
  183. package/host-cp/src/pylon-worlds-source.mjs +68 -0
  184. package/host-cp/src/redact.mjs +67 -0
  185. package/host-cp/src/secret-cache.mjs +104 -0
  186. package/host-cp/src/server.mjs +2215 -0
  187. package/host-cp/src/sse-gate.mjs +117 -0
  188. package/host-cp/src/version-status.mjs +209 -0
  189. package/host-cp/src/workspace-catalog.mjs +149 -0
  190. package/host-cp/src/world-names-store.mjs +176 -0
  191. package/host-cp/src/world-pr-state.mjs +97 -0
  192. package/host-cp/src/world-progress.mjs +322 -0
  193. package/host-cp/src/world-tunnel-manager.mjs +288 -0
  194. package/host-cp/src/worlds-db-source.mjs +191 -0
  195. package/host-cp/src/worlds-source.mjs +59 -0
  196. package/package.json +38 -0
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * @typedef {object} PrStateEntry
6
+ * @property {string} pr_url
7
+ * @property {number|null} pr_number
8
+ * @property {string|null} pr_repo
9
+ * @property {string|null} pr_created_at
10
+ * @property {'open'|'merged'|'merged_destroyed'} pr_state
11
+ * @property {string|null} pr_merged_at
12
+ * @property {boolean} auto_destroy_on_merge
13
+ */
14
+
15
+ /**
16
+ * @param {string} filePath
17
+ */
18
+ export function createWorldPrStateStore(filePath) {
19
+ /** @type {Record<string, PrStateEntry>} */
20
+ let cache = {};
21
+ let cacheMtimeMs = -1;
22
+
23
+ function readFromDisk() {
24
+ if (!fs.existsSync(filePath)) {
25
+ cache = {};
26
+ cacheMtimeMs = 0;
27
+ return;
28
+ }
29
+ try {
30
+ const stat = fs.statSync(filePath);
31
+ if (stat.mtimeMs === cacheMtimeMs) return;
32
+ const raw = fs.readFileSync(filePath, 'utf-8');
33
+ const parsed = JSON.parse(raw);
34
+ cache = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
35
+ cacheMtimeMs = stat.mtimeMs;
36
+ } catch (err) {
37
+ console.error(`world-pr-state: failed to read ${filePath}: ${err.message}`);
38
+ cache = {};
39
+ cacheMtimeMs = 0;
40
+ }
41
+ }
42
+
43
+ function writeToDisk() {
44
+ const dir = path.dirname(filePath);
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
47
+ fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), 'utf-8');
48
+ fs.renameSync(tmp, filePath);
49
+ try {
50
+ cacheMtimeMs = fs.statSync(filePath).mtimeMs;
51
+ } catch {
52
+ cacheMtimeMs = 0;
53
+ }
54
+ }
55
+
56
+ function getAll() {
57
+ readFromDisk();
58
+ return { ...cache };
59
+ }
60
+
61
+ /** @param {string} worldId */
62
+ function get(worldId) {
63
+ readFromDisk();
64
+ return cache[worldId] ?? null;
65
+ }
66
+
67
+ /**
68
+ * Upsert — merges data with the existing entry.
69
+ * @param {string} worldId
70
+ * @param {Partial<PrStateEntry>} data
71
+ */
72
+ function set(worldId, data) {
73
+ readFromDisk();
74
+ const existing = cache[worldId] ?? {};
75
+ cache = { ...cache, [worldId]: { ...existing, ...data } };
76
+ writeToDisk();
77
+ }
78
+
79
+ /** @param {string} worldId */
80
+ function remove(worldId) {
81
+ readFromDisk();
82
+ if (!(worldId in cache)) return;
83
+ const next = { ...cache };
84
+ delete next[worldId];
85
+ cache = next;
86
+ writeToDisk();
87
+ }
88
+
89
+ function getWorldsToWatch() {
90
+ readFromDisk();
91
+ return Object.entries(cache)
92
+ .filter(([, entry]) => entry.pr_url && entry.pr_state !== 'merged_destroyed')
93
+ .map(([worldId, entry]) => ({ worldId, ...entry }));
94
+ }
95
+
96
+ return { getAll, get, set, remove, getWorldsToWatch };
97
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * World progress computation — maps world state onto the 8-phase ladder
3
+ * shown in the inbox row progress bar.
4
+ *
5
+ * @module world-progress
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { createRequire } from 'node:module';
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+ import { readPlanProgress } from './plan-progress.mjs';
14
+
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ // Mirror of @olam/core/src/world-paths.mjs. Inlined deliberately: host-cp's
18
+ // slim Docker image does NOT bundle @olam/core (see server.mjs ~L560 for the
19
+ // architectural decision). Keep these two definitions in sync until the
20
+ // host-cp image build is taught to vendor workspace deps.
21
+ const WORLD_DB_FILENAME = 'world.db';
22
+ function getWorldDbPath(workspacePath) {
23
+ return path.join(workspacePath, WORLD_DB_FILENAME);
24
+ }
25
+
26
+ /**
27
+ * Phase ladder definition.
28
+ * @type {Array<{name: string, index: number}>}
29
+ */
30
+ const PHASES = [
31
+ { name: 'starting', index: 1 },
32
+ { name: 'implementing', index: 2 },
33
+ { name: 'committing', index: 3 },
34
+ { name: 'pushing', index: 4 },
35
+ { name: 'in_review', index: 5 },
36
+ { name: 'ci_failed', index: 6 },
37
+ { name: 'ready', index: 7 },
38
+ { name: 'merged', index: 8 },
39
+ ];
40
+
41
+ const PHASE_TOTAL = PHASES.length;
42
+ const IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
43
+
44
+ /**
45
+ * Determine the current phase from observable state.
46
+ *
47
+ * @param {{
48
+ * thoughts: number,
49
+ * commitsAhead: number,
50
+ * pushed: boolean,
51
+ * prUrl: string|null,
52
+ * prChecks: 'pending'|'passing'|'failing'|null,
53
+ * prState: 'open'|'merged'|'closed'|null,
54
+ * }} state
55
+ * @returns {string} phase name
56
+ */
57
+ export function determinePhase({ thoughts, commitsAhead, pushed, prUrl, prChecks, prState }) {
58
+ // merged
59
+ if (prState === 'merged') return 'merged';
60
+
61
+ // prUrl exists
62
+ if (prUrl) {
63
+ if (prChecks === 'failing') return 'ci_failed';
64
+ if (prChecks === 'passing' && prState === 'open') return 'ready';
65
+ // prChecks is null or pending
66
+ return 'in_review';
67
+ }
68
+
69
+ // No PR yet
70
+ if (pushed) return 'pushing';
71
+ if (commitsAhead >= 1) return 'committing';
72
+ if (thoughts >= 30) return 'implementing';
73
+ return 'starting';
74
+ }
75
+
76
+ /**
77
+ * Build the safe/default response for a world.
78
+ *
79
+ * @param {string} worldId
80
+ * @returns {object}
81
+ */
82
+ export function makeSafeResponse(worldId) {
83
+ return {
84
+ worldId,
85
+ phase: 'starting',
86
+ phaseIndex: 1,
87
+ phaseTotal: PHASE_TOTAL,
88
+ isIdle: false,
89
+ thoughts: 0,
90
+ lastActivityAt: null,
91
+ runtimeMs: 0,
92
+ commitsAhead: 0,
93
+ pushed: false,
94
+ prUrl: null,
95
+ prNumber: null,
96
+ prChecks: null,
97
+ prState: null,
98
+ plan: null,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Read a world row from worlds.db.
104
+ *
105
+ * @param {string} dbPath
106
+ * @param {string} worldId
107
+ * @returns {{ branch: string, repos: string[], workspacePath: string, createdAt: string } | null}
108
+ */
109
+ function defaultReadWorldRow(dbPath, worldId) {
110
+ try {
111
+ const Database = createRequire(import.meta.url)('better-sqlite3');
112
+ const db = new Database(dbPath, { readonly: true });
113
+ db.pragma('journal_mode = WAL');
114
+ const row = db.prepare(
115
+ 'SELECT branch, repos, workspace_path, created_at FROM worlds WHERE id = ?',
116
+ ).get(worldId);
117
+ db.close();
118
+ if (!row) return null;
119
+ let repos = [];
120
+ try {
121
+ repos = typeof row.repos === 'string' ? JSON.parse(row.repos) : (row.repos ?? []);
122
+ } catch {
123
+ repos = [];
124
+ }
125
+ return {
126
+ branch: row.branch ?? 'main',
127
+ repos,
128
+ workspacePath: row.workspace_path ?? '',
129
+ createdAt: row.created_at ?? null,
130
+ };
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Read thought count and last activity from a world.db.
138
+ *
139
+ * @param {string} dbPath
140
+ * @returns {{ count: number, lastAt: string|null }}
141
+ */
142
+ function defaultReadThoughts(dbPath) {
143
+ try {
144
+ const Database = createRequire(import.meta.url)('better-sqlite3');
145
+ const db = new Database(dbPath, { readonly: true });
146
+ db.pragma('journal_mode = WAL');
147
+ const row = db
148
+ .prepare('SELECT COUNT(*) AS cnt, MAX(created_at) AS last_at FROM thought_nodes')
149
+ .get();
150
+ db.close();
151
+ return {
152
+ count: Number(row?.cnt ?? 0),
153
+ lastAt: row?.last_at ?? null,
154
+ };
155
+ } catch {
156
+ return { count: 0, lastAt: null };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Count commits ahead of origin/main for a git worktree.
162
+ *
163
+ * @param {string} worktreePath
164
+ * @returns {Promise<number>}
165
+ */
166
+ async function defaultGitCommitsAhead(worktreePath) {
167
+ try {
168
+ const { stdout } = await execFileAsync(
169
+ 'git',
170
+ ['-C', worktreePath, 'rev-list', 'origin/main..HEAD', '--count'],
171
+ { timeout: 5000 },
172
+ );
173
+ const n = parseInt(stdout.trim(), 10);
174
+ return Number.isFinite(n) ? n : 0;
175
+ } catch {
176
+ return 0;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Check whether the branch has been pushed to origin.
182
+ *
183
+ * @param {string} worktreePath
184
+ * @param {string} branch
185
+ * @returns {Promise<boolean>}
186
+ */
187
+ async function defaultGitIsPushed(worktreePath, branch) {
188
+ try {
189
+ await execFileAsync(
190
+ 'git',
191
+ ['-C', worktreePath, 'rev-parse', '--quiet', '--verify', `origin/${branch}`],
192
+ { timeout: 5000 },
193
+ );
194
+ return true;
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Compute the current progress state for a world.
202
+ *
203
+ * @param {string} worldId
204
+ * @param {{
205
+ * worldsDbPath?: string,
206
+ * prCache?: { getPr: (prUrl: string, getToken: () => Promise<string|null>) => Promise<{state:string|null,number:number|null,checks:string|null}|null> },
207
+ * prStateStore?: { get: (worldId: string) => object|null },
208
+ * getGhToken?: () => Promise<string|null>,
209
+ * _readWorldRow?: (dbPath: string, worldId: string) => object|null,
210
+ * _readThoughts?: (dbPath: string) => { count: number, lastAt: string|null },
211
+ * _gitCommitsAhead?: (worktreePath: string) => Promise<number>,
212
+ * _gitIsPushed?: (worktreePath: string, branch: string) => Promise<boolean>,
213
+ * }} [deps]
214
+ * @returns {Promise<object>}
215
+ */
216
+ export async function computeProgress(worldId, deps = {}) {
217
+ const safe = makeSafeResponse(worldId);
218
+
219
+ try {
220
+ const {
221
+ worldsDbPath = process.env.OLAM_WORLDS_DB ?? path.join(homedir(), '.olam/worlds.db'),
222
+ prCache = null,
223
+ prStateStore = null,
224
+ getGhToken = async () => null,
225
+ _readWorldRow = defaultReadWorldRow,
226
+ _readThoughts = defaultReadThoughts,
227
+ _gitCommitsAhead = defaultGitCommitsAhead,
228
+ _gitIsPushed = defaultGitIsPushed,
229
+ } = deps;
230
+
231
+ // Read world row
232
+ const worldRow = _readWorldRow(worldsDbPath, worldId);
233
+ if (!worldRow) return safe;
234
+
235
+ const { branch, repos, workspacePath, createdAt } = worldRow;
236
+ const worktreePath = repos.length > 0 ? path.join(workspacePath, repos[0]) : workspacePath;
237
+
238
+ // Compute runtimeMs
239
+ const runtimeMs = createdAt ? Date.now() - new Date(createdAt).getTime() : 0;
240
+
241
+ // Read thoughts
242
+ const thoughtsDbPath = getWorldDbPath(workspacePath);
243
+ const { count: thoughts, lastAt: thoughtsLastAt } = _readThoughts(thoughtsDbPath);
244
+
245
+ // Git state
246
+ const [commitsAhead, pushed] = await Promise.all([
247
+ _gitCommitsAhead(worktreePath),
248
+ _gitIsPushed(worktreePath, branch),
249
+ ]);
250
+
251
+ // PR state — check prStateStore first
252
+ let prUrl = null;
253
+ let prNumber = null;
254
+ let prState = null;
255
+ let prChecks = null;
256
+
257
+ if (prStateStore) {
258
+ const prEntry = prStateStore.get(worldId);
259
+ if (prEntry) {
260
+ prUrl = prEntry.pr_url ?? null;
261
+ prNumber = prEntry.pr_number ?? null;
262
+ // Normalize merged_destroyed → merged
263
+ const rawState = prEntry.pr_state ?? null;
264
+ prState = rawState === 'merged_destroyed' ? 'merged' : (rawState === 'none' ? null : rawState);
265
+ }
266
+ }
267
+
268
+ // Live PR data from cache
269
+ if (prUrl && prCache) {
270
+ try {
271
+ const livePr = await prCache.getPr(prUrl, getGhToken);
272
+ if (livePr) {
273
+ prChecks = livePr.checks;
274
+ // Update state if live data shows merged
275
+ if (livePr.state === 'merged') prState = 'merged';
276
+ if (livePr.number != null) prNumber = livePr.number;
277
+ }
278
+ } catch {
279
+ // Non-fatal
280
+ }
281
+ }
282
+
283
+ // Determine phase
284
+ const phase = determinePhase({ thoughts, commitsAhead, pushed, prUrl, prChecks, prState });
285
+ const phaseEntry = PHASES.find((p) => p.name === phase) ?? PHASES[0];
286
+
287
+ // Idle overlay — only for implementing or committing phases
288
+ let isIdle = false;
289
+ if (phase === 'implementing' || phase === 'committing') {
290
+ if (thoughtsLastAt) {
291
+ const lastActivityMs = new Date(thoughtsLastAt).getTime();
292
+ if (!isNaN(lastActivityMs) && Date.now() - lastActivityMs > IDLE_THRESHOLD_MS) {
293
+ isIdle = true;
294
+ }
295
+ }
296
+ }
297
+
298
+ // Plan progress — additive; null when no tracker found
299
+ const lastActivityAtMs = thoughtsLastAt ? new Date(thoughtsLastAt).getTime() : null;
300
+ const plan = readPlanProgress(worktreePath, branch, { lastActivityAtMs });
301
+
302
+ return {
303
+ worldId,
304
+ phase,
305
+ phaseIndex: phaseEntry.index,
306
+ phaseTotal: PHASE_TOTAL,
307
+ isIdle,
308
+ thoughts,
309
+ lastActivityAt: thoughtsLastAt ?? null,
310
+ runtimeMs: Math.max(0, runtimeMs),
311
+ commitsAhead,
312
+ pushed,
313
+ prUrl,
314
+ prNumber,
315
+ prChecks,
316
+ prState,
317
+ plan,
318
+ };
319
+ } catch {
320
+ return safe;
321
+ }
322
+ }
@@ -0,0 +1,288 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ // Deployment-mode values injected by server.mjs via configure().
7
+ // Defaults are bare-node-safe so the module is usable in tests without configure().
8
+ let HOST_FOR_WORLD = process.env.OLAM_HOST_FOR_WORLD ?? '127.0.0.1';
9
+ let TUNNELS_PATH =
10
+ process.env.OLAM_WORLD_TUNNELS_PATH ??
11
+ path.join(os.homedir(), '.olam', 'world-tunnels.json');
12
+
13
+ /**
14
+ * Called by server.mjs immediately after it resolves HOST_FOR_WORLD and
15
+ * WORLD_TUNNELS_PATH from the deployment-mode branch. Avoids re-deriving
16
+ * container-specific literals (host.docker.internal, /data/…) in this module.
17
+ * Re-runs loadState() when tunnelsPath differs from the env-var default so
18
+ * container-mode persistence is loaded from /data/ rather than ~/.olam/.
19
+ */
20
+ export function configure({ hostForWorld, tunnelsPath }) {
21
+ HOST_FOR_WORLD = hostForWorld;
22
+ if (tunnelsPath !== TUNNELS_PATH) {
23
+ TUNNELS_PATH = tunnelsPath;
24
+ loadState();
25
+ }
26
+ }
27
+
28
+ const TUNNEL_TIMEOUT_MS = 30_000;
29
+ const PROBE_TIMEOUT_MS = 3_000;
30
+ const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
31
+
32
+ export const STATUS = {
33
+ IDLE: 'idle',
34
+ STARTING: 'starting',
35
+ RUNNING: 'running',
36
+ ERROR: 'error',
37
+ STALE: 'stale',
38
+ };
39
+
40
+ export class AlreadyStartingError extends Error {
41
+ constructor(worldId, serviceName) {
42
+ super(`tunnel for ${serviceName} in world ${worldId} is already starting`);
43
+ this.name = 'AlreadyStartingError';
44
+ this.worldId = worldId;
45
+ this.serviceName = serviceName;
46
+ }
47
+ }
48
+
49
+ export class TunnelTimeoutError extends Error {
50
+ constructor(worldId, serviceName) {
51
+ super(`tunnel for ${serviceName} in world ${worldId} timed out after 30s with no URL`);
52
+ this.name = 'TunnelTimeoutError';
53
+ this.worldId = worldId;
54
+ this.serviceName = serviceName;
55
+ }
56
+ }
57
+
58
+ // Key: `${worldId}:${serviceName}` → {worldId, serviceName, port, status, url, process?}
59
+ const registry = new Map();
60
+
61
+ function tunnelKey(worldId, serviceName) {
62
+ return `${worldId}:${serviceName}`;
63
+ }
64
+
65
+ function loadState() {
66
+ try {
67
+ if (!fs.existsSync(TUNNELS_PATH)) return;
68
+ const raw = fs.readFileSync(TUNNELS_PATH, 'utf-8');
69
+ const data = JSON.parse(raw);
70
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return;
71
+ for (const [key, entry] of Object.entries(data)) {
72
+ registry.set(key, { ...entry, process: null });
73
+ }
74
+ } catch (err) {
75
+ console.error(`world-tunnel-manager: loadState failed: ${err.message}`);
76
+ }
77
+ }
78
+
79
+ function saveState() {
80
+ try {
81
+ const dir = path.dirname(TUNNELS_PATH);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ const data = {};
84
+ for (const [key, entry] of registry) {
85
+ // eslint-disable-next-line no-unused-vars
86
+ const { process: _proc, ...rest } = entry;
87
+ data[key] = rest;
88
+ }
89
+ const tmp = `${TUNNELS_PATH}.tmp-${process.pid}-${Date.now()}`;
90
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
91
+ fs.renameSync(tmp, TUNNELS_PATH);
92
+ } catch (err) {
93
+ console.error(`world-tunnel-manager: saveState failed: ${err.message}`);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Start a cloudflared quick-tunnel for a world service.
99
+ * Resolves with the assigned trycloudflare.com URL.
100
+ * Rejects with AlreadyStartingError if the service is already starting/running.
101
+ * Rejects with TunnelTimeoutError if no URL is emitted within 30s.
102
+ *
103
+ * @param {string} worldId
104
+ * @param {string} serviceName
105
+ * @param {number} port host-side port (i.e. the published port on this machine)
106
+ * @returns {Promise<string>} the public tunnel URL
107
+ */
108
+ export async function startTunnel(worldId, serviceName, port) {
109
+ const key = tunnelKey(worldId, serviceName);
110
+ const existing = registry.get(key);
111
+ if (existing && (existing.status === STATUS.STARTING || existing.status === STATUS.RUNNING)) {
112
+ throw new AlreadyStartingError(worldId, serviceName);
113
+ }
114
+
115
+ const entry = {
116
+ worldId,
117
+ serviceName,
118
+ port,
119
+ status: STATUS.STARTING,
120
+ url: null,
121
+ process: null,
122
+ };
123
+ registry.set(key, entry);
124
+ saveState();
125
+
126
+ const target = `http://${HOST_FOR_WORLD}:${port}`;
127
+ const child = spawn('cloudflared', ['tunnel', '--url', target], {
128
+ stdio: ['ignore', 'pipe', 'pipe'],
129
+ detached: false,
130
+ });
131
+ entry.process = child;
132
+
133
+ return new Promise((resolve, reject) => {
134
+ let settled = false;
135
+
136
+ function settle(resolvedUrl) {
137
+ if (settled) return;
138
+ settled = true;
139
+ clearTimeout(timer);
140
+
141
+ if (resolvedUrl) {
142
+ entry.status = STATUS.RUNNING;
143
+ entry.url = resolvedUrl;
144
+ saveState();
145
+ resolve(resolvedUrl);
146
+ } else {
147
+ entry.status = STATUS.ERROR;
148
+ entry.process = null;
149
+ saveState();
150
+ reject(new TunnelTimeoutError(worldId, serviceName));
151
+ }
152
+ }
153
+
154
+ const timer = setTimeout(() => settle(null), TUNNEL_TIMEOUT_MS);
155
+
156
+ function scanChunk(chunk) {
157
+ const lines = chunk.toString().split('\n');
158
+ for (const line of lines) {
159
+ const match = URL_PATTERN.exec(line);
160
+ if (match) { settle(match[0]); return; }
161
+ }
162
+ }
163
+
164
+ child.stdout.on('data', scanChunk);
165
+ child.stderr.on('data', scanChunk);
166
+
167
+ child.on('error', (err) => {
168
+ console.error(`world-tunnel-manager: cloudflared spawn error: ${err.message}`);
169
+ settle(null);
170
+ });
171
+
172
+ child.on('exit', (code) => {
173
+ if (!settled) {
174
+ console.error(`world-tunnel-manager: cloudflared exited (code ${code}) before URL`);
175
+ settle(null);
176
+ } else {
177
+ // Process died after URL was emitted (tunnel dropped)
178
+ entry.status = STATUS.ERROR;
179
+ entry.process = null;
180
+ saveState();
181
+ }
182
+ });
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Stop a tunnel for a specific service. No-op if the service has no tunnel.
188
+ * @param {string} worldId
189
+ * @param {string} serviceName
190
+ */
191
+ export function stopTunnel(worldId, serviceName) {
192
+ const key = tunnelKey(worldId, serviceName);
193
+ const entry = registry.get(key);
194
+ if (!entry) return;
195
+ if (entry.process) {
196
+ try { entry.process.kill('SIGTERM'); } catch { /* already dead */ }
197
+ entry.process = null;
198
+ }
199
+ entry.status = STATUS.IDLE;
200
+ entry.url = null;
201
+ saveState();
202
+ }
203
+
204
+ /**
205
+ * Return the current tunnel state for all services in a world.
206
+ * @param {string} worldId
207
+ * @returns {Array<{name: string, port: number, url: string|null, status: string}>}
208
+ */
209
+ export function getWorldTunnels(worldId) {
210
+ const result = [];
211
+ for (const entry of registry.values()) {
212
+ if (entry.worldId === worldId) {
213
+ result.push({
214
+ name: entry.serviceName,
215
+ port: entry.port,
216
+ url: entry.url,
217
+ status: entry.status,
218
+ });
219
+ }
220
+ }
221
+ return result;
222
+ }
223
+
224
+ /**
225
+ * Kill all tunnels for a world. Called when a world is destroyed.
226
+ * Idempotent — no-op if world has no tunnels.
227
+ * @param {string} worldId
228
+ */
229
+ export function killWorld(worldId) {
230
+ const toDelete = [];
231
+ for (const [key, entry] of registry) {
232
+ if (entry.worldId !== worldId) continue;
233
+ if (entry.process) {
234
+ try { entry.process.kill('SIGTERM'); } catch { /* already dead */ }
235
+ entry.process = null;
236
+ }
237
+ toDelete.push(key);
238
+ }
239
+ for (const key of toDelete) registry.delete(key);
240
+ if (toDelete.length > 0) saveState();
241
+ }
242
+
243
+ /**
244
+ * On startup, probe each persisted "running" tunnel. If the URL is unreachable,
245
+ * mark as stale so the UI can surface a Re-publish affordance.
246
+ */
247
+ export async function probeAllOnStartup() {
248
+ const toProbe = [];
249
+ for (const [key, entry] of registry) {
250
+ if (entry.status === STATUS.RUNNING && entry.url) {
251
+ toProbe.push({ key, url: entry.url });
252
+ }
253
+ }
254
+
255
+ await Promise.all(
256
+ toProbe.map(async ({ key, url }) => {
257
+ try {
258
+ const res = await fetch(url, {
259
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
260
+ });
261
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
262
+ } catch {
263
+ const entry = registry.get(key);
264
+ if (entry) {
265
+ entry.status = STATUS.STALE;
266
+ saveState();
267
+ }
268
+ }
269
+ }),
270
+ );
271
+ }
272
+
273
+ function killAll() {
274
+ for (const entry of registry.values()) {
275
+ if (entry.process) {
276
+ try { entry.process.kill('SIGTERM'); } catch { /* already dead */ }
277
+ entry.process = null;
278
+ }
279
+ }
280
+ }
281
+
282
+ process.on('SIGTERM', killAll);
283
+ process.on('exit', killAll);
284
+
285
+ // Initialise on module load using env-var or bare-node default path.
286
+ // configure() re-runs loadState() when server.mjs provides a different path
287
+ // (container mode: /data/world-tunnels.json vs the ~/.olam default above).
288
+ loadState();