@bongos/core 1.20.83 → 1.21.2
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 +186 -81
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +153 -4
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +60 -59
- package/docs/copy-registry.json +72 -63
- package/docs/design/project-startup-direction.md +63 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +8 -0
- package/docs/page-inventory.json +9 -3
- package/docs/page-readings.json +427 -410
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- package/migrations/core_270_craft_achievements.sql +128 -0
- package/modules/economy/achievements.js +199 -4
- package/modules/economy/credits.js +16 -0
- package/modules/economy/reward.js +3 -0
- package/modules/hall-ui/public/builders.js +1 -1
- package/modules/hall-ui/public/genesis-home.js +17 -3
- package/modules/hall-ui/public/hall-render.js +24 -2
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- package/modules/hall-ui/public/roster.css +21 -0
- package/modules/hall-ui/public/roster.html +3 -0
- package/modules/hall-ui/public/roster.js +121 -21
- package/modules/hall-ui/public/roster.states.json +26 -1
- package/modules/hall-ui/records/builders-roster.md +20 -0
- package/modules/lifecycle/publish-reconciler.js +25 -0
- package/modules/lifecycle/workflow-dispatch.js +21 -11
- package/modules/npm-release/release.js +10 -1
- package/modules/onboarding/founding-birth.js +116 -0
- package/modules/onboarding/genesis-home.js +72 -5
- package/modules/onboarding/port.js +9 -2
- package/modules/onboarding/routes/onboarding.js +8 -1
- package/modules/provisioning/birth.js +64 -0
- package/modules/provisioning/dns-resolves.js +54 -0
- package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
- package/modules/provisioning/pollers/liveness-sweep.js +5 -0
- package/modules/provisioning/provisioning.js +7 -6
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +48 -0
- package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
- package/modules/public-landing/public/projects.html +40 -15
- package/modules/sessions/db.js +58 -0
- package/modules/sessions/routes/sessions.js +97 -0
- package/modules/status-ui/public/status.js +39 -7
- package/modules/status-ui/public/style.css +24 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +45 -0
- package/scripts/gds/provision.js +77 -53
- package/scripts/gds/session-digest.js +11 -3
- package/scripts/gds/session-transcript-build.js +155 -0
- package/scripts/gds/session-transcript-upload.js +29 -0
- package/scripts/gds/ship-session-upload.js +6 -1
- package/scripts/gds/upgrade-commit-identity.js +32 -0
- package/scripts/gds/upgrade.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +2 -0
- package/src/bongos/db.js +13 -0
- package/src/bongos/role-stats.js +36 -2
- package/src/bongos/routes/public.js +13 -2
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/achievements_reconcile.mjs +13 -0
- package/tests/craft_achievements.mjs +349 -0
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_builders.mjs +2 -1
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/hall_builders_page_pass.mjs +2 -1
- package/tests/hub_map_orb_states.mjs +5 -1
- package/tests/leaderboard_roles.mjs +272 -0
- package/tests/npm_release_release.mjs +37 -2
- package/tests/projects_hub.mjs +5 -3
- package/tests/projects_hub_dns_ready.mjs +153 -0
- package/tests/projects_hub_pre_uat.mjs +9 -5
- package/tests/provision_dns_first.mjs +171 -0
- package/tests/provision_settings_apply.mjs +6 -1
- package/tests/provisioning_settings_env.mjs +49 -0
- package/tests/session_transcript.mjs +273 -0
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/upgrade_commit_identity.mjs +89 -0
- package/tests/wizard_demo.mjs +29 -3
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
link into index.html. It is a page now; /#leaderboard redirects here. -->
|
|
35
35
|
<section class="scroll" id="roster-scroll" aria-labelledby="roster-h">
|
|
36
36
|
<h2 id="roster-h" class="scroll__h">The roster<span class="count-pill" id="roster-count" hidden></span></h2>
|
|
37
|
+
<!-- The role toggle (task 1004437, owner decision (b)): Everyone, then one
|
|
38
|
+
chip per craft; a craft ranks by the credits earned in it. -->
|
|
39
|
+
<div class="roster-roles" id="roster-roles" role="group" aria-label="Show"></div>
|
|
37
40
|
<div id="roster-filters"></div>
|
|
38
41
|
<div id="roster-body">
|
|
39
42
|
<div class="profile__placeholder">Loading the roster…</div>
|
|
@@ -49,17 +49,31 @@
|
|
|
49
49
|
'first-parallel-claim': '⚓',
|
|
50
50
|
'streak-3': '\u{1F525}',
|
|
51
51
|
'streak-7': '\u{1F30B}',
|
|
52
|
-
|
|
52
|
+
// task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
|
|
53
|
+
'art-pipeline-shipper': '\u{1F6E0}\u{FE0F}',
|
|
54
|
+
'first-thought-ignited': '\u{1F4A1}',
|
|
55
|
+
'first-full-idea': '\u{1F4DC}',
|
|
56
|
+
'first-board-ratification': '\u{1F3DB}\u{FE0F}',
|
|
57
|
+
'thought-built-ten': '\u{1F31F}',
|
|
58
|
+
'first-page-approved': '\u{1F3A8}',
|
|
59
|
+
'ten-pages-approved': '\u{1F5BC}\u{FE0F}',
|
|
60
|
+
'whole-surface-tweaked': '\u{1F5FA}\u{FE0F}',
|
|
53
61
|
'gds-shipper': '⚙️',
|
|
54
62
|
'breaking-the-100c-bar': '\u{1F4AF}',
|
|
55
63
|
};
|
|
64
|
+
// An achievement id as a sentence: "first-page-approved" → "First page approved".
|
|
65
|
+
function achWords(id) {
|
|
66
|
+
var s = String(id || '').replace(/-/g, ' ');
|
|
67
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
68
|
+
}
|
|
56
69
|
function achRowHtml(ids) {
|
|
57
70
|
if (!Array.isArray(ids) || !ids.length) return '';
|
|
58
71
|
var inner = ids.map(function (id) {
|
|
59
72
|
var emoji = ACH_EMOJI[id];
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
73
|
+
// The title is the achievement's name as words ("First page approved"), not
|
|
74
|
+
// its id slug (task 1004438): it is what a hover shows and a screen reader says.
|
|
75
|
+
var words = achWords(id);
|
|
76
|
+
return emoji ? '<span class="lb-ach" title="' + esc(words) + '" aria-label="' + esc(words) + '">' + emoji + '</span>' : '';
|
|
63
77
|
}).join('');
|
|
64
78
|
return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
|
|
65
79
|
}
|
|
@@ -94,9 +108,34 @@
|
|
|
94
108
|
return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
// task 1004437 (WA6.RP07): the roles. Each row's `roles` block is the public
|
|
112
|
+
// list's own (src/bongos/role-stats.js readRolesForBuilders): `shown` is the
|
|
113
|
+
// main role first, then every craft the builder has EARNED in — evidence of
|
|
114
|
+
// work, not only what they declared — and `credits_by_craft` is the ledger
|
|
115
|
+
// split. Owner decision (b): a column naming them AND the role toggle above,
|
|
116
|
+
// which ranks by the credits earned in that craft. The figure a role ranks by
|
|
117
|
+
// is in the row (ADR 0255: a public list is ordered only by what it publishes).
|
|
118
|
+
var CRAFTS = ['engineer', 'artist', 'ideator'];
|
|
119
|
+
var CRAFT_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
|
|
120
|
+
var ROLE_CHIPS = [['', 'Everyone'], ['engineer', 'Engineers'], ['artist', 'Artists'], ['ideator', 'Ideators']];
|
|
121
|
+
function shownOf(b) { return (b.roles && Array.isArray(b.roles.shown)) ? b.roles.shown : []; }
|
|
122
|
+
function craftCredits(b, craft) {
|
|
123
|
+
var by = b.roles && b.roles.credits_by_craft;
|
|
124
|
+
return by ? (Number(by[craft]) || 0) : 0;
|
|
125
|
+
}
|
|
126
|
+
function creditOf(b) { return role ? craftCredits(b, role) : (Number(b.total_credits) || 0); }
|
|
127
|
+
function rolesHtml(b) {
|
|
128
|
+
var shown = shownOf(b);
|
|
129
|
+
if (!shown.length) return '<span class="lb-roles lb-roles--none">—</span>';
|
|
130
|
+
return '<span class="lb-roles">' + shown.map(function (c, i) {
|
|
131
|
+
return '<span class="lb-role' + (i === 0 ? ' lb-role--lead' : '') + '">' + esc(CRAFT_WORD[c] || c) + '</span>';
|
|
132
|
+
}).join('') + '</span>';
|
|
133
|
+
}
|
|
134
|
+
|
|
97
135
|
var all = [];
|
|
98
136
|
var query = '';
|
|
99
137
|
var sortBy = '';
|
|
138
|
+
var role = '';
|
|
100
139
|
|
|
101
140
|
// PLACE IS CANONICAL, not the row's position in the current sort.
|
|
102
141
|
// hall-render's leaderboardPlace() already defines a builder's place as
|
|
@@ -108,6 +147,16 @@
|
|
|
108
147
|
rows.slice()
|
|
109
148
|
.sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
|
|
110
149
|
.forEach(function (b, i) { b.__place = i + 1; });
|
|
150
|
+
// A role's place is the same idea inside that role: credits earned in the
|
|
151
|
+
// craft, descending, among the builders it is shown for. Stamped once too,
|
|
152
|
+
// so the place under a role toggle does not renumber under a search.
|
|
153
|
+
rows.forEach(function (b) { b.__placeBy = {}; });
|
|
154
|
+
CRAFTS.forEach(function (c) {
|
|
155
|
+
rows.filter(function (b) { return shownOf(b).indexOf(c) !== -1; })
|
|
156
|
+
.map(function (b, i) { return { b: b, i: i }; })
|
|
157
|
+
.sort(function (x, y) { return (craftCredits(y.b, c) - craftCredits(x.b, c)) || (x.i - y.i); })
|
|
158
|
+
.forEach(function (x, i) { x.b.__placeBy[c] = i + 1; });
|
|
159
|
+
});
|
|
111
160
|
// The efficiency title is stamped here too, so the division and the string
|
|
112
161
|
// work happen ONCE per load rather than per visible row per render (every
|
|
113
162
|
// sort, search keystroke and page turn re-renders).
|
|
@@ -116,6 +165,7 @@
|
|
|
116
165
|
}
|
|
117
166
|
|
|
118
167
|
function matches(b) {
|
|
168
|
+
if (role && shownOf(b).indexOf(role) === -1) return false;
|
|
119
169
|
if (!query) return true;
|
|
120
170
|
var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
|
|
121
171
|
return hay.indexOf(query) !== -1;
|
|
@@ -130,7 +180,8 @@
|
|
|
130
180
|
tokens: function (b) { return -(Number(b.tokens_used) || 0); },
|
|
131
181
|
};
|
|
132
182
|
function ordered(rows) {
|
|
133
|
-
|
|
183
|
+
// The default lens is credits; under a role it is the credits that role earned.
|
|
184
|
+
var key = SORTS[sortBy] || (role ? function (b) { return -craftCredits(b, role); } : null);
|
|
134
185
|
if (!key) return rows;
|
|
135
186
|
return rows.map(function (b, i) { return { b: b, i: i }; })
|
|
136
187
|
.sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
|
|
@@ -140,7 +191,14 @@
|
|
|
140
191
|
var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
|
|
141
192
|
var ledger = null;
|
|
142
193
|
|
|
194
|
+
// Tokens only mean something for AI sessions. A builder who works in the
|
|
195
|
+
// browser (an artist in the studio, an ideator in the room) records none, and
|
|
196
|
+
// a 0 beside them reads as "did nothing" (task 1004437): their cell says so in
|
|
197
|
+
// words instead, and the tokens-per-credit figure is offered only where there
|
|
198
|
+
// are tokens to divide.
|
|
199
|
+
function hasTokens(b) { return (Number(b.tokens_used) || 0) > 0; }
|
|
143
200
|
function efficiencyTitle(b) {
|
|
201
|
+
if (!hasTokens(b)) return 'No AI sessions recorded — this builder’s work is not measured in tokens';
|
|
144
202
|
var credits = Number(b.total_credits) || 0;
|
|
145
203
|
var one = currencyLabel().toLowerCase().replace(/s$/, '');
|
|
146
204
|
if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
|
|
@@ -168,20 +226,69 @@
|
|
|
168
226
|
}
|
|
169
227
|
|
|
170
228
|
function rowHtml(b) {
|
|
229
|
+
var place = role ? (b.__placeBy && b.__placeBy[role]) : b.__place;
|
|
230
|
+
var tokens = hasTokens(b)
|
|
231
|
+
? esc(fmtInt(b.tokens_used))
|
|
232
|
+
: '<span class="lb-none" aria-label="No AI sessions">—</span>';
|
|
171
233
|
return '<tr>'
|
|
172
|
-
+ '<td class="ledger__num ledger__place">' + esc(String(
|
|
234
|
+
+ '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
|
|
173
235
|
+ '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
|
|
236
|
+
+ '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
|
|
174
237
|
+ '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
|
|
175
|
-
+ '<td class="ledger__num">' + esc(fmtInt(b
|
|
238
|
+
+ '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
|
|
176
239
|
+ '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
|
|
177
|
-
+ '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' +
|
|
240
|
+
+ '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
|
|
178
241
|
+ '</tr>';
|
|
179
242
|
}
|
|
180
243
|
|
|
244
|
+
function columns() {
|
|
245
|
+
return ['Place', 'Builder', 'Roles', 'Rank',
|
|
246
|
+
{ label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
|
|
247
|
+
{ label: 'Karma', class: 'ledger__num' },
|
|
248
|
+
{ label: 'Tokens', class: 'ledger__num' }];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function makeRosterLedger() {
|
|
252
|
+
body.textContent = '';
|
|
253
|
+
body.className = '';
|
|
254
|
+
ledger = kit.makeLedger({
|
|
255
|
+
mount: body,
|
|
256
|
+
columns: columns(),
|
|
257
|
+
rowHtml: rowHtml,
|
|
258
|
+
emptyMsg: role
|
|
259
|
+
? 'No builder on the roster has earned as ' + CRAFT_WORD[role].toLowerCase() + ' yet, or none matches that search.'
|
|
260
|
+
: 'No builder on the roster matches that search.',
|
|
261
|
+
pager: pager,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
181
265
|
function rerender() {
|
|
182
266
|
if (ledger) ledger.render(ordered(all.filter(matches)));
|
|
183
267
|
}
|
|
184
268
|
|
|
269
|
+
// The role toggle (owner decision (b)): Everyone, then one per craft. A pressed
|
|
270
|
+
// chip ranks by the credits that craft earned and shows only the builders it
|
|
271
|
+
// is shown for. The ledger is rebuilt so the credits header names the craft.
|
|
272
|
+
function roleChipsHtml() {
|
|
273
|
+
return ROLE_CHIPS.map(function (c) {
|
|
274
|
+
return '<button type="button" class="roster-role" data-role="' + esc(c[0]) + '" aria-pressed="' + (c[0] === role) + '">' + esc(c[1]) + '</button>';
|
|
275
|
+
}).join('');
|
|
276
|
+
}
|
|
277
|
+
function mountRoleChips() {
|
|
278
|
+
var mount = document.getElementById('roster-roles');
|
|
279
|
+
if (!mount) return;
|
|
280
|
+
mount.innerHTML = roleChipsHtml();
|
|
281
|
+
mount.addEventListener('click', function (e) {
|
|
282
|
+
var btn = e.target.closest('[data-role]');
|
|
283
|
+
if (!btn || btn.dataset.role === role) return;
|
|
284
|
+
role = btn.dataset.role;
|
|
285
|
+
mount.querySelectorAll('[data-role]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.role === role)); });
|
|
286
|
+
pager.page = 1;
|
|
287
|
+
makeRosterLedger();
|
|
288
|
+
rerender();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
185
292
|
function notice(msg) {
|
|
186
293
|
body.innerHTML = '';
|
|
187
294
|
body.className = 'note';
|
|
@@ -221,23 +328,16 @@
|
|
|
221
328
|
rerender();
|
|
222
329
|
},
|
|
223
330
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
ledger = kit.makeLedger({
|
|
227
|
-
mount: body,
|
|
228
|
-
columns: ['Place', 'Builder', 'Rank',
|
|
229
|
-
{ label: currencyLabel(), class: 'ledger__num' },
|
|
230
|
-
{ label: 'Karma', class: 'ledger__num' },
|
|
231
|
-
{ label: 'Tokens', class: 'ledger__num' }],
|
|
232
|
-
rowHtml: rowHtml,
|
|
233
|
-
emptyMsg: 'No builder on the roster matches that search.',
|
|
234
|
-
pager: pager,
|
|
235
|
-
});
|
|
331
|
+
mountRoleChips();
|
|
332
|
+
makeRosterLedger();
|
|
236
333
|
rerender();
|
|
237
334
|
}
|
|
238
335
|
|
|
239
336
|
var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
|
|
240
|
-
|
|
337
|
+
// The PUBLIC list (task 1004437): the same rows the authed /leaderboard
|
|
338
|
+
// carries plus each builder's roles, and the one the status page reads, so
|
|
339
|
+
// the hall and the public page cannot rank a role differently.
|
|
340
|
+
fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
|
|
241
341
|
.then(function (r) {
|
|
242
342
|
if (!r.ok) throw new Error('http ' + r.status);
|
|
243
343
|
return r.json();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/
|
|
2
|
+
"_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/public__leaderboard.json, since task 1004437 the list the page reads: fifteen builders with avatars, ranks, karma, tokens and each one's roles, including an artist and an ideator with no AI sessions).",
|
|
3
3
|
"page": "/builders/roster",
|
|
4
4
|
"surface": "hall-ui",
|
|
5
5
|
"stub": {
|
|
@@ -44,6 +44,31 @@
|
|
|
44
44
|
]
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
|
+
"artists": {
|
|
48
|
+
"_": "The role toggle with Artists pressed (task 1004437, owner decision (b)): only the builders shown as artists, ranked by the credits that craft earned, the credits header naming the craft. Against the harness the browser-only artist (2001) leads, with a dash where an AI builder's tokens would be. The harness serves public__leaderboard.json, which carries the roles.",
|
|
49
|
+
"auth": true,
|
|
50
|
+
"actions": [
|
|
51
|
+
[
|
|
52
|
+
"wait",
|
|
53
|
+
700
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
"click",
|
|
57
|
+
"[data-role=\"artist\"]"
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
"wait",
|
|
61
|
+
200
|
|
62
|
+
]
|
|
63
|
+
],
|
|
64
|
+
"expect": {
|
|
65
|
+
"visible": [
|
|
66
|
+
"#roster-scroll",
|
|
67
|
+
"#roster-roles",
|
|
68
|
+
".lb-roles"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
47
72
|
"search": {
|
|
48
73
|
"auth": true,
|
|
49
74
|
"actions": [
|
|
@@ -8,3 +8,23 @@
|
|
|
8
8
|
- **It is deliberately UNGATED,** and `tests/hall_page_gate_map.mjs` spells that out rather than defaulting. Its one data source, `GET /leaderboard`, is deliberately unauthenticated and mirrors the public status surface; a page must not be gated tighter than the API it fronts, and it was already reachable to a stranger as a view on the ungated index. No visibility predicate is applied and that is correct — privacy suppresses cross-project surfaces only, so a `private` builder still appears on the roster of a project they build on (ADR 0225).
|
|
9
9
|
|
|
10
10
|
Pinned by `tests/hall_nav.mjs` (the Community group's middle id is `roster`) and `tests/hall_page_gate_map.mjs`. Measured through `roster.states.json` — whose `out` state renders the REAL page, since the page is ungated.
|
|
11
|
+
|
|
12
|
+
## The roles (task 1004437, WA6.RP07)
|
|
13
|
+
|
|
14
|
+
Owner decision (b), 2026-09-30: the leaderboard gets BOTH a column naming each builder's roles and the Everyone / Engineers / Artists / Ideators toggle above it. The approved mock is `docs/design/mocks/profile-roles/` (`?state=board`, `?state=board-artists`).
|
|
15
|
+
|
|
16
|
+
- **The roster now reads `GET /public/leaderboard`,** the list the status page reads, which carries each row's `roles` (`src/bongos/role-stats.js` `readRolesForBuilders`: the profile's own main/shown rules, two reads for the whole page) and, since this task, `karma`. The hall and the public page cannot rank a role differently. The authed `/leaderboard` is unchanged and no longer read by the hall.
|
|
17
|
+
- **Roles column:** one pill per role in `roles.shown`, the main one a step stronger. `shown` is the main role plus every craft the builder has EARNED in, so it is evidence of work, not only a declared preference.
|
|
18
|
+
- **The toggle** keeps the builders a craft is shown for and ranks them by the credits that craft earned (`roles.credits_by_craft`, in the row: ADR 0255). The place column becomes the place inside that craft, the credits header says "Credits as artist", and the karma/rank/tokens lenses still apply on top.
|
|
19
|
+
- **Tokens only where they apply.** A builder with no recorded AI session (an artist in the studio, an ideator in the room) reads a dash, and the cell's tooltip says no AI sessions were recorded, instead of a 0 and a "0 tokens per credit" figure that read as "did nothing".
|
|
20
|
+
- **Home's Standing card** reads the same list and adds "#N as an artist" beside "#N on the leaderboard" when the place inside the builder's main role differs from the overall one (`hall-render.js` `rolePlaceText`).
|
|
21
|
+
- **The status page** names each builder's roles under their name, on that page's own palette (`status.js` `renderRoles`).
|
|
22
|
+
|
|
23
|
+
Pinned by `tests/leaderboard_roles.mjs` (the read, the route, the REAL roster.js over the harness fixture with the toggle clicked, the Standing card's place and the status renderer, run). The `artists` state in `roster.states.json` renders the toggle pressed.
|
|
24
|
+
|
|
25
|
+
### Deliberate differences from the mock (RP07)
|
|
26
|
+
|
|
27
|
+
- **No "Best known for" column.** Each row's headline count (works shipped, pages approved, thoughts ignited) is a per-builder read in three different modules; the list would pay three more reads for every page of builders. The profile, one click away, leads with exactly that number.
|
|
28
|
+
- **The roster keeps its live columns**: place with the builder's number, rank, karma and tokens, the founding mark and the laurels, and its search and sort lenses. The mock draws the leaderboard bare; the live hall's chrome wins (the blend rule).
|
|
29
|
+
- **The column is "Roles", not "Preferred roles"**: it lists roles the builder has earned in as well as the one they chose, so "preferred" would understate it.
|
|
30
|
+
- **The status page has the roles but not the toggle.** It is a one-glance public dashboard of the top builders on its own palette; the toggle lives on the roster, which is the leaderboard a builder works from.
|
|
@@ -944,6 +944,29 @@ async function runPromotionSweep(deps = {}) {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
+
// The craft-achievement backfill (task 1004438, WA6.RP08): the one badge a
|
|
948
|
+
// migration cannot backfill, "A Whole Surface", needs the page inventory, which is
|
|
949
|
+
// a file the database never sees. Run ONCE per process start, on the first tick,
|
|
950
|
+
// through the reward port (economy is optional; no port, no sweep). Pure DB and
|
|
951
|
+
// idempotent, so a restart that runs it again unlocks nothing twice; a failure is
|
|
952
|
+
// logged and never touches the other sweeps.
|
|
953
|
+
let craftBackfillDone = false;
|
|
954
|
+
async function runCraftAchievementBackfill(deps = {}) {
|
|
955
|
+
const { logFn, errFn } = loggers(deps);
|
|
956
|
+
if (craftBackfillDone && !deps.forceCraftBackfill) return { skipped: 'already_ran' };
|
|
957
|
+
craftBackfillDone = true;
|
|
958
|
+
const reward = deps.reward || seams.resolveOptional('reward');
|
|
959
|
+
if (!reward || typeof reward.backfillCraftAchievements !== 'function') return { skipped: 'unsupported' };
|
|
960
|
+
try {
|
|
961
|
+
const out = await reward.backfillCraftAchievements({});
|
|
962
|
+
if (out && out.unlocked) logFn(`[publish-reconciler] craft achievements: ${out.unlocked} unlocked across ${out.checked} builder(s)`);
|
|
963
|
+
return out;
|
|
964
|
+
} catch (e) {
|
|
965
|
+
errFn(`[publish-reconciler] craft achievement backfill (non-blocking): ${e && e.message}`);
|
|
966
|
+
return { skipped: 'error', error: e && e.message };
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
947
970
|
// Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
|
|
948
971
|
// by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
|
|
949
972
|
// default whenever a push credential is configured; the route's getPublishStatus
|
|
@@ -964,6 +987,7 @@ function start(deps = {}) {
|
|
|
964
987
|
runCostSweep(deps).catch(crashHandler('cost sweep'));
|
|
965
988
|
runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
|
|
966
989
|
runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
|
|
990
|
+
runCraftAchievementBackfill(deps).catch(crashHandler('craft achievement backfill'));
|
|
967
991
|
};
|
|
968
992
|
|
|
969
993
|
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
@@ -1002,6 +1026,7 @@ module.exports = {
|
|
|
1002
1026
|
runCostSweep,
|
|
1003
1027
|
runPromotionSweep,
|
|
1004
1028
|
runCriterionSweep,
|
|
1029
|
+
runCraftAchievementBackfill,
|
|
1005
1030
|
couldBeStaleGenerated,
|
|
1006
1031
|
describeTarget,
|
|
1007
1032
|
INTERVAL_MS,
|
|
@@ -5,16 +5,21 @@
|
|
|
5
5
|
// Kept beside github-push.js rather than in it: it shares that file's credential and headers,
|
|
6
6
|
// and nothing else.
|
|
7
7
|
|
|
8
|
-
const {
|
|
8
|
+
const { getInstallationToken, ghHeaders, isSafeBranch } = require('./github-push');
|
|
9
9
|
const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
|
|
10
10
|
|
|
11
11
|
// dispatchWorkflow — start a GitHub Actions workflow on this repo (task 1004298, ADR 0361). The
|
|
12
12
|
// Release button on /deploy uses it to run publish.yml's release job, so the npm credential never
|
|
13
|
-
// leaves GitHub: the server
|
|
14
|
-
// Actions: write for this one call. Returns { ok:true } on GitHub's 204, otherwise
|
|
15
|
-
// { ok:false, code, status? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
|
|
16
|
-
// (GitHub said no: 403 = the
|
|
17
|
-
// 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
|
|
13
|
+
// leaves GitHub: the server uses only the GitHub credential it already holds, which needs
|
|
14
|
+
// Actions: write for this one call. Returns { ok:true, credential } on GitHub's 204, otherwise
|
|
15
|
+
// { ok:false, code, status?, credential? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
|
|
16
|
+
// REFUSED (GitHub said no: 403 = the credential lacks Actions: write, 404 = no such workflow or no
|
|
17
|
+
// access, 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
|
|
18
|
+
//
|
|
19
|
+
// `credential` says WHICH one was used — 'app' (a GitHub App installation token) or 'token' (the
|
|
20
|
+
// GITHUB_PUSH_TOKEN access token) — so a refusal can name the thing the owner must edit
|
|
21
|
+
// (task 1004518: cloudbongos.com uses the access token, and a message naming "the App" sent the
|
|
22
|
+
// owner to the wrong settings page). Resolved in resolveToken's order: App first, then the token.
|
|
18
23
|
//
|
|
19
24
|
// ONLY THE WORKFLOWS NAMED HERE can be started. The port lends the server's GitHub credential to
|
|
20
25
|
// another module; an allowlist keeps a buggy or future borrower from running any other workflow in
|
|
@@ -22,8 +27,13 @@ const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
|
|
|
22
27
|
const DISPATCHABLE_WORKFLOWS = Object.freeze(['publish.yml']);
|
|
23
28
|
async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps = {}) {
|
|
24
29
|
if (!DISPATCHABLE_WORKFLOWS.includes(String(workflow || '')) || !isSafeBranch(ref)) return { ok: false, code: 'BAD_INPUT' };
|
|
25
|
-
let token;
|
|
26
|
-
|
|
30
|
+
let token = deps.token || null;
|
|
31
|
+
let credential = token ? (deps.credential || 'token') : null;
|
|
32
|
+
if (!token) {
|
|
33
|
+
try { token = await (deps.appToken || getInstallationToken)(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED', credential: 'app' }; }
|
|
34
|
+
if (token) credential = 'app';
|
|
35
|
+
}
|
|
36
|
+
if (!token && process.env.GITHUB_PUSH_TOKEN) { token = process.env.GITHUB_PUSH_TOKEN; credential = 'token'; }
|
|
27
37
|
if (!token) return { ok: false, code: 'UNCONFIGURED' };
|
|
28
38
|
const repoInfo = deps.repoInfo || loadRepoInfo();
|
|
29
39
|
if (!repoInfo || repoInfo.error) return { ok: false, code: 'NO_REPO_INFO' };
|
|
@@ -35,10 +45,10 @@ async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps =
|
|
|
35
45
|
headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
|
|
36
46
|
body: JSON.stringify({ ref, inputs }),
|
|
37
47
|
});
|
|
38
|
-
if (res.status === 204 || res.ok) return { ok: true };
|
|
39
|
-
return { ok: false, code: 'REFUSED', status: res.status };
|
|
48
|
+
if (res.status === 204 || res.ok) return { ok: true, credential };
|
|
49
|
+
return { ok: false, code: 'REFUSED', status: res.status, credential };
|
|
40
50
|
} catch (_) {
|
|
41
|
-
return { ok: false, code: 'UNREACHABLE' };
|
|
51
|
+
return { ok: false, code: 'UNREACHABLE', credential };
|
|
42
52
|
}
|
|
43
53
|
}
|
|
44
54
|
|
|
@@ -95,6 +95,15 @@ async function readReleaseLedger(pool) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// The credential a refusal names, in the words of the settings page the owner must open: GitHub
|
|
99
|
+
// lists an App under Developer settings → GitHub Apps, and an access token under Developer
|
|
100
|
+
// settings → Personal access tokens (task 1004518).
|
|
101
|
+
function credentialName(credential) {
|
|
102
|
+
if (credential === 'app') return "the server's GitHub App";
|
|
103
|
+
if (credential === 'token') return "the server's GitHub access token (GITHUB_PUSH_TOKEN, under Developer settings → Personal access tokens)";
|
|
104
|
+
return "the server's GitHub credential";
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
/**
|
|
99
108
|
* What the Release button does. Returns { status, body } for the route to send.
|
|
100
109
|
*
|
|
@@ -118,7 +127,7 @@ async function requestRelease({ version, registry, dispatch } = {}) {
|
|
|
118
127
|
}
|
|
119
128
|
const why = !r ? 'no answer'
|
|
120
129
|
: r.code === 'UNCONFIGURED' ? 'this server has no GitHub credential'
|
|
121
|
-
: r.code === 'REFUSED' && r.status === 403 ?
|
|
130
|
+
: r.code === 'REFUSED' && r.status === 403 ? `GitHub refused: ${credentialName(r.credential)} needs the Actions: write permission on this repository`
|
|
122
131
|
: r.code === 'REFUSED' && r.status === 404 ? 'GitHub could not find publish.yml, or the App cannot see this repository'
|
|
123
132
|
: r.code === 'REFUSED' ? `GitHub refused the request (${r.status})`
|
|
124
133
|
: r.code === 'UNREACHABLE' ? 'GitHub could not be reached'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/onboarding/founding-birth.js — A NEW PROJECT IS BORN INTO GENESIS (task
|
|
4
|
+
// 1004506 / BV2.PS18, goal 1000121; spec docs/specs/bongos-v2-project-startup.md step 5,
|
|
5
|
+
// D4, D8, D9).
|
|
6
|
+
//
|
|
7
|
+
// THE PROBLEM. POST /founding/start existed, but nothing called it, so a project made
|
|
8
|
+
// through the startup flow opened as an ordinary hall. The create request lands on the
|
|
9
|
+
// HUB, while founding mode lives in the NEW INSTANCE's own project_settings, and the hub
|
|
10
|
+
// holds no way into that database. So the hub only says so, and the instance acts:
|
|
11
|
+
//
|
|
12
|
+
// 1. The hub's create route stamps a reserved key on the new row
|
|
13
|
+
// (modules/provisioning/birth.js) — never on a row that already exists.
|
|
14
|
+
// 2. The runner writes it into the instance's web.env with the other settings
|
|
15
|
+
// companions: <PREFIX>_FOUNDING_BIRTH, plus <PREFIX>_DEMO_PEOPLE / _DEMO_HOURS
|
|
16
|
+
// when the row is a demo. src/branding.js maps them onto `founding.*`, which is
|
|
17
|
+
// server-only (clientBranding never publishes it).
|
|
18
|
+
// 3. Here, the instance reads that marker and, once, starts founding — and for a
|
|
19
|
+
// demo, also settles the expedited plan, so its decide stage needs no choices.
|
|
20
|
+
//
|
|
21
|
+
// WHEN IT RUNS: lazily, on the first founding read (GET /me through the port, and
|
|
22
|
+
// GET /founding/genesis), not at boot. The first /me is the hall's first paint after the
|
|
23
|
+
// founder's first sign-in, so the hall opens already in genesis; and by then the
|
|
24
|
+
// founder is seated and the kickoff seed has run, so the board the demo's stages are
|
|
25
|
+
// filed on exists. At boot it would race the module load order (the lifecycle port the
|
|
26
|
+
// plan files tasks through may not be registered yet) and a database that is still
|
|
27
|
+
// coming up.
|
|
28
|
+
//
|
|
29
|
+
// WHY IT CANNOT RACE OR TOUCH AN OLD PROJECT.
|
|
30
|
+
// * No marker, no run: a project created before this shipped has none (D8), and a
|
|
31
|
+
// marker-less read resolves without touching the database.
|
|
32
|
+
// * Once per process: one shared promise, so two concurrent first reads share one run.
|
|
33
|
+
// * Once for good: founding is started only when it was never started and never
|
|
34
|
+
// ended (founding.started_at / ended_at), so a restart, a later settings push that
|
|
35
|
+
// rewrites the env, or an owner who already started by hand changes nothing. A demo
|
|
36
|
+
// carried over keeps its state for the same reason: genesis happened once.
|
|
37
|
+
// * The demo's plan is saved only while the project is still deciding with no plan,
|
|
38
|
+
// and savePlan itself refuses outside 'decide'; the stage tasks are filed by a seeder
|
|
39
|
+
// that skips a step already filed.
|
|
40
|
+
|
|
41
|
+
const fm = require('./founding-mode');
|
|
42
|
+
const genesis = require('./genesis-home');
|
|
43
|
+
|
|
44
|
+
// The demo's limits and the bounds check are genesis-home.js's own, never retyped here.
|
|
45
|
+
const { DEMO_PEOPLE, DEMO_HOURS, intIn } = genesis;
|
|
46
|
+
|
|
47
|
+
// birthMarker(founding) — PURE. The resolved branding pack's `founding` block → whether
|
|
48
|
+
// this project was born through the startup flow, and its demo time box if a demo. A
|
|
49
|
+
// malformed demo value reads as no demo (the project still founds, with the normal plan).
|
|
50
|
+
function birthMarker(founding) {
|
|
51
|
+
const f = founding && typeof founding === 'object' ? founding : {};
|
|
52
|
+
const born = typeof f.birth === 'string' && Number.isFinite(new Date(f.birth).getTime());
|
|
53
|
+
if (!born) return { born: false, demo: null };
|
|
54
|
+
const people = Number(f.demoPeople);
|
|
55
|
+
const hours = Number(f.demoHours);
|
|
56
|
+
const demo = intIn(people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) && intIn(hours, DEMO_HOURS[0], DEMO_HOURS[1]) ? { people, hours_each: hours } : null;
|
|
57
|
+
return { born: true, demo };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function markerFrom(deps) {
|
|
61
|
+
if (deps.marker) return deps.marker;
|
|
62
|
+
let b;
|
|
63
|
+
try { b = require('../../src/module-api').branding(); } catch (_) { return { born: false, demo: null }; }
|
|
64
|
+
return birthMarker(b && b.founding);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// bornFounding(deps) — the guarded birth. Returns what it did:
|
|
68
|
+
// { ran: false, reason } nothing to do (no marker, already, alive)
|
|
69
|
+
// { ran: true, started, plan, demo } it started founding and/or saved the plan
|
|
70
|
+
// { ran: false, reason, retry: true } the demo plan could not be saved yet
|
|
71
|
+
async function bornFounding(deps = {}) {
|
|
72
|
+
const marker = markerFrom(deps);
|
|
73
|
+
if (!marker.born) return { ran: false, reason: 'no_marker' };
|
|
74
|
+
const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
|
|
75
|
+
const [started, ended] = await Promise.all([get(fm.KEYS.STARTED_AT), get(fm.KEYS.ENDED_AT)]);
|
|
76
|
+
if (ended && ended.value) return { ran: false, reason: 'already_alive' };
|
|
77
|
+
let didStart = false;
|
|
78
|
+
if (!(started && started.value)) {
|
|
79
|
+
const r = await genesis.startFounding(null, deps);
|
|
80
|
+
if (!r.ok) return { ran: false, reason: r.reason };
|
|
81
|
+
didStart = true;
|
|
82
|
+
}
|
|
83
|
+
if (!marker.demo) return didStart ? { ran: true, started: true, plan: false, demo: false } : { ran: false, reason: 'already_started' };
|
|
84
|
+
// The demo's expedited plan: only while still deciding, and only if none is saved.
|
|
85
|
+
const mode = await fm.foundingModeFor(genesis.modeDepsFor({ ...deps, getSetting: get }));
|
|
86
|
+
const planRow = await get(genesis.PLAN_KEY);
|
|
87
|
+
if (!mode || mode.stage !== 'decide' || (planRow && planRow.value)) {
|
|
88
|
+
return didStart ? { ran: true, started: true, plan: false, demo: true } : { ran: false, reason: 'already_started' };
|
|
89
|
+
}
|
|
90
|
+
const saved = await genesis.savePlan(genesis.demoPlan(marker.demo), null, deps);
|
|
91
|
+
if (!saved.ok) return { ran: false, reason: saved.reason, retry: true };
|
|
92
|
+
return { ran: true, started: didStart, plan: true, demo: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ensureBirth(deps) — the once-per-process door the reads call. Shares one run between
|
|
96
|
+
// concurrent callers; forgets a run that failed or asked to retry, so a later read tries
|
|
97
|
+
// again. Never throws: a birth hiccup must never fail the read that triggered it.
|
|
98
|
+
let once = null;
|
|
99
|
+
function ensureBirth(deps = {}) {
|
|
100
|
+
if (once) return once;
|
|
101
|
+
const p = bornFounding(deps).then((r) => {
|
|
102
|
+
if (r && r.retry) once = null;
|
|
103
|
+
return r;
|
|
104
|
+
}, (err) => {
|
|
105
|
+
once = null;
|
|
106
|
+
try { require('../../src/module-api').logger('onboarding').error('[onboarding] founding birth failed', err); } catch (_) { /* no logger: nothing to do */ }
|
|
107
|
+
return { ran: false, reason: 'error' };
|
|
108
|
+
});
|
|
109
|
+
once = p;
|
|
110
|
+
return p;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Test seam: forget the per-process run.
|
|
114
|
+
function resetBirthForTests() { once = null; }
|
|
115
|
+
|
|
116
|
+
module.exports = { birthMarker, bornFounding, ensureBirth, resetBirthForTests };
|