@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,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/runner-drift.js — do the intent-runner units run the core this instance installed? (task 1002722)
|
|
3
|
+
//
|
|
4
|
+
// WHY. For weeks the prod intent runners ran core 1.19.2 from a retired clone while the
|
|
5
|
+
// instance served a current core, so an owner's domain-attach click executed as a TEARDOWN
|
|
6
|
+
// (task 1002716). The hotfix repointed the units with systemd drop-ins; nothing noticed the
|
|
7
|
+
// drift, and the only tell was a PWD in sudo logs. This reads each `*intent-runner*` unit the
|
|
8
|
+
// way systemd does (a drop-in overrides the base file), finds the core package the ExecStart
|
|
9
|
+
// script lives in, and compares its version to the instance's installed @bongos/core.
|
|
10
|
+
//
|
|
11
|
+
// A unit whose version cannot be read — a shell wrapper that picks the core at runtime, a
|
|
12
|
+
// missing package — is UNRESOLVED, never ok: a gate that guesses green is the incident again.
|
|
13
|
+
//
|
|
14
|
+
// node scripts/gds/runner-drift.js [--instance-root <dir>] [--json]
|
|
15
|
+
// exit 0 = every unit resolved and matched (or no systemd here), 1 = drift, 2 = bad usage.
|
|
16
|
+
// Unresolved units are listed but do not fail the run. `bongos doctor` runs the same check.
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { spawnSync } = require('node:child_process');
|
|
22
|
+
|
|
23
|
+
const CORE_NAMES = ['@bongos/core', '@cloudbongos/core'];
|
|
24
|
+
const P = path.posix; // unit files describe the Linux control plane, whatever runs the tests
|
|
25
|
+
|
|
26
|
+
// The effective WorkingDirectory + ExecStart lines from `systemctl cat` output. Later files
|
|
27
|
+
// are drop-ins: a later WorkingDirectory wins, an empty `ExecStart=` clears the list, and a
|
|
28
|
+
// drop-in that only ADDS one leaves the base command running too (oneshot runs them all).
|
|
29
|
+
function effectiveUnit(catText) {
|
|
30
|
+
let workingDirectory = null;
|
|
31
|
+
let execs = [];
|
|
32
|
+
for (const raw of String(catText || '').split(/\r?\n/)) {
|
|
33
|
+
const line = raw.trim();
|
|
34
|
+
const m = line.match(/^(WorkingDirectory|ExecStart)=(.*)$/);
|
|
35
|
+
if (!m) continue;
|
|
36
|
+
const value = m[2].trim();
|
|
37
|
+
if (m[1] === 'WorkingDirectory') workingDirectory = value.replace(/^-/, '') || null;
|
|
38
|
+
else if (value === '') execs = [];
|
|
39
|
+
else execs.push(value);
|
|
40
|
+
}
|
|
41
|
+
return { workingDirectory, execStarts: execs };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The absolute path of the .js script an ExecStart runs, or null when its first argument is
|
|
45
|
+
// not one (a shell wrapper decides at runtime which core it loads).
|
|
46
|
+
function runnerScriptPath({ workingDirectory, execStart }) {
|
|
47
|
+
const tokens = String(execStart || '').trim().split(/\s+/).filter(Boolean);
|
|
48
|
+
if (!tokens.length) return null;
|
|
49
|
+
let i = 0;
|
|
50
|
+
if (P.basename(tokens[i].replace(/^[@\-:+!]+/, '')) === 'env') {
|
|
51
|
+
i++;
|
|
52
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
53
|
+
}
|
|
54
|
+
const script = tokens.slice(i + 1).find((tok) => !tok.startsWith('-'));
|
|
55
|
+
if (!script || !/\.[cm]?js$/.test(script)) return null;
|
|
56
|
+
if (script.startsWith('/')) return script;
|
|
57
|
+
return workingDirectory ? P.join(workingDirectory, script) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJson(p, fsImpl) {
|
|
61
|
+
try { return JSON.parse(fsImpl.readFileSync(p, 'utf8')); } catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The core version a script belongs to: the nearest enclosing @bongos/core package, or a
|
|
65
|
+
// source checkout's CORE_VERSION. Null when neither is found.
|
|
66
|
+
function coreVersionFor(scriptPath, { fsImpl = fs } = {}) {
|
|
67
|
+
let dir = P.dirname(String(scriptPath || ''));
|
|
68
|
+
for (;;) {
|
|
69
|
+
const pkg = readJson(P.join(dir, 'package.json'), fsImpl);
|
|
70
|
+
if (pkg && CORE_NAMES.includes(pkg.name) && pkg.version) return String(pkg.version);
|
|
71
|
+
try {
|
|
72
|
+
const m = String(fsImpl.readFileSync(P.join(dir, 'src', 'module-api.js'), 'utf8')).match(/CORE_VERSION\s*=\s*['"]([^'"]+)['"]/);
|
|
73
|
+
if (m) return m[1];
|
|
74
|
+
} catch { /* not a checkout root */ }
|
|
75
|
+
const up = P.dirname(dir);
|
|
76
|
+
if (up === dir) return null;
|
|
77
|
+
dir = up;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function installedCoreVersion(instanceRoot, { fsImpl = fs } = {}) {
|
|
82
|
+
if (!instanceRoot) return null;
|
|
83
|
+
for (const name of CORE_NAMES) {
|
|
84
|
+
const pkg = readJson(P.join(instanceRoot, 'node_modules', name, 'package.json'), fsImpl);
|
|
85
|
+
if (pkg && pkg.version) return String(pkg.version);
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assessExecStart({ workingDirectory, execStart, instanceVersion, instanceRoot, fsImpl }) {
|
|
91
|
+
const scriptPath = runnerScriptPath({ workingDirectory, execStart });
|
|
92
|
+
const base = { scriptPath, runnerVersion: null, instanceVersion };
|
|
93
|
+
if (!scriptPath) return { ...base, status: 'unresolved', reason: `ExecStart does not run a .js script directly (${execStart}) — cannot tell which core it loads` };
|
|
94
|
+
const runnerVersion = coreVersionFor(scriptPath, { fsImpl });
|
|
95
|
+
const found = { ...base, runnerVersion };
|
|
96
|
+
if (!runnerVersion) return { ...found, status: 'unresolved', reason: `no core version found above ${scriptPath}` };
|
|
97
|
+
if (!instanceVersion) return { ...found, status: 'unresolved', reason: `no installed @bongos/core under ${instanceRoot}` };
|
|
98
|
+
if (runnerVersion === instanceVersion) return { ...found, status: 'ok', reason: `runs core ${runnerVersion}` };
|
|
99
|
+
return { ...found, status: 'drift', reason: `runs core ${runnerVersion} (${scriptPath}) but the instance installed ${instanceVersion} at ${instanceRoot}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// One verdict per unit, from its WORST ExecStart: drift, then unresolved, then ok.
|
|
103
|
+
function assessRunnerUnit({ name, catText, instanceRoot, fsImpl = fs }) {
|
|
104
|
+
const { workingDirectory, execStarts } = effectiveUnit(catText);
|
|
105
|
+
const instanceVersion = installedCoreVersion(instanceRoot, { fsImpl });
|
|
106
|
+
if (!execStarts.length) return { name, status: 'unresolved', reason: 'no ExecStart in the unit', scriptPath: null, runnerVersion: null, instanceVersion };
|
|
107
|
+
const all = execStarts.map((execStart) => assessExecStart({ workingDirectory, execStart, instanceVersion, instanceRoot, fsImpl }));
|
|
108
|
+
const worst = ['drift', 'unresolved', 'ok'].map((s) => all.find((r) => r.status === s)).find(Boolean);
|
|
109
|
+
return { name, ...worst };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseUnitList(stdout) {
|
|
113
|
+
return String(stdout || '').split(/\r?\n/).map((l) => l.trim().split(/\s+/)[0]).filter((n) => n && n.endsWith('.service'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The first line of every run-intents, so a journal grep shows a stale runner at once.
|
|
117
|
+
function runnerBanner({ runnerVersion, runnerRoot, instanceVersion, instanceRoot }) {
|
|
118
|
+
const tail = instanceVersion ? `instance core ${instanceVersion} at ${instanceRoot}` : `no installed core at ${instanceRoot}`;
|
|
119
|
+
const line = `runner core ${runnerVersion || '?'} from ${runnerRoot} (${tail})`;
|
|
120
|
+
return runnerVersion && instanceVersion && runnerVersion !== instanceVersion ? `⚠ DRIFT: ${line}` : line;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Every intent-runner unit on this host, assessed. Null when there is no systemd to ask.
|
|
124
|
+
function collectRunnerDrift({ instanceRoot }) {
|
|
125
|
+
const run = (args) => spawnSync('systemctl', args, { encoding: 'utf8', timeout: 15000 });
|
|
126
|
+
const list = run(['list-units', '--all', '--plain', '--no-legend', '--no-pager', '--type=service', '*intent-runner*']);
|
|
127
|
+
if (list.error || list.status !== 0) return null;
|
|
128
|
+
return parseUnitList(list.stdout).map((name) => {
|
|
129
|
+
const cat = run(['cat', '--no-pager', name]);
|
|
130
|
+
return assessRunnerUnit({ name, catText: cat.status === 0 ? cat.stdout : '', instanceRoot });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function main(argv) {
|
|
135
|
+
const at = argv.indexOf('--instance-root');
|
|
136
|
+
const instanceRoot = (at >= 0 ? argv[at + 1] : null) || process.env.BONGOS_INSTANCE_ROOT;
|
|
137
|
+
if (!instanceRoot) {
|
|
138
|
+
console.error('runner-drift: name the instance root — --instance-root <dir> or BONGOS_INSTANCE_ROOT');
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
const results = collectRunnerDrift({ instanceRoot });
|
|
142
|
+
if (argv.includes('--json')) console.log(JSON.stringify({ instanceRoot, systemd: results !== null, units: results || [] }, null, 2));
|
|
143
|
+
else if (results === null) console.log('runner-drift: no systemd on this host — nothing to check');
|
|
144
|
+
else if (!results.length) console.log('runner-drift: no *intent-runner* units on this host');
|
|
145
|
+
else for (const r of results) console.log(`${r.status === 'ok' ? '✓' : r.status === 'drift' ? '✖' : '?'} ${r.name}: ${r.reason}`);
|
|
146
|
+
return results && results.some((r) => r.status === 'drift') ? 1 : 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { effectiveUnit, runnerScriptPath, coreVersionFor, installedCoreVersion, assessRunnerUnit, parseUnitList, runnerBanner, collectRunnerDrift };
|
|
150
|
+
|
|
151
|
+
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
|
|
12
12
|
const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal, staleSummaryNotice } = require('./ship-honesty.js');
|
|
13
|
+
const { plainCardLines } = require('./plain-cards.js');
|
|
13
14
|
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
14
15
|
const { isModuleKey, scopeForModule, moduleDriftFiles: computeModuleDrift } = require('../../src/bongos/module-scope-map');
|
|
15
16
|
const { runShipGate } = require('./sandbox-stage');
|
|
@@ -251,12 +252,14 @@ async function shipMain() {
|
|
|
251
252
|
console.error('');
|
|
252
253
|
console.error('Your work was never committed — shipping now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
|
|
253
254
|
console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run /builder-ship. Your claim is still active.');
|
|
255
|
+
for (const line of plainCardLines('uncommitted_work')) console.error(line);
|
|
254
256
|
process.exit(1);
|
|
255
257
|
}
|
|
256
258
|
if (verdict.reason === 'nothing-to-ship') {
|
|
257
259
|
console.error(`Nothing to ship: the committed diff vs ${graderBaseline || 'baseline'} is empty and the working tree is clean.`);
|
|
258
260
|
console.error('The grader reads committed history, not the working tree — commit your work on this branch, then re-run /builder-ship. Your claim is still active.');
|
|
259
261
|
console.error('If this task legitimately has no code/doc artifact (a pure DB-only or no-op task), re-run with --allow-empty.');
|
|
262
|
+
for (const line of plainCardLines('nothing_to_ship')) console.error(line);
|
|
260
263
|
process.exit(1);
|
|
261
264
|
}
|
|
262
265
|
// verdict.reason === 'allow-empty': a deliberate no-artifact / DB-only ship.
|
|
@@ -425,6 +428,7 @@ async function shipMain() {
|
|
|
425
428
|
if (!passed && !allowSmokeFail) {
|
|
426
429
|
console.error('');
|
|
427
430
|
for (const line of smokeGateMessages(taskId).abort) console.error(line);
|
|
431
|
+
for (const line of plainCardLines('smoke_failed')) console.error(line);
|
|
428
432
|
process.exit(1);
|
|
429
433
|
}
|
|
430
434
|
|
|
@@ -14,6 +14,7 @@ const os = require('node:os');
|
|
|
14
14
|
const { cliClient, rewardPolicy } = require('./cli-lib');
|
|
15
15
|
const { gitOk } = require('./ship-git.js');
|
|
16
16
|
const { resolveInstanceBase } = require('./ship-deploy-target.js');
|
|
17
|
+
const { plainCardLines } = require('./plain-cards.js');
|
|
17
18
|
// task 1003313: the shared onboarding/doctor preflight — reused (not duplicated)
|
|
18
19
|
// so the grader outage explains itself with the same `claude CLI` verdict doctor
|
|
19
20
|
// gives, and the two can never drift.
|
|
@@ -294,6 +295,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
|
|
|
294
295
|
out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
|
|
295
296
|
out.push('Or land it now via manual-confirm:');
|
|
296
297
|
out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/bongos/tasks/${taskId}/confirm`);
|
|
298
|
+
out.push(...plainCardLines('grade_unavailable'));
|
|
297
299
|
return out.join('\n');
|
|
298
300
|
}
|
|
299
301
|
|
|
@@ -324,6 +326,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
|
|
|
324
326
|
out.push(' - Split the protected files out of your diff and re-ship the rest, or');
|
|
325
327
|
out.push(' - hand this task to a builder at/above the floor the refusal names.');
|
|
326
328
|
out.push(`(--regrade re-runs the same server check and will refuse again unless the diff changes.)`);
|
|
329
|
+
out.push(...plainCardLines('grade_refused_authorization'));
|
|
327
330
|
return out.join('\n');
|
|
328
331
|
}
|
|
329
332
|
|
|
@@ -351,6 +354,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
|
|
|
351
354
|
out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
|
|
352
355
|
out.push('Or land it now via manual-confirm:');
|
|
353
356
|
out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/bongos/tasks/${taskId}/confirm`);
|
|
357
|
+
out.push(...plainCardLines('grade_lens_unavailable'));
|
|
354
358
|
return out.join('\n');
|
|
355
359
|
}
|
|
356
360
|
|
|
@@ -378,6 +382,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
|
|
|
378
382
|
out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
|
|
379
383
|
out.push('Last resort (only if the grade was wrong):');
|
|
380
384
|
out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/bongos/tasks/${taskId}/confirm`);
|
|
385
|
+
out.push(...plainCardLines('grade_failed'));
|
|
381
386
|
}
|
|
382
387
|
return out.join('\n');
|
|
383
388
|
}
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -15,6 +15,7 @@ const { cliClient, isBoxScope } = require('./cli-lib');
|
|
|
15
15
|
const { gitArgvOk, gitOk, sanitizeGitHubRefs, shellOk } = require('./ship-git.js');
|
|
16
16
|
const { buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, deployedCommitStatus, fetchDeployedVersion, ghArgvOk, ghJson, pushVia, resolveInstanceBase } = require('./ship-deploy-target.js');
|
|
17
17
|
const { normalizeApiError } = require('./ship-io.js');
|
|
18
|
+
const { plainCardLines } = require('./plain-cards.js');
|
|
18
19
|
|
|
19
20
|
// ---------- the land outcome (task 1002541, part 1) ----------
|
|
20
21
|
//
|
|
@@ -311,6 +312,7 @@ function landPendingLines(taskId, { merged } = {}) {
|
|
|
311
312
|
function printLandPending(taskId, opts = {}) {
|
|
312
313
|
console.log('');
|
|
313
314
|
for (const line of landPendingLines(taskId, opts)) console.log(line);
|
|
315
|
+
for (const line of plainCardLines('land_pending')) console.log(line);
|
|
314
316
|
}
|
|
315
317
|
|
|
316
318
|
// task 1002645 — the honest close-out when the PR MERGED but the server refused the
|
|
@@ -349,6 +351,14 @@ function redPrLine(md) {
|
|
|
349
351
|
return named ? ` ci-land: the PR is RED — the \`${named[1]}\` check failed; ${fix}` : null;
|
|
350
352
|
}
|
|
351
353
|
|
|
354
|
+
// redPrLines — the red line followed by the same reason in plain words (task 1003989).
|
|
355
|
+
// The card is keyed on the merge reason; when the failing tests were readable that
|
|
356
|
+
// reason is `failing_check:<name>`, which resolves to the failing_check card.
|
|
357
|
+
function redPrLines(md) {
|
|
358
|
+
const red = redPrLine(md);
|
|
359
|
+
return red ? [red, ...plainCardLines((md && md.reason) || 'failing_check')] : [];
|
|
360
|
+
}
|
|
361
|
+
|
|
352
362
|
// One module-scope sleep command shared by both poll loops (task 1093 / C9). A
|
|
353
363
|
// child `node -e setTimeout` is used (not `await new Promise`) so it blocks
|
|
354
364
|
// identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
|
|
@@ -377,7 +387,7 @@ async function pollServerPublish(taskId, branch) {
|
|
|
377
387
|
const red = redPrLine(r.data.merge_driver);
|
|
378
388
|
if (red && red !== redLine) {
|
|
379
389
|
redLine = red;
|
|
380
|
-
console.log(
|
|
390
|
+
for (const line of redPrLines(r.data.merge_driver)) console.log(line);
|
|
381
391
|
}
|
|
382
392
|
const outcome = classifyLandOutcome(r.data);
|
|
383
393
|
if (outcome === 'shipped') {
|
|
@@ -401,6 +411,7 @@ async function pollServerPublish(taskId, branch) {
|
|
|
401
411
|
if (blockedReason) {
|
|
402
412
|
console.log('');
|
|
403
413
|
for (const line of reconcileBlockedLines(taskId, blockedReason)) console.log(line);
|
|
414
|
+
for (const line of plainCardLines('reconcile_blocked')) console.log(line);
|
|
404
415
|
return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
|
|
405
416
|
}
|
|
406
417
|
printLandPending(taskId);
|
|
@@ -562,6 +573,7 @@ async function ciLandLocal(taskId, branch, valueSummary) {
|
|
|
562
573
|
} else {
|
|
563
574
|
console.log(` ci-land: to retry — ${d.fix}`);
|
|
564
575
|
}
|
|
576
|
+
for (const line of plainCardLines(`ship:${d.id}`)) console.log(line);
|
|
565
577
|
return landBailed(
|
|
566
578
|
`could not push ${branch} to origin (${d.id})`,
|
|
567
579
|
d.terminal ? d.fix.split('\n')[0] : `node scripts/gds/ship.js ${taskId}`,
|
|
@@ -713,6 +725,7 @@ module.exports = {
|
|
|
713
725
|
pickStaleGateReviewRun,
|
|
714
726
|
reconcileBlockedLines,
|
|
715
727
|
redPrLine,
|
|
728
|
+
redPrLines,
|
|
716
729
|
reconciledCardStatus,
|
|
717
730
|
strandCommand,
|
|
718
731
|
strandNextStep,
|
package/scripts/gds/start.js
CHANGED
|
@@ -68,6 +68,7 @@ const { checkTree: _checkTree } = require('./tree-preflight.js');
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError, cardFlagsFromMe } = require('./cli-lib');
|
|
71
|
+
const { plainCardLines } = require('./plain-cards');
|
|
71
72
|
const { contextPack, formatContextPackCompact } = require('./context-pack');
|
|
72
73
|
const { depsFinding } = require('./preflight');
|
|
73
74
|
|
|
@@ -719,11 +720,13 @@ async function main() {
|
|
|
719
720
|
if (r.status === 403 && r.data && r.data.error === 'stage_required') {
|
|
720
721
|
console.log('');
|
|
721
722
|
console.log(r.data.what_to_do || `Clear "${r.data.required_stage_label || r.data.required_stage}" before the work queue opens.`);
|
|
723
|
+
for (const line of plainCardLines('stage_required')) console.log(line);
|
|
722
724
|
console.log('');
|
|
723
725
|
process.exit(0);
|
|
724
726
|
}
|
|
725
727
|
if (!r.ok) {
|
|
726
728
|
console.error(`failed (${r.status}):`, r.data);
|
|
729
|
+
for (const line of plainCardLines('board_unavailable')) console.error(line);
|
|
727
730
|
process.exit(1);
|
|
728
731
|
}
|
|
729
732
|
|
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.837'; // 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');
|
|
@@ -28,13 +28,15 @@ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../m
|
|
|
28
28
|
const db = require('../modules/lifecycle/db.js');
|
|
29
29
|
|
|
30
30
|
const VER = 'ZZZ-CG', WORKER = 90000201, STALLED = 90000201, FRESH = 90000202;
|
|
31
|
+
// task 1003294 — the unprovable-strand cases below use their own rows.
|
|
32
|
+
const AGED = 90000203, FRESH2 = 90000204, AGED_OTHER = 90000205;
|
|
31
33
|
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
|
|
32
34
|
|
|
33
35
|
async function cleanup() {
|
|
34
36
|
await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
|
|
35
|
-
await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, [STALLED, FRESH]]).catch(() => {});
|
|
36
|
-
await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [[STALLED, FRESH]]).catch(() => {});
|
|
37
|
-
await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [[STALLED, FRESH]]).catch(() => {});
|
|
37
|
+
await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, [STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
|
|
38
|
+
await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
|
|
39
|
+
await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
|
|
38
40
|
await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
|
|
39
41
|
await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
|
|
40
42
|
}
|
|
@@ -88,6 +90,55 @@ try {
|
|
|
88
90
|
const activeClaim = (await q(`SELECT builder_id FROM claims WHERE task_id=$1 AND released_at IS NULL`, [FRESH]))[0];
|
|
89
91
|
ok(activeClaim && Number(activeClaim.builder_id) === WORKER, 'the worker now holds an active claim on the task');
|
|
90
92
|
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// task 1003294 — a strand the builder CANNOT clear stops BLOCKING, without
|
|
95
|
+
// stopping being REPORTED.
|
|
96
|
+
//
|
|
97
|
+
// 'strand:no_branch_no_tip' means no branch in the core repo and no published
|
|
98
|
+
// tip. That is either "never pushed" (their chore, the gate is working) or
|
|
99
|
+
// "delivered into the INSTANCE repo" (task 1003253 — real, complete, deployed,
|
|
100
|
+
// and structurally invisible to this reconciler). Indistinguishable here, so the
|
|
101
|
+
// gate keeps its teeth for a bounded window and then lets go. These cases prove
|
|
102
|
+
// the SQL actually does that, which the DB-free suite cannot.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH]);
|
|
105
|
+
|
|
106
|
+
const seedStrand = async (id, reason, ageHours) => {
|
|
107
|
+
await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,shipped_by) VALUES ($1,$2,'aged','confirmed',10,'feature',$3)`, [id, VER, WORKER]);
|
|
108
|
+
await q(`INSERT INTO claims (task_id,builder_id,claimed_at) VALUES ($1,$2, now())`, [id, WORKER]);
|
|
109
|
+
await db.flagTaskNeedsRebase(String(id), reason);
|
|
110
|
+
await q(`UPDATE tasks SET needs_rebase_at = now() - ($2::bigint * interval '1 hour') WHERE id = $1`, [id, ageHours]);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind) VALUES ($1,$2,'fresh2','ready',10,'feature')`, [FRESH2, VER]);
|
|
114
|
+
|
|
115
|
+
// 4) An unprovable strand AGED past the grace no longer refuses a claim.
|
|
116
|
+
await seedStrand(AGED, 'strand:no_branch_no_tip', 48);
|
|
117
|
+
const claim2 = await db.claimTask({ taskId: String(FRESH2), builderId: String(WORKER), worktreeName: 'cg-fresh2' });
|
|
118
|
+
ok(claim2 && Number(claim2.task_id) === FRESH2,
|
|
119
|
+
'an unprovable strand past the grace no longer freezes the queue');
|
|
120
|
+
|
|
121
|
+
// 5) …and it is STILL owed and STILL surfaced. This is the half that must not
|
|
122
|
+
// be lost: releasing the gate is not resolving the strand.
|
|
123
|
+
const stillOwed = await db.needsRebaseTasksForBuilder(String(WORKER));
|
|
124
|
+
ok(stillOwed.some((t) => Number(t.id) === AGED),
|
|
125
|
+
'the ungated strand is still reported to its builder (me.js / hall / CLI)');
|
|
126
|
+
const agedFlag = (await q(`SELECT needs_rebase FROM tasks WHERE id=$1`, [AGED]))[0].needs_rebase;
|
|
127
|
+
ok(agedFlag === true, 'the flag is untouched — the row is still owed, just not blocking');
|
|
128
|
+
|
|
129
|
+
// 6) A strand with a DIFFERENT reason, equally aged, still gates: this release is
|
|
130
|
+
// scoped to the one ambiguous reason, not to age in general.
|
|
131
|
+
await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH2]);
|
|
132
|
+
await q(`UPDATE tasks SET status='shipped', needs_rebase=false WHERE id=$1`, [AGED]);
|
|
133
|
+
await seedStrand(AGED_OTHER, 'strand:pr_not_landed', 48);
|
|
134
|
+
await q(`UPDATE tasks SET status='ready' WHERE id=$1`, [FRESH2]);
|
|
135
|
+
let threw2 = null;
|
|
136
|
+
try {
|
|
137
|
+
await db.claimTask({ taskId: String(FRESH2), builderId: String(WORKER), worktreeName: 'cg-fresh2b' });
|
|
138
|
+
} catch (e) { threw2 = e; }
|
|
139
|
+
ok(threw2 && threw2.code === 'REBASE_REQUIRED',
|
|
140
|
+
'an aged pr_not_landed strand still gates — the builder CAN fix that one');
|
|
141
|
+
|
|
91
142
|
console.log(`\nclaim_gate_rebase (ADR 0120 part 3): ${pass} passed, 0 failed`);
|
|
92
143
|
} catch (e) {
|
|
93
144
|
console.error('claim_gate_rebase.mjs FAILED:', e.stack || e.message);
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// tests/plain_cards.mjs — every failure a builder can be parked behind has a
|
|
2
|
+
// plain-language card (task 1003989).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The CLIs explain a refusal in engineer prose and the lifecycle skills said
|
|
5
|
+
// "relay verbatim", so a non-technical builder met REBASE_REQUIRED or
|
|
6
|
+
// strand:pr_not_landed raw, at exactly the moment they were stuck. The registry in
|
|
7
|
+
// scripts/gds/plain-cards.js says the same thing in plain words. This file holds
|
|
8
|
+
// three lines: a new failure code cannot ship without a card, a card cannot drift
|
|
9
|
+
// back into jargon, and the deliberate restrictions stay marked as deliberate.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/plain_cards.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { CARDS, cardFor, plainCardLines, PLAIN_MARKER } from '../scripts/gds/plain-cards.js';
|
|
17
|
+
import * as shipLand from '../scripts/gds/ship-land.js';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { JARGON_TERMS } = require('../modules/sessions/accessibility.js');
|
|
21
|
+
const { formatClaimFailure } = require('../scripts/gds/claim.js');
|
|
22
|
+
const { formatGradeOutcome } = require('../scripts/gds/ship-grade-steps.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function test(name, fn) {
|
|
27
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const read = (rel) => fs.readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
|
|
32
|
+
const missing = (codes) => [...new Set(codes)].filter((c) => !cardFor(c)).sort();
|
|
33
|
+
|
|
34
|
+
// ── completeness: the codes are read out of the code that raises them ──────────
|
|
35
|
+
|
|
36
|
+
test('every refusal claim.js renders has a card', () => {
|
|
37
|
+
const src = read('scripts/gds/claim.js');
|
|
38
|
+
const body = src.slice(src.indexOf('function claimFailureGuidance'), src.indexOf('function goalMembershipOfferPlan'));
|
|
39
|
+
const codes = [...body.matchAll(/code === '([A-Za-z_]+)'/g)].map((m) => m[1]);
|
|
40
|
+
assert.ok(codes.length >= 12, `expected the claim ladder, found ${codes.length}`);
|
|
41
|
+
assert.deepEqual(missing(codes), []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('every refusal the claim ladder can throw server-side has a card', () => {
|
|
45
|
+
const src = read('modules/lifecycle/db-claims.js');
|
|
46
|
+
const codes = [...src.matchAll(/\bcode: '([A-Za-z_]+)'/g), ...src.matchAll(/\.code = '([A-Za-z_]+)'/g)].map((m) => m[1]);
|
|
47
|
+
assert.ok(codes.includes('REBASE_REQUIRED'), 'the ladder was not found — has db-claims.js moved?');
|
|
48
|
+
assert.deepEqual(missing(codes), []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('every reason a confirmed task can be flagged with has a card', () => {
|
|
52
|
+
// The reconciler composes `strand:${reason}` at runtime, so read both halves: the
|
|
53
|
+
// literal tokens, and the four values the `reason` ternary can take.
|
|
54
|
+
const src = read('modules/lifecycle/publish-reconciler.js');
|
|
55
|
+
const ternary = src.slice(src.indexOf('const reason = prClosedUnmerged'));
|
|
56
|
+
const reasons = [...ternary.slice(0, ternary.indexOf(';')).matchAll(/'([a-z_]+)'/g)].map((m) => `strand:${m[1]}`);
|
|
57
|
+
const literals = [...src.matchAll(/strand:([a-z_]{3,})/g)].map((m) => `strand:${m[1]}`);
|
|
58
|
+
assert.equal(reasons.length, 4, `the reason ternary moved: ${reasons.join(',')}`);
|
|
59
|
+
assert.ok(literals.length >= 2, 'the conflict strand literals were not found');
|
|
60
|
+
assert.deepEqual(missing([...reasons, ...literals]), []);
|
|
61
|
+
assert.ok(cardFor('strand:unresolvable_conflict:docs/a.md,docs/b.md'), 'a reason carrying file names still resolves');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('every reason an open pull request has not merged has a card', () => {
|
|
65
|
+
const src = read('modules/lifecycle/github-push.js');
|
|
66
|
+
const body = src.slice(src.indexOf('function mergeBlockReason'), src.indexOf('async function mergeIfGreen'));
|
|
67
|
+
const tokens = [...body.matchAll(/return [`']([a-z_]+)/g)].map((m) => m[1]);
|
|
68
|
+
assert.ok(tokens.includes('merge_conflict') && tokens.includes('failing_check_unreadable'), tokens.join(','));
|
|
69
|
+
assert.deepEqual(missing(tokens), []);
|
|
70
|
+
assert.ok(cardFor('failing_check:unit') && cardFor('non_required_check_failing:e2e'), 'named-check reasons resolve by prefix');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('every push diagnosis and land bail has a card', () => {
|
|
74
|
+
const ids = [...shipLand.PUSH_DIAGNOSES.map((d) => d.id), 'unknown',
|
|
75
|
+
shipLand.LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH, shipLand.LAND_BAIL_NO_TASK_BRANCH];
|
|
76
|
+
assert.deepEqual(missing(ids.map((id) => `ship:${id}`)), []);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── the words ─────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
// Beyond the scorer's list: the git/GitHub nouns these particular failures are
|
|
82
|
+
// made of, which is exactly what the card exists to keep out of the first line.
|
|
83
|
+
const GIT_NOUNS = ['branch', 'pull request', 'commit', 'merge conflict', 'CI', 'SHA', 'diff', 'PAT', 'origin'];
|
|
84
|
+
|
|
85
|
+
test('no card speaks in the vocabulary it exists to translate', () => {
|
|
86
|
+
const banned = [...JARGON_TERMS, ...GIT_NOUNS];
|
|
87
|
+
const hits = [];
|
|
88
|
+
for (const [code, card] of Object.entries(CARDS)) {
|
|
89
|
+
for (const field of ['happened', 'next', 'person']) {
|
|
90
|
+
for (const term of banned) {
|
|
91
|
+
const re = new RegExp(`(^|[^A-Za-z])${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z]|$)`, /^[A-Z+-]+$/.test(term) ? '' : 'i');
|
|
92
|
+
if (re.test(card[field] || '')) hits.push(`${code}.${field}: "${term}"`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
assert.deepEqual(hits, []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('every card is complete, and short enough to be read', () => {
|
|
100
|
+
for (const [code, card] of Object.entries(CARDS)) {
|
|
101
|
+
assert.ok(card.happened && card.next, `${code} needs happened + next`);
|
|
102
|
+
assert.equal(typeof card.by_design, 'boolean', `${code}.by_design`);
|
|
103
|
+
assert.ok(card.person === null || typeof card.person === 'string', `${code}.person is null or who`);
|
|
104
|
+
for (const f of ['happened', 'next']) assert.ok(card[f].length <= 230, `${code}.${f} is ${card[f].length} chars`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the restrictions that must never be worked around are marked by_design', () => {
|
|
109
|
+
for (const code of ['REBASE_REQUIRED', 'INSUFFICIENT_RANK', 'rank_too_low_for_task', 'session_already_has_active_claim',
|
|
110
|
+
'main_checkout_claim', 'ship:protected_branch', 'ship:auth', 'box_scope', 'no_push_credential', 'grade_refused_authorization']) {
|
|
111
|
+
assert.equal(cardFor(code)?.by_design, true, code);
|
|
112
|
+
}
|
|
113
|
+
assert.match(cardFor('no_push_credential').next, /never ask/i);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── the rendering, and the seams that print it ────────────────────────────────
|
|
117
|
+
|
|
118
|
+
test('plainCardLines renders four labelled lines under the marker, and nothing for an unknown code', () => {
|
|
119
|
+
const lines = plainCardLines('ALREADY_CLAIMED');
|
|
120
|
+
assert.ok(lines[0].includes(PLAIN_MARKER));
|
|
121
|
+
assert.equal(lines.length, 5);
|
|
122
|
+
assert.deepEqual(plainCardLines('never_heard_of_it'), []);
|
|
123
|
+
assert.deepEqual(plainCardLines(undefined), []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a card interpolates nothing — it is static text, so it adds no injection surface', () => {
|
|
127
|
+
for (const card of Object.values(CARDS)) assert.ok(!/\$\{|%s|<[a-z_]+>/.test(JSON.stringify(card)));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a refused claim prints the engineer text first and the card after it', () => {
|
|
131
|
+
const r = { status: 409, data: { error: { code: 'REBASE_REQUIRED', message: 'x', details: { rebaseTasks: [{ id: 7, reason: 'strand:pr_not_landed' }] } } } };
|
|
132
|
+
const { lines, exit } = formatClaimFailure(r, 42);
|
|
133
|
+
assert.equal(exit, 1);
|
|
134
|
+
assert.match(lines[0], /queue is gated/);
|
|
135
|
+
const at = lines.findIndex((l) => l.includes(PLAIN_MARKER));
|
|
136
|
+
assert.ok(at > 0, 'card follows the existing guidance');
|
|
137
|
+
assert.ok(lines.slice(at).join('\n').includes(CARDS.REBASE_REQUIRED.happened));
|
|
138
|
+
assert.ok(lines.join('\n').includes(CARDS['strand:pr_not_landed'].happened), 'the flagged reason is translated too');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an unmapped claim code still renders, just without a card', () => {
|
|
142
|
+
const { lines } = formatClaimFailure({ status: 500, data: { error: { code: 'SOMETHING_NEW', message: 'm' } } }, 1);
|
|
143
|
+
assert.ok(lines.length >= 1 && !lines.join('\n').includes(PLAIN_MARKER));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a grader outage and a failed grade each carry their own card', () => {
|
|
147
|
+
const outage = formatGradeOutcome({ passed: false, signals: { panel_outcome: 'unavailable' } }, 9);
|
|
148
|
+
assert.ok(outage.includes(CARDS.grade_unavailable.happened));
|
|
149
|
+
const fail = formatGradeOutcome({ passed: false, score: 4, threshold: 7, issues: [] }, 9);
|
|
150
|
+
assert.ok(fail.includes(CARDS.grade_failed.happened));
|
|
151
|
+
assert.ok(!formatGradeOutcome({ passed: true, score: 9, threshold: 7 }, 9).includes(PLAIN_MARKER), 'a pass needs no card');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a red pull request line is followed by its card', () => {
|
|
155
|
+
const lines = shipLand.redPrLines({ reason: 'failing_check_unreadable', checks_unreadable: true });
|
|
156
|
+
assert.ok(lines[0].includes('RED') && lines.join('\n').includes(CARDS.failing_check_unreadable.happened));
|
|
157
|
+
assert.deepEqual(shipLand.redPrLines({ reason: 'review_required' }), []);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a person at a terminal gets no card — it is written in the agent\'s voice', () => {
|
|
161
|
+
assert.deepEqual(plainCardLines('REBASE_REQUIRED', { BONGOS_SURFACE: 'terminal' }), []);
|
|
162
|
+
assert.equal(plainCardLines('REBASE_REQUIRED', {}).length, 5);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the sign-in printers, the release refusals and the claim guard each print their card', () => {
|
|
166
|
+
const wired = {
|
|
167
|
+
'scripts/gds/cli-lib.js': ['session_invalid', 'box_scope', 'no_session'],
|
|
168
|
+
'scripts/gds/release.js': ['no_active_claim', 'CLAIM_SESSION_MISMATCH'],
|
|
169
|
+
'scripts/gds/claim.js': ['main_checkout_claim'],
|
|
170
|
+
'scripts/gds/ship-land.js': ['land_pending', 'reconcile_blocked'],
|
|
171
|
+
'scripts/gds/push-path-brief.js': ['no_push_credential'],
|
|
172
|
+
};
|
|
173
|
+
for (const [file, codes] of Object.entries(wired)) {
|
|
174
|
+
const src = read(file);
|
|
175
|
+
for (const code of codes) {
|
|
176
|
+
assert.ok(src.includes(`plainCardLines('${code}')`), `${file} should print the ${code} card`);
|
|
177
|
+
assert.ok(cardFor(code), `${code} has no card`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('every way start.js can refuse or fail prints a card', () => {
|
|
183
|
+
const src = read('scripts/gds/start.js');
|
|
184
|
+
const codes = [...src.matchAll(/\.error === '([A-Za-z_]+)'/g)].map((m) => m[1]);
|
|
185
|
+
assert.ok(codes.includes('stage_required'), 'start.js no longer names its refusal — has the branch moved?');
|
|
186
|
+
assert.deepEqual(missing(codes), []);
|
|
187
|
+
for (const code of [...new Set(codes)]) assert.ok(src.includes(`plainCardLines('${code}')`), `start.js never prints the ${code} card`);
|
|
188
|
+
// The generic non-ok exit is the last way out; it must not be the one mute path.
|
|
189
|
+
const generic = src.slice(src.indexOf('if (!r.ok) {'));
|
|
190
|
+
assert.ok(generic.slice(0, generic.indexOf('process.exit(1)')).includes("plainCardLines('board_unavailable')"));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('no card is an orphan — each is printed by name, or belongs to a family a seam prints by code', () => {
|
|
194
|
+
const scripts = fs.readdirSync(new URL('../scripts/gds/', import.meta.url)).filter((f) => f.endsWith('.js') && f !== 'plain-cards.js');
|
|
195
|
+
const printed = new Set();
|
|
196
|
+
for (const f of scripts) for (const m of read(`scripts/gds/${f}`).matchAll(/plainCardLines\('([^']+)'\)/g)) printed.add(m[1]);
|
|
197
|
+
// Families reach plainCardLines through a variable: the claim refusal code, the
|
|
198
|
+
// flagged strand reason, the merge reason on a red pull request, `ship:${d.id}`.
|
|
199
|
+
const claimSrc = read('scripts/gds/claim.js') + read('modules/lifecycle/db-claims.js') + read('modules/lifecycle/routes/claims.js') + read('modules/lifecycle/goal-authz.js');
|
|
200
|
+
const mergeSrc = read('modules/lifecycle/github-push.js');
|
|
201
|
+
const byFamily = (code) => code.startsWith('strand:') || code.startsWith('ship:')
|
|
202
|
+
|| claimSrc.includes(`'${code}'`) || mergeSrc.includes(`'${code}'`) || mergeSrc.includes(`\`${code}:`);
|
|
203
|
+
const orphans = Object.keys(CARDS).filter((code) => !printed.has(code) && !byFamily(code));
|
|
204
|
+
assert.deepEqual(orphans, []);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the four lifecycle skills tell the agent how to use the card', () => {
|
|
208
|
+
for (const skill of ['builder-claim', 'builder-ship', 'builder-start', 'builder-release']) {
|
|
209
|
+
const md = read(`.claude/skills/${skill}/SKILL.md`);
|
|
210
|
+
assert.ok(md.includes(PLAIN_MARKER), `${skill} never mentions the ${PLAIN_MARKER} card`);
|
|
211
|
+
assert.match(md, /interaction profile/, `${skill} does not tie the card to the builder's profile`);
|
|
212
|
+
}
|
|
213
|
+
assert.match(read('.claude/skills/builder-claim/SKILL.md'), /never look for a way round it/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('the public CLI package ships the card file its scripts require', () => {
|
|
217
|
+
assert.ok(read('scripts/gds/build-cli-package.js').includes("'scripts/gds/plain-cards.js'"));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
console.log(`\nplain cards (task 1003989) tests: ${passed} passed, ${failed} failed`);
|
|
221
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
package/tests/provision.mjs
CHANGED
|
@@ -1219,6 +1219,21 @@ await ta('cmdRunIntents (dry-run) lists the pending queue, runs nothing', async
|
|
|
1219
1219
|
assert.equal(r.ran, 0);
|
|
1220
1220
|
});
|
|
1221
1221
|
|
|
1222
|
+
await ta('cmdRunIntents opens with the runner core version + path, so a journal grep shows a stale runner (task 1002722)', async () => {
|
|
1223
|
+
for (const apply of [false, true]) {
|
|
1224
|
+
const lines = [];
|
|
1225
|
+
const deps = {
|
|
1226
|
+
apply, log: (l) => lines.push(l), instanceRoot: '/no/such/instance',
|
|
1227
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1228
|
+
provisioning: { claimNextIntent: async () => null, claimNextOAuthManifest: async () => null },
|
|
1229
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }),
|
|
1230
|
+
};
|
|
1231
|
+
await P.cmdRunIntents(deps);
|
|
1232
|
+
const { REPO_ROOT } = require('../scripts/gds/provision-config.js');
|
|
1233
|
+
assert.equal(lines[0], `runner core ${P.coreVersionSafe()} from ${REPO_ROOT} (no installed core at /no/such/instance)`, `apply=${apply}`);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1222
1237
|
await ta('cmdRunIntents (--apply) claims → provisions → resolves the intent done', async () => {
|
|
1223
1238
|
let claims = 0; const resolved = [];
|
|
1224
1239
|
const deps = {
|
|
@@ -2240,6 +2255,9 @@ await ta('cmdRunIntents (--apply): an UNKNOWN intent action never falls through
|
|
|
2240
2255
|
assert.equal(resolved.length, 1);
|
|
2241
2256
|
assert.equal(resolved[0].state, 'error');
|
|
2242
2257
|
assert.ok(!cmds.some((c) => /systemctl (stop|disable)/.test(c)), 'no teardown commands ran');
|
|
2258
|
+
// task 1002722 (the refusal itself landed in task 1002692): the error names the action AND
|
|
2259
|
+
// the runner's core, so a stale runner is diagnosable from the intent row alone.
|
|
2260
|
+
assert.equal(resolved[0].err, `unknown_action 'oauth-manifest' — runner core ${P.coreVersionSafe()} does not know it (runner too old?); refusing to guess`);
|
|
2243
2261
|
});
|
|
2244
2262
|
|
|
2245
2263
|
console.log('\nprovisioning truthfulness (task 1002720 — QA-walk F15/F16/F19):');
|