@bongos/core 1.19.271 → 1.19.273

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.
@@ -17,6 +17,7 @@ const require = createRequire(import.meta.url);
17
17
  const {
18
18
  apiContract,
19
19
  resolveWorktreeRoot,
20
+ worktreeGuidance,
20
21
  osFacts,
21
22
  normalizeClaims,
22
23
  buildContextPack,
@@ -177,6 +178,104 @@ t('buildContextPack: composes worktree + claims + api + os from injected runners
177
178
  assert.equal(pack.os.platform, 'linux');
178
179
  });
179
180
 
181
+ // ---- task 1002573: which tree is this, and does the guidance tell the truth ----
182
+ //
183
+ // The pack used to print the root and then assert "this is the session's tree,
184
+ // not the main checkout" UNCONDITIONALLY. Claiming from the main checkout
185
+ // therefore produced a flatly false sentence, and the tooling told the builder to
186
+ // do the one thing that makes the ship unmergeable (the receiving tree is the one
187
+ // holding the branch — task 1002572). The signal was already computed elsewhere
188
+ // (claim.js detectWorktreeName) and simply never surfaced.
189
+ //
190
+ // Stubs mirror real git: in the MAIN checkout `--git-dir` and `--git-common-dir`
191
+ // both answer '.git'; in a LINKED worktree they diverge.
192
+ const gitMain = (args) => {
193
+ const a = args.join(' ');
194
+ if (a.includes('--show-toplevel')) return '/repo\n';
195
+ if (a.includes('--git-common-dir')) return '.git\n';
196
+ if (a.includes('--git-dir')) return '.git\n';
197
+ throw new Error(`unexpected git call: ${a}`);
198
+ };
199
+ const gitLinked = (args) => {
200
+ const a = args.join(' ');
201
+ if (a.includes('--show-toplevel')) return '/repo/.claude/worktrees/task-1\n';
202
+ if (a.includes('--git-common-dir')) return '/repo/.git\n';
203
+ if (a.includes('--git-dir')) return '/repo/.git/worktrees/task-1\n';
204
+ throw new Error(`unexpected git call: ${a}`);
205
+ };
206
+
207
+ t('resolveWorktreeRoot: identical git-dir and common-dir ⇒ the MAIN checkout', () => {
208
+ const r = resolveWorktreeRoot({ runGit: gitMain, cwd: '/elsewhere' });
209
+ assert.equal(r.root, '/repo');
210
+ assert.equal(r.degraded, false);
211
+ assert.equal(r.isMainCheckout, true);
212
+ });
213
+
214
+ t('resolveWorktreeRoot: diverging git-dir ⇒ a LINKED worktree', () => {
215
+ const r = resolveWorktreeRoot({ runGit: gitLinked, cwd: '/elsewhere' });
216
+ assert.equal(r.root, '/repo/.claude/worktrees/task-1');
217
+ assert.equal(r.isMainCheckout, false);
218
+ });
219
+
220
+ t('resolveWorktreeRoot: unresolvable tree kind is UNKNOWN (null), never a guess', () => {
221
+ // toplevel works, but the dir probes fail — the kind must not default to either.
222
+ const partial = (args) => {
223
+ if (args.join(' ').includes('--show-toplevel')) return '/repo\n';
224
+ throw new Error('no');
225
+ };
226
+ assert.equal(resolveWorktreeRoot({ runGit: partial, cwd: '/c' }).isMainCheckout, null);
227
+ // And a total git failure still degrades to cwd without throwing.
228
+ const dead = resolveWorktreeRoot({ runGit: () => { throw new Error('no git'); }, cwd: '/here' });
229
+ assert.equal(dead.root, '/here');
230
+ assert.equal(dead.degraded, true);
231
+ assert.equal(dead.isMainCheckout, null);
232
+ });
233
+
234
+ t('worktreeGuidance: the main-checkout text DIFFERS from the worktree text', () => {
235
+ // The task's own verify criterion: the guidance must not read the same in both
236
+ // trees. Anything else means the builder is being told the same thing whether
237
+ // it is true or not.
238
+ const mainText = worktreeGuidance({ isMainCheckout: true }).join('\n');
239
+ const wtText = worktreeGuidance({ isMainCheckout: false }).join('\n');
240
+ assert.notEqual(mainText, wtText);
241
+ // The old, wrong sentence must NOT appear in the main checkout.
242
+ assert.doesNotMatch(mainText, /not the main checkout/i,
243
+ 'the main checkout must never be told it is not the main checkout');
244
+ assert.match(wtText, /not the main checkout/i, 'a real worktree keeps the original line');
245
+ });
246
+
247
+ t('worktreeGuidance: the main-checkout warning names the CONSEQUENCE, not just the fact', () => {
248
+ const text = worktreeGuidance({ isMainCheckout: true }).join('\n');
249
+ assert.match(text, /MAIN CHECKOUT/);
250
+ assert.match(text, /auto-merge/i, 'says what breaks');
251
+ assert.match(text, /confirmed/, 'and where the ship strands');
252
+ assert.match(text, /git worktree add/, 'and gives the command that avoids it');
253
+ });
254
+
255
+ t('worktreeGuidance: unknown tree kind hedges instead of asserting either', () => {
256
+ for (const w of [{ isMainCheckout: null }, {}, null, undefined]) {
257
+ const text = worktreeGuidance(w).join('\n');
258
+ assert.match(text, /Could not tell/i, `hedged for ${JSON.stringify(w)}`);
259
+ assert.doesNotMatch(text, /THIS IS THE MAIN CHECKOUT/, 'never assert main');
260
+ assert.doesNotMatch(text, /This is the session's tree/, 'never assert worktree');
261
+ }
262
+ });
263
+
264
+ t('formatContextPack: renders the main-checkout WARNING when that is the truth', () => {
265
+ const pack = buildContextPack({
266
+ activeClaims: [{ task_id: 942, title: 'x' }],
267
+ currentTaskId: 942,
268
+ cwd: '/fallback',
269
+ platform: 'linux',
270
+ runGit: gitMain,
271
+ runWhich: fakeWhich({ timeout: '/usr/bin/timeout', psql: '/usr/bin/psql' }),
272
+ });
273
+ const out = formatContextPack(pack);
274
+ assert.match(out, /THIS IS THE MAIN CHECKOUT/);
275
+ assert.doesNotMatch(out, /not the main checkout/i);
276
+ assert.ok(!/undefined/.test(out), 'no undefined leaked into the rendered pack');
277
+ });
278
+
180
279
  // ---- formatting ------------------------------------------------------------
181
280
  t('formatClaims: empty vs held, with the (this one) marker', () => {
182
281
  assert.equal(formatClaims([]), '(none held)');
@@ -194,16 +293,20 @@ t('formatOs: names the macOS no-timeout case in prose', () => {
194
293
  });
195
294
 
196
295
  t('formatContextPack: includes worktree root, prefix, a real kind, and warns the wrong-tree footgun', () => {
296
+ // task 1002573: uses a LINKED-worktree stub. It previously passed an
297
+ // args-ignoring stub, which returned the same path for every probe — i.e. the
298
+ // main checkout — while asserting the "not the main checkout" line. The test
299
+ // was encoding the bug.
197
300
  const pack = buildContextPack({
198
301
  activeClaims: [{ task_id: 942, title: 'x' }],
199
302
  currentTaskId: 942,
200
303
  cwd: '/fallback',
201
304
  platform: 'linux',
202
- runGit: () => '/work/tree\n',
305
+ runGit: gitLinked,
203
306
  runWhich: fakeWhich({ timeout: '/usr/bin/timeout', psql: '/usr/bin/psql' }),
204
307
  });
205
308
  const out = formatContextPack(pack);
206
- assert.match(out, /\/work\/tree/);
309
+ assert.match(out, /task-1/);
207
310
  assert.match(out, /\/api\/bongos/);
208
311
  assert.match(out, /feature/); // a real kind from VALID_KINDS
209
312
  assert.match(out, /not the main checkout/i);
@@ -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);