@bongos/core 1.20.81 → 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 (45) hide show
  1. package/.bongos-core.json +71 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +46 -1
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +33 -26
  10. package/docs/copy-registry.json +164 -92
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-readings.json +58 -43
  13. package/modules/economy/earnings.js +117 -0
  14. package/modules/economy/module.json +1 -1
  15. package/modules/economy/routes/earnings.js +47 -0
  16. package/modules/hall-ui/public/gate.css +49 -0
  17. package/modules/hall-ui/public/gate.html +5 -7
  18. package/modules/hall-ui/public/gate.js +215 -127
  19. package/modules/hall-ui/public/profile-charts.js +206 -104
  20. package/modules/hall-ui/public/profile.css +23 -0
  21. package/modules/hall-ui/public/profile.js +3 -3
  22. package/modules/hall-ui/public/profile.states.json +8 -4
  23. package/modules/hall-ui/records/profile-roles.md +21 -0
  24. package/modules/lifecycle/db-goals.js +6 -0
  25. package/modules/lifecycle/db-versions.js +3 -0
  26. package/modules/lifecycle/done-when.js +4 -0
  27. package/modules/lifecycle/goal-close-sweep.js +34 -0
  28. package/modules/provisioning/app-status.js +17 -4
  29. package/modules/public-landing/public/projects.html +7 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +22 -0
  33. package/scripts/hall-preview/server.js +4 -0
  34. package/src/module-api.js +1 -1
  35. package/tests/auto_satisfy_criteria.mjs +8 -6
  36. package/tests/autonomy_builder_scope.mjs +1 -1
  37. package/tests/autonomy_fence_priority.mjs +5 -3
  38. package/tests/autonomy_runner_identity.mjs +1 -1
  39. package/tests/criterion_goal_attach.mjs +5 -1
  40. package/tests/goal_close_cancels_requests.mjs +82 -0
  41. package/tests/goal_closure_invariants.mjs +5 -2
  42. package/tests/hall_profile_world.mjs +57 -58
  43. package/tests/profile_earnings.mjs +237 -0
  44. package/tests/projects_hub_app_status.mjs +17 -0
  45. package/tests/provisioning_app_status.mjs +25 -1
@@ -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');