@bongos/core 1.20.80 → 1.20.82

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.
Files changed (56) hide show
  1. package/.bongos-core.json +98 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +245 -4
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +84 -71
  10. package/docs/copy-registry.json +271 -145
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +2 -1
  13. package/docs/page-readings.json +139 -119
  14. package/modules/autonomy/cadence.js +3 -0
  15. package/modules/autonomy/db.js +136 -0
  16. package/modules/autonomy/fence.js +88 -3
  17. package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
  18. package/modules/autonomy/routes/autonomy.js +123 -3
  19. package/modules/economy/earnings.js +117 -0
  20. package/modules/economy/module.json +1 -1
  21. package/modules/economy/routes/earnings.js +47 -0
  22. package/modules/government/catalog.js +9 -1
  23. package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
  24. package/modules/hall-ui/public/gate.css +49 -0
  25. package/modules/hall-ui/public/gate.html +8 -7
  26. package/modules/hall-ui/public/gate.js +272 -115
  27. package/modules/hall-ui/public/profile-charts.js +206 -104
  28. package/modules/hall-ui/public/profile.css +23 -0
  29. package/modules/hall-ui/public/profile.js +3 -3
  30. package/modules/hall-ui/public/profile.states.json +8 -4
  31. package/modules/hall-ui/public/settings-autobongos.js +156 -0
  32. package/modules/hall-ui/public/settings.html +19 -0
  33. package/modules/hall-ui/public/settings.js +1 -0
  34. package/modules/hall-ui/records/profile-roles.md +21 -0
  35. package/modules/lifecycle/db-goals.js +6 -0
  36. package/modules/lifecycle/db-versions.js +3 -0
  37. package/modules/lifecycle/done-when.js +4 -0
  38. package/modules/lifecycle/goal-close-sweep.js +34 -0
  39. package/modules/provisioning/app-status.js +17 -4
  40. package/modules/public-landing/public/projects.html +7 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/release-notes.json +28 -0
  44. package/scripts/hall-preview/server.js +4 -0
  45. package/src/module-api.js +1 -1
  46. package/tests/auto_satisfy_criteria.mjs +8 -6
  47. package/tests/autonomy_builder_scope.mjs +474 -0
  48. package/tests/autonomy_fence_priority.mjs +7 -3
  49. package/tests/autonomy_runner_identity.mjs +1 -1
  50. package/tests/criterion_goal_attach.mjs +5 -1
  51. package/tests/goal_close_cancels_requests.mjs +82 -0
  52. package/tests/goal_closure_invariants.mjs +5 -2
  53. package/tests/hall_profile_world.mjs +57 -58
  54. package/tests/profile_earnings.mjs +237 -0
  55. package/tests/projects_hub_app_status.mjs +17 -0
  56. package/tests/provisioning_app_status.mjs +25 -1
@@ -21,12 +21,13 @@
21
21
  // names; craftMix folds the tail past seven into "other" rather than
22
22
  // minting an eighth hue; weeklyBuckets is a time axis (every week present,
23
23
  // empty or not).
24
- // * DRACHMAE COME FROM THE LEDGER (task 1003491). The earned line is the
25
- // credits ledger — /me/credits on your own profile, /builders/:id/credits
26
- // on another's — `delta` cumulated by `recorded_at`, so it ends at the
27
- // balance. Per-task credits_awarded is 0 on a cost-plus instance and is no
28
- // longer read at all (the owner's chart had flatlined since July on it).
29
- // A viewer the ledger refuses gets the credits_reward estimate, labelled.
24
+ // * DRACHMAE COME FROM THE LEDGER (task 1003491), and for EVERYONE (task
25
+ // 1004436). The earned line is GET /builders/:id/earnings — the ledger
26
+ // summed per day and kind, one read for your own profile and anyone's —
27
+ // cumulated by day, so it ends at the balance. Per-task credits_awarded is
28
+ // 0 on a cost-plus instance and is never read (the owner's chart had
29
+ // flatlined since July on it). The earnings draw with no shipped task at
30
+ // all; the tests that prove it live in tests/profile_earnings.mjs.
30
31
  // * NO NUMBER IS PICTURE-ONLY. Every time chart carries a table twin with a
31
32
  // row per plotted day; the readout is written with textContent.
32
33
  // * OWN-ONLY MEANS OWN-ONLY. /analytics/builder/:id is asked for only when
@@ -209,13 +210,13 @@ test('cumulativeByDay folds a day\'s ships into one point and accumulates, skipp
209
210
  ], 'newest-first ledger rows still accumulate oldest-first, by their own timestamp');
210
211
  });
211
212
 
212
- test('estimateOf is the credits_reward figure and nothing else — the per-task award column is not read anywhere', () => {
213
+ test('the per-task award column is not read anywhere — nor the estimate that stood in for the ledger', () => {
213
214
  const C = bootCharts();
214
- assert.equal(C.estimateOf({ credits_awarded: 58, credits_reward: 60 }), 60, 'the estimate ignores what was awarded');
215
- assert.equal(C.estimateOf({ credits_awarded: 0, credits_reward: 45 }), 45, 'a cost-plus 0 award does not zero the estimate');
216
- assert.equal(C.estimateOf({}), 0);
217
- assert.equal(C.estimateOf(null), 0);
218
215
  assert.equal(C.creditsOf, undefined, 'the old ledger-or-estimate picker is gone');
216
+ // task 1004436: every signed-in viewer reads the earnings route, so the
217
+ // credits_reward estimate a refused ledger fell back to has nothing to cover.
218
+ assert.equal(C.estimateOf, undefined, 'the estimate fallback is gone too');
219
+ assert.ok(!chartsJs.includes('credits_reward'), 'profile-charts.js never sums an estimate');
219
220
  // The whole reason for task 1003491: on a cost-plus instance every task's
220
221
  // credits_awarded is 0, so a drachmae series summed from it flatlines the day
221
222
  // per-task payout stops. The column must not be read by the charts at all.
@@ -269,10 +270,9 @@ test('clipToWindow (the lookback) enters the window at the running height, not a
269
270
 
270
271
  // ---- the charts: what render asks for and draws ---------------------------------
271
272
 
272
- // `ledger` is what the credits routes answer: an array of rows (paged by the
273
- // route's 200 in the real shape, { rows, page: { total } }), or 'denied' for the
274
- // 403 a viewer without builder.roster.read gets on someone else's ledger.
275
- function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
273
+ // `earnings` is what GET /builders/:id/earnings answers: its `days` rows
274
+ // ({ day, kind, credits }), or 'failed' for a 500.
275
+ function bootRender({ rows, status = 200, analytics = null, earnings = [] }) {
276
276
  const requests = [];
277
277
  const mount = { innerHTML: '', querySelector: () => null };
278
278
  const controls = { innerHTML: '', addEventListener() {}, querySelectorAll: () => [] };
@@ -285,10 +285,10 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
285
285
  const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
286
286
  return { status: 200, ok: true, data: { total: rows.length, rows: rows.slice(offset, offset + 100) } };
287
287
  }
288
- if (url.includes('/credits')) {
289
- if (ledger === 'denied') return { status: 403, ok: false, data: null };
290
- const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
291
- return { status: 200, ok: true, data: { rows: ledger.slice(offset, offset + 200), page: { limit: 200, offset, total: ledger.length } } };
288
+ if (url.includes('/earnings')) {
289
+ if (status !== 200) return { status, ok: false, data: null };
290
+ if (earnings === 'failed') return { status: 500, ok: false, data: null };
291
+ return { status: 200, ok: true, data: { days: earnings } };
292
292
  }
293
293
  if (url.includes('/analytics/')) return analytics ? { status: 200, ok: true, data: analytics } : { status: 403, ok: false, data: null };
294
294
  return { status: 404, ok: false, data: null };
@@ -324,13 +324,13 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
324
324
  }
325
325
 
326
326
  const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'engineer', credits_awarded: 10, credits_reward: 12, ...extra });
327
- // a credits-ledger row: what the instance actually paid, when
328
- const pay = (d, delta, extra = {}) => ({ delta, reason: 'session.token_reward', recorded_at: `${d}T22:00:00Z`, ...extra });
327
+ // an earnings row: what the ledger paid on one day, for one kind of credit
328
+ const pay = (d, credits, kind = 'session') => ({ day: d, kind, credits });
329
329
 
330
330
  test('render draws three public figures with a table twin per time chart, and no own-only one for another builder', async () => {
331
331
  const b = bootRender({
332
332
  rows: [ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20', { discipline: 'ui' })],
333
- ledger: [pay('2026-08-01', 20), pay('2026-08-20', 30)],
333
+ earnings: [pay('2026-08-01', 20), pay('2026-08-20', 30)],
334
334
  });
335
335
  await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
336
336
  const out = b.mount.innerHTML;
@@ -349,8 +349,8 @@ test('render asks for the pace chart on the viewer\'s own profile only', async (
349
349
  const own = bootRender({ rows: [ship('2026-08-01')], analytics });
350
350
  await own.C.render({ api: own.api, API: '/api/bongos', builderId: 95, selfId: '95', signedIn: true, now: NOW });
351
351
  assert.ok(own.requests.some((u) => u.includes('/analytics/builder/95')), 'own profile: the analytics read');
352
- assert.ok(own.requests.some((u) => u.includes('/me/credits?')), 'own profile: your own ledger');
353
- assert.ok(!own.requests.some((u) => u.includes('/builders/95/credits')), 'never through the roster route for yourself');
352
+ assert.ok(own.requests.some((u) => u.includes('/builders/95/earnings')), 'own profile: the same earnings read as anyone\'s');
353
+ assert.ok(!own.requests.some((u) => u.includes('/credits')), 'never the itemised ledger');
354
354
  assert.ok(own.mount.innerHTML.includes('id="chart-rate"'), 'and the pace chart draws');
355
355
  assert.ok(own.mount.innerHTML.includes('only you see this one'), 'saying plainly that it is private');
356
356
  });
@@ -365,10 +365,10 @@ test('signed out, the section says why and asks the server for nothing', async (
365
365
  assert.match(gone.mount.innerHTML, /visible to signed-in builders/, 'a 401 mid-way reads the same');
366
366
  });
367
367
 
368
- test('an empty record is one honest line, not three empty cards', async () => {
368
+ test('an empty record AND no earnings is one honest line, not three empty cards', async () => {
369
369
  const b = bootRender({ rows: [] });
370
370
  await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
371
- assert.match(b.mount.innerHTML, /No works shipped yet/);
371
+ assert.match(b.mount.innerHTML, /Nothing earned or shipped yet/);
372
372
  assert.ok(!b.mount.innerHTML.includes('<figure'), 'no figure drew');
373
373
  assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and nothing else was asked for');
374
374
  });
@@ -404,47 +404,41 @@ test('a 30-day window drops the older ships from the craft mix and clips the axi
404
404
  assert.equal(C.craftMix(within).length, 1, 'only the recent craft survives a 30-day window');
405
405
  });
406
406
 
407
- test('the drachmae line is the credits ledger: /me/credits on your own profile, cumulated by recorded_at, ending at the balance', async () => {
407
+ test('the drachmae line is the earnings by day, cumulated, ending at the balance', async () => {
408
408
  const rows = [ship('2026-08-01'), ship('2026-08-20')]; // per-task awards would sum to 20
409
- const ledger = [pay('2026-09-01', 12), pay('2026-08-20', 39), pay('2026-08-20', 19), pay('2026-07-05', 30)]; // balance 100
410
- const b = bootRender({ rows, ledger });
409
+ // two kinds on one day are two rows; they fold into one point
410
+ const earnings = [pay('2026-07-05', 30), pay('2026-08-20', 39), pay('2026-08-20', 19, 'task'), pay('2026-09-01', 12)]; // balance 100
411
+ const b = bootRender({ rows, earnings });
411
412
  await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
412
- assert.ok(b.requests.some((u) => u.includes('/me/credits?limit=200&offset=0')), 'own profile: /me/credits, paged');
413
- assert.ok(!b.requests.some((u) => u.includes('/builders/95/credits')), 'not the roster route');
413
+ assert.ok(b.requests.some((u) => u.endsWith('/api/bongos/builders/95/earnings')), 'one earnings read, unpaged');
414
414
  const out = b.mount.innerHTML;
415
415
  const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
416
416
  assert.ok(fig.includes('>100 drachmae</text>'), 'the endpoint is the ledger balance, not the 20 of per-task awards');
417
417
  assert.ok(fig.includes('<td>2026-07-05</td><td class="ledger__num">30 drachmae</td>'), 'a paid day with no ship is still a point');
418
- assert.ok(fig.includes('<td>2026-08-20</td><td class="ledger__num">88 drachmae</td>'), 'two payments on one day fold into one point');
419
- assert.match(fig, /100 paid, from the credits ledger/);
418
+ assert.ok(fig.includes('<td>2026-08-20</td><td class="ledger__num">88 drachmae</td>'), 'two kinds on one day fold into one point');
419
+ assert.match(fig, /100 drachmae in all/);
420
420
  assert.doesNotMatch(fig, /estimated/);
421
421
  });
422
422
 
423
- test('another builder\'s ledger is read from /builders/:id/credits, paged by 200 to total', async () => {
424
- const ledger = Array.from({ length: 450 }, (_, i) => pay(`2026-08-${String(1 + (i % 28)).padStart(2, '0')}`, 1));
425
- const b = bootRender({ rows: [ship('2026-08-01')], ledger });
423
+ test('another builder\'s earnings come from the same route, never the itemised ledger', async () => {
424
+ const b = bootRender({ rows: [ship('2026-08-01')], earnings: [pay('2026-08-01', 450)] });
426
425
  await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
427
- const pages = b.requests.filter((u) => u.includes('/builders/95/credits'));
428
- assert.equal(pages.length, 3, 'three pages of 200 for 450 rows');
429
- assert.ok(!b.requests.some((u) => u.includes('/me/credits')), 'never your own ledger for someone else\'s chart');
430
- assert.ok(b.mount.innerHTML.includes('>450 drachmae</text>'), 'and every row counted once');
426
+ assert.ok(b.requests.some((u) => u.includes('/builders/95/earnings')), 'the earnings read');
427
+ assert.ok(!b.requests.some((u) => u.includes('/credits')), 'no ledger read, so no viewer is refused and handed an estimate');
428
+ assert.ok(b.mount.innerHTML.includes('>450 drachmae</text>'));
431
429
  });
432
430
 
433
- test('a viewer the ledger refuses gets the credits_reward estimate, cumulated and labelled estimated', async () => {
434
- // the cost-plus shape: every award 0, the estimate is all there is to show
435
- const rows = [ship('2026-08-01', { credits_awarded: 0, credits_reward: 45 }), ship('2026-08-20', { credits_awarded: 0, credits_reward: 55 })];
436
- const b = bootRender({ rows, ledger: 'denied' });
431
+ test('an earnings read that fails says so in its figure; the works charts still draw', async () => {
432
+ const b = bootRender({ rows: [ship('2026-08-01')], earnings: 'failed' });
437
433
  await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
438
434
  const out = b.mount.innerHTML;
439
- const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
440
- assert.ok(fig.includes('>100 drachmae</text>'), 'the estimate climbs where the 0 awards would have flatlined');
441
- assert.match(fig, /estimated from shipped work, not the ledger/);
442
- assert.ok(!out.includes('id="chart-rate"'), 'the pace chart stays own-only');
443
- assert.ok(!out.includes('Could not load'), 'a 403 on the ledger is a fallback, not a failure');
435
+ assert.match(out, /Could not load the earnings/);
436
+ assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-mix"'), 'the record is not taken down with it');
437
+ assert.doesNotMatch(out, />0 drachmae</, 'never a zero it did not read');
444
438
  });
445
439
 
446
- test('a readable ledger with nothing in it draws one honest line, not a crash', async () => {
447
- const b = bootRender({ rows: [ship('2026-08-01')], ledger: [] });
440
+ test('earnings with nothing in them, beside a shipped record, draw one honest line, not a crash', async () => {
441
+ const b = bootRender({ rows: [ship('2026-08-01')], earnings: [] });
448
442
  await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
449
443
  const out = b.mount.innerHTML;
450
444
  const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
@@ -452,14 +446,19 @@ test('a readable ledger with nothing in it draws one honest line, not a crash',
452
446
  assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-mix"'), 'the other charts are untouched');
453
447
  });
454
448
 
455
- test('the harness carries a credits-ledger fixture the chart can climb on, self-paged, serving both routes', () => {
456
- const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'me__credits.json'), 'utf8'));
457
- assert.ok(Array.isArray(fx.rows) && fx.rows.length > 1, 'rows to draw');
458
- assert.equal(fx.page.total, fx.rows.length, 'total pinned to the rows here, or the pager would loop on the same page');
459
- assert.ok(fx.rows.every((r) => Number.isFinite(r.delta) && Number.isFinite(Date.parse(r.recorded_at))), 'every row is a delta at a time');
460
- assert.ok(new Set(fx.rows.map((r) => r.recorded_at.slice(0, 10))).size > 1, 'across more than one day, so the line is not flat');
449
+ test('the harness carries earnings fixtures the chart can climb on, each summing to its own total', () => {
461
450
  const server = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'server.js'), 'utf8');
462
- assert.ok(server.includes("[/^builders\\/\\d+\\/credits$/, 'me__credits']"), 'the roster route falls back to the same ledger');
451
+ assert.ok(server.includes("[/^builders\\/\\d+\\/earnings$/, 'builders__earnings']"), 'any id falls back to the generic earnings');
452
+ for (const name of ['builders__earnings', 'builders__2001__earnings', 'builders__2002__earnings']) {
453
+ const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', `${name}.json`), 'utf8'));
454
+ assert.ok(Array.isArray(fx.days) && new Set(fx.days.map((d) => d.day)).size > 1, `${name}: across more than one day, so the line is not flat`);
455
+ assert.ok(fx.days.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d.day) && Number.isFinite(d.credits)), `${name}: every row is credits on a day`);
456
+ const sum = fx.days.reduce((s, d) => s + d.credits, 0);
457
+ assert.equal(sum, fx.total, `${name}: the days add up to the total`);
458
+ for (const [kind, n] of Object.entries(fx.totals)) {
459
+ assert.equal(fx.days.filter((d) => d.kind === kind).reduce((s, d) => s + d.credits, 0), n, `${name}: ${kind} adds up`);
460
+ }
461
+ }
463
462
  });
464
463
 
465
464
  test('the readout is written with textContent — the hover layer never builds markup from data', () => {
@@ -0,0 +1,237 @@
1
+ // tests/profile_earnings.mjs — everyone gets an earnings chart, labelled by kind
2
+ // of credit (task 1004436 / WA6.RP06, goal 1000095, criterion
3
+ // wa6-roles-read-true-in-public).
4
+ //
5
+ // The done-when: "an artist and an ideator fixture with credits and zero shipped
6
+ // tasks both get a labelled earnings chart". Run for real, end to end:
7
+ //
8
+ // 1. THE KINDS. Which kind each ledger reason is, as the read's own SQL says it,
9
+ // and the one kind the reason cannot tell apart (a descent hop shares stream
10
+ // 1's key) held against the WRITER's own description text.
11
+ // 2. THE FOLD. Every kind named, zero days dropped, the parts summing to the
12
+ // whole.
13
+ // 3. THE ROUTE. Signed in only, a bad id refused, an unknown builder a 404, and
14
+ // the read scoped to that one builder.
15
+ // 4. THE PAGE. The REAL profile-charts.js on the REAL kit, fed the harness's
16
+ // artist and ideator fixtures (credits, no shipped task): a figure, its
17
+ // lede, and "where it came from" in the mock's words.
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ process.env.NODE_ENV = 'test';
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
30
+ const { test, summary } = makeRunner();
31
+
32
+ const earnings = require('../modules/economy/earnings.js');
33
+ const credits = require('../modules/economy/credits.js');
34
+ const fixture = (name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures', `${name}.json`), 'utf8'));
35
+
36
+ // ---- 1. the kinds -------------------------------------------------------------
37
+
38
+ await test('every kind the SQL can name is a kind the page has words for', () => {
39
+ const named = [...earnings.EARNINGS_SQL.matchAll(/THEN '([a-z-]+)'/g)].map((m) => m[1]);
40
+ named.push('other'); // the ELSE
41
+ assert.deepEqual([...new Set(named)].sort(), [...earnings.KINDS].sort(), 'the SQL and KINDS name the same set');
42
+ const page = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-charts.js'), 'utf8');
43
+ for (const k of earnings.KINDS) assert.ok(page.includes(`${k.includes('-') ? `'${k}'` : k}: '`), `the page has words for ${k}`);
44
+ });
45
+
46
+ await test('a descent hop is told from a direct payout by the WRITER\'s own words', () => {
47
+ const src = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
48
+ // the descent writer and the direct writer, by their own describe() lines
49
+ const descent = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped \$\{depth\} hop[^\n]*/);
50
+ const direct = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped under idea[^\n]*/);
51
+ assert.ok(descent && direct, 'both writers are where this test reads them');
52
+ assert.ok(descent[0].includes(earnings.DESCENT_MARK.trim()), 'the descent writer says DESCENT_MARK');
53
+ assert.ok(!direct[0].includes(earnings.DESCENT_MARK.trim()), 'and the direct one does not, or every idea payout would read as descent');
54
+ // the descent case is checked BEFORE the plain stream-1 case, or it never matches
55
+ const sql = earnings.EARNINGS_SQL;
56
+ assert.ok(sql.indexOf("'idea-descent'") < sql.indexOf("'idea-ship'"), 'descent first');
57
+ });
58
+
59
+ await test('the LIKE patterns are the economy\'s own prefixes, escaped', async () => {
60
+ let seen = null;
61
+ await earnings.readEarnings('7', { client: { query: async (sql, params) => { seen = { sql, params }; return { rows: [] }; } } });
62
+ assert.equal(seen.params[0], '7', 'scoped to the one builder');
63
+ assert.equal(seen.params[1], credits.ARTIST_PAGE_REASON_PREFIX.replace(/_/g, '\\_') + '%');
64
+ assert.equal(seen.params[2], credits.IDEA_STREAM_REASON_PREFIXES.task_ship.replace(/_/g, '\\_') + '%');
65
+ assert.equal(seen.params[3], credits.IDEA_STREAM_REASON_PREFIXES.full_pass.replace(/_/g, '\\_') + '%');
66
+ assert.equal(seen.params[4], credits.IDEA_STREAM_REASON_PREFIXES.governance_pass.replace(/_/g, '\\_') + '%');
67
+ assert.equal(seen.params[5], `%${earnings.DESCENT_MARK}%`);
68
+ assert.match(seen.sql, /WHERE builder_id = \$1\b/, 'never the whole ledger');
69
+ assert.doesNotMatch(seen.sql.split('FROM')[0].replace(/COALESCE\(description[^)]*\)/, ''), /description|task_id|session_id/, 'no itemisation leaves the read');
70
+ });
71
+
72
+ // ---- 2. the fold --------------------------------------------------------------
73
+
74
+ await test('the fold names every kind, drops zero days, sums to the whole and sorts by day', () => {
75
+ const out = earnings.foldEarnings([
76
+ { day: '2026-09-02', kind: 'page', credits: 30 },
77
+ { day: '2026-09-01', kind: 'idea-ship', credits: 12.4 },
78
+ { day: '2026-09-01', kind: 'session', credits: 0 },
79
+ { day: '2026-09-03', kind: 'something-new', credits: 5 },
80
+ ]);
81
+ assert.deepEqual(Object.keys(out.totals).sort(), [...earnings.KINDS].sort(), 'every kind, zero where nothing was earned');
82
+ assert.equal(out.totals.page, 30);
83
+ assert.equal(out.totals['idea-ship'], 12);
84
+ assert.equal(out.totals.other, 5, 'a kind the table does not know is still counted');
85
+ assert.equal(out.total, 47);
86
+ assert.deepEqual(out.days.map((d) => d.day), ['2026-09-01', '2026-09-02', '2026-09-03'], 'the zero day is gone');
87
+ });
88
+
89
+ // ---- 3. the route -------------------------------------------------------------
90
+
91
+ await test('the route: signed in only, a bad id refused, an unknown builder a 404', async () => {
92
+ const api = require('../src/module-api.js');
93
+ // The read itself is proven above through its `client` seam, against its own
94
+ // SQL; here it is swapped before the route loads (the route takes it at
95
+ // require time), so the test never opens a database pool.
96
+ const realRead = earnings.readEarnings;
97
+ const realGet = api.getBuilderById;
98
+ const realRequire = api.requireBuilder;
99
+ const asked = [];
100
+ earnings.readEarnings = async (id) => { asked.push([id]); return earnings.foldEarnings([{ day: '2026-09-01', kind: 'page', credits: 30 }]); };
101
+ api.getBuilderById = async (id) => (String(id) === '2001' ? { id: 2001 } : null);
102
+ let signedIn = true;
103
+ api.requireBuilder = (req, res, next) => (signedIn ? next() : res.status(401).json({ error: 'unauthorized' }));
104
+ const express = require('express');
105
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
106
+ const app = express();
107
+ app.use(attachFail);
108
+ app.use(require('../modules/economy/routes/earnings.js')());
109
+ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
110
+ const get = async (p) => { const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`); return { status: r.status, body: await r.json() }; };
111
+ try {
112
+ const ok = await get('/builders/2001/earnings');
113
+ assert.equal(ok.status, 200);
114
+ assert.equal(ok.body.total, 30);
115
+ assert.deepEqual(ok.body.days, [{ day: '2026-09-01', kind: 'page', credits: 30 }]);
116
+ assert.equal(String(asked.at(-1)[0]), '2001', 'the read is that builder\'s rows');
117
+ assert.equal((await get('/builders/abc/earnings')).status, 400);
118
+ assert.equal((await get('/builders/9999/earnings')).status, 404);
119
+ signedIn = false;
120
+ assert.equal((await get('/builders/2001/earnings')).status, 401, 'owner decision (a): earnings need a sign-in');
121
+ } finally {
122
+ earnings.readEarnings = realRead;
123
+ api.getBuilderById = realGet;
124
+ api.requireBuilder = realRequire;
125
+ server.closeAllConnections();
126
+ await new Promise((r) => server.close(r));
127
+ // Let the closed sockets' handles finish closing before the runner's
128
+ // process.exit, or Windows' libuv aborts on a handle still mid-close.
129
+ await new Promise((r) => setTimeout(r, 50));
130
+ }
131
+ });
132
+
133
+ await test('the economy module mounts the route', () => {
134
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/economy/module.json'), 'utf8'));
135
+ assert.ok(manifest.contributes.routes.includes('earnings'));
136
+ });
137
+
138
+ // ---- 4. the page --------------------------------------------------------------
139
+
140
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
141
+ const NOW = Date.parse('2026-10-01T12:00:00Z');
142
+
143
+ function bootCharts(route) {
144
+ const requests = [];
145
+ const mount = { innerHTML: '', querySelector: () => null };
146
+ const controls = { innerHTML: '', addEventListener() {}, querySelectorAll: () => [] };
147
+ const api = {
148
+ request: async (method, url) => {
149
+ requests.push(url);
150
+ const hit = route(url);
151
+ return hit ? { status: 200, ok: true, data: hit } : { status: 404, ok: false, data: null };
152
+ },
153
+ };
154
+ const sandbox = {
155
+ window: {
156
+ __BRANDING__: { currency: { label: 'credits' } },
157
+ localStorage: { getItem: () => null, setItem() {} },
158
+ OTB: {
159
+ roleAxisLabel: () => 'Role',
160
+ roleLabel: (d) => d,
161
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
162
+ $: (sel) => (sel === '#charts-grid' ? mount : sel === '#charts-controls' ? controls : null),
163
+ fmtNum: (n) => String(n ?? 0),
164
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
165
+ },
166
+ },
167
+ console, Date, Map, Set, Math, Number, String, Array, Object, Infinity, URLSearchParams, Promise,
168
+ };
169
+ sandbox.globalThis = sandbox;
170
+ vm.createContext(sandbox);
171
+ for (const f of ['hall-kit.js', 'profile-charts.js']) {
172
+ vm.runInContext(fs.readFileSync(path.join(HALL, f), 'utf8'), sandbox, { filename: f });
173
+ }
174
+ return { C: sandbox.window.OTBProfileCharts, api, requests, mount };
175
+ }
176
+
177
+ // the harness's own fixtures: an artist and an ideator who never shipped a task
178
+ const PEOPLE = [
179
+ {
180
+ who: 'artist', id: 2001, name: 'Builder 7',
181
+ lede: 'Builder 7 has never shipped a task. Every credit here came from approved pages and ideas.',
182
+ kinds: [['Pages approved', '370'], ['Ideas that became shipped work', '42']],
183
+ end: '412 credits',
184
+ },
185
+ {
186
+ who: 'ideator', id: 2002, name: 'Builder 9',
187
+ lede: 'Builder 9 has never shipped a task. Every credit here came from ideas.',
188
+ kinds: [['Ideas that became shipped work', '96'], ['Work split from their ideas', '38'], ['Ratified by the board', '30'], ['Full Ideas passed', '22']],
189
+ end: '186 credits',
190
+ },
191
+ ];
192
+
193
+ for (const P of PEOPLE) {
194
+ await test(`the ${P.who} fixture, with credits and no shipped task, gets a labelled earnings chart`, async () => {
195
+ const activity = fixture(`builders__${P.id}__activity`);
196
+ const earned = fixture(`builders__${P.id}__earnings`);
197
+ assert.equal(activity.rows.length, 0, 'the fixture really has no shipped task');
198
+ const b = bootCharts((url) => (url.includes('/activity') ? activity : url.includes('/earnings') ? earned : null));
199
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: P.id, selfId: 1, signedIn: true, name: P.name, now: NOW });
200
+ const out = b.mount.innerHTML;
201
+ assert.ok(out.includes('id="chart-drachmae"'), 'the earnings figure drew');
202
+ assert.ok(out.includes('Earnings over time'));
203
+ assert.ok(out.includes(P.lede), 'the lede says who never shipped, and where the credits came from');
204
+ assert.ok(out.includes(`>${P.end}</text>`), 'the line ends at the balance');
205
+ assert.ok(out.includes('where it came from'));
206
+ // "where it came from", in order, with its numbers
207
+ const rows = [...out.matchAll(/<span class="kit-bars__label">([^<]+)<\/span>[\s\S]*?<span class="kit-bars__val">([^<]+)<\/span>/g)].map((m) => [m[1], m[2]]);
208
+ assert.deepEqual(rows, P.kinds, 'every kind that earned, largest first, in the mock\'s words');
209
+ assert.ok(out.includes('No works shipped yet'), 'the works charts say "none yet" on their own');
210
+ assert.ok(!out.includes('id="chart-works"'), 'and draw no empty card');
211
+ assert.ok(!b.requests.some((u) => u.includes('/credits')), 'the itemised ledger is never read');
212
+ });
213
+ }
214
+
215
+ await test('the kinds follow the lookback window; the line still ends at the all-time total', () => {
216
+ const b = bootCharts(() => null);
217
+ const days = [{ day: '2026-06-01', kind: 'page', credits: 100 }, { day: '2026-09-25', kind: 'idea-ship', credits: 5 }];
218
+ assert.deepEqual(JSON.parse(JSON.stringify(b.C.earningsKinds(days))).map((k) => k.kind), ['page', 'idea-ship']);
219
+ assert.deepEqual(JSON.parse(JSON.stringify(b.C.earningsKinds(days.slice(1)))).map((k) => k.kind), ['idea-ship'], 'a 30-day window drops June');
220
+ });
221
+
222
+ await test('the lede: your own profile says "you"; someone who ships gets the plain sentence', () => {
223
+ const { C } = bootCharts(() => null);
224
+ const kinds = [{ kind: 'idea-ship', credits: 5 }];
225
+ assert.equal(C.earningsLede({ isOwn: true, shipped: false, kinds }), 'You have never shipped a task. Every credit here came from ideas.');
226
+ assert.equal(C.earningsLede({ name: 'Mara', isOwn: false, shipped: true, kinds }), 'Every credit Mara has earned, by the kind of work that earned it.');
227
+ assert.equal(C.earningsLede({ isOwn: true, shipped: true, kinds }), 'Every credit you have earned, by the kind of work that earned it.');
228
+ });
229
+
230
+ await test('signed out, nothing is asked for and the line says why', async () => {
231
+ const b = bootCharts(() => ({}));
232
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 2002, selfId: null, signedIn: false, now: NOW });
233
+ assert.equal(b.requests.length, 0);
234
+ assert.match(b.mount.innerHTML, /Earnings and shipped work are visible to signed-in builders/);
235
+ });
236
+
237
+ summary();
@@ -117,6 +117,23 @@ test('building and creating say where it is, with the stage marked', () => {
117
117
  assert.match(w.html({ app: { ...BUILDING.app, mode: 'adopt' } }), /Linked\. Waiting for your app to answer/);
118
118
  });
119
119
 
120
+ // Task 1004502: past the time box the card stops saying "building" and sends the owner to
121
+ // Render's deploy history, while the watch keeps asking (the app may still come up).
122
+ test('a quiet build: no "building", the way to Render\'s deploy history, and the watch keeps going', () => {
123
+ const w = load();
124
+ const QUIET = { ok: true, app: { ...BUILDING.app, stage: 'quiet', quiet: true,
125
+ links: { ...LINKS, deploys: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/events' } }, env_vars: [] };
126
+ const html = w.html(QUIET);
127
+ assert.doesNotMatch(html, /Render is building your app/);
128
+ assert.doesNotMatch(html, /appStages/, 'no progress rail claims a stage');
129
+ assert.match(html, /<p>Your app hasn’t answered for over 20 minutes\. Render may have stopped the deploy — its deploy history says why\.<\/p>/);
130
+ assert.match(html, /<a href="https:\/\/dashboard\.render\.com\/web\/srv-aaaaaaaa1111\/events" target="_blank" rel="noopener noreferrer">See its deploys on Render<\/a>/);
131
+ assert.match(html, /If it starts answering, this changes to Live by itself\./);
132
+ assert.equal(w.appTerminal(QUIET), false, 'the watch keeps asking');
133
+ const adopt = w.html({ ...QUIET, app: { ...QUIET.app, mode: 'adopt' } });
134
+ assert.match(adopt, /Your app hasn’t answered for over 20 minutes/);
135
+ });
136
+
120
137
  test('a failure: the sentence, a Retry, and no promise of a retry by itself', () => {
121
138
  const w = load();
122
139
  const v = w.view(FAILED);
@@ -48,7 +48,8 @@ await ta('each runner state maps to the stage the owner sees', () => {
48
48
  await ta('a live app carries its address and the two Render links', () => {
49
49
  const s = appStatus({ ...HOST, state: 'live' });
50
50
  assert.equal(s.url, 'https://demo.onrender.com');
51
- assert.deepEqual(s.links, { service: 'https://dashboard.render.com/web/srv-aaaaaaaa1111', env: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/env' });
51
+ assert.deepEqual(s.links, { service: 'https://dashboard.render.com/web/srv-aaaaaaaa1111', env: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/env',
52
+ deploys: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/events' });
52
53
  assert.equal(s.error, null);
53
54
  assert.equal(s.retry, null);
54
55
  });
@@ -75,6 +76,29 @@ await ta('a run still holding the lent key reads as waiting, not as something to
75
76
  assert.equal(s.retry, null);
76
77
  });
77
78
 
79
+ // Task 1004502: Render can give up on a deploy (project 43 timed out in 15 minutes) and the
80
+ // address check alone can never learn it, so a build that has not answered in QUIET_AFTER_MS
81
+ // stops claiming to be building and sends the owner to Render's own deploy history.
82
+ await ta('a build with no answer past the time box reads "quiet", not "building" (task 1004502)', () => {
83
+ const { QUIET_AFTER_MS } = require('../modules/provisioning/app-status.js');
84
+ assert.equal(QUIET_AFTER_MS, 20 * 60_000);
85
+ const t0 = Date.parse('2026-10-01T10:00:00Z');
86
+ const at = (state, ms) => appStatus({ ...HOST, state, updatedAt: new Date(t0).toISOString() }, { now: t0 + ms });
87
+ assert.equal(at('deploying', QUIET_AFTER_MS - 1000).quiet, false, 'still inside the time box');
88
+ assert.equal(at('deploying', QUIET_AFTER_MS - 1000).stage, 'building');
89
+ const q = at('deploying', QUIET_AFTER_MS + 1000);
90
+ assert.equal(q.quiet, true);
91
+ assert.equal(q.stage, 'quiet', 'it no longer reads as building');
92
+ assert.equal(q.links.deploys, 'https://dashboard.render.com/web/srv-aaaaaaaa1111/events');
93
+ assert.equal(q.error, null, 'not a failure: the platform cannot see Render\'s verdict');
94
+ assert.equal(q.retry, null);
95
+ assert.equal(at('linked', QUIET_AFTER_MS + 1000).stage, 'quiet', 'an adopted app waits the same way');
96
+ assert.equal(at('live', QUIET_AFTER_MS * 10).stage, 'live', 'a live app is never quiet');
97
+ assert.equal(at('creating', QUIET_AFTER_MS * 10).quiet, false, 'only a build is time-boxed');
98
+ assert.equal(appStatus({ ...HOST, state: 'deploying' }, { now: t0 }).quiet, false, 'no start time → no claim');
99
+ assert.equal(appStatus({ ...HOST, state: 'deploying', updatedAt: 'nonsense' }, { now: t0 }).quiet, false);
100
+ });
101
+
78
102
  console.log('\nGET /provisioning/instances/:id/render:');
79
103
  const provisioning = require('../modules/provisioning/provisioning.js');
80
104
  const envManifest = require('../modules/provisioning/env-manifest.js');