@bongos/core 1.19.916 → 1.19.917
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 +46 -21
- package/docs/adr/0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md +148 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/self-host.md +1 -1
- package/migrations/core_252_claims_published_head_sha_idx.sql +33 -0
- package/modules/lifecycle/cascade.js +92 -4
- package/modules/lifecycle/db-ship.js +43 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/removal-derivation.js +332 -0
- package/modules/lifecycle/routes/lifecycle.js +24 -0
- package/modules/lifecycle/tests/cascade.mjs +21 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo.js +52 -0
- package/scripts/gds/provision.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/provision_account_identity.mjs +135 -0
- package/tests/removal_proposal.mjs +583 -0
package/modules/lifecycle/db.js
CHANGED
|
@@ -185,6 +185,7 @@ const {
|
|
|
185
185
|
getActiveClaimsForBuilderWithStale,
|
|
186
186
|
getBuildersRoster,
|
|
187
187
|
getClaimPublishedSha,
|
|
188
|
+
shippedTasksByPublishedSha,
|
|
188
189
|
listConfirmedTasksForReconcile,
|
|
189
190
|
mergeTask,
|
|
190
191
|
needsRebaseTasksForBuilder,
|
|
@@ -330,6 +331,7 @@ module.exports = {
|
|
|
330
331
|
listConfirmedTasksForReconcile, // task 1134 — server-owned publish reconciler feed
|
|
331
332
|
setClaimPublishedSha, // task 1547 — record the SERVER-pushed tip for per-task land proof
|
|
332
333
|
getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
|
|
334
|
+
shippedTasksByPublishedSha, // task 1004090 / BV2.R15 — the reverse read, for removal attribution
|
|
333
335
|
getActiveClaimsForBuilder,
|
|
334
336
|
moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
|
|
335
337
|
activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/removal-derivation.js — WHAT A SHIP DELETED, AND WHOSE WORK
|
|
4
|
+
// IT WAS (task 1004090 / BV2.R15, goal 1000095; ADR 0313).
|
|
5
|
+
//
|
|
6
|
+
// Brightness (R20) is the live share of a descent, so something has to be able to
|
|
7
|
+
// say that shipped work was later removed. Nothing recorded that: `shipped` is
|
|
8
|
+
// terminal, there is no reverted status, and `tasks.merged_into_task_id` is
|
|
9
|
+
// CHECK-constrained to abandoned rows. This file derives the signal.
|
|
10
|
+
//
|
|
11
|
+
// It produces a QUESTION, never a verdict. The rule in ./cascade.js files a task
|
|
12
|
+
// at backlog; a person confirms it (R16). Nothing here changes a status, and
|
|
13
|
+
// nothing here touches credits — ADR 0313 is explicit that the ledger is never
|
|
14
|
+
// rewritten, because refactors, renames and file moves all look like deletions to
|
|
15
|
+
// a diff and a derived-only signal would dim the sky continuously.
|
|
16
|
+
//
|
|
17
|
+
// ── WHY NOT THE SIGNAL THAT ALREADY EXISTS ──────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// `netNegativeBonusAmount` is the nearest existing thing and is unusable, for
|
|
20
|
+
// three reasons ADR 0313 sets out:
|
|
21
|
+
// • It has no TARGET. `removed > added` over a whole ship says that deletion
|
|
22
|
+
// happened, never what was deleted or whose work it was. Brightness needs the
|
|
23
|
+
// target; the boolean carries none.
|
|
24
|
+
// • It is CLIENT-SUPPLIED — it reads `signals.diff_stats` from the grade request
|
|
25
|
+
// body, and `grader-score.js` already documents defending against a forged
|
|
26
|
+
// `diff_stats`.
|
|
27
|
+
// • Brightness sits beside money. Deriving it from a forgeable, target-less
|
|
28
|
+
// counter would be wrong even where it was convenient.
|
|
29
|
+
//
|
|
30
|
+
// So the signal comes from the SERVER's own view of the merge instead. Landing is
|
|
31
|
+
// server-mediated (ADR 0055): the server holds the authority and can ask GitHub
|
|
32
|
+
// what the pull request actually changed, rather than be told by the box that
|
|
33
|
+
// built it.
|
|
34
|
+
|
|
35
|
+
const api = require('../../src/module-api');
|
|
36
|
+
const githubPush = require('./github-push');
|
|
37
|
+
|
|
38
|
+
// How many deleted paths one ship will be attributed — and therefore the HARD
|
|
39
|
+
// CEILING on GitHub calls this derivation can make per ship. Each path costs one
|
|
40
|
+
// commit-history request, and they run on the server's shared push credential,
|
|
41
|
+
// which the publish path itself depends on: spending that budget to answer a
|
|
42
|
+
// question nobody asked for would be a poor trade.
|
|
43
|
+
//
|
|
44
|
+
// A ship that removes more than this is a sweep — a directory move, a vendored
|
|
45
|
+
// drop, a module deletion — where the proposal is least useful and the cost is
|
|
46
|
+
// highest. Eight is deliberately small: a genuine "this ship removed someone's
|
|
47
|
+
// feature" is a handful of files, and beyond that the truncation note tells a
|
|
48
|
+
// reader to weigh the ship as a move rather than a removal.
|
|
49
|
+
//
|
|
50
|
+
// The cap is on the ATTRIBUTION, not the detection: the deletion count is still
|
|
51
|
+
// reported honestly so the proposal can say its list is partial.
|
|
52
|
+
const MAX_ATTRIBUTED_PATHS = 8;
|
|
53
|
+
|
|
54
|
+
// listCommitsForPath — the shas of the commits that touched ONE path, newest
|
|
55
|
+
// first. The read behind attribution: a deleted file is blamed back to the commit
|
|
56
|
+
// that introduced it, and that sha is resolved through `claims.published_head_sha`.
|
|
57
|
+
//
|
|
58
|
+
// IT LIVES HERE RATHER THAN IN github-push.js, which is where its siblings live,
|
|
59
|
+
// because that file sits ON the 1500-line ratchet ceiling — the same reason
|
|
60
|
+
// `pr-assign.js` was carved out of it, and this module's CLAUDE.md says so in as
|
|
61
|
+
// many words. Its only consumer is the derivation below.
|
|
62
|
+
//
|
|
63
|
+
// WHY THE API AND NOT `git log`. The server keeps no persistent clone — pushBundle
|
|
64
|
+
// works in a temp dir off a bundle and throws it away — so there is no working tree
|
|
65
|
+
// to blame in. GitHub holds the history. Same reason getPullRequestFiles reads the
|
|
66
|
+
// REST files API rather than diffing locally.
|
|
67
|
+
//
|
|
68
|
+
// `until` bounds the walk to commits at or before the merge being examined, so a
|
|
69
|
+
// LATER commit that re-added the path cannot be mistaken for the one that
|
|
70
|
+
// introduced it.
|
|
71
|
+
//
|
|
72
|
+
// Caps at ONE page of 100. Attribution only needs to find a published tip, and a
|
|
73
|
+
// path with more than 100 commits above the one that introduced it is old enough
|
|
74
|
+
// that the introducing claim almost certainly predates `published_head_sha`
|
|
75
|
+
// (migrations/171) — in which case the correct output is no attribution at all,
|
|
76
|
+
// which is exactly what an exhausted page yields. Paging further would spend rate
|
|
77
|
+
// limit to reach the same silence.
|
|
78
|
+
async function listCommitsForPath({ path: filePath, until = null }, deps = {}) {
|
|
79
|
+
// resolveToken is injectable for the same reason fetchImpl and repoInfo are:
|
|
80
|
+
// this function is the one place a rate-limit response is classified, and that
|
|
81
|
+
// classification has to be testable without a live credential.
|
|
82
|
+
const token = deps.resolveToken ? await deps.resolveToken(deps) : await githubPush.resolveToken(deps);
|
|
83
|
+
if (!token) throw new Error('PUSH_UNCONFIGURED');
|
|
84
|
+
if (typeof filePath !== 'string' || !filePath) throw new Error('BAD_PATH');
|
|
85
|
+
const info = deps.repoInfo || api.repoInfo;
|
|
86
|
+
if (!info || info.error || !info.owner || !info.repo) throw new Error('NO_REPO_INFO');
|
|
87
|
+
const doFetch = deps.fetchImpl || fetch;
|
|
88
|
+
const qs = new URLSearchParams({ path: filePath, per_page: '100' });
|
|
89
|
+
if (until) qs.set('until', String(until));
|
|
90
|
+
const res = await doFetch(
|
|
91
|
+
`https://api.github.com/repos/${info.owner}/${info.repo}/commits?${qs.toString()}`,
|
|
92
|
+
{ headers: githubPush.ghHeaders(token) }
|
|
93
|
+
);
|
|
94
|
+
// A path that has never existed is a 404 and an EMPTY history, not an error: the
|
|
95
|
+
// caller asks "who introduced this", and "nobody we can see" is an answer.
|
|
96
|
+
if (res.status === 404) return [];
|
|
97
|
+
// RATE LIMITING IS NOT A PER-PATH FAILURE — it is a statement about the whole
|
|
98
|
+
// credential, so it must not be retried and must not be walked past. GitHub
|
|
99
|
+
// signals it as 403 or 429 with the remaining count at zero (a 403 for any
|
|
100
|
+
// other reason keeps a non-zero remaining, or sends no header at all, and is
|
|
101
|
+
// treated as an ordinary failure). Thrown with a recognisable name so the
|
|
102
|
+
// caller can ABANDON the rest of the walk rather than spend the next seven
|
|
103
|
+
// requests learning the same thing.
|
|
104
|
+
if (res.status === 429 || res.status === 403) {
|
|
105
|
+
const remaining = res.headers && typeof res.headers.get === 'function'
|
|
106
|
+
? res.headers.get('x-ratelimit-remaining')
|
|
107
|
+
: null;
|
|
108
|
+
if (res.status === 429 || remaining === '0') {
|
|
109
|
+
const e = new Error('RATE_LIMITED');
|
|
110
|
+
e.rateLimited = true;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok) throw new Error(`STATUS_FETCH_FAILED ${res.status}`);
|
|
115
|
+
const commits = await res.json();
|
|
116
|
+
if (!Array.isArray(commits)) return [];
|
|
117
|
+
return commits.map((c) => c && c.sha).filter((sha) => typeof sha === 'string' && sha);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// The pure half
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
// WHAT COUNTS AS REMOVED. ADR 0313 names three exclusions, and each is a status
|
|
125
|
+
// the REST files API already distinguishes — which is exactly why the derivation
|
|
126
|
+
// reads that API rather than counting lines:
|
|
127
|
+
//
|
|
128
|
+
// • 'renamed' — a rename or move is NOT a removal. Git's own rename detection
|
|
129
|
+
// resolves it and the work still stands. This is the exclusion
|
|
130
|
+
// that matters most: a directory reorganisation would otherwise
|
|
131
|
+
// propose a removal for every file in it.
|
|
132
|
+
// • 'modified' — a rewrite in place is NOT a removal. Lines changing is how
|
|
133
|
+
// code lives, and this is where a line-count heuristic
|
|
134
|
+
// (`removed > added`) goes wrong.
|
|
135
|
+
// • 'added' / 'copied' / 'changed' — nothing was lost.
|
|
136
|
+
//
|
|
137
|
+
// Only 'removed' survives. The third ADR exclusion — a cold body is not a remnant
|
|
138
|
+
// — is not expressible here: it is enforced downstream by attributing solely to
|
|
139
|
+
// tasks that actually SHIPPED (see shippedTasksByPublishedSha), because an idea
|
|
140
|
+
// declined at triage never ignited and nothing was built to lose.
|
|
141
|
+
//
|
|
142
|
+
// Pure: hand it the files array and it answers. No network, no clock, no DB.
|
|
143
|
+
function deletedPathsFrom(files) {
|
|
144
|
+
if (!Array.isArray(files)) return [];
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
if (!f || typeof f !== 'object') continue;
|
|
148
|
+
// `deleted` is getPullRequestFiles' own mapping of status === 'removed'.
|
|
149
|
+
// Both are read, and they must AGREE: trusting `deleted` alone would follow a
|
|
150
|
+
// caller that set it by hand, and trusting `status` alone would miss a caller
|
|
151
|
+
// that shaped the row the other way. Where only one is present, it decides.
|
|
152
|
+
const status = typeof f.status === 'string' ? f.status.toLowerCase() : null;
|
|
153
|
+
const flagged = f.deleted === true;
|
|
154
|
+
const isRemoval = status ? status === 'removed' : flagged;
|
|
155
|
+
if (!isRemoval) continue;
|
|
156
|
+
if (typeof f.path !== 'string' || !f.path) continue;
|
|
157
|
+
out.push(f.path);
|
|
158
|
+
}
|
|
159
|
+
return [...new Set(out)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Group attributed paths by the task that published them, so the proposal reads
|
|
163
|
+
// as "this task lost these files" rather than a flat list a person has to sort.
|
|
164
|
+
// Pure. Insertion order is preserved, which keeps the rendered body stable for a
|
|
165
|
+
// given diff rather than reshuffling between runs.
|
|
166
|
+
function groupByTask(attributions) {
|
|
167
|
+
const byTask = new Map();
|
|
168
|
+
for (const a of Array.isArray(attributions) ? attributions : []) {
|
|
169
|
+
if (!a || a.taskId == null) continue;
|
|
170
|
+
const key = String(a.taskId);
|
|
171
|
+
if (!byTask.has(key)) byTask.set(key, { taskId: key, title: a.title || null, paths: [] });
|
|
172
|
+
const row = byTask.get(key);
|
|
173
|
+
if (a.path && !row.paths.includes(a.path)) row.paths.push(a.path);
|
|
174
|
+
}
|
|
175
|
+
return [...byTask.values()];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// The impure half — one network read per deleted path, then one DB read
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
// Resolve deleted paths to the shipped tasks that published them.
|
|
183
|
+
//
|
|
184
|
+
// For each path: ask GitHub which commits touched it up to the merge, then look
|
|
185
|
+
// those shas up against `claims.published_head_sha`. The OLDEST matching commit is
|
|
186
|
+
// taken as the one that introduced the file — the newest would attribute a file to
|
|
187
|
+
// whoever last edited it, which is a different and wrong question.
|
|
188
|
+
//
|
|
189
|
+
// `shipTaskId` is excluded: a ship that deletes its OWN earlier file is a builder
|
|
190
|
+
// tidying up inside one piece of work, not a removal of someone else's.
|
|
191
|
+
//
|
|
192
|
+
// Everything is best-effort per path. One path whose history cannot be read costs
|
|
193
|
+
// that path its attribution and nothing else — the other paths still resolve, and
|
|
194
|
+
// a total failure yields an empty list, which files no proposal at all.
|
|
195
|
+
async function attributeDeletions({ paths, shipTaskId, until = null }, deps = {}) {
|
|
196
|
+
const listCommits = deps.listCommitsForPath || listCommitsForPath;
|
|
197
|
+
const lookup = deps.shippedTasksByPublishedSha;
|
|
198
|
+
const errors = [];
|
|
199
|
+
if (typeof lookup !== 'function') return { attributions: [], errors: ['no sha lookup provided'] };
|
|
200
|
+
|
|
201
|
+
const wanted = (Array.isArray(paths) ? paths : []).slice(0, MAX_ATTRIBUTED_PATHS);
|
|
202
|
+
const perPath = [];
|
|
203
|
+
for (const path of wanted) {
|
|
204
|
+
try {
|
|
205
|
+
// Newest first from the API; reversed so the OLDEST visible commit wins.
|
|
206
|
+
const shas = await listCommits({ path, until });
|
|
207
|
+
perPath.push({ path, shas: [...(Array.isArray(shas) ? shas : [])].reverse() });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
// A rate-limit refusal ends the WALK, not just this path. Continuing would
|
|
210
|
+
// spend the remaining budget on requests already known to fail, on the same
|
|
211
|
+
// credential the publish path needs — the opposite of backing off. There is
|
|
212
|
+
// deliberately no retry and no sleep: this runs on the ship path, and
|
|
213
|
+
// waiting there to dim a star later is the wrong trade (ADR 0313).
|
|
214
|
+
if (e && e.rateLimited) {
|
|
215
|
+
errors.push('removal: GitHub rate limit reached — attribution abandoned for this ship');
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
errors.push(`removal: history for ${path} unreadable (${e && e.message})`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ONE database read for every path's shas together, rather than one per path.
|
|
223
|
+
let shaToTask;
|
|
224
|
+
try {
|
|
225
|
+
shaToTask = await lookup(perPath.flatMap((p) => p.shas));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { attributions: [], errors: [...errors, `removal: sha lookup failed (${e && e.message})`] };
|
|
228
|
+
}
|
|
229
|
+
if (!(shaToTask instanceof Map)) return { attributions: [], errors: [...errors, 'removal: sha lookup returned no map'] };
|
|
230
|
+
|
|
231
|
+
const attributions = [];
|
|
232
|
+
for (const { path, shas } of perPath) {
|
|
233
|
+
for (const sha of shas) {
|
|
234
|
+
const hit = shaToTask.get(sha);
|
|
235
|
+
if (!hit) continue;
|
|
236
|
+
if (shipTaskId != null && String(hit.taskId) === String(shipTaskId)) break;
|
|
237
|
+
attributions.push({ path, taskId: hit.taskId, title: hit.title, sha });
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { attributions, errors };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// THE WHOLE DERIVATION, as the one call the cascade context resolver makes.
|
|
245
|
+
//
|
|
246
|
+
// NON-BLOCKING IS THE CONTRACT, not a nicety. This runs on the path a ship
|
|
247
|
+
// already takes, and ADR 0313 says so in as many words: "a derivation that errors
|
|
248
|
+
// files nothing and never fails the merge. Landing work is more important than
|
|
249
|
+
// dimming a star on time." So every failure mode below returns a report rather
|
|
250
|
+
// than throwing, and the caller treats an empty `removals` as "file nothing".
|
|
251
|
+
async function deriveRemovalsForShip({ taskId, prNumber, until = null }, deps = {}) {
|
|
252
|
+
const empty = (errors) => ({ removals: [], deletedCount: 0, truncated: false, errors });
|
|
253
|
+
try {
|
|
254
|
+
if (prNumber == null) return empty(['removal: no pull request for this ship — nothing to read']);
|
|
255
|
+
|
|
256
|
+
const getFiles = deps.getPullRequestFiles
|
|
257
|
+
|| ((args) => githubPush.getPullRequestFiles(args));
|
|
258
|
+
|
|
259
|
+
let files;
|
|
260
|
+
try {
|
|
261
|
+
files = await getFiles({ number: Number(prNumber) });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return empty([`removal: PR files unreadable (${e && e.message})`]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const deleted = deletedPathsFrom(files);
|
|
267
|
+
if (!deleted.length) return empty([]);
|
|
268
|
+
|
|
269
|
+
const { attributions, errors } = await attributeDeletions(
|
|
270
|
+
{ paths: deleted, shipTaskId: taskId, until },
|
|
271
|
+
deps
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
removals: groupByTask(attributions),
|
|
276
|
+
deletedCount: deleted.length,
|
|
277
|
+
truncated: deleted.length > MAX_ATTRIBUTED_PATHS,
|
|
278
|
+
errors,
|
|
279
|
+
};
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return empty([`removal: ${e && e.message}`]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// removalCandidatesForTask — the whole resolver the ship listener wires, as ONE
|
|
286
|
+
// testable function rather than a closure inside the listener.
|
|
287
|
+
//
|
|
288
|
+
// IT LIVES HERE BECAUSE THE CLOSURE VERSION SHIPPED A DEAD FEATURE. Written
|
|
289
|
+
// inline in routes/lifecycle.js it read `pr.number` from findTaskPullRequest,
|
|
290
|
+
// which returns `pr_number` — so `prNumber` was null on every real ship,
|
|
291
|
+
// deriveRemovalsForShip short-circuited every time, and the rule could never
|
|
292
|
+
// fire in production. Every test injected `removalCandidates` or
|
|
293
|
+
// `getPullRequestFiles` directly, so the whole suite passed over a feature that
|
|
294
|
+
// did nothing: the parts agreed with each other and none of them was wired to
|
|
295
|
+
// the part that was wrong.
|
|
296
|
+
//
|
|
297
|
+
// A closure inside a listener registration is not reachable from a test without
|
|
298
|
+
// rebuilding the router and intercepting the registration. Naming it makes the
|
|
299
|
+
// field read testable, and leaves the listener a single line that cannot hide a
|
|
300
|
+
// second one.
|
|
301
|
+
//
|
|
302
|
+
// Resolving the PR here rather than inside deriveRemovalsForShip keeps THAT
|
|
303
|
+
// function free of the task->PR question, which github-push already answers.
|
|
304
|
+
// A task with no findable PR derives nothing, which files nothing.
|
|
305
|
+
async function removalCandidatesForTask(task, deps = {}) {
|
|
306
|
+
const empty = { removals: [], deletedCount: 0, truncated: false, errors: [] };
|
|
307
|
+
const findPr = deps.findTaskPullRequest
|
|
308
|
+
|| ((args) => githubPush.findTaskPullRequest(args));
|
|
309
|
+
let prNumber = null;
|
|
310
|
+
try {
|
|
311
|
+
const pr = await findPr({ taskId: task && task.id });
|
|
312
|
+
// `pr_number`, NOT `pr.number` — findTaskPullRequest returns
|
|
313
|
+
// { branch, pr_number, pr_state, merged }. Pinned by test, because reading
|
|
314
|
+
// the wrong field here is silent: it yields null, which is a legal "no PR"
|
|
315
|
+
// answer, so the feature simply never runs and nothing ever complains.
|
|
316
|
+
prNumber = pr && pr.pr_number != null ? pr.pr_number : null;
|
|
317
|
+
} catch (_) {
|
|
318
|
+
// Never let the PR lookup cost the ship. Same contract as everything below.
|
|
319
|
+
return empty;
|
|
320
|
+
}
|
|
321
|
+
return deriveRemovalsForShip({ taskId: task && task.id, prNumber }, deps);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
MAX_ATTRIBUTED_PATHS,
|
|
326
|
+
removalCandidatesForTask,
|
|
327
|
+
listCommitsForPath,
|
|
328
|
+
deletedPathsFrom,
|
|
329
|
+
groupByTask,
|
|
330
|
+
attributeDeletions,
|
|
331
|
+
deriveRemovalsForShip,
|
|
332
|
+
};
|
|
@@ -28,6 +28,7 @@ const api = require('../../../src/module-api');
|
|
|
28
28
|
const lifecycle = require('../lifecycle');
|
|
29
29
|
const db = require('../db');
|
|
30
30
|
const cascade = require('../cascade');
|
|
31
|
+
const removalDerivation = require('../removal-derivation');
|
|
31
32
|
|
|
32
33
|
// task 1003549 — the cross-role cascade listener. `task.shipped` already exists
|
|
33
34
|
// as a kernel event (emitted post-commit by the publish reconciler and the ship
|
|
@@ -78,6 +79,29 @@ function registerSeams() {
|
|
|
78
79
|
// HOLDS a deploy can never be read differently. At 'off' the artist-review
|
|
79
80
|
// rule files nothing.
|
|
80
81
|
artistGateMode: () => api.artistGate.artistGateMode(),
|
|
82
|
+
// WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). The derivation
|
|
83
|
+
// asks GitHub what the pull request actually changed and blames the deleted
|
|
84
|
+
// paths back through `claims.published_head_sha` — the SERVER's own view of
|
|
85
|
+
// the merge, never the client-supplied `diff_stats` the net-negative bonus
|
|
86
|
+
// reads, which is target-less and already known-forgeable.
|
|
87
|
+
//
|
|
88
|
+
// Resolving the PR number here rather than inside the derivation keeps that
|
|
89
|
+
// file free of the task->PR question, which github-push already answers.
|
|
90
|
+
// A task with no findable PR derives nothing, which files nothing.
|
|
91
|
+
//
|
|
92
|
+
// Every failure is swallowed into an empty result: this runs on the ship
|
|
93
|
+
// path, and ADR 0313 is explicit that landing work matters more than dimming
|
|
94
|
+
// a star on time.
|
|
95
|
+
//
|
|
96
|
+
// The resolver itself is `removalCandidatesForTask`, NOT a closure here. It
|
|
97
|
+
// was a closure, and it read the wrong field off findTaskPullRequest
|
|
98
|
+
// (`number` for `pr_number`), which made the whole rule dead in production
|
|
99
|
+
// while every test passed — because every test injected this dep. A closure
|
|
100
|
+
// inside a listener registration is not reachable from a test; a named
|
|
101
|
+
// function is, and this line is now too small to hide a bug of its own.
|
|
102
|
+
removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
|
|
103
|
+
shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
|
|
104
|
+
}),
|
|
81
105
|
log: api.logger ? api.logger('lifecycle') : null,
|
|
82
106
|
}));
|
|
83
107
|
}
|
|
@@ -194,15 +194,32 @@ const shippedCopy = (over = {}) => ({
|
|
|
194
194
|
...over,
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
-
await test('the artist review is
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
197
|
+
await test('the artist review is keyed and disciplined, and the table stays well-formed', () => {
|
|
198
|
+
// This pinned CASCADES.length === 1 until task 1004090 appended the removal
|
|
199
|
+
// proposal (BV2.R15). A count is the wrong pin for a table whose whole contract
|
|
200
|
+
// is "extend by appending an entry" (ADR 0242) — it fails on the one change it
|
|
201
|
+
// is meant to permit, and says nothing about WHICH rules are there.
|
|
202
|
+
//
|
|
203
|
+
// So it pins the rule by KEY instead, and pins the SET of keys separately below:
|
|
204
|
+
// a rule vanishing and a rule being added are different events and should fail
|
|
205
|
+
// differently.
|
|
206
|
+
const r = cascade.CASCADES.find((x) => x.key === 'artist-review-on-ship');
|
|
207
|
+
assert.ok(r, 'the artist review rule is gone from CASCADES');
|
|
201
208
|
assert.equal(r.discipline, 'artist', 'the review is FOR the artist, whatever role shipped the work');
|
|
202
209
|
assert.equal(r.kind, 'unclassified', 'a valid kind — not the invalid "chore" the spec used to default to');
|
|
203
210
|
assert.deepEqual(cascade.validateCascades(), [], 'and the shipped table is well-formed');
|
|
204
211
|
});
|
|
205
212
|
|
|
213
|
+
await test('the production rule set is exactly what is declared', () => {
|
|
214
|
+
// The other half of the pin above: adding a rule is a deliberate act, so it
|
|
215
|
+
// should have to be recorded here. Duplicate keys would collide on source_ref
|
|
216
|
+
// and silently suppress one another, which validateCascades already refuses.
|
|
217
|
+
assert.deepEqual(
|
|
218
|
+
cascade.CASCADES.map((r) => r.key).sort(),
|
|
219
|
+
['artist-review-on-ship', 'removal-proposed-on-ship'],
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
206
223
|
await test('a copy/visual ship generates the two-step artist review', () => {
|
|
207
224
|
const { matched } = cascade.cascadesFor(shippedCopy());
|
|
208
225
|
assert.equal(matched.length, 1);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.917",
|
|
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.917",
|
|
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.917",
|
|
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",
|
|
@@ -201,6 +201,56 @@ function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
|
|
|
201
201
|
return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// Can this plane give a child instance its OWN unix account AT ALL? (task 1004018, ADR 0317)
|
|
205
|
+
//
|
|
206
|
+
// ensureInstanceUserCmd above needs uid 0 — directly, or through the `privileged` sudo
|
|
207
|
+
// prefix. The self-host image runs `USER node` and installs no sudo, so there it cannot
|
|
208
|
+
// run, and every child on that plane would share one uid: exactly the /proc/<pid>/environ
|
|
209
|
+
// vector ADR 0281 exists to close. Probed, not configured — a runner either reaches root
|
|
210
|
+
// on this plane or it does not, and an operator who wants provisioning to proceed needs
|
|
211
|
+
// privilege, not a flag that says to pretend.
|
|
212
|
+
//
|
|
213
|
+
// The probe runs the SAME escalation the mutation will, so a pass is evidence rather than
|
|
214
|
+
// inference. Read-only, no mutation, and no pipeline: a pipeline's exit status is the last
|
|
215
|
+
// stage's, which would erase the difference below.
|
|
216
|
+
//
|
|
217
|
+
// A shell that RAN AND ANSWERED is the only failure that proves the plane cannot. Under
|
|
218
|
+
// remote exec boxExec is `ssh <host> bash -s`, so an unreachable co-hosting box throws
|
|
219
|
+
// here too — and answering that with "this plane cannot create accounts, provision
|
|
220
|
+
// elsewhere" would be a confident wrong diagnosis that burns the intent's retry budget on
|
|
221
|
+
// a network blip. Exit 1 (not root) and 127 (no sudo binary) are read as proof; ssh
|
|
222
|
+
// reports its own failures as 255, so both still mean what they say across the hop.
|
|
223
|
+
const ACCOUNT_PROOF_STATUS = new Set([1, 127, 9009]);
|
|
224
|
+
function accountPlaneGap(exec, { privileged = false } = {}) {
|
|
225
|
+
// sh -c, not a bare test: under sudo the outer (unprivileged) shell would expand
|
|
226
|
+
// $(id -u) before sudo ever ran, and the probe would answer about the wrong user.
|
|
227
|
+
const cmd = `${privileged ? 'sudo ' : ''}sh -c 'test "$(id -u)" = 0'`;
|
|
228
|
+
try { exec(cmd); return null; } catch (e) {
|
|
229
|
+
// e.stderr is a zero-length Buffer when the command failed SILENTLY — truthy, and
|
|
230
|
+
// String()s to '' — so it has to be length-checked or it shadows the only cause there
|
|
231
|
+
// is. `test` exiting 1 writes nothing, and that is this probe's commonest refusal.
|
|
232
|
+
const stderr = e && e.stderr && e.stderr.length ? e.stderr : null;
|
|
233
|
+
const why = String(stderr || (e && e.message) || e).trim().slice(0, 200) || `exit status ${e && e.status}`;
|
|
234
|
+
if (!ACCOUNT_PROOF_STATUS.has(e && e.status)) {
|
|
235
|
+
return `could not confirm the runner can create a per-instance unix account on this plane: '${cmd}' failed (${why}). Every instance is given its own account, so provisioning refuses rather than assume — re-run once the box answers.`;
|
|
236
|
+
}
|
|
237
|
+
return `this plane cannot create a per-instance unix account, so every child instance would run as one uid and could read its siblings' secrets: '${cmd}' failed (${why}). Provision from a host where the runner is root or holds sudo (PROVISION_SUDO=1) — a shared uid is the isolation ADR 0281 exists to provide, not a detail.`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The preflight around accountPlaneGap: the narration its sibling preflights carry,
|
|
242
|
+
// including the dry-run line without which the plan prints step 1c's useradd as though it
|
|
243
|
+
// will work. Returns the refusal, or null to proceed. `dedicated` skips it — that account
|
|
244
|
+
// is created by cloud-init, which runs as root. Lives here beside the account it guards.
|
|
245
|
+
function accountPreflight({ exec, apply, log, shape, privileged = false, probe = accountPlaneGap }) {
|
|
246
|
+
if (shape === 'dedicated') return null;
|
|
247
|
+
if (!apply) { log(' [preflight] would probe whether the runner can create this instance\'s own unix account'); return null; }
|
|
248
|
+
const missing = probe(exec, { privileged });
|
|
249
|
+
if (missing) return `Account preflight failed: ${missing}`;
|
|
250
|
+
log(' [preflight] the runner can create the instance\'s own unix account ✓');
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
204
254
|
// Create-or-rotate the instance's PG login role and hand it its OWN database — and
|
|
205
255
|
// nothing else. Three statements, one psql, ON_ERROR_STOP:
|
|
206
256
|
// 1. CREATE ROLE ... LOGIN PASSWORD, or ALTER ... PASSWORD when it already exists (the
|
|
@@ -1020,6 +1070,8 @@ async function refreshStandaloneCorePin(inst, deps) {
|
|
|
1020
1070
|
|
|
1021
1071
|
module.exports = {
|
|
1022
1072
|
APP_USER,
|
|
1073
|
+
accountPlaneGap,
|
|
1074
|
+
accountPreflight,
|
|
1023
1075
|
CORE_PKG_DIR,
|
|
1024
1076
|
INSTANCE_GITIGNORE,
|
|
1025
1077
|
alreadyScaffolded,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
|
|
|
50
50
|
const crypto = require('node:crypto');
|
|
51
51
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
|
-
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
53
|
+
const { APP_USER, accountPreflight, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
55
|
const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
const { resolveProcessManager } = require('./provision-process.js');
|
|
@@ -276,6 +276,12 @@ async function provisionInstance(inst, deps) {
|
|
|
276
276
|
// skipped 0303 entirely, reached step 6 and died on the snippet write.
|
|
277
277
|
const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap });
|
|
278
278
|
if (ingressGap) return { ok: false, error: ingressGap };
|
|
279
|
+
// Account preflight (task 1004018, ADR 0317). The third and last PLANE question — can this
|
|
280
|
+
// box give the child its own unix account? — before 0303 below asks the per-DOMAIN one. A
|
|
281
|
+
// plane that cannot would run every child as one uid, the ADR 0281 vector, and step 1c's
|
|
282
|
+
// useradd would crash mid-provision instead of refusing.
|
|
283
|
+
const accountGap = accountPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, privileged, probe: deps.accountPlaneGap });
|
|
284
|
+
if (accountGap) return { ok: false, error: accountGap };
|
|
279
285
|
// Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
|
|
280
286
|
// can never certify this domain, so refuse before touching it. Injectable like the DNS check.
|
|
281
287
|
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
|
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.917'; // 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');
|