@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,282 @@
1
+ /**
2
+ * Plan progress parser — reads phase-*-tasks.md trackers to derive
3
+ * phase/task state for the inbox progress bar.
4
+ *
5
+ * @module plan-progress
6
+ */
7
+
8
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const WORKING_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
12
+
13
+ /**
14
+ * Parse simple key:value pairs from a YAML frontmatter block (---…---).
15
+ * Handles single-line scalar values only — enough for feature/phase keys.
16
+ *
17
+ * @param {string} content
18
+ * @returns {Record<string, string>}
19
+ */
20
+ function parseFrontmatter(content) {
21
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
22
+ if (!match) return {};
23
+ const result = {};
24
+ for (const line of match[1].split('\n')) {
25
+ const m = line.match(/^([\w-]+):\s*(.+)$/);
26
+ if (m) result[m[1]] = m[2].trim();
27
+ }
28
+ return result;
29
+ }
30
+
31
+ /**
32
+ * Extract task definitions from "## Task list" section.
33
+ * Matches headings like:
34
+ * ### A0 — name
35
+ * ### B1 step 5 — multi-part name
36
+ *
37
+ * @param {string} content
38
+ * @returns {Array<{id: string, name: string}>}
39
+ */
40
+ function extractTaskDefs(content) {
41
+ const sectionMatch = content.match(/^## Task list\s*\n([\s\S]*)/m);
42
+ if (!sectionMatch) return [];
43
+
44
+ const taskSection = sectionMatch[1];
45
+ const tasks = [];
46
+ const re = /^###\s+([A-Z]\d+)\b([^\n]*)/gm;
47
+ let m;
48
+ while ((m = re.exec(taskSection)) !== null) {
49
+ const id = m[1];
50
+ const rest = m[2].trim();
51
+ // Strip leading em-dash, double-hyphen, or plain hyphen separator
52
+ const name = rest.replace(/^\s*[—\-]{1,2}\s*/, '').trim() || id;
53
+ tasks.push({ id, name });
54
+ }
55
+ return tasks;
56
+ }
57
+
58
+ /**
59
+ * Extract completed task IDs from the CP0 log comment block.
60
+ * Matches lines like: A0 (2026-05-05): ...
61
+ * A2 (2026-05-05, rebase): ...
62
+ *
63
+ * @param {string} content
64
+ * @returns {Set<string>}
65
+ */
66
+ function extractCp0Completed(content) {
67
+ const completed = new Set();
68
+ const logMatch = content.match(/<!--\s*CP0 log[\s\S]*?-->/);
69
+ if (!logMatch) return completed;
70
+
71
+ const re = /^([A-Z]\d+)\s*\(/gm;
72
+ let m;
73
+ while ((m = re.exec(logMatch[0])) !== null) {
74
+ completed.add(m[1]);
75
+ }
76
+ return completed;
77
+ }
78
+
79
+ /**
80
+ * Extract completed task IDs from an item-format Status table.
81
+ * Matches rows like: | A1 | Tool loader index | done |
82
+ *
83
+ * @param {string} content
84
+ * @returns {Set<string>}
85
+ */
86
+ function extractItemTableCompleted(content) {
87
+ const completed = new Set();
88
+ // No `m` flag — `$` must mean end-of-string so the lazy quantifier captures
89
+ // the whole table, not just the first line.
90
+ const statusMatch = content.match(/## Status\s*\n([\s\S]*?)(?=\n##\s|$)/);
91
+ if (!statusMatch) return completed;
92
+
93
+ const re = /^\|\s*([A-Z]\d+)\s*\|[^|]+\|\s*done\s*\|/gim;
94
+ let m;
95
+ while ((m = re.exec(statusMatch[1])) !== null) {
96
+ completed.add(m[1]);
97
+ }
98
+ return completed;
99
+ }
100
+
101
+ /**
102
+ * Extract the authoritative done count from a count-format Status table.
103
+ * Matches rows like: | done | 3 |
104
+ *
105
+ * @param {string} content
106
+ * @returns {number|null}
107
+ */
108
+ function extractDoneCount(content) {
109
+ const m = content.match(/\|\s*done\s*\|\s*(\d+)\s*\|/i);
110
+ return m ? parseInt(m[1], 10) : null;
111
+ }
112
+
113
+ /**
114
+ * Resolve the feature slug from a branch name or by scanning docs/plans/.
115
+ *
116
+ * Strategy:
117
+ * 1. Strip "feat/" prefix + optional "-phase-X" suffix from branch.
118
+ * 2. Exact match against plans subdirectory names.
119
+ * 3. Prefix match (branch slug starts with a plan dir name).
120
+ * 4. Fallback: most-recently-modified plans dir that has phase trackers.
121
+ *
122
+ * @param {string} repoPath - path to the git checkout
123
+ * @param {string|null} branch
124
+ * @returns {string|null}
125
+ */
126
+ function resolveFeatureSlug(repoPath, branch) {
127
+ const plansDir = path.join(repoPath, 'docs', 'plans');
128
+ let entries;
129
+ try {
130
+ entries = readdirSync(plansDir, { withFileTypes: true })
131
+ .filter((d) => d.isDirectory())
132
+ .map((d) => d.name);
133
+ } catch {
134
+ return null;
135
+ }
136
+
137
+ if (branch) {
138
+ // Strip feat/ prefix, any nested path, and trailing -phase-X suffix
139
+ const slug = branch
140
+ .replace(/^feat\//, '')
141
+ .replace(/\/.*$/, '')
142
+ .replace(/-phase-[a-z]$/, '');
143
+
144
+ // Exact match
145
+ if (entries.includes(slug)) return slug;
146
+
147
+ // Prefix match (slug starts with a plan dir name)
148
+ const prefixMatch = entries.find((d) => slug.startsWith(d));
149
+ if (prefixMatch) return prefixMatch;
150
+
151
+ // Branch provided but no name match — don't guess
152
+ return null;
153
+ }
154
+
155
+ // No branch: fallback to most-recently-modified dir with phase tracker files
156
+ let newest = null;
157
+ let newestMtime = 0;
158
+ for (const dir of entries) {
159
+ const dirPath = path.join(plansDir, dir);
160
+ try {
161
+ const files = readdirSync(dirPath);
162
+ if (!files.some((f) => /^phase-[a-z]-tasks\.md$/.test(f))) continue;
163
+ const mtime = statSync(dirPath).mtimeMs;
164
+ if (mtime > newestMtime) {
165
+ newestMtime = mtime;
166
+ newest = dir;
167
+ }
168
+ } catch {
169
+ // skip unreadable entries
170
+ }
171
+ }
172
+
173
+ return newest;
174
+ }
175
+
176
+ /**
177
+ * Parse a single phase tracker file into phase/task state.
178
+ *
179
+ * @param {string} filePath
180
+ * @param {boolean} isRecentlyActive - whether the world had recent activity
181
+ * @param {{ workingMarked: boolean }} state - mutable flag shared across phases
182
+ * @returns {{ id: string, name: string, status: string, tasks: Array }|null}
183
+ */
184
+ function parseTrackerFile(filePath, isRecentlyActive, state) {
185
+ let content;
186
+ try {
187
+ content = readFileSync(filePath, 'utf8');
188
+ } catch {
189
+ return null;
190
+ }
191
+
192
+ const fm = parseFrontmatter(content);
193
+
194
+ // Phase ID: frontmatter "phase" field or filename "phase-X-tasks.md"
195
+ const phaseId =
196
+ fm.phase ||
197
+ path.basename(filePath).match(/^phase-([a-z])-tasks\.md$/)?.[1] ||
198
+ '?';
199
+
200
+ const phaseName = `Phase ${phaseId.toUpperCase()}`;
201
+
202
+ const taskDefs = extractTaskDefs(content);
203
+ if (taskDefs.length === 0) return null;
204
+
205
+ // Collect completions from all sources
206
+ const cp0Completed = extractCp0Completed(content);
207
+ const itemTableCompleted = extractItemTableCompleted(content);
208
+ const doneCount = extractDoneCount(content);
209
+
210
+ // Merge CP0 log + item-table; count-format overrides if present
211
+ const mergedCompleted = new Set([...cp0Completed, ...itemTableCompleted]);
212
+
213
+ const tasks = taskDefs.map((t, i) => {
214
+ const isComplete =
215
+ doneCount !== null
216
+ ? i < doneCount // count format is authoritative
217
+ : mergedCompleted.has(t.id);
218
+
219
+ if (isComplete) return { id: t.id, name: t.name, status: 'complete' };
220
+
221
+ // First pending task across all phases = candidate for "working"
222
+ if (!state.workingMarked) {
223
+ state.workingMarked = true;
224
+ return {
225
+ id: t.id,
226
+ name: t.name,
227
+ status: isRecentlyActive ? 'working' : 'pending',
228
+ };
229
+ }
230
+
231
+ return { id: t.id, name: t.name, status: 'pending' };
232
+ });
233
+
234
+ const allComplete = tasks.every((t) => t.status === 'complete');
235
+ const anyWorking = tasks.some((t) => t.status === 'working');
236
+ const phaseStatus = allComplete ? 'complete' : anyWorking ? 'working' : 'pending';
237
+
238
+ return { id: phaseId, name: phaseName, status: phaseStatus, tasks };
239
+ }
240
+
241
+ /**
242
+ * Read plan progress from a world's git checkout.
243
+ *
244
+ * @param {string} repoPath - absolute path to the git checkout
245
+ * @param {string|null} branch - current branch name (e.g. "feat/foo-phase-a")
246
+ * @param {{ lastActivityAtMs?: number|null }} [opts]
247
+ * @returns {{ feature: string, phases: Array }|null}
248
+ * null when no plan tracker is found (caller falls back to legacy bar)
249
+ */
250
+ export function readPlanProgress(repoPath, branch, { lastActivityAtMs = null } = {}) {
251
+ const feature = resolveFeatureSlug(repoPath, branch);
252
+ if (!feature) return null;
253
+
254
+ const plansDir = path.join(repoPath, 'docs', 'plans', feature);
255
+ let phaseFiles;
256
+ try {
257
+ phaseFiles = readdirSync(plansDir)
258
+ .filter((f) => /^phase-[a-z]-tasks\.md$/.test(f))
259
+ .sort();
260
+ } catch {
261
+ return null;
262
+ }
263
+
264
+ if (phaseFiles.length === 0) return null;
265
+
266
+ const isRecentlyActive =
267
+ lastActivityAtMs != null
268
+ ? Date.now() - lastActivityAtMs <= WORKING_THRESHOLD_MS
269
+ : false;
270
+
271
+ const state = { workingMarked: false };
272
+
273
+ const phases = phaseFiles
274
+ .map((file) =>
275
+ parseTrackerFile(path.join(plansDir, file), isRecentlyActive, state),
276
+ )
277
+ .filter(Boolean);
278
+
279
+ if (phases.length === 0) return null;
280
+
281
+ return { feature, phases };
282
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * In-memory cache for GitHub PR data with TTL and concurrent-fetch coalescing.
3
+ *
4
+ * @module pr-cache
5
+ */
6
+
7
+ const GH_API_BASE = 'https://api.github.com';
8
+ const TTL_MS = 30_000;
9
+
10
+ /**
11
+ * Parse owner, repo, and PR number from a GitHub PR URL.
12
+ *
13
+ * @param {string} prUrl e.g. https://github.com/owner/repo/pull/123
14
+ * @returns {{ owner: string, repo: string, number: number } | null}
15
+ */
16
+ function parsePrUrl(prUrl) {
17
+ const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
18
+ if (!m) return null;
19
+ return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
20
+ }
21
+
22
+ /**
23
+ * Reduce an array of check runs into a single status string.
24
+ *
25
+ * @param {Array<{conclusion: string|null, status: string}>} checkRuns
26
+ * @returns {'pending'|'passing'|'failing'|null}
27
+ */
28
+ function reduceCheckRuns(checkRuns) {
29
+ if (!checkRuns || checkRuns.length === 0) return null;
30
+
31
+ let hasFailure = false;
32
+ let hasPending = false;
33
+
34
+ for (const run of checkRuns) {
35
+ const conclusion = run.conclusion;
36
+ const status = run.status;
37
+
38
+ if (
39
+ conclusion === 'failure' ||
40
+ conclusion === 'timed_out' ||
41
+ conclusion === 'action_required'
42
+ ) {
43
+ hasFailure = true;
44
+ } else if (
45
+ status === 'queued' ||
46
+ status === 'in_progress' ||
47
+ conclusion === null
48
+ ) {
49
+ hasPending = true;
50
+ }
51
+ }
52
+
53
+ if (hasFailure) return 'failing';
54
+ if (hasPending) return 'pending';
55
+ return 'passing';
56
+ }
57
+
58
+ /**
59
+ * @typedef {object} PrCacheEntry
60
+ * @property {number} fetchedAt
61
+ * @property {'open'|'merged'|'closed'|null} prState
62
+ * @property {number|null} prNumber
63
+ * @property {'pending'|'passing'|'failing'|null} prChecks
64
+ * @property {Promise<PrData>|null} promise
65
+ */
66
+
67
+ /**
68
+ * @typedef {object} PrData
69
+ * @property {'open'|'merged'|'closed'|null} state
70
+ * @property {number|null} number
71
+ * @property {'pending'|'passing'|'failing'|null} checks
72
+ */
73
+
74
+ /**
75
+ * Fetch PR data from GitHub API.
76
+ *
77
+ * @param {string} prUrl
78
+ * @param {() => Promise<string|null>} getToken
79
+ * @returns {Promise<PrData>}
80
+ */
81
+ async function fetchPrData(prUrl, getToken) {
82
+ const parsed = parsePrUrl(prUrl);
83
+ if (!parsed) return { state: null, number: null, checks: null };
84
+
85
+ const token = await getToken();
86
+ /** @type {HeadersInit} */
87
+ const headers = { Accept: 'application/vnd.github+json' };
88
+ if (token) headers['Authorization'] = `token ${token}`;
89
+
90
+ // Fetch PR metadata
91
+ const prResp = await fetch(
92
+ `${GH_API_BASE}/repos/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}`,
93
+ { headers, signal: AbortSignal.timeout(10_000) },
94
+ );
95
+ if (!prResp.ok) {
96
+ return { state: null, number: parsed.number, checks: null };
97
+ }
98
+ const prData = await prResp.json();
99
+
100
+ let state = prData.state ?? null;
101
+ if (state === 'closed' && prData.merged_at) state = 'merged';
102
+
103
+ const sha = prData.head?.sha ?? null;
104
+ if (!sha) {
105
+ return { state, number: parsed.number, checks: null };
106
+ }
107
+
108
+ // Fetch check runs for the head SHA
109
+ let checks = null;
110
+ try {
111
+ const checksResp = await fetch(
112
+ `${GH_API_BASE}/repos/${parsed.owner}/${parsed.repo}/commits/${sha}/check-runs`,
113
+ { headers, signal: AbortSignal.timeout(10_000) },
114
+ );
115
+ if (checksResp.ok) {
116
+ const checksData = await checksResp.json();
117
+ const runs = Array.isArray(checksData.check_runs) ? checksData.check_runs : [];
118
+ checks = reduceCheckRuns(runs);
119
+ }
120
+ } catch {
121
+ // Non-fatal — return null checks
122
+ }
123
+
124
+ return { state, number: parsed.number, checks };
125
+ }
126
+
127
+ /**
128
+ * Create a PR data cache with TTL and concurrent-fetch coalescing.
129
+ *
130
+ * @returns {{ getPr: (prUrl: string, getToken: () => Promise<string|null>) => Promise<PrData|null> }}
131
+ */
132
+ export function createPrCache() {
133
+ /** @type {Map<string, PrCacheEntry>} */
134
+ const cache = new Map();
135
+
136
+ /**
137
+ * Get PR data for a URL, using cache if fresh or coalescing concurrent fetches.
138
+ *
139
+ * @param {string} prUrl
140
+ * @param {() => Promise<string|null>} getToken
141
+ * @returns {Promise<PrData|null>}
142
+ */
143
+ async function getPr(prUrl, getToken) {
144
+ if (!prUrl) return null;
145
+
146
+ const now = Date.now();
147
+ const entry = cache.get(prUrl);
148
+
149
+ // Fresh cache hit
150
+ if (entry && !entry.promise && now - entry.fetchedAt < TTL_MS) {
151
+ return { state: entry.prState, number: entry.prNumber, checks: entry.prChecks };
152
+ }
153
+
154
+ // In-flight fetch — coalesce
155
+ if (entry && entry.promise) {
156
+ try {
157
+ return await entry.promise;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ // Stale or missing — start new fetch
164
+ const promise = fetchPrData(prUrl, getToken).then(
165
+ (data) => {
166
+ cache.set(prUrl, {
167
+ fetchedAt: Date.now(),
168
+ prState: data.state,
169
+ prNumber: data.number,
170
+ prChecks: data.checks,
171
+ promise: null,
172
+ });
173
+ return data;
174
+ },
175
+ (err) => {
176
+ // Clear promise on error so next call retries
177
+ const current = cache.get(prUrl);
178
+ if (current && current.promise) {
179
+ cache.set(prUrl, { ...current, promise: null });
180
+ }
181
+ throw err;
182
+ },
183
+ );
184
+
185
+ cache.set(prUrl, {
186
+ fetchedAt: entry ? entry.fetchedAt : 0,
187
+ prState: entry ? entry.prState : null,
188
+ prNumber: entry ? entry.prNumber : null,
189
+ prChecks: entry ? entry.prChecks : null,
190
+ promise,
191
+ });
192
+
193
+ try {
194
+ return await promise;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ return { getPr };
201
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * PR merge poller for auto-destroying worlds whose PR has merged.
3
+ *
4
+ * State machine per world:
5
+ * open -> merged (on GitHub reports merged)
6
+ * merged -> merged_destroyed (after grace period, if auto_destroy_on_merge)
7
+ */
8
+
9
+ const GH_API_BASE = 'https://api.github.com';
10
+
11
+ /**
12
+ * Parse owner, repo, and PR number from a GitHub PR URL.
13
+ * @param {string} prUrl e.g. https://github.com/org/repo/pull/123
14
+ * @returns {{ owner: string, repo: string, number: number } | null}
15
+ */
16
+ function parsePrUrl(prUrl) {
17
+ const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
18
+ if (!m) return null;
19
+ return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
20
+ }
21
+
22
+ /**
23
+ * @param {{
24
+ * prStateStore: import('./world-pr-state.mjs').ReturnType<typeof createWorldPrStateStore>,
25
+ * getGhToken: () => Promise<string|null>,
26
+ * destroyWorld: (worldId: string) => Promise<void>,
27
+ * pollIntervalMs?: number,
28
+ * gracePeriodMs?: number,
29
+ * }} opts
30
+ */
31
+ export function createPrMergePoller({
32
+ prStateStore,
33
+ getGhToken,
34
+ destroyWorld,
35
+ pollIntervalMs = 300_000,
36
+ gracePeriodMs = 600_000,
37
+ }) {
38
+ let intervalId = null;
39
+ let disabled = false;
40
+ let warnedOnce = false;
41
+ // Track in-flight grace timers so stop() can clear them
42
+ const graceTimers = new Map();
43
+
44
+ async function destroyAndMark(worldId) {
45
+ const entry = prStateStore.get(worldId);
46
+ const prUrl = entry?.pr_url ?? '(unknown)';
47
+ const mergedAt = entry?.pr_merged_at ?? '(unknown)';
48
+ console.log(
49
+ `[pr-merge-poller] auto-destroyed world ${worldId}: PR ${prUrl} merged at ${mergedAt}, destroyed at ${new Date().toISOString()}`,
50
+ );
51
+ try {
52
+ await destroyWorld(worldId);
53
+ } catch (err) {
54
+ console.error(`[pr-merge-poller] destroyWorld failed for ${worldId}:`, err.message);
55
+ }
56
+ prStateStore.set(worldId, { pr_state: 'merged_destroyed' });
57
+ graceTimers.delete(worldId);
58
+ }
59
+
60
+ function scheduleGrace(worldId, entry) {
61
+ if (graceTimers.has(worldId)) return; // already scheduled
62
+ const id = setTimeout(() => {
63
+ destroyAndMark(worldId).catch((err) => {
64
+ console.error(`[pr-merge-poller] destroyAndMark error for ${worldId}:`, err.message);
65
+ });
66
+ }, gracePeriodMs);
67
+ graceTimers.set(worldId, id);
68
+ }
69
+
70
+ async function checkPr(worldId, entry, ghToken) {
71
+ const parsed = parsePrUrl(entry.pr_url);
72
+ if (!parsed) {
73
+ console.warn(`[pr-merge-poller] cannot parse PR URL for ${worldId}: ${entry.pr_url}`);
74
+ return;
75
+ }
76
+ const apiUrl = `${GH_API_BASE}/repos/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}`;
77
+ let data;
78
+ try {
79
+ const resp = await fetch(apiUrl, {
80
+ headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' },
81
+ });
82
+ if (!resp.ok) {
83
+ console.warn(`[pr-merge-poller] GH API ${resp.status} for ${worldId}`);
84
+ return;
85
+ }
86
+ data = await resp.json();
87
+ } catch (err) {
88
+ console.warn(`[pr-merge-poller] fetch failed for ${worldId}:`, err.message);
89
+ return;
90
+ }
91
+
92
+ const isMerged = data.state === 'closed' && data.merged_at != null;
93
+ if (!isMerged) return;
94
+
95
+ prStateStore.set(worldId, {
96
+ pr_state: 'merged',
97
+ pr_merged_at: data.merged_at,
98
+ });
99
+
100
+ if (entry.auto_destroy_on_merge === false) return;
101
+
102
+ scheduleGrace(worldId, prStateStore.get(worldId));
103
+ }
104
+
105
+ async function pollOnce() {
106
+ const ghToken = await getGhToken();
107
+ if (!ghToken) {
108
+ if (!warnedOnce) {
109
+ console.warn(
110
+ 'pr-merge-poller: no GH token found (GH_TOKEN/GITHUB_TOKEN env or /gh-config/hosts.yml); PR polling disabled',
111
+ );
112
+ warnedOnce = true;
113
+ }
114
+ disabled = true;
115
+ stop();
116
+ return;
117
+ }
118
+
119
+ const worlds = prStateStore.getWorldsToWatch();
120
+ for (const entry of worlds) {
121
+ const { worldId, ...rest } = entry;
122
+ if (rest.pr_state === 'open') {
123
+ await checkPr(worldId, rest, ghToken);
124
+ } else if (rest.pr_state === 'merged') {
125
+ // Resume grace timer for merged entries that survived a restart
126
+ if (rest.auto_destroy_on_merge !== false) {
127
+ scheduleGrace(worldId, rest);
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ function start() {
134
+ if (intervalId !== null || disabled) return;
135
+ intervalId = setInterval(() => {
136
+ pollOnce().catch((err) => {
137
+ console.error('[pr-merge-poller] pollOnce error:', err.message);
138
+ });
139
+ }, pollIntervalMs);
140
+ }
141
+
142
+ function stop() {
143
+ if (intervalId !== null) {
144
+ clearInterval(intervalId);
145
+ intervalId = null;
146
+ }
147
+ for (const id of graceTimers.values()) {
148
+ clearTimeout(id);
149
+ }
150
+ graceTimers.clear();
151
+ }
152
+
153
+ return { start, stop };
154
+ }