@bongos/core 1.19.835 → 1.19.837
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 +58 -33
- package/.claude/skills/builder-claim/SKILL.md +2 -0
- package/.claude/skills/builder-release/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +2 -0
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-claims.js +4 -2
- package/modules/lifecycle/db-ship.js +64 -0
- package/modules/lifecycle/publish-reconciler.js +23 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api.js +5 -1
- package/scripts/gds/box.js +5 -1
- package/scripts/gds/build-cli-package.js +1 -0
- package/scripts/gds/claim.js +17 -2
- package/scripts/gds/cli-lib.js +11 -0
- package/scripts/gds/doctor.js +28 -1
- package/scripts/gds/plain-cards.js +348 -0
- package/scripts/gds/provision.js +4 -1
- package/scripts/gds/push-path-brief.js +2 -0
- package/scripts/gds/release.js +3 -0
- package/scripts/gds/runner-drift.js +151 -0
- package/scripts/gds/ship-flow.js +4 -0
- package/scripts/gds/ship-grade-steps.js +5 -0
- package/scripts/gds/ship-land.js +14 -1
- package/scripts/gds/start.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/claim_gate_rebase.mjs +54 -3
- package/tests/plain_cards.mjs +221 -0
- package/tests/provision.mjs +18 -0
- package/tests/rebase_gate_unprovable.mjs +134 -0
- package/tests/runner_drift.mjs +173 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// tests/rebase_gate_unprovable.mjs — a strand the builder CANNOT clear must stop
|
|
2
|
+
// blocking their queue without stopping being reported (task 1003294).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. publish-reconciler proves a land by looking for a branch / published tip
|
|
5
|
+
// in the CORE repo. A task that legitimately delivers into the INSTANCE repo (task
|
|
6
|
+
// 1003253 — a real, complete, deployed commit on the instance host) can never be
|
|
7
|
+
// proven here, so the sweep writes needs_rebase_reason='strand:no_branch_no_tip'.
|
|
8
|
+
// ADR 0120 part 3 then refuses that builder EVERY new claim until they land or
|
|
9
|
+
// release it — and they can do neither, because there is nothing in this repository
|
|
10
|
+
// to land. The recorded remedy was a hand-run SQL UPDATE on the droplet.
|
|
11
|
+
//
|
|
12
|
+
// THE SHAPE OF THE FIX, and what these tests are really protecting: the gate and the
|
|
13
|
+
// SURFACING were one function. "Stop blocking" must not become "stop telling them",
|
|
14
|
+
// so the fix SPLITS them — gatingRebaseTasksForBuilder may release a strand past the
|
|
15
|
+
// grace, needsRebaseTasksForBuilder keeps answering in full for me.js / the hall /
|
|
16
|
+
// the CLI. Re-collapsing the two is the regression this file exists to catch.
|
|
17
|
+
//
|
|
18
|
+
// SQL SEMANTICS ARE NOT TESTED HERE — they need a real Postgres and live in
|
|
19
|
+
// tests/claim_gate_rebase.mjs (INTEGRATION set). What is testable DB-free is the
|
|
20
|
+
// wiring, the parameters, and the NULL-handling SHAPE of the predicate, and a
|
|
21
|
+
// fail-open in that shape is the dangerous mistake, so it is asserted directly.
|
|
22
|
+
//
|
|
23
|
+
// Run: node tests/rebase_gate_unprovable.mjs
|
|
24
|
+
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { makeRunner } from './helpers.mjs';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const { test, summary } = makeRunner();
|
|
34
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const read = (rel) => readFileSync(path.join(REPO_ROOT, rel), 'utf8');
|
|
36
|
+
|
|
37
|
+
const shipMod = require('../modules/lifecycle/db-ship.js');
|
|
38
|
+
const CLAIMS = read('modules/lifecycle/db-claims.js');
|
|
39
|
+
const SHIP = read('modules/lifecycle/db-ship.js');
|
|
40
|
+
const RECON = read('modules/lifecycle/publish-reconciler.js');
|
|
41
|
+
|
|
42
|
+
// A client that records what it was asked, so the query can be inspected without a DB.
|
|
43
|
+
const spyClient = () => {
|
|
44
|
+
const seen = [];
|
|
45
|
+
return { seen, query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
await test('the CLAIM GATE asks the gating query, not the surfacing one', async () => {
|
|
49
|
+
assert.match(CLAIMS, /gatingRebaseTasksForBuilder\(builderId, client\)/,
|
|
50
|
+
'claimTask must consult the gating subset');
|
|
51
|
+
assert.doesNotMatch(CLAIMS, /needsRebaseTasksForBuilder\(/,
|
|
52
|
+
'the gate must NOT use the unfiltered surfacing query — that is the bug, restored');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await test('the SURFACING query still returns everything — no exclusion at all', () => {
|
|
56
|
+
const body = SHIP.slice(
|
|
57
|
+
SHIP.indexOf('async function needsRebaseTasksForBuilder'),
|
|
58
|
+
SHIP.indexOf('// The ship gate (task 1002483')
|
|
59
|
+
);
|
|
60
|
+
assert.ok(body.length > 0, 'needsRebaseTasksForBuilder must still exist');
|
|
61
|
+
assert.doesNotMatch(body, /interval '1 millisecond'/,
|
|
62
|
+
'the surfacing query must not inherit the gate grace — the builder still sees the strand');
|
|
63
|
+
assert.doesNotMatch(body, /no_branch_no_tip/,
|
|
64
|
+
'the surfacing query must not filter on the strand reason');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await test('the gating query is parameterised with the grace and the reason', async () => {
|
|
68
|
+
const c = spyClient();
|
|
69
|
+
await shipMod.gatingRebaseTasksForBuilder(4242, c);
|
|
70
|
+
assert.equal(c.seen.length, 1);
|
|
71
|
+
const { sql, params } = c.seen[0];
|
|
72
|
+
assert.equal(params[0], 4242, 'builder id');
|
|
73
|
+
assert.equal(params[1], shipMod.UNPROVABLE_GATE_GRACE_MS, 'the grace is passed, not inlined');
|
|
74
|
+
assert.equal(params[2], 'strand:no_branch_no_tip',
|
|
75
|
+
'the reason is passed in the SPELLING announceStrand stores (strand:<reason>)');
|
|
76
|
+
assert.match(sql, /needs_rebase = true AND status = 'confirmed' AND shipped_by = \$1/,
|
|
77
|
+
'it must still be the same set of owed rebases, only narrowed');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('an explicit grace overrides the default — the window is tunable', async () => {
|
|
81
|
+
const c = spyClient();
|
|
82
|
+
await shipMod.gatingRebaseTasksForBuilder(1, c, 5_000);
|
|
83
|
+
assert.equal(c.seen[0].params[1], 5_000);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('a negative or junk grace degrades to 0, never to a negative interval', async () => {
|
|
87
|
+
// A negative interval would make `needs_rebase_at < now() + X` true for FRESH
|
|
88
|
+
// strands — releasing the gate immediately, the exact inversion of the intent.
|
|
89
|
+
for (const bad of [-1, NaN, undefined, 'nonsense']) {
|
|
90
|
+
const c = spyClient();
|
|
91
|
+
await shipMod.gatingRebaseTasksForBuilder(1, c, bad);
|
|
92
|
+
assert.ok(c.seen[0].params[1] >= 0, `grace ${String(bad)} must not go negative`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('the predicate is FAIL-CLOSED on NULLs — an unknown keeps the gate', () => {
|
|
97
|
+
const body = SHIP.slice(SHIP.indexOf('async function gatingRebaseTasksForBuilder'));
|
|
98
|
+
const q = body.slice(0, body.indexOf('return rows;'));
|
|
99
|
+
// A bare `needs_rebase_reason = $3` yields NULL for a NULL reason, and
|
|
100
|
+
// NOT(NULL AND true AND true) is NULL, which drops the row from the WHERE —
|
|
101
|
+
// releasing the gate for a row we know nothing about. COALESCE removes the NULL.
|
|
102
|
+
assert.match(q, /COALESCE\(needs_rebase_reason, ''\) = \$3/,
|
|
103
|
+
'the reason comparison must not be able to produce NULL');
|
|
104
|
+
assert.match(q, /needs_rebase_at IS NOT NULL/,
|
|
105
|
+
'a NULL timestamp must not participate in the age comparison');
|
|
106
|
+
assert.match(q, /AND NOT \(/, 'the exclusion is expressed as NOT over non-NULL operands');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('the reconciler ESCALATES past the grace instead of going quiet', () => {
|
|
110
|
+
assert.match(RECON, /UNPROVABLE_GATE_GRACE_MS/,
|
|
111
|
+
'the sweep must use the SAME constant as the gate, not a second number that can drift');
|
|
112
|
+
assert.match(RECON, /require\('\.\/db-ship\.js'\)/, 'imported rather than restated');
|
|
113
|
+
assert.match(RECON, /UNPROVABLE task \$\{r\.task_id\}/,
|
|
114
|
+
'an ungated strand must still be named on every sweep');
|
|
115
|
+
assert.match(RECON, /still confirmed and still unlanded/,
|
|
116
|
+
'the log must say this is not a resolution');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('releasing the gate does NOT clear the flag — the row stays owed', () => {
|
|
120
|
+
// The fix must not touch needs_rebase/needs_rebase_reason. If a future change
|
|
121
|
+
// "tidied up" by clearing the flag past the grace, the strand would vanish from
|
|
122
|
+
// every surfacing and the task would silently sit confirmed forever.
|
|
123
|
+
const body = SHIP.slice(SHIP.indexOf('async function gatingRebaseTasksForBuilder'));
|
|
124
|
+
const q = body.slice(0, body.indexOf('return rows;'));
|
|
125
|
+
assert.doesNotMatch(q, /UPDATE|SET /, 'the gating query must be a pure read');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('the grace is bounded and sane by default', () => {
|
|
129
|
+
assert.ok(shipMod.UNPROVABLE_GATE_GRACE_MS > 0, 'a zero default would never gate at all');
|
|
130
|
+
assert.ok(shipMod.UNPROVABLE_GATE_GRACE_MS >= 60 * 60_000,
|
|
131
|
+
'too short and case (a) — the builder who simply never pushed — escapes the gate that exists for them');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
summary();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// tests/runner_drift.mjs — the intent-runner drift gate (task 1002722).
|
|
2
|
+
//
|
|
3
|
+
// The incident: for weeks the prod intent runners ran core 1.19.2 from a retired clone while
|
|
4
|
+
// the instance served a current core, so an owner's domain-attach click executed as a
|
|
5
|
+
// TEARDOWN (task 1002716). The hotfix repointed the units with systemd drop-ins. These cases
|
|
6
|
+
// pin the reading of a unit the way systemd reads it (a drop-in overrides the base file) and
|
|
7
|
+
// the version comparison, against a fake filesystem shaped like the live box.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/runner_drift.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { effectiveUnit, runnerScriptPath, coreVersionFor, installedCoreVersion, assessRunnerUnit, parseUnitList, runnerBanner } from '../scripts/gds/runner-drift.js';
|
|
14
|
+
|
|
15
|
+
let passed = 0, failed = 0;
|
|
16
|
+
function t(name, fn) {
|
|
17
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
18
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The live selfhost unit as `systemctl cat` prints it (2026-09-17): base file + drop-in.
|
|
22
|
+
const LIVE_CAT = `# /etc/systemd/system/selfhost-provision-intent-runner.service
|
|
23
|
+
[Service]
|
|
24
|
+
Type=oneshot
|
|
25
|
+
WorkingDirectory=/home/lars/cloudbongos
|
|
26
|
+
EnvironmentFile=/etc/cloudbongos/box.env
|
|
27
|
+
ExecStart=/usr/bin/env bash infra/core-node.sh scripts/gds/provision.js run-intents --apply
|
|
28
|
+
|
|
29
|
+
# /etc/systemd/system/selfhost-provision-intent-runner.service.d/override.conf
|
|
30
|
+
[Service]
|
|
31
|
+
WorkingDirectory=/home/lars/cloudbongos-instance
|
|
32
|
+
ExecStart=
|
|
33
|
+
ExecStart=/usr/bin/node node_modules/@bongos/core/scripts/gds/provision.js run-intents --apply
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
// A fake fs: path → file body. Only readFileSync + existsSync are used.
|
|
37
|
+
function fakeFs(files) {
|
|
38
|
+
return {
|
|
39
|
+
existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p),
|
|
40
|
+
readFileSync: (p) => { if (!(p in files)) { const e = new Error(`ENOENT ${p}`); e.code = 'ENOENT'; throw e; } return files[p]; },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const pkg = (name, version) => JSON.stringify({ name, version });
|
|
44
|
+
const INSTANCE = '/home/lars/cloudbongos-instance';
|
|
45
|
+
const boxFs = (installed, runnerPkg = installed) => fakeFs({
|
|
46
|
+
[`${INSTANCE}/node_modules/@bongos/core/package.json`]: pkg('@bongos/core', installed),
|
|
47
|
+
[`${INSTANCE}/package.json`]: pkg('cloudbongos-instance', '0.0.1'),
|
|
48
|
+
...(runnerPkg !== installed ? { '/opt/other/node_modules/@bongos/core/package.json': pkg('@bongos/core', runnerPkg) } : {}),
|
|
49
|
+
'/home/lars/cloudbongos/src/module-api.js': "const CORE_VERSION = '1.19.2'; // old clone\n",
|
|
50
|
+
'/home/lars/cloudbongos/package.json': pkg('cloud-bongos', '0.0.2'),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
t('effectiveUnit: a drop-in WorkingDirectory wins, and an empty ExecStart= resets the base one', () => {
|
|
54
|
+
assert.deepEqual(effectiveUnit(LIVE_CAT), {
|
|
55
|
+
workingDirectory: INSTANCE,
|
|
56
|
+
execStarts: ['/usr/bin/node node_modules/@bongos/core/scripts/gds/provision.js run-intents --apply'],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
t('effectiveUnit: with no drop-in the base file stands (the pre-hotfix shape)', () => {
|
|
61
|
+
const base = LIVE_CAT.split('# /etc/systemd/system/selfhost-provision-intent-runner.service.d')[0];
|
|
62
|
+
const u = effectiveUnit(base);
|
|
63
|
+
assert.equal(u.workingDirectory, '/home/lars/cloudbongos');
|
|
64
|
+
assert.equal(u.execStarts.length, 1);
|
|
65
|
+
assert.match(u.execStarts[0], /infra\/core-node\.sh/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
t('assessRunnerUnit: a drop-in that ADDS an ExecStart without clearing leaves the stale base command running — never ok', () => {
|
|
69
|
+
const cat = LIVE_CAT.replace('ExecStart=\nExecStart=/usr/bin/node', 'ExecStart=/usr/bin/node')
|
|
70
|
+
.replace('ExecStart=/usr/bin/env bash infra/core-node.sh scripts/gds/provision.js run-intents --apply', 'ExecStart=/usr/bin/node /home/lars/cloudbongos/scripts/gds/provision.js run-intents --apply');
|
|
71
|
+
assert.equal(effectiveUnit(cat).execStarts.length, 2);
|
|
72
|
+
const r = assessRunnerUnit({ name: 'x', catText: cat, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') });
|
|
73
|
+
assert.equal(r.status, 'drift', r.reason);
|
|
74
|
+
assert.match(r.reason, /1\.19\.2/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
t('runnerScriptPath: node + a relative script resolves against WorkingDirectory; a wrapper resolves to null', () => {
|
|
78
|
+
assert.equal(runnerScriptPath({ workingDirectory: INSTANCE, execStart: '/usr/bin/node node_modules/@bongos/core/scripts/gds/provision.js run-intents --apply' }),
|
|
79
|
+
`${INSTANCE}/node_modules/@bongos/core/scripts/gds/provision.js`);
|
|
80
|
+
assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '/usr/bin/env node /abs/core/scripts/gds/box.js run-intents' }), '/abs/core/scripts/gds/box.js');
|
|
81
|
+
assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '-/usr/bin/node scripts/gds/box.js' }), '/w/scripts/gds/box.js', 'systemd exec prefixes are stripped');
|
|
82
|
+
assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '/usr/bin/env NODE_ENV=production node --max-old-space-size=512 scripts/gds/box.js' }), '/w/scripts/gds/box.js', 'env assignments and node flags are skipped');
|
|
83
|
+
assert.equal(runnerScriptPath({ workingDirectory: '/w', execStart: '/usr/bin/env bash infra/core-node.sh scripts/gds/provision.js' }), null,
|
|
84
|
+
'a shell wrapper decides at runtime which core it loads — the gate must not guess');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
t('coreVersionFor: an installed package reads its package.json; a source checkout reads CORE_VERSION', () => {
|
|
88
|
+
const fs = boxFs('1.19.831');
|
|
89
|
+
assert.equal(coreVersionFor(`${INSTANCE}/node_modules/@bongos/core/scripts/gds/provision.js`, { fsImpl: fs }), '1.19.831');
|
|
90
|
+
assert.equal(coreVersionFor('/home/lars/cloudbongos/scripts/gds/provision.js', { fsImpl: fs }), '1.19.2');
|
|
91
|
+
assert.equal(coreVersionFor('/nowhere/scripts/gds/provision.js', { fsImpl: fs }), null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
t('installedCoreVersion: reads <root>/node_modules/@bongos/core; null when absent', () => {
|
|
95
|
+
assert.equal(installedCoreVersion(INSTANCE, { fsImpl: boxFs('1.19.831') }), '1.19.831');
|
|
96
|
+
assert.equal(installedCoreVersion('/empty', { fsImpl: boxFs('1.19.831') }), null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
t('assessRunnerUnit: the live drop-in shape is ok', () => {
|
|
100
|
+
const r = assessRunnerUnit({ name: 'selfhost-provision-intent-runner.service', catText: LIVE_CAT, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') });
|
|
101
|
+
assert.equal(r.status, 'ok', r.reason);
|
|
102
|
+
assert.equal(r.runnerVersion, '1.19.831');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
t('assessRunnerUnit: a unit running a source clone on another core is DRIFT, naming both versions', () => {
|
|
106
|
+
const cat = LIVE_CAT.replace(/ExecStart=\/usr\/bin\/node node_modules[^\n]*/, 'ExecStart=/usr/bin/node /home/lars/cloudbongos/scripts/gds/provision.js run-intents --apply');
|
|
107
|
+
const r = assessRunnerUnit({ name: 'x.service', catText: cat, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') });
|
|
108
|
+
assert.equal(r.status, 'drift');
|
|
109
|
+
assert.match(r.reason, /1\.19\.2/);
|
|
110
|
+
assert.match(r.reason, /1\.19\.831/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
t('assessRunnerUnit: a wrapper, or a version it cannot read, is UNRESOLVED — never reported as ok', () => {
|
|
114
|
+
const base = LIVE_CAT.split('# /etc/systemd/system/selfhost-provision-intent-runner.service.d')[0];
|
|
115
|
+
assert.equal(assessRunnerUnit({ name: 'x', catText: base, instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') }).status, 'unresolved');
|
|
116
|
+
assert.equal(assessRunnerUnit({ name: 'x', catText: LIVE_CAT, instanceRoot: '/empty', fsImpl: boxFs('1.19.831') }).status, 'unresolved');
|
|
117
|
+
assert.equal(assessRunnerUnit({ name: 'x', catText: '', instanceRoot: INSTANCE, fsImpl: boxFs('1.19.831') }).status, 'unresolved');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
t('parseUnitList: plain list-units output → service names only', () => {
|
|
121
|
+
const out = 'cohost-provision-intent-runner.service loaded inactive dead Cloud Bongos — drain\nselfhost-provision-intent-runner.service loaded inactive dead x\n\n';
|
|
122
|
+
assert.deepEqual(parseUnitList(out), ['cohost-provision-intent-runner.service', 'selfhost-provision-intent-runner.service']);
|
|
123
|
+
assert.deepEqual(parseUnitList(''), []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
t('runnerBanner: names the runner core + path, and flags a differing instance core', () => {
|
|
127
|
+
assert.equal(runnerBanner({ runnerVersion: '1.19.831', runnerRoot: '/r', instanceVersion: '1.19.831', instanceRoot: '/i' }),
|
|
128
|
+
'runner core 1.19.831 from /r (instance core 1.19.831 at /i)');
|
|
129
|
+
assert.match(runnerBanner({ runnerVersion: '1.19.2', runnerRoot: '/r', instanceVersion: '1.19.831', instanceRoot: '/i' }), /^⚠ DRIFT: runner core 1\.19\.2 from \/r \(instance core 1\.19\.831 at \/i\)/);
|
|
130
|
+
assert.equal(runnerBanner({ runnerVersion: null, runnerRoot: '/r', instanceVersion: null, instanceRoot: '/i' }), 'runner core ? from /r (no installed core at /i)');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// box.js run-intents is the other runner the incident class covers — it opens with the same line.
|
|
134
|
+
const { cmdRunIntents: boxRunIntents } = await import('../scripts/gds/box.js');
|
|
135
|
+
{
|
|
136
|
+
const lines = [];
|
|
137
|
+
const origLog = console.log;
|
|
138
|
+
const origRoot = process.env.BONGOS_INSTANCE_ROOT;
|
|
139
|
+
process.env.BONGOS_INSTANCE_ROOT = '/no/such/instance';
|
|
140
|
+
console.log = (l) => lines.push(String(l));
|
|
141
|
+
try { await boxRunIntents({ query: async () => ({ rows: [] }) }, {}, null, { apply: false }); }
|
|
142
|
+
finally {
|
|
143
|
+
console.log = origLog;
|
|
144
|
+
if (origRoot === undefined) delete process.env.BONGOS_INSTANCE_ROOT; else process.env.BONGOS_INSTANCE_ROOT = origRoot;
|
|
145
|
+
}
|
|
146
|
+
t('box.js run-intents opens with the runner core version line', () => {
|
|
147
|
+
assert.match(lines[0], /^runner core \d+\.\d+\.\d+ from .+ \(no installed core at \/no\/such\/instance\)$/);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { runnerDriftRows } = await import('../scripts/gds/doctor.js');
|
|
152
|
+
|
|
153
|
+
t('doctor maps each verdict to a row: ok → ok, drift → FAIL with the fix, unresolved → warn', () => {
|
|
154
|
+
const rows = runnerDriftRows([
|
|
155
|
+
{ name: 'a.service', status: 'ok', reason: 'runs core 1.19.831' },
|
|
156
|
+
{ name: 'b.service', status: 'drift', reason: 'runs core 1.19.2 (/x) but the instance installed 1.19.831 at /i' },
|
|
157
|
+
{ name: 'c.service', status: 'unresolved', reason: 'ExecStart does not run a .js script directly' },
|
|
158
|
+
]);
|
|
159
|
+
assert.deepEqual(rows.map((r) => r.status), ['ok', 'fail', 'warn']);
|
|
160
|
+
assert.equal(rows[0].msg, 'a.service: runs core 1.19.831');
|
|
161
|
+
assert.match(rows[1].msg, /^b\.service: runs core 1\.19\.2 .* — repoint the unit's WorkingDirectory \+ ExecStart at the installed core/);
|
|
162
|
+
assert.equal(rows[2].msg, 'c.service: ExecStart does not run a .js script directly');
|
|
163
|
+
assert.deepEqual(runnerDriftRows(null), [], 'no systemd → no rows, so a laptop shows no section');
|
|
164
|
+
assert.deepEqual(runnerDriftRows([]), []);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
t('doctor runs the runner-drift probe', () => {
|
|
168
|
+
const src = readFileSync(new URL('../scripts/gds/doctor.js', import.meta.url), 'utf8');
|
|
169
|
+
assert.match(src.slice(src.indexOf('require.main === module')), /checkRunnerDrift\(\)/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
console.log(`\nrunner_drift: ${passed} passed, ${failed} failed`);
|
|
173
|
+
process.exit(failed ? 1 : 0);
|