@bongos/core 1.21.166 → 1.21.168
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 +153 -73
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +306 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +21 -20
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +445 -445
- package/modules/autonomy/governor-docket.js +10 -5
- package/modules/builder-settings/builder-needs.js +29 -3
- package/modules/copy-desk/guarded-lines.js +89 -0
- package/modules/copy-desk/guarded-lines.json +14 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/page-status.js +77 -7
- package/modules/copy-desk/pages.js +4 -0
- package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +8 -0
- package/modules/copy-desk/stuck-tweaks.js +79 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
- package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
- package/modules/government/asks-db.js +38 -2
- package/modules/government/catalog.js +1 -1
- package/modules/government/docket.js +20 -2
- package/modules/government/migrations/government_032_ask_flags.sql +35 -0
- package/modules/government/routes/government.js +43 -1
- package/modules/hall-ui/public/approval-queue-lib.js +4 -0
- package/modules/hall-ui/public/approval-queue.js +1 -0
- package/modules/hall-ui/public/brand-apply.js +3 -0
- package/modules/hall-ui/public/city-decide.js +642 -0
- package/modules/hall-ui/public/city-draw.js +14 -3
- package/modules/hall-ui/public/city-switchboard.js +141 -0
- package/modules/hall-ui/public/city.css +176 -10
- package/modules/hall-ui/public/city.html +8 -3
- package/modules/hall-ui/public/city.js +185 -38
- package/modules/hall-ui/public/sky-data.js +4 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
- package/modules/hall-ui/public/tweak-editor.css +2 -0
- package/modules/hall-ui/public/tweak-editor.js +3 -1
- package/modules/lifecycle/db-claims.js +20 -5
- package/modules/lifecycle/rebase-gate.js +78 -0
- package/modules/lifecycle/routes/claims.js +10 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/gds/ask.js +68 -1
- package/scripts/gds/claim.js +6 -1
- package/scripts/gds/copy-apply.js +2 -0
- package/src/bongos/module-promotion.js +219 -0
- package/src/bongos/route-rank-check.js +7 -0
- package/src/bongos/routes/me.js +11 -0
- package/src/bongos/routes/modules.js +46 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -0
- package/tests/claim_gate_ci_unblock.mjs +4 -0
- package/tests/claim_gate_rebase.mjs +56 -3
- package/tests/copy_desk_page_editor.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +42 -0
- package/tests/copy_guarded_lines.mjs +54 -0
- package/tests/copy_stuck_page_tweaks.mjs +118 -0
- package/tests/government_ask_flags.mjs +80 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/government_treasury.mjs +1 -0
- package/tests/governor_docket.mjs +1 -1
- package/tests/hall_approval_queue.mjs +15 -0
- package/tests/hall_city.mjs +5 -4
- package/tests/hall_city_living.mjs +118 -12
- package/tests/hall_city_rewording.mjs +51 -0
- package/tests/hall_city_switchboard.mjs +63 -0
- package/tests/hall_gov_real_site.mjs +2 -2
- package/tests/hall_sky.mjs +18 -0
- package/tests/hall_street_deck.mjs +6 -4
- package/tests/hall_street_depth.mjs +1 -1
- package/tests/hall_street_desk.mjs +5 -3
- package/tests/hall_tweak_editor.mjs +26 -0
- package/tests/module_promotion.mjs +290 -0
- package/tests/rebase_gate_set_aside.mjs +73 -0
- package/tests/reward_mode_surfacing.mjs +12 -0
package/release-notes.json
CHANGED
|
@@ -10050,5 +10050,33 @@
|
|
|
10050
10050
|
"id": "1004786",
|
|
10051
10051
|
"text": "Settings no longer shows a broken image-generation key card on projects without the art tools. Before, builders were told their free art coverage had ended and to add a key, but the card could never load, so there was nowhere"
|
|
10052
10052
|
}
|
|
10053
|
+
],
|
|
10054
|
+
"1.21.167": [
|
|
10055
|
+
{
|
|
10056
|
+
"id": "1004784",
|
|
10057
|
+
"text": "The city's building cards now let you decide right there. Every waiting item shows its real answers inside the card, Tab flips through a building's items and the arrows flip buildings, and a number key picks then confirms. \"De"
|
|
10058
|
+
},
|
|
10059
|
+
{
|
|
10060
|
+
"id": "1004788",
|
|
10061
|
+
"text": "One stuck task no longer stops all your other work. If a finished task is waiting on something outside you, such as a safety guard on the server or a person who must land it, it is set aside: it no longer blocks new claims, it"
|
|
10062
|
+
},
|
|
10063
|
+
{
|
|
10064
|
+
"id": "1004776",
|
|
10065
|
+
"text": "The page tweak editor now tells the artist, while they write, when a line carries a promise the product must keep, such as the Account page saying Private hides the page from everyone including its owner. The line shows what i"
|
|
10066
|
+
},
|
|
10067
|
+
{
|
|
10068
|
+
"id": "1003804",
|
|
10069
|
+
"text": "New module versions now move from testers to everyone on their own: after 7 days, or after 2 days once 5 testers installed them cleanly. Any tester error holds a version until a reviewer releases it."
|
|
10070
|
+
},
|
|
10071
|
+
{
|
|
10072
|
+
"id": "1004782",
|
|
10073
|
+
"text": "Goals no longer count abandoned work in their credit totals. Will goal drops by 241 credits of dropped tasks; across the project about 20,000 credits of abandoned estimates stop inflating goal sizes in the sky map and goal pag"
|
|
10074
|
+
}
|
|
10075
|
+
],
|
|
10076
|
+
"1.21.168": [
|
|
10077
|
+
{
|
|
10078
|
+
"id": "1004777",
|
|
10079
|
+
"text": "A page tweak that sits too long now tells someone. If a round waits too long for its artist's review, that artist gets a line in Needs attention on Home. If it waits to be applied or to go live, builders who can apply it get t"
|
|
10080
|
+
}
|
|
10053
10081
|
]
|
|
10054
10082
|
}
|
package/scripts/gds/ask.js
CHANGED
|
@@ -135,11 +135,68 @@ async function check(file, deps = {}) {
|
|
|
135
135
|
return { code: v.ok ? 0 : 1, lines: verdictLines(v) };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// ---------- the reword list (owner, 2026-10-23) ----------
|
|
139
|
+
// A reader pressed "?" on a city card: the matter's words did not make sense.
|
|
140
|
+
// `unclear` prints what waits for a rewrite (the flagged matters, or with
|
|
141
|
+
// --legacy every open matter that waits on a person and has no stored card
|
|
142
|
+
// yet), each with the words it has now; `put` stores the rewritten card
|
|
143
|
+
// (PUT /government/asks/:matter), which closes the matter's flag.
|
|
144
|
+
async function client(deps) {
|
|
145
|
+
if (deps && deps.client) return deps.client;
|
|
146
|
+
const lib = require('./cli-lib');
|
|
147
|
+
if (!lib.readSessionToken()) throw new Error('no Bongos session — run /builder-setup first');
|
|
148
|
+
return lib.cliClient();
|
|
149
|
+
}
|
|
150
|
+
function matterLines(m) {
|
|
151
|
+
const out = [`${m.id} [${m.building}]${m.flag && m.flag.times > 1 ? ` flagged ${m.flag.times}x` : ''}`];
|
|
152
|
+
out.push(` title: ${String(m.title || '').trim()}`);
|
|
153
|
+
if (m.detail) out.push(` detail: ${String(m.detail).replace(/\s+/g, ' ').trim().slice(0, 600)}`);
|
|
154
|
+
if (m.ask) out.push(` card: ${m.ask.question} (${(m.ask.options || []).map((o) => o.label).join(' / ')})`);
|
|
155
|
+
if (m.flag && m.flag.note) out.push(` why unclear: ${m.flag.note}`);
|
|
156
|
+
if (Array.isArray(m.acts) && m.acts.length) out.push(` acts: ${m.acts.map((a) => a.key + (Array.isArray(a.options) && a.options.length ? '(' + a.options.map((o) => o.key).join('|') + ')' : '')).join(', ')}`);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
async function unclear({ legacy = false, json = false } = {}, deps = {}) {
|
|
160
|
+
const api = await client(deps);
|
|
161
|
+
let list;
|
|
162
|
+
if (legacy) {
|
|
163
|
+
const r = await api.request('GET', '/government/docket');
|
|
164
|
+
if (!r || !r.ok) return { code: 1, lines: [`ask: the docket could not be read (${r && r.status}).`] };
|
|
165
|
+
list = ((r.data && r.data.matters) || []).filter((m) => m && m.state === 'needs' && !m.ask)
|
|
166
|
+
.map((m) => ({ id: m.id, building: m.building, source: m.source, title: m.title, detail: m.detail || null, ask: null, acts: m.acts || [], flag: m.unclear || null }));
|
|
167
|
+
} else {
|
|
168
|
+
const r = await api.request('GET', '/government/asks/unclear');
|
|
169
|
+
if (!r || !r.ok) return { code: 1, lines: [`ask: the reword list could not be read (${r && r.status}).`] };
|
|
170
|
+
list = (r.data && r.data.matters) || [];
|
|
171
|
+
}
|
|
172
|
+
if (json) return { code: 0, lines: [JSON.stringify(list, null, 2)] };
|
|
173
|
+
if (!list.length) return { code: 0, lines: [legacy ? 'ask: every open matter already carries a card.' : 'ask: nothing on the reword list.'] };
|
|
174
|
+
const lines = [`${list.length} ${legacy ? 'open matter(s) with no card yet' : 'matter(s) flagged as unclear'}:`, ''];
|
|
175
|
+
list.forEach((m) => { lines.push(...matterLines(m), ''); });
|
|
176
|
+
lines.push('Write each one a card (node scripts/gds/ask.js template), check it, then: node scripts/gds/ask.js put <matter-id> <file>');
|
|
177
|
+
return { code: 0, lines };
|
|
178
|
+
}
|
|
179
|
+
async function put(matterId, file, deps = {}) {
|
|
180
|
+
let body;
|
|
181
|
+
try { body = readJson(file); } catch (e) { return { code: 2, lines: [`ask: could not read the card as JSON (${e.message}).`] }; }
|
|
182
|
+
const local = asks.checkAsk(askOf(body));
|
|
183
|
+
if (!local.ok) return { code: 1, lines: verdictLines({ ok: false, ask: null, errors: local.errors, where: 'local' }) };
|
|
184
|
+
const api = await client(deps);
|
|
185
|
+
const r = await api.request('PUT', '/government/asks/' + encodeURIComponent(matterId), { body: { ask: askOf(body) } });
|
|
186
|
+
if (r && r.ok && r.data && r.data.ok) return { code: 0, lines: [`ask: saved the card on ${matterId}; its "?" flag is closed.`, '', ...verdictLines({ ok: true, ask: r.data.ask, where: 'server' })] };
|
|
187
|
+
const err = r && r.data && r.data.error;
|
|
188
|
+
return { code: 1, lines: [`ask: ${matterId} was not saved (${r && r.status}${err ? ' ' + err.code : ''}). ${err && err.message ? err.message : ''}`.trim()] };
|
|
189
|
+
}
|
|
190
|
+
|
|
138
191
|
const USAGE = [
|
|
139
192
|
'usage:',
|
|
140
193
|
' node scripts/gds/ask.js template print a blank card to fill in',
|
|
141
194
|
' node scripts/gds/ask.js check <file|-> check a card; on a pass, print it as a person reads it',
|
|
142
195
|
' --local check with the rules here, without the server',
|
|
196
|
+
' node scripts/gds/ask.js unclear the reword list: matters a reader flagged with "?"',
|
|
197
|
+
' --legacy instead: every open matter on a person with no card yet',
|
|
198
|
+
' --json as JSON',
|
|
199
|
+
' node scripts/gds/ask.js put <matter> <file> store a rewritten card on a matter (closes its flag)',
|
|
143
200
|
].join('\n');
|
|
144
201
|
|
|
145
202
|
async function main(argv = process.argv.slice(2)) {
|
|
@@ -153,6 +210,16 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
153
210
|
console.log(r.lines.join('\n'));
|
|
154
211
|
return r.code;
|
|
155
212
|
}
|
|
213
|
+
if (cmd === 'unclear') {
|
|
214
|
+
const r = await unclear({ legacy: argv.includes('--legacy'), json: argv.includes('--json') });
|
|
215
|
+
console.log(r.lines.join('\n'));
|
|
216
|
+
return r.code;
|
|
217
|
+
}
|
|
218
|
+
if (cmd === 'put' && argv[1] && argv[2]) {
|
|
219
|
+
const r = await put(argv[1], argv[2]);
|
|
220
|
+
console.log(r.lines.join('\n'));
|
|
221
|
+
return r.code;
|
|
222
|
+
}
|
|
156
223
|
console.error(USAGE);
|
|
157
224
|
return 2;
|
|
158
225
|
}
|
|
@@ -161,4 +228,4 @@ if (require.main === module) {
|
|
|
161
228
|
main().then((code) => { process.exitCode = code; }, (e) => { console.error(`ask: ${e && e.message}`); process.exitCode = 1; });
|
|
162
229
|
}
|
|
163
230
|
|
|
164
|
-
module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, main };
|
|
231
|
+
module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, unclear, put, main };
|
package/scripts/gds/claim.js
CHANGED
|
@@ -369,7 +369,10 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
|
|
|
369
369
|
// posture) and this surface dropped it on the floor. Name the owed tasks, then
|
|
370
370
|
// print the server's menu.
|
|
371
371
|
if (code === 'REBASE_REQUIRED') {
|
|
372
|
-
|
|
372
|
+
// task 1004788: only the fixable ones gate; set-aside ones are listed apart.
|
|
373
|
+
const all = (d.rebaseTasks || d.rebase_tasks || []);
|
|
374
|
+
const owed = all.filter((t) => !t.waiting).slice(0, MAX_LISTED_TASKS);
|
|
375
|
+
const waiting = all.filter((t) => t.waiting).slice(0, MAX_LISTED_TASKS);
|
|
373
376
|
lines.push(owed.length
|
|
374
377
|
? `Your queue is gated — ${owed.length} confirmed task(s) are waiting on your rebase:`
|
|
375
378
|
: 'Your queue is gated — a confirmed task is waiting on your rebase.');
|
|
@@ -377,6 +380,8 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
|
|
|
377
380
|
lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''}`);
|
|
378
381
|
if (t.reason) lines.push(` flagged: ${clip(t.reason)}`);
|
|
379
382
|
}
|
|
383
|
+
if (waiting.length) lines.push(' Waiting, not on you (set aside; they land on their own):');
|
|
384
|
+
for (const t of waiting) lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''} — ${clip(t.waits_on || t.reason || 'outside you')}`);
|
|
380
385
|
lines.push(` ${clip(d.hint, 600) || 'Clear it with /strand-fix N, or rebase the branch and re-ship — or /builder-release it to give up on one.'}`);
|
|
381
386
|
return { lines, exit: QUEUE_GATED_EXIT };
|
|
382
387
|
}
|
|
@@ -598,6 +598,8 @@ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () =>
|
|
|
598
598
|
log(` - ${a.before}`);
|
|
599
599
|
log(` + ${a.after}`);
|
|
600
600
|
}
|
|
601
|
+
// task 1004776: the submit's guarded-line warnings, so whoever applies sees them too.
|
|
602
|
+
for (const w of batch.warnings || []) log(` ${w.key} WARNING: no longer says what it must: ${w.must_still_say}${w.guarded_by ? ` (guarded by ${w.guarded_by})` : ''}`);
|
|
601
603
|
for (const x of result.refused) {
|
|
602
604
|
log(` ${x.key} REFUSED (${x.code})${x.detail && x.detail.what_this_means ? `: ${x.detail.what_this_means}` : ''}`);
|
|
603
605
|
log(` line: ${JSON.stringify(x.before)}`);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/module-promotion.js — the promotion gate: when a module version on
|
|
4
|
+
// the tester channel moves to general, and when it is held (task 1003804, ADR 0344
|
|
5
|
+
// D3; owner decisions 2026-10-23 recorded on the task).
|
|
6
|
+
//
|
|
7
|
+
// THE RULE, in the order it is applied:
|
|
8
|
+
// 1. A version only ever moves while its Security gate has passed and its own
|
|
9
|
+
// tests pass (ADR 0343). Tests pass = every test file passed (score 100), or
|
|
10
|
+
// the module declares no tests (`no_data` is not a fail). Anything else —
|
|
11
|
+
// failed, pending, not yet run — waits; it is never held for it.
|
|
12
|
+
// 2. HOLD: any tester reports a failed install, or any runtime error, on the
|
|
13
|
+
// version. A held version stays on tester until a Metic+ person releases it
|
|
14
|
+
// (`module.delist`, owner 2026-10-23) or a newer version replaces it.
|
|
15
|
+
// 3. EARLY: from day 2 on tester, 5 or more holders reported a clean install and
|
|
16
|
+
// nobody reported a problem.
|
|
17
|
+
// 4. SEVEN DAYS: on tester for 7 days. This applies whether or not any tester
|
|
18
|
+
// picked it up, so a module nobody volunteers for still gets updated — a
|
|
19
|
+
// version is only ever held on evidence, never for lack of testers.
|
|
20
|
+
//
|
|
21
|
+
// Every report on a tester-channel version comes from a tester, because only
|
|
22
|
+
// testers are handed one (task 1003805), so the reports are counted as they are.
|
|
23
|
+
//
|
|
24
|
+
// A move is a NEW store_module_version_channels row (core_276), never an update:
|
|
25
|
+
// set_by NULL when the sweep moves it, the person when a person releases it, and a
|
|
26
|
+
// short reason code. decidePromotion is the whole rule and is pure, so the tests
|
|
27
|
+
// drive it directly; sweepPromotions and releaseHeld are the two writers.
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const PROMOTE_AFTER_DAYS = 7;
|
|
31
|
+
const EARLY_AFTER_DAYS = 2;
|
|
32
|
+
const EARLY_CLEAN_INSTALLS = 5;
|
|
33
|
+
const SWEEP_LIMIT = 200;
|
|
34
|
+
|
|
35
|
+
const REASONS = Object.freeze({
|
|
36
|
+
AFTER_7_DAYS: 'promoted_after_7_days',
|
|
37
|
+
EARLY: 'promoted_early_clean_installs',
|
|
38
|
+
HELD: 'held_tester_errors',
|
|
39
|
+
RELEASED: 'released_by_person',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function poolOf(db) { return db || require('./pool').pool; }
|
|
43
|
+
|
|
44
|
+
// Did the ADR 0343 checks pass? signals: the current row per part.
|
|
45
|
+
function checksPass({ security, tests } = {}) {
|
|
46
|
+
if (!security || security.outcome !== 'passed') return false;
|
|
47
|
+
if (!tests) return false;
|
|
48
|
+
if (tests.outcome === 'no_data') return true;
|
|
49
|
+
return tests.outcome === 'scored' && Number(tests.score) === 100;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The rule. evidence: { onTesterSince, held, security, tests, cleanInstalls,
|
|
53
|
+
// failedInstalls, runtimeErrors }. Returns { action: 'promote'|'hold'|'wait',
|
|
54
|
+
// reason, days_on_tester, ... } — the same object an author reads.
|
|
55
|
+
function decidePromotion(evidence, now = new Date()) {
|
|
56
|
+
const since = new Date(evidence.onTesterSince);
|
|
57
|
+
const days = Math.max(0, (now.getTime() - since.getTime()) / DAY_MS);
|
|
58
|
+
const base = {
|
|
59
|
+
days_on_tester: Math.floor(days),
|
|
60
|
+
clean_installs: evidence.cleanInstalls || 0,
|
|
61
|
+
failed_installs: evidence.failedInstalls || 0,
|
|
62
|
+
runtime_errors: evidence.runtimeErrors || 0,
|
|
63
|
+
checks_passed: checksPass(evidence),
|
|
64
|
+
general_on: new Date(since.getTime() + PROMOTE_AFTER_DAYS * DAY_MS).toISOString(),
|
|
65
|
+
};
|
|
66
|
+
if (evidence.held) return { action: 'wait', reason: 'held_until_released', ...base };
|
|
67
|
+
if (base.failed_installs > 0 || base.runtime_errors > 0) return { action: 'hold', reason: REASONS.HELD, ...base };
|
|
68
|
+
if (!base.checks_passed) return { action: 'wait', reason: 'checks_not_passed', ...base };
|
|
69
|
+
if (days >= PROMOTE_AFTER_DAYS) return { action: 'promote', reason: REASONS.AFTER_7_DAYS, ...base };
|
|
70
|
+
if (days >= EARLY_AFTER_DAYS && base.clean_installs >= EARLY_CLEAN_INSTALLS) {
|
|
71
|
+
return { action: 'promote', reason: REASONS.EARLY, ...base };
|
|
72
|
+
}
|
|
73
|
+
return { action: 'wait', reason: 'waiting_for_time_or_installs', ...base };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The evidence for every version currently on tester (optionally one version),
|
|
77
|
+
// bounded. onTesterSince is when it reached tester (its first tester row since
|
|
78
|
+
// it was last off tester — in practice its publish), held is whether its current
|
|
79
|
+
// row is a hold.
|
|
80
|
+
async function readTesterEvidence({ db, versionId = null, limit = SWEEP_LIMIT } = {}) {
|
|
81
|
+
const pool = poolOf(db);
|
|
82
|
+
const { rows } = await pool.query(
|
|
83
|
+
// One probe per version into idx_store_module_version_channels_current (its
|
|
84
|
+
// newest row, LIMIT 1), never a DISTINCT ON over every version's whole move
|
|
85
|
+
// history: the work grows with versions, not with how often they moved.
|
|
86
|
+
`SELECT v.id AS version_id, v.module_key, v.version, cur.reason AS current_reason,
|
|
87
|
+
(SELECT min(t.set_at) FROM store_module_version_channels t
|
|
88
|
+
WHERE t.version_id = v.id AND t.channel = 'tester') AS on_tester_since,
|
|
89
|
+
(SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND r.ok)::int AS clean_installs,
|
|
90
|
+
(SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND NOT r.ok)::int AS failed_installs,
|
|
91
|
+
(SELECT coalesce(sum(e.error_count), 0) FROM module_runtime_error_counts e WHERE e.version_id = v.id)::int AS runtime_errors
|
|
92
|
+
FROM store_module_versions v
|
|
93
|
+
CROSS JOIN LATERAL (
|
|
94
|
+
SELECT c.channel, c.reason FROM store_module_version_channels c
|
|
95
|
+
WHERE c.version_id = v.id ORDER BY c.set_at DESC, c.id DESC LIMIT 1
|
|
96
|
+
) cur
|
|
97
|
+
WHERE cur.channel = 'tester' AND ($1::bigint IS NULL OR v.id = $1)
|
|
98
|
+
ORDER BY v.id
|
|
99
|
+
LIMIT $2`, [versionId, limit]);
|
|
100
|
+
if (!rows.length) return [];
|
|
101
|
+
const { rows: signals } = await pool.query(
|
|
102
|
+
`SELECT DISTINCT ON (version_id, part) version_id, part, outcome, score
|
|
103
|
+
FROM module_assessment_signals
|
|
104
|
+
WHERE version_id = ANY($1) AND part IN ('security', 'tests')
|
|
105
|
+
ORDER BY version_id, part, measured_at DESC, id DESC`, [rows.map((r) => r.version_id)]);
|
|
106
|
+
const parts = new Map();
|
|
107
|
+
for (const s of signals) {
|
|
108
|
+
const k = String(s.version_id);
|
|
109
|
+
if (!parts.has(k)) parts.set(k, {});
|
|
110
|
+
parts.get(k)[s.part] = s;
|
|
111
|
+
}
|
|
112
|
+
return rows.map((r) => ({
|
|
113
|
+
versionId: r.version_id,
|
|
114
|
+
moduleKey: r.module_key,
|
|
115
|
+
version: r.version,
|
|
116
|
+
onTesterSince: r.on_tester_since,
|
|
117
|
+
currentReason: r.current_reason,
|
|
118
|
+
held: r.current_reason === REASONS.HELD,
|
|
119
|
+
cleanInstalls: r.clean_installs,
|
|
120
|
+
failedInstalls: r.failed_installs,
|
|
121
|
+
runtimeErrors: r.runtime_errors,
|
|
122
|
+
security: (parts.get(String(r.version_id)) || {}).security || null,
|
|
123
|
+
tests: (parts.get(String(r.version_id)) || {}).tests || null,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Insert the move only if the version's current row is still the one we read, so
|
|
128
|
+
// a sweep racing a release (or another sweep) writes at most one move.
|
|
129
|
+
async function insertMove(pool, { versionId, channel, reason, setBy = null, fromReason }) {
|
|
130
|
+
const { rowCount } = await pool.query(
|
|
131
|
+
`INSERT INTO store_module_version_channels (version_id, channel, reason, set_by)
|
|
132
|
+
SELECT $1, $2, $3, $4
|
|
133
|
+
WHERE (SELECT c.reason FROM store_module_version_channels c
|
|
134
|
+
WHERE c.version_id = $1 ORDER BY c.set_at DESC, c.id DESC LIMIT 1) = $5`,
|
|
135
|
+
[versionId, channel, reason, setBy, fromReason]);
|
|
136
|
+
return rowCount === 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// One pass: promote or hold every version on tester the rule says to. Returns
|
|
140
|
+
// { promoted: [...], held: [...] }. Per-version failures are isolated.
|
|
141
|
+
async function sweepPromotions({ db, now = new Date(), log = null } = {}) {
|
|
142
|
+
const pool = poolOf(db);
|
|
143
|
+
const out = { promoted: [], held: [] };
|
|
144
|
+
const evidence = await readTesterEvidence({ db: pool });
|
|
145
|
+
for (const e of evidence) {
|
|
146
|
+
const d = decidePromotion(e, now);
|
|
147
|
+
if (d.action === 'wait') continue;
|
|
148
|
+
try {
|
|
149
|
+
const channel = d.action === 'promote' ? 'general' : 'tester';
|
|
150
|
+
const moved = await insertMove(pool, { versionId: e.versionId, channel, reason: d.reason, fromReason: e.currentReason });
|
|
151
|
+
if (!moved) continue;
|
|
152
|
+
const entry = { version_id: String(e.versionId), module_key: e.moduleKey, version: e.version, reason: d.reason };
|
|
153
|
+
(d.action === 'promote' ? out.promoted : out.held).push(entry);
|
|
154
|
+
if (log) log.info(entry, `${e.moduleKey} ${e.version}: ${d.reason}`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (log) log.warn({ versionId: e.versionId, err: err.message }, 'promotion move failed; the next sweep retries');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Where one version stands against the rule, for its author. null when the
|
|
163
|
+
// version has no channel row.
|
|
164
|
+
async function readStanding(versionId, { db, now = new Date() } = {}) {
|
|
165
|
+
const pool = poolOf(db);
|
|
166
|
+
const { rows } = await pool.query(
|
|
167
|
+
`SELECT channel, reason, set_by, set_at FROM store_module_version_channels
|
|
168
|
+
WHERE version_id = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [versionId]);
|
|
169
|
+
if (!rows.length) return null;
|
|
170
|
+
const cur = rows[0];
|
|
171
|
+
const standing = {
|
|
172
|
+
channel: cur.channel,
|
|
173
|
+
reason: cur.reason,
|
|
174
|
+
set_at: cur.set_at,
|
|
175
|
+
moved_by: cur.set_by == null ? 'system' : String(cur.set_by),
|
|
176
|
+
rule: RULE_TEXT,
|
|
177
|
+
};
|
|
178
|
+
if (cur.channel !== 'tester') return standing;
|
|
179
|
+
const [e] = await readTesterEvidence({ db: pool, versionId });
|
|
180
|
+
if (!e) return standing;
|
|
181
|
+
return { ...standing, next: decidePromotion(e, now) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A Metic+ person releases a HELD version to general. Only a held version: a
|
|
185
|
+
// version still waiting for its time is the rule's to move, not a person's.
|
|
186
|
+
async function releaseHeld(versionId, builderId, { db } = {}) {
|
|
187
|
+
const pool = poolOf(db);
|
|
188
|
+
const moved = await insertMove(pool, {
|
|
189
|
+
versionId, channel: 'general', reason: REASONS.RELEASED, setBy: builderId, fromReason: REASONS.HELD,
|
|
190
|
+
});
|
|
191
|
+
if (!moved) {
|
|
192
|
+
return { ok: false, code: 'not_held', status: 409, message: 'Only a version held on the tester channel can be released by a person. Others move on their own after seven days.' };
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, channel: 'general', reason: REASONS.RELEASED };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const RULE_TEXT = `A new version goes to testers first. It reaches everyone after ${PROMOTE_AFTER_DAYS} days, `
|
|
198
|
+
+ `or after ${EARLY_AFTER_DAYS} days once ${EARLY_CLEAN_INSTALLS} or more testers installed it cleanly, `
|
|
199
|
+
+ 'as long as its security check and its own tests pass. Any failed tester install or runtime error '
|
|
200
|
+
+ 'holds it on testers until a reviewer releases it.';
|
|
201
|
+
|
|
202
|
+
// The in-process sweep, started by src/platform-server.js after listen (never by
|
|
203
|
+
// a test). Hourly is plenty for a rule counted in days. MODULE_PROMOTION_DISABLED=1
|
|
204
|
+
// switches it off.
|
|
205
|
+
const SWEEP_MS = 60 * 60 * 1000;
|
|
206
|
+
let timer = null;
|
|
207
|
+
|
|
208
|
+
function start({ env = process.env, log = require('./logger').logger.child({ src: 'module-promotion' }) } = {}) {
|
|
209
|
+
if (env.MODULE_PROMOTION_DISABLED === '1' || timer) return false;
|
|
210
|
+
const run = () => sweepPromotions({ log }).catch((err) => log.warn({ err: err.message }, 'promotion sweep failed'));
|
|
211
|
+
setTimeout(run, 30_000).unref();
|
|
212
|
+
timer = setInterval(run, SWEEP_MS);
|
|
213
|
+
timer.unref();
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start,
|
|
219
|
+
};
|
|
@@ -245,6 +245,9 @@ const EXPECTED_RANKS = {
|
|
|
245
245
|
// Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
|
|
246
246
|
// it for every instance, so it is pinned to its own atom.
|
|
247
247
|
'POST /store/modules/:key/delist': 'perm:module.delist',
|
|
248
|
+
// Releasing a version the promotion gate held on tester (task 1003804) hands it to
|
|
249
|
+
// every project; the owner put it on the delist atom (2026-10-23), so it is pinned.
|
|
250
|
+
'POST /store/modules/:key/versions/:version/release': 'perm:module.delist',
|
|
248
251
|
};
|
|
249
252
|
|
|
250
253
|
// The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
|
|
@@ -320,6 +323,10 @@ const GOVERNMENT_PINS = {
|
|
|
320
323
|
'POST /government/asks/check': 'perm:board.vote.cast',
|
|
321
324
|
'PUT /government/asks/:matterId': 'perm:board.vote.cast',
|
|
322
325
|
'POST /government/asks/:matterId/pass': 'perm:board.vote.cast',
|
|
326
|
+
// the reword list (owner 2026-10-23): a reader flags a card it cannot follow,
|
|
327
|
+
// a session reads the open flags; both under the docket's own atom
|
|
328
|
+
'POST /government/asks/:matterId/unclear': 'perm:board.vote.cast',
|
|
329
|
+
'GET /government/asks/unclear': 'perm:board.vote.cast',
|
|
323
330
|
// task 1004564: a proposer withdraws their own open sitting before any vote —
|
|
324
331
|
// the proposer's half of putting one up, so the same atom. The proposer-only
|
|
325
332
|
// and no-votes walls are in withdrawItem (board-withdraw.js) and its write.
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -184,6 +184,15 @@ module.exports = function buildMeRouter() {
|
|
|
184
184
|
// same GET /me -> Conductor path, deliberately not a second mechanism.
|
|
185
185
|
// default-on, so present in prod; null when the module is off.
|
|
186
186
|
const specialities = seams.resolveOptional('specialities');
|
|
187
|
+
// task 1004777: stuck page tweaks this builder should be told about, through
|
|
188
|
+
// the copy-desk PORT (core never imports the module, ADR 0093). Started here so
|
|
189
|
+
// it runs beside the fan-out below; [] when copy-desk is off or the read fails.
|
|
190
|
+
const stuckPageTweaksP = (async () => {
|
|
191
|
+
try {
|
|
192
|
+
const port = seams.resolveOptional('copy-desk.stuckPageTweaksFor');
|
|
193
|
+
return typeof port === 'function' ? await port(req.builder.id, req.builder.rank) : [];
|
|
194
|
+
} catch (_) { return []; }
|
|
195
|
+
})();
|
|
187
196
|
const [
|
|
188
197
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
189
198
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
|
|
@@ -593,6 +602,8 @@ module.exports = function buildMeRouter() {
|
|
|
593
602
|
openTaskRecommendations: taskRecommendationCount,
|
|
594
603
|
// task 1003178 (C08): open mingle introductions waiting on this builder.
|
|
595
604
|
openMingleIntroductions: mingleIntroductionCount,
|
|
605
|
+
// task 1004777: one Needs-attention line per stuck page tweak.
|
|
606
|
+
stuckPageTweaks: await stuckPageTweaksP,
|
|
596
607
|
}) : [],
|
|
597
608
|
};
|
|
598
609
|
// Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
|
|
@@ -112,6 +112,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
|
|
|
112
112
|
const { listOverrides, setOverride } = require('../module-overrides');
|
|
113
113
|
const { listCatalog, SORT_PARTS } = require('../module-listing');
|
|
114
114
|
const { readTesting, setTester, setGeneralOnly } = require('../module-testing');
|
|
115
|
+
const { readStanding, releaseHeld } = require('../module-promotion');
|
|
115
116
|
|
|
116
117
|
// The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
|
|
117
118
|
// module's pages, 'pollers' its background jobs. PURE.
|
|
@@ -578,6 +579,51 @@ module.exports = function buildModulesRouter() {
|
|
|
578
579
|
res.json({ ok: true, module_key: key, status: r.module.status, delisted_at: r.module.delisted_at });
|
|
579
580
|
}));
|
|
580
581
|
|
|
582
|
+
// GET /api/bongos/store/modules/:key/versions/:version/channel — where a version
|
|
583
|
+
// stands on the promotion gate (task 1003804, ADR 0344 D3): its current channel,
|
|
584
|
+
// who moved it there and why, the rule in plain words, and on tester the numbers
|
|
585
|
+
// the rule reads and what it is waiting for. Any signed-in builder may read it, so
|
|
586
|
+
// an author is never left guessing why a version has not reached everyone.
|
|
587
|
+
router.get('/store/modules/:key/versions/:version/channel', auth.requireBuilder,
|
|
588
|
+
asyncHandler('GET /store/modules/:key/versions/:version/channel', async (req, res) => {
|
|
589
|
+
const { key, version } = req.params;
|
|
590
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
591
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
592
|
+
}
|
|
593
|
+
const r = await resolveReadable(key, version);
|
|
594
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
595
|
+
const standing = await readStanding(r.version.id);
|
|
596
|
+
if (!standing) return res.fail('version_has_no_channel', { status: 404, message: `${key} ${version} has no release channel recorded.` });
|
|
597
|
+
res.set('Cache-Control', 'no-store');
|
|
598
|
+
res.json({ module_key: key, version, ...standing });
|
|
599
|
+
}));
|
|
600
|
+
|
|
601
|
+
// POST /api/bongos/store/modules/:key/versions/:version/release — a Metic+ person
|
|
602
|
+
// releases a version the promotion gate HELD on tester (a tester reported a failed
|
|
603
|
+
// install or errors) to general (task 1003804). Gated on module.delist: the owner
|
|
604
|
+
// ruled (2026-10-23) the people who may take a module out of the store are the ones
|
|
605
|
+
// who may let a held version out, with no new permission. Only a held version: one
|
|
606
|
+
// still waiting for its time is the rule's to move. A reason is required; the audit
|
|
607
|
+
// middleware records it with who released it, and set_by on the new channel row.
|
|
608
|
+
router.post('/store/modules/:key/versions/:version/release', auth.requireBuilder, auth.requirePermission('module.delist'),
|
|
609
|
+
asyncHandler('POST /store/modules/:key/versions/:version/release', async (req, res) => {
|
|
610
|
+
if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
|
|
611
|
+
const { key, version } = req.params;
|
|
612
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
613
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
614
|
+
}
|
|
615
|
+
const reason = String(req.body?.reason || '').trim();
|
|
616
|
+
if (!reason) {
|
|
617
|
+
return res.fail('reason_required', { status: 400, message: 'Say why this held version is safe for everyone, in reason.' });
|
|
618
|
+
}
|
|
619
|
+
const r = await resolveReadable(key, version);
|
|
620
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
621
|
+
const out = await releaseHeld(r.version.id, req.builder.id);
|
|
622
|
+
if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
|
|
623
|
+
log.info({ versionId: r.version.id, builderId: req.builder.id }, `${key} ${version} released from hold to general`);
|
|
624
|
+
res.json({ ok: true, module_key: key, version, channel: out.channel, reason: out.reason });
|
|
625
|
+
}));
|
|
626
|
+
|
|
581
627
|
// GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
|
|
582
628
|
// read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
|
|
583
629
|
// like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.168'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/src/platform-server.js
CHANGED
|
@@ -420,6 +420,11 @@ if (require.main === module) {
|
|
|
420
420
|
// starts the pollers never writes the tally or spawns the sender.
|
|
421
421
|
try { startErrorTally(); startErrorReports(); }
|
|
422
422
|
catch (e) { logger.warn({ err: e.message }, 'module error tally did not start; module error counts are not kept'); }
|
|
423
|
+
// The store's promotion gate (task 1003804): an hourly sweep moves tester-channel
|
|
424
|
+
// versions to general, or holds them, by ADR 0344 D3's rule. Here, not in
|
|
425
|
+
// startBackgroundServices(), so a test that starts the pollers writes no channel row.
|
|
426
|
+
try { const { start: startPromotionSweep } = require('./bongos/module-promotion'); startPromotionSweep(); }
|
|
427
|
+
catch (e) { logger.warn({ err: e.message }, 'module promotion sweep did not start; tester versions will not move to general'); }
|
|
423
428
|
});
|
|
424
429
|
})().catch((err) => {
|
|
425
430
|
// THE AWAIT ABOVE MUST NOT SOFTEN A FATAL BOOT (task 1002857, caught by
|
|
@@ -13,6 +13,10 @@ import { strict as assert } from 'node:assert';
|
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
14
|
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
|
+
// task 1004788: fixable strands gate only at REBASE_GATE_LIMIT (default 3). This
|
|
17
|
+
// suite's fake builder owes ONE, and what it pins is the P1-infra exemption from
|
|
18
|
+
// a gate that IS shut, so the limit is set to 1 to keep the gate shut.
|
|
19
|
+
process.env.REBASE_GATE_LIMIT = '1';
|
|
16
20
|
|
|
17
21
|
let passed = 0;
|
|
18
22
|
let failed = 0;
|
|
@@ -14,6 +14,9 @@ import { strict as assert } from 'node:assert';
|
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
15
|
const { Pool } = require('pg');
|
|
16
16
|
|
|
17
|
+
// Cases 1-7 predate task 1004788 and pin the gate's original single-strand shape,
|
|
18
|
+
// so they run with the limit at 1; cases 8-10 move it back to the default (3).
|
|
19
|
+
process.env.REBASE_GATE_LIMIT = '1';
|
|
17
20
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
18
21
|
try {
|
|
19
22
|
await pool.query('SELECT 1');
|
|
@@ -32,13 +35,16 @@ const VER = 'ZZZ-CG', WORKER = 90000201, STALLED = 90000201, FRESH = 90000202;
|
|
|
32
35
|
const AGED = 90000203, FRESH2 = 90000204, AGED_OTHER = 90000205;
|
|
33
36
|
// task 1004370 — the CI-unblock exemption.
|
|
34
37
|
const CIFIX = 90000206;
|
|
38
|
+
// task 1004788 — the set-aside and the limit.
|
|
39
|
+
const GUARDED = 90000207, FRESH3 = 90000208, FIX1 = 90000209, FIX2 = 90000210, FIX3 = 90000211;
|
|
40
|
+
const ALL_IDS = [90000201, 90000202, 90000203, 90000204, 90000205, 90000206, 90000207, 90000208, 90000209, 90000210, 90000211];
|
|
35
41
|
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
|
|
36
42
|
|
|
37
43
|
async function cleanup() {
|
|
38
44
|
await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
|
|
39
|
-
await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER,
|
|
40
|
-
await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [
|
|
41
|
-
await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [
|
|
45
|
+
await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, ALL_IDS]).catch(() => {});
|
|
46
|
+
await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
47
|
+
await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
42
48
|
await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
|
|
43
49
|
await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
|
|
44
50
|
}
|
|
@@ -151,6 +157,53 @@ try {
|
|
|
151
157
|
ok((await db.needsRebaseTasksForBuilder(String(WORKER))).some((t) => Number(t.id) === AGED_OTHER),
|
|
152
158
|
'and the owed rebase is still reported: the exemption lets one claim through, it clears nothing');
|
|
153
159
|
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// task 1004788 — one stuck land must not block every claim. A strand parked on
|
|
162
|
+
// a SERVER-SIDE guard is set aside (never gates, still reported); fixable
|
|
163
|
+
// strands gate only once REBASE_GATE_LIMIT pile up; and the batch dry run and
|
|
164
|
+
// the batch claim ask the same question as claimTask.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// 8) Limit still 1, AGED_OTHER (fixable) cleared: a server-guard strand alone does not gate.
|
|
167
|
+
await q(`UPDATE tasks SET status='shipped', needs_rebase=false WHERE id=$1`, [AGED_OTHER]);
|
|
168
|
+
await q(`UPDATE claims SET released_at = now() WHERE task_id = ANY($1::bigint[]) AND released_at IS NULL`, [[CIFIX]]);
|
|
169
|
+
await seedStrand(GUARDED, 'strand:branch_modifies_executed_code:.gitattributes', 1);
|
|
170
|
+
await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind) VALUES ($1,$2,'fresh3','ready',10,'feature')`, [FRESH3, VER]);
|
|
171
|
+
const claim3 = await db.claimTask({ taskId: String(FRESH3), builderId: String(WORKER), worktreeName: 'cg-fresh3' });
|
|
172
|
+
ok(claim3 && Number(claim3.task_id) === FRESH3, 'a confirmed task parked on a server-side guard does not refuse a new claim');
|
|
173
|
+
ok((await db.needsRebaseTasksForBuilder(String(WORKER))).some((t) => Number(t.id) === GUARDED),
|
|
174
|
+
'the set-aside task is still reported as owed, never dropped');
|
|
175
|
+
await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH3]);
|
|
176
|
+
await q(`UPDATE tasks SET status='ready' WHERE id=$1`, [FRESH3]);
|
|
177
|
+
|
|
178
|
+
// 9) Default limit (3): one and two fixable strands do not gate; the third does.
|
|
179
|
+
delete process.env.REBASE_GATE_LIMIT;
|
|
180
|
+
await seedStrand(FIX1, 'strand:unresolvable_conflict:docs/x.json', 1);
|
|
181
|
+
await seedStrand(FIX2, 'strand:pr_not_landed', 1);
|
|
182
|
+
const v2 = await db.validateClaimBatch({ taskIds: [FRESH3], builderId: String(WORKER) });
|
|
183
|
+
ok(!JSON.stringify(v2).includes('REBASE_REQUIRED'), 'two fixable strands (+ one set aside) do not gate the dry run');
|
|
184
|
+
const claim4 = await db.claimTask({ taskId: String(FRESH3), builderId: String(WORKER), worktreeName: 'cg-fresh3b' });
|
|
185
|
+
ok(claim4 && Number(claim4.task_id) === FRESH3, 'two fixable strands do not refuse a claim');
|
|
186
|
+
await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH3]);
|
|
187
|
+
await q(`UPDATE tasks SET status='ready' WHERE id=$1`, [FRESH3]);
|
|
188
|
+
|
|
189
|
+
await seedStrand(FIX3, 'strand:pr_not_landed', 1);
|
|
190
|
+
let threw3 = null;
|
|
191
|
+
try { await db.claimTask({ taskId: String(FRESH3), builderId: String(WORKER), worktreeName: 'cg-fresh3c' }); } catch (e) { threw3 = e; }
|
|
192
|
+
ok(threw3 && threw3.code === 'REBASE_REQUIRED', 'fixable strands at the limit still refuse');
|
|
193
|
+
const named = (threw3.rebaseTasks || []);
|
|
194
|
+
ok(named.filter((t) => !t.waiting).map((t) => Number(t.id)).sort().join() === [FIX1, FIX2, FIX3].sort().join(),
|
|
195
|
+
'the refusal names exactly the fixable tasks as gating');
|
|
196
|
+
ok(named.some((t) => t.waiting && Number(t.id) === GUARDED && /server-side guard/.test(t.waits_on)),
|
|
197
|
+
'and lists the set-aside one as waiting, with what it waits on');
|
|
198
|
+
|
|
199
|
+
// 10) The batch dry run and the batch claim agree with claimTask.
|
|
200
|
+
const v3 = await db.validateClaimBatch({ taskIds: [FRESH3], builderId: String(WORKER) });
|
|
201
|
+
ok(JSON.stringify(v3).includes('REBASE_REQUIRED'), 'the batch dry run refuses exactly when claimTask does');
|
|
202
|
+
let batchOut = null, batchErr = null;
|
|
203
|
+
try { batchOut = await db.claimTasksBatch({ taskIds: [FRESH3], builderId: String(WORKER), worktreePrefix: 'cg-b' }); } catch (e) { batchErr = e; }
|
|
204
|
+
ok(JSON.stringify(batchOut || batchErr).includes('REBASE_REQUIRED'), 'the batch claim refuses too');
|
|
205
|
+
ok((await q(`SELECT status FROM tasks WHERE id=$1`, [FRESH3]))[0].status === 'ready', 'and claims nothing');
|
|
206
|
+
|
|
154
207
|
console.log(`\nclaim_gate_rebase (ADR 0120 part 3): ${pass} passed, 0 failed`);
|
|
155
208
|
} catch (e) {
|
|
156
209
|
console.error('claim_gate_rebase.mjs FAILED:', e.stack || e.message);
|
|
@@ -104,7 +104,7 @@ try {
|
|
|
104
104
|
['L0004', 'greeting', 'Your desk is quiet today', 'engineer'],
|
|
105
105
|
['L0005', 'footer', 'Resume where you left off', null],
|
|
106
106
|
]);
|
|
107
|
-
assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer' });
|
|
107
|
+
assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
|
|
108
108
|
assert.equal(r.body.round, null);
|
|
109
109
|
assert.equal(r.body.draft, null);
|
|
110
110
|
assert.deepEqual(r.body.counter, { changed: 0, total: 5 });
|