@bongos/core 1.19.623 → 1.19.625
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 +31 -21
- package/docs/copy-inventory.md +76 -76
- package/docs/copy-registry.json +81 -81
- package/docs/module-api-changelog.md +4 -0
- package/modules/builder-settings/builder-needs.js +41 -4
- package/modules/economy/credits.js +38 -0
- package/modules/economy/reward.js +5 -0
- package/modules/hall-ui/public/hall-render.js +26 -7
- package/modules/sessions/db.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-earnings.js +123 -0
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/builder_needs.mjs +122 -0
- package/tests/cli_sessions.mjs +109 -4
- package/tests/session_earnings_mirror_db.mjs +250 -0
|
@@ -1695,5 +1695,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1695
1695
|
landed since 1.19.621 with no explicit bump. run 34322091367. (task 1002620)
|
|
1696
1696
|
1.19.623 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1697
1697
|
landed since 1.19.622 with no explicit bump. run 34323141703. (task 1002620)
|
|
1698
|
+
1.19.624 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1699
|
+
landed since 1.19.623 with no explicit bump. run 34326009063. (task 1002620)
|
|
1700
|
+
1.19.625 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1701
|
+
landed since 1.19.624 with no explicit bump. run 34328603214. (task 1002620)
|
|
1698
1702
|
---------------------------------------------------------------------------
|
|
1699
1703
|
```
|
|
@@ -219,6 +219,36 @@ function mingleIntroductionsNeed(ctx) {
|
|
|
219
219
|
|
|
220
220
|
const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed];
|
|
221
221
|
|
|
222
|
+
// Priority WITHIN a state bucket (task 1003738). computeNeeds used to sort by
|
|
223
|
+
// state alone with a stable sort, so the NEEDS array order silently decided
|
|
224
|
+
// which item a single-slot renderer showed. board_votes sits fourth, behind
|
|
225
|
+
// art_key — so any builder with an unset image key never saw a waiting board
|
|
226
|
+
// vote, while both renderers' comments claimed they showed "the highest-priority"
|
|
227
|
+
// need. Within a bucket there was no such thing.
|
|
228
|
+
//
|
|
229
|
+
// NOT keyed on `severity`, which is the obvious-looking choice and the wrong
|
|
230
|
+
// one: art_key is severity 'action' and board_votes is 'info', so ordering by
|
|
231
|
+
// severity would have PRESERVED the exact bug this fixes. The rule here is who
|
|
232
|
+
// is waiting:
|
|
233
|
+
//
|
|
234
|
+
// A NEED SOMEONE ELSE IS BLOCKED ON OUTRANKS A NEED THAT ONLY AFFECTS YOU.
|
|
235
|
+
//
|
|
236
|
+
// A sitting cannot be decided until a member votes, and its window can expire;
|
|
237
|
+
// an unset art key stops only your own art, whenever you next want it.
|
|
238
|
+
//
|
|
239
|
+
// Lower number = louder. An id missing from this map sorts last, so appending to
|
|
240
|
+
// NEEDS without a weight is safe (it just lands at the back of its bucket).
|
|
241
|
+
// founding_welcome is deliberately negative: during the founding grace the
|
|
242
|
+
// roster leads with the welcome, which is what the grace is for.
|
|
243
|
+
const NEED_PRIORITY = {
|
|
244
|
+
founding_welcome: -1,
|
|
245
|
+
board_votes: 0,
|
|
246
|
+
help_requests: 1,
|
|
247
|
+
art_key: 2,
|
|
248
|
+
task_recommendations: 3,
|
|
249
|
+
mingle_introductions: 4,
|
|
250
|
+
};
|
|
251
|
+
|
|
222
252
|
// ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
|
|
223
253
|
//
|
|
224
254
|
// A freshly-provisioned instance used to greet its founding owner with "your
|
|
@@ -265,9 +295,14 @@ function foundingWelcomeNeed(ctx) {
|
|
|
265
295
|
// functions read (see each need's doc). Returns:
|
|
266
296
|
// { items: Need[], action_needed_count, has_action_needed }
|
|
267
297
|
// items is ordered: action_needed first (loudest), then covered, then satisfied,
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
298
|
+
// and WITHIN each bucket by NEED_PRIORITY above — so a renderer that takes "the
|
|
299
|
+
// first one" really does get the most urgent, which before task 1003738 it did
|
|
300
|
+
// not (it got whichever need NEEDS happened to list first). During the founding
|
|
301
|
+
// grace nothing is action_needed, so the welcome is what that renderer shows.
|
|
302
|
+
//
|
|
303
|
+
// A renderer that can show a LIST should render every action_needed item rather
|
|
304
|
+
// than the first: ordering decides emphasis, it does not make the rest safe to
|
|
305
|
+
// drop. Home's "From the system" inbox does exactly that.
|
|
271
306
|
function computeNeeds(ctx = {}) {
|
|
272
307
|
const grace = foundingGraceActive(ctx);
|
|
273
308
|
let items = NEEDS.map((fn) => fn(ctx)).filter(Boolean);
|
|
@@ -282,7 +317,8 @@ function computeNeeds(ctx = {}) {
|
|
|
282
317
|
items.unshift(foundingWelcomeNeed(ctx));
|
|
283
318
|
}
|
|
284
319
|
const order = { action_needed: 0, covered: 1, satisfied: 2 };
|
|
285
|
-
|
|
320
|
+
const weight = (n) => NEED_PRIORITY[n.id] ?? 99;
|
|
321
|
+
items.sort((a, b) => ((order[a.state] ?? 9) - (order[b.state] ?? 9)) || (weight(a) - weight(b)));
|
|
286
322
|
const actionNeeded = items.filter((n) => n.state === 'action_needed');
|
|
287
323
|
return {
|
|
288
324
|
items,
|
|
@@ -299,4 +335,5 @@ module.exports = {
|
|
|
299
335
|
foundingGraceActive,
|
|
300
336
|
FOUNDING_GRACE_DAYS,
|
|
301
337
|
SETTINGS_PATH,
|
|
338
|
+
NEED_PRIORITY,
|
|
302
339
|
};
|
|
@@ -931,6 +931,43 @@ async function sumCreditsForTasks(builderId, taskIds) {
|
|
|
931
931
|
return rows[0] ? rows[0].total : 0;
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
// sumSessionEarnings — what ONE session actually earned its builder, across BOTH
|
|
935
|
+
// of the ledger's attribution keys. The authoritative answer to the question
|
|
936
|
+
// session_records.drachmae_earned exists to mirror.
|
|
937
|
+
//
|
|
938
|
+
// Why it has to be two keys: credit_log rows reach a builder by two different
|
|
939
|
+
// routes, and each stream picks exactly one. The per-task streams (the confirm
|
|
940
|
+
// credit, the idea slices) key on `task_id` and leave session_id NULL. The
|
|
941
|
+
// cost-plus session reward (ADR 0054) is the mirror image — it keys on
|
|
942
|
+
// `session_id` and leaves task_id NULL deliberately, because it pays for the
|
|
943
|
+
// SESSION's real token spend, not for any one task in it.
|
|
944
|
+
//
|
|
945
|
+
// sumCreditsForTasks() above sees only the first route. That was survivable while
|
|
946
|
+
// every instance paid both streams, but under reward mode 'cost-plus-only'
|
|
947
|
+
// (ADR 0146 — this instance's standing choice) the session reward is the ONLY
|
|
948
|
+
// stream that pays, so a task_id-keyed sum is structurally always 0 and the hall
|
|
949
|
+
// told every builder they had earned nothing for work they were genuinely paid
|
|
950
|
+
// for (task 1003634). A builder reported it as missing pay; the money was never
|
|
951
|
+
// missing, the mirror was.
|
|
952
|
+
//
|
|
953
|
+
// `executor` is a Pool or a tx client, so the caller can ask INSIDE the same
|
|
954
|
+
// transaction that just booked the reward and see it. The OR is one predicate over
|
|
955
|
+
// one scan, so a row matching both keys is summed once, not twice — the figure is
|
|
956
|
+
// a pure function of ledger state and can be re-derived any number of times.
|
|
957
|
+
async function sumSessionEarnings(executor, builderId, taskIds, sessionId) {
|
|
958
|
+
const ids = Array.isArray(taskIds) ? taskIds.map(Number).filter(Number.isFinite) : [];
|
|
959
|
+
const sid = sessionId == null ? null : String(sessionId);
|
|
960
|
+
if (ids.length === 0 && !sid) return 0;
|
|
961
|
+
const { rows } = await executor.query(
|
|
962
|
+
`SELECT COALESCE(SUM(delta), 0)::int AS total
|
|
963
|
+
FROM credit_log
|
|
964
|
+
WHERE builder_id = $1
|
|
965
|
+
AND (task_id = ANY($2::bigint[]) OR ($3::text IS NOT NULL AND session_id = $3))`,
|
|
966
|
+
[builderId, ids, sid]
|
|
967
|
+
);
|
|
968
|
+
return rows[0] ? rows[0].total : 0;
|
|
969
|
+
}
|
|
970
|
+
|
|
934
971
|
// The three ideator streams (ADR 0172) each book under a reason key that EMBEDS
|
|
935
972
|
// the idea (and, for stream 1, the task) — that string IS the idempotency
|
|
936
973
|
// predicate, which is why it can't be a bare constant:
|
|
@@ -1038,6 +1075,7 @@ module.exports = {
|
|
|
1038
1075
|
netNegativeBonusAmount,
|
|
1039
1076
|
awardNetNegativeBonus,
|
|
1040
1077
|
sumCreditsForTasks,
|
|
1078
|
+
sumSessionEarnings,
|
|
1041
1079
|
// the ledger read surface (task 1003484, blocker 1000127)
|
|
1042
1080
|
creditLedger,
|
|
1043
1081
|
IDEA_STREAM_REASON_PREFIXES,
|
|
@@ -73,6 +73,11 @@ module.exports = {
|
|
|
73
73
|
getBuilderAchievements: (...a) => achievements.getBuilderAchievements(...a),
|
|
74
74
|
getLockedAchievementsProgress: (...a) => achievements.getLockedAchievementsProgress(...a),
|
|
75
75
|
sumCreditsForTasks: (...a) => credits.sumCreditsForTasks(...a),
|
|
76
|
+
// The two-key session earnings sum (task 1003634) — transaction-participant:
|
|
77
|
+
// the sessions module passes its tx `client` so it reads the cost-plus reward
|
|
78
|
+
// its own transaction just booked. On the port because credit_log is economy's
|
|
79
|
+
// table and sessions may only cross the wall through here (ADR 0083 / 0093 §2).
|
|
80
|
+
sumSessionEarnings: (...a) => credits.sumSessionEarnings(...a),
|
|
76
81
|
// The per-row ledger read (task 1003484). On the port because the surface core
|
|
77
82
|
// most wants it for is a HALL view — "what did this idea earn me" — and a core
|
|
78
83
|
// route must reach economy through here, never by importing the module.
|
|
@@ -475,6 +475,14 @@
|
|
|
475
475
|
const need = (needs && Array.isArray(needs.items))
|
|
476
476
|
? needs.items.find((n) => n && n.state !== 'satisfied')
|
|
477
477
|
: null;
|
|
478
|
+
// task 1003738: this banner is ONE box, so it still shows one need — but the
|
|
479
|
+
// Home inbox below is a list and must not drop the rest. computeNeeds now
|
|
480
|
+
// orders the action_needed bucket by an explicit weight, so `need` above is
|
|
481
|
+
// deliberately the most urgent rather than whichever the registry listed
|
|
482
|
+
// first (which was permanently the art key, hiding waiting board votes).
|
|
483
|
+
const actionNeeds = (needs && Array.isArray(needs.items))
|
|
484
|
+
? needs.items.filter((n) => n && n.state === 'action_needed')
|
|
485
|
+
: [];
|
|
478
486
|
const needsBanner = need
|
|
479
487
|
? `<div class="profile__needs profile__needs--${need.state === 'action_needed' ? 'action' : 'covered'}" role="status">
|
|
480
488
|
<strong>${escapeHtml(need.title)}</strong> — ${escapeHtml(need.message)}
|
|
@@ -576,7 +584,7 @@
|
|
|
576
584
|
}
|
|
577
585
|
const homeStandingSection = document.getElementById('home-standing');
|
|
578
586
|
if (homeStandingSection && !ctx.newcomerGated) homeStandingSection.hidden = false;
|
|
579
|
-
renderSystemNotices({ rebaseTasks, need, cost });
|
|
587
|
+
renderSystemNotices({ rebaseTasks, need, actionNeeds, cost });
|
|
580
588
|
}
|
|
581
589
|
|
|
582
590
|
// The ONE string for "no preferred work chosen" — the mock's copy — on both
|
|
@@ -594,7 +602,7 @@
|
|
|
594
602
|
// is a line item in Needs-attention, never a card). Only what awaits the
|
|
595
603
|
// builder lands here — a "we cover it" note is information, and stays on the
|
|
596
604
|
// full Standing view with the other two, where the banners still render.
|
|
597
|
-
function renderSystemNotices({ rebaseTasks, need, cost }) {
|
|
605
|
+
function renderSystemNotices({ rebaseTasks, need, actionNeeds, cost }) {
|
|
598
606
|
const box = document.getElementById('attention-system');
|
|
599
607
|
if (!box) return;
|
|
600
608
|
const rows = [];
|
|
@@ -610,13 +618,24 @@
|
|
|
610
618
|
`<span class="ginbox__line">New claims are paused: ${rebaseTasks.length} confirmed ${one ? 'task' : 'tasks'} can't land yet, so ${one ? 'its' : 'their'} reward is pending. Rebase and re-ship, or release: ${refs}.</span>` +
|
|
611
619
|
`</div></li>`);
|
|
612
620
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
621
|
+
// task 1003738: EVERY action_needed need gets its own line, not just the
|
|
622
|
+
// first. This surface is a list by design (the placement rule in
|
|
623
|
+
// modules/hall-ui/CLAUDE.md — an action awaiting the builder is a line item
|
|
624
|
+
// in Needs-attention), but it used to render a single need, so one
|
|
625
|
+
// permanently-open item suppressed all the others: a builder whose image key
|
|
626
|
+
// was unset never saw that ideas were sitting before the board undecided.
|
|
627
|
+
// Ordering still decides which is loudest; it no longer decides which exist.
|
|
628
|
+
// The `need` fallback keeps a caller that passes only the top need working.
|
|
629
|
+
const noticeNeeds = (Array.isArray(actionNeeds) && actionNeeds.length)
|
|
630
|
+
? actionNeeds
|
|
631
|
+
: (need && need.state === 'action_needed' ? [need] : []);
|
|
632
|
+
for (const n of noticeNeeds) {
|
|
633
|
+
const act = n.action && n.action.href
|
|
634
|
+
? `<div class="ginbox__actions"><a class="btn-ghost" href="${escapeHtml(n.action.href)}">${escapeHtml(n.action.label || 'Set it up')}</a></div>`
|
|
616
635
|
: '';
|
|
617
636
|
rows.push(`<li class="ginbox__item ginbox__item--warn"><div class="ginbox__body">` +
|
|
618
|
-
`<span class="ginbox__kind">${escapeHtml(
|
|
619
|
-
`<span class="ginbox__line">${escapeHtml(
|
|
637
|
+
`<span class="ginbox__kind">${escapeHtml(n.title)}</span>` +
|
|
638
|
+
`<span class="ginbox__line">${escapeHtml(n.message)}</span>` +
|
|
620
639
|
`</div>${act}</li>`);
|
|
621
640
|
}
|
|
622
641
|
if (cost && (cost.level === 'warn' || cost.level === 'over')) {
|
package/modules/sessions/db.js
CHANGED
|
@@ -109,6 +109,52 @@ async function upsertSessionRecord(opts) {
|
|
|
109
109
|
return upsertSessionRecordTx(pool, opts);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// syncSessionDrachmaeEarnedTx — re-derive session_records.drachmae_earned from
|
|
113
|
+
// the ledger (task 1003634). Returns the synced value, or null if there was no
|
|
114
|
+
// row to sync (or economy is disabled).
|
|
115
|
+
//
|
|
116
|
+
// The column means "the credit_log delta attributable to this session"
|
|
117
|
+
// (migration 062), but the only thing that ever wrote it was the route's
|
|
118
|
+
// sumCreditsForTasks(), which sums rows keyed on `task_id`. The cost-plus session
|
|
119
|
+
// reward keys on `session_id` and leaves task_id NULL deliberately, because it
|
|
120
|
+
// pays for the session's real token spend rather than for any one task in it. So
|
|
121
|
+
// the mirror could not see it. Under reward mode 'cost-plus-only' (ADR 0146 —
|
|
122
|
+
// this instance's standing choice) that reward is the ONLY stream that pays, so
|
|
123
|
+
// the figure was structurally 0 for every session, and the hall told builders
|
|
124
|
+
// they had earned nothing for work they were genuinely paid for. A builder
|
|
125
|
+
// reported it as missing pay; the pay was never missing, this number was.
|
|
126
|
+
//
|
|
127
|
+
// It asks economy for the total over BOTH attribution keys and SETS it, rather
|
|
128
|
+
// than adding a delta. That is what makes it idempotent and safe to call from
|
|
129
|
+
// every trigger: the sum covers the row's ACCUMULATED task_ids (set-unioned
|
|
130
|
+
// across every upload the session has made) plus its session_id, so it already
|
|
131
|
+
// describes the whole session however many times it runs. Re-running can only
|
|
132
|
+
// land on the same number, which is why calling it on an already-paid session is
|
|
133
|
+
// a repair rather than a double-count.
|
|
134
|
+
async function syncSessionDrachmaeEarnedTx(client, builderId, sessionId) {
|
|
135
|
+
const reward = seams.resolveOptional('reward');
|
|
136
|
+
if (!reward || !sessionId) return null;
|
|
137
|
+
// The row's task_ids are read back rather than taken from the caller: the
|
|
138
|
+
// upsert set-unions them, and the two deferred triggers never had them at all.
|
|
139
|
+
const { rows } = await client.query(
|
|
140
|
+
`SELECT task_ids FROM session_records WHERE session_id = $1 AND builder_id = $2`,
|
|
141
|
+
[sessionId, builderId]
|
|
142
|
+
);
|
|
143
|
+
if (rows.length === 0) return null;
|
|
144
|
+
const ids = Array.isArray(rows[0].task_ids)
|
|
145
|
+
? rows[0].task_ids.map((n) => Number(n)).filter(Number.isFinite)
|
|
146
|
+
: [];
|
|
147
|
+
const earned = await reward.sumSessionEarnings(client, builderId, ids, sessionId);
|
|
148
|
+
const { rows: synced } = await client.query(
|
|
149
|
+
`UPDATE session_records
|
|
150
|
+
SET drachmae_earned = $3
|
|
151
|
+
WHERE session_id = $1 AND builder_id = $2
|
|
152
|
+
RETURNING drachmae_earned`,
|
|
153
|
+
[sessionId, builderId, Math.trunc(Number(earned) || 0)]
|
|
154
|
+
);
|
|
155
|
+
return synced.length > 0 ? synced[0].drachmae_earned : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
112
158
|
// awardSessionTokenRewardTx — resolve the economy `reward` port and book the
|
|
113
159
|
// idempotent cost-plus session token reward on `client`, mirroring the granted
|
|
114
160
|
// drachmae onto the session_records row for observability (the authoritative
|
|
@@ -137,6 +183,19 @@ async function awardSessionTokenRewardTx(client, { builderId, sessionId, modelUs
|
|
|
137
183
|
[sessionId, tokenReward.drachmae, builderId]
|
|
138
184
|
);
|
|
139
185
|
}
|
|
186
|
+
// Sync the earnings mirror on EVERY attempt, not only a fresh award. The
|
|
187
|
+
// watermark makes a re-attempt on an already-paid session a clean no-op for the
|
|
188
|
+
// money, and this is where that no-op earns its keep: it still repairs a row
|
|
189
|
+
// whose drachmae_earned was written by the old task_id-only sum. Since all
|
|
190
|
+
// three pay-on-land triggers funnel through here, one call site covers the
|
|
191
|
+
// upload-time gate, the task.shipped listener, and the state-based sweep.
|
|
192
|
+
const sessionDrachmaeEarned = await syncSessionDrachmaeEarnedTx(client, builderId, sessionId);
|
|
193
|
+
// Ride the synced figure back on the result so a caller holding a pre-sync
|
|
194
|
+
// record can refresh it — otherwise POST /sessions echoes the stale 0 it was
|
|
195
|
+
// handed even though the row is now correct.
|
|
196
|
+
if (tokenReward && sessionDrachmaeEarned !== null) {
|
|
197
|
+
tokenReward.sessionDrachmaeEarned = sessionDrachmaeEarned;
|
|
198
|
+
}
|
|
140
199
|
return tokenReward;
|
|
141
200
|
}
|
|
142
201
|
|
|
@@ -186,6 +245,12 @@ async function upsertSessionRecordWithReward(opts) {
|
|
|
186
245
|
totalTokens: opts.totalTokens || 0,
|
|
187
246
|
});
|
|
188
247
|
}
|
|
248
|
+
// The upsert wrote the figure the route computed BEFORE this transaction
|
|
249
|
+
// booked anything; the award has since re-derived it. Adopt it so the
|
|
250
|
+
// response matches the row.
|
|
251
|
+
if (tokenReward && tokenReward.sessionDrachmaeEarned != null) {
|
|
252
|
+
record.drachmae_earned = tokenReward.sessionDrachmaeEarned;
|
|
253
|
+
}
|
|
189
254
|
}
|
|
190
255
|
return record ? { ...record, tokenReward } : null;
|
|
191
256
|
});
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.625",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.625",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.625",
|
|
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",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/backfill-session-earnings.js — repair the historical
|
|
5
|
+
// `session_records.drachmae_earned` mirror (task 1003634).
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS IS, and what it emphatically is NOT. It moves NO money. It writes no
|
|
8
|
+
// `credit_log` row, mints nothing, and cannot change any builder's balance. It
|
|
9
|
+
// rewrites ONE display column to agree with the ledger that already exists. That
|
|
10
|
+
// makes it categorically unlike its neighbours here (backfill-task-rewards.js,
|
|
11
|
+
// backfill-subagent-rewards.js), which mint credits and carry an ADR 0097 /
|
|
12
|
+
// ADR 0173 safety model to match. Read this one as a data repair.
|
|
13
|
+
//
|
|
14
|
+
// WHY IT IS NEEDED. `drachmae_earned` means "the credit_log delta attributable to
|
|
15
|
+
// this session" (migration 062), but the only thing that ever wrote it summed
|
|
16
|
+
// rows keyed on `task_id`. The cost-plus session reward (ADR 0054) keys on
|
|
17
|
+
// `session_id` and leaves `task_id` NULL on purpose, because it pays for the
|
|
18
|
+
// session's real token spend rather than for any one task in it. So the mirror
|
|
19
|
+
// could not see it. Under reward mode `cost-plus-only` (ADR 0146 — this
|
|
20
|
+
// instance's standing choice) that reward is the ONLY stream that pays, so the
|
|
21
|
+
// column read 0 for essentially every session, and the hall told builders they
|
|
22
|
+
// had earned nothing for work they had genuinely been paid for. A builder
|
|
23
|
+
// reported it as missing pay; the pay was never missing, this number was.
|
|
24
|
+
//
|
|
25
|
+
// WHY THE LIVE FIX DOES NOT COVER HISTORY. The code fix re-derives the figure
|
|
26
|
+
// whenever a reward books, and all three pay-on-land triggers now funnel through
|
|
27
|
+
// that. But none of them revisits a session that is already paid in full — the
|
|
28
|
+
// state-based sweep explicitly skips any session that already has a
|
|
29
|
+
// `session.token_reward` row. So already-paid history never self-heals, which is
|
|
30
|
+
// precisely the history a builder looks at. Hence a one-shot pass.
|
|
31
|
+
//
|
|
32
|
+
// SAFETY. DRY-RUN BY DEFAULT: with no flags it prints the plan and writes
|
|
33
|
+
// nothing. `--apply` performs the UPDATEs. Idempotent by construction — every
|
|
34
|
+
// row is SET to a value derived wholly from current ledger state, so re-running
|
|
35
|
+
// lands on the same numbers, and a row already correct is skipped and reported
|
|
36
|
+
// as such. `--builder N` scopes the pass to one builder (useful for verifying on
|
|
37
|
+
// a single account before the full sweep).
|
|
38
|
+
//
|
|
39
|
+
// Run: node scripts/gds/backfill-session-earnings.js [--builder N] [--limit N]
|
|
40
|
+
// node scripts/gds/backfill-session-earnings.js --apply
|
|
41
|
+
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
|
|
44
|
+
const credits = require('../../modules/economy/credits');
|
|
45
|
+
const { arg, hasFlag } = require('./cli-lib');
|
|
46
|
+
|
|
47
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
48
|
+
|
|
49
|
+
function loadPool() {
|
|
50
|
+
const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
|
|
51
|
+
return pool;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
const apply = hasFlag('--apply');
|
|
56
|
+
const builderArg = arg('--builder');
|
|
57
|
+
const limitArg = Number(arg('--limit'));
|
|
58
|
+
const limit = Number.isFinite(limitArg) && limitArg > 0 ? Math.trunc(limitArg) : null;
|
|
59
|
+
|
|
60
|
+
const pool = loadPool();
|
|
61
|
+
const client = await pool.connect();
|
|
62
|
+
let changed = 0;
|
|
63
|
+
let alreadyCorrect = 0;
|
|
64
|
+
let scanned = 0;
|
|
65
|
+
let deltaTotal = 0;
|
|
66
|
+
const examples = [];
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const params = [];
|
|
70
|
+
let where = 'WHERE sr.session_id IS NOT NULL';
|
|
71
|
+
if (builderArg) { params.push(builderArg); where += ` AND sr.builder_id = $${params.length}`; }
|
|
72
|
+
let sql = `SELECT sr.session_id, sr.builder_id, sr.task_ids, sr.drachmae_earned,
|
|
73
|
+
b.github_login
|
|
74
|
+
FROM session_records sr
|
|
75
|
+
JOIN builders b ON b.id = sr.builder_id
|
|
76
|
+
${where}
|
|
77
|
+
ORDER BY sr.uploaded_at DESC`;
|
|
78
|
+
if (limit) { params.push(limit); sql += ` LIMIT $${params.length}`; }
|
|
79
|
+
|
|
80
|
+
const { rows } = await client.query(sql, params);
|
|
81
|
+
scanned = rows.length;
|
|
82
|
+
|
|
83
|
+
for (const r of rows) {
|
|
84
|
+
const ids = Array.isArray(r.task_ids)
|
|
85
|
+
? r.task_ids.map((n) => Number(n)).filter(Number.isFinite)
|
|
86
|
+
: [];
|
|
87
|
+
// The same two-key sum the live path uses — one definition, so a repaired
|
|
88
|
+
// row and a freshly-synced row can never disagree.
|
|
89
|
+
const truth = await credits.sumSessionEarnings(client, r.builder_id, ids, r.session_id);
|
|
90
|
+
const was = Number(r.drachmae_earned) || 0;
|
|
91
|
+
if (truth === was) { alreadyCorrect++; continue; }
|
|
92
|
+
changed++;
|
|
93
|
+
deltaTotal += (truth - was);
|
|
94
|
+
if (examples.length < 15) {
|
|
95
|
+
examples.push(` ${String(r.github_login).padEnd(18)} ${r.session_id.slice(0, 8)} ${was} → ${truth}`);
|
|
96
|
+
}
|
|
97
|
+
if (apply) {
|
|
98
|
+
await client.query(
|
|
99
|
+
`UPDATE session_records SET drachmae_earned = $3
|
|
100
|
+
WHERE session_id = $1 AND builder_id = $2`,
|
|
101
|
+
[r.session_id, r.builder_id, truth]
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
client.release();
|
|
107
|
+
await pool.end().catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(`\nsession-earnings backfill — ${apply ? 'APPLIED' : 'DRY RUN (no writes)'}`);
|
|
111
|
+
console.log(` sessions scanned ${scanned}`);
|
|
112
|
+
console.log(` already correct ${alreadyCorrect}`);
|
|
113
|
+
console.log(` ${apply ? 'repaired' : 'would repair'}${apply ? ' ' : ' '}${changed}`);
|
|
114
|
+
console.log(` net mirror change ${deltaTotal >= 0 ? '+' : ''}${deltaTotal} drachmae of DISPLAY (no money moved)`);
|
|
115
|
+
if (examples.length > 0) {
|
|
116
|
+
console.log(`\n sample rows:\n${examples.join('\n')}`);
|
|
117
|
+
}
|
|
118
|
+
if (!apply && changed > 0) {
|
|
119
|
+
console.log(`\n re-run with --apply to write these.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -106,6 +106,13 @@ const INTEGRATION = new Set([
|
|
|
106
106
|
// parses this set out of the source, so a quoted word in a comment reads as an
|
|
107
107
|
// entry — the same reason the copy_desk_flags_db note above is worded that way.)
|
|
108
108
|
'idea_credit_streams_db',
|
|
109
|
+
// task 1003634: the session earnings mirror against real SQL. The defect was
|
|
110
|
+
// never arithmetic — it was which credit_log rows a WHERE clause can reach, so
|
|
111
|
+
// a stubbed client would answer exactly the assumption under test. Here the
|
|
112
|
+
// two-key predicate meets a real planner, and the first test asserts the OLD
|
|
113
|
+
// predicate still returns 0 so the bug itself stays executable. Same
|
|
114
|
+
// always-rolled-back transaction discipline as the entry above.
|
|
115
|
+
'session_earnings_mirror_db',
|
|
109
116
|
// RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
|
|
110
117
|
// 1003091. A curated set keyed by a stem that no longer exists silently stops
|
|
111
118
|
// covering its file: both of these dropped OUT of the integration set and INTO the
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.625'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/builder_needs.mjs
CHANGED
|
@@ -209,5 +209,127 @@ t('the /me aggregator feeds computeNeeds the founder facts under their contract
|
|
|
209
209
|
assert.match(src, /getFoundingBuilder\(\)/, 'me.js must resolve the founder row');
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
// ---- two needs at once (task 1003738) --------------------------------------
|
|
213
|
+
//
|
|
214
|
+
// The defect: computeNeeds sorted by state alone with a stable sort, so within
|
|
215
|
+
// the action_needed bucket the NEEDS array order decided everything — and
|
|
216
|
+
// board_votes is listed fourth, behind art_key. A builder with an unset image
|
|
217
|
+
// key therefore never saw a waiting board vote, on either surface, because both
|
|
218
|
+
// renderers took "the first one". The fix is two-part and both parts are pinned
|
|
219
|
+
// here: an explicit priority inside the bucket, and a LIST surface that renders
|
|
220
|
+
// every action_needed item instead of only the loudest.
|
|
221
|
+
console.log('two simultaneous action_needed needs (task 1003738):');
|
|
222
|
+
|
|
223
|
+
// art_key action_needed (archon, no own key) + board_votes action_needed.
|
|
224
|
+
const TWO = {
|
|
225
|
+
rank: 'archon',
|
|
226
|
+
ownKeySet: false,
|
|
227
|
+
pendingBoardVotes: { count: 2, first_item_id: 12 },
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
t('both needs are action_needed — the fixture really does collide', () => {
|
|
231
|
+
const r = needs.computeNeeds(TWO);
|
|
232
|
+
const ids = r.items.filter((n) => n.state === 'action_needed').map((n) => n.id);
|
|
233
|
+
assert.ok(ids.includes('art_key'), 'art_key must be action_needed here');
|
|
234
|
+
assert.ok(ids.includes('board_votes'), 'board_votes must be action_needed here');
|
|
235
|
+
assert.equal(r.action_needed_count, 2);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
t('the board vote outranks the art key — someone else is blocked on it', () => {
|
|
239
|
+
const r = needs.computeNeeds(TWO);
|
|
240
|
+
assert.equal(r.items[0].id, 'board_votes',
|
|
241
|
+
'a need others are blocked on must lead; registry order must not decide');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
t('priority is NOT severity — severity would have kept the bug', () => {
|
|
245
|
+
// The trap worth pinning: art_key is severity 'action' and board_votes is
|
|
246
|
+
// 'info', so ordering the bucket by severity would have left the art key
|
|
247
|
+
// first and the board vote hidden. If someone "simplifies" the weight map
|
|
248
|
+
// into a severity sort, this fails.
|
|
249
|
+
const r = needs.computeNeeds(TWO);
|
|
250
|
+
const art_ = r.items.find((n) => n.id === 'art_key');
|
|
251
|
+
const board = r.items.find((n) => n.id === 'board_votes');
|
|
252
|
+
assert.equal(art_.severity, 'action');
|
|
253
|
+
assert.equal(board.severity, 'info');
|
|
254
|
+
assert.ok(needs.NEED_PRIORITY.board_votes < needs.NEED_PRIORITY.art_key,
|
|
255
|
+
'the weight map, not severity, is what orders the bucket');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
t('an unweighted need sorts last in its bucket, never first', () => {
|
|
259
|
+
// Appending to NEEDS without adding a weight must not silently take the lead.
|
|
260
|
+
const items = [{ id: 'brand_new', state: 'action_needed' }, { id: 'board_votes', state: 'action_needed' }];
|
|
261
|
+
const order = { action_needed: 0, covered: 1, satisfied: 2 };
|
|
262
|
+
const weight = (n) => needs.NEED_PRIORITY[n.id] ?? 99;
|
|
263
|
+
items.sort((a, b) => ((order[a.state] ?? 9) - (order[b.state] ?? 9)) || (weight(a) - weight(b)));
|
|
264
|
+
assert.equal(items[0].id, 'board_votes');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
t('the founding welcome still leads during the grace', () => {
|
|
268
|
+
// The weight tie-break must not demote the welcome: before this task it was
|
|
269
|
+
// first only because unshift + a stable sort put it there.
|
|
270
|
+
const r = needs.computeNeeds({
|
|
271
|
+
...TWO, isFoundingOwner: true, founderCreatedAt: new Date().toISOString(), projectName: 'Mercury',
|
|
272
|
+
});
|
|
273
|
+
assert.equal(r.items[0].id, 'founding_welcome');
|
|
274
|
+
assert.equal(r.has_action_needed, false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ---- the rendered output ----------------------------------------------------
|
|
278
|
+
//
|
|
279
|
+
// renderSystemNotices lives inside hall-render.js's IIFE, so it cannot be
|
|
280
|
+
// imported — but it only touches `document`, `escapeHtml` and `window`, so the
|
|
281
|
+
// real shipped function is sliced out and RUN here (the repo's "run it rather
|
|
282
|
+
// than grep it" preference) rather than asserted against by regex. A grep would
|
|
283
|
+
// pass on a comment; this fails if the board vote is not in the HTML.
|
|
284
|
+
function runSystemNotices(args) {
|
|
285
|
+
const src = readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-render.js'), 'utf8');
|
|
286
|
+
const start = src.indexOf('function renderSystemNotices');
|
|
287
|
+
assert.ok(start > 0, 'renderSystemNotices must still exist in hall-render.js');
|
|
288
|
+
// Anchor on the BODY brace, not the first '{' — the signature destructures
|
|
289
|
+
// its argument, so the first brace closes the parameter list.
|
|
290
|
+
const argsEnd = src.indexOf(') {', start);
|
|
291
|
+
assert.ok(argsEnd > start, 'renderSystemNotices signature changed shape');
|
|
292
|
+
let i = src.indexOf('{', argsEnd);
|
|
293
|
+
let depth = 0;
|
|
294
|
+
let end = -1;
|
|
295
|
+
for (; i < src.length; i++) {
|
|
296
|
+
if (src[i] === '{') depth++;
|
|
297
|
+
else if (src[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
|
|
298
|
+
}
|
|
299
|
+
assert.ok(end > start, 'could not slice renderSystemNotices');
|
|
300
|
+
const box = { innerHTML: '' };
|
|
301
|
+
const doc = { getElementById: (id) => (id === 'attention-system' ? box : null) };
|
|
302
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
303
|
+
const body = src.slice(start, end);
|
|
304
|
+
const make = new Function('document', 'escapeHtml', 'window', `${body}\nreturn renderSystemNotices;`);
|
|
305
|
+
make(doc, esc, {})(args);
|
|
306
|
+
return box.innerHTML;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
t('THE BUG: with the art key also open, the board vote is still rendered', () => {
|
|
310
|
+
const r = needs.computeNeeds(TWO);
|
|
311
|
+
const actionNeeds = r.items.filter((n) => n.state === 'action_needed');
|
|
312
|
+
const html = runSystemNotices({
|
|
313
|
+
rebaseTasks: [], need: actionNeeds[0], actionNeeds, cost: null,
|
|
314
|
+
});
|
|
315
|
+
assert.match(html, /waiting on your vote/i,
|
|
316
|
+
'the board vote must be reachable in the rendered output, not suppressed');
|
|
317
|
+
assert.match(html, /art coverage has ended/i,
|
|
318
|
+
'and the art key must not be dropped either — the list shows both');
|
|
319
|
+
assert.equal((html.match(/<li class="ginbox__item/g) || []).length, 2, 'one line per waiting need');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
t('a single action_needed need still renders exactly one line', () => {
|
|
323
|
+
const r = needs.computeNeeds({ rank: 'thetes', ownKeySet: false });
|
|
324
|
+
const actionNeeds = r.items.filter((n) => n.state === 'action_needed');
|
|
325
|
+
const html = runSystemNotices({ rebaseTasks: [], need: actionNeeds[0], actionNeeds, cost: null });
|
|
326
|
+
assert.equal((html.match(/<li class="ginbox__item/g) || []).length, 1);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
t('nothing waiting renders nothing at all', () => {
|
|
330
|
+
const html = runSystemNotices({ rebaseTasks: [], need: null, actionNeeds: [], cost: null });
|
|
331
|
+
assert.equal(html, '');
|
|
332
|
+
});
|
|
333
|
+
|
|
212
334
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
213
335
|
process.exit(failed ? 1 : 0);
|