@bongos/core 1.20.34 → 1.20.35

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.
@@ -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,
@@ -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
- const WEB_PLANS = ['starter', 'standard', 'pro', 'pro_plus', 'pro_max', 'pro_ultra'];
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
- var RENDER_WEB_PLANS = [['starter', 'Starter', 7], ['standard', 'Standard', 25], ['pro', 'Pro', 85]];
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] + ', about $' + p[2] + '/month';
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] + ' — about $' + p[2] + '/month');
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) st.renderStart = ev.target.value;
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.34",
3
+ "version": "1.20.35",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.34",
9
+ "version": "1.20.35",
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.34",
3
+ "version": "1.20.35",
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",
@@ -8284,5 +8284,18 @@
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
+ }
8287
8300
  ]
8288
8301
  }
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.34'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.35'; // 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');
@@ -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
- assert.match(md, /Do NOT close the ask/, 'and hands the close to a person');
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
+ });
@@ -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
- async request(method, u, reqBody) {
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];
@@ -0,0 +1,103 @@
1
+ // tests/hall_client_request_shape.mjs — every write a hall page sends through the
2
+ // generated client's escape hatch carries its payload UNDER `body` (task 1004458).
3
+ //
4
+ // `api.request(method, path, opts)` reads `opts.body` as the JSON payload and
5
+ // nothing else. Handing it the payload itself fails two quiet ways, and both
6
+ // shipped: `{ decision }` (the mingle buttons) and `{ reason }` / `{ version_id,
7
+ // … }` (the studio's verdicts) have no `body` key, so the request goes out EMPTY;
8
+ // and `{ body: text }` (the Collab reply) sends a bare JSON string, which the
9
+ // server's strict parser refuses as `bad_json`. Neither is visible until someone
10
+ // presses the button on the live site.
11
+ //
12
+ // So this RUNS the real client against a recording fetch to pin what `body`
13
+ // means, then scans every hall-side call for an options object whose keys are
14
+ // not options.
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const OPTION_KEYS = new Set(['body', 'query', 'headers', 'hasBody', 'cache']);
23
+
24
+ function client() {
25
+ const sent = [];
26
+ const win = {};
27
+ const src = fs.readFileSync(path.join(ROOT, 'clients', 'bongos-client', 'bongos-client.global.js'), 'utf8');
28
+ new Function('window', 'globalThis', 'self', src)(win, win, win);
29
+ const fetch = async (url, init) => { sent.push({ url, init }); return { ok: true, status: 200, text: async () => '{}' }; };
30
+ return { sent, api: win.BongosClient.createClient({ fetch, throwOnError: false }) };
31
+ }
32
+
33
+ test('the escape hatch sends opts.body as the payload, and nothing else', async () => {
34
+ const { sent, api } = client();
35
+ await api.request('POST', '/x', { body: { body: 'hello' } });
36
+ assert.equal(sent[0].init.body, '{"body":"hello"}', 'a nested object arrives as an object');
37
+ await api.request('POST', '/x', { decision: 'accepted' });
38
+ assert.equal(sent[1].init.body, undefined, 'a payload passed as options is DROPPED — this is the trap');
39
+ });
40
+
41
+ // Every `api.request('POST'|'PATCH'|'PUT'|'DELETE', …)` in a hall-side file.
42
+ function hallFiles() {
43
+ const out = [];
44
+ for (const mod of fs.readdirSync(path.join(ROOT, 'modules'))) {
45
+ const dir = path.join(ROOT, 'modules', mod, 'public');
46
+ if (!fs.existsSync(dir)) continue;
47
+ for (const f of fs.readdirSync(dir)) if (f.endsWith('.js')) out.push(path.join(dir, f));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // The top-level arguments of the call starting at `open` (the index of its "(").
53
+ function argsAt(src, open) {
54
+ const args = [];
55
+ let depth = 0; let cur = ''; let quote = null;
56
+ for (let i = open + 1; i < src.length; i++) {
57
+ const c = src[i];
58
+ if (quote) { cur += c; if (c === '\\') { cur += src[++i]; } else if (c === quote) quote = null; continue; }
59
+ if (c === '"' || c === "'" || c === '`') { quote = c; cur += c; continue; }
60
+ if ('({['.includes(c)) depth++;
61
+ if (')}]'.includes(c)) { if (depth === 0) { args.push(cur.trim()); return args; } depth--; }
62
+ if (c === ',' && depth === 0) { args.push(cur.trim()); cur = ''; continue; }
63
+ cur += c;
64
+ }
65
+ return args;
66
+ }
67
+
68
+ // The keys of an object literal's top level: `{ a, b: 1, ...c }` → ['a', 'b', '...'].
69
+ function topKeys(obj) {
70
+ const inner = obj.trim().replace(/^\{/, '').replace(/\}$/, '');
71
+ return argsAt(`(${inner})`, 0).filter(Boolean).map((part) => {
72
+ if (part.startsWith('...')) return '...';
73
+ const m = /^([A-Za-z_$][\w$]*)\s*(?::|$)/.exec(part);
74
+ return m ? m[1] : part;
75
+ });
76
+ }
77
+
78
+ test('no hall page hands the client a payload where it expects options', () => {
79
+ const bad = [];
80
+ let seen = 0;
81
+ for (const file of hallFiles()) {
82
+ const src = fs.readFileSync(file, 'utf8');
83
+ const re = /\bapi\.request\(\s*'(POST|PATCH|PUT|DELETE)'/g;
84
+ let m;
85
+ while ((m = re.exec(src))) {
86
+ seen++;
87
+ const args = argsAt(src, src.indexOf('(', m.index));
88
+ const where = `${path.relative(ROOT, file)}:${src.slice(0, m.index).split('\n').length}`;
89
+ const opts = args[2];
90
+ if (!opts) continue;
91
+ if (!opts.startsWith('{')) { bad.push(`${where} passes \`${opts}\` — wrap it as { body: … }`); continue; }
92
+ const unknown = topKeys(opts).filter((k) => !OPTION_KEYS.has(k));
93
+ if (unknown.length) bad.push(`${where} passes ${unknown.join(', ')} as options — they are dropped; nest them under body`);
94
+ }
95
+ }
96
+ assert.ok(seen > 10, `the scan found the calls it is guarding (${seen})`);
97
+ assert.deepEqual(bad, [], bad.join('\n'));
98
+ });
99
+
100
+ test('the Collab reply nests its `body` field inside the payload', () => {
101
+ const js = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'collab.js'), 'utf8');
102
+ assert.match(js, /\/replies`, \{ body: \{ body: v\.body \} \}\)/, 'a bare string is refused by the server as bad_json');
103
+ });
@@ -130,7 +130,13 @@ function bootStudio({ answers = {}, signedIn = true } = {}) {
130
130
  execCommand() {},
131
131
  };
132
132
  const client = {
133
- request: async (method, url, body) => {
133
+ // The real client takes an OPTIONS object and sends `opts.body` as the JSON
134
+ // payload (task 1004458 moved every hall write onto that shape). Record the
135
+ // payload, not the options, so assertions read what the server would receive.
136
+ request: async (method, url, opts) => {
137
+ // Strictly `opts.body`, like the real client: a bare payload passed as the
138
+ // third argument arrives as undefined, which is the dropped-payload bug 1004458 fixed.
139
+ const body = opts ? opts.body : undefined;
134
140
  requests.push({ method, url, body });
135
141
  const key = `${method} ${url.replace(/^\/api\/bongos/, '')}`;
136
142
  if (key === 'GET /me') return signedIn ? ok({ builder: { id: ME, display_name: 'Artist A', rank: 'metic' } }) : { ok: false, status: 401, data: {} };
@@ -239,7 +239,11 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
239
239
  return els.get(id);
240
240
  };
241
241
  const calls = [];
242
- const request = async (method, url, body) => {
242
+ // The REAL client's shape: request(method, path, { body }). This fake once took
243
+ // the payload as the third argument, which is exactly the mistake the studio
244
+ // made — so every verdict here was green while each one sent nothing (task 1004458).
245
+ const request = async (method, url, opts) => {
246
+ const body = opts && opts.body;
243
247
  calls.push({ method, url, body });
244
248
  if (method !== 'GET') {
245
249
  const custom = onWrite && onWrite({ method, url, body });
@@ -276,8 +276,10 @@ test('the settle refusal no longer lets you enumerate ids', () => {
276
276
  const from = routes.indexOf("'PATCH /help-requests/:id'");
277
277
  const patch = routes.slice(from, routes.indexOf('help_request_update_failed', from));
278
278
  assert.ok(!/getHelpRequest/.test(patch), 'no existence lookup in the refusal path');
279
+ // One refusal per WRITE: the settle's, and since task 1004458 the reopen's.
280
+ // Each covers absent / not yours / wrong state in one sentence.
279
281
  const reasons = patch.match(/reason: '[^']+'/g) || [];
280
- assert.equal(reasons.length, 1, `exactly one refusal reason, got ${reasons}`);
282
+ assert.equal(reasons.length, 2, `exactly one refusal reason per write, got ${reasons}`);
281
283
  });
282
284
 
283
285
  test('both reads are capped', () => {
@@ -504,3 +506,54 @@ test('a reply to an ask that never existed is a named 404, not an FK 500', () =>
504
506
  const line = routes.slice(routes.lastIndexOf('router.post', idx), idx);
505
507
  assert.match(line, /api\.requireBuilder/, 'signed in, like every other route here');
506
508
  });
509
+
510
+ // ---- reopen (task 1004458) ------------------------------------------------
511
+ // RUN against a recording pool, like the settle tests above: the rule is the
512
+ // WHERE, and a source grep would agree with a WHERE that had lost a clause.
513
+ async function reopenSql(args) {
514
+ const calls = [];
515
+ const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 1 }] }; } };
516
+ const row = await help.reopenHelpRequest(args, { pool });
517
+ return { ...calls[0], row };
518
+ }
519
+
520
+ test('only the ASKER reopens, and only a settled ask', async () => {
521
+ const c = await reopenSql({ id: 5, builderId: 7 });
522
+ const where = c.sql.slice(c.sql.indexOf('WHERE'), c.sql.indexOf('RETURNING'));
523
+ assert.match(where, /requested_by = \$2/, 'the asker, bound, in the WHERE');
524
+ assert.ok(!/needs_builder_id|needs_craft/.test(where), 'the addressee has no branch here');
525
+ assert.ok(!/\bOR\b/.test(where), `a pure conjunction, so no clause can be bypassed: ${where}`);
526
+ assert.match(where, /status <> 'open'/, 'an open ask is not reopened again');
527
+ assert.deepEqual(c.params, [5, 7]);
528
+ });
529
+
530
+ test('reopening clears who settled it and marks it reopened', async () => {
531
+ const c = await reopenSql({ id: 5, builderId: 7 });
532
+ assert.match(c.sql, /status = 'open'/);
533
+ assert.match(c.sql, /settled_by = NULL/, 'it no longer reads as closed by someone');
534
+ assert.match(c.sql, /settled_reason = NULL/, 'nor as closed by the ledger');
535
+ assert.match(c.sql, /reopened_at = now\(\)/, 'the fact the ledger settle reads to leave it alone');
536
+ });
537
+
538
+ test('a REOPENED ask is never closed again by its task having shipped', async () => {
539
+ const calls = [];
540
+ const pool = { query: async (sql) => { calls.push(sql); return { rows: [] }; } };
541
+ await help.settleFromLedger({ pool });
542
+ assert.match(calls[0], /h\.reopened_at IS NULL/, 'the asker said the shipped task did not answer it');
543
+ });
544
+
545
+ test('PATCH routes status open to the asker-only reopen, never to the settle', () => {
546
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
547
+ const idx = routes.indexOf("'PATCH /help-requests/:id'");
548
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
549
+ assert.match(body, /enum: \['answered', 'withdrawn', 'open'\]/);
550
+ assert.match(body, /req\.body\.status === 'open'[\s\S]*?reopenHelpRequest\(\{ id, builderId: req\.builder\.id \}\)/,
551
+ 'the reopen takes the caller from the session, and no crafts at all');
552
+ assert.ok(body.indexOf('reopenHelpRequest') < body.indexOf('setHelpRequestStatus'), 'and returns before the settle runs');
553
+ });
554
+
555
+ test('the reopen migration adds the column and alters no core table', () => {
556
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_016_help_request_reopen.sql');
557
+ assert.match(sql, /ALTER TABLE lifecycle_help_requests\s+ADD COLUMN IF NOT EXISTS reopened_at timestamptz/);
558
+ assert.ok(!/ALTER TABLE (?!lifecycle_)/.test(sql));
559
+ });
@@ -283,6 +283,48 @@ test('Next needs a complete answer: a mode, then a start command for a native ru
283
283
  assert.equal(w.validateRender(), true, 'docker needs no commands');
284
284
  });
285
285
 
286
+ // task 1004457 — found live: the ask stayed on screen after the command was typed.
287
+ test('typing a start command clears the "how your app starts" ask at once', async () => {
288
+ const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
289
+ w.typeKey(KEY);
290
+ await w.check();
291
+ await w.pickWorkspace(0);
292
+ w.pickService({ value: 'new' });
293
+ assert.equal(w.validateRender(), false);
294
+ assert.match(w.$('h-render').className, /err/);
295
+ w.$('f-renderStart').value = ' ';
296
+ w.$('renderNew').fire('input', { target: w.$('f-renderStart') });
297
+ assert.match(w.$('h-render').textContent, /how your app starts/, 'blank is still no answer');
298
+ w.$('f-renderStart').value = 'npm start';
299
+ w.$('renderNew').fire('input', { target: w.$('f-renderStart') });
300
+ assert.doesNotMatch(w.$('h-render').className, /err/);
301
+ assert.doesNotMatch(w.$('h-render').textContent, /how your app starts/);
302
+ });
303
+
304
+ test('the empty command boxes read as examples, not as filled-in values', () => {
305
+ assert.match(HUB, /id="f-renderBuild"[^>]*placeholder="for example: npm ci"/);
306
+ assert.match(HUB, /id="f-renderStart"[^>]*placeholder="for example: npm start"/);
307
+ });
308
+
309
+ // task 1004457 — owner call 2026-09-30: offer Render's Free web plan, never preselected.
310
+ test('Free is on the plan list with its catch spelled out, and Starter stays the default', async () => {
311
+ const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
312
+ w.typeKey(KEY);
313
+ await w.check();
314
+ await w.pickWorkspace(0);
315
+ w.pickService({ value: 'new' });
316
+ const opts = w.$('f-renderPlan').children;
317
+ assert.deepEqual(opts.map((o) => o.value), ['free', 'starter', 'standard', 'pro']);
318
+ assert.match(opts[0].textContent, /^Free — \$0, but it sleeps when idle/);
319
+ assert.equal(opts.find((o) => o.selected).value, 'starter', 'Free is never the preselected plan');
320
+ w.state.renderPlan = 'free'; w.state.renderStart = 'npm start';
321
+ assert.equal(w.renderRequestBody().web_plan, 'free');
322
+ const dbOpts = w.$('f-renderDbPlan').children.map((o) => o.value);
323
+ assert.equal(dbOpts.includes('free'), false, 'the database stays paid-only');
324
+ const r = load({ draft: { appPlatform: 'render', renderOwnerName: 'Mine', renderMode: 'greenfield', renderRuntime: 'node', renderPlan: 'free' } });
325
+ assert.equal(r.appRow(), 'Render, workspace Mine — a new app (Node.js, Free, $0, but it sleeps when idle and takes about a minute to wake up), no database. Render bills you directly.');
326
+ });
327
+
286
328
  test('what Create sends: greenfield carries the build and the plans, a link only the service', async () => {
287
329
  const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
288
330
  w.typeKey(KEY);
@@ -116,6 +116,13 @@ await ta('a missing project is 404', async () => {
116
116
  assert.equal((await post(PATH, GREEN)).status, 404);
117
117
  });
118
118
 
119
+ await ta('Render\'s Free web plan is accepted and recorded (task 1004457)', async () => {
120
+ reset();
121
+ const r = await post(PATH, { ...GREEN, web_plan: 'free' });
122
+ assert.equal(r.status, 200, r.raw);
123
+ assert.equal(enqueueCalls[0].appHost.webPlan, 'free');
124
+ });
125
+
119
126
  console.log('\nthe shape is checked first:');
120
127
  for (const [name, body, re] of [
121
128
  ['a key that is not a Render key', { ...GREEN, key: 'ghp_notrender' }, /Render API key/],