@bongos/core 1.19.272 → 1.19.274

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.
@@ -560,7 +560,7 @@ module.exports = function buildTasksRouter() {
560
560
  description: { type: 'string', maxLength: LIMITS.DESCRIPTION },
561
561
  status: { type: 'string', maxLength: 32 },
562
562
  touches: { type: 'array', maxItems: 500, itemsType: 'string' },
563
- est_minutes: { type: 'integer', min: 0, max: 100000 },
563
+ est_minutes: { type: 'integer', min: 0, max: 100000 }, // AGENT-minutes — see est-advisory.js (task 1003148)
564
564
  est_cost_usd: { type: 'number', min: 0 },
565
565
  manual_degree: { type: 'integer', min: TASK_FIELD_BOUNDS.manual_degree.min, max: TASK_FIELD_BOUNDS.manual_degree.max },
566
566
  priority: { type: 'integer', min: TASK_FIELD_BOUNDS.priority.min, max: TASK_FIELD_BOUNDS.priority.max },
@@ -757,7 +757,7 @@ module.exports = function buildTasksRouter() {
757
757
  needs_migration: needsMigration,
758
758
  criteria,
759
759
  suggested_criteria,
760
- goal_advisory,
760
+ goal_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
761
761
  });
762
762
  } catch (err) {
763
763
  console.error('[gds] POST /tasks', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.272",
3
+ "version": "1.19.274",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.272",
9
+ "version": "1.19.274",
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.19.272",
3
+ "version": "1.19.274",
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/src/module-api.js CHANGED
@@ -971,8 +971,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
971
971
  // landed since 1.19.270 with no explicit bump. run 32437463473. (task 1002620)
972
972
  // 1.19.272 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
973
973
  // landed since 1.19.271 with no explicit bump. run 32438140524. (task 1002620)
974
+ // 1.19.273 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
975
+ // landed since 1.19.272 with no explicit bump. run 32441024959. (task 1002620)
976
+ // 1.19.274 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
977
+ // landed since 1.19.273 with no explicit bump. run 32490024209. (task 1002620)
974
978
  // ---------------------------------------------------------------------------
975
- const CORE_VERSION = '1.19.272'; // CI auto-patch carrier (ADR 0161); see changelog above
979
+ const CORE_VERSION = '1.19.274'; // CI auto-patch carrier (ADR 0161); see changelog above
976
980
 
977
981
  // A namespaced logger so a module's log lines are attributable + consistent.
978
982
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,183 @@
1
+ // tests/est_advisory.mjs — the estimate-SCALE advisory (task 1003148).
2
+ //
3
+ // THE HOLE. Nothing tells a task author their estimate is on the wrong SCALE.
4
+ // Across 1,173 shipped tasks carrying both est_minutes and actual_minutes the
5
+ // median actual/est ratio is 0.100, uniform across every kind with a real sample
6
+ // (feature 0.078/n=489, bug 0.150/n=214, infra 0.097/n=144, cleanup 0.133/n=89).
7
+ // Authors write human-hours-shaped numbers for work an agent does in minutes.
8
+ //
9
+ // pms_calibrated_minutes (migration 015) already divides by that median, so the
10
+ // board looks sane while the STORED number stays wrong and every new task re-adds
11
+ // the error — a permanent correction with no feedback loop. It misleads humans
12
+ // directly too: GET /tasks/:id leads with the raw field, and a session quoted task
13
+ // 1002572 to the owner as "60 min" when its calibrated value is 9.
14
+ //
15
+ // What we prove (PURE — no DB, no network):
16
+ // 1. An out-of-scale estimate is flagged, with the calibrated equivalent, the
17
+ // ratio, and the SAMPLE SIZE it rests on — evidence, not an assertion.
18
+ // 2. An in-scale estimate returns null. Silence is the common case; an advisory
19
+ // that fires on every create is noise and gets ignored.
20
+ // 3. It never AUTO-CORRECTS. The submitted value is reported, not replaced —
21
+ // silently rewriting the author's number is worse than the current silence,
22
+ // because then nobody learns the scale either.
23
+ // 4. The ratio source mirrors pms_calibrated_minutes' own thresholds (>=5 for a
24
+ // kind, else the overall population), so advisory and calibration can never
25
+ // disagree about which number applies — and it says WHICH basis it used.
26
+ // 5. Missing / hostile / thin-sample input yields null, never a guess.
27
+ //
28
+ // Run: node tests/est_advisory.mjs
29
+
30
+ import { strict as assert } from 'node:assert';
31
+ import fs from 'node:fs';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const { adviseEstimate, pickRatio, buildEstAdvisory, MIN_MATERIAL_DELTA, SCALE_OK_RATIO } =
36
+ require('../modules/lifecycle/est-advisory.js');
37
+
38
+ let passed = 0;
39
+ let failed = 0;
40
+ function t(name, fn) {
41
+ try { const r = fn(); if (r && r.then) return r.then(
42
+ () => { passed++; console.log(` ok ${name}`); },
43
+ (e) => { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); });
44
+ passed++; console.log(` ok ${name}`);
45
+ } catch (e) { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
46
+ return Promise.resolve();
47
+ }
48
+
49
+ // The real shape pms_calibration_summary() returns, with the real medians.
50
+ const SUMMARY = [
51
+ { kind: 'feature', median_ratio: '0.0780', sample_size: 489 },
52
+ { kind: 'bug', median_ratio: '0.1500', sample_size: 214 },
53
+ { kind: 'infra', median_ratio: '0.0970', sample_size: 144 },
54
+ { kind: 'cleanup', median_ratio: '0.1330', sample_size: 89 },
55
+ { kind: 'blocker-resolution', median_ratio: '0.1280', sample_size: 2 }, // below the kind floor
56
+ ];
57
+
58
+ // ---- 1. the flag, with its evidence ----
59
+
60
+ await t('an out-of-scale bug estimate is flagged with the calibrated equivalent', () => {
61
+ const a = adviseEstimate({ estMinutes: 60, kind: 'bug', summary: SUMMARY, taskId: 1002572 });
62
+ assert.ok(a, 'must advise');
63
+ assert.equal(a.submitted_minutes, 60);
64
+ assert.equal(a.calibrated_minutes, 9, '60 * 0.15 = 9 — the real number for task 1002572');
65
+ assert.equal(a.basis, 'bug');
66
+ assert.equal(a.sample_size, 214, 'quotes the sample the claim rests on');
67
+ assert.equal(a.median_ratio, 0.15);
68
+ assert.equal(a.kind_factor, 6.7, 'the historical factor is a property of the KIND');
69
+ assert.match(a.message, /AGENT-minutes/, 'names the unit, which is the actual defect');
70
+ assert.match(a.next_step, /PATCH \/tasks\/1002572 \{"est_minutes": 9\}/, 'gives the exact fix');
71
+ });
72
+
73
+ await t('the advisory reports, never auto-corrects', () => {
74
+ const a = adviseEstimate({ estMinutes: 300, kind: 'bug', summary: SUMMARY, taskId: 5 });
75
+ assert.equal(a.submitted_minutes, 300, 'the submitted value is preserved verbatim');
76
+ assert.equal(a.calibrated_minutes, 45);
77
+ // Nothing in the advisory mutates anything — it is a pure verdict object.
78
+ assert.equal(typeof a, 'object');
79
+ assert.ok(!('task' in a), 'no task payload — it cannot rewrite the row');
80
+ });
81
+
82
+ // ---- 2. silence is the common case ----
83
+
84
+ await t('an already-agent-scale estimate returns null (no noise on a good create)', () => {
85
+ // The trigger is a MATERIAL translation, not a ratio — a ratio is constant by
86
+ // construction (see MIN_MATERIAL_DELTA) and would fire on correct estimates too.
87
+ assert.equal(adviseEstimate({ estMinutes: 9, kind: 'bug', summary: SUMMARY }), null,
88
+ '9 min for a bug is already agent-scale — translating it says nothing useful');
89
+ assert.equal(adviseEstimate({ estMinutes: 2, kind: 'bug', summary: SUMMARY }), null);
90
+ assert.equal(adviseEstimate({ estMinutes: 4, kind: 'feature', summary: SUMMARY }), null);
91
+ });
92
+
93
+ await t('the loop closes at the POPULATION level, not per task', () => {
94
+ // The tempting claim — "adopt the suggestion and it goes quiet" — is FALSE, and
95
+ // asserting it is how a wrong comment would have shipped. The calibration is a
96
+ // constant multiply, so the adopted value gets translated too:
97
+ const loud = adviseEstimate({ estMinutes: 300, kind: 'bug', summary: SUMMARY });
98
+ assert.ok(loud, 'a human-hours estimate is flagged');
99
+ assert.ok(adviseEstimate({ estMinutes: loud.calibrated_minutes, kind: 'bug', summary: SUMMARY }),
100
+ 'adopting the value does NOT silence it — the multiplier re-applies');
101
+
102
+ // What DOES retire the advisory: the population correcting, which raises the
103
+ // median ratio toward 1.0 (pms_calibration_summary recomputes from shipped
104
+ // rows). Then even a large estimate translates to nearly itself.
105
+ const corrected = [{ kind: 'bug', median_ratio: '0.9500', sample_size: 400 }];
106
+ assert.equal(adviseEstimate({ estMinutes: 300, kind: 'bug', summary: corrected }), null,
107
+ 'once estimates and actuals agree, the advisory has nothing to say — at ANY size');
108
+ assert.equal(adviseEstimate({ estMinutes: 5000, kind: 'bug', summary: corrected }), null,
109
+ 'and size alone must never trigger it — this is a SCALE advisory, not a big-number one');
110
+ assert.ok(MIN_MATERIAL_DELTA > 0 && SCALE_OK_RATIO > 0 && SCALE_OK_RATIO < 1);
111
+ });
112
+
113
+ // ---- 3/4. which ratio, and why ----
114
+
115
+ await t('pickRatio uses the KIND when it has enough samples, and says so', () => {
116
+ const p = pickRatio({ kind: 'feature', summary: SUMMARY });
117
+ assert.equal(p.basis, 'feature');
118
+ assert.equal(p.sample_size, 489);
119
+ assert.equal(p.ratio, 0.078);
120
+ });
121
+
122
+ await t('pickRatio falls back off a THIN kind rather than quoting 2 samples', () => {
123
+ // blocker-resolution has n=2, below the >=5 floor pms_calibrated_minutes uses.
124
+ const p = pickRatio({ kind: 'blocker-resolution', summary: SUMMARY });
125
+ assert.notEqual(p.basis, 'blocker-resolution', 'must not quote a 2-sample median as authority');
126
+ assert.equal(p.basis, 'all kinds');
127
+ assert.ok(p.sample_size > 900, 'the overall basis carries the whole population');
128
+ });
129
+
130
+ await t('an unknown kind still gets the overall basis', () => {
131
+ const p = pickRatio({ kind: 'nonexistent-kind', summary: SUMMARY });
132
+ assert.equal(p.basis, 'all kinds');
133
+ });
134
+
135
+ // ---- 5. fail closed ----
136
+
137
+ await t('no estimate, no history, or hostile input ⇒ null, never a guess', () => {
138
+ assert.equal(adviseEstimate({ estMinutes: null, kind: 'bug', summary: SUMMARY }), null);
139
+ assert.equal(adviseEstimate({ estMinutes: 0, kind: 'bug', summary: SUMMARY }), null);
140
+ assert.equal(adviseEstimate({ estMinutes: -5, kind: 'bug', summary: SUMMARY }), null);
141
+ assert.equal(adviseEstimate({ estMinutes: 'sixty', kind: 'bug', summary: SUMMARY }), null);
142
+ // Thin overall population: nothing to claim.
143
+ assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [{ kind: 'bug', median_ratio: '0.15', sample_size: 3 }] }), null);
144
+ assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [] }), null);
145
+ assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: null }), null);
146
+ // Garbage rows must not crash or be trusted.
147
+ assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [{ kind: 'bug', median_ratio: 'x', sample_size: 'y' }] }), null);
148
+ });
149
+
150
+ await t('buildEstAdvisory is NON-BLOCKING — a db failure yields null, not a throw', async () => {
151
+ const thrower = { calibrationSummary: async () => { throw new Error('pg down'); } };
152
+ assert.equal(await buildEstAdvisory({ db: thrower, task: { id: 1, kind: 'bug', est_minutes: 600 } }), null);
153
+ // No estimate on the task at all: nothing to judge, and no db call needed.
154
+ let called = false;
155
+ const spy = { calibrationSummary: async () => { called = true; return SUMMARY; } };
156
+ assert.equal(await buildEstAdvisory({ db: spy, task: { id: 1, kind: 'bug', est_minutes: null } }), null);
157
+ assert.equal(called, false, 'must not query when there is no estimate to judge');
158
+ });
159
+
160
+ await t('buildEstAdvisory returns the advisory on the happy path', async () => {
161
+ const db = { calibrationSummary: async () => SUMMARY };
162
+ const a = await buildEstAdvisory({ db, task: { id: 77, kind: 'feature', est_minutes: 90 } });
163
+ assert.ok(a);
164
+ assert.equal(a.calibrated_minutes, 7, '90 * 0.078 = 7');
165
+ assert.equal(a.basis, 'feature');
166
+ assert.match(a.next_step, /PATCH \/tasks\/77/);
167
+ });
168
+
169
+ await t('the route attaches est_advisory to the 201 body, non-blocking', () => {
170
+ // Mirrors the goal_advisory contract test. The call is folded into the response
171
+ // literal because modules/lifecycle/routes/tasks.js is over the size ratchet's
172
+ // cap and main sits exactly AT the baseline — so this feature had to land at
173
+ // net-zero lines in that file. Asserting the shape keeps the fold honest.
174
+ const src = fs.readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
175
+ const body201 = (src.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
176
+ assert.ok(body201, 'found the 201 response body');
177
+ assert.match(body201, /est_advisory:/, 'est_advisory rides the create response');
178
+ assert.match(body201, /buildEstAdvisory\(\{ db, task \}\)/, 'and delegates rather than inlining logic');
179
+ });
180
+
181
+ console.log('');
182
+ console.log(`est_advisory: ${passed} passed, ${failed} failed`);
183
+ process.exit(failed === 0 ? 0 : 1);
@@ -117,7 +117,12 @@ await test('db.js resolves the catch-all through generalGoalTitle — no second
117
117
 
118
118
  await test('the route delegates to buildGoalAdvisory in one line — no inline logic to drift', () => {
119
119
  assert.match(tasksSrc, /const goal_advisory = await buildGoalAdvisory\(\{ db, versionId: body\.version_id, task \}\);/);
120
- assert.match(tasksSrc, /goal_advisory,\s*\}\);/, 'included in the 201 body');
120
+ // task 1003148: was /goal_advisory,\s*\}\);/ — it required goal_advisory to be
121
+ // the LAST key, which the sibling est_advisory broke. The assertion's intent is
122
+ // "present in the 201 body", so assert that, not the key order.
123
+ const body201 = (tasksSrc.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
124
+ assert.ok(body201, 'found the 201 response body');
125
+ assert.match(body201, /goal_advisory,/, 'included in the 201 body');
121
126
  });
122
127
 
123
128
  // ---- buildGoalAdvisory: the real function, driven with a stub db ----
@@ -0,0 +1,264 @@
1
+ // tests/hall_error_envelope.mjs — task 1002462.
2
+ //
3
+ // THE DEFECT. The server returns the ADR 0116 envelope via res.fail():
4
+ // `{ error: { code, message, details } }`. The code is NESTED, so a bare
5
+ // `data.error === 'CODE'` test compares a string to an OBJECT and silently never
6
+ // matches — every switch falls through to its generic default. The builder gets
7
+ // "Could not complete that (409). Try again." for a refusal the server described
8
+ // precisely.
9
+ //
10
+ // It survived because it degrades into a wrong-but-PLAUSIBLE toast rather than a
11
+ // visible break, so nothing failed and no smoke test could catch it. The same
12
+ // defect appeared independently on four hall surfaces.
13
+ //
14
+ // The fix puts the readers in dom-utils.js — the shared toolkit every hall page
15
+ // already loads first — so a fifth copy is never the easiest option.
16
+ //
17
+ // What we prove, executing the REAL files in a vm (the tests/hall_signout.mjs and
18
+ // goal_criterion_confirm_ui.mjs precedent — there is no jsdom here):
19
+ // 1. dom-utils' errCode / errMessage / errDetails read BOTH wire shapes and
20
+ // survive hostile input. This is now the single point of failure for four
21
+ // surfaces, so it is tested directly rather than through a page.
22
+ // 2. END TO END on the real task.js: a 409 carrying the envelope renders the
23
+ // SERVER'S OWN sentence in the toast, not the generic fallback. This is the
24
+ // user-visible regression, driven through a real button click.
25
+ // 3. Neither consumer still reads the flat shape anywhere.
26
+ //
27
+ // Run: node tests/hall_error_envelope.mjs
28
+
29
+ import assert from 'node:assert/strict';
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import vm from 'node:vm';
33
+ import { fileURLToPath } from 'node:url';
34
+
35
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
37
+
38
+ let passed = 0;
39
+ let failed = 0;
40
+ function t(name, fn) {
41
+ try {
42
+ const r = fn();
43
+ if (r && typeof r.then === 'function') {
44
+ return r.then(
45
+ () => { passed++; console.log(` ok ${name}`); },
46
+ (e) => { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
47
+ );
48
+ }
49
+ passed++; console.log(` ok ${name}`);
50
+ } catch (e) {
51
+ failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`);
52
+ }
53
+ return Promise.resolve();
54
+ }
55
+
56
+ // ---- 1. the shared readers, from the real dom-utils.js ----------------------
57
+
58
+ function loadOTB() {
59
+ const sandbox = {
60
+ window: {},
61
+ document: {
62
+ createElement: () => ({ style: {}, classList: { add() {}, remove() {} }, appendChild() {}, remove() {} }),
63
+ body: { appendChild() {} },
64
+ querySelector: () => null,
65
+ querySelectorAll: () => [],
66
+ getElementById: () => null,
67
+ },
68
+ setTimeout, clearTimeout, console,
69
+ };
70
+ sandbox.globalThis = sandbox;
71
+ vm.createContext(sandbox);
72
+ vm.runInContext(fs.readFileSync(path.join(HALL, 'dom-utils.js'), 'utf8'), sandbox, { filename: 'dom-utils.js' });
73
+ return sandbox.window.OTB;
74
+ }
75
+
76
+ await t('dom-utils exports the envelope readers on window.OTB', () => {
77
+ const OTB = loadOTB();
78
+ for (const fn of ['errCode', 'errMessage', 'errDetails']) {
79
+ assert.equal(typeof OTB[fn], 'function', `${fn} must be on the shared toolkit`);
80
+ }
81
+ });
82
+
83
+ await t('errCode reads the ADR 0116 envelope AND the legacy flat shape', () => {
84
+ const { errCode } = loadOTB();
85
+ assert.equal(errCode({ error: { code: 'ALREADY_CLAIMED', message: 'x' } }), 'ALREADY_CLAIMED');
86
+ assert.equal(errCode({ error: 'ALREADY_CLAIMED' }), 'ALREADY_CLAIMED', 'an older server must still map');
87
+ assert.equal(errCode({ code: 'ALREADY_CLAIMED' }), 'ALREADY_CLAIMED');
88
+ });
89
+
90
+ await t('errCode never returns an object — the whole bug in one assertion', () => {
91
+ const { errCode } = loadOTB();
92
+ const code = errCode({ error: { code: 'TOUCHES_CONFLICT', details: { conflicts: [] } } });
93
+ assert.equal(typeof code, 'string');
94
+ assert.notEqual(typeof code, 'object', 'returning the object is what made every switch case miss');
95
+ });
96
+
97
+ await t('errMessage prefers the envelope message over a top-level one', () => {
98
+ const { errMessage } = loadOTB();
99
+ assert.equal(errMessage({ error: { code: 'c', message: 'the real sentence' } }), 'the real sentence');
100
+ assert.equal(errMessage({ message: 'legacy sentence' }), 'legacy sentence');
101
+ assert.equal(errMessage({ error: 'c' }), '');
102
+ });
103
+
104
+ await t('errDetails takes error.details, else falls back to the BODY (flat shape)', () => {
105
+ const { errDetails } = loadOTB();
106
+ assert.deepEqual(errDetails({ error: { code: 'c', details: { conflicts: [{ title: 'A' }] } } }).conflicts, [{ title: 'A' }]);
107
+ // On the flat shape the extra fields sat at the top level.
108
+ assert.deepEqual(errDetails({ error: 'c', conflicts: [{ title: 'B' }] }).conflicts, [{ title: 'B' }]);
109
+ });
110
+
111
+ await t('the readers survive hostile / empty input without throwing', () => {
112
+ const { errCode, errMessage, errDetails } = loadOTB();
113
+ for (const bad of [undefined, null, {}, { error: null }, { error: 0 }, { error: [] }, 'str', 42]) {
114
+ assert.equal(typeof errCode(bad), 'string', `errCode(${JSON.stringify(bad)})`);
115
+ assert.equal(typeof errMessage(bad), 'string');
116
+ assert.equal(typeof errDetails(bad), 'object');
117
+ }
118
+ });
119
+
120
+ // ---- 2. end to end through the REAL task.js --------------------------------
121
+
122
+ // Minimal DOM: only what task.js touches (getElementById/querySelector by id,
123
+ // addEventListener, innerHTML, textContent). Elements are plain objects with a
124
+ // click() that invokes the registered handler.
125
+ // The DOM handlers call runLifecycle without returning its promise, so the toast
126
+ // lands several microtasks after the click. Flush rather than guess a timeout.
127
+ async function flush(n = 12) {
128
+ for (let i = 0; i < n; i += 1) await new Promise((r) => setImmediate(r));
129
+ }
130
+
131
+ function makeEl(id) {
132
+ return {
133
+ id, innerHTML: '', textContent: '', disabled: false, dataset: {}, style: {},
134
+ _handlers: {},
135
+ addEventListener(ev, fn) { this._handlers[ev] = fn; },
136
+ click() { return this._handlers.click && this._handlers.click(); },
137
+ querySelector: () => null,
138
+ querySelectorAll: () => [],
139
+ classList: { add() {}, remove() {}, toggle() {} },
140
+ setAttribute() {}, removeAttribute() {}, appendChild() {}, remove() {},
141
+ };
142
+ }
143
+
144
+ async function bootTaskPage({ task, failWith }) {
145
+ const els = new Map();
146
+ const el = (id) => {
147
+ const key = String(id).replace(/^#/, '');
148
+ if (!els.has(key)) els.set(key, makeEl(key));
149
+ return els.get(key);
150
+ };
151
+ const toasts = [];
152
+ const document = {
153
+ _ready: null,
154
+ // renderTask does document.title.split(' — ') — it must be a real string.
155
+ title: '#0 — task — Cloud Bongos',
156
+ addEventListener(ev, fn) { if (ev === 'DOMContentLoaded') this._ready = fn; },
157
+ getElementById: (id) => el(id),
158
+ querySelector: (sel) => el(sel),
159
+ querySelectorAll: () => [],
160
+ createElement: () => makeEl('tmp'),
161
+ body: makeEl('body'),
162
+ };
163
+ const sandbox = {
164
+ document,
165
+ console,
166
+ setTimeout,
167
+ clearTimeout,
168
+ location: { hostname: 'builders.example.com', pathname: `/task/${task.id}`, href: '', assign() {} },
169
+ window: {
170
+ OTBBranding: { taskCreditValue: () => null, taskCreditLabel: () => 'credits' },
171
+ confirm: () => true,
172
+ location: { hostname: 'builders.example.com', pathname: `/task/${task.id}`, assign() {} },
173
+ // The generated typed client's result mode: { status, ok, data }.
174
+ BongosClient: {
175
+ createClient: () => ({
176
+ request: async (method, urlPath) => {
177
+ if (method === 'GET' && urlPath.includes('/me')) {
178
+ return { ok: true, status: 200, data: { builder: { id: 1, rank: 'archon' } } };
179
+ }
180
+ if (method === 'GET') return { ok: true, status: 200, data: { task, last_claim: null } };
181
+ // The lifecycle POST — refuse it with the shape under test.
182
+ return { ok: false, status: failWith.status, data: failWith.body };
183
+ },
184
+ }),
185
+ },
186
+ },
187
+ };
188
+ sandbox.window.document = document;
189
+ sandbox.globalThis = sandbox;
190
+ vm.createContext(sandbox);
191
+ // dom-utils FIRST, in this same context — exactly the real page's script order,
192
+ // and required because dom-utils' `$` closes over the document it sees at
193
+ // definition time.
194
+ vm.runInContext(fs.readFileSync(path.join(HALL, 'dom-utils.js'), 'utf8'), sandbox, { filename: 'dom-utils.js' });
195
+ const realToast = sandbox.window.OTB.toast;
196
+ sandbox.window.OTB = { ...sandbox.window.OTB, toast: (m) => { toasts.push(String(m)); } };
197
+ void realToast;
198
+ vm.runInContext(fs.readFileSync(path.join(HALL, 'task.js'), 'utf8'), sandbox, { filename: 'task.js' });
199
+ assert.ok(document._ready, 'task.js registered a DOMContentLoaded handler');
200
+ await document._ready();
201
+ return { els, el, toasts };
202
+ }
203
+
204
+ const VERIFY_TASK = { id: 4242, kind: 'verify', title: 'a verify task', status: 'completed', description: 'x' };
205
+
206
+ await t('END TO END: a 409 envelope renders the SERVER\'S sentence, not the generic fallback', async () => {
207
+ const serverSentence = "Task #4242 is a verification task (kind='verify') — confirm it only AFTER the verification ran.";
208
+ const { el, toasts } = await bootTaskPage({
209
+ task: VERIFY_TASK,
210
+ failWith: {
211
+ status: 409,
212
+ body: { error: { code: 'verify_attestation_required', message: serverSentence, details: {} } },
213
+ },
214
+ });
215
+ const btn = el('confirm-btn');
216
+ assert.ok(btn._handlers.click, 'the confirm control rendered and is wired');
217
+ await btn.click();
218
+ await flush();
219
+ const shown = toasts.join(' | ');
220
+ assert.ok(
221
+ shown.includes(serverSentence),
222
+ `the toast must carry the server's own sentence. got: ${shown}`
223
+ );
224
+ assert.ok(
225
+ !/Could not complete that/.test(shown),
226
+ `the generic fallback is exactly the bug — got: ${shown}`
227
+ );
228
+ });
229
+
230
+ await t('END TO END: the legacy FLAT shape still maps (older server)', async () => {
231
+ const { el, toasts } = await bootTaskPage({
232
+ // status 'confirmed' + a supervisor is what renders the SHIP control.
233
+ task: { ...VERIFY_TASK, kind: 'feature', status: 'confirmed' },
234
+ failWith: { status: 409, body: { error: 'task_not_confirmed' } },
235
+ });
236
+ const btn = el('ship-btn');
237
+ assert.ok(btn._handlers.click, 'the ship control rendered');
238
+ await btn.click();
239
+ await flush();
240
+ const shown = toasts.join(' | ');
241
+ assert.match(shown, /only be shipped once it is confirmed/, `got: ${shown}`);
242
+ assert.ok(!/Could not complete that/.test(shown), `got: ${shown}`);
243
+ });
244
+
245
+ // ---- 3. no flat reads left in either consumer -------------------------------
246
+
247
+ await t('neither work.js nor task.js reads the flat error shape any more', () => {
248
+ for (const rel of ['work.js', 'task.js']) {
249
+ const src = fs.readFileSync(path.join(HALL, rel), 'utf8');
250
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
251
+ assert.doesNotMatch(code, /data\s*&&\s*data\.error\b/, `${rel}: still reads data.error directly`);
252
+ assert.doesNotMatch(code, /err\.data\.error\s*===/, `${rel}: still compares err.data.error to a string`);
253
+ assert.doesNotMatch(code, /\bdata\.conflicts\b/, `${rel}: still reads conflicts off the body`);
254
+ assert.doesNotMatch(code, /\bdata\.what_to_do\b/, `${rel}: still reads what_to_do off the body`);
255
+ // And it must pull the shared readers rather than redefine a fifth copy.
256
+ assert.match(src, /window\.OTB/, `${rel}: uses the shared toolkit`);
257
+ assert.match(src, /errCode/, `${rel}: goes through errCode`);
258
+ assert.doesNotMatch(code, /function errCode\s*\(/, `${rel}: must NOT define its own copy`);
259
+ }
260
+ });
261
+
262
+ console.log('');
263
+ console.log(`hall_error_envelope: ${passed} passed, ${failed} failed`);
264
+ process.exit(failed === 0 ? 0 : 1);