@bongos/core 1.19.292 → 1.19.294
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 +45 -20
- package/docs/adr/0184-ship-requires-an-assurance.md +106 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +13 -1
- package/docs/api-reference.md +1 -1
- package/modules/lifecycle/db.js +10 -18
- package/modules/lifecycle/publish-reconciler.js +8 -1
- package/modules/lifecycle/routes/tasks.js +8 -2
- package/modules/lifecycle/ship-assurance.js +159 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship.js +6 -4
- package/scripts/gds/strand-watch.js +265 -0
- package/src/module-api.js +5 -1
- package/tests/orphaned_claim_ship.mjs +9 -4
- package/tests/pay_on_land.mjs +9 -4
- package/tests/ship_assurance.mjs +188 -0
- package/tests/strand_watch.mjs +210 -0
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.294",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.294",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.294",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/ship.js
CHANGED
|
@@ -1193,7 +1193,8 @@ function shouldBailOnFetchFailure(fetchedMain, deployMode, via) {
|
|
|
1193
1193
|
// `n` the command that recovers it.
|
|
1194
1194
|
// Only landBailed makes the card say NOT landed. Purely additive: no bail site
|
|
1195
1195
|
// changes what it DOES, only what it now reports back.
|
|
1196
|
-
|
|
1196
|
+
// ADR 0184: an EMPTY ship has no commit to prove, so it must DECLARE itself.
|
|
1197
|
+
function landed(o = {}) { return { ok: true, pending: false, reason: null, nextStep: null, noArtifact: o.noArtifact === true, noArtifactReason: o.noArtifactReason || null }; }
|
|
1197
1198
|
function landPending() { return { ok: false, pending: true, reason: null, nextStep: null }; }
|
|
1198
1199
|
function landBailed(reason, nextStep = null) { return { ok: false, pending: false, reason, nextStep }; }
|
|
1199
1200
|
|
|
@@ -2534,7 +2535,7 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
2534
2535
|
if (dbOnly && shellOk('git fetch origin main')
|
|
2535
2536
|
&& zeroCommitVerdict(gitOk('git rev-list origin/main..HEAD --count'), true) === 'db-only') {
|
|
2536
2537
|
console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) from the main checkout - nothing to merge; advancing to shipped without merge/deploy.');
|
|
2537
|
-
return landed();
|
|
2538
|
+
return landed({ noArtifact: true, noArtifactReason: 'empty ship from the main checkout — no commits to merge' });
|
|
2538
2539
|
}
|
|
2539
2540
|
console.log(` auto-merge: skipped (current branch is ${branch})`);
|
|
2540
2541
|
// The merge source IS the current branch, so on main there is nothing to
|
|
@@ -2599,7 +2600,7 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
2599
2600
|
}
|
|
2600
2601
|
if (verdict === 'db-only') {
|
|
2601
2602
|
console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) - no commits ahead of main; advancing to shipped without merge/deploy.');
|
|
2602
|
-
return landed();
|
|
2603
|
+
return landed({ noArtifact: true, noArtifactReason: 'empty ship — no commits ahead of main' });
|
|
2603
2604
|
}
|
|
2604
2605
|
|
|
2605
2606
|
// #679 / ADR 0042: in 'ci' deploy mode the operator's machine never merges or
|
|
@@ -3236,7 +3237,8 @@ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOn
|
|
|
3236
3237
|
// task that never landed.
|
|
3237
3238
|
if (!merged.ok) return merged;
|
|
3238
3239
|
|
|
3239
|
-
const
|
|
3240
|
+
const shipBody = merged.noArtifact ? { no_artifact: true, no_artifact_reason: merged.noArtifactReason || 'deliberate empty ship (--db-only)' } : {}; // ADR 0184
|
|
3241
|
+
const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: shipBody });
|
|
3240
3242
|
if (!ship.ok) {
|
|
3241
3243
|
// task 1049 / 1002809: the server may have already flipped this task to
|
|
3242
3244
|
// shipped (reconcile-on-read, or the reconciler racing this call). Verify
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/strand-watch.js — say out loud that a ship got stuck (task 1003163).
|
|
5
|
+
//
|
|
6
|
+
// THE GAP. Nothing watched for a stranded ship. On 2026-08-22 a manual status sweep found
|
|
7
|
+
// task 1002483 parked at `completed` for THREE DAYS carrying 651 lines of tested work on a
|
|
8
|
+
// local branch, task 1002792 parked two days behind it, and task 1002459 sitting at
|
|
9
|
+
// `confirmed` for FOUR DAYS with no artifact anywhere. Not one of the three had produced a
|
|
10
|
+
// single signal; all were found by accident.
|
|
11
|
+
//
|
|
12
|
+
// WHY land-watch.js IS NOT THIS (it looks like it should be). land-watch.js renders the
|
|
13
|
+
// SHIPPED banner after an async CI land (task 1687). It cannot detect a strand: it reads
|
|
14
|
+
// machine-local breadcrumbs that ship.js writes only at CONFIRMED-exit — so a task parked at
|
|
15
|
+
// `completed` never gets one — it DROPS a breadcrumb after 30 minutes by design, and it is
|
|
16
|
+
// per-machine, so a strand on a box nobody reopens is invisible forever.
|
|
17
|
+
//
|
|
18
|
+
// THE SIGNAL ALREADY EXISTS. This is the part worth knowing: the publish-reconciler ALREADY
|
|
19
|
+
// diagnoses the worst case and already writes it down. Task 1002459 has carried
|
|
20
|
+
// `needs_rebase_reason = 'strand:land_not_proven'` since 2026-08-18 — the reconciler probed
|
|
21
|
+
// it, could not prove a land, gave up, and recorded exactly that. Nobody ever read the field.
|
|
22
|
+
// So this module is not a detector. It is the READOUT of a diagnosis the system performs and
|
|
23
|
+
// then discards, which is why it is small.
|
|
24
|
+
//
|
|
25
|
+
// SHAPE COPIED FROM deploy-divergence.js, deliberately. That check was written after the
|
|
26
|
+
// ten-day staleness incident (blocker 1000097 / task 1002521), is wired into SessionStart in
|
|
27
|
+
// .claude/settings.json, and on 2026-08-22 it reported the stale pin in the first message of
|
|
28
|
+
// the session — which is how that whole day's work started. A cheap check, on a path that
|
|
29
|
+
// already runs, reporting loudly, beats a monitoring subsystem that can itself silently stop.
|
|
30
|
+
// So: fail-open, bounded, and SILENT when the board is clean.
|
|
31
|
+
//
|
|
32
|
+
// Usage:
|
|
33
|
+
// node scripts/gds/strand-watch.js # human-readable report
|
|
34
|
+
// node scripts/gds/strand-watch.js --line # ONE line, or nothing; always exit 0
|
|
35
|
+
// node scripts/gds/strand-watch.js --json # machine shape
|
|
36
|
+
// node scripts/gds/strand-watch.js --hours N # override the `completed` threshold
|
|
37
|
+
//
|
|
38
|
+
// Exit codes: 0 always on --line (fail-open). Otherwise 0 clean, 1 strands found, 2 undetermined.
|
|
39
|
+
|
|
40
|
+
const { apiCall, arg, hasFlag } = require('./cli-lib');
|
|
41
|
+
|
|
42
|
+
// ---- thresholds -------------------------------------------------------------
|
|
43
|
+
// Reasoned, not magic. Written down because a future reader will want to retune them.
|
|
44
|
+
//
|
|
45
|
+
// `confirmed` means the grade PASSED and only the land is outstanding. The publish-reconciler
|
|
46
|
+
// sweeps every ~5 minutes, so one hour is twelve missed sweeps — already anomalous, while
|
|
47
|
+
// still leaving room for a slow CI queue or a transient GitHub failure.
|
|
48
|
+
const CONFIRMED_STALE_MS = 60 * 60 * 1000;
|
|
49
|
+
// `completed` means the grade did NOT pass. A builder may legitimately be mid-recovery for
|
|
50
|
+
// hours (see the /grade-recover loop), so this is deliberately generous: 12 hours means the
|
|
51
|
+
// strand survived a night, which no active recovery does. The three real cases were 2–3 DAYS.
|
|
52
|
+
const COMPLETED_STALE_MS = 12 * 60 * 60 * 1000;
|
|
53
|
+
|
|
54
|
+
// A reconciler-recorded strand needs NO threshold. The reconciler already probed every branch
|
|
55
|
+
// convention, failed to prove a land, and wrote the reason down; waiting longer cannot change
|
|
56
|
+
// that verdict, and task 1002459 proves the alternative is sitting for days.
|
|
57
|
+
const STRAND_REASON_PREFIX = 'strand:';
|
|
58
|
+
|
|
59
|
+
const WATCHED = ['completed', 'confirmed'];
|
|
60
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
61
|
+
// Page size per watched status. A queue past this would silently lose the OLDEST strands —
|
|
62
|
+
// exactly the class of quiet cap this whole check exists to abolish — so a full page is
|
|
63
|
+
// reported as `truncated` rather than passed off as complete. 200 is far above any healthy
|
|
64
|
+
// board (the real numbers were 1 and 2).
|
|
65
|
+
const PAGE_LIMIT = 200;
|
|
66
|
+
|
|
67
|
+
// ---- classification (pure — this is the unit-testable core) -----------------
|
|
68
|
+
|
|
69
|
+
// Task ids are numeric and reconciler reasons come from a fixed vocabulary, so both are safe
|
|
70
|
+
// to print. TITLES ARE NOT, and that omission is deliberate — see the trust-boundary note on
|
|
71
|
+
// `line()` below.
|
|
72
|
+
const safeId = (v) => (/^\d{1,19}$/.test(String(v)) ? String(v) : null);
|
|
73
|
+
const safeReason = (v) => (typeof v === 'string' && /^[a-z0-9_:.-]{1,64}$/i.test(v) ? v : null);
|
|
74
|
+
|
|
75
|
+
function humanAge(ms) {
|
|
76
|
+
if (!Number.isFinite(ms) || ms < 0) return 'unknown';
|
|
77
|
+
const h = ms / 3600000;
|
|
78
|
+
if (h < 1) return `${Math.max(1, Math.round(ms / 60000))}m`;
|
|
79
|
+
if (h < 48) return `${Math.round(h)}h`;
|
|
80
|
+
return `${Math.round(h / 24)}d`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Decide whether ONE task is a strand, and if so which shape and what fixes it. The two
|
|
84
|
+
// shapes need opposite responses, which is the whole reason to distinguish them:
|
|
85
|
+
// completed -> the GRADE parked it; re-grade or fix findings (/grade-recover).
|
|
86
|
+
// confirmed -> the grade passed and the LAND is outstanding (/merge-mode is the fallback).
|
|
87
|
+
// A reconciler-recorded strand is called out separately because it is terminal without a
|
|
88
|
+
// human: the automated healers have already refused it.
|
|
89
|
+
function classifyStrand(task, { nowMs, confirmedStaleMs = CONFIRMED_STALE_MS, completedStaleMs = COMPLETED_STALE_MS } = {}) {
|
|
90
|
+
const t = task || {};
|
|
91
|
+
const status = String(t.status || '');
|
|
92
|
+
if (!WATCHED.includes(status)) return null;
|
|
93
|
+
|
|
94
|
+
const id = safeId(t.id);
|
|
95
|
+
if (!id) return null;
|
|
96
|
+
|
|
97
|
+
const updated = Date.parse(t.updated_at || '');
|
|
98
|
+
const ageMs = Number.isFinite(updated) && Number.isFinite(nowMs) ? nowMs - updated : NaN;
|
|
99
|
+
|
|
100
|
+
const reason = safeReason(t.needs_rebase_reason);
|
|
101
|
+
const reconcilerGaveUp = status === 'confirmed'
|
|
102
|
+
&& t.needs_rebase === true
|
|
103
|
+
&& !!reason
|
|
104
|
+
&& reason.toLowerCase().startsWith(STRAND_REASON_PREFIX);
|
|
105
|
+
|
|
106
|
+
if (reconcilerGaveUp) {
|
|
107
|
+
return {
|
|
108
|
+
id, status, ageMs, age: humanAge(ageMs), shape: 'unprovable', reason,
|
|
109
|
+
remedy: 'check whether any artifact exists at all — a confirm with no branch and nothing on main was codeless',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const limit = status === 'confirmed' ? confirmedStaleMs : completedStaleMs;
|
|
114
|
+
// An unreadable updated_at must not manufacture a strand — fail QUIET on bad data here,
|
|
115
|
+
// because this text is injected into every session and a false alarm trains people to
|
|
116
|
+
// scroll past the real one.
|
|
117
|
+
if (!Number.isFinite(ageMs) || ageMs <= limit) return null;
|
|
118
|
+
|
|
119
|
+
return status === 'confirmed'
|
|
120
|
+
? { id, status, ageMs, age: humanAge(ageMs), shape: 'unlanded', reason: null,
|
|
121
|
+
remedy: '/merge-mode is the documented manual fallback' }
|
|
122
|
+
: { id, status, ageMs, age: humanAge(ageMs), shape: 'ungraded', reason: null,
|
|
123
|
+
remedy: '/grade-recover diagnoses which of the three shapes parked it' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sort worst-first so a truncated line shows the most urgent: reconciler-refused, then oldest.
|
|
127
|
+
const SHAPE_RANK = { unprovable: 0, unlanded: 1, ungraded: 2 };
|
|
128
|
+
function rankStrands(strands) {
|
|
129
|
+
return strands.slice().sort((a, b) => {
|
|
130
|
+
const d = (SHAPE_RANK[a.shape] ?? 9) - (SHAPE_RANK[b.shape] ?? 9);
|
|
131
|
+
return d !== 0 ? d : (b.ageMs || 0) - (a.ageMs || 0);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- fetch ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confirmedStaleMs, completedStaleMs, call = apiCall } = {}) {
|
|
138
|
+
// CONCURRENT, not sequential. This runs on the SessionStart path beside deploy-divergence,
|
|
139
|
+
// so a serial loop would double the latency the builder waits through for no reason.
|
|
140
|
+
const withTimeout = (p, label) => Promise.race([
|
|
141
|
+
p,
|
|
142
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)),
|
|
143
|
+
]).catch((e) => { throw new Error(`could not read ${label} tasks: ${e.message || e}`); });
|
|
144
|
+
|
|
145
|
+
let pages;
|
|
146
|
+
try {
|
|
147
|
+
pages = await Promise.all(WATCHED.map(async (status) => {
|
|
148
|
+
const res = await withTimeout(call('GET', `/api/gds/tasks?status=${status}&limit=${PAGE_LIMIT}`), status);
|
|
149
|
+
const body = res && (res.data || res);
|
|
150
|
+
const tasks = body && Array.isArray(body.tasks) ? body.tasks : null;
|
|
151
|
+
if (!tasks) throw new Error(`unexpected shape from /tasks?status=${status}`);
|
|
152
|
+
return { status, tasks };
|
|
153
|
+
}));
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return { undetermined: true, reason: e.message || String(e), strands: [], truncated: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const strands = [];
|
|
159
|
+
const truncated = [];
|
|
160
|
+
for (const { status, tasks } of pages) {
|
|
161
|
+
if (tasks.length >= PAGE_LIMIT) truncated.push(status);
|
|
162
|
+
for (const t of tasks) {
|
|
163
|
+
const s = classifyStrand(t, { nowMs, confirmedStaleMs, completedStaleMs });
|
|
164
|
+
if (s) strands.push(s);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { undetermined: false, ok: strands.length === 0 && !truncated.length, strands: rankStrands(strands), truncated };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- rendering --------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
// ONE line for SessionStart, or null. Null IS the silence: a clean board must say nothing,
|
|
173
|
+
// or the line becomes wallpaper and the real warning gets scrolled past.
|
|
174
|
+
//
|
|
175
|
+
// TRUST BOUNDARY — why no task titles. A task title is builder-authored free text, and this
|
|
176
|
+
// string is injected into an agent's context at the start of every session. Interpolating a
|
|
177
|
+
// title raw would hand anyone who can file a task a standing prompt-injection channel, which
|
|
178
|
+
// is the same reasoning deploy-divergence.js applies to the /version response it reads (it
|
|
179
|
+
// accepts only a strict semver for exactly this purpose). Ids and a fixed reason vocabulary
|
|
180
|
+
// carry all the signal that is needed and none of the risk — the agent can GET the task.
|
|
181
|
+
function line(r, { max = 3 } = {}) {
|
|
182
|
+
if (!r || r.undetermined) return null;
|
|
183
|
+
const trunc = Array.isArray(r.truncated) && r.truncated.length
|
|
184
|
+
? ` (the ${r.truncated.join(' and ')} queue${r.truncated.length > 1 ? 's' : ''} filled a full ${PAGE_LIMIT}-row page, so older strands may be unlisted)`
|
|
185
|
+
: '';
|
|
186
|
+
if (!r.strands.length) {
|
|
187
|
+
// A full page with no strand still means coverage was capped — say so rather than imply
|
|
188
|
+
// a clean board, which is the silent-cap failure this check is against.
|
|
189
|
+
return trunc ? `Strand check could not see the whole board${trunc}. Run: node scripts/gds/strand-watch.js` : null;
|
|
190
|
+
}
|
|
191
|
+
const shown = r.strands.slice(0, max)
|
|
192
|
+
.map((s) => `task ${s.id} (${s.status}, ${s.age}${s.reason ? `, ${s.reason}` : ''})`);
|
|
193
|
+
const more = (r.strands.length > shown.length ? ` +${r.strands.length - shown.length} more` : '') + trunc;
|
|
194
|
+
const worst = r.strands[0];
|
|
195
|
+
// Lead with what the WORST shape actually means. "work is finished but not landed" is true
|
|
196
|
+
// of a confirmed strand and false of a completed one (the grade parked that, so the work may
|
|
197
|
+
// not be finished at all) — one generic sentence for both would be wrong half the time.
|
|
198
|
+
const lead = {
|
|
199
|
+
unprovable: 'the reconciler already probed this and gave up, so it will never self-heal',
|
|
200
|
+
unlanded: 'the grade passed and only the land is outstanding',
|
|
201
|
+
ungraded: 'the grade parked this; it is not waiting on a land',
|
|
202
|
+
}[worst.shape] || 'parked mid-pipeline';
|
|
203
|
+
return `Stranded ship${r.strands.length === 1 ? '' : 's'}: ${shown.join('; ')}${more}. `
|
|
204
|
+
+ `Worst: ${lead} — ${worst.remedy}. `
|
|
205
|
+
+ 'A strand produces no signal of its own; this line is the only one.';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function report(r) {
|
|
209
|
+
if (!r) { console.error('strand-watch: no result'); return; }
|
|
210
|
+
if (r.undetermined) { console.error(`strand-watch: undetermined — ${r.reason}`); return; }
|
|
211
|
+
console.log('');
|
|
212
|
+
console.log(' Stranded ships');
|
|
213
|
+
console.log(' ──────────────────────────────────────────────────────────');
|
|
214
|
+
if (Array.isArray(r.truncated) && r.truncated.length) {
|
|
215
|
+
console.log(` ! coverage capped: the ${r.truncated.join(' and ')} queue(s) returned a full ${PAGE_LIMIT}-row page,`);
|
|
216
|
+
console.log(' so strands older than the cutoff are NOT listed below.');
|
|
217
|
+
}
|
|
218
|
+
if (r.ok) {
|
|
219
|
+
console.log(' ✓ none — nothing parked at completed or confirmed past its threshold.');
|
|
220
|
+
console.log('');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const s of r.strands) {
|
|
224
|
+
console.log(` task ${s.id} ${s.status.padEnd(9)} ${String(s.age).padStart(4)} ${s.shape}${s.reason ? ` (${s.reason})` : ''}`);
|
|
225
|
+
console.log(` ${s.remedy}`);
|
|
226
|
+
}
|
|
227
|
+
console.log('');
|
|
228
|
+
console.log(' "completed" means the grade parked it. "confirmed" means it passed and the');
|
|
229
|
+
console.log(' land never finished. Neither state produces a signal on its own.');
|
|
230
|
+
console.log('');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function main() {
|
|
234
|
+
const hours = Number(arg('--hours'));
|
|
235
|
+
const opts = Number.isFinite(hours) && hours > 0 ? { completedStaleMs: hours * 3600000 } : {};
|
|
236
|
+
|
|
237
|
+
// --line: the SessionStart shape. FAIL-OPEN — a strand is context for the agent, and an
|
|
238
|
+
// unreachable API is nothing at all; neither is a failure of the session this rides into.
|
|
239
|
+
// Note it sets exitCode and RETURNS rather than calling process.exit: an explicit exit here
|
|
240
|
+
// races libuv's teardown of the request socket and aborts the process on Windows/Node 24
|
|
241
|
+
// (the documented `UV_HANDLE_CLOSING` assertion — see cli-lib's cliExit note and
|
|
242
|
+
// deploy-divergence's identical comment).
|
|
243
|
+
if (hasFlag('--line')) {
|
|
244
|
+
try {
|
|
245
|
+
const one = line(await check(opts));
|
|
246
|
+
if (one) console.log(one);
|
|
247
|
+
} catch { /* no session, offline, unreadable — all silence */ }
|
|
248
|
+
process.exitCode = 0;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const r = await check(opts);
|
|
253
|
+
if (hasFlag('--json')) console.log(JSON.stringify(r, null, 2));
|
|
254
|
+
else report(r);
|
|
255
|
+
process.exitCode = r.undetermined ? 2 : (r.ok ? 0 : 1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (require.main === module) {
|
|
259
|
+
main().catch((e) => { console.error('strand-watch: failed —', e.message || e); process.exit(2); });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
check, line, report, classifyStrand, rankStrands, humanAge,
|
|
264
|
+
CONFIRMED_STALE_MS, COMPLETED_STALE_MS, STRAND_REASON_PREFIX, WATCHED, PAGE_LIMIT,
|
|
265
|
+
};
|
package/src/module-api.js
CHANGED
|
@@ -1011,8 +1011,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1011
1011
|
// landed since 1.19.290 with no explicit bump. run 32580353000. (task 1002620)
|
|
1012
1012
|
// 1.19.292 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1013
1013
|
// landed since 1.19.291 with no explicit bump. run 32582800234. (task 1002620)
|
|
1014
|
+
// 1.19.293 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1015
|
+
// landed since 1.19.292 with no explicit bump. run 32586174253. (task 1002620)
|
|
1016
|
+
// 1.19.294 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1017
|
+
// landed since 1.19.293 with no explicit bump. run 32588977219. (task 1002620)
|
|
1014
1018
|
// ---------------------------------------------------------------------------
|
|
1015
|
-
const CORE_VERSION = '1.19.
|
|
1019
|
+
const CORE_VERSION = '1.19.294'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1016
1020
|
|
|
1017
1021
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1018
1022
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -79,9 +79,14 @@ const idxOf = (queries, re) => queries.findIndex((q) => re.test(String(q.sql).re
|
|
|
79
79
|
const CLAIMS_CLOSE = /^UPDATE claims SET released_at = now\(\), outcome = COALESCE\(outcome, 'shipped'\) WHERE task_id = \$1 AND released_at IS NULL/;
|
|
80
80
|
const SHIP_TASK = /^UPDATE tasks SET status = 'shipped'/;
|
|
81
81
|
|
|
82
|
+
// task 1002483: shipTask now requires a landAssurance and is fail-closed.
|
|
83
|
+
// These tests exercise what happens AFTER the gate, so they pass the same
|
|
84
|
+
// 'proof' shape the reconciler and reconcile-on-read do.
|
|
85
|
+
const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
|
|
86
|
+
|
|
82
87
|
await test('ship heals a strand: open claim on the shipped task is closed', async () => {
|
|
83
88
|
const pool = fakePool({ confirmed: true, orphanClaimIds: [1292] });
|
|
84
|
-
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool });
|
|
89
|
+
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool });
|
|
85
90
|
|
|
86
91
|
// the ship itself succeeded and the shipRow is returned
|
|
87
92
|
assert.equal(result.id, TASK_ID);
|
|
@@ -95,7 +100,7 @@ await test('ship heals a strand: open claim on the shipped task is closed', asyn
|
|
|
95
100
|
|
|
96
101
|
await test('claims-close runs in-txn: after BEGIN + the ship UPDATE, before COMMIT', async () => {
|
|
97
102
|
const pool = fakePool({ confirmed: true, orphanClaimIds: [1292] });
|
|
98
|
-
await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool });
|
|
103
|
+
await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool });
|
|
99
104
|
const q = pool._client.queries;
|
|
100
105
|
|
|
101
106
|
const iBegin = idxOf(q, /^BEGIN$/);
|
|
@@ -113,7 +118,7 @@ await test('normal ship is a no-op safety net: UPDATE runs, nothing to close', a
|
|
|
113
118
|
// resolveClaim already closed the claim on the happy path — the UPDATE matches
|
|
114
119
|
// zero rows. The ship must still succeed and still issue the (harmless) UPDATE.
|
|
115
120
|
const pool = fakePool({ confirmed: true, orphanClaimIds: [] });
|
|
116
|
-
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool });
|
|
121
|
+
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool });
|
|
117
122
|
|
|
118
123
|
assert.equal(result.status, 'shipped');
|
|
119
124
|
const close = pool._client.queries.find((q) => CLAIMS_CLOSE.test(String(q.sql).replace(/\s+/g, ' ').trim()));
|
|
@@ -123,7 +128,7 @@ await test('normal ship is a no-op safety net: UPDATE runs, nothing to close', a
|
|
|
123
128
|
await test('rejected ship (not confirmed) never closes claims, and rolls back', async () => {
|
|
124
129
|
const pool = fakePool({ confirmed: false });
|
|
125
130
|
await assert.rejects(
|
|
126
|
-
() => shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool }),
|
|
131
|
+
() => shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool }),
|
|
127
132
|
(e) => e.code === 'TASK_NOT_CONFIRMED',
|
|
128
133
|
);
|
|
129
134
|
const q = pool._client.queries;
|
package/tests/pay_on_land.mjs
CHANGED
|
@@ -82,12 +82,17 @@ const RECONCILER = 999; // the builder_id the reconciler passes to shipTask
|
|
|
82
82
|
const WORKER = 25; // the claim holder who actually did the work
|
|
83
83
|
const TASK = { id: 660, title: 'fixture task' };
|
|
84
84
|
|
|
85
|
+
// task 1002483: shipTask now requires a landAssurance and is fail-closed.
|
|
86
|
+
// These tests exercise what happens AFTER the gate, so they pass the same
|
|
87
|
+
// 'proof' shape the reconciler and reconcile-on-read do.
|
|
88
|
+
const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
|
|
89
|
+
|
|
85
90
|
// =========================================================================
|
|
86
91
|
await test('ship: the per-task credit lands at SHIP (reason=task.shipped), attributed to the claim holder', async () => {
|
|
87
92
|
// shipped_by was set to the claim holder at confirm; the reconciler triggers
|
|
88
93
|
// the ship with its own builderId — attribution must still be the worker.
|
|
89
94
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, creditsReward: 15, kind: 'feature' });
|
|
90
|
-
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool });
|
|
95
|
+
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
91
96
|
|
|
92
97
|
assert.equal(out.status, 'shipped', 'task flips to shipped');
|
|
93
98
|
assert.equal(out.creditedBuilderId, WORKER, 'credit attributed to the claim holder, not the reconciler');
|
|
@@ -103,14 +108,14 @@ await test('ship: the per-task credit lands at SHIP (reason=task.shipped), attri
|
|
|
103
108
|
|
|
104
109
|
await test('ship: idempotent — a prior task.shipped credit means no second INSERT', async () => {
|
|
105
110
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, priorShipCredit: true });
|
|
106
|
-
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool });
|
|
111
|
+
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
107
112
|
assert.equal(out.creditsAwarded, 0, 'no fresh credit on a re-ship / reconciler re-sweep');
|
|
108
113
|
assert.equal(all(pool._client.queries, /INSERT INTO credit_log/).length, 0, 'must not double-insert credit');
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
await test('ship: credits_reward = 0 → no credit_log INSERT', async () => {
|
|
112
117
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, creditsReward: 0 });
|
|
113
|
-
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool });
|
|
118
|
+
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
114
119
|
assert.equal(out.creditsAwarded, 0, 'a zero-reward task pays nothing');
|
|
115
120
|
assert.equal(all(pool._client.queries, /INSERT INTO credit_log/).length, 0, 'no INSERT for a 0-reward task');
|
|
116
121
|
});
|
|
@@ -118,7 +123,7 @@ await test('ship: credits_reward = 0 → no credit_log INSERT', async () => {
|
|
|
118
123
|
await test('ship: not in confirmed state → throws TASK_NOT_CONFIRMED, awards nothing', async () => {
|
|
119
124
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, confirmed: false });
|
|
120
125
|
await assert.rejects(
|
|
121
|
-
() => shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool }),
|
|
126
|
+
() => shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool }),
|
|
122
127
|
(err) => err.code === 'TASK_NOT_CONFIRMED',
|
|
123
128
|
'must throw TASK_NOT_CONFIRMED when the UPDATE matches no confirmed row'
|
|
124
129
|
);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// tests/ship_assurance.mjs — the ship gate (task 1002483, goal 1000024).
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE. db.shipTask is where the ledger says work is delivered AND where the
|
|
4
|
+
// credit is booked (ADR 0120 pays on land). Its only precondition was
|
|
5
|
+
// `WHERE status = 'confirmed'` — nothing about whether any code reached main. The
|
|
6
|
+
// two AUTOMATED paths each proved the land before calling, but the MANUAL route
|
|
7
|
+
// (POST /tasks/:id/ship) called it with nothing. So a task confirmed with no
|
|
8
|
+
// pushed code could be flipped to shipped by its claim holder, or by any Metic+
|
|
9
|
+
// sweeping /merge-mode, and it booked its task.shipped credit.
|
|
10
|
+
//
|
|
11
|
+
// It went untracked for two months because its tracking task (1000900) was closed
|
|
12
|
+
// in June on a rationale asserting task 1001547 had delivered the confirm gate.
|
|
13
|
+
// It had not — blocker 1000047's own final update says so, and the code agreed
|
|
14
|
+
// with the blocker, not the closure note. That is why these tests assert against
|
|
15
|
+
// the CODE and not against anyone's account of it.
|
|
16
|
+
//
|
|
17
|
+
// THE GATE. shipTask now requires a `landAssurance` and is FAIL-CLOSED: no
|
|
18
|
+
// assurance, no ship. Two legitimate kinds:
|
|
19
|
+
// proof the caller verified this task's own commit is on main
|
|
20
|
+
// no_artifact a deliberate DB-only ship — an explicitly supported shape
|
|
21
|
+
// (CLAUDE.md, "Direct DB writes are work too") that task 1002478
|
|
22
|
+
// documents as a dead end below Archon. Naming it as a KIND is
|
|
23
|
+
// what stops this gate from making 1002478 worse: a no-artifact
|
|
24
|
+
// ship becomes a recorded answer rather than an absence.
|
|
25
|
+
//
|
|
26
|
+
// DB-free: the validator is pure, and the fail-closed refusal happens before any
|
|
27
|
+
// pool use, so shipTask can be called with no database at all.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/ship_assurance.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import url from 'node:url';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
39
|
+
const db = require(path.join(ROOT, 'modules/lifecycle/db.js'));
|
|
40
|
+
const { normalizeShipAssurance, SHIP_ASSURANCE_KINDS } = db;
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
async function t(name, fn) {
|
|
45
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
46
|
+
catch (e) { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log('the ship assurance — what counts as a shippable reason');
|
|
50
|
+
|
|
51
|
+
await t('a missing assurance is refused', () => {
|
|
52
|
+
for (const bad of [undefined, null, '', 0, false, [], 'proof']) {
|
|
53
|
+
const r = normalizeShipAssurance(bad);
|
|
54
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must not pass`);
|
|
55
|
+
assert.equal(r.code, 'ship_assurance_required');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t('an unknown kind is refused, and the refusal lists what is allowed', () => {
|
|
60
|
+
const r = normalizeShipAssurance({ kind: 'trust_me', reason: 'it landed, honest' });
|
|
61
|
+
assert.equal(r.ok, false);
|
|
62
|
+
assert.equal(r.code, 'ship_assurance_bad_kind');
|
|
63
|
+
assert.deepEqual(r.allowed, SHIP_ASSURANCE_KINDS);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await t('a reasonless assurance is refused — the ledger records WHY, not just that', () => {
|
|
67
|
+
for (const reason of [undefined, null, '', ' ', '\n\t ']) {
|
|
68
|
+
const r = normalizeShipAssurance({ kind: 'proof', reason });
|
|
69
|
+
assert.equal(r.ok, false, `${JSON.stringify(reason)} is not a reason`);
|
|
70
|
+
assert.equal(r.code, 'ship_assurance_reason_required');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await t('both legitimate kinds pass, and the reason is tidied', () => {
|
|
75
|
+
const p = normalizeShipAssurance({ kind: 'proof', reason: ' published tip abc1234 in main \n' });
|
|
76
|
+
assert.equal(p.ok, true);
|
|
77
|
+
assert.deepEqual(p.value, { kind: 'proof', reason: 'published tip abc1234 in main' });
|
|
78
|
+
|
|
79
|
+
const n = normalizeShipAssurance({ kind: 'no_artifact', reason: 'DB-only: widened a goal scope wall' });
|
|
80
|
+
assert.equal(n.ok, true);
|
|
81
|
+
assert.equal(n.value.kind, 'no_artifact');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await t('a runaway reason is capped rather than refused', () => {
|
|
85
|
+
const r = normalizeShipAssurance({ kind: 'proof', reason: 'x'.repeat(5000) });
|
|
86
|
+
assert.equal(r.ok, true, 'a long reason is still a reason — cap it, do not lose the ship');
|
|
87
|
+
assert.equal(r.value.reason.length, 500);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
console.log('\nshipTask is FAIL-CLOSED, before it touches a database');
|
|
91
|
+
|
|
92
|
+
await t('shipTask with NO assurance throws ship_assurance_required', async () => {
|
|
93
|
+
// No DB is configured here on purpose: the gate must refuse before any pool
|
|
94
|
+
// use, so a caller that cannot account for the ship never even opens a txn.
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
() => db.shipTask({ taskId: 1, builderId: 1 }),
|
|
97
|
+
(e) => e.code === 'ship_assurance_required',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await t('shipTask with a MALFORMED assurance throws too — not a silent default', async () => {
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
() => db.shipTask({ taskId: 1, builderId: 1, landAssurance: { kind: 'proof' } }),
|
|
104
|
+
(e) => e.code === 'ship_assurance_reason_required',
|
|
105
|
+
);
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
() => db.shipTask({ taskId: 1, builderId: 1, landAssurance: { kind: 'nope', reason: 'x'.repeat(20) } }),
|
|
108
|
+
(e) => e.code === 'ship_assurance_bad_kind',
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log('\nevery call site accounts for its ship');
|
|
113
|
+
|
|
114
|
+
// Source tripwires. The behavioural tests above prove the gate refuses; these
|
|
115
|
+
// prove nobody routes AROUND it. A new caller that forgets the assurance would
|
|
116
|
+
// fail at runtime — but only on the path someone exercises, which for a ship path
|
|
117
|
+
// can be weeks later and in production.
|
|
118
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
119
|
+
|
|
120
|
+
await t('all three production call sites pass a landAssurance', () => {
|
|
121
|
+
const sites = [
|
|
122
|
+
['modules/lifecycle/publish-reconciler.js', 'the reconciler sweep'],
|
|
123
|
+
['modules/lifecycle/routes/tasks.js', 'the manual ship route + reconcile-on-read'],
|
|
124
|
+
];
|
|
125
|
+
let found = 0;
|
|
126
|
+
for (const [rel, label] of sites) {
|
|
127
|
+
const src = read(rel);
|
|
128
|
+
for (const m of src.matchAll(/db\.shipTask\(\{([\s\S]{0,300}?)\}\)/g)) {
|
|
129
|
+
found++;
|
|
130
|
+
assert.match(m[1], /landAssurance/,
|
|
131
|
+
`${label} (${rel}) calls db.shipTask without a landAssurance — that is the hole reopening`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
assert.equal(found, 3, `expected 3 production shipTask call sites, found ${found}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await t('the manual ship path PROVES the land, and refuses when it cannot', () => {
|
|
138
|
+
// The derivation lives in ship-assurance.js (a sibling of ship-preflight), not
|
|
139
|
+
// in the already-oversized route file — so the assertions follow it there.
|
|
140
|
+
const src = read('modules/lifecycle/ship-assurance.js');
|
|
141
|
+
assert.match(src, /landProvenForTask/,
|
|
142
|
+
'the manual path must DERIVE a proof — it is the path that had none');
|
|
143
|
+
assert.match(src, /ship_land_not_proven/,
|
|
144
|
+
'and it must REFUSE when there is no proof and no declaration');
|
|
145
|
+
assert.match(src, /no_artifact/,
|
|
146
|
+
'with the deliberate no-artifact declaration as the only other way through');
|
|
147
|
+
|
|
148
|
+
// ORDER: prove first, declaration second. A declaration must never be able to
|
|
149
|
+
// override a proof that was available, and someone who pushed real work should
|
|
150
|
+
// never have to declare anything.
|
|
151
|
+
assert.ok(src.indexOf('landProvenForTask') < src.indexOf('body.no_artifact === true'),
|
|
152
|
+
'the proof must be attempted BEFORE the declaration is consulted');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('the route wires the derivation and answers its refusal verbatim', () => {
|
|
156
|
+
const src = read('modules/lifecycle/routes/tasks.js');
|
|
157
|
+
const i = src.indexOf("router.post('/tasks/:id/ship'");
|
|
158
|
+
assert.ok(i > -1);
|
|
159
|
+
const handler = src.slice(i, src.indexOf('router.', i + 20));
|
|
160
|
+
assert.match(handler, /gateManualShip/, 'the route must call the gate');
|
|
161
|
+
assert.match(handler, /if \(!landAssurance\) return;/,
|
|
162
|
+
'and must BAIL when the gate refused — the refusal is already on the wire');
|
|
163
|
+
// The assurance passed to the write must be the DERIVED value, never an inline
|
|
164
|
+
// literal — `{ kind: 'proof', reason: 'assumed' }` would satisfy the gate's shape
|
|
165
|
+
// while asserting a land nobody checked. (Found by injecting exactly that and
|
|
166
|
+
// watching an earlier draft of this test stay green.)
|
|
167
|
+
assert.match(handler, /db\.shipTask\(\{[^}]*landAssurance\s*\}\)/,
|
|
168
|
+
'the route must pass the DERIVED landAssurance variable, not an inline literal');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await t('the no-artifact declaration cannot be made silently', () => {
|
|
172
|
+
const src = read('modules/lifecycle/ship-assurance.js');
|
|
173
|
+
assert.match(src, /no_artifact_reason_required/,
|
|
174
|
+
'declaring "no artifact" without saying what was delivered must be refused');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await t('the CLI carries the declaration through its deliberate-empty path', () => {
|
|
178
|
+
// Without this, the gate would refuse exactly the --db-only / --allow-empty
|
|
179
|
+
// flow CLAUDE.md calls a supported shape — turning a correctness fix into a
|
|
180
|
+
// regression for the builders who rely on it.
|
|
181
|
+
const src = read('scripts/gds/ship.js');
|
|
182
|
+
assert.match(src, /no_artifact:\s*true/, 'ship.js must send the declaration on an empty land');
|
|
183
|
+
assert.match(src, /landed\(\{ noArtifact: true/,
|
|
184
|
+
'and the deliberate-empty land sites must mark themselves as such');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
188
|
+
process.exit(failed ? 1 : 0);
|