@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
|
@@ -3193,8 +3193,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3193
3193
|
'<div class="runs"><span class="rLbl">runs</span>' + tagsHtml(p.tags) + '</div>' +
|
|
3194
3194
|
/* at most two doors (task 1003356): the site, and the hall where the
|
|
3195
3195
|
viewer builds there and the hall answers at a different address */
|
|
3196
|
-
'<div class="rowDoors">' + (
|
|
3197
|
-
? '<a class="rowGo" href="' + esc(
|
|
3196
|
+
'<div class="rowDoors">' + (siteHref(p)
|
|
3197
|
+
? '<a class="rowGo" href="' + esc(siteHref(p)) + '" target="_blank" rel="noopener">view</a>'
|
|
3198
3198
|
: '') + hallGlyphHtml(p, rel) + '</div>' +
|
|
3199
3199
|
'</li>';
|
|
3200
3200
|
}).join(''));
|
|
@@ -3413,8 +3413,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3413
3413
|
var HALL_GLYPH = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M2.8 7.4 10 3.5l7.2 3.9"/><path d="M5.2 8.8v5.6M10 8.8v5.6M14.8 8.8v5.6"/><path d="M3.4 16.3h13.2"/></svg>';
|
|
3414
3414
|
function isMember(rel) { return rel === 'owner' || rel === 'builder'; }
|
|
3415
3415
|
/* the row's hall glyph: a member's door, only where the hall is elsewhere */
|
|
3416
|
+
/* the site door for a map entry: one of the viewer's own projects gets none until
|
|
3417
|
+
its address answers (task 1004505, addressReady) */
|
|
3418
|
+
function siteHref(p) { return p._inst && !addressReady(p._inst) ? '' : safeHttpUrl(p.origin); }
|
|
3416
3419
|
function hallGlyphHtml(p, rel) {
|
|
3417
3420
|
var hall = isMember(rel) ? hallUrlOf(p) : '';
|
|
3421
|
+
if (p._inst && !addressReady(p._inst)) hall = ''; /* your own project's hall waits for its name too (task 1004505) */
|
|
3418
3422
|
return hall
|
|
3419
3423
|
? '<a class="rowHall" href="' + esc(hall) + '" target="_blank" rel="noopener" aria-label="Builders\' hall" title="Builders\' hall">' + HALL_GLYPH + '</a>'
|
|
3420
3424
|
: '';
|
|
@@ -3472,7 +3476,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3472
3476
|
}
|
|
3473
3477
|
|
|
3474
3478
|
function openCard(p, rel, returnTo) {
|
|
3475
|
-
var href = p.isSelf ? '/' :
|
|
3479
|
+
var href = p.isSelf ? '/' : siteHref(p);
|
|
3476
3480
|
/* the art: catalog text an Archon typed, scheme-checked server-side and
|
|
3477
3481
|
again here before it becomes a src */
|
|
3478
3482
|
cardArt(p.isSelf ? '' : (backdropPlate(p.card_backdrop) || safeHttpUrl(p.art_url)));
|
|
@@ -6937,7 +6941,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6937
6941
|
return '<a href="' + (instanceId ? '?view=manage&id=' + encodeURIComponent(instanceId) : '?view=mine') +
|
|
6938
6942
|
'">' + label + '</a>';
|
|
6939
6943
|
}
|
|
6940
|
-
var live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
|
|
6944
|
+
var live = { instStatus: null, addrReady: false, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
|
|
6941
6945
|
var planPromises = {}; /* mode|domain → Promise<steps[]> */
|
|
6942
6946
|
var lastStepsHtml = '';
|
|
6943
6947
|
var lastProgHtml = ''; /* same change-only contract, for the progress strip */
|
|
@@ -7083,7 +7087,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7083
7087
|
/* the door opens only when the project ANSWERS and sign-in is wired: a
|
|
7084
7088
|
link to an address that does not resolve yet is the worst first
|
|
7085
7089
|
impression a founder can get of their own project (task 1003232) */
|
|
7086
|
-
if (live.instStatus !== 'active' || live.manifest !== 'done') {
|
|
7090
|
+
if (live.instStatus !== 'active' || !live.addrReady || live.manifest !== 'done') {
|
|
7087
7091
|
return '<li class="slocked" data-onboard-step="first-sign-in"><b>First sign-in</b><p>Unlocks once your project is answering and sign-in above is wired. Then you’ll open <code style="font-family:var(--font-mono)">' + esc(domain) + '</code>, sign in with GitHub once, and be seated as the owner — the founding Archon.</p></li>';
|
|
7088
7092
|
}
|
|
7089
7093
|
return '<li data-onboard-step="first-sign-in"><b>First sign-in</b><p>Open <a href="https://' + esc(domain) + '" target="_blank" rel="noopener"><code style="font-family:var(--font-mono)">' + esc(domain) + '</code></a> and sign in with GitHub once. That seats you as the owner — the founding Archon.</p></li>';
|
|
@@ -7091,7 +7095,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7091
7095
|
|
|
7092
7096
|
function renderStepEnterHall() {
|
|
7093
7097
|
var domain = resolvedDomain();
|
|
7094
|
-
if (domain && (live.instStatus !== 'active' || live.manifest !== 'done')) {
|
|
7098
|
+
if (domain && (live.instStatus !== 'active' || !live.addrReady || live.manifest !== 'done')) {
|
|
7095
7099
|
return '<li class="slocked" data-onboard-step="enter-hall"><b>Enter your builders hall</b><p>Unlocks with the first sign-in — your project’s home will be <code style="font-family:var(--font-mono)">' + esc(domain) + '/builders</code>.</p></li>';
|
|
7096
7100
|
}
|
|
7097
7101
|
return domain
|
|
@@ -7383,6 +7387,14 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7383
7387
|
projectPageLink('its own page') + '; the steps below that need one unlock then.', '', flow));
|
|
7384
7388
|
return true;
|
|
7385
7389
|
}
|
|
7390
|
+
/* task 1004505: no link until the platform's own lookup has seen the name
|
|
7391
|
+
answer. A founder's first click on a name that does not resolve yet is
|
|
7392
|
+
cached as "not found" by their resolver for up to half an hour. */
|
|
7393
|
+
if (!addressReady(inst)) {
|
|
7394
|
+
setProgress(progressMarkup(2, 2,
|
|
7395
|
+
'The build finished — your address is being set up. This panel links it the moment it answers.', '', flow));
|
|
7396
|
+
return false;
|
|
7397
|
+
}
|
|
7386
7398
|
if (inst.last_verified_up === false) {
|
|
7387
7399
|
setProgress(progressMarkup(2, 2,
|
|
7388
7400
|
'The build finished, but ' + link + ' isn’t answering right now — this panel keeps checking.', '', flow));
|
|
@@ -7471,8 +7483,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7471
7483
|
/* the checklist reflects live state (task 1002694): a status transition —
|
|
7472
7484
|
e.g. the scaffold landing — re-renders the steps (change-only, so the
|
|
7473
7485
|
5s tick never clobbers an open dropdown or mid-flight button) */
|
|
7474
|
-
if (inst && inst.status !== live.instStatus) {
|
|
7475
|
-
live.instStatus = inst.status; renderSteps();
|
|
7486
|
+
if (inst && (inst.status !== live.instStatus || addressReady(inst) !== live.addrReady)) {
|
|
7487
|
+
live.instStatus = inst.status; live.addrReady = addressReady(inst); renderSteps();
|
|
7476
7488
|
if (state.step === 13) paintRail(13); /* the rail finishes only when the project answers */
|
|
7477
7489
|
}
|
|
7478
7490
|
var terminal = renderProgress(inst);
|
|
@@ -7505,7 +7517,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7505
7517
|
instanceId = null;
|
|
7506
7518
|
watchPending = false; manifestPending = false; manifestState = null;
|
|
7507
7519
|
manifestStartedAt = 0; manifestStale = false;
|
|
7508
|
-
live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
|
|
7520
|
+
live = { instStatus: null, addrReady: false, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
|
|
7509
7521
|
lastStepsHtml = '';
|
|
7510
7522
|
lastProgHtml = '';
|
|
7511
7523
|
try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
|
|
@@ -7797,6 +7809,11 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7797
7809
|
so the page can never say "Answering at …" beside a chip that says "live?". */
|
|
7798
7810
|
function verifiedAt(inst) { return inst.last_verified_at ? new Date(inst.last_verified_at).getTime() : 0; }
|
|
7799
7811
|
function freshUp(inst) { var at = verifiedAt(inst); return !!(inst.last_verified_up && at && (Date.now() - at) <= FRESH_MS); }
|
|
7812
|
+
/* an address is LINKED only once the platform's own lookup has seen it answer (task
|
|
7813
|
+
1004505): a click made before the name exists is cached as "not found" by the
|
|
7814
|
+
visitor's resolver for up to half an hour, so the provision status alone is never
|
|
7815
|
+
enough. dns_resolves is the server's sighting; an up probe is the same proof. */
|
|
7816
|
+
function addressReady(inst) { return !!(inst && inst.domain && (inst.dns_resolves || inst.last_verified_up)); }
|
|
7800
7817
|
/* the landing moment (task 1003243): a standup or revive that VERIFIABLY came
|
|
7801
7818
|
up, still recent enough to be news. Two gates, both already on the card —
|
|
7802
7819
|
freshUp() so "live" stays ONE rule (a lit third stage beside a "live?" chip
|
|
@@ -7933,7 +7950,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7933
7950
|
else if (inst.status === 'error' || (inst.status === 'active' && inst.last_verified_up === false)) cls += ' ashen';
|
|
7934
7951
|
else if (inst.status === 'active' && !freshUp(inst)) cls += ' dim';
|
|
7935
7952
|
var attrs = planetAttrs(pl) + ' style="--acc:' + pl.acc + ';--d:64px"';
|
|
7936
|
-
if (inst.status === 'active' && inst
|
|
7953
|
+
if (inst.status === 'active' && addressReady(inst)) {
|
|
7937
7954
|
return '<a class="' + cls + '"' + attrs + ' href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener" title="Open ' + esc(inst.slug) + '" aria-label="Open ' + esc(inst.slug) + '"><span class="disc"></span></a>';
|
|
7938
7955
|
}
|
|
7939
7956
|
return '<span class="' + cls + '"' + attrs + ' aria-hidden="true"><span class="disc"></span></span>';
|
|
@@ -8085,6 +8102,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8085
8102
|
has. Since task 1003355 the list row acts on nothing, so a row's sentence
|
|
8086
8103
|
sends the owner to the project's own page and the manage page's says
|
|
8087
8104
|
"below" — the ADR 0199 §8 rule that a refusal must name a reachable door. */
|
|
8105
|
+
var ADDRESS_SETTING_UP = 'Your address is being set up — it shows here, with a link, as soon as it answers.';
|
|
8088
8106
|
function addressRow(inst, onManage) {
|
|
8089
8107
|
/* state FIRST, then domain: a torn-down or snagged project still holds its
|
|
8090
8108
|
domain (ADR 0181 keeps port + address), and the standup promise below
|
|
@@ -8099,6 +8117,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8099
8117
|
/* "answering" is a VERIFIED claim (direction §3 / F16): only a FRESH up
|
|
8100
8118
|
probe earns it — never the runner finishing its script, and never a
|
|
8101
8119
|
probe the chip has already aged into "live?" */
|
|
8120
|
+
/* task 1004505: no link — and no name to type — until the platform's own
|
|
8121
|
+
lookup has seen it answer; the manual-DNS note still names the address,
|
|
8122
|
+
because pointing it is the owner's own step */
|
|
8123
|
+
if (!addressReady(inst)) {
|
|
8124
|
+
return inst.dns_note ? 'Its address is <code>' + esc(inst.domain) + '</code>. ' + esc(inst.dns_note)
|
|
8125
|
+
: ADDRESS_SETTING_UP;
|
|
8126
|
+
}
|
|
8102
8127
|
var link = '<a href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>';
|
|
8103
8128
|
if (freshUp(inst)) return 'Answering at ' + link;
|
|
8104
8129
|
/* the runner had no DNS hook (F16): the CAUSE beats the bare symptom —
|
|
@@ -8119,7 +8144,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8119
8144
|
return 'Its address is ' + link + ' — confirming it answers.';
|
|
8120
8145
|
}
|
|
8121
8146
|
if (inst.status === 'error') return 'Address <code>' + esc(inst.domain) + '</code> — held for this project, but nothing is answering there yet.';
|
|
8122
|
-
return '
|
|
8147
|
+
return inst.dns_note ? 'Its address is <code>' + esc(inst.domain) + '</code>. ' + esc(inst.dns_note) : ADDRESS_SETTING_UP;
|
|
8123
8148
|
}
|
|
8124
8149
|
/* the HONEST no-address state: sign-in on the instance needs a real domain */
|
|
8125
8150
|
return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one. ' +
|
|
@@ -8191,7 +8216,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8191
8216
|
links, so middle/ctrl-click works; the delegated handler upgrades a plain
|
|
8192
8217
|
click on Manage (task 1003041). */
|
|
8193
8218
|
var acts = [];
|
|
8194
|
-
if (inst.status === 'active' && inst
|
|
8219
|
+
if (inst.status === 'active' && addressReady(inst)) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">View</a>');
|
|
8195
8220
|
acts.push('<a class="btn small ghost" data-manage="' + iid + '" href="?view=manage&id=' + iid + '">Manage</a>');
|
|
8196
8221
|
body += '<div class="actions">' + acts.join('') + '</div>';
|
|
8197
8222
|
/* sig() buckets recency per minute, so a live row re-renders on the
|
|
@@ -8514,7 +8539,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8514
8539
|
$('mErr').innerHTML = snag ? errMarkup(inst) : '';
|
|
8515
8540
|
$('mErr').hidden = !snag;
|
|
8516
8541
|
var acts = [];
|
|
8517
|
-
if (inst.status === 'active' && inst
|
|
8542
|
+
if (inst.status === 'active' && addressReady(inst)) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
|
|
8518
8543
|
if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-m-retry>Try again</button>');
|
|
8519
8544
|
if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-m-revive>Bring it back</button>');
|
|
8520
8545
|
if (canRestart(inst)) acts.push('<button type="button" class="btn small" data-m-restart>Restart it</button>');
|
|
@@ -9319,7 +9344,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
9319
9344
|
/* what the project RUNS: its own public manifest, read cross-origin (the
|
|
9320
9345
|
same GET /instance the platform's runner reads after a restart) */
|
|
9321
9346
|
function loadVisRuns(inst) {
|
|
9322
|
-
if (!inst || inst.status !== 'active' || !inst
|
|
9347
|
+
if (!inst || inst.status !== 'active' || !addressReady(inst)) { visRuns(''); return; } /* the browser's own lookup waits for the name too (task 1004505) */
|
|
9323
9348
|
var asked = manageId;
|
|
9324
9349
|
visRuns('Checking what the project is running…');
|
|
9325
9350
|
ensureManifest(inst.domain).then(function (ans) {
|
|
@@ -9772,7 +9797,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
9772
9797
|
/* what the project RUNS: the SAME shared manifest read the reach card uses
|
|
9773
9798
|
(task 1003554) — a different key out of one answer, not a second GET */
|
|
9774
9799
|
function loadJoinRuns(inst) {
|
|
9775
|
-
if (!inst || inst.status !== 'active' || !inst
|
|
9800
|
+
if (!inst || inst.status !== 'active' || !addressReady(inst)) { joinRuns(''); return; } /* the browser's own lookup waits for the name too (task 1004505) */
|
|
9776
9801
|
var asked = manageId;
|
|
9777
9802
|
joinRuns('Checking what the project is running…');
|
|
9778
9803
|
ensureManifest(inst.domain).then(function (ans) {
|
package/modules/sessions/db.js
CHANGED
|
@@ -658,6 +658,62 @@ async function getSessionById(id) {
|
|
|
658
658
|
return rows[0] || null;
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
+
const TRANSCRIPT_PAGE_DEFAULT = 500; // turns per read; the reader pages with offset=
|
|
662
|
+
const TRANSCRIPT_PAGE_MAX = 2000;
|
|
663
|
+
|
|
664
|
+
// upsertSessionTranscript — store/refresh one session's FULL scrubbed transcript
|
|
665
|
+
// (ADR 0051, task 1001004). Keyed on session_id like session_records, and the same
|
|
666
|
+
// ownership guard applies: a row can only be rewritten by the builder who owns it,
|
|
667
|
+
// so guessing another builder's session_id cannot overwrite their transcript. The
|
|
668
|
+
// content arrives already scrubbed (the uploader's box is the scrub point); this
|
|
669
|
+
// layer stores it and never inspects it. `turnsJson` is the ALREADY-serialized
|
|
670
|
+
// array (the route serializes once to measure it; re-stringifying 12 MB here would
|
|
671
|
+
// block the event loop a second time). The row is only ever created FROM the
|
|
672
|
+
// caller's own session_records row, so a transcript cannot exist for a session the
|
|
673
|
+
// builder did not upload a digest for (and a stranger cannot squat a session_id
|
|
674
|
+
// ahead of its owner). Returns { session_id, turn_count, byte_size, truncated } or
|
|
675
|
+
// null when there is no such owned session.
|
|
676
|
+
async function upsertSessionTranscript({ sessionId, builderId, turnsJson, turnCount = 0, truncated = false, byteSize = 0 }) {
|
|
677
|
+
const { rows } = await pool.query(
|
|
678
|
+
`INSERT INTO session_transcripts (session_id, builder_id, turn_count, byte_size, truncated, turns)
|
|
679
|
+
SELECT sr.session_id, sr.builder_id, $3, $4, $5, $6::jsonb
|
|
680
|
+
FROM session_records sr
|
|
681
|
+
WHERE sr.session_id = $1 AND sr.builder_id = $2
|
|
682
|
+
ON CONFLICT (session_id) DO UPDATE SET
|
|
683
|
+
turn_count = EXCLUDED.turn_count,
|
|
684
|
+
byte_size = EXCLUDED.byte_size,
|
|
685
|
+
truncated = EXCLUDED.truncated,
|
|
686
|
+
turns = EXCLUDED.turns,
|
|
687
|
+
uploaded_at = now()
|
|
688
|
+
WHERE session_transcripts.builder_id = EXCLUDED.builder_id
|
|
689
|
+
RETURNING session_id, turn_count, byte_size, truncated`,
|
|
690
|
+
[sessionId, builderId, Math.max(0, Math.trunc(Number(turnCount) || 0)), Math.max(0, Math.trunc(Number(byteSize) || 0)), !!truncated, turnsJson]
|
|
691
|
+
);
|
|
692
|
+
return rows[0] || null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// getSessionTranscript — one PAGE of the full transcript for one session_records row (by its
|
|
696
|
+
// numeric id, the same id /sessions/:id uses), with the owner so the route can
|
|
697
|
+
// apply the own-vs-Archon rule. null when the session or its transcript is absent.
|
|
698
|
+
async function getSessionTranscript(id, { offset = 0, limit = TRANSCRIPT_PAGE_DEFAULT } = {}) {
|
|
699
|
+
const off = Math.max(0, Math.trunc(Number(offset) || 0));
|
|
700
|
+
const lim = Math.min(TRANSCRIPT_PAGE_MAX, Math.max(1, Math.trunc(Number(limit) || TRANSCRIPT_PAGE_DEFAULT)));
|
|
701
|
+
// The slice happens in Postgres so a page read never ships the whole MB-scale
|
|
702
|
+
// jsonb to Node just to throw most of it away.
|
|
703
|
+
const { rows } = await pool.query(
|
|
704
|
+
`SELECT sr.id, sr.session_id, sr.builder_id, st.turn_count, st.byte_size,
|
|
705
|
+
st.truncated, st.uploaded_at,
|
|
706
|
+
COALESCE((SELECT jsonb_agg(e.v ORDER BY e.n)
|
|
707
|
+
FROM jsonb_array_elements(st.turns) WITH ORDINALITY AS e(v, n)
|
|
708
|
+
WHERE e.n > $2 AND e.n <= $2 + $3), '[]'::jsonb) AS turns
|
|
709
|
+
FROM session_records sr
|
|
710
|
+
JOIN session_transcripts st ON st.session_id = sr.session_id
|
|
711
|
+
WHERE sr.id = $1`,
|
|
712
|
+
[Number(id), off, lim]
|
|
713
|
+
);
|
|
714
|
+
return rows[0] ? { ...rows[0], offset: off, limit: lim } : null;
|
|
715
|
+
}
|
|
716
|
+
|
|
661
717
|
// listSessionsByBuilder — a builder's OWN sessions (self-serve summary list).
|
|
662
718
|
async function listSessionsByBuilder(builderId, limit = 25) {
|
|
663
719
|
const { rows } = await pool.query(
|
|
@@ -865,6 +921,8 @@ async function sessionTypeCounts() {
|
|
|
865
921
|
module.exports = {
|
|
866
922
|
// write side (6D.1)
|
|
867
923
|
upsertSessionRecord,
|
|
924
|
+
upsertSessionTranscript, // ADR 0051 — full scrubbed transcript (separate table)
|
|
925
|
+
getSessionTranscript,
|
|
868
926
|
upsertSessionRecordWithReward, // route entry point — upsert + token reward (task 1005)
|
|
869
927
|
awardDeferredSessionRewardForTask, // ADR 0120 land-side trigger — task.shipped listener calls it
|
|
870
928
|
reconcileUnbookedSessionRewards, // ADR 0120 state-based safety net — books rewards for out-of-band ships
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// audited (the BFG principal posture until 6C.1).
|
|
17
17
|
// GET /sessions/:id — one session's full detail for deep-dive. Owner
|
|
18
18
|
// self-serve; cross-builder requires Archon + audit.
|
|
19
|
+
// PUT /sessions/:sid/transcript
|
|
20
|
+
// — upload the FULL scrubbed transcript of your OWN
|
|
21
|
+
// session (ADR 0051). Own 16 MB parser.
|
|
22
|
+
// GET /sessions/:id/transcript
|
|
23
|
+
// — read it. Owner, or Archon + audit written first.
|
|
19
24
|
//
|
|
20
25
|
// Cross-builder reads are GETs, which the global audit middleware does NOT
|
|
21
26
|
// instrument — so each writes its own audit_log row (fail-closed: logged BEFORE
|
|
@@ -54,6 +59,16 @@ const seams = api; // resolveOptional('reward' | 'builder-settings') — kernel-
|
|
|
54
59
|
const { validateOrRespond, parseId } = api;
|
|
55
60
|
|
|
56
61
|
const UPLOAD_BODY_LIMIT = '4mb';
|
|
62
|
+
// ADR 0051 — the full transcript is MB-scale. The client's own ceiling is 12 MB of
|
|
63
|
+
// content (scripts/gds/session-transcript-build.js); 16 MB leaves JSON overhead.
|
|
64
|
+
const TRANSCRIPT_BODY_LIMIT = '16mb';
|
|
65
|
+
const MAX_TRANSCRIPT_TURNS = 20000;
|
|
66
|
+
// SR-17 for the transcript (the sibling POST bounds detail[] to 2 MB; this row is
|
|
67
|
+
// deliberately ~6x that because a full transcript IS the product, but it is still
|
|
68
|
+
// bounded IN the handler, not just by the parser): a per-turn ceiling stops one
|
|
69
|
+
// absurd turn, and the serialized total matches the client's own ceiling.
|
|
70
|
+
const MAX_TRANSCRIPT_TURN_BYTES = 256 * 1024;
|
|
71
|
+
const MAX_TRANSCRIPT_TOTAL_BYTES = 12 * 1024 * 1024;
|
|
57
72
|
const MAX_DETAIL_TURNS = 5000; // a hard ceiling; ship.js bounds well under this
|
|
58
73
|
|
|
59
74
|
// Disk-fill defense (SR-17 / task 997). The 4 MB body limit + maxItems=5000 cap
|
|
@@ -189,6 +204,7 @@ module.exports = function buildSessionsRouter() {
|
|
|
189
204
|
registerSeams();
|
|
190
205
|
const router = express.Router();
|
|
191
206
|
const uploadJson = express.json({ limit: UPLOAD_BODY_LIMIT });
|
|
207
|
+
const transcriptJson = express.json({ limit: TRANSCRIPT_BODY_LIMIT });
|
|
192
208
|
|
|
193
209
|
// rank: any-builder — upload your OWN session digest. Owner is forced to the
|
|
194
210
|
// authenticated caller; the body cannot attribute a record to another builder.
|
|
@@ -315,6 +331,59 @@ module.exports = function buildSessionsRouter() {
|
|
|
315
331
|
}
|
|
316
332
|
});
|
|
317
333
|
|
|
334
|
+
// rank: any-builder — upload the FULL scrubbed transcript of your OWN session
|
|
335
|
+
// (ADR 0051, task 1001004). Separate from POST /sessions on purpose: the digest
|
|
336
|
+
// is KB and carries the reward/evaluator path under a 4 MB parser; the transcript
|
|
337
|
+
// is MB and gets its own larger parser, so a transcript failure can never block a
|
|
338
|
+
// digest. The row is created only FROM the caller's own session_records row (no
|
|
339
|
+
// digest → 404, someone else's session_id → 404, never a confirmation it exists).
|
|
340
|
+
// The content is scrubbed on the uploader's box; the server bounds SHAPE, not
|
|
341
|
+
// secrets — every turn must be a plain object and the whole body is capped.
|
|
342
|
+
router.put('/sessions/:sid/transcript', auth.requireBuilder, transcriptJson, async (req, res) => {
|
|
343
|
+
const sid = String(req.params.sid || '');
|
|
344
|
+
if (!/^[A-Za-z0-9_-]{1,256}$/.test(sid)) return res.fail('bad_session_id', 400);
|
|
345
|
+
if (validateOrRespond(req, res, {
|
|
346
|
+
turns: { required: true, type: 'array', maxItems: MAX_TRANSCRIPT_TURNS },
|
|
347
|
+
truncated: { type: 'boolean' },
|
|
348
|
+
})) return;
|
|
349
|
+
const body = req.body || {};
|
|
350
|
+
const turns = body.turns;
|
|
351
|
+
// Serialize each turn ONCE: the same strings are measured, then joined into the
|
|
352
|
+
// jsonb text the db stores, so a 12 MB transcript is not stringified twice.
|
|
353
|
+
const parts = new Array(turns.length);
|
|
354
|
+
let totalBytes = 2;
|
|
355
|
+
for (let idx = 0; idx < turns.length; idx++) {
|
|
356
|
+
const t = turns[idx];
|
|
357
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) {
|
|
358
|
+
return res.fail('bad_transcript_turn', { status: 400, message: `turns[${idx}] must be an object` });
|
|
359
|
+
}
|
|
360
|
+
parts[idx] = JSON.stringify(t);
|
|
361
|
+
const turnBytes = Buffer.byteLength(parts[idx], 'utf8');
|
|
362
|
+
if (turnBytes > MAX_TRANSCRIPT_TURN_BYTES) {
|
|
363
|
+
return res.fail('transcript_turn_too_large', { status: 413, message: `turns[${idx}] is ${turnBytes} bytes; the per-turn cap is ${MAX_TRANSCRIPT_TURN_BYTES} bytes` });
|
|
364
|
+
}
|
|
365
|
+
totalBytes += turnBytes + 1;
|
|
366
|
+
if (totalBytes > MAX_TRANSCRIPT_TOTAL_BYTES) {
|
|
367
|
+
return res.fail('transcript_too_large', { status: 413, message: `transcript exceeds the ${MAX_TRANSCRIPT_TOTAL_BYTES}-byte total cap` });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const stored = await db.upsertSessionTranscript({
|
|
372
|
+
sessionId: sid,
|
|
373
|
+
builderId: req.builder.id, // owner = caller, never the body
|
|
374
|
+
turnsJson: `[${parts.join(',')}]`,
|
|
375
|
+
turnCount: turns.length,
|
|
376
|
+
truncated: body.truncated === true,
|
|
377
|
+
byteSize: totalBytes,
|
|
378
|
+
});
|
|
379
|
+
if (!stored) return res.fail('not_found', 404);
|
|
380
|
+
res.status(201).json({ transcript: stored });
|
|
381
|
+
} catch (err) {
|
|
382
|
+
api.logger('sessions').error(err, 'PUT /sessions/:sid/transcript failed');
|
|
383
|
+
res.fail('upload_failed', 500);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
318
387
|
// GET /sessions/search + /sessions/mine are declared BEFORE /sessions/:id so
|
|
319
388
|
// Express matches the literal paths first (the numeric :id never swallows them).
|
|
320
389
|
|
|
@@ -458,5 +527,33 @@ module.exports = function buildSessionsRouter() {
|
|
|
458
527
|
}
|
|
459
528
|
});
|
|
460
529
|
|
|
530
|
+
// rank: any-builder — the FULL transcript of one session (ADR 0051). Same rule as
|
|
531
|
+
// GET /sessions/:id: the owner reads their own; a cross-builder read is
|
|
532
|
+
// Archon-only, 404 (never 403) for everyone else, and the fail-closed audit row
|
|
533
|
+
// is written BEFORE any transcript content is served — transcripts are the most
|
|
534
|
+
// sensitive thing this module holds. Paged (?offset=&limit=, default 500 turns,
|
|
535
|
+
// max 2000): a transcript is MB-scale and the response carries turn_count so the
|
|
536
|
+
// reader knows how far to go.
|
|
537
|
+
router.get('/sessions/:id/transcript', auth.requireBuilder, readRateLimit, async (req, res) => {
|
|
538
|
+
const id = parseId(req, res, { code: 'bad_session_id', positiveInt: true });
|
|
539
|
+
if (id === null) return;
|
|
540
|
+
try {
|
|
541
|
+
const t = await db.getSessionTranscript(id, { offset: req.query.offset, limit: req.query.limit });
|
|
542
|
+
if (!t) return res.fail('not_found', 404);
|
|
543
|
+
if (String(t.builder_id) !== String(req.builder.id)) {
|
|
544
|
+
if (req.builder.rank !== 'archon') return res.fail('not_found', 404);
|
|
545
|
+
await recordSessionRead(req, {
|
|
546
|
+
route: '/api/bongos/sessions/:id/transcript',
|
|
547
|
+
targetBuilderId: t.builder_id,
|
|
548
|
+
target: `transcript:${id}`,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
res.json({ transcript: t });
|
|
552
|
+
} catch (err) {
|
|
553
|
+
api.logger('sessions').error(err, 'GET /sessions/:id/transcript failed');
|
|
554
|
+
res.fail('fetch_failed', 500);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
461
558
|
return router;
|
|
462
559
|
};
|
|
@@ -1076,17 +1076,26 @@ function updateHeadlineShippedFromItems() {
|
|
|
1076
1076
|
|
|
1077
1077
|
// ---------- leaderboard ----------
|
|
1078
1078
|
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1079
|
+
// The catalog is small and changes only by migration (the latest: the seven
|
|
1080
|
+
// craft achievements of task 1004438, migrations/core_270), so hardcoding the
|
|
1081
|
+
// emoji map here is cheaper than another API round-trip per page load. Mirrors
|
|
1082
|
+
// modules/hall-ui/public/roster.js ACH_EMOJI — tests/craft_achievements.mjs
|
|
1083
|
+
// holds both maps to the migration's ids and icons.
|
|
1083
1084
|
const ACH_EMOJI = {
|
|
1084
1085
|
'first-ship': '🚢',
|
|
1085
1086
|
'first-claim': '🪙',
|
|
1086
1087
|
'first-parallel-claim': '⚓',
|
|
1087
1088
|
'streak-3': '🔥',
|
|
1088
1089
|
'streak-7': '🌋',
|
|
1089
|
-
|
|
1090
|
+
// task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
|
|
1091
|
+
'art-pipeline-shipper': '🛠️',
|
|
1092
|
+
'first-thought-ignited': '💡',
|
|
1093
|
+
'first-full-idea': '📜',
|
|
1094
|
+
'first-board-ratification': '🏛️',
|
|
1095
|
+
'thought-built-ten': '🌟',
|
|
1096
|
+
'first-page-approved': '🎨',
|
|
1097
|
+
'ten-pages-approved': '🖼️',
|
|
1098
|
+
'whole-surface-tweaked': '🗺️',
|
|
1090
1099
|
'gds-shipper': '⚙️',
|
|
1091
1100
|
'breaking-the-100c-bar': '💯',
|
|
1092
1101
|
};
|
|
@@ -1101,14 +1110,35 @@ function renderAchievementRow(ids) {
|
|
|
1101
1110
|
if (!emoji) continue;
|
|
1102
1111
|
const span = document.createElement('span');
|
|
1103
1112
|
span.className = 'leader__ach';
|
|
1104
|
-
|
|
1105
|
-
|
|
1113
|
+
// the name as words ("First page approved"), not the id slug (task 1004438)
|
|
1114
|
+
const words = String(id).replace(/-/g, ' ');
|
|
1115
|
+
span.title = words.charAt(0).toUpperCase() + words.slice(1);
|
|
1116
|
+
span.setAttribute('aria-label', span.title);
|
|
1106
1117
|
span.textContent = emoji;
|
|
1107
1118
|
row.appendChild(span);
|
|
1108
1119
|
}
|
|
1109
1120
|
return row.children.length > 0 ? row : null;
|
|
1110
1121
|
}
|
|
1111
1122
|
|
|
1123
|
+
// task 1004437 (WA6.RP07): each row names its roles, main first — the crafts
|
|
1124
|
+
// the builder has EARNED in, from the row's own `roles.shown`, not only what
|
|
1125
|
+
// they declared. The same words the hall's roster uses. Text only, never markup.
|
|
1126
|
+
const ROLE_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
|
|
1127
|
+
function renderRoles(roles) {
|
|
1128
|
+
const shown = roles && Array.isArray(roles.shown) ? roles.shown.filter((c) => ROLE_WORD[c]) : [];
|
|
1129
|
+
if (!shown.length) return null;
|
|
1130
|
+
const row = document.createElement('span');
|
|
1131
|
+
row.className = 'leader__roles';
|
|
1132
|
+
row.setAttribute('aria-label', 'roles');
|
|
1133
|
+
shown.forEach((c, i) => {
|
|
1134
|
+
const chip = document.createElement('span');
|
|
1135
|
+
chip.className = i === 0 ? 'leader__role leader__role--lead' : 'leader__role';
|
|
1136
|
+
chip.textContent = ROLE_WORD[c];
|
|
1137
|
+
row.appendChild(chip);
|
|
1138
|
+
});
|
|
1139
|
+
return row;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1112
1142
|
async function loadLeaderboard() {
|
|
1113
1143
|
const data = await fetchJson('/api/bongos/public/leaderboard');
|
|
1114
1144
|
const rows = data.leaderboard || [];
|
|
@@ -1138,6 +1168,8 @@ async function loadLeaderboard() {
|
|
|
1138
1168
|
login.className = 'leader__login';
|
|
1139
1169
|
login.textContent = `@${b.github_login}`;
|
|
1140
1170
|
name.appendChild(login);
|
|
1171
|
+
const roles = renderRoles(b.roles);
|
|
1172
|
+
if (roles) name.appendChild(roles);
|
|
1141
1173
|
const badges = renderAchievementRow(b.achievements);
|
|
1142
1174
|
if (badges) name.appendChild(badges);
|
|
1143
1175
|
const credits = document.createElement('div');
|
|
@@ -749,6 +749,30 @@ main {
|
|
|
749
749
|
letter-spacing: 0;
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
+
/* The roles under each builder's name (task 1004437): one small tag per role
|
|
753
|
+
the builder has earned in, the main one a step stronger, as the hall's
|
|
754
|
+
roster and the approved mock draw them, on this page's own palette. */
|
|
755
|
+
.leader__roles {
|
|
756
|
+
display: flex;
|
|
757
|
+
flex-wrap: wrap;
|
|
758
|
+
gap: 4px;
|
|
759
|
+
margin-top: 4px;
|
|
760
|
+
}
|
|
761
|
+
.leader__role {
|
|
762
|
+
display: inline-flex;
|
|
763
|
+
align-items: center;
|
|
764
|
+
min-height: 22px;
|
|
765
|
+
padding: 0 8px;
|
|
766
|
+
border: 1px solid var(--rule-soft);
|
|
767
|
+
font-family: var(--font-body);
|
|
768
|
+
font-size: 0.78rem;
|
|
769
|
+
font-weight: 400;
|
|
770
|
+
letter-spacing: 0;
|
|
771
|
+
color: var(--ink-soft);
|
|
772
|
+
text-transform: none;
|
|
773
|
+
}
|
|
774
|
+
.leader__role--lead { color: var(--ink); border-color: var(--rule); }
|
|
775
|
+
|
|
752
776
|
/* Inline laurels next to each architect's name. Slightly dimmed so the
|
|
753
777
|
name still leads the row; emoji glyphs would otherwise punch above the
|
|
754
778
|
marble palette. PMS-V2 #54 */
|