@pleri/olam-cli 0.1.65 → 0.1.66
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/image-digests.json +3 -3
- package/host-cp/src/pr-nanny.mjs +284 -0
- package/host-cp/src/server.mjs +67 -0
- package/package.json +1 -1
package/dist/image-digests.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"auth": "sha256:1e6d3574077ba70ba908b5ae343e9ff669937468ff1f48e3e9a449ac38578cc6",
|
|
3
|
-
"devbox": "sha256:
|
|
4
|
-
"host-cp": "sha256:
|
|
3
|
+
"devbox": "sha256:51d091385dfea24529a97f0949780b92a3f2fad4011a3b32106645046de3213e",
|
|
4
|
+
"host-cp": "sha256:b96191c00ca6f1ef5c12990a5802f8737a44500c0d28bcea5182052e28cf0838",
|
|
5
5
|
"mcp-auth": "sha256:cccf9fc022a3b58080007761ff10e4820080d26491e7b6e758e3e766c9eac896",
|
|
6
6
|
"$schema_version": 1,
|
|
7
|
-
"$published_version": "0.1.
|
|
7
|
+
"$published_version": "0.1.66",
|
|
8
8
|
"$registry": "ghcr.io/pleri"
|
|
9
9
|
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR Nanny — host-side daemon that watches all worlds' open PRs and
|
|
3
|
+
* dispatches fixes via `olam dispatch` when CI/reviews block them.
|
|
4
|
+
*
|
|
5
|
+
* Extends the pr-merge-poller loop pattern. Runs at 60s cadence.
|
|
6
|
+
* Opt-out: OLAM_PR_NANNY=0 (default: enabled).
|
|
7
|
+
*
|
|
8
|
+
* State machine per PR (stored in world-pr-state.json nanny_* fields):
|
|
9
|
+
* watching → dispatching → (paused | escalated | halted)
|
|
10
|
+
*
|
|
11
|
+
* Halt conditions (stop dispatching but keep watching):
|
|
12
|
+
* 1. dispatch_count >= MAX_DISPATCHES (configurable, default 5)
|
|
13
|
+
* 2. wall-clock since first dispatch >= MAX_WALL_CLOCK_MIN (default 60)
|
|
14
|
+
* 3. same-root-cause loop detected (last 2 dispatch summaries identical)
|
|
15
|
+
* 4. operator manual pause
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
const GH_API_BASE = 'https://api.github.com';
|
|
24
|
+
|
|
25
|
+
// Known external-blocker CI check name patterns.
|
|
26
|
+
// When ALL failing checks match these patterns, the PR is not actionable
|
|
27
|
+
// (the root cause is infrastructure/release-pipeline, not the world's code).
|
|
28
|
+
const EXTERNAL_BLOCKER_PATTERNS = [
|
|
29
|
+
/detect-image-scopes/i,
|
|
30
|
+
/publish-mcp-auth/i,
|
|
31
|
+
/retag-mcp-auth/i,
|
|
32
|
+
/bootstrap.*publish/i,
|
|
33
|
+
/release.*pipeline/i,
|
|
34
|
+
/ghcr.*push/i,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} checkName
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function isExternalBlockerCheck(checkName) {
|
|
42
|
+
return EXTERNAL_BLOCKER_PATTERNS.some((re) => re.test(checkName));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns true when ALL failing CI checks are external-blocker patterns.
|
|
47
|
+
* @param {Array<{name: string, conclusion: string|null}>} checks
|
|
48
|
+
*/
|
|
49
|
+
export function isExternalBlocker(checks) {
|
|
50
|
+
const failing = checks.filter(
|
|
51
|
+
(c) => c.conclusion === 'failure' || c.conclusion === 'action_required',
|
|
52
|
+
);
|
|
53
|
+
if (failing.length === 0) return false;
|
|
54
|
+
return failing.every((c) => isExternalBlockerCheck(c.name));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} prUrl e.g. https://github.com/org/repo/pull/123
|
|
59
|
+
* @returns {{ owner: string, repo: string, number: number } | null}
|
|
60
|
+
*/
|
|
61
|
+
function parsePrUrl(prUrl) {
|
|
62
|
+
const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{
|
|
69
|
+
* prStateStore: ReturnType<import('./world-pr-state.mjs').createWorldPrStateStore>,
|
|
70
|
+
* getGhToken: () => Promise<string|null>,
|
|
71
|
+
* dispatchToWorld: (worldId: string, prompt: string) => Promise<void>,
|
|
72
|
+
* consultCodex: (ctx: string) => Promise<string>,
|
|
73
|
+
* pollIntervalMs?: number,
|
|
74
|
+
* maxDispatches?: number,
|
|
75
|
+
* maxWallClockMin?: number,
|
|
76
|
+
* }} opts
|
|
77
|
+
*/
|
|
78
|
+
export function createPrNanny({
|
|
79
|
+
prStateStore,
|
|
80
|
+
getGhToken,
|
|
81
|
+
dispatchToWorld,
|
|
82
|
+
consultCodex,
|
|
83
|
+
pollIntervalMs = 60_000,
|
|
84
|
+
maxDispatches = parseInt(process.env.OLAM_PR_NANNY_MAX_DISPATCHES ?? '5', 10),
|
|
85
|
+
maxWallClockMin = parseInt(process.env.OLAM_PR_NANNY_MAX_WALL_CLOCK_MIN ?? '60', 10),
|
|
86
|
+
}) {
|
|
87
|
+
const enabled = (process.env.OLAM_PR_NANNY ?? '1') !== '0';
|
|
88
|
+
if (!enabled) return { start() {}, stop() {} };
|
|
89
|
+
|
|
90
|
+
let intervalId = null;
|
|
91
|
+
let warnedOnce = false;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fetch CI check runs for the PR's head SHA.
|
|
95
|
+
* @param {string} owner @param {string} repo @param {number} prNumber @param {string} ghToken
|
|
96
|
+
* @returns {Promise<Array<{name: string, conclusion: string|null}>>}
|
|
97
|
+
*/
|
|
98
|
+
async function fetchChecks(owner, repo, prNumber, ghToken) {
|
|
99
|
+
try {
|
|
100
|
+
// First get the PR head SHA
|
|
101
|
+
const prRes = await fetch(
|
|
102
|
+
`${GH_API_BASE}/repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
103
|
+
{ headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' } },
|
|
104
|
+
);
|
|
105
|
+
if (!prRes.ok) return [];
|
|
106
|
+
const prData = await prRes.json();
|
|
107
|
+
const sha = prData.head?.sha;
|
|
108
|
+
if (!sha) return [];
|
|
109
|
+
|
|
110
|
+
const checkRes = await fetch(
|
|
111
|
+
`${GH_API_BASE}/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100`,
|
|
112
|
+
{ headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' } },
|
|
113
|
+
);
|
|
114
|
+
if (!checkRes.ok) return [];
|
|
115
|
+
const checkData = await checkRes.json();
|
|
116
|
+
return (checkData.check_runs ?? []).map((r) => ({
|
|
117
|
+
name: r.name,
|
|
118
|
+
conclusion: r.conclusion,
|
|
119
|
+
status: r.status,
|
|
120
|
+
}));
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} worldId
|
|
128
|
+
* @param {object} entry current pr-state entry
|
|
129
|
+
* @param {string} ghToken
|
|
130
|
+
*/
|
|
131
|
+
async function processWorld(worldId, entry, ghToken) {
|
|
132
|
+
if (entry.nanny_paused || entry.nanny_escalated) return;
|
|
133
|
+
if (entry.pr_state !== 'open') return;
|
|
134
|
+
|
|
135
|
+
const parsed = parsePrUrl(entry.pr_url);
|
|
136
|
+
if (!parsed) return;
|
|
137
|
+
|
|
138
|
+
// Halt: dispatch cap
|
|
139
|
+
const dispatchCount = entry.nanny_dispatch_count ?? 0;
|
|
140
|
+
if (dispatchCount >= maxDispatches) return;
|
|
141
|
+
|
|
142
|
+
// Halt: wall-clock ceiling
|
|
143
|
+
if (entry.nanny_first_dispatch_at) {
|
|
144
|
+
const elapsedMin = (Date.now() - new Date(entry.nanny_first_dispatch_at).getTime()) / 60_000;
|
|
145
|
+
if (elapsedMin >= maxWallClockMin) return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const checks = await fetchChecks(parsed.owner, parsed.repo, parsed.number, ghToken);
|
|
149
|
+
const hasCiFailure = checks.some(
|
|
150
|
+
(c) => c.conclusion === 'failure' || c.conclusion === 'action_required',
|
|
151
|
+
);
|
|
152
|
+
const allPassing = checks.length > 0 && checks.every(
|
|
153
|
+
(c) => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral',
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (allPassing || checks.length === 0) return;
|
|
157
|
+
if (!hasCiFailure) return;
|
|
158
|
+
|
|
159
|
+
// External blocker — do not dispatch
|
|
160
|
+
if (isExternalBlocker(checks)) {
|
|
161
|
+
prStateStore.set(worldId, { nanny_external_blocker: true });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
prStateStore.set(worldId, { nanny_external_blocker: false });
|
|
166
|
+
|
|
167
|
+
const failingNames = checks
|
|
168
|
+
.filter((c) => c.conclusion === 'failure' || c.conclusion === 'action_required')
|
|
169
|
+
.map((c) => c.name)
|
|
170
|
+
.join(', ');
|
|
171
|
+
|
|
172
|
+
const prompt = `CI is failing on PR ${entry.pr_url}. Failing checks: ${failingNames}. Investigate the root cause, fix the code, and push.`;
|
|
173
|
+
|
|
174
|
+
// Halt: same-root-cause loop detection
|
|
175
|
+
if (entry.nanny_last_dispatch_prompt && entry.nanny_last_dispatch_prompt === prompt) {
|
|
176
|
+
console.log(`[pr-nanny] loop detected for ${worldId} — same prompt as last dispatch, halting`);
|
|
177
|
+
prStateStore.set(worldId, { nanny_loop_halted: true });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Consult Codex before dispatching
|
|
182
|
+
const codexCtx = `World ${worldId} has a failing PR: ${entry.pr_url}. Failing CI checks: ${failingNames}. Should we dispatch a fix? Answer: agree, push-back, or rethink.`;
|
|
183
|
+
let verdict = 'agree';
|
|
184
|
+
try {
|
|
185
|
+
verdict = await consultCodex(codexCtx);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.warn(`[pr-nanny] codex consult failed for ${worldId}: ${err.message} — defaulting to agree`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (verdict === 'push-back') {
|
|
191
|
+
prStateStore.set(worldId, { nanny_paused: true, nanny_pause_reason: 'codex_pushback' });
|
|
192
|
+
console.log(`[pr-nanny] Codex push-back for ${worldId} — pausing nanny`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (verdict === 'rethink') {
|
|
196
|
+
prStateStore.set(worldId, { nanny_escalated: true, nanny_escalate_reason: 'codex_rethink' });
|
|
197
|
+
console.log(`[pr-nanny] Codex rethink for ${worldId} — escalating`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Dispatch fix
|
|
202
|
+
try {
|
|
203
|
+
await dispatchToWorld(worldId, prompt);
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
prStateStore.set(worldId, {
|
|
206
|
+
nanny_dispatch_count: dispatchCount + 1,
|
|
207
|
+
nanny_first_dispatch_at: entry.nanny_first_dispatch_at ?? now,
|
|
208
|
+
nanny_last_dispatch_at: now,
|
|
209
|
+
nanny_last_dispatch_prompt: prompt,
|
|
210
|
+
});
|
|
211
|
+
console.log(`[pr-nanny] dispatched fix to ${worldId} (dispatch ${dispatchCount + 1}/${maxDispatches})`);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error(`[pr-nanny] dispatch failed for ${worldId}: ${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function pollOnce() {
|
|
218
|
+
const ghToken = await getGhToken();
|
|
219
|
+
if (!ghToken) {
|
|
220
|
+
if (!warnedOnce) {
|
|
221
|
+
console.warn('[pr-nanny] no GH token — CI polling disabled');
|
|
222
|
+
warnedOnce = true;
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const worlds = prStateStore.getWorldsToWatch();
|
|
228
|
+
for (const { worldId, ...entry } of worlds) {
|
|
229
|
+
try {
|
|
230
|
+
await processWorld(worldId, entry, ghToken);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(`[pr-nanny] processWorld error for ${worldId}: ${err.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function start() {
|
|
238
|
+
if (intervalId !== null) return;
|
|
239
|
+
// Immediate first poll
|
|
240
|
+
pollOnce().catch((err) => console.error('[pr-nanny] pollOnce error:', err.message));
|
|
241
|
+
intervalId = setInterval(() => {
|
|
242
|
+
pollOnce().catch((err) => console.error('[pr-nanny] pollOnce error:', err.message));
|
|
243
|
+
}, pollIntervalMs);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function stop() {
|
|
247
|
+
if (intervalId !== null) {
|
|
248
|
+
clearInterval(intervalId);
|
|
249
|
+
intervalId = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { start, stop };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Default Codex consultation via the host-side `codex` CLI.
|
|
258
|
+
* @param {string} ctx
|
|
259
|
+
* @returns {Promise<'agree'|'push-back'|'rethink'>}
|
|
260
|
+
*/
|
|
261
|
+
export async function defaultConsultCodex(ctx) {
|
|
262
|
+
try {
|
|
263
|
+
const { stdout } = await execFileAsync('codex', [
|
|
264
|
+
'--quiet',
|
|
265
|
+
'--model', 'codex-mini-latest',
|
|
266
|
+
`Adversarial review — is this a good idea? ${ctx} Reply with exactly one word: agree, push-back, or rethink.`,
|
|
267
|
+
], { timeout: 30_000 });
|
|
268
|
+
const text = stdout.trim().toLowerCase();
|
|
269
|
+
if (text.startsWith('push')) return 'push-back';
|
|
270
|
+
if (text.startsWith('rethink')) return 'rethink';
|
|
271
|
+
return 'agree';
|
|
272
|
+
} catch {
|
|
273
|
+
return 'agree'; // fail-open: if codex unavailable, dispatch anyway
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Default dispatch: shell out to `olam dispatch <worldId> <prompt>`.
|
|
279
|
+
* @param {string} worldId
|
|
280
|
+
* @param {string} prompt
|
|
281
|
+
*/
|
|
282
|
+
export async function defaultDispatchToWorld(worldId, prompt) {
|
|
283
|
+
await execFileAsync('olam', ['dispatch', worldId, prompt], { timeout: 60_000 });
|
|
284
|
+
}
|
package/host-cp/src/server.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { composeWorldsSources } from './compose-worlds-sources.mjs';
|
|
|
55
55
|
import { createWorldPrStateStore } from './world-pr-state.mjs';
|
|
56
56
|
import { PlanOrchestrator } from './plan-orchestrator.mjs';
|
|
57
57
|
import { createPrMergePoller } from './pr-merge-poller.mjs';
|
|
58
|
+
import { createPrNanny, defaultConsultCodex, defaultDispatchToWorld } from './pr-nanny.mjs';
|
|
58
59
|
import { parse as parseYaml } from 'yaml';
|
|
59
60
|
import { startWorldsDbReconciler } from './worlds-db-source.mjs';
|
|
60
61
|
import { authSecretHint } from './auth-secret-hint.mjs';
|
|
@@ -346,6 +347,17 @@ const prPoller = createPrMergePoller({
|
|
|
346
347
|
});
|
|
347
348
|
prPoller.start();
|
|
348
349
|
|
|
350
|
+
// ── PR Nanny — watch all open PRs and dispatch fixes ──────────────────────
|
|
351
|
+
|
|
352
|
+
const prNanny = createPrNanny({
|
|
353
|
+
prStateStore,
|
|
354
|
+
getGhToken: resolveGhToken,
|
|
355
|
+
dispatchToWorld: defaultDispatchToWorld,
|
|
356
|
+
consultCodex: defaultConsultCodex,
|
|
357
|
+
pollIntervalMs: parseInt(process.env.OLAM_PR_NANNY_POLL_INTERVAL_MS ?? '60000', 10),
|
|
358
|
+
});
|
|
359
|
+
prNanny.start();
|
|
360
|
+
|
|
349
361
|
// ── Worlds-DB reconcile loop ────────────────────────────────────
|
|
350
362
|
//
|
|
351
363
|
// When host-cp runs bare-node, the CLI's auto-register may not have fired
|
|
@@ -618,6 +630,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
618
630
|
pr_number: pr.pr_number ?? null,
|
|
619
631
|
pr_url: pr.pr_url ?? null,
|
|
620
632
|
pr_state: prState,
|
|
633
|
+
nanny_dispatch_count: pr.nanny_dispatch_count ?? 0,
|
|
634
|
+
nanny_paused: pr.nanny_paused ?? false,
|
|
635
|
+
nanny_escalated: pr.nanny_escalated ?? false,
|
|
636
|
+
nanny_loop_halted: pr.nanny_loop_halted ?? false,
|
|
637
|
+
nanny_external_blocker: pr.nanny_external_blocker ?? false,
|
|
621
638
|
});
|
|
622
639
|
}
|
|
623
640
|
|
|
@@ -1321,6 +1338,56 @@ const server = http.createServer(async (req, res) => {
|
|
|
1321
1338
|
return;
|
|
1322
1339
|
}
|
|
1323
1340
|
|
|
1341
|
+
// ── PR Nanny operator overrides ──────────────────────────────────────────
|
|
1342
|
+
//
|
|
1343
|
+
// POST /api/admin/nanny/:worldId/pause — pause nanny for this world
|
|
1344
|
+
// POST /api/admin/nanny/:worldId/resume — resume nanny for this world
|
|
1345
|
+
// POST /api/admin/nanny/:worldId/escalate — mark escalated, stop dispatching
|
|
1346
|
+
// GET /api/admin/nanny — dump nanny state for all worlds
|
|
1347
|
+
//
|
|
1348
|
+
// Calling-world identity: X-Olam-World-Id header validated against registry.
|
|
1349
|
+
|
|
1350
|
+
const nannyAction = /^\/api\/admin\/nanny\/([^/?#]+)\/(pause|resume|escalate)$/.exec(url.pathname);
|
|
1351
|
+
if (nannyAction && req.method === 'POST') {
|
|
1352
|
+
if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
|
|
1353
|
+
const worldId = decodeURIComponent(nannyAction[1]);
|
|
1354
|
+
const action = nannyAction[2];
|
|
1355
|
+
// Validate calling-world identity when header present
|
|
1356
|
+
const callerWorldId = req.headers['x-olam-world-id'];
|
|
1357
|
+
if (callerWorldId && !(callerWorldId in WORLDS)) {
|
|
1358
|
+
return jsonReply(res, 403, { error: 'caller_world_not_registered' });
|
|
1359
|
+
}
|
|
1360
|
+
const existing = prStateStore.get(worldId);
|
|
1361
|
+
if (!existing) return jsonReply(res, 404, { error: 'world_not_found' });
|
|
1362
|
+
if (action === 'pause') {
|
|
1363
|
+
prStateStore.set(worldId, { nanny_paused: true, nanny_pause_reason: 'operator' });
|
|
1364
|
+
} else if (action === 'resume') {
|
|
1365
|
+
prStateStore.set(worldId, { nanny_paused: false, nanny_loop_halted: false, nanny_escalated: false });
|
|
1366
|
+
} else if (action === 'escalate') {
|
|
1367
|
+
prStateStore.set(worldId, { nanny_escalated: true, nanny_escalate_reason: 'operator' });
|
|
1368
|
+
}
|
|
1369
|
+
return jsonReply(res, 200, prStateStore.get(worldId));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (url.pathname === '/api/admin/nanny' && req.method === 'GET') {
|
|
1373
|
+
if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
|
|
1374
|
+
const all = prStateStore.getAll();
|
|
1375
|
+
const nannyStates = Object.fromEntries(
|
|
1376
|
+
Object.entries(all).map(([id, e]) => [id, {
|
|
1377
|
+
nanny_dispatch_count: e.nanny_dispatch_count ?? 0,
|
|
1378
|
+
nanny_paused: e.nanny_paused ?? false,
|
|
1379
|
+
nanny_escalated: e.nanny_escalated ?? false,
|
|
1380
|
+
nanny_loop_halted: e.nanny_loop_halted ?? false,
|
|
1381
|
+
nanny_external_blocker: e.nanny_external_blocker ?? false,
|
|
1382
|
+
nanny_first_dispatch_at: e.nanny_first_dispatch_at ?? null,
|
|
1383
|
+
nanny_last_dispatch_at: e.nanny_last_dispatch_at ?? null,
|
|
1384
|
+
pr_url: e.pr_url ?? null,
|
|
1385
|
+
pr_state: e.pr_state ?? null,
|
|
1386
|
+
}]),
|
|
1387
|
+
);
|
|
1388
|
+
return jsonReply(res, 200, nannyStates);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1324
1391
|
// DELETE /api/worlds/:worldId — immediate destroy
|
|
1325
1392
|
const worldDestroyMatch = /^\/api\/worlds\/([^/?#]+)$/.exec(url.pathname);
|
|
1326
1393
|
if (worldDestroyMatch && req.method === 'DELETE') {
|