@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
@@ -27,6 +27,7 @@ const { pool } = require('../../src/module-api');
27
27
  const { nonTerminalSql } = require('./task-status');
28
28
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
29
29
  const { executorFor } = require('./db-executor.js');
30
+ const { cancelPendingMembershipRequests } = require('./goal-close-sweep.js');
30
31
  // BV1.R16 (task 1003603) — auto-close. Dependency-free module, so requiring it
31
32
  // here creates no cycle (db-versions.js requires THIS file, which is why the
32
33
  // rule could not live beside closeVersion).
@@ -565,6 +566,9 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
565
566
  RETURNING g.id, g.title, g.version_id, g.status`,
566
567
  [goalIds]
567
568
  );
569
+ // Task 1003660: an achieved goal takes no new members — close its pending asks
570
+ // on the same executor, so the cancel rides the ship transaction.
571
+ await cancelPendingMembershipRequests(exec, goals.map((g) => g.id));
568
572
  // BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
569
573
  // non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
570
574
  // above that have not committed yet — a check on the pool would never close the
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ // goal-close-sweep.js — closing a goal cancels its outstanding invites + join
3
+ // requests (task 1003660, from idea 1000787).
4
+ //
5
+ // Task 1002887 added the accept-time `goal_not_open` re-check, which refuses a
6
+ // stale invite when someone tries to accept it — but until then the row kept
7
+ // listing in the invitee's inbox as something they could act on. A goal that has
8
+ // left `open` (achieved or archived) can take no new members, so its pending rows
9
+ // are dead; this closes them at the moment the goal closes instead.
10
+ //
11
+ // BOTH kinds ('invite' and 'request') are cancelled, with the same 'cancelled'
12
+ // terminal state addGoalMember uses (core_188: "withdrawn/rescinded"). 'rejected'
13
+ // would stamp a decision on someone nobody made one about, and 'accepted' stays
14
+ // reserved for a flow that actually ran. resolved_by is left NULL: no person
15
+ // resolved it, the goal closing did.
16
+ //
17
+ // Takes the CALLER'S executor, never the module pool, and requires nothing — so
18
+ // the ship-time cascade in done-when.js (which cannot require db-goals.js without
19
+ // a cycle) and every archive path share one statement, and the cancel commits or
20
+ // rolls back with the status flip it belongs to.
21
+
22
+ async function cancelPendingMembershipRequests(exec, goalIds) {
23
+ const ids = (Array.isArray(goalIds) ? goalIds : [goalIds]).filter((g) => g != null).map(Number);
24
+ if (ids.length === 0) return 0;
25
+ const { rowCount } = await exec.query(
26
+ `UPDATE goal_membership_requests
27
+ SET status = 'cancelled', resolved_at = now(), updated_at = now()
28
+ WHERE goal_id = ANY($1::bigint[]) AND status = 'pending'`,
29
+ [ids]
30
+ );
31
+ return rowCount ?? 0;
32
+ }
33
+
34
+ module.exports = { cancelPendingMembershipRequests };
@@ -23,27 +23,40 @@ const RENDER_URL_RE = /^https:\/\/[a-z0-9-]{1,63}\.onrender\.com\/?$/;
23
23
 
24
24
  // The stages the owner sees, in order. `failed` is not a stage: it is the setup stopping.
25
25
  const STAGES = ['creating', 'building', 'live'];
26
+
27
+ // How long a build may go unanswered before the owner stops being told it is building
28
+ // (task 1004502). Render can give up on a deploy — project 43's timed out in 15 minutes —
29
+ // and with the key gone (ADR 0327) the platform cannot read that verdict. So past this the
30
+ // stage is `quiet`: not a failure (we cannot see one), but no longer a promise, with the
31
+ // way to Render's own deploy history. The poller keeps knocking, so a late answer still
32
+ // turns it `live`. Measured from the runner's last write (`updatedAt`, provision-render.js),
33
+ // which is when `deploying` or `linked` began — the poller's `live` write never touches it.
34
+ const QUIET_AFTER_MS = 20 * 60_000;
26
35
  const STAGE_OF = { requested: 'creating', creating: 'creating', deploying: 'building', linked: 'building', live: 'live' };
27
36
 
28
37
  function serviceLinks(serviceId) {
29
38
  if (!SERVICE_ID_RE.test(String(serviceId || ''))) return null;
30
39
  const base = `${DASHBOARD}/web/${serviceId}`;
31
- return { service: base, env: `${base}/env` };
40
+ return { service: base, env: `${base}/env`, deploys: `${base}/events` };
32
41
  }
33
42
 
34
43
  function appUrl(url) { return RENDER_URL_RE.test(String(url || '')) ? String(url).replace(/\/+$/, '') : null; }
35
44
 
36
45
  // The owner's view of app_host: the stage, the address, the links, the failure sentence,
37
46
  // and the answers a retry re-sends (never the key — there is none to send).
38
- function appStatus(appHost, { keyWaiting = false } = {}) {
47
+ function appStatus(appHost, { keyWaiting = false, now = Date.now() } = {}) {
39
48
  const h = appHost || null;
40
49
  if (!h || h.platform !== 'render') return null;
41
50
  if (h.state === 'disconnected') return { platform: 'render', state: 'disconnected', stage: null };
42
51
  const failed = h.state === 'failed';
52
+ const stage = failed ? null : (STAGE_OF[h.state] || 'creating');
53
+ const since = Date.parse(h.updatedAt || '');
54
+ const quiet = stage === 'building' && Number.isFinite(since) && now - since > QUIET_AFTER_MS;
43
55
  return {
44
56
  platform: 'render',
45
57
  state: h.state || 'requested',
46
- stage: failed ? null : (STAGE_OF[h.state] || 'creating'),
58
+ stage: quiet ? 'quiet' : stage,
59
+ quiet,
47
60
  // an intent that still holds the lent key has not run yet, so the owner waits rather
48
61
  // than retries
49
62
  waiting: !!keyWaiting,
@@ -80,4 +93,4 @@ function retryAnswers(h) {
80
93
  return r;
81
94
  }
82
95
 
83
- module.exports = { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers, STAGES, RENDER_URL_RE, SERVICE_ID_RE };
96
+ module.exports = { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers, STAGES, QUIET_AFTER_MS, RENDER_URL_RE, SERVICE_ID_RE };
@@ -2333,6 +2333,13 @@ summary{min-height:24px;padding:3px 0;}
2333
2333
  v.hint.setAttribute('role', 'status');
2334
2334
  v.retryForm.appendChild(v.hint);
2335
2335
  v.root.appendChild(v.retryForm);
2336
+ } else if (app.stage === 'quiet') {
2337
+ /* Past the time box (task 1004502): Render may have given up, and with the key gone we
2338
+ cannot see its verdict — so no stage is claimed, the owner is sent to Render's own
2339
+ deploy history, and the watch keeps asking in case the app still comes up. */
2340
+ v.root.appendChild(appPara(['Your app hasn’t answered for over 20 minutes. Render may have stopped the deploy — its deploy history says why.']));
2341
+ if (app.links && app.links.deploys) v.root.appendChild(appPara([appLink(app.links.deploys, 'See its deploys on Render')]));
2342
+ v.root.appendChild(appPara(['If it starts answering, this changes to Live by itself.']));
2336
2343
  } else {
2337
2344
  var at = APP_STAGES.map(function (s) { return s[0]; }).indexOf(app.stage);
2338
2345
  var ol = appEl('ol', 'appStages');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.81",
3
+ "version": "1.20.82",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.81",
9
+ "version": "1.20.82",
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.20.81",
3
+ "version": "1.20.82",
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",
@@ -8631,5 +8631,27 @@
8631
8631
  "id": "1004501",
8632
8632
  "text": "Each builder now chooses which goals their own Autobongos runner works, in Settings, from the goals the project allows. A builder who chooses nothing gets no unattended work. Who may run a runner at all is now a project permis"
8633
8633
  }
8634
+ ],
8635
+ "1.20.82": [
8636
+ {
8637
+ "id": "1004502",
8638
+ "text": "When an app on Render never comes up, its project card no longer says 'building' forever: after 20 minutes it says the app hasn't answered and links straight to the app's deploy history on Render."
8639
+ },
8640
+ {
8641
+ "id": "1004452",
8642
+ "text": "We tested the deploy self-repair on a real throwaway project on the server. When a project has leftover unsaved changes, the platform now fixes them itself and the update goes through. When it hits something it cannot fix, it"
8643
+ },
8644
+ {
8645
+ "id": "1004436",
8646
+ "text": "Every profile now shows an earnings chart for anyone who has earned credits, even artists and ideators who have never shipped a task (before, they got no chart at all). Beside the line, a \"where it came from\" list shows each k"
8647
+ },
8648
+ {
8649
+ "id": "1003660",
8650
+ "text": "Closing a goal now clears its pending invites and join requests, so a stale invite disappears from the invitee's inbox instead of sitting there until someone tries to accept it."
8651
+ },
8652
+ {
8653
+ "id": "1004509",
8654
+ "text": "The Autobongos runner panel on the Gate page is now much lighter to read. While anything is missing it shows a short checklist saying what to do next; once everything is ready it shrinks to one status line with an on/off switc"
8655
+ }
8634
8656
  ]
8635
8657
  }
@@ -118,6 +118,10 @@ const FIXTURE_FALLBACKS = [
118
118
  // the profile's drachmae chart reads the credits ledger (task 1003491): one
119
119
  // ledger fixture serves your own (/me/credits, exact match) and any other id.
120
120
  [/^builders\/\d+\/credits$/, 'me__credits'],
121
+ // the profile's earnings figure reads earnings by day and kind (task 1004436):
122
+ // one engineer's fixture serves any id; the artist and the ideator who never
123
+ // shipped a task (2001, 2002) carry their own, beside an empty activity record.
124
+ [/^builders\/\d+\/earnings$/, 'builders__earnings'],
121
125
  // the profile's per-craft activity log (task 1003281). One fixture per SECTION
122
126
  // rather than per id, because the section is what changes the row shape; the
123
127
  // craft and the page window are query string, which fixture matching ignores.
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.81'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.82'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -107,12 +107,14 @@ await test('ASYMMETRY (rollup): the /status rollup is untouched by this task', (
107
107
  await test('EXEC: every write goes through the caller-supplied exec (so a ship txn is atomic)', async () => {
108
108
  const exec = fakeExec({ rows: [CRIT] }, { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] });
109
109
  const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
110
- // Three since BV1.R16 (task 1003603): the criterion update, the goal cascade,
111
- // and the version auto-close — which is the point, because the third must ride
112
- // the SAME exec as the first two or a version would never close on the ship
113
- // that finished it.
114
- assert.equal(exec.calls.length, 3, 'criterion update + goal cascade + version auto-close');
115
- assert.match(exec.calls[2].sql, /UPDATE versions v/, 'and the third is the auto-close, on exec');
110
+ // Four: the criterion update, the goal cascade, the pending-request sweep for the
111
+ // goals it just achieved (task 1003660), and the version auto-close (BV1.R16,
112
+ // task 1003603) — every one must ride the SAME exec or a rolled-back ship could
113
+ // leave a half-applied close (or a version that never closes on the ship that
114
+ // finished it).
115
+ assert.equal(exec.calls.length, 4, 'criterion update + goal cascade + request sweep + version auto-close');
116
+ assert.match(exec.calls[2].sql, /UPDATE goal_membership_requests/, 'the sweep rides exec');
117
+ assert.match(exec.calls[3].sql, /UPDATE versions v/, 'and the last is the auto-close, on exec');
116
118
  assert.deepEqual(out.criteria, [CRIT]);
117
119
  assert.deepEqual(out.goals.map((g) => g.id), [77]);
118
120
  const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
@@ -457,7 +457,7 @@ test('autonomy.run is an operational Metic permission, granted to Metic and Arch
457
457
 
458
458
  test('the owner page draws the PROJECT fence and pauses one builder; the settings panel writes the caller’s own picks', () => {
459
459
  const gate = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
460
- assert.match(gate, /fence\.project \|\| fence/, 'the switch panel must not draw the owner’s own narrowed picks as the project list');
460
+ assert.match(gate, /const fence = raw\.project \|\| raw;/, 'the switch panel must not draw the owner’s own narrowed picks as the project list');
461
461
  assert.match(gate, /\/autonomy\/runners\/all/);
462
462
  assert.match(gate, /\/autonomy\/builders\/\$\{encodeURIComponent\(id\)\}\/pause/);
463
463
  const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.html'), 'utf8');
@@ -206,8 +206,10 @@ test('the gate page renders the priority, lists it first, and posts to the prior
206
206
  const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
207
207
  // The PROJECT fence's priority (task 1004501): GET /autonomy/fence now answers
208
208
  // with the caller's narrowed fence, and the switch panel draws `project`.
209
- assert.match(src, /renderFenceGoals\(project\.goals, project\.priority_goal_id\)/, 'the page must pass the priority it read');
209
+ // The chips (task 1004509) are drawn from that project fence.
210
+ assert.match(src, /goalChipsHtml\(fence\.goals, fence\.priority_goal_id\)/, 'the page must pass the priority it read');
210
211
  assert.match(src, /sendJson\('POST', '\/autonomy\/fence\/priority', \{ goal_id: goalId \}\)/);
211
- assert.match(src, /Make priority/);
212
- assert.match(src, /Clear priority/);
212
+ // The star on a chip sets it, and the star on the priority chip clears it.
213
+ assert.match(src, /Work this goal first/);
214
+ assert.match(src, /Worked first — click to clear/);
213
215
  });
@@ -208,7 +208,7 @@ test('the migration adds the four columns, additively', () => {
208
208
 
209
209
  test('the gate card draws the PC, the account, the code and the restarting label', () => {
210
210
  const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
211
- assert.match(src, /restarting: 'Restarting'/);
211
+ assert.match(src, /Restarting to pick up new code/);
212
212
  assert.match(src, /PC <strong>\$\{escapeHtml\(h\.host\)\}<\/strong>/);
213
213
  assert.match(src, /Claude account \$\{escapeHtml\(h\.account\)\}/);
214
214
  assert.match(src, /behind main/);
@@ -557,9 +557,13 @@ await test('setGoalStatus writes on the transaction client (task 1003840)', asyn
557
557
  const seen = [];
558
558
  const fakeClient = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 3, status: 'achieved' }] }; } };
559
559
  await realGoals.setGoalStatus({ goalId: 3, status: 'achieved' }, { client: fakeClient });
560
- assert.equal(seen.length, 1, 'the UPDATE reached the injected client');
560
+ // Two since task 1003660: the status flip, then the sweep of the goal's pending
561
+ // invites/requests. BOTH must land on the injected client, or the sweep would
562
+ // reintroduce the very pool-vs-transaction deadlock this test guards.
563
+ assert.equal(seen.length, 2, 'the UPDATE and the request sweep reached the injected client');
561
564
  assert.match(seen[0].sql, /UPDATE goals/);
562
565
  assert.deepEqual(seen[0].params, [3, 'achieved']);
566
+ assert.match(seen[1].sql, /UPDATE goal_membership_requests/);
563
567
  });
564
568
 
565
569
  // The wrapper installed above is removed, so nothing after this file's own tests
@@ -0,0 +1,82 @@
1
+ // tests/goal_close_cancels_requests.mjs
2
+ //
3
+ // Task 1003660: closing a goal (achieved / archived) cancels its pending invites
4
+ // and join-requests, so a stale row leaves the inbox instead of waiting to be
5
+ // refused at accept time. DB-free: a SQL-aware fake records the statements.
6
+ //
7
+ // Run: node tests/goal_close_cancels_requests.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
12
+
13
+ process.env.NODE_ENV = 'test';
14
+ const require = createRequire(import.meta.url);
15
+ const { setGoalStatus } = require('../modules/lifecycle/db-goals.js');
16
+ const { cancelPendingMembershipRequests } = require('../modules/lifecycle/goal-close-sweep.js');
17
+
18
+ const { test, summary } = makeRunner();
19
+
20
+ function fakePool(handler) {
21
+ const c = makeSqlAwareClient(handler);
22
+ return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
23
+ }
24
+ const norm = (q) => String(q.sql).replace(/\s+/g, ' ');
25
+ const sweeps = (pool) => pool.queries.filter((q) => /^UPDATE goal_membership_requests/.test(norm(q)));
26
+ const goalRow = (status) => ({ id: 7, status });
27
+ const flip = (status) => (s) => (/^UPDATE goals/.test(s) ? { rows: [goalRow(status)] } : null);
28
+
29
+ await test('cancelPendingMembershipRequests: cancels BOTH kinds, pending only, for the given goals', async () => {
30
+ const pool = fakePool(() => ({ rows: [], rowCount: 2 }));
31
+ const n = await cancelPendingMembershipRequests(pool, [7, '8']);
32
+ assert.equal(n, 2);
33
+ const [q] = sweeps(pool);
34
+ assert.match(norm(q), /SET status = 'cancelled'/);
35
+ assert.match(norm(q), /status = 'pending'/);
36
+ assert.doesNotMatch(norm(q), /kind\s*=/, 'neither the invite nor the request arm is excluded');
37
+ assert.deepEqual(q.params, [[7, 8]]);
38
+ });
39
+
40
+ await test('cancelPendingMembershipRequests: no ids → no statement', async () => {
41
+ const pool = fakePool(() => ({ rows: [] }));
42
+ assert.equal(await cancelPendingMembershipRequests(pool, []), 0);
43
+ assert.equal(await cancelPendingMembershipRequests(pool, null), 0);
44
+ assert.equal(pool.queries.length, 0);
45
+ });
46
+
47
+ for (const status of ['achieved', 'archived']) {
48
+ await test(`setGoalStatus(${status}) sweeps the goal's pending requests`, async () => {
49
+ const pool = fakePool(flip(status));
50
+ await setGoalStatus({ goalId: 7, status }, { pool });
51
+ assert.equal(sweeps(pool).length, 1);
52
+ assert.deepEqual(sweeps(pool)[0].params, [[7]]);
53
+ });
54
+ }
55
+
56
+ await test('setGoalStatus(open) — a reopen — sweeps nothing', async () => {
57
+ const pool = fakePool(flip('open'));
58
+ await setGoalStatus({ goalId: 7, status: 'open' }, { pool });
59
+ assert.equal(sweeps(pool).length, 0);
60
+ });
61
+
62
+ await test('setGoalStatus on an unknown goal sweeps nothing', async () => {
63
+ const pool = fakePool(() => ({ rows: [] }));
64
+ await setGoalStatus({ goalId: 99, status: 'archived' }, { pool });
65
+ assert.equal(sweeps(pool).length, 0);
66
+ });
67
+
68
+ await test('every in-module writer that archives or achieves a goal also sweeps', async () => {
69
+ const { readFileSync } = await import('node:fs');
70
+ const read = (f) => readFileSync(new URL(`../modules/lifecycle/${f}`, import.meta.url), 'utf8');
71
+ const count = (s, re) => (s.match(re) || []).length;
72
+ const closes = /status = 'archived'|status = 'achieved'/g;
73
+ for (const f of ['db-goals.js', 'db-versions.js', 'done-when.js']) {
74
+ const src = read(f);
75
+ // setGoalStatus takes the status as a parameter (covered above); every
76
+ // literal close in these files must have a sweep call.
77
+ assert.ok(count(src, /cancelPendingMembershipRequests\(/g) >= count(src, closes),
78
+ `${f}: each literal goal close needs a cancelPendingMembershipRequests call`);
79
+ }
80
+ });
81
+
82
+ summary();
@@ -256,10 +256,13 @@ test('every statement goes through the caller exec — none reaches the module p
256
256
  // nothing reaches the module pool — is exactly what makes auto-close correct on
257
257
  // a ship: the goal it keys on was flipped in this uncommitted transaction, and
258
258
  // a pool read would not see it.
259
- assert.equal(exec.calls.length, 3, 'the criterion close, the cascade and the version auto-close, all on exec');
259
+ // Task 1003660 added a fourth: the sweep of the achieved goal's pending
260
+ // invites/requests, between the cascade and the version auto-close.
261
+ assert.equal(exec.calls.length, 4, 'the criterion close, the cascade, the request sweep and the version auto-close, all on exec');
260
262
  assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
261
263
  assert.match(exec.calls[1].sql, /UPDATE goals g/);
262
- assert.match(exec.calls[2].sql, /UPDATE versions v/);
264
+ assert.match(exec.calls[2].sql, /UPDATE goal_membership_requests/);
265
+ assert.match(exec.calls[3].sql, /UPDATE versions v/);
263
266
  });
264
267
 
265
268
  // ---------------------------------------------------------------------------
@@ -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', () => {