@bongos/core 1.20.34 → 1.20.36
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/.bongos-core.json +49 -39
- package/.claude/skills/ask-for-help/SKILL.md +5 -3
- package/.claude/skills/collab-review/SKILL.md +8 -5
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +68 -56
- package/docs/copy-registry.json +255 -117
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +17 -17
- package/modules/hall-ui/public/approval-queue.js +1 -1
- package/modules/hall-ui/public/collab-lib.js +10 -1
- package/modules/hall-ui/public/collab.js +47 -10
- package/modules/hall-ui/public/deploy.js +231 -11
- package/modules/hall-ui/public/project-settings.css +28 -0
- package/modules/hall-ui/public/studio.js +7 -7
- package/modules/lifecycle/help-requests.js +35 -2
- package/modules/lifecycle/migrations/lifecycle_016_help_request_reopen.sql +23 -0
- package/modules/lifecycle/routes/help-requests.js +15 -1
- package/modules/provisioning/core-upgrade.js +4 -1
- package/modules/provisioning/render-standup.js +4 -1
- package/modules/public-landing/public/projects.html +16 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +19 -0
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +27 -1
- package/tests/core_upgrade_door.mjs +8 -1
- package/tests/deploy_page_projects.mjs +165 -1
- package/tests/hall_approval_queue.mjs +5 -1
- package/tests/hall_client_request_shape.mjs +103 -0
- package/tests/hall_studio_home.mjs +7 -1
- package/tests/hall_studio_world.mjs +5 -1
- package/tests/help_requests.mjs +54 -1
- package/tests/projects_hub_app_step.mjs +42 -0
- package/tests/provisioning_render_route.mjs +7 -0
|
@@ -186,3 +186,31 @@
|
|
|
186
186
|
padding: 0.35rem 0;
|
|
187
187
|
min-height: 24px;
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/* Why a move stopped, by kind (task 1004450). WAITING is the accent, like a move in
|
|
191
|
+
flight — something is still being done, on its own clock. A DECISION is the warn mark:
|
|
192
|
+
nothing is broken, and the next step is the owner's. */
|
|
193
|
+
.dp-settled--waiting {
|
|
194
|
+
border-left: 3px solid var(--accent);
|
|
195
|
+
padding-left: 0.7rem;
|
|
196
|
+
}
|
|
197
|
+
.dp-settled--decision {
|
|
198
|
+
border-left: 3px solid var(--warn);
|
|
199
|
+
padding-left: 0.7rem;
|
|
200
|
+
}
|
|
201
|
+
.dp-settled .ps-controls { margin: 0.4rem 0 0.2rem; }
|
|
202
|
+
|
|
203
|
+
/* The project's update rule (task 1004445) — a quiet setting under the move, set apart by
|
|
204
|
+
a hairline so it does not read as part of whichever state sits above it. */
|
|
205
|
+
.dp-rule {
|
|
206
|
+
margin: 0.9rem 0 0;
|
|
207
|
+
padding-top: 0.7rem;
|
|
208
|
+
border-top: 1px solid var(--rule-soft);
|
|
209
|
+
}
|
|
210
|
+
.dp-rule .ps-controls { align-items: center; gap: 0.5rem; }
|
|
211
|
+
.dp-rule__label { font-size: 0.86rem; color: var(--ink-soft); }
|
|
212
|
+
.dp-rule__select { min-height: 32px; }
|
|
213
|
+
/* A link inside a stopped move's sentence ("the owner's blockers") is a control and owes
|
|
214
|
+
the 24px floor: the prose-link recipe buys the height with padding and hands it back
|
|
215
|
+
with an equal negative margin, so the line does not re-space. */
|
|
216
|
+
.dp-settled .ov-row__sub a { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; }
|
|
@@ -386,7 +386,7 @@
|
|
|
386
386
|
const said = words();
|
|
387
387
|
const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
|
|
388
388
|
const reason = `Reviewed in the studio by ${who} — the copy and visuals are good as shipped.${said ? ` ${said}` : ''}`;
|
|
389
|
-
const res = await api.request('POST', WRITE_ROUTES.abandon(p.taskId), { reason });
|
|
389
|
+
const res = await api.request('POST', WRITE_ROUTES.abandon(p.taskId), { body: { reason } });
|
|
390
390
|
if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
|
|
391
391
|
toast('Dismissed — the copy and visuals are good as shipped.');
|
|
392
392
|
retire(p);
|
|
@@ -417,7 +417,7 @@
|
|
|
417
417
|
const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
|
|
418
418
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
419
419
|
const block = `\n\n---\n\nARTIST NOTE (${stamp}, ${who}, from the studio):\n\n${said}`;
|
|
420
|
-
const res = await api.request('PATCH', WRITE_ROUTES.patch(p.taskId), { description: `${base}${block}` });
|
|
420
|
+
const res = await api.request('PATCH', WRITE_ROUTES.patch(p.taskId), { body: { description: `${base}${block}` } });
|
|
421
421
|
if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
|
|
422
422
|
p.description = `${base}${block}`;
|
|
423
423
|
toast('Noted on the review — it stays open.');
|
|
@@ -447,7 +447,7 @@
|
|
|
447
447
|
if (!said) return verdictError('Write what needs work first — a follow-up with no brief cannot be claimed.');
|
|
448
448
|
const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
|
|
449
449
|
const about = p.shippedId ? `task ${p.shippedId}` : `review ${p.taskId}`;
|
|
450
|
-
const created = await api.request('POST', WRITE_ROUTES.create(), {
|
|
450
|
+
const created = await api.request('POST', WRITE_ROUTES.create(), { body: {
|
|
451
451
|
version_id: p.versionId,
|
|
452
452
|
goal_id: p.goalId ? Number(p.goalId) : undefined,
|
|
453
453
|
title: `Artist verdict: the copy or visuals shipped in ${about} need work`,
|
|
@@ -463,17 +463,17 @@
|
|
|
463
463
|
discipline: 'artist',
|
|
464
464
|
source: 'studio-verdict',
|
|
465
465
|
source_ref: `studio-needs-work-on-review-${p.taskId}`,
|
|
466
|
-
});
|
|
466
|
+
} });
|
|
467
467
|
if (!created.ok) return verdictError(errMessage(created.data) || 'The follow-up could not be filed. Nothing changed.');
|
|
468
468
|
const newId = created.data && created.data.task && created.data.task.id;
|
|
469
469
|
if (!newId) return verdictError('The follow-up came back without an id, so the review was left open.');
|
|
470
470
|
// Close the review against the follow-up. If this half fails the follow-up
|
|
471
471
|
// still exists and is reported as such — a stranded-but-visible task beats
|
|
472
472
|
// a silent success, and re-running the same verdict is safe.
|
|
473
|
-
const closed = await api.request('POST', WRITE_ROUTES.merge(p.taskId), {
|
|
473
|
+
const closed = await api.request('POST', WRITE_ROUTES.merge(p.taskId), { body: {
|
|
474
474
|
into_task_id: Number(newId),
|
|
475
475
|
reason: `Artist verdict from the studio: the review's answer is task ${newId}, which carries the work.`,
|
|
476
|
-
});
|
|
476
|
+
} });
|
|
477
477
|
if (!closed.ok) {
|
|
478
478
|
return verdictError(`Task ${newId} was filed, but this review could not be closed against it — close it from the review itself.`);
|
|
479
479
|
}
|
|
@@ -882,7 +882,7 @@
|
|
|
882
882
|
wedit.save = 'saving';
|
|
883
883
|
wedit.error = '';
|
|
884
884
|
paintEditing();
|
|
885
|
-
wedit.inflight = api.request('PUT', WRITE_ROUTES.draft(STUDIO), body);
|
|
885
|
+
wedit.inflight = api.request('PUT', WRITE_ROUTES.draft(STUDIO), { body });
|
|
886
886
|
const res = await wedit.inflight;
|
|
887
887
|
wedit.inflight = null;
|
|
888
888
|
if (res.ok) {
|
|
@@ -48,7 +48,7 @@ async function craftsForBuilder(builderId, deps = {}) {
|
|
|
48
48
|
return [...new Set(list.map(craftForDiscipline).filter(Boolean))];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by, settled_reason';
|
|
51
|
+
const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by, settled_reason, reopened_at';
|
|
52
52
|
// The two list reads also NAME the people on a row — who is asking (the fact
|
|
53
53
|
// the collab page owes a helper: "who to answer", task 1003471) and who a
|
|
54
54
|
// by-name ask is addressed to — through two LEFT JOINs on builders. COLS stays
|
|
@@ -257,6 +257,11 @@ async function listOpenAll({ limit = MAX_ROWS } = {}, deps = {}) {
|
|
|
257
257
|
// it, and inventing one — treating a reply as an answer, say — would close the
|
|
258
258
|
// clarifying question that was asking for more detail. Those still need a person.
|
|
259
259
|
//
|
|
260
|
+
// NEVER a REOPENED row (task 1004458). An asker who reopens an ask is saying the
|
|
261
|
+
// shipped task did NOT answer it; closing it again on the next page load because
|
|
262
|
+
// that same task is still shipped would overrule them in under a second. Once
|
|
263
|
+
// reopened, only a person closes it.
|
|
264
|
+
//
|
|
260
265
|
// IDEMPOTENT, and safe to run on every list read: the WHERE matches only rows
|
|
261
266
|
// that are still open, so a second run within the same page load finds nothing.
|
|
262
267
|
async function settleFromLedger(deps = {}) {
|
|
@@ -266,6 +271,7 @@ async function settleFromLedger(deps = {}) {
|
|
|
266
271
|
SET status = 'answered', settled_reason = 'task_shipped', updated_at = now()
|
|
267
272
|
WHERE h.status = 'open'
|
|
268
273
|
AND h.task_id IS NOT NULL
|
|
274
|
+
AND h.reopened_at IS NULL
|
|
269
275
|
AND EXISTS (SELECT 1 FROM tasks t WHERE t.id = h.task_id AND t.status = 'shipped')
|
|
270
276
|
RETURNING h.id, h.task_id`
|
|
271
277
|
);
|
|
@@ -359,6 +365,33 @@ async function setHelpRequestStatus({ id, builderId, status, crafts = [] }, deps
|
|
|
359
365
|
return rows[0] || null;
|
|
360
366
|
}
|
|
361
367
|
|
|
368
|
+
// THE ASKER REOPENS (task 1004458, owner direction: "like a traditional ticket").
|
|
369
|
+
// Closing moved to the addressee with ADR 0330, which means an ask can now be
|
|
370
|
+
// closed by someone other than the person who needed the answer — so the person
|
|
371
|
+
// who needed it gets the last word. Only they may reopen: an addressee who wants
|
|
372
|
+
// more has a reply box, and a third party reopening someone else's question
|
|
373
|
+
// would be putting it back in a queue its owner had left.
|
|
374
|
+
//
|
|
375
|
+
// Any settled status reopens — 'answered' by a person or by the ledger, and the
|
|
376
|
+
// asker's own 'withdrawn'. The settle fields are cleared so the row reads as the
|
|
377
|
+
// open ask it is again, and `reopened_at` is what keeps settleFromLedger from
|
|
378
|
+
// closing it straight back.
|
|
379
|
+
//
|
|
380
|
+
// Returns null for absent, not yours, or still open — ONE answer for all three,
|
|
381
|
+
// for the same enumeration reason setHelpRequestStatus gives.
|
|
382
|
+
async function reopenHelpRequest({ id, builderId }, deps = {}) {
|
|
383
|
+
const activePool = deps.pool || pool;
|
|
384
|
+
const { rows } = await activePool.query(
|
|
385
|
+
`UPDATE lifecycle_help_requests
|
|
386
|
+
SET status = 'open', settled_by = NULL, settled_reason = NULL,
|
|
387
|
+
reopened_at = now(), updated_at = now()
|
|
388
|
+
WHERE id = $1 AND requested_by = $2 AND status <> 'open'
|
|
389
|
+
RETURNING ${COLS}`,
|
|
390
|
+
[id, builderId]
|
|
391
|
+
);
|
|
392
|
+
return rows[0] || null;
|
|
393
|
+
}
|
|
394
|
+
|
|
362
395
|
// ---- replies (task 1004216) -------------------------------------------------
|
|
363
396
|
//
|
|
364
397
|
// APPEND-ONLY, and the table's own trigger is the authority — there is deliberately
|
|
@@ -450,7 +483,7 @@ async function repliesForRequests(ids, deps = {}) {
|
|
|
450
483
|
module.exports = {
|
|
451
484
|
HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
|
|
452
485
|
validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
|
|
453
|
-
builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus,
|
|
486
|
+
builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus, reopenHelpRequest,
|
|
454
487
|
settleFromLedger, listArchivedForBuilder, listArchivedMine,
|
|
455
488
|
countOpenForBuilder, craftMapSql,
|
|
456
489
|
MAX_REPLY, MAX_THREAD_ON_LIST, validateReply, createReply, listReplies, repliesForRequests,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- lifecycle_016_help_request_reopen.sql — the asker can reopen a settled ask
|
|
2
|
+
-- (task 1004458, owner direction 2026-09-30: "mark it as resolved and the
|
|
3
|
+
-- requester should then be able to re-open like traditional ticket fashion").
|
|
4
|
+
--
|
|
5
|
+
-- WHY A COLUMN AND NOT JUST A STATUS FLIP. Reopening sets status back to 'open',
|
|
6
|
+
-- which is all the page needs. But settleFromLedger (lifecycle_012) closes every
|
|
7
|
+
-- open ask whose task has shipped, on every Collab read — so an ask the ledger
|
|
8
|
+
-- closed, then reopened by its asker, would be closed again on the very next
|
|
9
|
+
-- page load. `reopened_at` is the fact that settle reads to leave it alone: the
|
|
10
|
+
-- asker has said the shipped task did not answer the question, and only a person
|
|
11
|
+
-- closes it from then on.
|
|
12
|
+
--
|
|
13
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it; no core
|
|
14
|
+
-- table is altered (ADR 0083 §Decision #5).
|
|
15
|
+
|
|
16
|
+
ALTER TABLE lifecycle_help_requests
|
|
17
|
+
ADD COLUMN IF NOT EXISTS reopened_at timestamptz;
|
|
18
|
+
|
|
19
|
+
COMMENT ON COLUMN lifecycle_help_requests.reopened_at IS
|
|
20
|
+
'When the asker last reopened this ask after it was settled (task 1004458). Non-NULL keeps settleFromLedger from closing it again because its task shipped.';
|
|
21
|
+
|
|
22
|
+
INSERT INTO schema_migrations (version) VALUES ('lifecycle_016_help_request_reopen')
|
|
23
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -200,6 +200,9 @@ module.exports = function buildHelpRequestsRouter() {
|
|
|
200
200
|
// Nobody is assigned by being asked — closing an ask ends a question, it does
|
|
201
201
|
// not accept work (ADR 0187 §3).
|
|
202
202
|
//
|
|
203
|
+
// 'open' REOPENS a settled ask (task 1004458) — the asker's alone, ticket
|
|
204
|
+
// style: whoever closed it, the person who needed the answer has the last word.
|
|
205
|
+
//
|
|
203
206
|
// rank: any-builder — who may settle is enforced in the UPDATE's WHERE, not by
|
|
204
207
|
// rank. The caller's crafts come from THEIR OWN profile, never from the body,
|
|
205
208
|
// for the same reason the addressed read refuses a ?craft= it was handed.
|
|
@@ -207,9 +210,20 @@ module.exports = function buildHelpRequestsRouter() {
|
|
|
207
210
|
const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
|
|
208
211
|
if (id === null) return;
|
|
209
212
|
if (validateOrRespond(req, res, {
|
|
210
|
-
status: { required: true, type: 'string', enum: ['answered', 'withdrawn'] },
|
|
213
|
+
status: { required: true, type: 'string', enum: ['answered', 'withdrawn', 'open'] },
|
|
211
214
|
})) return;
|
|
212
215
|
|
|
216
|
+
if (req.body.status === 'open') {
|
|
217
|
+
const reopened = await help.reopenHelpRequest({ id, builderId: req.builder.id });
|
|
218
|
+
if (!reopened) {
|
|
219
|
+
// Same single refusal as below, for the same enumeration reason.
|
|
220
|
+
return res.fail('help_request_not_reopenable', 404, {
|
|
221
|
+
reason: 'no settled help request with that id that you asked',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return res.json({ ok: true, help_request: reopened });
|
|
225
|
+
}
|
|
226
|
+
|
|
213
227
|
const crafts = await help.craftsForBuilder(req.builder.id);
|
|
214
228
|
const row = await help.setHelpRequestStatus({
|
|
215
229
|
id, builderId: req.builder.id, status: req.body.status, crafts,
|
|
@@ -348,7 +348,10 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
|
|
|
348
348
|
preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
|
|
349
349
|
// not_before: a failed move waiting out its retry backoff (task 1004447) — the page can
|
|
350
350
|
// say "trying again at …" instead of an unexplained "pending" for up to six hours.
|
|
351
|
-
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null, not_before: openIntent.not_before || null, last_error: openIntent.not_before && openIntent.last_error ? String(openIntent.last_error).slice(0, 600) : null
|
|
351
|
+
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null, not_before: openIntent.not_before || null, last_error: openIntent.not_before && openIntent.last_error ? String(openIntent.last_error).slice(0, 600) : null,
|
|
352
|
+
// WHY it is waiting (task 1004450): a transient retry and a known snag the runner just
|
|
353
|
+
// fixed wait the same way, and the page words them differently. Only on a waiting row.
|
|
354
|
+
failure: openIntent.not_before && openIntent.failure_class ? { class: openIntent.failure_class, reason: openIntent.failure_reason || null } : null } : null,
|
|
352
355
|
// THE LAST MOVE, WHATEVER BECAME OF IT (task 1004145).
|
|
353
356
|
//
|
|
354
357
|
// `open_intent` is `state IN ('pending','running')` — by the index's own definition.
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
// The plan and region vocabulary — shared with the runner's payload builder, which
|
|
23
23
|
// requires it from here, so the web tier and the runner can never disagree on a name.
|
|
24
|
-
|
|
24
|
+
// `free` is a web plan only (task 1004457, owner call 2026-09-30): the app sleeps when
|
|
25
|
+
// idle, which is the owner's trade to make. The database has no free plan here —
|
|
26
|
+
// Render's free Postgres has no backups and expires.
|
|
27
|
+
const WEB_PLANS = ['free', 'starter','standard', 'pro', 'pro_plus', 'pro_max', 'pro_ultra'];
|
|
25
28
|
const DB_PLANS = ['basic_256mb', 'basic_1gb', 'basic_4gb', 'pro_4gb', 'pro_8gb'];
|
|
26
29
|
const REGIONS = ['virginia', 'ohio', 'oregon', 'frankfurt', 'singapore'];
|
|
27
30
|
const MODES = ['adopt', 'greenfield'];
|
|
@@ -1434,10 +1434,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1434
1434
|
</select>
|
|
1435
1435
|
</label>
|
|
1436
1436
|
<label class="wizField" id="renderBuildWrap"><span>Build command <small>— leave empty if there is none</small></span>
|
|
1437
|
-
<input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="npm ci">
|
|
1437
|
+
<input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm ci">
|
|
1438
1438
|
</label>
|
|
1439
1439
|
<label class="wizField" id="renderStartWrap"><span>Start command</span>
|
|
1440
|
-
<input type="text" id="f-renderStart" autocomplete="off" spellcheck="false" maxlength="500" placeholder="npm start">
|
|
1440
|
+
<input type="text" id="f-renderStart" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm start">
|
|
1441
1441
|
</label>
|
|
1442
1442
|
<label class="wizField"><span>Plan</span>
|
|
1443
1443
|
<select id="f-renderPlan"></select>
|
|
@@ -1800,14 +1800,18 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1800
1800
|
a key reaches every workspace its user is in, so nothing is pre-picked (ADR 0327).
|
|
1801
1801
|
Prices are Render's list prices per month: starter and basic_256mb were measured by the
|
|
1802
1802
|
pilot (task 1004142); Render's own pricing page is the source of truth, and the review
|
|
1803
|
-
says Render bills the owner. Lists are built node by node, never from an HTML string.
|
|
1804
|
-
|
|
1803
|
+
says Render bills the owner. Lists are built node by node, never from an HTML string.
|
|
1804
|
+
Free (task 1004457) is listed first by price but never preselected: its app sleeps when
|
|
1805
|
+
idle, and the list says so rather than letting $0 read as a plain bargain. */
|
|
1806
|
+
var RENDER_WEB_PLANS = [['free', 'Free', 0], ['starter', 'Starter', 7], ['standard', 'Standard', 25], ['pro', 'Pro', 85]];
|
|
1807
|
+
var RENDER_FREE_NOTE = '$0, but it sleeps when idle and takes about a minute to wake up';
|
|
1808
|
+
function planPrice(p) { return p[2] ? 'about $' + p[2] + '/month' : RENDER_FREE_NOTE; }
|
|
1805
1809
|
var RENDER_DB_PLANS = [['basic_256mb', 'Basic 256 MB', 6], ['basic_1gb', 'Basic 1 GB', 19], ['basic_4gb', 'Basic 4 GB', 75]];
|
|
1806
1810
|
var RENDER_RUNTIMES = { node: 'Node.js', python: 'Python', ruby: 'Ruby', go: 'Go', rust: 'Rust', elixir: 'Elixir', docker: 'Docker' };
|
|
1807
1811
|
var RENDER_KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
|
|
1808
1812
|
function planLabel(list, v) {
|
|
1809
1813
|
var p = list.filter(function (x) { return x[0] === v; })[0] || list[0];
|
|
1810
|
-
return p[1] + ',
|
|
1814
|
+
return p[1] + ', ' + planPrice(p);
|
|
1811
1815
|
}
|
|
1812
1816
|
function runtimeLabel(v) { return RENDER_RUNTIMES[v] || RENDER_RUNTIMES.node; }
|
|
1813
1817
|
function repoOf(url) { var m = String(url || '').replace(/\.git$/, '').match(/github\.com\/([^/]+\/[^/]+)$/i); return m ? m[1].toLowerCase() : ''; }
|
|
@@ -1832,7 +1836,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1832
1836
|
function fillPlans(sel, list, v) {
|
|
1833
1837
|
sel.textContent = '';
|
|
1834
1838
|
list.forEach(function (p) {
|
|
1835
|
-
var o = appEl('option', null, p[1] + ' —
|
|
1839
|
+
var o = appEl('option', null, p[1] + ' — ' + planPrice(p));
|
|
1836
1840
|
o.value = p[0];
|
|
1837
1841
|
if (p[0] === v) o.selected = true;
|
|
1838
1842
|
sel.appendChild(o);
|
|
@@ -1972,7 +1976,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1972
1976
|
});
|
|
1973
1977
|
q('renderNew').addEventListener('input', function (ev) {
|
|
1974
1978
|
if (ev.target.id === q('f-renderBuild').id) st.renderBuild = ev.target.value;
|
|
1975
|
-
if (ev.target.id === q('f-renderStart').id)
|
|
1979
|
+
if (ev.target.id === q('f-renderStart').id) {
|
|
1980
|
+
st.renderStart = ev.target.value;
|
|
1981
|
+
// the "say how your app starts" ask is answered the moment there is a command —
|
|
1982
|
+
// it used to stay on screen until the next press of Next
|
|
1983
|
+
if (String(st.renderStart).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
|
|
1984
|
+
}
|
|
1976
1985
|
hooks.save();
|
|
1977
1986
|
});
|
|
1978
1987
|
function validate() {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.36",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.36",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.36",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8284,5 +8284,24 @@
|
|
|
8284
8284
|
"id": "1004454",
|
|
8285
8285
|
"text": "The unattended runner no longer stops after two tasks: it keeps working while there's usage left, and if it hits the usage limit it sleeps until the limit resets and then carries on by itself."
|
|
8286
8286
|
}
|
|
8287
|
+
],
|
|
8288
|
+
"1.20.35": [
|
|
8289
|
+
{
|
|
8290
|
+
"id": "1004461",
|
|
8291
|
+
"text": "Fixes a broken automatic check that was blocking everyone's work from landing."
|
|
8292
|
+
},
|
|
8293
|
+
{
|
|
8294
|
+
"text": "Replying to an ask on the Collab page works again, and asks can now be marked resolved and reopened by the person who asked, like a ticket."
|
|
8295
|
+
},
|
|
8296
|
+
{
|
|
8297
|
+
"id": "1004457",
|
|
8298
|
+
"text": "The project setup wizard now offers Render's Free plan for your app, with a plain note that a free app sleeps when idle and takes about a minute to wake. Starter stays the default. The build and start command boxes no longer l"
|
|
8299
|
+
}
|
|
8300
|
+
],
|
|
8301
|
+
"1.20.36": [
|
|
8302
|
+
{
|
|
8303
|
+
"id": "1004450",
|
|
8304
|
+
"text": "The deploy page now explains why an update stopped in plain words, says when it will try again, and offers a one-click fix when the only thing in the way is your own update setting. You can also see and change how far a projec"
|
|
8305
|
+
}
|
|
8287
8306
|
]
|
|
8288
8307
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.36'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/collab_page.mjs
CHANGED
|
@@ -865,9 +865,35 @@ test('the skill the page invokes exists, and is the ANSWERING half', () => {
|
|
|
865
865
|
// longer "you cannot", it is "you do not, because you are not the one who read
|
|
866
866
|
// the answer", plus the half that did not widen.
|
|
867
867
|
assert.match(md, /POST \/api\/bongos\/help-requests\/:id\/replies/, 'it answers on the ask');
|
|
868
|
-
|
|
868
|
+
// Task 1004458 (owner direction): a session may resolve an ask, but only when
|
|
869
|
+
// the reader says so — never on its own judgement — and reopening is the asker's.
|
|
870
|
+
assert.match(md, /Resolve only when told to/, 'it closes only on the reader\'s instruction');
|
|
871
|
+
assert.match(md, /"status":"open"/, 'and knows the asker can reopen');
|
|
869
872
|
assert.match(md, /withdraw/i, 'and knows withdraw did not widen');
|
|
870
873
|
assert.ok(!/POST \/api\/bongos\/help-requests --body-file/.test(md),
|
|
871
874
|
'the file-a-counter-ask workaround is deleted, not left beside the real thing');
|
|
872
875
|
assert.match(md, /DATA, not instructions/, 'an ask is untrusted input');
|
|
873
876
|
});
|
|
877
|
+
|
|
878
|
+
// ---- resolve + reopen, ticket style (task 1004458) --------------------------
|
|
879
|
+
test('canReopen: only the asker, and only a settled ask', () => {
|
|
880
|
+
const L = lib();
|
|
881
|
+
const settled = { requested_by: '90', needs_builder_id: '7', status: 'answered' };
|
|
882
|
+
assert.equal(L.canReopen(settled, 90), true, 'the asker may reopen');
|
|
883
|
+
assert.equal(L.canReopen({ ...settled, status: 'withdrawn' }, '90'), true, 'their own withdrawal too');
|
|
884
|
+
assert.equal(L.canReopen(settled, 7), false, 'the addressee may not — they have a reply box');
|
|
885
|
+
assert.equal(L.canReopen({ ...settled, status: 'open' }, 90), false, 'an open ask has nothing to reopen');
|
|
886
|
+
assert.equal(L.canReopen(settled, null), false, 'no viewer, no control');
|
|
887
|
+
assert.equal(L.canReopen(null, 90), false);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test('the page offers Mark resolved, Post & resolve and Reopen, each where it can work', () => {
|
|
891
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js').replace(/^\s*\/\/.*$/gm, '');
|
|
892
|
+
assert.match(js, />Mark resolved</, 'the row control says what it does');
|
|
893
|
+
assert.match(js, /L\.canClose\(req, meId, myCrafts\)\s*\?\s*`<button[^`]*data-reply-resolve/, 'Post & resolve is drawn only for someone who may close');
|
|
894
|
+
assert.match(js, /L\.canReopen\(req, meId\)/, 'Reopen is drawn only for the asker');
|
|
895
|
+
assert.match(js, /settle\(Number\(reopenBtn\.getAttribute\('data-id'\)\), 'open'\)/, 'and it sends status open');
|
|
896
|
+
// The close waits for the reply: a refused reply must not leave an ask closed.
|
|
897
|
+
const post = js.slice(js.indexOf('async function postReply'), js.indexOf('function toggleReply'));
|
|
898
|
+
assert.ok(post.indexOf('if (!r.ok)') < post.indexOf("status: 'answered'"), 'resolve runs only after the reply landed');
|
|
899
|
+
});
|
|
@@ -431,7 +431,7 @@ await t('schema_pending passes through as a count, and UNKNOWN stays null rather
|
|
|
431
431
|
await t('an open intent is surfaced with its target, so the page can say what is already running', () => {
|
|
432
432
|
const p = CU.previewFromSnapshot(previewInst, { served_version: '1.19.758', available: [], recommended: null },
|
|
433
433
|
{ action: 'core-upgrade', state: 'running', target_version: '1.19.762' });
|
|
434
|
-
assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null });
|
|
434
|
+
assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null, failure: null });
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
console.log('\nthe deploy page is gated on the same atom as its routes:');
|
|
@@ -1007,5 +1007,12 @@ await t('the preview says when a waiting move will try again, and why it is wait
|
|
|
1007
1007
|
'an ordinary pending intent shows no error');
|
|
1008
1008
|
});
|
|
1009
1009
|
|
|
1010
|
+
await t('a waiting move says what KIND of failure it is waiting out; a plain pending one says none (task 1004450)', () => {
|
|
1011
|
+
const p = CU.previewFromSnapshot(ACTIVE, null, waiting({ failure_class: 'known_wedge', failure_reason: 'dirty_tree' }), null);
|
|
1012
|
+
assert.deepEqual(p.open_intent.failure, { class: 'known_wedge', reason: 'dirty_tree' });
|
|
1013
|
+
assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting({ not_before: null, failure_class: 'transient' }), null).open_intent.failure, null);
|
|
1014
|
+
assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting(), null).open_intent.failure, null, 'a row from before the columns');
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1010
1017
|
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
1011
1018
|
if (failed) process.exit(1);
|
|
@@ -517,7 +517,7 @@ test('the runner’s shell output is folded behind a disclosure, never the headl
|
|
|
517
517
|
}) });
|
|
518
518
|
await b.settle();
|
|
519
519
|
const html = b.panel();
|
|
520
|
-
assert.match(html, /<details class="dp-settled__detail"><summary>
|
|
520
|
+
assert.match(html, /<details class="dp-settled__detail"><summary>Technical detail — what the upgrade said<\/summary>/, 'folded');
|
|
521
521
|
assert.match(html, /working tree not clean/, 'and the words are still all there');
|
|
522
522
|
assert.ok(html.indexOf('did not land') < html.indexOf('<details'), 'the plain sentence comes first');
|
|
523
523
|
});
|
|
@@ -645,3 +645,167 @@ test('the reconnect link returns to the page\u2019s full https address when ther
|
|
|
645
645
|
await b.settle();
|
|
646
646
|
assert.ok(b.panel().includes(`return=${encodeURIComponent(here)}`), b.panel());
|
|
647
647
|
});
|
|
648
|
+
|
|
649
|
+
// ── why a move stopped, by kind; the update rule; waiting is not moving (task 1004450) ──
|
|
650
|
+
|
|
651
|
+
// A body built inside the vm is another realm's object; compare its data, not its prototype.
|
|
652
|
+
const plain = (o) => JSON.parse(JSON.stringify(o));
|
|
653
|
+
const soon = (mins) => new Date(Date.now() + mins * 60000).toISOString();
|
|
654
|
+
const failedAs = (cls, reason, over = {}) => ({
|
|
655
|
+
target_version: '1.19.871', state: 'error', at: new Date().toISOString(), error: 'raw runner words', superseded: false,
|
|
656
|
+
failure: { class: cls, reason }, ...over,
|
|
657
|
+
});
|
|
658
|
+
const waitingIntent = (cls, reason, mins = 60) => ({
|
|
659
|
+
action: 'core-upgrade', state: 'pending', target_version: '1.19.871', not_before: soon(mins),
|
|
660
|
+
last_error: 'x', failure: cls ? { class: cls, reason } : null,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test('a move waiting out a temporary failure says WHEN it tries again, and offers to try now', async () => {
|
|
664
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { open_intent: waitingIntent('transient', 'not_published') }) });
|
|
665
|
+
await b.settle();
|
|
666
|
+
const html = b.panel();
|
|
667
|
+
assert.match(html, /dp-settled--waiting/);
|
|
668
|
+
assert.match(html, /Waiting to try core 1\.19\.871 again at .*in about 60 min/);
|
|
669
|
+
assert.match(html, /not published yet/, 'the reason, in plain words');
|
|
670
|
+
assert.match(html, /usually temporary/);
|
|
671
|
+
assert.doesNotMatch(html, /is pending for this box/, 'not the generic busy line');
|
|
672
|
+
b.els.get('dp-retry-1')._on.click();
|
|
673
|
+
await b.settle();
|
|
674
|
+
const post = b.posts.find((p) => /core-upgrade$/.test(p.path));
|
|
675
|
+
assert.deepEqual(post && plain(post.body), { to: '1.19.871' }, 'Try now asks for the SAME move');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('a known snag the runner just fixed says what it did, and that it tries once more', async () => {
|
|
679
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { open_intent: waitingIntent('known_wedge', 'dirty_tree', 2) }) });
|
|
680
|
+
await b.settle();
|
|
681
|
+
const html = b.panel();
|
|
682
|
+
assert.match(html, /A known snag stopped the move/);
|
|
683
|
+
assert.match(html, /unsaved file changes/);
|
|
684
|
+
assert.match(html, /saved those changes on its own/);
|
|
685
|
+
assert.match(html, /tries the move once more/);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('a pending move with a PAST not_before is still a move under way, not a wait', async () => {
|
|
689
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { open_intent: waitingIntent('transient', 'not_published', -5) }) });
|
|
690
|
+
await b.settle();
|
|
691
|
+
assert.doesNotMatch(b.panel(), /dp-settled--waiting/);
|
|
692
|
+
assert.match(b.panel(), /is pending for this box/);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test('a move that falls into a WAIT stops the poll at once, with no "still working" toast', async () => {
|
|
696
|
+
const b = boot({
|
|
697
|
+
scope: 'hoodify-finance', moveTries: 50,
|
|
698
|
+
instanceOver: (inst, n) => (n >= 2 ? { open_intent: waitingIntent('transient', 'registry_unreadable') } : {}),
|
|
699
|
+
});
|
|
700
|
+
await b.settle();
|
|
701
|
+
b.els.get('dp-preview-32')._on.click();
|
|
702
|
+
b.els.get('dp-confirm-32')._on.click();
|
|
703
|
+
for (let i = 0; i < 4; i++) await b.settle();
|
|
704
|
+
assert.match(b.panel(), /dp-settled--waiting/, 'the row shows the wait');
|
|
705
|
+
assert.doesNotMatch(b.panel(), /dp-moving/, 'and is no longer painted as moving');
|
|
706
|
+
assert.ok(!b.toasts.some((t) => /Still working/.test(t)), `no false "still working": ${b.toasts}`);
|
|
707
|
+
assert.ok(b.reads.get('32') < 10, `the poll stopped early (read ${b.reads.get('32')} times, bound 50)`);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test('a refusal by the update rule offers ONE click: allow new releases, then move', async () => {
|
|
711
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'patch', last_move: failedAs('needs_decision', 'outside_channel', { target_version: '1.20.1' }) }) });
|
|
712
|
+
await b.settle();
|
|
713
|
+
const html = b.panel();
|
|
714
|
+
assert.match(html, /dp-settled--decision/);
|
|
715
|
+
assert.match(html, /Your call/);
|
|
716
|
+
assert.match(html, /outside this project’s update rule/);
|
|
717
|
+
assert.match(html, /Allow new releases and move to 1\.20\.1/);
|
|
718
|
+
b.els.get('dp-allow-1')._on.click();
|
|
719
|
+
await b.settle();
|
|
720
|
+
const paths = b.posts.map((p) => p.path);
|
|
721
|
+
const rule = paths.indexOf('/provisioning/instances/1/update-channel');
|
|
722
|
+
const mv = paths.indexOf('/provisioning/instances/1/core-upgrade');
|
|
723
|
+
assert.ok(rule >= 0 && mv > rule, `the rule changes FIRST, then the move is queued: ${paths}`);
|
|
724
|
+
assert.deepEqual(plain(b.posts[rule].body), { channel: 'minor' });
|
|
725
|
+
assert.deepEqual(plain(b.posts[mv].body), { to: '1.20.1' });
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('under the widest rule already, the refusal offers no allow button', async () => {
|
|
729
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'minor', last_move: failedAs('needs_decision', 'outside_channel') }) });
|
|
730
|
+
await b.settle();
|
|
731
|
+
assert.doesNotMatch(b.panel(), /dp-allow-1/);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('the other decisions say what the owner can do, with no button that cannot help', async () => {
|
|
735
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('needs_decision', 'artist_gate') }) });
|
|
736
|
+
await b.settle();
|
|
737
|
+
assert.match(b.panel(), /Approve the artist review/);
|
|
738
|
+
assert.doesNotMatch(b.panel(), /dp-allow-1/);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('a failure nothing automatic can fix points at the blocker filed for it', async () => {
|
|
742
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('unknown', 'health') }) });
|
|
743
|
+
await b.settle();
|
|
744
|
+
const html = b.panel();
|
|
745
|
+
assert.match(html, /nothing automatic can fix it/);
|
|
746
|
+
assert.match(html, /did not answer after restarting/);
|
|
747
|
+
assert.match(html, /href="\/blockers"/);
|
|
748
|
+
assert.match(html, /<details class="dp-settled__detail">/, 'the runner’s words are still one click away');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('a failed ROLLBACK says the project may be down', async () => {
|
|
752
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('unknown', 'rollback_failed') }) });
|
|
753
|
+
await b.settle();
|
|
754
|
+
assert.match(b.panel(), /may be down/);
|
|
755
|
+
assert.match(b.panel(), /href="\/blockers"/);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('a temporary failure that ran out of retries offers to try again — only for the move on offer', async () => {
|
|
759
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('transient', 'npm_install') }) });
|
|
760
|
+
await b.settle();
|
|
761
|
+
assert.match(b.panel(), /kept stopping/);
|
|
762
|
+
assert.match(b.panel(), /dp-again-1/);
|
|
763
|
+
const other = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('transient', 'npm_install', { target_version: '1.19.850' }) }) });
|
|
764
|
+
await other.settle();
|
|
765
|
+
assert.doesNotMatch(other.panel(), /dp-again-1/, 'a try-again for a version no longer on offer would be a different move');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test('the update rule is shown in words and can be changed from the page', async () => {
|
|
769
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'patch' }) });
|
|
770
|
+
await b.settle();
|
|
771
|
+
const html = b.panel();
|
|
772
|
+
assert.match(html, /Update rule: Fixes only/);
|
|
773
|
+
assert.match(html, /<option value="patch" selected>/);
|
|
774
|
+
b.els.set('dp-rule-sel-1', { value: 'minor' });
|
|
775
|
+
b.els.get('dp-rule-save-1')._on.click();
|
|
776
|
+
await b.settle();
|
|
777
|
+
const post = b.posts.find((p) => /update-channel$/.test(p.path));
|
|
778
|
+
assert.deepEqual(post && plain(post.body), { channel: 'minor' });
|
|
779
|
+
assert.ok(b.toasts.length, 'the server’s sentence is shown');
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('a preview with no rule reading shows no rule control, rather than a guessed one', async () => {
|
|
783
|
+
const b = boot();
|
|
784
|
+
await b.settle();
|
|
785
|
+
assert.doesNotMatch(b.panel(), /Update rule/);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test('every reason the runner can give has owner words on this page — none falls to the fallback', async () => {
|
|
789
|
+
// upgrade-outcome.js's REASON_CLASS is the vocabulary; a reason added there with no
|
|
790
|
+
// sentence here would reach the owner as "no words for this yet".
|
|
791
|
+
const { createRequire } = await import('node:module');
|
|
792
|
+
const req = createRequire(import.meta.url);
|
|
793
|
+
const vocab = Object.keys(req(path.join(ROOT, 'scripts/gds/upgrade-outcome.js')).REASON_CLASS || {});
|
|
794
|
+
assert.ok(vocab.length > 20, 'the vocabulary was read');
|
|
795
|
+
const block = SRC.slice(SRC.indexOf('const REASON_WORDS = {'), SRC.indexOf('const reasonWords'));
|
|
796
|
+
const missing = vocab.filter((k) => !new RegExp(`\\b${k}:`).test(block));
|
|
797
|
+
assert.deepEqual(missing, [], `reasons with no owner words: ${missing}`);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test('a module that is not ready says what the owner can do about it', async () => {
|
|
801
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('needs_decision', 'module_incompatible') }) });
|
|
802
|
+
await b.settle();
|
|
803
|
+
assert.match(b.panel(), /Turn that module off/);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('a known snag it could not fix names the next step, and does NOT point at a blocker that was never filed', async () => {
|
|
807
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { last_move: failedAs('known_wedge', 'disk_full') }) });
|
|
808
|
+
await b.settle();
|
|
809
|
+
assert.match(b.panel(), /whoever runs that machine clears the snag/);
|
|
810
|
+
assert.doesNotMatch(b.panel(), /href="\/blockers"/, 'move-escalation files no blocker for this class');
|
|
811
|
+
});
|
|
@@ -92,7 +92,11 @@ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
|
|
|
92
92
|
get activeElement() { return boot.focused || null; },
|
|
93
93
|
};
|
|
94
94
|
const api = {
|
|
95
|
-
|
|
95
|
+
// The REAL client's shape: request(method, path, { body }). This fake once
|
|
96
|
+
// took the payload as the third argument, which is exactly the mistake the
|
|
97
|
+
// panel made — so the suite agreed with a write that sent nothing (task 1004458).
|
|
98
|
+
async request(method, u, opts) {
|
|
99
|
+
const reqBody = opts && opts.body;
|
|
96
100
|
requests.push({ method, url: u, body: reqBody });
|
|
97
101
|
const key = `${method} ${u}`;
|
|
98
102
|
const hit = answers[key];
|