@bongos/core 1.19.505 → 1.19.507

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.
@@ -121,91 +121,146 @@
121
121
  $('#sessions-scroll').hidden = true;
122
122
  }
123
123
 
124
- // ---- paging state --------------------------------------------------------
124
+ // ---- paging --------------------------------------------------------------
125
+ //
126
+ // This page used to hand-roll its own server-offset pager, and hall-ui/CLAUDE.md
127
+ // carried it as the ONE sanctioned exception to "no bespoke pager" (audited task
128
+ // 2179) — because the kit could only slice an array the browser already held, and
129
+ // this corpus cannot be held. Task 1003281 added the server-offset mode to the kit
130
+ // and left the migration as its own task; this is it. The exception is gone, so
131
+ // there is one pager contract in the hall again rather than one-plus-a-footnote.
132
+ //
133
+ // What the reader gains: a per-page control that remembers their choice, and a
134
+ // clamp — if the corpus shrinks under an open page, renderServerPage() says so and
135
+ // we re-ask, instead of showing an empty page under a range that claims rows.
136
+ //
137
+ // The one thing NOT taken from the kit is makeLedger: it stamps table+pager into a
138
+ // single mount, and the CARD is this page's scroll container (hall-ui/CLAUDE.md, the
139
+ // sideways-scroll rule), so a pager stamped into the table's mount would slide with
140
+ // the columns. Keeping it a separate sibling below the ledger is what holds it still.
141
+ // (This page used to carry a .ledger-table-wrap scrollport; task 1003470 removed it
142
+ // because the wrapper broke the shell's sticky th. The separate mount outlived it.)
143
+ // Table markup stays in sessions.html; the PAGER is the kit's.
125
144
 
126
- const PAGE_SIZE = 50;
127
- const state = { offset: 0, total: 0 };
145
+ // Guarded the way every other kit consumer guards it (profile.js): the kit is a
146
+ // separate <script>, so a cache miss or a load-order slip must degrade to a named
147
+ // failure rather than an uncaught throw at parse time that also kills load().
148
+ const kit = window.OTBKit;
149
+ const PAGER_PREFIX = 'sessions';
150
+ // 50 was this page's fixed page size before the per-page control existed; keeping it
151
+ // as the default means a returning reader's first page looks exactly as it did.
152
+ const pager = kit ? kit.makePager(PAGER_PREFIX, 'otb.sessions.perPage', { defaultPerPage: 50 }) : null;
153
+ // `page`/`perPage` here are the last values that actually PAINTED. The kit moves the
154
+ // pager first and calls back second, so a failed fetch has to put it back to these —
155
+ // otherwise the bar goes on claiming a page whose rows never arrived. Keeping both
156
+ // (not just the page) means a failed per-page change rolls back cleanly too.
157
+ const state = { total: 0, shown: 0, page: 1, perPage: pager?.perPage ?? 0 };
128
158
  let loading = false;
159
+ // Two clicks can be in flight at once; only the newest may paint (the profile
160
+ // activity-log idiom, task 1003281).
161
+ let inFlight = 0;
129
162
 
163
+ // Paint the kit's bar, then apply the one thing the kit deliberately does not model:
164
+ // a fetch is in flight, so nothing may be pressed again until it lands. Only the
165
+ // DISABLING is ours — re-enabling the arrows is left to the kit, which knows whether
166
+ // there is a page on either side; re-enabling them here would light up "Prev" on
167
+ // page 1. Returns the kit's clamped flag: true = the page outran a shrunken corpus.
130
168
  function renderPager() {
131
- const pager = $('#ledger-pager');
132
- const total = state.total;
133
- if (total <= PAGE_SIZE) {
134
- // Everything fits on one page — no pager needed.
135
- pager.hidden = true;
136
- return;
169
+ const clamped = kit.renderServerPage(pager, state.total, state.shown);
170
+ const perPage = $(`#${PAGER_PREFIX}-per-page`);
171
+ if (perPage) perPage.disabled = loading;
172
+ if (loading) {
173
+ for (const id of ['prev', 'next']) {
174
+ const el = $(`#${PAGER_PREFIX}-${id}`);
175
+ if (el) el.disabled = true;
176
+ }
137
177
  }
138
- pager.hidden = false;
139
- const page = Math.floor(state.offset / PAGE_SIZE) + 1;
140
- const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
141
- const from = state.offset + 1;
142
- const to = Math.min(state.offset + PAGE_SIZE, total);
143
- $('#pager-pos').textContent = `Page ${fmtInt(page)} of ${fmtInt(pages)} · ${fmtInt(from)}–${fmtInt(to)} of ${fmtInt(total)}`;
144
- $('#pager-prev').disabled = loading || state.offset <= 0;
145
- $('#pager-next').disabled = loading || state.offset + PAGE_SIZE >= total;
178
+ return clamped;
146
179
  }
147
180
 
181
+ // Paint one page. Returns true when the pager clamped — the corpus shrank under an
182
+ // open page, so the caller must re-ask rather than leave a range that lies.
148
183
  function showData(data) {
149
184
  const sessions = Array.isArray(data.sessions) ? data.sessions : [];
150
185
  const totals = data.totals || { total: data.total || sessions.length };
151
186
  state.total = num(totals.total);
187
+ state.shown = sessions.length;
152
188
  $('#gate-scroll').hidden = true;
153
189
  $('#stats-scroll').hidden = false;
154
190
  $('#sessions-scroll').hidden = false;
155
191
  renderStats(totals);
156
192
  renderTable(sessions);
157
- renderPager();
193
+ const clamped = renderPager();
158
194
  $('#ledger-sub').textContent =
159
195
  `${fmtInt(totals.total)} session${num(totals.total) === 1 ? '' : 's'} across ${fmtInt(totals.builders)} builder${num(totals.builders) === 1 ? '' : 's'}.`;
160
196
  $('#ledger-foot').textContent =
161
197
  'Most recent first. Each load of this page is itself recorded in the audit log.';
198
+ return clamped;
162
199
  }
163
200
 
164
201
  // ---- load ----------------------------------------------------------------
165
202
 
166
- // Fetch one page at the current offset. Returns true on success.
167
- async function fetchPage() {
203
+ // Fetch the page the pager currently points at. Returns true on success.
204
+ //
205
+ // `pager.page` has ALREADY moved by the time this runs — wirePager advances it and
206
+ // then calls back — so a failure restores `state.page`/`state.perPage`, the last
207
+ // values that painted. That roll-back is what the old hand-roll did with
208
+ // `state.offset`, and it is the one piece of it worth carrying over.
209
+ async function fetchPage({ reclamps = 0 } = {}) {
210
+ const seq = ++inFlight;
168
211
  loading = true;
169
212
  renderPager();
170
213
  try {
171
214
  const res = await api.request(
172
215
  'GET',
173
- `${API}/sessions/search?order=recent&limit=${PAGE_SIZE}&offset=${state.offset}`
216
+ `${API}/sessions/search?order=recent&limit=${pager.perPage}&offset=${kit.pageOffset(pager)}`
174
217
  );
218
+ // A newer click already went out; painting this answer would show a page nobody
219
+ // is on any more.
220
+ if (seq !== inFlight) return true;
175
221
  if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return false; }
176
222
  if (res.status === 403) {
177
223
  showSealed('You do not have access to the session log.');
178
224
  return false;
179
225
  }
180
226
  if (!res.ok) throw new Error(`search → ${res.status}`);
181
- const data = res.data;
182
- showData(data);
227
+ const clamped = showData(res.data);
228
+ // The corpus shrank under this page (sessions pruned between loads):
229
+ // renderServerPage pulled the page back into range, so go get the rows that page
230
+ // actually holds rather than leaving a range that disagrees with the table.
231
+ // One re-ask, never a loop: a corpus shrinking on every round-trip would
232
+ // otherwise spin here, and a stale range for one paint is the smaller harm.
233
+ if (clamped && reclamps < 1) { loading = false; return fetchPage({ reclamps: reclamps + 1 }); }
234
+ state.page = pager.page;
235
+ state.perPage = pager.perPage;
183
236
  return true;
184
237
  } catch (err) {
238
+ if (seq !== inFlight) return true;
185
239
  console.error('[sessions] failed to load corpus', err);
240
+ pager.page = state.page;
241
+ pager.perPage = state.perPage;
186
242
  $('#ledger-sub').textContent = 'The session log could not be loaded. Try again.';
187
243
  toast('Could not load sessions.');
188
244
  return false;
189
245
  } finally {
190
- loading = false;
191
- renderPager();
246
+ if (seq === inFlight) {
247
+ loading = false;
248
+ renderPager();
249
+ }
192
250
  }
193
251
  }
194
252
 
195
- async function goTo(offset) {
196
- if (loading) return;
197
- const next = Math.max(0, offset);
198
- if (next === state.offset) return;
199
- const prev = state.offset;
200
- state.offset = next;
201
- const ok = await fetchPage();
202
- if (!ok) state.offset = prev; // roll back on failure so the pager stays honest
203
- else window.scrollTo({ top: 0, behavior: 'smooth' });
204
- }
205
-
206
- function wirePager() {
207
- $('#pager-prev').addEventListener('click', () => goTo(state.offset - PAGE_SIZE));
208
- $('#pager-next').addEventListener('click', () => goTo(state.offset + PAGE_SIZE));
253
+ function mountPager() {
254
+ const mount = $('#sessions-pager-mount');
255
+ if (!mount) return;
256
+ mount.innerHTML = kit.pagerBarHtml(PAGER_PREFIX, { label: 'sessions' });
257
+ // The kit has already updated `pager` by the time this fires; go get the rows that
258
+ // match it. Scrolling up only on success keeps a failed page from yanking the
259
+ // reader away from the table they were still looking at.
260
+ kit.wirePager(pager, async () => {
261
+ const ok = await fetchPage();
262
+ if (ok) window.scrollTo({ top: 0, behavior: 'smooth' });
263
+ });
209
264
  }
210
265
 
211
266
  async function load() {
@@ -234,8 +289,14 @@
234
289
  return;
235
290
  }
236
291
 
292
+ if (!kit) {
293
+ console.error('[sessions] OTBKit did not load — the pager cannot be built');
294
+ $('#ledger-sub').textContent = 'The session log could not be loaded. Try again.';
295
+ return;
296
+ }
297
+
237
298
  // 2. Fetch the first page (Archon-gated + audited server-side).
238
- wirePager();
299
+ mountPager();
239
300
  await fetchPage();
240
301
  }
241
302
 
@@ -161,7 +161,12 @@ module.exports = function buildTasksRouter() {
161
161
  if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
162
162
  const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
163
163
  if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
164
- const [claimable, active] = await Promise.all([
164
+ // ?limit=N — opt-in, for a caller that renders N rows (goal 1000072; the hall's
165
+ // two-line Home card was pulling this whole feed, descriptions and all). It slices
166
+ // the RETURNED feed, never the query — lifecycle/CLAUDE.md has the why.
167
+ const claimLimit = posInt(req.query.limit, 'bad_limit', 1000);
168
+ if (claimLimit && claimLimit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer (max 1000).' });
169
+ const [feed, active] = await Promise.all([
165
170
  db.listClaimableTasks({
166
171
  versionId: version || null,
167
172
  builderId: req.builder.id,
@@ -172,13 +177,17 @@ module.exports = function buildTasksRouter() {
172
177
  }),
173
178
  db.listActiveClaims(),
174
179
  ]);
180
+ const claimable = claimLimit ? feed.slice(0, claimLimit) : feed;
175
181
  // V3.R75 (#294): tick the onboarding state machine for first_start. Fire
176
182
  // and forget — the response should never wait on or fail because of the
177
183
  // onboarding write. safeMarkStage swallows errors and handles the
178
184
  // graduation broadcast. Through the `onboarding` kernel port (BV1.R81 / ADR
179
185
  // 0093 §1); ?. degrades to a no-op when the module is off.
180
186
  seams.resolveOptional('onboarding')?.safeMarkStage(pool, req.builder.id, 'first_start', req.builder);
181
- res.json({ claimable, active_claims: active });
187
+ // `claimable_total` is what makes the limit honest — two rows with no count reads
188
+ // as "there are only two". It is the length of the array the slice came from, never
189
+ // a second count query (that is how a total and its own list drift apart, ADR 0197).
190
+ res.json({ claimable, claimable_total: feed.length, active_claims: active });
182
191
  });
183
192
 
184
193
  // GET /tasks/newcomer-floor — supply gauge for the newcomer queue (task 614).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.505",
3
+ "version": "1.19.507",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.505",
9
+ "version": "1.19.507",
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.505",
3
+ "version": "1.19.507",
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
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.505'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.507'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -198,6 +198,44 @@ test('the sidebar scrolls its nav list, not the whole column', () => {
198
198
 
199
199
  // ---- 6. every hall list is paged through the kit (task 1002446) -----------
200
200
 
201
+ test('the kit is the ONLY thing in the hall that emits pager markup', () => {
202
+ // hall-ui/CLAUDE.md's "no bespoke pager" rule had one sanctioned exception for two
203
+ // releases — sessions.js, which paged server-side by offset, a thing the kit could
204
+ // not express (audited task 2179). Task 1003281 added `makeLedger({ serverPager })`
205
+ // and left the migration as its own task; it has now landed, so the hall is back to
206
+ // ONE pager contract with no footnote.
207
+ //
208
+ // A rule that only lives in prose is a rule that comes back as an eleventh pager.
209
+ // This is the machine-checkable half: the kit STAMPS every bar at runtime, so any
210
+ // .pager markup written by hand — in a page's HTML or in another script's template —
211
+ // is by construction a second implementation. Adding an exception here is where the
212
+ // conversation happens, exactly like the budgets above.
213
+ const OWNER = 'hall-kit.js';
214
+ const offenders = [];
215
+ for (const f of fs.readdirSync(HALL)) {
216
+ if (!/\.(html|js)$/.test(f) || f === OWNER) continue;
217
+ const src = read(f);
218
+ // `class="pager…"` in a template string or in static markup. The kit's own callers
219
+ // pass a PREFIX and get the markup back; they never write the class themselves.
220
+ if (/class="pager[\s"_-]/.test(src)) offenders.push(f);
221
+ }
222
+ assert.deepEqual(offenders, [],
223
+ `${offenders.join(', ')} writes pager markup by hand — call OTBKit.pagerBarHtml(prefix) `
224
+ + '(or makeLedger, which stamps it for you) instead of a second copy.');
225
+ });
226
+
227
+ test('the session log pages on the kit — the last hand-roll is gone', () => {
228
+ // The specific exception this rule carried, pinned by name so its removal cannot be
229
+ // quietly undone. Behaviour is exercised for real in tests/sessions_kit_pager.mjs;
230
+ // this is the structural half.
231
+ const js = read('sessions.js');
232
+ for (const fn of ['makePager', 'pagerBarHtml', 'wirePager', 'pageOffset', 'renderServerPage']) {
233
+ assert.match(js, new RegExp(`kit\\.${fn}\\(`), `sessions.js must page through the kit's ${fn}`);
234
+ }
235
+ assert.doesNotMatch(js, /const PAGE_SIZE\b/, 'its fixed page size is gone — per-page is the kit\'s now');
236
+ assert.match(read('sessions.html'), /hall-kit\.js/, 'and the page has to load the kit');
237
+ });
238
+
201
239
  test('the peer-recognition feed is paged through OTBKit, not by hand', () => {
202
240
  assert.match(read('hall-widgets.js'), /pagerBarHtml\('acclaim'/, 'the acclaim card stamps the kit pager bar');
203
241
  const js = read('builders.js');
@@ -294,8 +294,18 @@ test('the system notices are inbox LINES, first in the card, and counted by its
294
294
 
295
295
  test('the greeting reads /me, the public progress and the claimable feed, and builders.js feeds all three', () => {
296
296
  assert.match(buildersJs, /getJSON\(`\$\{API\}\/public\/progress`\)/, 'Home fetches the public progress once');
297
- assert.match(buildersJs, /lastClaimableCount = resp && Array\.isArray\(resp\.claimable\) \? resp\.claimable\.length : null;/,
297
+ // The count comes off the SAME Next-up response — one request, not a second
298
+ // fetch. It reads `claimable_total` rather than the length of the returned array,
299
+ // because that request is now bounded (?limit=2, task 1003545): the array holds two
300
+ // rows and the total holds the whole rank-scoped queue. Asserting the derivation
301
+ // rather than one literal expression is deliberate — the previous version pinned
302
+ // `resp.claimable.length`, which read as a requirement to download the feed.
303
+ assert.match(buildersJs, /lastClaimableCount = Number\.isFinite\(claimableTotal\)/,
298
304
  'the claimable feed Home already fetches for Next-up answers the count');
305
+ assert.match(buildersJs, /const claimableTotal = Number\(resp && resp\.claimable_total\);/,
306
+ 'and it takes the count from the response total, so a bounded read still knows it');
307
+ assert.doesNotMatch(buildersJs, /getJSON\(`\$\{API\}\/tasks\/claimable`/,
308
+ 'no unbounded claimable fetch on Home — the count must never cost the whole queue');
299
309
  assert.match(buildersJs, /get lastProgressState\(\)/, 'the renderer reads progress through a getter, so it cannot freeze at boot');
300
310
  assert.match(buildersJs, /get lastClaimableCount\(\)/, 'and the count the same way');
301
311
  assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 3, 'rendered after /me, after progress and after the claimable feed — each half as it lands');
@@ -0,0 +1,159 @@
1
+ // tests/home_bounded_reads.mjs — the Home card asks for what it renders
2
+ // (goal 1000072, "Human legibility").
3
+ //
4
+ // THE PATTERN THIS CLOSES. This goal's criterion `legibility-sort-and-staleness` set
5
+ // the rule in its own words — "~1,357 tasks on BONGOS-V1 alone. A view that does not
6
+ // survive that number is not a solution" — and `modules/lifecycle/goal-rollup.js`
7
+ // exists because the Goals page broke it: **5,229,455 bytes for ~1,400 tasks, measured
8
+ // 2026-08-25, to produce roughly forty small counter objects.**
9
+ //
10
+ // The same shape was still live on the page every builder lands on first. Home's
11
+ // "Next up" card called `GET /tasks/claimable` — no limit, `SELECT v.*`, every
12
+ // description on every claimable row — and then `.slice(0, 2)` to fill TWO lines. Its
13
+ // triage card pulled the route's default fifty ideas to render five.
14
+ //
15
+ // So this pins the rule rather than the two instances: a Home card may not download a
16
+ // feed and throw most of it away, and the limit it asks for must come back with a
17
+ // total, or two rows are indistinguishable from "there are only two".
18
+ //
19
+ // Run: node tests/home_bounded_reads.mjs
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { createRequire } from 'node:module';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
30
+ const BUILDERS_JS = read('modules/hall-ui/public/builders.js');
31
+ const WIDGETS_JS = read('modules/hall-ui/public/hall-widgets.js');
32
+ const ROUTE = read('modules/lifecycle/routes/tasks.js');
33
+ const DB_TASKS = read('modules/lifecycle/db-tasks.js');
34
+
35
+ // Comments stripped before any "is the bad pattern gone" assertion — the same rule
36
+ // scripts/gds/category-advisory-guard.js applies. A comment EXPLAINING the pattern that
37
+ // was removed is documentation; matching it would make the fix un-documentable.
38
+ const noComments = (src) => src.replace(/^\s*\/\/.*$/gm, '');
39
+
40
+ const { test, summary } = makeRunner();
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // 1. the rule, over Home's own boot reads
44
+ // ---------------------------------------------------------------------------
45
+
46
+ await test('no Home read downloads a feed and slices it in the browser', () => {
47
+ // The tell is a `.slice(0, N)` applied to a response body. Narrowing what is
48
+ // DISPLAYED is fine; narrowing what was FETCHED is the bug, and the two look
49
+ // identical at the call site — which is why this is a test and not a code review.
50
+ const src = noComments(BUILDERS_JS);
51
+ for (const fn of ['loadNextUp', 'loadTriageInbox']) {
52
+ const from = src.indexOf(`function ${fn}(`);
53
+ assert.ok(from > 0, `${fn} must exist`);
54
+ const body = src.slice(from, src.indexOf('\n }\n', from));
55
+ const name = fn;
56
+ const url = body.match(/\$\{API\}\/[^`'"]+/);
57
+ assert.ok(url, `${name} must issue a request`);
58
+ assert.match(url[0], /[?&]limit=\d+/, `${name} must ask the server for its page size`);
59
+ }
60
+ });
61
+
62
+ await test('the two cards ask for exactly the rows they draw', () => {
63
+ assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=2/, 'Next up draws two rows');
64
+ assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card draws five');
65
+ // …and no longer re-slices what came back.
66
+ const src = noComments(BUILDERS_JS);
67
+ const from = src.indexOf('function loadNextUp(');
68
+ const body = src.slice(from, src.indexOf('\n }\n', from));
69
+ assert.ok(body.length > 0, 'loadNextUp body found');
70
+ assert.doesNotMatch(body, /\.slice\(0,\s*2\)/, 'the client-side slice is gone');
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // 2. the limit is honest — it comes back with a total
75
+ // ---------------------------------------------------------------------------
76
+
77
+ await test('GET /tasks/claimable validates ?limit and answers with a total', () => {
78
+ assert.match(ROUTE, /posInt\(req\.query\.limit, 'bad_limit', 1000\)/, 'same validator as GET /tasks');
79
+ assert.match(ROUTE, /res\.fail\('bad_limit'/);
80
+ assert.match(ROUTE, /claimable_total: feed\.length/);
81
+ // The total must be the length of the very array the rows were sliced from. A
82
+ // companion count query would be a second copy of a filter chain that runs half in
83
+ // SQL and half in JS — the ADR 0197 failure, one release later.
84
+ assert.doesNotMatch(ROUTE, /countClaimableTasks/,
85
+ 'no second count query: the sliced array reports its own length');
86
+ assert.match(ROUTE, /const claimable = claimLimit \? feed\.slice\(0, claimLimit\) : feed;/);
87
+ });
88
+
89
+ await test('the limit is OPT-IN — the Work Board and /builder-start are untouched', () => {
90
+ // Their whole job is the full feed. A default limit would silently truncate the board
91
+ // and the CLI's claim menu, which is a worse bug than the one being fixed. `posInt`
92
+ // returns undefined for an absent param, so `claimLimit ? … : feed` hands back
93
+ // everything exactly as before.
94
+ assert.match(ROUTE, /claimLimit \? feed\.slice/);
95
+ assert.doesNotMatch(DB_TASKS, /listClaimableTasks\([^)]*limit/,
96
+ 'the db helper gained no limit — nothing below the route changed');
97
+ });
98
+
99
+ await test('every db.* the claimable handler calls actually exists', () => {
100
+ // The bug this catches, caught the hard way: an earlier cut pushed the limit into the
101
+ // db layer, was reverted, and left the route calling `db.listClaimableTasksPaged` — a
102
+ // function that no longer existed. Every assertion in this file is source text, and
103
+ // source text cannot see a missing symbol; a neighbouring suite found it instead.
104
+ const from = ROUTE.indexOf("router.get('/tasks/claimable'");
105
+ const body = ROUTE.slice(from, ROUTE.indexOf('res.json({ claimable', from));
106
+ const called = [...new Set([...body.matchAll(/\bdb\.([A-Za-z0-9_]+)\s*\(/g)].map((m) => m[1]))];
107
+ assert.ok(called.length > 0, 'the handler calls the db layer');
108
+ const dbExports = read('modules/lifecycle/db.js');
109
+ const block = dbExports.slice(dbExports.lastIndexOf('module.exports'));
110
+ for (const fn of called) {
111
+ assert.ok(new RegExp(`\\b${fn}\\b`).test(block), `db.js must export ${fn} — the route calls it`);
112
+ }
113
+ });
114
+
115
+ await test('the db layer is untouched: no new export, no second feed function', () => {
116
+ // The first cut pushed the limit into db-tasks.js and exported a paged twin. It
117
+ // bought nothing — a SQL LIMIT is unsafe here either way, so the query does the same
118
+ // work — and it widened the hand-mirrored export lists in db.js/db-tasks.js until
119
+ // they shared a 30-line run and tripped the duplication ratchet. The wire is the
120
+ // thing being saved, and the route is where the wire is.
121
+ assert.doesNotMatch(DB_TASKS, /listClaimableTasksPaged/);
122
+ assert.doesNotMatch(read('modules/lifecycle/db.js'), /listClaimableTasksPaged/);
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // 3. the trap a SQL LIMIT would have walked into
127
+ // ---------------------------------------------------------------------------
128
+
129
+ await test('the limit is applied AFTER the JS post-filters, not as a SQL LIMIT', () => {
130
+ // listClaimableTasks filters twice in JS after the query returns: the autonomous
131
+ // runner's protected-path + membership drops, and capNewcomerTasks' display cap.
132
+ // `LIMIT 2` in SQL can hand back two rows that both get dropped, leaving an empty
133
+ // feed with claimable work sitting right behind it. Slicing the RETURNED feed is
134
+ // what makes the limit safe, and it is why the query is unchanged.
135
+ const fn = DB_TASKS.slice(
136
+ DB_TASKS.indexOf('async function listClaimableTasks('),
137
+ DB_TASKS.indexOf('function taskTouchesProtectedPath(')
138
+ );
139
+ assert.ok(fn.length > 0, 'found listClaimableTasks');
140
+ assert.doesNotMatch(fn, /LIMIT \$/, 'no SQL LIMIT in the claimable query');
141
+ assert.match(fn, /return capNewcomerTasks\(rows\);/, 'the JS post-filters still have the last word');
142
+ const routeFrom = ROUTE.indexOf("router.get('/tasks/claimable'");
143
+ const routeBody = ROUTE.slice(routeFrom, ROUTE.indexOf('res.json({ claimable', routeFrom));
144
+ assert.match(routeBody, /feed\.slice\(0, claimLimit\)/, 'the slice happens on the finished feed');
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // 4. what the reader sees
149
+ // ---------------------------------------------------------------------------
150
+
151
+ await test('the foot names the queue the two rows came off', () => {
152
+ assert.match(WIDGETS_JS, /id="home-next-foot"/, 'the link has an id to write into');
153
+ assert.match(BUILDERS_JS, /See all \$\{fmtNum\(total\)\} ready work/);
154
+ // Silent rather than wrong when the count is missing or not larger than what is
155
+ // shown: "See all 2" under two rows is noise, and a guessed number is worse.
156
+ assert.match(BUILDERS_JS, /Number\.isFinite\(total\) && total > rows\.length/);
157
+ });
158
+
159
+ summary();