@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.
- package/.bongos-core.json +71 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +46 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +33 -26
- package/docs/copy-registry.json +164 -92
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +58 -43
- package/modules/economy/earnings.js +117 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/routes/earnings.js +47 -0
- package/modules/hall-ui/public/gate.css +49 -0
- package/modules/hall-ui/public/gate.html +5 -7
- package/modules/hall-ui/public/gate.js +215 -127
- package/modules/hall-ui/public/profile-charts.js +206 -104
- package/modules/hall-ui/public/profile.css +23 -0
- package/modules/hall-ui/public/profile.js +3 -3
- package/modules/hall-ui/public/profile.states.json +8 -4
- package/modules/hall-ui/records/profile-roles.md +21 -0
- package/modules/lifecycle/db-goals.js +6 -0
- package/modules/lifecycle/db-versions.js +3 -0
- package/modules/lifecycle/done-when.js +4 -0
- package/modules/lifecycle/goal-close-sweep.js +34 -0
- package/modules/provisioning/app-status.js +17 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +8 -6
- package/tests/autonomy_builder_scope.mjs +1 -1
- package/tests/autonomy_fence_priority.mjs +5 -3
- package/tests/autonomy_runner_identity.mjs +1 -1
- package/tests/criterion_goal_attach.mjs +5 -1
- package/tests/goal_close_cancels_requests.mjs +82 -0
- package/tests/goal_closure_invariants.mjs +5 -2
- package/tests/hall_profile_world.mjs +57 -58
- package/tests/profile_earnings.mjs +237 -0
- package/tests/projects_hub_app_status.mjs +17 -0
- 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:
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// that
|
|
114
|
-
|
|
115
|
-
assert.
|
|
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 \|\|
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
assert.match(src, /
|
|
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, /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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('
|
|
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
|
-
// `
|
|
273
|
-
//
|
|
274
|
-
|
|
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('/
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
return { status: 200, ok: true, data: {
|
|
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
|
-
//
|
|
328
|
-
const pay = (d,
|
|
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
|
-
|
|
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('/
|
|
353
|
-
assert.ok(!own.requests.some((u) => u.includes('/
|
|
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, /
|
|
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
|
|
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
|
-
|
|
410
|
-
const
|
|
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.
|
|
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
|
|
419
|
-
assert.match(fig, /100
|
|
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
|
|
424
|
-
const
|
|
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
|
-
|
|
428
|
-
assert.
|
|
429
|
-
assert.ok(
|
|
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('
|
|
434
|
-
|
|
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
|
-
|
|
440
|
-
assert.ok(
|
|
441
|
-
assert.
|
|
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('
|
|
447
|
-
const b = bootRender({ rows: [ship('2026-08-01')],
|
|
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
|
|
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+\\/
|
|
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', () => {
|