@bongos/core 1.19.679 → 1.19.681
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 +33 -23
- package/.gitattributes +16 -2
- package/clients/bongos-client/index.d.ts +1 -2
- package/docs/adr/0279-an-upgrade-is-proven-by-the-served-version-not-the-health-check.md +48 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -29
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/instance-service-restart.md +89 -0
- package/modules/lifecycle/github-push.js +23 -5
- package/modules/lifecycle/routes/tasks.js +36 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/git-merge-regen.js +29 -0
- package/scripts/gds/upgrade.js +153 -17
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +5 -0
- package/tests/git_merge_regen.mjs +64 -2
- package/tests/publish_branch_route.mjs +57 -0
- package/tests/upgrade.mjs +259 -0
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// mergeMainIntoBranch self-heal so the heal runs even where the driver isn't
|
|
17
17
|
// installed per-clone.
|
|
18
18
|
//
|
|
19
|
+
// THE DRIVER ONLY FIRES ON A CONFLICT. A merge that lands CLEAN still leaves the
|
|
20
|
+
// generated docs describing the pre-merge tree — new symbols, a new session log and a
|
|
21
|
+
// new skill all arrive without conflicting. Three things close that: ship.js (at ship
|
|
22
|
+
// time), the server's conflict-resolve.js step 7b (for a PR it resolves), and
|
|
23
|
+
// `.husky/post-merge` (task 1001431) for the bare local `git merge` / `git pull` that
|
|
24
|
+
// neither of those covers.
|
|
25
|
+
//
|
|
19
26
|
// SAFETY: it regenerates the GENERATED content and 3-way-merges the hand-written
|
|
20
27
|
// parts of nested docs; it succeeds ONLY if no conflict markers remain. A genuine
|
|
21
28
|
// hand-written conflict falls through to a normal git conflict — it never invents a
|
|
@@ -61,7 +68,13 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
61
68
|
// branches that both touch hall/status/landing copy regenerate them differently
|
|
62
69
|
// and conflict on ordinary text; they join the whole-file group for the same
|
|
63
70
|
// reason session-log-index.md did.
|
|
71
|
+
// task 1001431: the ROOT CLAUDE.md joined too. It is mostly hand-written, but §13 carries
|
|
72
|
+
// the bounded BEGIN/END GENERATED SESSION-LOG SNIPPET block (ADR 0062 §8) — so two sessions
|
|
73
|
+
// that each add a session-log file regenerate that block differently and ALWAYS conflict
|
|
74
|
+
// there, and nothing healed it: the driver did not know the file, and `merge=union` would be
|
|
75
|
+
// wrong for prose. Same shape as docs/file-map.md (3-way-merge the prose, re-render the block).
|
|
64
76
|
const REGENERABLE = new Set([
|
|
77
|
+
'CLAUDE.md',
|
|
65
78
|
'docs/repo-map.md',
|
|
66
79
|
'docs/file-map.md',
|
|
67
80
|
'docs/session-log-index.md',
|
|
@@ -124,6 +137,22 @@ function regenerateResolved(relpath, { base = '', ours = '', theirs = '' } = {})
|
|
|
124
137
|
return { content: CI.renderReport(CI.buildRegistry()), ok: true };
|
|
125
138
|
}
|
|
126
139
|
|
|
140
|
+
// The ROOT CLAUDE.md (task 1001431): hand-written prose carrying ONE generated block, the
|
|
141
|
+
// §13 session-log snippet. 3-way-merge the prose, then re-render the snippet from the
|
|
142
|
+
// session-log files on disk. Must be handled BEFORE the nested-CLAUDE.md fallthrough below,
|
|
143
|
+
// which derives a directory by stripping `/CLAUDE.md` — for the root path that strips
|
|
144
|
+
// nothing and would ask gen-repo-map for a symbol block for a directory named "CLAUDE.md".
|
|
145
|
+
// It also needs none of the code-symbol model, so it sits with the other pre-buildModel rules.
|
|
146
|
+
// Same provisionality caveat as the whole-file rules above: collectEntries() scans the
|
|
147
|
+
// working tree, which during a merge is still ours, so the block this writes describes our
|
|
148
|
+
// side. That is what makes the merge COMPLETE; the post-merge regeneration (conflict-resolve
|
|
149
|
+
// step 7b / ship-preflight-steps' resolveInWorktree) is what makes it CORRECT.
|
|
150
|
+
if (relpath === 'CLAUDE.md') {
|
|
151
|
+
const merged = threeWayMerge(base, ours, theirs);
|
|
152
|
+
const out = SI.injectSnippet(merged, SI.renderSnippet(SI.collectEntries()));
|
|
153
|
+
return { content: out, ok: !hasMarkers(out) };
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
const model = G.buildModel();
|
|
128
157
|
|
|
129
158
|
// docs/repo-map.md is fully generated — ignore all three sides, rebuild it.
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -521,7 +521,20 @@ function healDocAssets({ coreRoot }, run = spawnSync, fsImpl = fs) {
|
|
|
521
521
|
return { ok: failed.length === 0, ran, failed, missing };
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
-
|
|
524
|
+
// Restart the instance service so the newly-installed core becomes the one being SERVED.
|
|
525
|
+
// Escalation is the whole difficulty here (task 1002884). A provisioned instance runs under a
|
|
526
|
+
// `User=` systemd unit, and the unattended sweep that restarts it has no TTY — so a bare
|
|
527
|
+
// `systemctl restart` from the owning (non-root) user returns "Interactive authentication
|
|
528
|
+
// required" and exits 1. We retry once through `sudo -n`, the same idiom dev.js and dev-lib.js
|
|
529
|
+
// already use for privileged systemctl; `-n` fails fast rather than blocking on a password
|
|
530
|
+
// prompt, so the retry costs nothing when it isn't permitted. An operator who prefers polkit
|
|
531
|
+
// over sudo can install the rule in docs/recipes/instance-service-restart.md instead — either
|
|
532
|
+
// path makes this return ok.
|
|
533
|
+
// `--restart-cmd` is NOT escalated: an explicit command is the operator's own contract.
|
|
534
|
+
// Trust boundary: `service` comes from `--service` or the subscription roster — operator-supplied
|
|
535
|
+
// config, never request input — and the escalation reaches only as far as the host's own sudoers
|
|
536
|
+
// or polkit scoping allows. Do not wire a caller that derives `service` from anything untrusted.
|
|
537
|
+
function restartService({ service, restartCmd }, run = spawnSync, deps = {}) {
|
|
525
538
|
if (restartCmd) {
|
|
526
539
|
const parts = restartCmd.split(/\s+/);
|
|
527
540
|
const r = run(parts[0], parts.slice(1), { stdio: 'inherit' });
|
|
@@ -529,7 +542,14 @@ function restartService({ service, restartCmd }, run = spawnSync) {
|
|
|
529
542
|
}
|
|
530
543
|
if (service) {
|
|
531
544
|
const r = run('systemctl', ['restart', service], { stdio: 'inherit' });
|
|
532
|
-
|
|
545
|
+
if (r && !r.error && r.status === 0) return { ok: true, code: 0 };
|
|
546
|
+
const isRoot = deps.isRoot !== undefined
|
|
547
|
+
? !!deps.isRoot
|
|
548
|
+
: (typeof process.getuid === 'function' && process.getuid() === 0);
|
|
549
|
+
if (isRoot) return { ok: false, code: r && r.status };
|
|
550
|
+
const s = run('sudo', ['-n', 'systemctl', 'restart', service], { stdio: 'inherit' });
|
|
551
|
+
const ok = !!s && !s.error && s.status === 0;
|
|
552
|
+
return { ok, code: ok ? 0 : (s && s.status), triedSudo: true, directCode: r && r.status };
|
|
533
553
|
}
|
|
534
554
|
return { ok: null, skipped: true };
|
|
535
555
|
}
|
|
@@ -622,6 +642,68 @@ async function pollHealth(healthUrl, deps = {}) {
|
|
|
622
642
|
}
|
|
623
643
|
}
|
|
624
644
|
|
|
645
|
+
// ---- served-version verification (task 1002884) -----------------------------
|
|
646
|
+
// The gap this closes: after a restart, `readInstalledCoreVersion` reads node_modules on DISK
|
|
647
|
+
// and `pollHealth` proves only that *something* answers the port. Neither can tell "the new core
|
|
648
|
+
// is up" from "the old core never went down" — so when a restart silently failed on
|
|
649
|
+
// cloudbongos.com (2026-08-11), the health poll hit the STILL-RUNNING old process, got 200, and
|
|
650
|
+
// the bump reported "✓ upgrade complete" while live kept serving the previous core.
|
|
651
|
+
// `/version` reports the LIVE process's coreVersion (serve-internal.js, since 1.17.2), which is
|
|
652
|
+
// the one signal that distinguishes the two.
|
|
653
|
+
|
|
654
|
+
// Default the version endpoint to /version on the same origin as the health URL. Deriving rather
|
|
655
|
+
// than demanding a second flag is deliberate: every existing call site passes only --health-url,
|
|
656
|
+
// and a check that has to be opted into is off exactly where it is needed. Returns null when the
|
|
657
|
+
// health URL isn't parseable as one.
|
|
658
|
+
function deriveVersionUrl(healthUrl) {
|
|
659
|
+
try {
|
|
660
|
+
const u = new URL(healthUrl);
|
|
661
|
+
u.pathname = '/version';
|
|
662
|
+
u.search = '';
|
|
663
|
+
u.hash = '';
|
|
664
|
+
return u.toString();
|
|
665
|
+
} catch { return null; }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Poll the version endpoint until it yields a parseable coreVersion or the budget elapses. Runs
|
|
669
|
+
// AFTER the health poll, so the service is already answering and the budget is short — this is
|
|
670
|
+
// re-reading a live endpoint, not waiting for a boot. A body without a usable coreVersion is
|
|
671
|
+
// "unreadable", NOT a mismatch: the caller must be able to tell "it says the wrong version"
|
|
672
|
+
// (a real failure) from "it wouldn't tell me" (unconfirmed), because only the first is proof.
|
|
673
|
+
async function pollServedVersion(versionUrl, deps = {}) {
|
|
674
|
+
const fetchImpl = deps.fetch || fetch;
|
|
675
|
+
const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
676
|
+
const now = deps.now || Date.now;
|
|
677
|
+
// Default to the health budget when a caller (or a test) has already narrowed it, else 15s.
|
|
678
|
+
const budgetMs = deps.versionBudgetMs != null
|
|
679
|
+
? deps.versionBudgetMs
|
|
680
|
+
: (deps.healthBudgetMs != null ? deps.healthBudgetMs : 15_000);
|
|
681
|
+
// A hard attempt cap alongside the clock: `now` is injectable, and a frozen or non-advancing
|
|
682
|
+
// clock would otherwise spin this loop forever instead of giving up.
|
|
683
|
+
const maxAttempts = deps.versionMaxAttempts != null ? deps.versionMaxAttempts : 12;
|
|
684
|
+
const deadline = now() + budgetMs;
|
|
685
|
+
let attempt = 0;
|
|
686
|
+
let last = { ok: false, error: 'not attempted' };
|
|
687
|
+
for (;;) {
|
|
688
|
+
attempt++;
|
|
689
|
+
try {
|
|
690
|
+
const res = await fetchImpl(versionUrl, { signal: AbortSignal.timeout(5_000) });
|
|
691
|
+
if (!res || !res.ok) last = { ok: false, error: `status ${res && res.status}` };
|
|
692
|
+
else if (typeof res.json !== 'function') last = { ok: false, error: 'response carried no JSON body' };
|
|
693
|
+
else {
|
|
694
|
+
const body = await res.json();
|
|
695
|
+
const version = body && typeof body.coreVersion === 'string' ? body.coreVersion : null;
|
|
696
|
+
if (version) return { ok: true, version, attempts: attempt };
|
|
697
|
+
last = { ok: false, error: 'no coreVersion in the response body' };
|
|
698
|
+
}
|
|
699
|
+
} catch (e) {
|
|
700
|
+
last = { ok: false, error: e.message };
|
|
701
|
+
}
|
|
702
|
+
if (now() >= deadline || attempt >= maxAttempts) return { ...last, attempts: attempt, timedOut: true };
|
|
703
|
+
await sleep(Math.min(2_000, 500 * attempt));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
625
707
|
// ---- integrity-pin verification (task 1002216 / audit H8, finding F1) --------
|
|
626
708
|
// The packager (package-core.js) computes tree_sha256 = canonicalTreeHash over the
|
|
627
709
|
// redacted core files (+ the two synthesized files) and records it, the per-file
|
|
@@ -738,7 +820,9 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
|
|
|
738
820
|
else err(` ! reinstall of previous core failed (${inst.reason || 'exit ' + inst.code}) — the instance may need manual repair`);
|
|
739
821
|
|
|
740
822
|
// 3. restart back onto the previous core.
|
|
741
|
-
|
|
823
|
+
// `deps` is passed here for the same reason the forward path passes it: this restart runs
|
|
824
|
+
// AFTER a failure, so its sudo-escalation is the one that matters most and must be testable.
|
|
825
|
+
const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run, deps);
|
|
742
826
|
if (rs.skipped) log(' • restart: no --service/--restart-cmd — restart the instance service manually to load the reverted core.');
|
|
743
827
|
else if (!rs.ok) err(` ! restart after rollback failed (exit ${rs.code}) — restart the service manually.`);
|
|
744
828
|
else log(` ✓ restarted ${opts.service || 'service'} on the previous core`);
|
|
@@ -961,10 +1045,24 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
961
1045
|
} else log(' • doc regen skipped (--skip-regen-docs)');
|
|
962
1046
|
|
|
963
1047
|
// 6. restart.
|
|
964
|
-
const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run);
|
|
1048
|
+
const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run, deps);
|
|
965
1049
|
if (rs.skipped) log(` • restart: no --service/--restart-cmd — restart the instance service to load core ${targetVersion}.`);
|
|
966
|
-
else if (!rs.ok)
|
|
967
|
-
|
|
1050
|
+
else if (!rs.ok) {
|
|
1051
|
+
// task 1002884: a failed restart is an upgrade FAILURE, not a warning. It used to fall
|
|
1052
|
+
// through to the verification below — but the old process is still up, so the health poll
|
|
1053
|
+
// passed against IT and the bump wrote a success row for a core that was never served.
|
|
1054
|
+
// Failing here also keeps disk and process consistent: rollback puts the previous core back
|
|
1055
|
+
// on disk, matching the one that is (still) running.
|
|
1056
|
+
err(` ✖ restart failed (exit ${rs.code})${rs.triedSudo ? ` — \`systemctl restart\` exited ${rs.directCode} and \`sudo -n systemctl restart\` could not escalate` : ''}`);
|
|
1057
|
+
err(' The previous core is still the one being served. Grant the restart a non-interactive path');
|
|
1058
|
+
err(' (sudoers NOPASSWD or a polkit rule — docs/recipes/instance-service-restart.md), then re-run.');
|
|
1059
|
+
const error = `restart failed (exit ${rs.code}) — the instance is still running the previous core`;
|
|
1060
|
+
if (rollbackOnFailure && snapshot) {
|
|
1061
|
+
const rb = await doRollback('restart failed');
|
|
1062
|
+
return { ok: false, error, fromVersion, toVersion: targetVersion, restartOk: false, rolledBack: true, rollback: rb };
|
|
1063
|
+
}
|
|
1064
|
+
return { ok: false, error, fromVersion, toVersion: targetVersion, restartOk: false };
|
|
1065
|
+
} else log(` ✓ restarted ${opts.service || 'service'}${rs.triedSudo ? ' (via sudo -n)' : ''}`);
|
|
968
1066
|
|
|
969
1067
|
// 7. verify: installed version == target, optional /healthz, ledger row.
|
|
970
1068
|
const installed = readInstalledCoreVersion(instanceDir, fsImpl);
|
|
@@ -985,12 +1083,48 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
985
1083
|
log(' • no managed restart + no --health-url — new core installed but not confirmed serving; restart the instance + health-check it to complete the bump.');
|
|
986
1084
|
}
|
|
987
1085
|
|
|
1086
|
+
// 7b. Confirm the SERVED process actually swapped (task 1002884). Everything above this point
|
|
1087
|
+
// can be true of an instance still running the OLD core: the pin moved, node_modules holds
|
|
1088
|
+
// the new version, and /healthz answers 200 — from the process that never went down. Only
|
|
1089
|
+
// the live coreVersion settles it.
|
|
1090
|
+
// A definite MISMATCH is a failure (same treatment as a failed health check). Being unable
|
|
1091
|
+
// to READ the endpoint is not: an instance may not expose it, and refusing every such bump
|
|
1092
|
+
// would be a worse regression than the false-pass. Passing --version-url explicitly asks
|
|
1093
|
+
// for proof, so there an unreadable endpoint IS a failure.
|
|
1094
|
+
let servedOk = null;
|
|
1095
|
+
let servedVersion = null;
|
|
1096
|
+
const versionUrl = opts.versionUrl || (opts.healthUrl ? deriveVersionUrl(opts.healthUrl) : null);
|
|
1097
|
+
if (opts.healthCheck === false) {
|
|
1098
|
+
// --no-health-check is the documented opt-out from confirming the bump serves; it covers this.
|
|
1099
|
+
} else if (versionUrl && healthOk !== false) {
|
|
1100
|
+
const sv = await pollServedVersion(versionUrl, deps);
|
|
1101
|
+
servedVersion = sv.version || null;
|
|
1102
|
+
if (sv.ok && servedVersion === targetVersion) {
|
|
1103
|
+
servedOk = true;
|
|
1104
|
+
log(` ✓ served core version: ${servedVersion} (${versionUrl})`);
|
|
1105
|
+
} else if (sv.ok) {
|
|
1106
|
+
servedOk = false;
|
|
1107
|
+
err(` ! served core version is ${servedVersion} but the target is ${targetVersion} (${versionUrl})`);
|
|
1108
|
+
err(' The restart did not swap the running process — the old core is still being served.');
|
|
1109
|
+
} else if (opts.versionUrl) {
|
|
1110
|
+
servedOk = false;
|
|
1111
|
+
err(` ! could not read ${versionUrl} (${sv.error}) — --version-url was given, so this bump cannot be confirmed`);
|
|
1112
|
+
} else {
|
|
1113
|
+
err(` ! could not read ${versionUrl} (${sv.error}) — this bump is NOT confirmed against the running process.`);
|
|
1114
|
+
err(' Pass --version-url <url> if this instance serves its version elsewhere.');
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
988
1118
|
// A failed post-restart health check means the new core is serving broken — auto-revert (task
|
|
989
|
-
// 2149) instead of recording an "upgraded" row + leaving the instance down.
|
|
990
|
-
//
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1119
|
+
// 2149) instead of recording an "upgraded" row + leaving the instance down. A served-version
|
|
1120
|
+
// mismatch means the new core is not serving AT ALL (task 1002884) and reverts the same way.
|
|
1121
|
+
// The rollback writes its OWN rolled_back ledger row, so skip the normal insert below.
|
|
1122
|
+
if ((healthOk === false || servedOk === false) && rollbackOnFailure && snapshot) {
|
|
1123
|
+
const why = healthOk === false
|
|
1124
|
+
? 'health check failed after restart'
|
|
1125
|
+
: `served core version did not change (still ${servedVersion || 'unconfirmed'}, expected ${targetVersion})`;
|
|
1126
|
+
const rb = await doRollback(why);
|
|
1127
|
+
return { ok: false, error: `${why} — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, rolledBack: true, rollback: rb };
|
|
994
1128
|
}
|
|
995
1129
|
|
|
996
1130
|
const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: opts.note }, deps);
|
|
@@ -1010,9 +1144,9 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1010
1144
|
dryRun: opts.dryRun,
|
|
1011
1145
|
}, run, log, err);
|
|
1012
1146
|
|
|
1013
|
-
const ok = versionOk && healthOk !== false;
|
|
1147
|
+
const ok = versionOk && healthOk !== false && servedOk !== false;
|
|
1014
1148
|
log(ok ? `\n✓ upgrade complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! upgrade finished with warnings — review above.`);
|
|
1015
|
-
return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, ledger: ledger.ok, pin };
|
|
1149
|
+
return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, ledger: ledger.ok, pin };
|
|
1016
1150
|
}
|
|
1017
1151
|
|
|
1018
1152
|
function parseArgs(argv) {
|
|
@@ -1023,6 +1157,7 @@ function parseArgs(argv) {
|
|
|
1023
1157
|
service: arg('--service', argv),
|
|
1024
1158
|
restartCmd: arg('--restart-cmd', argv),
|
|
1025
1159
|
healthUrl: arg('--health-url', argv),
|
|
1160
|
+
versionUrl: arg('--version-url', argv), // task 1002884: read the LIVE process's coreVersion back; defaults to /version on the health URL's origin
|
|
1026
1161
|
sourceCommit: arg('--source-commit', argv),
|
|
1027
1162
|
note: arg('--note', argv),
|
|
1028
1163
|
pinPath: arg('--pin-path', argv),
|
|
@@ -1058,7 +1193,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1058
1193
|
' --service <unit> systemd unit to restart after applying',
|
|
1059
1194
|
' --restart-cmd <cmd> explicit restart command (overrides --service)',
|
|
1060
1195
|
' --health-url <url> poll after restart (~30s) until 2xx; a still-failing check fails the bump (auto-rollback). REQUIRED for a bump that restarts the service, unless --no-health-check.',
|
|
1061
|
-
' --
|
|
1196
|
+
' --version-url <url> read the RUNNING process\'s coreVersion back after restart and require it to equal --to (default: /version on the --health-url origin). A health check alone cannot tell a new core from an old one that never went down.',
|
|
1197
|
+
' --no-health-check explicitly skip the post-restart health confirmation (default: the check is ON — a bump that restarts the service must confirm it serves); also skips the served-version read-back',
|
|
1062
1198
|
' --skip-migrate do not run `npm run migrate`',
|
|
1063
1199
|
' --skip-materialize do not refresh .claude/',
|
|
1064
1200
|
' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
|
|
@@ -1069,8 +1205,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1069
1205
|
' --dry-run show the plan; change nothing',
|
|
1070
1206
|
' --force skip the clean-tree + same-version guards',
|
|
1071
1207
|
'',
|
|
1072
|
-
'Full bump: pre-flight → bump pin → npm install → verify integrity pin → migrate → materialize .claude → regen API docs+client → restart → verify.',
|
|
1073
|
-
'On a failure after the pin moves (install/pin-verify/migrate/health), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
|
|
1208
|
+
'Full bump: pre-flight → bump pin → npm install → verify integrity pin → migrate → materialize .claude → regen API docs+client → restart → verify (health + served coreVersion).',
|
|
1209
|
+
'On a failure after the pin moves (install/pin-verify/migrate/restart/health/served-version), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
|
|
1074
1210
|
].join('\n') + '\n');
|
|
1075
1211
|
return 0;
|
|
1076
1212
|
}
|
|
@@ -1087,7 +1223,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1087
1223
|
module.exports = {
|
|
1088
1224
|
runUpgrade, parseArgs, main, rollback,
|
|
1089
1225
|
versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
|
|
1090
|
-
vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
|
|
1226
|
+
vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
|
|
1091
1227
|
readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
|
|
1092
1228
|
CORE_PKG, ARTIFACT,
|
|
1093
1229
|
};
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.681'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -554,6 +554,11 @@ test('1003546 every REGENERABLE artifact has a producer, and every producer is i
|
|
|
554
554
|
['docs/repo-map.md', 'scripts/gds/gen-repo-map.js'],
|
|
555
555
|
['docs/file-map.md', 'scripts/gds/gen-file-map.js'],
|
|
556
556
|
['docs/session-log-index.md', 'scripts/gds/gen-session-index.js'],
|
|
557
|
+
// task 1001431 — the ROOT CLAUDE.md joined REGENERABLE for its §13 snippet block.
|
|
558
|
+
// gen-session-index.js writes BOTH the (gitignored) whole index and that tracked
|
|
559
|
+
// snippet, and it is already in GENERATOR_SCRIPTS, so the server's post-merge
|
|
560
|
+
// regeneration covers the new entry with no change to the generator list.
|
|
561
|
+
['CLAUDE.md', 'scripts/gds/gen-session-index.js'],
|
|
557
562
|
[CI.REGISTRY_REL, 'scripts/gds/copy-inventory.js'],
|
|
558
563
|
[CI.REPORT_REL, 'scripts/gds/copy-inventory.js'],
|
|
559
564
|
...G.NESTED_DIRS.map((d) => [`${d}/CLAUDE.md`, 'scripts/gds/gen-repo-map.js']),
|
|
@@ -45,6 +45,7 @@ const repo = path.resolve(here, '..');
|
|
|
45
45
|
const regen = require('../scripts/gds/git-merge-regen.js');
|
|
46
46
|
const G = require('../scripts/gds/gen-repo-map.js');
|
|
47
47
|
const CI = require('../scripts/gds/copy-inventory.js');
|
|
48
|
+
const SI = require('../scripts/gds/gen-session-index.js'); // task 1001431 — the root CLAUDE.md's §13 snippet
|
|
48
49
|
|
|
49
50
|
const MARKERS = /^(<{7}|={7}|>{7})/m;
|
|
50
51
|
|
|
@@ -120,10 +121,67 @@ function withBlockBody(src, body) {
|
|
|
120
121
|
|
|
121
122
|
// ── 5) a non-generated path is never in scope ───────────────────────────────────
|
|
122
123
|
{
|
|
123
|
-
assert.equal(regen.REGENERABLE.has('CLAUDE.md'), false, 'top-level CLAUDE.md is hand-written — never auto-healed');
|
|
124
124
|
assert.equal(regen.REGENERABLE.has('scripts/gds/box.js'), false, 'source files are never auto-healed');
|
|
125
125
|
const r = regen.regenerateResolved('scripts/gds/box.js', { base: 'a', ours: 'b', theirs: 'c' });
|
|
126
126
|
assert.equal(r.content, null, 'a non-generated path resolves to null content');
|
|
127
|
+
|
|
128
|
+
// REVERSAL (task 1001431). This block used to assert the opposite —
|
|
129
|
+
// REGENERABLE.has('CLAUDE.md') === false, 'top-level CLAUDE.md is hand-written'
|
|
130
|
+
// — and that was correct when it was written. ADR 0062 §8 then gave the root
|
|
131
|
+
// CLAUDE.md a generated §13 BEGIN/END SESSION-LOG SNIPPET block, which made the
|
|
132
|
+
// premise false without anything noticing: two sessions each adding a session-log
|
|
133
|
+
// file regenerate that block differently and conflict there on EVERY merge, and no
|
|
134
|
+
// layer healed it (not the driver, not ship.js, not the server resolver).
|
|
135
|
+
assert.equal(regen.REGENERABLE.has('CLAUDE.md'), true,
|
|
136
|
+
'the root CLAUDE.md carries a generated §13 snippet block and IS healed (task 1001431)');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 5b) the root CLAUDE.md heals: prose 3-way-merged, snippet re-rendered ────────
|
|
140
|
+
// It is the docs/file-map.md shape (hand-written prose + a generated block), NOT the
|
|
141
|
+
// whole-file shape — so a conflict in the PROSE must still fall through to a human.
|
|
142
|
+
{
|
|
143
|
+
const realRoot = fs.readFileSync(path.join(repo, 'CLAUDE.md'), 'utf8');
|
|
144
|
+
const freshSnippet = SI.renderSnippet(SI.collectEntries());
|
|
145
|
+
|
|
146
|
+
// A conflict confined to the generated block heals, and the block comes out equal to
|
|
147
|
+
// a fresh render regardless of what either side had in it.
|
|
148
|
+
const mangle = (tag) => SI.injectSnippet(realRoot, `${SI.BEGIN}\n- ${tag}\n${SI.END}`);
|
|
149
|
+
const healed = regen.regenerateResolved('CLAUDE.md', {
|
|
150
|
+
base: realRoot, ours: mangle('OURS entry'), theirs: mangle('THEIRS entry'),
|
|
151
|
+
});
|
|
152
|
+
assert.equal(healed.ok, true, 'a conflict inside the generated snippet must heal');
|
|
153
|
+
assert.ok(!MARKERS.test(healed.content), 'no conflict markers survive the heal');
|
|
154
|
+
assert.ok(healed.content.includes(freshSnippet), 'the snippet is re-rendered from the session-log files');
|
|
155
|
+
assert.ok(!healed.content.includes('OURS entry') && !healed.content.includes('THEIRS entry'),
|
|
156
|
+
'neither side\'s stale snippet content survives');
|
|
157
|
+
|
|
158
|
+
// Hand-written PROSE that genuinely diverges is NOT auto-resolved.
|
|
159
|
+
const firstNL = realRoot.indexOf('\n');
|
|
160
|
+
const proseConflict = regen.regenerateResolved('CLAUDE.md', {
|
|
161
|
+
base: realRoot,
|
|
162
|
+
ours: '# OURS heading' + realRoot.slice(firstNL),
|
|
163
|
+
theirs: '# THEIRS heading' + realRoot.slice(firstNL),
|
|
164
|
+
});
|
|
165
|
+
assert.equal(proseConflict.ok, false, 'a real prose conflict must fall through to a human');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── 5c) the .gitattributes pattern for the root CLAUDE.md is ROOT-ANCHORED ───────
|
|
169
|
+
// `/CLAUDE.md` vs `CLAUDE.md` is the whole ballgame: a gitattributes pattern with no
|
|
170
|
+
// slash matches at ANY depth, so the bare spelling would also capture the 22
|
|
171
|
+
// modules/*/CLAUDE.md files and scripts/gds/CLAUDE.md — none of which are in
|
|
172
|
+
// NESTED_DIRS, so the driver has no rule for them, exits 1, and FORCES a conflict git
|
|
173
|
+
// would otherwise merge cleanly. Asserted through git itself, not by reading the file,
|
|
174
|
+
// because the pattern semantics are git's and not ours to restate.
|
|
175
|
+
{
|
|
176
|
+
const attr = (p) => {
|
|
177
|
+
const r = spawnSync('git', ['check-attr', 'merge', '--', p], { cwd: repo, encoding: 'utf8' });
|
|
178
|
+
return (r.stdout || '').trim();
|
|
179
|
+
};
|
|
180
|
+
assert.match(attr('CLAUDE.md'), /otb-regen$/, 'the root CLAUDE.md must get the driver');
|
|
181
|
+
for (const p of ['modules/economy/CLAUDE.md', 'scripts/gds/CLAUDE.md']) {
|
|
182
|
+
assert.doesNotMatch(attr(p), /otb-regen/,
|
|
183
|
+
`${p} is not regenerable — the root pattern must be anchored so it is not captured`);
|
|
184
|
+
}
|
|
127
185
|
}
|
|
128
186
|
|
|
129
187
|
// ── 6) END-TO-END: a real `git merge` invokes the driver and auto-resolves ──────
|
|
@@ -193,7 +251,11 @@ function withBlockBody(src, body) {
|
|
|
193
251
|
.filter((line) => line && !line.startsWith('#'))
|
|
194
252
|
.map((line) => line.split(/\s+/))
|
|
195
253
|
.filter((tokens) => tokens.slice(1).includes('merge=otb-regen'))
|
|
196
|
-
|
|
254
|
+
// Strip a leading `/`: in a gitattributes pattern that is a ROOT ANCHOR, not part of
|
|
255
|
+
// the path (task 1001431 — `/CLAUDE.md` must not also match modules/*/CLAUDE.md). The
|
|
256
|
+
// driver receives git's %P, which never carries it, so REGENERABLE keys are unanchored
|
|
257
|
+
// and the two lists are compared as the PATH sets they are.
|
|
258
|
+
.map((tokens) => tokens[0].replace(/^\//, ''));
|
|
197
259
|
assert.ok(otbRegenPaths.length > 0, 'expected at least one merge=otb-regen line in .gitattributes');
|
|
198
260
|
for (const p of otbRegenPaths) {
|
|
199
261
|
assert.ok(regen.REGENERABLE.has(p), `.gitattributes declares merge=otb-regen for ${p} but REGENERABLE (git-merge-regen.js) does not know it`);
|
|
@@ -194,6 +194,63 @@ await t('enableAutoMerge: posts the graphql mutation; false on a graphql error',
|
|
|
194
194
|
assert.equal(await gp.enableAutoMerge({ nodeId: null }, { token: 'TT', fetchImpl: okFetch }), false, 'no node id → false');
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
// ---- publishFailedDetail: the generic 500 names its cause (task 1001431) --
|
|
198
|
+
await t('publishFailedDetail: threads the error code so publish_failed is never bare', async () => {
|
|
199
|
+
const D = buildTasksRouter.publishFailedDetail;
|
|
200
|
+
|
|
201
|
+
// The regression this closes: a code with no git_step/stderr used to produce {}, so the
|
|
202
|
+
// builder received `publish_failed` and nothing else — an expired App token and a
|
|
203
|
+
// GraphQL outage were indistinguishable.
|
|
204
|
+
assert.deepEqual(D({ code: 'APP_TOKEN_FAILED' }), { code: 'APP_TOKEN_FAILED' });
|
|
205
|
+
assert.deepEqual(D({ code: 'GRAPHQL_FAILED', detail: '[{"type":"FORBIDDEN"}]' }),
|
|
206
|
+
{ code: 'GRAPHQL_FAILED', detail: '[{"type":"FORBIDDEN"}]' });
|
|
207
|
+
|
|
208
|
+
// Existing field names + meanings are unchanged — ship.js prints `reason`.
|
|
209
|
+
assert.deepEqual(
|
|
210
|
+
D({ code: 'GIT_FAILED', git_step: 'push', stderr: 'Permission denied', stderr_bytes: 17, exit_code: 128, timed_out: true }),
|
|
211
|
+
{ code: 'GIT_FAILED', step: 'push', reason: 'Permission denied', reason_bytes: 17, git_exit_code: 128, timed_out: true }
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Absent fields stay absent rather than serializing as null/undefined keys.
|
|
215
|
+
assert.deepEqual(D({}), {});
|
|
216
|
+
assert.deepEqual(D(), {});
|
|
217
|
+
assert.equal('timed_out' in D({ timed_out: false }), false, 'a falsy timed_out is omitted, not sent as false');
|
|
218
|
+
assert.equal('git_exit_code' in D({ exit_code: 0 }), true, 'exit_code 0 is real and must survive');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---- enableAutoMergeDetailed: says WHY it could not arm (task 1001431) ----
|
|
222
|
+
await t('enableAutoMergeDetailed: reports the GitHub message instead of a bare false', async () => {
|
|
223
|
+
const okFetch = async () => jsonRes(200, { data: { enablePullRequestAutoMerge: {} } });
|
|
224
|
+
assert.deepEqual(await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { token: 'TT', fetchImpl: okFetch }),
|
|
225
|
+
{ ok: true, reason: null });
|
|
226
|
+
|
|
227
|
+
// The real-world case: a repository with allow_auto_merge OFF. GitHub answers with a
|
|
228
|
+
// plain GraphQL error, and its own message is the actionable one.
|
|
229
|
+
const offFetch = async () => jsonRes(200, { errors: [{ message: 'Auto-merge is not allowed for this repository' }] });
|
|
230
|
+
const off = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { token: 'TT', fetchImpl: offFetch });
|
|
231
|
+
assert.equal(off.ok, false);
|
|
232
|
+
assert.match(off.reason, /Auto-merge is not allowed/, 'the repo-level cause reaches the caller');
|
|
233
|
+
|
|
234
|
+
const httpFetch = async () => jsonRes(502, {});
|
|
235
|
+
const http = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { token: 'TT', fetchImpl: httpFetch });
|
|
236
|
+
assert.equal(http.ok, false);
|
|
237
|
+
assert.match(http.reason, /HTTP 502/, 'an HTTP failure is distinguishable from a rejection');
|
|
238
|
+
|
|
239
|
+
const thrown = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, {
|
|
240
|
+
token: 'TT', fetchImpl: async () => { throw new Error('socket hang up'); },
|
|
241
|
+
});
|
|
242
|
+
assert.equal(thrown.ok, false);
|
|
243
|
+
assert.match(thrown.reason, /socket hang up/);
|
|
244
|
+
|
|
245
|
+
assert.equal((await gp.enableAutoMergeDetailed({ nodeId: null }, { token: 'TT', fetchImpl: okFetch })).ok, false);
|
|
246
|
+
assert.match((await gp.enableAutoMergeDetailed({ nodeId: null }, { token: 'TT', fetchImpl: okFetch })).reason,
|
|
247
|
+
/no node id/, 'a missing node id is named, not silently false');
|
|
248
|
+
|
|
249
|
+
// The boolean façade keeps its exact contract for every existing caller.
|
|
250
|
+
assert.equal(await gp.enableAutoMerge({ nodeId: 'N1' }, { token: 'TT', fetchImpl: okFetch }), true);
|
|
251
|
+
assert.equal(await gp.enableAutoMerge({ nodeId: 'N1' }, { token: 'TT', fetchImpl: offFetch }), false);
|
|
252
|
+
});
|
|
253
|
+
|
|
197
254
|
// ---- getPublishStatus: the three states -----------------------------------
|
|
198
255
|
await t('getPublishStatus: open PR → pending; merged + deploy success → success', async () => {
|
|
199
256
|
const mergeSha = 'c'.repeat(40);
|