@bongos/core 1.20.82 → 1.21.1
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 +154 -64
- 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 +10 -10
- package/docs/copy-registry.json +12 -12
- 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 +8 -3
- package/docs/page-readings.json +382 -373
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- package/modules/hall-ui/public/genesis-home.js +17 -3
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- package/modules/lifecycle/done-when.js +82 -42
- 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/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 +41 -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/src/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +20 -0
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/goal_closure_invariants.mjs +68 -0
- package/tests/hub_map_orb_states.mjs +5 -1
- 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
package/scripts/gds/provision.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// composes the docs/recipes/cloudbongos-standup.md runbook into ONE idempotent,
|
|
14
14
|
// dry-run-default, drift-reconciling flow (ADR 0016 / ADR 0031 §9.4 trust boundary):
|
|
15
15
|
//
|
|
16
|
-
//
|
|
16
|
+
// DNS A record FIRST (task 1004505 — the name must exist before anyone is shown it)
|
|
17
|
+
// → server (a process on an allocated port — co-tenant or cloud-host)
|
|
17
18
|
// → createdb + GDS-only migrate (MEDUSA_GDS_ONLY=1)
|
|
18
19
|
// → write config + per-instance /etc/<inst>/web.env
|
|
19
20
|
// → install + enable a per-instance systemd unit (+ allocate a port)
|
|
@@ -251,8 +252,8 @@ async function provisionInstance(inst, deps) {
|
|
|
251
252
|
|
|
252
253
|
if (isByoHost(inst.hosting_shape)) return recordByoHost(inst, deps); // stand up NOTHING, ABOVE every preflight — reasoning in provision-byo-host.js's header (ADR 0323)
|
|
253
254
|
// ── preflight: DNS token scope (task 1980) ─────────────────────────────────
|
|
254
|
-
// BEFORE committing to the address (
|
|
255
|
-
//
|
|
255
|
+
// BEFORE committing to the address (the step-0 DNS UPSERT /
|
|
256
|
+
// allocating a port), verify the control-plane Cloudflare token can actually reach
|
|
256
257
|
// the domain's zone. Turns the Emersonian standup's confusing mid-flow step-5 DNS
|
|
257
258
|
// failure into an up-front, actionable stop with no half-done provision. Read-only.
|
|
258
259
|
if (inst.domain && CONFIG.dnsHook) {
|
|
@@ -292,6 +293,64 @@ async function provisionInstance(inst, deps) {
|
|
|
292
293
|
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
293
294
|
if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
|
|
294
295
|
}
|
|
296
|
+
// ── step 0: DNS FIRST — Cloudflare A-record UPSERT for the instance's domain (P4) ──
|
|
297
|
+
// The first mutation of a standup, ahead of the status flip and every step after it
|
|
298
|
+
// (task 1004505, BV2.PS17). The hub can show a project's address from the moment it
|
|
299
|
+
// is created, and a lookup made before the record exists is cached as "not found" by
|
|
300
|
+
// the visitor's resolver for the zone's negative TTL (30 min on cloudbongos.com) — a
|
|
301
|
+
// founder's first click used to lock their own home network out of the project for
|
|
302
|
+
// half an hour. Created here, the name answers before any surface can offer it; the
|
|
303
|
+
// hub also holds every link until a platform lookup has seen it answer (dns_resolves).
|
|
304
|
+
// The A-record target (recordIp):
|
|
305
|
+
// • co-tenant → PROVISION_PUBLIC_IP (the box it runs on — under remote-exec that
|
|
306
|
+
// is the co-hosting box, ADR 0130).
|
|
307
|
+
// • standalone → the CONTROL PLANE's own IP (task 2074): it runs control-plane-
|
|
308
|
+
// local, so its domain must point HERE, not at the co-hosting box.
|
|
309
|
+
// PROVISION_CONTROL_PLANE_IP when remote-exec splits the two boxes;
|
|
310
|
+
// else PROVISION_PUBLIC_IP (all-local: one and the same machine).
|
|
311
|
+
// Remote-exec set but no control-plane IP configured → null, which
|
|
312
|
+
// makes this step fail loud rather than point the domain at the wrong box.
|
|
313
|
+
const recordIp = standalone ? CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp) : CONFIG.publicIp;
|
|
314
|
+
// Formalized in P4 (#1945): the shared box-dns-cloudflare.sh hook UPSERTs an A
|
|
315
|
+
// record pointing the domain at recordIp, in the domain's own zone. It runs on the
|
|
316
|
+
// CONTROL PLANE for every shape: the hook needs the Cloudflare token, which never
|
|
317
|
+
// leaves the control plane. A DNS FAILURE SURFACES (the task-1180 lesson: never
|
|
318
|
+
// report provision success while the A record silently didn't land) — and, being
|
|
319
|
+
// first, it now fails before anything else was touched.
|
|
320
|
+
let dnsUpserted = false;
|
|
321
|
+
if (inst.domain && CONFIG.dnsHook) {
|
|
322
|
+
if (!recordIp) {
|
|
323
|
+
const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
|
|
324
|
+
if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env`);
|
|
325
|
+
log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
|
|
326
|
+
} else {
|
|
327
|
+
// Standalone runs control-plane-local under the *.cloudbongos.com wildcard origin
|
|
328
|
+
// cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant
|
|
329
|
+
// stays proxied=false so their on-demand ACME challenge (step 6) reaches the origin.
|
|
330
|
+
const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
|
|
331
|
+
log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
|
|
332
|
+
// controlExec, NOT exec: the DNS hook needs the Cloudflare token, which lives
|
|
333
|
+
// only on the control plane — it must never run on the (token-free) co-hosting
|
|
334
|
+
// box even when the box-mutating steps below go there over SSH (task 2065).
|
|
335
|
+
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
336
|
+
dnsUpserted = true;
|
|
337
|
+
// The clear half of the birth-path note (F16): a revive after the operator
|
|
338
|
+
// configured the hook must retire the stale "point your A record" sentence.
|
|
339
|
+
if (apply && provisioning.recordDnsNote) {
|
|
340
|
+
await provisioning.recordDnsNote(db, inst.id, null).catch(() => {});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
|
|
345
|
+
// Birth-path twin of the domain-attach note (F16): a domain with no hook is
|
|
346
|
+
// the same silent skip here, so the owner card gets the same sentence.
|
|
347
|
+
if (apply && inst.domain) {
|
|
348
|
+
if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id,
|
|
349
|
+
`Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
|
|
350
|
+
).catch(() => {});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
295
354
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
296
355
|
const patch = {};
|
|
297
356
|
|
|
@@ -318,16 +377,6 @@ async function provisionInstance(inst, deps) {
|
|
|
318
377
|
}
|
|
319
378
|
|
|
320
379
|
// ── step 1: server ───────────────────────────────────────────────────────
|
|
321
|
-
// The A-record target for step 5's DNS UPSERT:
|
|
322
|
-
// • co-tenant → PROVISION_PUBLIC_IP (the box it runs on — under remote-exec that
|
|
323
|
-
// is the co-hosting box, ADR 0130).
|
|
324
|
-
// • standalone → the CONTROL PLANE's own IP (task 2074): it runs control-plane-
|
|
325
|
-
// local, so its domain must point HERE, not at the co-hosting box.
|
|
326
|
-
// PROVISION_CONTROL_PLANE_IP when remote-exec splits the two boxes;
|
|
327
|
-
// else PROVISION_PUBLIC_IP (all-local: one and the same machine).
|
|
328
|
-
// Remote-exec set but no control-plane IP configured → null, which
|
|
329
|
-
// makes step 5 fail loud rather than point the domain at the wrong box.
|
|
330
|
-
const recordIp = standalone ? CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp) : CONFIG.publicIp;
|
|
331
380
|
// Co-tenant OR standalone: a process bound to an allocated port on the box it runs
|
|
332
381
|
// on. Co-tenant shares that box's checkout ($0, cloudbongos.com model — under
|
|
333
382
|
// remote-exec, the co-hosting box); standalone (ADR 0108) runs from its OWN repo
|
|
@@ -433,46 +482,7 @@ async function provisionInstance(inst, deps) {
|
|
|
433
482
|
log(' [backup] create backup dir + install + enable nightly DB backup timer');
|
|
434
483
|
pm.installBackup(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
|
|
435
484
|
|
|
436
|
-
//
|
|
437
|
-
// Formalized in P4 (#1945): the shared box-dns-cloudflare.sh hook UPSERTs an A
|
|
438
|
-
// record pointing the domain at its target IP (recordIp), in the domain's own
|
|
439
|
-
// zone, proxied=false so Caddy's on-demand ACME challenge (step 6) reaches the
|
|
440
|
-
// origin (dnsUpsertEnv). It runs on the CONTROL PLANE for every shape: the hook
|
|
441
|
-
// needs the Cloudflare token, which never leaves the control plane.
|
|
442
|
-
// Unlike P3's best-effort allowFail, a DNS FAILURE now SURFACES (the task-1180
|
|
443
|
-
// lesson: never report provision success while the A record silently didn't land).
|
|
444
|
-
if (inst.domain && CONFIG.dnsHook) {
|
|
445
|
-
if (!recordIp) {
|
|
446
|
-
const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
|
|
447
|
-
if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env`);
|
|
448
|
-
log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
|
|
449
|
-
} else {
|
|
450
|
-
// Standalone runs control-plane-local under the *.cloudbongos.com wildcard origin
|
|
451
|
-
// cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant
|
|
452
|
-
// stays proxied=false so their on-demand ACME challenge reaches the origin.
|
|
453
|
-
const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
|
|
454
|
-
log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
|
|
455
|
-
// controlExec, NOT exec: the DNS hook needs the Cloudflare token, which lives
|
|
456
|
-
// only on the control plane — it must never run on the (token-free) co-hosting
|
|
457
|
-
// box even when the box-mutating steps above went there over SSH (task 2065).
|
|
458
|
-
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
459
|
-
// The clear half of the birth-path note (F16): a revive after the operator
|
|
460
|
-
// configured the hook must retire the stale "point your A record" sentence.
|
|
461
|
-
if (apply && provisioning.recordDnsNote) {
|
|
462
|
-
await provisioning.recordDnsNote(db, inst.id, null).catch(() => {});
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
} else {
|
|
466
|
-
log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
|
|
467
|
-
// Birth-path twin of the domain-attach note (F16): a domain with no hook is
|
|
468
|
-
// the same silent skip here, so the owner card gets the same sentence.
|
|
469
|
-
if (apply && inst.domain) {
|
|
470
|
-
if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id,
|
|
471
|
-
`Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
|
|
472
|
-
).catch(() => {});
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
485
|
+
// (step 5, the DNS UPSERT, moved to step 0 at the top — task 1004505.)
|
|
476
486
|
// A re-point on a PRE-ACTIVE instance records its release here and never
|
|
477
487
|
// reaches the attach leg (that one no-ops for anything but 'active'), so this
|
|
478
488
|
// run owns the drain — and an `error` row is frequently a partial standup that
|
|
@@ -542,6 +552,20 @@ async function provisionInstance(inst, deps) {
|
|
|
542
552
|
// standalone paths never cleared it). The runner hands its own observation
|
|
543
553
|
// to the liveness overlay so the card gets an honest verdict immediately
|
|
544
554
|
// instead of waiting ~5 min for the first sweep.
|
|
555
|
+
// ── step 7c: the platform's own lookup (task 1004505) ─────────────────────
|
|
556
|
+
// The record went in at step 0, minutes ago, so this lookup cannot race it into a
|
|
557
|
+
// cached miss. An answer lets the hub link the address the moment the row reads
|
|
558
|
+
// active, instead of waiting for the first liveness sweep. Only for a record this
|
|
559
|
+
// run created: a manual-DNS domain is the owner's to point, and the sweep finds it.
|
|
560
|
+
if (apply && dnsUpserted && provisioning.lookupAnswers && provisioning.recordDnsResolved) {
|
|
561
|
+
if (await provisioning.lookupAnswers(inst.domain, { lookup: deps.lookup })) {
|
|
562
|
+
await provisioning.recordDnsResolved(db, inst.id).catch(() => {});
|
|
563
|
+
log(` [dns] ${inst.domain} answers — the hub may link it now`);
|
|
564
|
+
} else {
|
|
565
|
+
log(` [dns] ${inst.domain} does not answer the control plane yet — the hub holds its link until the sweep sees it`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
545
569
|
if (apply) {
|
|
546
570
|
await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, error_note: null, provisioned_at: new Date().toISOString() });
|
|
547
571
|
// The overlay column is DOMAIN-scoped when a domain exists (the sweep
|
|
@@ -34,6 +34,7 @@ const fs = require('fs');
|
|
|
34
34
|
const os = require('os');
|
|
35
35
|
const path = require('path');
|
|
36
36
|
const { cliClient, loadSession, arg } = require('./cli-lib');
|
|
37
|
+
const { uploadSessionTranscript } = require('./session-transcript-upload');
|
|
37
38
|
const { buildSessionDigest, collectEnvSecrets, foldSubagentUsage } = require('./ship');
|
|
38
39
|
|
|
39
40
|
|
|
@@ -115,8 +116,11 @@ function parseTranscript(transcriptPath) {
|
|
|
115
116
|
|
|
116
117
|
async function main() {
|
|
117
118
|
// Watchdog: a SessionEnd hook must never stall session exit. If the read or
|
|
118
|
-
// POST hangs (slow/offline network — fetch has no default timeout), bail.
|
|
119
|
-
|
|
119
|
+
// POST hangs (slow/offline network — fetch has no default timeout), bail. 25s,
|
|
120
|
+
// not the old 6s, because the full transcript (ADR 0051, up to ~12 MB) now
|
|
121
|
+
// uploads after the digest; the .claude/settings.json hook timeout (30s) is the
|
|
122
|
+
// outer bound, and a hang still just drops the upload, never blocks exit.
|
|
123
|
+
const watchdog = setTimeout(() => process.exit(0), 25000);
|
|
120
124
|
watchdog.unref();
|
|
121
125
|
try {
|
|
122
126
|
const stdinRaw = await readStdin();
|
|
@@ -143,7 +147,8 @@ async function main() {
|
|
|
143
147
|
const extraSecrets = collectEnvSecrets();
|
|
144
148
|
if (session.token) extraSecrets.push(session.token);
|
|
145
149
|
|
|
146
|
-
const
|
|
150
|
+
const entries = parseTranscript(transcriptPath);
|
|
151
|
+
const digest = buildSessionDigest(entries, { extraSecrets });
|
|
147
152
|
// task 1003435: fold this session's subagents subtree into model_usage, the
|
|
148
153
|
// same way the on-ship upload does — the whole session's tokens, not just the
|
|
149
154
|
// interactive thread's.
|
|
@@ -174,6 +179,9 @@ async function main() {
|
|
|
174
179
|
console.error(
|
|
175
180
|
`session-digest: stored full session (${digest.metrics.assistant_turns} turns, ${digest.totalTokens} tokens, ${digest.metrics.tool_error_count} tool errors).`
|
|
176
181
|
);
|
|
182
|
+
// ADR 0051: every session's FULL scrubbed transcript, after the digest (the
|
|
183
|
+
// server builds the transcript row from the digest's). Non-fatal.
|
|
184
|
+
console.error(`session-digest: ${await uploadSessionTranscript(api, sessionId, entries, extraSecrets)}`);
|
|
177
185
|
} else {
|
|
178
186
|
console.error(`session-digest: skipped (POST ${r && r.status}${r && r.data && r.data.error ? ' ' + r.data.error : ''}).`);
|
|
179
187
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// scripts/gds/session-transcript-build.js — the PURE transcript→full-transcript builder.
|
|
2
|
+
//
|
|
3
|
+
// ADR 0051 / task 1001004: every session is stored as its FULL per-turn content
|
|
4
|
+
// (user prompts, assistant text, tool calls AND their results), not just the
|
|
5
|
+
// metadata + 160-char snippets buildSessionDigest keeps. This is the sibling of
|
|
6
|
+
// session-digest-build.js and shares its scrubber: EVERY string leaves this file
|
|
7
|
+
// through scrubSecrets (modules/security/secret-scrub.js, the task-1003 scrubber),
|
|
8
|
+
// on the builder's box, before anything is uploaded. A string that reaches the
|
|
9
|
+
// output unscrubbed is a defect — keep that single choke point (`clean`).
|
|
10
|
+
//
|
|
11
|
+
// Bounded on purpose: each text field is capped and the whole transcript has a
|
|
12
|
+
// byte ceiling (head + tail kept, like buildSessionDigest's turn cap), so one
|
|
13
|
+
// runaway tool result cannot make an upload that the server must reject.
|
|
14
|
+
// Extended-thinking blocks are NOT captured: they are model-internal, often
|
|
15
|
+
// opaque, and ADR 0051 asks for what the builder asked and what they were told.
|
|
16
|
+
const { scrubSecrets } = require('../../modules/security/secret-scrub');
|
|
17
|
+
|
|
18
|
+
const MAX_FIELD_CHARS = 20000; // one prompt / reply / tool result / tool input
|
|
19
|
+
const MAX_TRANSCRIPT_BYTES = 12 * 1024 * 1024; // whole upload ceiling (the server enforces the same number)
|
|
20
|
+
const MAX_TRANSCRIPT_TURNS = 20000; // the route's maxItems; the builder trims to it so the server never has to refuse
|
|
21
|
+
const MAX_TURN_BYTES = 200 * 1024; // under the route's 256 KB per-turn cap, so a many-tool-call turn is shrunk here rather than refused there
|
|
22
|
+
const MAX_RAW_CHARS = 200000; // bound on what the scrubber is ever handed (entropy-sweep cost)
|
|
23
|
+
|
|
24
|
+
// clean — the one exit for every string: drop NULs (Postgres rejects them in
|
|
25
|
+
// jsonb), SCRUB, then cap. Scrub BEFORE the final cap so a credential straddling
|
|
26
|
+
// the cap boundary is redacted whole rather than sliced into a fragment too short
|
|
27
|
+
// for any scrub shape to recognise. The scrubber is only ever handed MAX_RAW_CHARS
|
|
28
|
+
// so a multi-MB tool result cannot make the entropy sweep unbounded.
|
|
29
|
+
function clean(text, extraSecrets) {
|
|
30
|
+
if (typeof text !== 'string' || text.length === 0) return undefined;
|
|
31
|
+
const flat = text.replace(/\0/g, '');
|
|
32
|
+
if (!flat) return undefined;
|
|
33
|
+
const bounded = flat.length > MAX_RAW_CHARS ? flat.slice(0, MAX_RAW_CHARS) : flat;
|
|
34
|
+
const scrubbed = scrubSecrets(bounded, extraSecrets);
|
|
35
|
+
if (!scrubbed) return undefined;
|
|
36
|
+
if (scrubbed.length <= MAX_FIELD_CHARS && flat.length === bounded.length) return scrubbed;
|
|
37
|
+
const omitted = (flat.length - bounded.length) + Math.max(0, scrubbed.length - MAX_FIELD_CHARS);
|
|
38
|
+
return `${scrubbed.slice(0, MAX_FIELD_CHARS)}…[+${omitted} chars]`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resultText(content) {
|
|
42
|
+
if (typeof content === 'string') return content;
|
|
43
|
+
if (Array.isArray(content)) {
|
|
44
|
+
return content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).filter(Boolean).join('\n');
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// shrinkTurn — cut every string field of an oversize turn to an even share of a
|
|
50
|
+
// fixed character budget (in place), marking the cut. Already scrubbed, so this
|
|
51
|
+
// only ever removes text.
|
|
52
|
+
function shrinkTurn(t) {
|
|
53
|
+
const fields = [];
|
|
54
|
+
if (typeof t.text === 'string') fields.push([t, 'text']);
|
|
55
|
+
for (const c of t.tool_calls || []) if (typeof c.input === 'string') fields.push([c, 'input']);
|
|
56
|
+
for (const r of t.tool_results || []) if (typeof r.text === 'string') fields.push([r, 'text']);
|
|
57
|
+
const cap = Math.max(200, Math.floor(40000 / Math.max(1, fields.length)));
|
|
58
|
+
for (const [o, k] of fields) {
|
|
59
|
+
if (o[k].length > cap) o[k] = `${o[k].slice(0, cap)}…[+${o[k].length - cap} chars]`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// buildSessionTranscript — parsed Claude Code transcript entries →
|
|
64
|
+
// { turns, turnCount, byteSize, truncated }. Pure; exported for tests.
|
|
65
|
+
function buildSessionTranscript(entries, opts = {}) {
|
|
66
|
+
const extra = opts.extraSecrets || [];
|
|
67
|
+
const maxBytes = opts.maxBytes || MAX_TRANSCRIPT_BYTES;
|
|
68
|
+
const maxTurns = opts.maxTurns || MAX_TRANSCRIPT_TURNS;
|
|
69
|
+
const turns = [];
|
|
70
|
+
let i = 0;
|
|
71
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
72
|
+
if (!e || typeof e !== 'object' || !e.message) continue;
|
|
73
|
+
const ts = typeof e.timestamp === 'string' ? e.timestamp : undefined;
|
|
74
|
+
const msg = e.message;
|
|
75
|
+
if (e.type === 'assistant') {
|
|
76
|
+
const row = { i, role: 'assistant', ts };
|
|
77
|
+
const texts = [];
|
|
78
|
+
const calls = [];
|
|
79
|
+
for (const c of Array.isArray(msg.content) ? msg.content : []) {
|
|
80
|
+
if (c && c.type === 'text' && typeof c.text === 'string') texts.push(c.text);
|
|
81
|
+
else if (c && c.type === 'tool_use') {
|
|
82
|
+
let input;
|
|
83
|
+
try { input = clean(JSON.stringify(c.input), extra); } catch (_) { input = undefined; }
|
|
84
|
+
calls.push({ name: String(c.name || '?'), input });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const text = clean(texts.join('\n'), extra);
|
|
88
|
+
if (text) row.text = text;
|
|
89
|
+
if (calls.length) row.tool_calls = calls;
|
|
90
|
+
if (!text && !calls.length) continue; // pure-thinking line: nothing readable
|
|
91
|
+
turns.push(row);
|
|
92
|
+
i++;
|
|
93
|
+
} else if (e.type === 'user') {
|
|
94
|
+
const row = { i, role: 'user', ts };
|
|
95
|
+
if (typeof msg.content === 'string') {
|
|
96
|
+
const text = clean(msg.content, extra);
|
|
97
|
+
if (text) row.text = text;
|
|
98
|
+
} else if (Array.isArray(msg.content)) {
|
|
99
|
+
const texts = [];
|
|
100
|
+
const results = [];
|
|
101
|
+
for (const c of msg.content) {
|
|
102
|
+
if (c && c.type === 'text' && typeof c.text === 'string') texts.push(c.text);
|
|
103
|
+
else if (c && c.type === 'tool_result') {
|
|
104
|
+
const r = { text: clean(resultText(c.content), extra) };
|
|
105
|
+
if (c.is_error) r.is_error = true;
|
|
106
|
+
results.push(r);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const text = clean(texts.join('\n'), extra);
|
|
110
|
+
if (text) row.text = text;
|
|
111
|
+
if (results.length) row.tool_results = results;
|
|
112
|
+
}
|
|
113
|
+
if (!row.text && !row.tool_results) continue;
|
|
114
|
+
turns.push(row);
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Byte ceiling: keep the head (what was asked) and the tail (how it ended),
|
|
120
|
+
// drop the middle, and SAY so — a silently shortened record reads as complete.
|
|
121
|
+
// Serialize each turn once; an oversize turn (many tool calls, each at the field
|
|
122
|
+
// cap) is shrunk field-by-field to fit, so the route's per-turn cap never refuses
|
|
123
|
+
// a transcript the builder could have trimmed. Sizes include the comma each turn
|
|
124
|
+
// costs in the array, and the total the 2 bytes of brackets, so this total is the
|
|
125
|
+
// SAME number the route measures against its ceiling.
|
|
126
|
+
const sizes = turns.map((t, idx) => {
|
|
127
|
+
let json = JSON.stringify(t);
|
|
128
|
+
let bytes = Buffer.byteLength(json, 'utf8');
|
|
129
|
+
if (bytes > MAX_TURN_BYTES) {
|
|
130
|
+
shrinkTurn(t);
|
|
131
|
+
json = JSON.stringify(t);
|
|
132
|
+
bytes = Buffer.byteLength(json, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
turns[idx] = t;
|
|
135
|
+
return bytes + 1;
|
|
136
|
+
});
|
|
137
|
+
let total = sizes.reduce((a, b) => a + b, 2);
|
|
138
|
+
let out = turns;
|
|
139
|
+
let truncated = false;
|
|
140
|
+
if (total > maxBytes || turns.length > maxTurns) {
|
|
141
|
+
truncated = true;
|
|
142
|
+
const headBudget = Math.floor(maxBytes * 0.6);
|
|
143
|
+
const tailBudget = maxBytes - headBudget;
|
|
144
|
+
let h = 0; let headEnd = 0;
|
|
145
|
+
const headCount = Math.floor(maxTurns * 0.6);
|
|
146
|
+
while (headEnd < turns.length && headEnd < headCount && h + sizes[headEnd] <= headBudget) h += sizes[headEnd++];
|
|
147
|
+
let t = 0; let tailStart = turns.length;
|
|
148
|
+
while (tailStart > headEnd && (turns.length - tailStart) < maxTurns - headEnd && t + sizes[tailStart - 1] <= tailBudget) t += sizes[--tailStart];
|
|
149
|
+
out = turns.slice(0, headEnd).concat(turns.slice(tailStart));
|
|
150
|
+
total = h + t + 2;
|
|
151
|
+
}
|
|
152
|
+
return { turns: out, turnCount: turns.length, byteSize: total, truncated };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { buildSessionTranscript, MAX_FIELD_CHARS, MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_TURNS };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// scripts/gds/session-transcript-upload.js — send a session's FULL scrubbed
|
|
2
|
+
// transcript to the corpus (ADR 0051, task 1001004).
|
|
3
|
+
//
|
|
4
|
+
// Shared by the SessionEnd hook (session-digest.js — fires for EVERY session) and
|
|
5
|
+
// the on-ship upload (ship-session-upload.js — covers the mid-session tail), so
|
|
6
|
+
// both build and send the transcript the same way. BEST-EFFORT and NON-FATAL like
|
|
7
|
+
// the digest upload beside it: returns a one-line outcome string, never throws.
|
|
8
|
+
// It must run AFTER the digest POST — the server creates the transcript row only
|
|
9
|
+
// from the caller's own session_records row.
|
|
10
|
+
const { buildSessionTranscript } = require('./session-transcript-build');
|
|
11
|
+
|
|
12
|
+
async function uploadSessionTranscript(api, sessionId, entries, extraSecrets) {
|
|
13
|
+
try {
|
|
14
|
+
const t = buildSessionTranscript(entries, { extraSecrets });
|
|
15
|
+
if (!t.turns.length) return 'transcript: nothing to store.';
|
|
16
|
+
const r = await api.sessions.putSessionsSidTranscript({
|
|
17
|
+
sid: sessionId,
|
|
18
|
+
body: { turns: t.turns, truncated: t.truncated },
|
|
19
|
+
});
|
|
20
|
+
if (r && r.ok) {
|
|
21
|
+
return `transcript: stored ${t.turns.length}/${t.turnCount} turns (${Math.round(t.byteSize / 1024)} KB${t.truncated ? ', truncated to the size ceiling' : ''}).`;
|
|
22
|
+
}
|
|
23
|
+
return `transcript: skipped (PUT ${r && r.status}${r && r.data && r.data.error ? ' ' + JSON.stringify(r.data.error) : ''}).`;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return `transcript: skipped — ${e && e.message ? e.message : e}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { uploadSessionTranscript };
|
|
@@ -13,6 +13,7 @@ const path = require('node:path');
|
|
|
13
13
|
const { cliClient, loadSession } = require('./cli-lib');
|
|
14
14
|
const { resolveTranscriptPath } = require('./ship-memory-sync.js');
|
|
15
15
|
const { apiErrorLine } = require('./ship-io.js');
|
|
16
|
+
const { uploadSessionTranscript } = require('./session-transcript-upload.js');
|
|
16
17
|
const { REPO_ROOT } = require('./ship-state.js');
|
|
17
18
|
|
|
18
19
|
// ---------- Session upload-on-ship (6D.1 / ADR 0027) ----------
|
|
@@ -268,11 +269,15 @@ async function uploadSessionOnShip(taskId) {
|
|
|
268
269
|
metrics: digest.metrics,
|
|
269
270
|
model_usage: digest.metrics.model_usage || {}, // per-model tokens → cost-plus reward (task 1005)
|
|
270
271
|
};
|
|
271
|
-
const
|
|
272
|
+
const api = await cliClient({ allowUnauthenticated: true });
|
|
273
|
+
const r = await api.sessions.postSessions({ body: payload });
|
|
272
274
|
if (r.ok) {
|
|
273
275
|
console.log(
|
|
274
276
|
` session upload: stored digest (${digest.metrics.assistant_turns} assistant turns, ${digest.totalTokens} tokens, ${digest.metrics.tool_error_count} tool errors, ${digest.metrics.harness_retry_count} harness retries).`
|
|
275
277
|
);
|
|
278
|
+
// ADR 0051: the full scrubbed transcript rides after the digest (the server
|
|
279
|
+
// creates it from the digest's row). Non-fatal — its outcome is a log line.
|
|
280
|
+
console.log(` session upload: ${await uploadSessionTranscript(api, sessionId, entries, extraSecrets)}`);
|
|
276
281
|
} else if (r.status === 401) {
|
|
277
282
|
recordUploadSkip(taskId, {
|
|
278
283
|
code: 'session_invalid',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/upgrade-commit-identity.js — WHO the pin commit is signed by when the project's
|
|
4
|
+
// git names nobody (task 1004514). Kept out of upgrade.js, which sits at the 1500-line cap.
|
|
5
|
+
//
|
|
6
|
+
// THE BUG. persistPin staged the new pin and ran `git commit`. In a repo with no
|
|
7
|
+
// user.name/user.email — none in the repo, none global for the account the upgrade runs as —
|
|
8
|
+
// git refuses ("Please tell me who you are"), so the move reported success with the pin
|
|
9
|
+
// STAGED but never committed. The next move then read its own leftovers as a dirty tree and
|
|
10
|
+
// spent its one automatic fix (wedge-remedy.js) committing them. Seen live on 2026-10-01: a
|
|
11
|
+
// throwaway hosted project on cloudbongos.com, and hermeslines-marketing, whose repo carries no
|
|
12
|
+
// identity while every scaffolded one does (provision-repo.js sets it at scaffold time).
|
|
13
|
+
//
|
|
14
|
+
// THE FIX IS A FALLBACK, NEVER AN OVERRIDE. A repo or account that names someone keeps
|
|
15
|
+
// signing as them. Only an empty answer gets the provisioner's own identity, the one the
|
|
16
|
+
// scaffold commits as, through git's AUTHOR/COMMITTER env — so nothing is written into the
|
|
17
|
+
// owner's git config, and the argv stays exactly what it was.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Env for `git commit` in `instanceDir`: null when git already names a committer there, else
|
|
21
|
+
* the provisioner's identity. Asks git itself, so repo, global and system config all count.
|
|
22
|
+
*/
|
|
23
|
+
function commitIdentityEnv(instanceDir, run, env = process.env) {
|
|
24
|
+
const r = run('git', ['config', 'user.email'], { cwd: instanceDir, encoding: 'utf8' });
|
|
25
|
+
if (r && !r.error && r.status === 0 && String(r.stdout || '').trim()) return null;
|
|
26
|
+
if (env.GIT_AUTHOR_EMAIL || env.GIT_COMMITTER_EMAIL) return null; // the caller already chose
|
|
27
|
+
const { provisionerBotName, provisionerBotEmail } = require('./instance-apex.js');
|
|
28
|
+
const name = provisionerBotName(), email = provisionerBotEmail(env);
|
|
29
|
+
return { ...env, GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { commitIdentityEnv };
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -352,7 +352,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
|
|
|
352
352
|
err(' ! could not stage the pin files — commit + push them by hand or the next deploy reverts this bump.');
|
|
353
353
|
return { applicable: true, committed: false, pushed: false, dirty, error: 'git add failed' };
|
|
354
354
|
}
|
|
355
|
-
const commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8' });
|
|
355
|
+
const idEnv = require('./upgrade-commit-identity').commitIdentityEnv(instanceDir, run), commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8', ...(idEnv ? { env: idEnv } : {}) }); // a repo naming nobody still commits (task 1004514)
|
|
356
356
|
if (!commit || commit.error || commit.status !== 0) {
|
|
357
357
|
err(' ! could not commit the pin — commit + push it by hand or the next deploy reverts this bump.');
|
|
358
358
|
return { applicable: true, committed: false, pushed: false, dirty, error: 'git commit failed' };
|
package/src/bongos/routes.js
CHANGED
|
@@ -93,6 +93,9 @@ function buildGdsRouter() {
|
|
|
93
93
|
if (
|
|
94
94
|
req.path === '/memory/sync' ||
|
|
95
95
|
req.path === '/sessions' ||
|
|
96
|
+
// ADR 0051 / task 1001004: the full-transcript upload (MB-scale), which
|
|
97
|
+
// mounts its own 16 MB parser in modules/sessions/routes/sessions.js.
|
|
98
|
+
/^\/sessions\/[A-Za-z0-9_-]+\/transcript$/.test(req.path) ||
|
|
96
99
|
req.path === '/llm-cache/store' ||
|
|
97
100
|
/^\/tasks\/\d+\/publish-branch$/.test(req.path) ||
|
|
98
101
|
// task 1003109: the ship-time task visual — a base64 image up to ~5.3 MB
|
package/src/branding.js
CHANGED
|
@@ -94,6 +94,15 @@ const ENV_OVERRIDES = [
|
|
|
94
94
|
['NIGHT_TINT', ['project', 'nightTint']],
|
|
95
95
|
['LOOK_ACCENT', ['project', 'lookAccent']],
|
|
96
96
|
['LOGO_URL', ['project', 'logoUrl']],
|
|
97
|
+
// The birth marker (task 1004506): set by the platform only on a project created
|
|
98
|
+
// through the startup flow, so a project made before that never has it (spec D8).
|
|
99
|
+
// FOUNDING_BIRTH is when the platform made the row; DEMO_PEOPLE / DEMO_HOURS are the
|
|
100
|
+
// demo's time box, empty for a real project. The onboarding module reads them once to
|
|
101
|
+
// start genesis (modules/onboarding/founding-birth.js). SERVER-ONLY: clientBranding
|
|
102
|
+
// publishes no `founding` block.
|
|
103
|
+
['FOUNDING_BIRTH', ['founding', 'birth']],
|
|
104
|
+
['DEMO_PEOPLE', ['founding', 'demoPeople']],
|
|
105
|
+
['DEMO_HOURS', ['founding', 'demoHours']],
|
|
97
106
|
];
|
|
98
107
|
|
|
99
108
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.
|
|
78
|
+
const CORE_VERSION = '1.21.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -158,6 +158,26 @@ await test('NO-OP: nothing closed means no cascade query at all', async () => {
|
|
|
158
158
|
assert.equal(exec.calls.length, 1, 'must not run the goal cascade when no criterion moved');
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
await test('NO-OP (scoped): a ship that flips no criterion still re-asks its OWN goal (task 1004513)', async () => {
|
|
162
|
+
// When every criterion was satisfied BEFORE the last task shipped, the ship
|
|
163
|
+
// flips none — and the early return above used to mean nothing re-checked the
|
|
164
|
+
// goal, which then sat `open` with all its work done (goal 1000119, 2026-10-01).
|
|
165
|
+
const exec = fakeExec(
|
|
166
|
+
{ rows: [] },
|
|
167
|
+
{ rows: [{ goal_id: 77 }] },
|
|
168
|
+
{ rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] }
|
|
169
|
+
);
|
|
170
|
+
const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
171
|
+
assert.deepEqual(out.criteria, [], 'no criterion moved — ADR 0183 is untouched');
|
|
172
|
+
assert.deepEqual(out.goals.map((g) => g.id), [77], 'but the goal closes on the ship that finished it');
|
|
173
|
+
assert.match(exec.calls[1].sql, /SELECT goal_id FROM tasks WHERE id = \$1::bigint/, 'the shipping task names the candidate');
|
|
174
|
+
assert.deepEqual(exec.calls[1].params, [5]);
|
|
175
|
+
assert.match(String(exec.calls[2].sql), /UPDATE goals g/, 'and it runs the SAME cascade statement');
|
|
176
|
+
assert.match(String(exec.calls[2].sql).replace(/\s+/g, ' '), /NOT EXISTS \(SELECT 1 FROM tasks t WHERE t\.goal_id = g\.id/,
|
|
177
|
+
'carrying R09 — this entry may not be a looser rule than the cascade');
|
|
178
|
+
assert.deepEqual(exec.calls[2].params, [[77]], 'scoped to that one goal, nothing wider');
|
|
179
|
+
});
|
|
180
|
+
|
|
161
181
|
await test('CASCADE: only OPEN goals, only with >=1 criterion and zero unsatisfied', async () => {
|
|
162
182
|
const exec = fakeExec({ rows: [CRIT] }, { rows: [] });
|
|
163
183
|
await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|