@bongos/core 1.19.882 → 1.19.884
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 +63 -58
- package/clients/bongos-client/index.d.ts +3 -3
- package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +10 -2
- package/docs/copy-inventory.md +24 -23
- package/docs/copy-registry.json +34 -25
- package/docs/file-map.md +1 -2
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/bongos-cli-release.md +1 -1
- package/docs/recipes/managed-settings-remote-control.md +1 -1
- package/migrations/core_248_module_enablement_overrides.sql +50 -0
- package/modules/dev-box/app/README.md +4 -3
- package/modules/hall-ui/public/collab-lib.js +33 -1
- package/modules/hall-ui/public/collab.css +33 -4
- package/modules/hall-ui/public/collab.html +9 -2
- package/modules/hall-ui/public/collab.js +68 -15
- package/modules/hall-ui/public/dom-utils.js +23 -1
- package/modules/lifecycle/routes/tasks.js +18 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-land.js +103 -14
- package/scripts/gds/ship-merge.js +28 -0
- package/scripts/gds/ship.js +8 -0
- package/scripts/hall-preview/server.js +38 -1
- package/src/bongos/module-overrides.js +117 -0
- package/src/bongos/routes/modules.js +56 -23
- package/src/module-api.js +1 -1
- package/src/modules.js +120 -14
- package/src/platform-server.js +47 -5
- package/tests/box_sweep_docs.mjs +7 -3
- package/tests/collab_page.mjs +136 -1
- package/tests/modules.mjs +110 -0
- package/tests/modules_route_wiring.mjs +103 -0
- package/tests/publish_branch_route.mjs +70 -2
- package/tests/ship_land_proof.mjs +218 -0
- package/docs/recipes/builder-box-lifecycle.md +0 -181
- package/docs/recipes/devbox-app-release.md +0 -103
- package/docs/recipes/devbox-app-windows-test.md +0 -128
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +0 -122
|
@@ -21,10 +21,36 @@
|
|
|
21
21
|
sees it. Four bands, four treatments: faint, soft, warn, danger. The 3px
|
|
22
22
|
coloured band the first cut painted down the row's left edge is gone — this
|
|
23
23
|
world marks state with a pill, never with a bar. */
|
|
24
|
+
/* THE CHIP ONLY WEARS ITS RING PAST THE THRESHOLD (artist verdict, task
|
|
25
|
+
1004041). All four bands still have their own ink, because that is what makes
|
|
26
|
+
the column readable at a glance and it is what the criterion asked for. What
|
|
27
|
+
changed is that the two CALM bands stop being pills: a ring around "just
|
|
28
|
+
asked" draws a chip on a row where nothing is wrong, so a board of healthy
|
|
29
|
+
asks opened as a wall of chips and the two bands that actually mean something
|
|
30
|
+
had no shape of their own left to mean it with. Faint and soft read as plain
|
|
31
|
+
quiet text; aging and stale keep the pill.
|
|
32
|
+
|
|
33
|
+
The WORDS carry the real age now (collab-lib ageLabel), so two stale rows no
|
|
34
|
+
longer read identically — which is the other half of the same verdict. */
|
|
24
35
|
.cb-age--fresh { color: var(--ink-faint); }
|
|
25
36
|
.cb-age--waiting { color: var(--ink-soft); }
|
|
26
37
|
.cb-age--aging { color: var(--warn); }
|
|
27
38
|
.cb-age--stale { color: var(--danger); font-weight: 600; }
|
|
39
|
+
.cb-age--fresh,
|
|
40
|
+
.cb-age--waiting {
|
|
41
|
+
border-color: transparent;
|
|
42
|
+
padding-left: 0;
|
|
43
|
+
padding-right: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ---- the figure strip -----------------------------------------------------
|
|
47
|
+
The shell's .stat-row, unchanged, except that this page's third tile holds
|
|
48
|
+
WORDS (the roles you answer for) rather than a figure. The shell's own
|
|
49
|
+
--badge modifier says that at 15px, but its phone block restates
|
|
50
|
+
.stat-tile__num at the same specificity and later in the sheet, so on a phone
|
|
51
|
+
the modifier loses and a list of role names sets at 28px. Two classes here
|
|
52
|
+
outrank both without touching the shell. */
|
|
53
|
+
.cb-figs .stat-tile__num--badge { font-size: 15px; letter-spacing: -0.02em; }
|
|
28
54
|
|
|
29
55
|
/* ---- the blocks ------------------------------------------------------------------
|
|
30
56
|
The top band is the shell's two-up grid (it stacks to one column at the
|
|
@@ -103,9 +129,13 @@
|
|
|
103
129
|
.cb-row .ov-row__facts a { color: var(--accent-ink); }
|
|
104
130
|
/* Inside the two condensed blocks a row has half the page: the state and the
|
|
105
131
|
actions drop UNDER the words as one line (the archetype's own phone shape),
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
and the two-up itself becomes one column before the shell's phone breakpoint,
|
|
133
|
+
at the width where two half-rows stop holding a sentence. The wide block below
|
|
134
|
+
keeps the archetype's two columns.
|
|
135
|
+
The separate date line this block used to suppress is gone from the markup
|
|
136
|
+
entirely (task 1004041): the age pill says how long in words and carries the
|
|
137
|
+
exact timestamp on its title, so a date beside it was the same fact twice —
|
|
138
|
+
which is what the "All open asks" rows were doing at full width. */
|
|
109
139
|
.cb-band .ov-row--lead { grid-template-columns: auto minmax(0, 1fr); row-gap: 8px; }
|
|
110
140
|
.cb-band .ov-row--lead > .ov-row__right {
|
|
111
141
|
grid-column: 2;
|
|
@@ -115,7 +145,6 @@
|
|
|
115
145
|
text-align: left;
|
|
116
146
|
}
|
|
117
147
|
.cb-band .ov-row__actions { justify-content: flex-start; }
|
|
118
|
-
.cb-band .ov-row__when { display: none; }
|
|
119
148
|
@media (max-width: 1100px) {
|
|
120
149
|
.cb-band { grid-template-columns: 1fr; }
|
|
121
150
|
}
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
where people are asking, their faces. The rows are the hall's row
|
|
36
36
|
archetype (oversight.css) led by a face; collab.js fills them. -->
|
|
37
37
|
<main id="hall-main">
|
|
38
|
+
<!-- The glass figure strip task 1003942 promised and did not build, and
|
|
39
|
+
the artist's verdict (task 1004041) asked for by name: three calm
|
|
40
|
+
numbers a person meets BEFORE any age pill. The shell's .stat-row,
|
|
41
|
+
the same band Harbor, Gate and the copy desk open on; collab.js
|
|
42
|
+
fills it from the two reads it already makes. -->
|
|
43
|
+
<div class="stat-row ov-summary cb-figs" id="cb-figs" aria-label="Where the asks stand"></div>
|
|
44
|
+
|
|
38
45
|
<div class="view-zone view-zone--band cb-band">
|
|
39
46
|
<section class="scroll cb-block" aria-labelledby="cb-for-me-h">
|
|
40
47
|
<div class="cb-block__head">
|
|
@@ -98,7 +105,7 @@
|
|
|
98
105
|
<script src="/builders/shell.js?v=2026-07-02-shell1"></script>
|
|
99
106
|
<script src="/builders/palette.js" defer></script>
|
|
100
107
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
101
|
-
<script src="/builders/collab-lib.js?v=2026-09-
|
|
102
|
-
<script src="/builders/collab.js?v=2026-09-
|
|
108
|
+
<script src="/builders/collab-lib.js?v=2026-09-19-agewords"></script>
|
|
109
|
+
<script src="/builders/collab.js?v=2026-09-19-agewords"></script>
|
|
103
110
|
</body>
|
|
104
111
|
</html>
|
|
@@ -47,16 +47,15 @@
|
|
|
47
47
|
|
|
48
48
|
let settling = false;
|
|
49
49
|
|
|
50
|
-
// The age pill: the band
|
|
50
|
+
// The age pill: the band picks the colour (collab.css .cb-age--*), the LABEL
|
|
51
|
+
// carries the real age, so fourteen old rows read as fourteen different ages
|
|
52
|
+
// instead of fourteen copies of one alarm (artist verdict, task 1004041). The
|
|
53
|
+
// exact timestamp stays on the title, where a reader who wants the date can
|
|
54
|
+
// still reach it — which is also why the separate date line that used to sit
|
|
55
|
+
// under this pill is gone: the same fact twice is one fact too many.
|
|
51
56
|
function ageChip(req, nowMs) {
|
|
52
57
|
const band = L.ageBand(req.created_at, nowMs);
|
|
53
|
-
return `<span class="fact-pill cb-age cb-age--${band.id}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function whenLine(iso) {
|
|
57
|
-
const d = iso ? new Date(iso) : null;
|
|
58
|
-
if (!d || Number.isNaN(d.getTime())) return '';
|
|
59
|
-
return `<span class="ov-row__when">${escapeHtml(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }))}</span>`;
|
|
58
|
+
return `<span class="fact-pill cb-age cb-age--${band.id}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(L.ageLabel(req.created_at, nowMs))}</span>`;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
// A DOM id per row that is stable, unique and safe to put in an attribute. The
|
|
@@ -183,21 +182,30 @@
|
|
|
183
182
|
<div class="ov-row__main">${askHtml(req, key)}
|
|
184
183
|
<div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
|
|
185
184
|
</div>
|
|
186
|
-
<div class="ov-row__right">${ageChip(req, nowMs)}
|
|
185
|
+
<div class="ov-row__right">${ageChip(req, nowMs)}</div>
|
|
187
186
|
</li>`;
|
|
188
187
|
}
|
|
189
188
|
|
|
190
189
|
// Your own ask, led by who you asked, with the two settle actions. Only the
|
|
191
190
|
// asker can settle — the server enforces it in the UPDATE's WHERE, this is
|
|
192
191
|
// just the affordance.
|
|
192
|
+
//
|
|
193
|
+
// THE AGE SITS WITH THE FACTS HERE, NOT IN THE RIGHT COLUMN (artist verdict,
|
|
194
|
+
// task 1004041). On every other block the right column holds one thing and the
|
|
195
|
+
// age is it. On this one it held the age AND the two settle buttons, so an
|
|
196
|
+
// alarm in the danger ink and a solid accent control ended up two centimetres
|
|
197
|
+
// apart — and DESIGN.md spends the accent only on what is pressable or live,
|
|
198
|
+
// which is precisely the distinction a reader could no longer make. Moving the
|
|
199
|
+
// pill down to the facts line puts the warning with the other facts about the
|
|
200
|
+
// ask and leaves the right column as what it is here: the place you act.
|
|
193
201
|
function mineRow(req, nowMs, extra, key) {
|
|
194
202
|
const id = escapeHtml(String(req.id));
|
|
195
203
|
return `<li class="ov-row ov-row--lead cb-row" data-id="${id}"${extra ? ' data-extra hidden' : ''}>
|
|
196
204
|
${faceHtml(L.faceOf(req, 'out'))}
|
|
197
205
|
<div class="ov-row__main">${askHtml(req, key)}
|
|
198
|
-
<div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}</div>
|
|
206
|
+
<div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}${ageChip(req, nowMs)}</div>
|
|
199
207
|
</div>
|
|
200
|
-
<div class="ov-row__right"
|
|
208
|
+
<div class="ov-row__right">
|
|
201
209
|
<div class="ov-row__actions">
|
|
202
210
|
<button class="btn-accent" data-settle="answered" data-id="${id}">Someone helped</button>
|
|
203
211
|
<button class="btn-ghost" data-settle="withdrawn" data-id="${id}">Withdraw</button>
|
|
@@ -214,7 +222,7 @@
|
|
|
214
222
|
<div class="ov-row__main">${askHtml(req, key)}
|
|
215
223
|
<div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
|
|
216
224
|
</div>
|
|
217
|
-
<div class="ov-row__right">${ageChip(req, nowMs)}
|
|
225
|
+
<div class="ov-row__right">${ageChip(req, nowMs)}</div>
|
|
218
226
|
</li>`;
|
|
219
227
|
}
|
|
220
228
|
|
|
@@ -247,6 +255,46 @@
|
|
|
247
255
|
}
|
|
248
256
|
}
|
|
249
257
|
|
|
258
|
+
// ---- the figure strip -------------------------------------------------------
|
|
259
|
+
//
|
|
260
|
+
// THREE CALM NUMBERS BEFORE ANY PILL (artist verdict, task 1004041). Task
|
|
261
|
+
// 1003942's description promised "a glass figure strip at the top (waiting on
|
|
262
|
+
// you / your open asks / crafts you answer for)" and it was never built — the
|
|
263
|
+
// grader's narc worker caught the omission and the artist named what it cost:
|
|
264
|
+
// with no numbers to open on, the first thing a person meets is the wall of
|
|
265
|
+
// age pills, which is why the two findings were one defect seen twice. So the
|
|
266
|
+
// strip is the shell's .stat-row, the same glass band Harbor, Gate and the
|
|
267
|
+
// copy desk open on, and it is drawn BEFORE the blocks in the markup.
|
|
268
|
+
//
|
|
269
|
+
// The figures repeat what the block heads count, and that is the point rather
|
|
270
|
+
// than a duplication: a head's count is found by reading the head, and a strip
|
|
271
|
+
// is read in one glance at the top of the page. The third one is not a count of
|
|
272
|
+
// asks at all — it is the roles that decide which asks reach you, which is the
|
|
273
|
+
// fact behind every empty "Asked of you" and has never been stated on the page.
|
|
274
|
+
// `badge` is the shell's own modifier for a tile whose value is WORDS rather
|
|
275
|
+
// than a figure — the display face at 34px is sized for a numeral and sets a
|
|
276
|
+
// list of role names as a headline.
|
|
277
|
+
function figureHtml(label, value, sub, badge) {
|
|
278
|
+
return `<div class="stat-tile">
|
|
279
|
+
<span class="stat-tile__label">${escapeHtml(label)}</span>
|
|
280
|
+
<span class="stat-tile__num${badge ? ' stat-tile__num--badge' : ''}">${escapeHtml(String(value))}</span>
|
|
281
|
+
<span class="stat-tile__sub">${escapeHtml(sub)}</span>
|
|
282
|
+
</div>`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function paintFigures(forMe, mine, crafts) {
|
|
286
|
+
const host = $('#cb-figs');
|
|
287
|
+
if (!host) return;
|
|
288
|
+
const axis = window.OTB.roleAxisLabel().toLowerCase();
|
|
289
|
+
// A ROLE LIST IS NOT A NUMBER, so the third tile shows the roles themselves
|
|
290
|
+
// and falls back to the honest em dash rather than to "0", which would read
|
|
291
|
+
// as a count of something rather than as "you have not set one".
|
|
292
|
+
const roles = crafts.map((c) => window.OTB.roleWord(c)).join(', ');
|
|
293
|
+
host.innerHTML = figureHtml('Waiting on you', forMe.length, forMe.length === 1 ? 'ask' : 'asks')
|
|
294
|
+
+ figureHtml('Your open asks', mine.length, mine.length === 1 ? 'ask' : 'asks')
|
|
295
|
+
+ figureHtml('Roles you answer for', roles || '—', roles ? `your ${axis}` : `no ${axis} set yet`, true);
|
|
296
|
+
}
|
|
297
|
+
|
|
250
298
|
// The per-row door. Independent of the block's "Show all N", which flips
|
|
251
299
|
// [data-extra] on the <li>s: this flips one <p> inside one row, so opening a
|
|
252
300
|
// row inside a condensed block and then expanding the block cannot leave either
|
|
@@ -323,6 +371,7 @@
|
|
|
323
371
|
const rowsOf = (r) => ((r && r.ok && r.data && r.data.help_requests) || []);
|
|
324
372
|
const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
|
|
325
373
|
|
|
374
|
+
paintFigures(rowsOf(forMe), rowsOf(mine), crafts);
|
|
326
375
|
paintBlock('for-me', rowsOf(forMe), forMeRow, nowMs, true);
|
|
327
376
|
paintBlock('mine', rowsOf(mine), mineRow, nowMs, false);
|
|
328
377
|
paintBlock('all', rowsOf(all), allRow, nowMs, true);
|
|
@@ -330,11 +379,15 @@
|
|
|
330
379
|
const sub = $('#cb-sub');
|
|
331
380
|
if (sub) {
|
|
332
381
|
// task 1003936: the axis word comes from OTB.roleAxisLabel(), never typed.
|
|
333
|
-
//
|
|
382
|
+
// task 1004041: the VALUES take roleWord(), not roleLabel() — this is a
|
|
383
|
+
// sentence, and the page said "Asks to Builders" six lines above "You asked
|
|
384
|
+
// artists". The opening clause is gone with it: it named the same two
|
|
385
|
+
// parties the clause after it names, and the page's own head and its three
|
|
386
|
+
// block headings already say what the page is.
|
|
334
387
|
const axis = window.OTB.roleAxisLabel().toLowerCase();
|
|
335
388
|
sub.textContent = crafts.length
|
|
336
|
-
? `
|
|
337
|
-
: `
|
|
389
|
+
? `Asks to ${crafts.map((c) => `${window.OTB.roleWord(c)}s`).join(', ')}, and to you by name, land in your block.`
|
|
390
|
+
: `With no ${axis} set, only asks addressed to you by name land in your block.`;
|
|
338
391
|
}
|
|
339
392
|
|
|
340
393
|
// The social half. Best-effort and LAST: an introductions read that fails
|
|
@@ -209,6 +209,28 @@
|
|
|
209
209
|
return s === 'ui' ? 'UI' : s.charAt(0).toUpperCase() + s.slice(1);
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// A CONTROL TAKES roleLabel(); A SENTENCE TAKES roleWord() — the rule task
|
|
213
|
+
// 1004041 exists to write down, next to the helper that made it necessary.
|
|
214
|
+
//
|
|
215
|
+
// roleLabel() capitalises, and that is right for a control: a picker option, a
|
|
216
|
+
// filter, a column head, a row tag. It is wrong in RUNNING PROSE, where a role
|
|
217
|
+
// is a common noun. Task 1003936 routed Collab's sub line through roleLabel and
|
|
218
|
+
// left collab-lib.js lowercase, reasoning about the two call sites separately —
|
|
219
|
+
// so one screen said "Asks to Builders" six lines above "You asked artists",
|
|
220
|
+
// which is the same word capitalised two ways on one page, the exact defect
|
|
221
|
+
// 1003936 existed to remove. Deciding per call site is what guarantees that;
|
|
222
|
+
// the axis needs two helpers, not one helper and a judgement each time.
|
|
223
|
+
//
|
|
224
|
+
// NOT ESCAPE-SAFE, like roleLabel(): a caller rendering it into markup escapes
|
|
225
|
+
// the result. The acronym exception is the same one, because 'UI' is not a
|
|
226
|
+
// common noun in any sentence either.
|
|
227
|
+
function roleWord(d) {
|
|
228
|
+
const s = String(d || '');
|
|
229
|
+
if (!s) return '';
|
|
230
|
+
if (s === ROLE_UNSET) return 'no role yet';
|
|
231
|
+
return s === 'ui' ? 'UI' : s.toLowerCase();
|
|
232
|
+
}
|
|
233
|
+
|
|
212
234
|
// The option list for any Role <select>, so no surface types the values or the
|
|
213
235
|
// labels again. Prefers the roles the SERVER serves (the same list
|
|
214
236
|
// db.updateBuilderDisciplines validates a PATCH against), and falls back to the
|
|
@@ -422,7 +444,7 @@
|
|
|
422
444
|
window.OTB = {
|
|
423
445
|
escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
|
|
424
446
|
// task 1003936 — ONE word for the role axis, all six surfaces
|
|
425
|
-
roleAxisLabel, roleLabel, roleOptions, roleIsUnset, ROLE_UNSET,
|
|
447
|
+
roleAxisLabel, roleLabel, roleWord, roleOptions, roleIsUnset, ROLE_UNSET,
|
|
426
448
|
fillKindFilter, kindLabel, kindIsUnset, KIND_UNSET, // task 1003453 — one Kind filler, all three surfaces
|
|
427
449
|
errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
|
|
428
450
|
contributeSettingsPanel, settingsPanels,
|
|
@@ -1357,17 +1357,31 @@ module.exports = function buildTasksRouter() {
|
|
|
1357
1357
|
// it here too. Sits BEFORE the credential 503 on purpose: the tip is true
|
|
1358
1358
|
// regardless, and a later-provisioned credential lets the sweep heal
|
|
1359
1359
|
// retroactively. Best-effort, mirroring publish-branch.
|
|
1360
|
+
//
|
|
1361
|
+
// task 1004055: and SAY whether it took. setClaimPublishedSha returns null when
|
|
1362
|
+
// no claim row matched, which read identically to success — so a caller that had
|
|
1363
|
+
// just merged to main was told nothing, and only found out one call later as an
|
|
1364
|
+
// opaque ship_land_not_proven 409, with the work already on main and the task
|
|
1365
|
+
// parked at 'confirmed'. `tip_recorded` travels on BOTH exits, so the answer does
|
|
1366
|
+
// not depend on which one the request happens to take.
|
|
1367
|
+
let tipRecorded = false;
|
|
1360
1368
|
try {
|
|
1361
|
-
await db.setClaimPublishedSha({ taskId: id, builderId: req.builder.id, sha: headSha });
|
|
1369
|
+
const claimId = await db.setClaimPublishedSha({ taskId: id, builderId: req.builder.id, sha: headSha });
|
|
1370
|
+
tipRecorded = claimId != null;
|
|
1371
|
+
if (!tipRecorded) log.warn('[gds] attest-gate: no claim row to record the published tip on', id);
|
|
1362
1372
|
} catch (e) {
|
|
1363
1373
|
log.error('[gds] setClaimPublishedSha via attest-gate (non-blocking)', id, e && e.message);
|
|
1364
1374
|
}
|
|
1365
1375
|
if (!githubPush.isConfigured()) {
|
|
1366
|
-
return res.fail('push_unconfigured', {
|
|
1367
|
-
|
|
1376
|
+
return res.fail('push_unconfigured', {
|
|
1377
|
+
status: 503,
|
|
1378
|
+
message: 'No server push credential (GitHub App / GITHUB_PUSH_TOKEN) to post the gate attestation. ' +
|
|
1379
|
+
'A gate-surface PR will need an owner review until the operator provisions it.',
|
|
1380
|
+
details: { tip_recorded: tipRecorded },
|
|
1381
|
+
});
|
|
1368
1382
|
}
|
|
1369
1383
|
const { attested, reason } = await postGateAuthorTrust({ rank: req.builder.rank, sha: headSha, taskId: id });
|
|
1370
|
-
return res.json({ ok: true, attested, reason });
|
|
1384
|
+
return res.json({ ok: true, attested, reason, tip_recorded: tipRecorded });
|
|
1371
1385
|
});
|
|
1372
1386
|
|
|
1373
1387
|
// GET /override-requests?status=open — Archon queue. Status defaults to open;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.884",
|
|
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.884",
|
|
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.884",
|
|
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-land.js
CHANGED
|
@@ -438,24 +438,100 @@ async function pollServerPublish(taskId, branch) {
|
|
|
438
438
|
// Best-effort + never throws: a non-Metic author, an unconfigured server, or a
|
|
439
439
|
// transient failure just leaves the PR un-attested → it escalates (the safe
|
|
440
440
|
// direction). A non-gate PR is unaffected by the attestation either way.
|
|
441
|
+
//
|
|
442
|
+
// task 1004055 — the endpoint does TWO things, and only one of them was ever
|
|
443
|
+
// reported. Besides the attestation (best-effort, and declining is a legitimate
|
|
444
|
+
// answer) it records claims.published_head_sha, the tip that makes the ship
|
|
445
|
+
// PROVABLE — and that half is load-bearing, not best-effort: without it POST /ship
|
|
446
|
+
// refuses the confirmed→shipped flip. So this returns { recorded, attested, reason }
|
|
447
|
+
// rather than undefined, and callers act on `recorded`. It still never throws.
|
|
441
448
|
async function attestGateTrust(taskId, headSha) {
|
|
442
|
-
if (!headSha) return;
|
|
449
|
+
if (!headSha) return { recorded: false, attested: false, reason: 'no head SHA to attest' };
|
|
450
|
+
let r;
|
|
443
451
|
try {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
452
|
+
r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
|
|
453
|
+
} catch (e) {
|
|
454
|
+
// task 1004055: a bare `catch (_) {}` here meant a thrown request error left NO
|
|
455
|
+
// trace at all — the quietest of the failure shapes, and the one that reached
|
|
456
|
+
// the builder as an unexplained ship_land_not_proven.
|
|
457
|
+
console.log(` ci-land: gate attestation request failed (${(e && e.message) || 'unknown error'}).`);
|
|
458
|
+
return { recorded: false, attested: false, reason: `request failed (${(e && e.message) || 'unknown error'})` };
|
|
459
|
+
}
|
|
460
|
+
const recorded = landProofRecorded(r);
|
|
461
|
+
if (r.ok && r.data) {
|
|
462
|
+
if (r.data.attested) {
|
|
463
|
+
console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
|
|
453
464
|
} else {
|
|
454
|
-
console.log(` ci-land: gate
|
|
465
|
+
console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
|
|
455
466
|
}
|
|
456
|
-
|
|
457
|
-
|
|
467
|
+
return { recorded, attested: !!r.data.attested, reason: r.data.reason || null };
|
|
468
|
+
}
|
|
469
|
+
if (r.status === 503) {
|
|
470
|
+
console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
|
|
471
|
+
return { recorded, attested: false, reason: 'the server has no push credential' };
|
|
458
472
|
}
|
|
473
|
+
console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
|
|
474
|
+
return { recorded, attested: false, reason: `HTTP ${r.status}` };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// landProofRecorded — PURE. Did an attest-gate response actually record
|
|
478
|
+
// claims.published_head_sha (task 1004055)? That row is the durable per-task land
|
|
479
|
+
// proof; without it POST /tasks/:id/ship refuses with ship_land_not_proven and the
|
|
480
|
+
// task strands at 'confirmed' with its work already on main.
|
|
481
|
+
//
|
|
482
|
+
// The server SAYS so in `tip_recorded` — on the 200 body and in the 503's details,
|
|
483
|
+
// because the write deliberately sits BEFORE the credential check. Prefer that.
|
|
484
|
+
// A server that predates the field sends neither, which is the NORMAL state here:
|
|
485
|
+
// an instance runs a PINNED core, so it lags main by however long the pin sits
|
|
486
|
+
// still. For those, fall back to the route's contract — the write is above the
|
|
487
|
+
// credential 503, so 200 and 503 both mean the tip landed and nothing else does.
|
|
488
|
+
// Exported so the truth table is pinned with no server.
|
|
489
|
+
function landProofRecorded(r) {
|
|
490
|
+
if (!r || typeof r.status !== 'number') return false;
|
|
491
|
+
const body = r.data || {};
|
|
492
|
+
const explicit = body.tip_recorded !== undefined
|
|
493
|
+
? body.tip_recorded
|
|
494
|
+
: (body.error && body.error.details ? body.error.details.tip_recorded : undefined);
|
|
495
|
+
if (explicit !== undefined) return explicit === true;
|
|
496
|
+
return r.status === 200 || r.status === 503;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// landProofWarningLines — PURE. What to print when the proof did NOT get recorded
|
|
500
|
+
// (task 1004055). Without it POST /ship refuses, the task parks at 'confirmed' —
|
|
501
|
+
// which gates the builder's WHOLE queue — and the publish-reconciler eventually
|
|
502
|
+
// writes strand:land_not_proven, after which nothing heals it. Say it HERE, while
|
|
503
|
+
// the sha is still on screen, and hand over the calls that fix it, rather than
|
|
504
|
+
// letting it surface as an opaque 409 later.
|
|
505
|
+
//
|
|
506
|
+
// `merged` is the tense, and it is the whole difference between the two callers:
|
|
507
|
+
// the local-merge path has ALREADY put the work on main and needs the full
|
|
508
|
+
// two-call recovery now; the PR path has only pushed, so the land may still be
|
|
509
|
+
// minutes away and the same recovery would be premature. Exported so both
|
|
510
|
+
// wordings are pinned.
|
|
511
|
+
function landProofWarningLines(taskId, sha, reason, { merged = true } = {}) {
|
|
512
|
+
const head = sha || '<the merge commit on main>';
|
|
513
|
+
const why = ` WARNING: the land proof for task ${taskId} was NOT recorded (${reason || 'unknown'}).`;
|
|
514
|
+
const fix = ` node scripts/gds/api.js POST /tasks/${taskId}/attest-gate --body '{"head_sha":"${head}"}'`;
|
|
515
|
+
if (!merged) {
|
|
516
|
+
return [
|
|
517
|
+
'',
|
|
518
|
+
why,
|
|
519
|
+
' Nothing is lost yet — the branch is pushed and the PR still lands normally.',
|
|
520
|
+
' But if the ship then refuses with ship_land_not_proven, re-record the tip',
|
|
521
|
+
' against the merge commit and re-run the ship:',
|
|
522
|
+
` node scripts/gds/api.js POST /tasks/${taskId}/attest-gate --body '{"head_sha":"<the merge commit on main>"}'`,
|
|
523
|
+
` node scripts/gds/ship.js ${taskId}`,
|
|
524
|
+
];
|
|
525
|
+
}
|
|
526
|
+
return [
|
|
527
|
+
'',
|
|
528
|
+
why,
|
|
529
|
+
' The merge IS on main, but the server cannot prove the land is this task\'s, so',
|
|
530
|
+
' the confirmed -> shipped flip will be refused (ship_land_not_proven) and the',
|
|
531
|
+
' task will park at confirmed — which blocks every new claim you make. Fix it:',
|
|
532
|
+
fix,
|
|
533
|
+
` node scripts/gds/api.js POST /tasks/${taskId}/ship --body '{}'`,
|
|
534
|
+
];
|
|
459
535
|
}
|
|
460
536
|
|
|
461
537
|
// pickStaleGateReviewRun — PURE. Among `gh run list --json
|
|
@@ -596,7 +672,14 @@ async function ciLandLocal(taskId, branch, valueSummary) {
|
|
|
596
672
|
// and a Metic+ gate-surface PR auto-passes — race-free for a fresh PR, since the
|
|
597
673
|
// `gh pr create` below is what fires gate-review and it runs only after this
|
|
598
674
|
// returns. (A reused open PR is handled by the re-run nudge after auto-merge.)
|
|
599
|
-
|
|
675
|
+
// task 1004055: the same call also records the land proof, so a miss here is not
|
|
676
|
+
// cosmetic — check it rather than firing and forgetting. Not fatal on this path:
|
|
677
|
+
// the branch is pushed and the PR lands regardless, so say what to do IF the ship
|
|
678
|
+
// is later refused, and carry on.
|
|
679
|
+
const gateProof = await attestGateTrust(taskId, headSha);
|
|
680
|
+
if (!gateProof.recorded) {
|
|
681
|
+
for (const line of landProofWarningLines(taskId, headSha, gateProof.reason, { merged: false })) console.log(line);
|
|
682
|
+
}
|
|
600
683
|
|
|
601
684
|
// Ensure exactly one PR for this branch. gh pr create is idempotent-ish: it
|
|
602
685
|
// errors if a PR already exists, so check first and reuse it.
|
|
@@ -723,6 +806,12 @@ module.exports = {
|
|
|
723
806
|
ciLand,
|
|
724
807
|
classifyLandOutcome,
|
|
725
808
|
formatPublishFailure,
|
|
809
|
+
// task 1004055: the land-proof half of attest-gate. attestGateTrust is exported
|
|
810
|
+
// so the LOCAL-merge path can record a proof it previously never recorded at all;
|
|
811
|
+
// the two pure halves so the truth table + the wording are pinned without a server.
|
|
812
|
+
attestGateTrust,
|
|
813
|
+
landProofRecorded,
|
|
814
|
+
landProofWarningLines,
|
|
726
815
|
// task 1002572 (PART 2): the bail ids + the pure "can a PR still land this?"
|
|
727
816
|
// decision, so the single-checkout fallback is opt-in per bail and unit-tested.
|
|
728
817
|
LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
|
|
@@ -20,9 +20,11 @@ const { mainIsProtected, pushVia, readDeployConfigRaw, resolveDeployMode, resolv
|
|
|
20
20
|
const {
|
|
21
21
|
LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
|
|
22
22
|
LAND_BAIL_NO_TASK_BRANCH,
|
|
23
|
+
attestGateTrust,
|
|
23
24
|
ciLand,
|
|
24
25
|
landBailRecoverableByPr,
|
|
25
26
|
landBailed,
|
|
27
|
+
landProofWarningLines,
|
|
26
28
|
landed,
|
|
27
29
|
} = require('./ship-land.js');
|
|
28
30
|
const { runSmoke } = require('./ship-grade.js');
|
|
@@ -301,6 +303,32 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
301
303
|
return landBailed('could not push the merged main to origin', '/merge-mode');
|
|
302
304
|
}
|
|
303
305
|
|
|
306
|
+
// task 1004055: RECORD THE LAND PROOF — the step this path never had.
|
|
307
|
+
//
|
|
308
|
+
// The two OTHER land paths each tell the server which commit is this task's:
|
|
309
|
+
// ciLandLocal posts attest-gate after its push, ciLandServer gets it from
|
|
310
|
+
// publish-branch. This path merges and pushes main ITSELF, so nothing ever did,
|
|
311
|
+
// and claims.published_head_sha stayed NULL. It also opens no PR and deletes the
|
|
312
|
+
// branch below, so getPublishStatus can find nothing by head ref either — which
|
|
313
|
+
// left deriveShipAssurance with neither of its two proofs and made the POST /ship
|
|
314
|
+
// three lines later refuse with ship_land_not_proven, EVERY time, with the work
|
|
315
|
+
// already merged. The task then parked at 'confirmed', gating its builder's whole
|
|
316
|
+
// queue, until someone ran the recovery by hand (five consecutive ships,
|
|
317
|
+
// 2026-09-19; twelve refusals in one day's audit log).
|
|
318
|
+
//
|
|
319
|
+
// The merge commit is the sha to record: the push above just put it on main, so
|
|
320
|
+
// the ancestry check cannot fail, and it is what the hand-recovery always used.
|
|
321
|
+
// It goes HERE, immediately after the push and BEFORE both the deploy step (which
|
|
322
|
+
// can bail) and the branch deletion (which destroys the only other evidence) —
|
|
323
|
+
// the proof must exist from the first moment the work is on main.
|
|
324
|
+
const mergeSha = gitOk('git rev-parse HEAD', { cwd: REPO_ROOT });
|
|
325
|
+
const proof = await attestGateTrust(taskId, mergeSha);
|
|
326
|
+
if (proof.recorded) {
|
|
327
|
+
console.log(` auto-merge: land proof recorded (merge ${String(mergeSha).slice(0, 8)} on main).`);
|
|
328
|
+
} else {
|
|
329
|
+
for (const line of landProofWarningLines(taskId, mergeSha, proof.reason)) console.log(line);
|
|
330
|
+
}
|
|
331
|
+
|
|
304
332
|
// Deploy step. The core carries NO deploy target of its own — under the
|
|
305
333
|
// standalone model a core change reaches a live instance via package-core +
|
|
306
334
|
// `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
|
package/scripts/gds/ship.js
CHANGED
|
@@ -127,6 +127,8 @@ const {
|
|
|
127
127
|
landPending,
|
|
128
128
|
landPendingLines,
|
|
129
129
|
landPollAfterAutoMerge,
|
|
130
|
+
landProofRecorded,
|
|
131
|
+
landProofWarningLines,
|
|
130
132
|
landed,
|
|
131
133
|
pickStaleGateReviewRun,
|
|
132
134
|
reconcileBlockedLines,
|
|
@@ -266,6 +268,12 @@ module.exports = {
|
|
|
266
268
|
// object, so a consumer MUST test `.ok`; the old `if (!merged)` would sail past
|
|
267
269
|
// a bail. Also pins pending (a poll timeout) as distinct from bailed.
|
|
268
270
|
landed, landPending, landBailed, strandCommand,
|
|
271
|
+
// task 1004055: the land proof the LOCAL-merge path never recorded. landProofRecorded
|
|
272
|
+
// is the "did the tip actually land?" truth table (explicit tip_recorded wins; a
|
|
273
|
+
// pinned older core falls back to the route's before-the-credential-check contract);
|
|
274
|
+
// landProofWarningLines is the recovery the builder gets when it did not, in the
|
|
275
|
+
// tense of whichever path is speaking. Both pure — exported so they pin without a server.
|
|
276
|
+
landProofRecorded, landProofWarningLines,
|
|
269
277
|
// task 1001916: the publish-branch failure formatter — exported pure so both
|
|
270
278
|
// wire shapes (envelope + legacy flat), the step/reason surfacing, and the
|
|
271
279
|
// non-fast-forward remedy hint are pinned without a server.
|
|
@@ -124,9 +124,46 @@ const FIXTURE_FALLBACKS = [
|
|
|
124
124
|
[/^connections\/degree\/[^/]+$/, 'connections__degree'],
|
|
125
125
|
];
|
|
126
126
|
|
|
127
|
+
// A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
|
|
128
|
+
// fixtures were written with absolute timestamps chosen to land one row in each
|
|
129
|
+
// of collab's four age bands — and three weeks later every one of them had
|
|
130
|
+
// drifted into the oldest band, so the harness rendered fourteen identical
|
|
131
|
+
// "waiting over a week" pills and an artist reviewing the page reasonably read
|
|
132
|
+
// that as the page's own defect. (Half of it was; the other half was this.)
|
|
133
|
+
//
|
|
134
|
+
// So a fixture may write a RELATIVE timestamp: "@-3d" is three days before the
|
|
135
|
+
// moment the page is served, "@-6h" six hours. Resolved on every load, so the
|
|
136
|
+
// bands the fixture was written to demonstrate are the bands it demonstrates a
|
|
137
|
+
// year from now. Any string field may use it; nothing else changes, so a fixture
|
|
138
|
+
// with absolute dates is served exactly as before.
|
|
139
|
+
//
|
|
140
|
+
// WRITE A FRACTIONAL OFFSET. "@-3d" lands exactly on the 3-day band boundary, so
|
|
141
|
+
// whether it reads as 2 days or 3 depends on the milliseconds between this
|
|
142
|
+
// resolve and the browser's own clock — the row flickers between two bands
|
|
143
|
+
// across reloads and a screenshot pass cannot be compared. "@-3.4d" cannot.
|
|
144
|
+
const RELATIVE_AT = /^@-(\d+(?:\.\d+)?)([dh])$/;
|
|
145
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
146
|
+
|
|
147
|
+
function resolveRelativeDates(v, nowMs) {
|
|
148
|
+
if (typeof v === 'string') {
|
|
149
|
+
const m = RELATIVE_AT.exec(v);
|
|
150
|
+
if (!m) return v;
|
|
151
|
+
const hours = Number(m[1]) * (m[2] === 'd' ? 24 : 1);
|
|
152
|
+
return new Date(nowMs - hours * HOUR_MS).toISOString();
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(v)) return v.map((x) => resolveRelativeDates(x, nowMs));
|
|
155
|
+
if (v && typeof v === 'object') {
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [k, val] of Object.entries(v)) out[k] = resolveRelativeDates(val, nowMs);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
return v;
|
|
161
|
+
}
|
|
162
|
+
|
|
127
163
|
function loadFixture(name) {
|
|
128
164
|
try {
|
|
129
|
-
|
|
165
|
+
const raw = JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf8'));
|
|
166
|
+
return resolveRelativeDates(raw, Date.now());
|
|
130
167
|
} catch (_) { return undefined; }
|
|
131
168
|
}
|
|
132
169
|
|