@bongos/core 1.21.73 → 1.21.74
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 -31
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +44 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +139 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +18 -14
- package/docs/copy-registry.json +72 -26
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +8 -8
- package/modules/hall-ui/public/project-settings.css +15 -0
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/project-settings.js +42 -0
- package/modules/lifecycle/routes/project-settings.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/shipping-mode.js +71 -0
- package/src/bongos/project-settings.js +67 -0
- package/src/module-api.js +7 -1
- package/tests/project_settings_page.mjs +8 -5
- package/tests/ship_mode.mjs +171 -0
|
@@ -34,6 +34,29 @@ const goalCategory = require('../goal-category');
|
|
|
34
34
|
|
|
35
35
|
const log = api.logger('lifecycle');
|
|
36
36
|
|
|
37
|
+
// --- ship mode: who starts a ship (task 1004628, ADR 0369) -------------------
|
|
38
|
+
//
|
|
39
|
+
// One row shape, two readers: the settings page (inside GET /project-settings) and
|
|
40
|
+
// any builder's CLI or AI session (GET /project-settings/ship-mode), so the sentence
|
|
41
|
+
// a person reads and the one an agent obeys are the same words.
|
|
42
|
+
function shipModeRow(resolved) {
|
|
43
|
+
return {
|
|
44
|
+
key: 'ship_mode',
|
|
45
|
+
title: 'Ship mode',
|
|
46
|
+
editable: true,
|
|
47
|
+
state: 'live',
|
|
48
|
+
value: { mode: resolved.mode, chosen: resolved.chosen, modes: api.projectSettings.SHIP_MODES },
|
|
49
|
+
sentence: api.projectSettings.describeShipMode(resolved),
|
|
50
|
+
choices: [
|
|
51
|
+
{ mode: 'auto', label: 'Auto (recommended)', sentence: 'When the AI finishes a task it ships and lands it. Nobody has to type a command.' },
|
|
52
|
+
{ mode: 'manual', label: 'Manual', sentence: 'The AI stops before each ship and asks a person to approve it.' },
|
|
53
|
+
],
|
|
54
|
+
source: resolved.source,
|
|
55
|
+
invalid: resolved.invalid,
|
|
56
|
+
reads: { file: 'project_settings[\'ship.mode\'] (migration core_231)', adr: 'ADR 0369' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
// --- the three rows that are READ, never written -----------------------------
|
|
38
61
|
//
|
|
39
62
|
// Each names the committed artefact it resolves from, because "you cannot edit this"
|
|
@@ -214,12 +237,14 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
214
237
|
// Inactive included: the set's SHAPE is what this page edits, and a deactivated
|
|
215
238
|
// category the owner cannot see is one they cannot bring back.
|
|
216
239
|
const categories = await goalCategory.listCategories({ includeInactive: true });
|
|
240
|
+
const shipMode = await api.projectSettings.resolveShipMode();
|
|
217
241
|
|
|
218
242
|
// A settings read is a configuration comparison — a cached one is wrong the
|
|
219
243
|
// moment somebody turns a knob (the rot.js precedent).
|
|
220
244
|
res.set('Cache-Control', 'no-store');
|
|
221
245
|
res.json({
|
|
222
246
|
rows: [
|
|
247
|
+
shipModeRow(shipMode),
|
|
223
248
|
{
|
|
224
249
|
key: 'rot_timer',
|
|
225
250
|
title: 'Stale timer',
|
|
@@ -316,7 +341,47 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
316
341
|
}
|
|
317
342
|
});
|
|
318
343
|
|
|
344
|
+
// GET /project-settings/ship-mode — the ship mode alone, for the CLI and the AI.
|
|
345
|
+
//
|
|
346
|
+
// rank: any signed-in builder. Unlike the full settings read this carries no
|
|
347
|
+
// reward internals or file paths an agent could not already read, and every
|
|
348
|
+
// builder's AI session must know whether to ship on its own or stop and ask —
|
|
349
|
+
// gating it Metic+ would leave a newcomer's session guessing.
|
|
350
|
+
router.get('/project-settings/ship-mode', api.requireBuilder, async (req, res) => {
|
|
351
|
+
try {
|
|
352
|
+
const resolved = await api.projectSettings.resolveShipMode();
|
|
353
|
+
res.set('Cache-Control', 'no-store');
|
|
354
|
+
const row = shipModeRow(resolved);
|
|
355
|
+
res.json({ mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, sentence: row.sentence, choices: row.choices });
|
|
356
|
+
} catch (err) {
|
|
357
|
+
log.error(`GET /project-settings/ship-mode: ${err && err.message}`);
|
|
358
|
+
res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// PATCH /project-settings/ship-mode — body { mode: 'auto' | 'manual' }.
|
|
363
|
+
//
|
|
364
|
+
// rank: project.settings.manage (metic+, government_010) — the same atom as the
|
|
365
|
+
// other writes on this page. Validated with the resolver's own parser, so the
|
|
366
|
+
// page, the CLI and the reader can never disagree about what a mode is.
|
|
367
|
+
router.patch('/project-settings/ship-mode', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
|
|
368
|
+
if (api.validateOrRespond(req, res, { mode: { required: true, type: 'string' } })) return;
|
|
369
|
+
const parsed = api.projectSettings.parseShipMode(req.body.mode);
|
|
370
|
+
if (!parsed.mode) {
|
|
371
|
+
return res.fail('bad_ship_mode', { status: 400, message: `mode: ${parsed.reason}` });
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await api.projectSettings.set(api.projectSettings.KEYS.SHIP_MODE, parsed.mode, req.builder.id);
|
|
375
|
+
const resolved = await api.projectSettings.resolveShipMode();
|
|
376
|
+
res.json({ ok: true, mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, sentence: api.projectSettings.describeShipMode(resolved) });
|
|
377
|
+
} catch (err) {
|
|
378
|
+
log.error(`PATCH /project-settings/ship-mode: ${err && err.message}`);
|
|
379
|
+
res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
319
383
|
return router;
|
|
320
384
|
};
|
|
321
385
|
|
|
322
386
|
module.exports.settingsDeskRows = settingsDeskRows;
|
|
387
|
+
module.exports.shipModeRow = shipModeRow;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.74",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.74",
|
|
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.21.
|
|
3
|
+
"version": "1.21.74",
|
|
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/release-notes.json
CHANGED
|
@@ -9248,5 +9248,11 @@
|
|
|
9248
9248
|
"id": "1004625",
|
|
9249
9249
|
"text": "The governor city now has its own tab, and in Governing mode it leads the sidebar with every other governing page tucked into one dropdown under it."
|
|
9250
9250
|
}
|
|
9251
|
+
],
|
|
9252
|
+
"1.21.74": [
|
|
9253
|
+
{
|
|
9254
|
+
"id": "1004628",
|
|
9255
|
+
"text": "Projects now have a Ship mode setting. Auto, the default, means the AI ships finished work on its own. Manual means it asks first. Owners can switch it on the project settings page."
|
|
9256
|
+
}
|
|
9251
9257
|
]
|
|
9252
9258
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/shipping-mode.js — read or set the project's ship mode (task 1004628, ADR 0369).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Ship mode says WHO starts a ship: in `auto` the AI finishes a task and ships
|
|
5
|
+
// it with no commands from the person; in `manual` it stops and asks first. An AI
|
|
6
|
+
// session has to know which before it ships, and a person has to be able to change
|
|
7
|
+
// it without opening the settings page — so the mode is one command away.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node scripts/gds/shipping-mode.js print the mode (any builder)
|
|
11
|
+
// node scripts/gds/shipping-mode.js auto|manual set it (needs project.settings.manage)
|
|
12
|
+
// node scripts/gds/shipping-mode.js --json print the mode as JSON
|
|
13
|
+
//
|
|
14
|
+
// Also `bongos ship-mode [auto|manual]`.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { apiCall, requireSession, errCode, cliExit } = require('./cli-lib');
|
|
19
|
+
|
|
20
|
+
const USAGE = 'usage: node scripts/gds/shipping-mode.js [auto|manual] [--json]';
|
|
21
|
+
|
|
22
|
+
// What a person sees. One sentence from the server, plus how to change it.
|
|
23
|
+
function describe(data) {
|
|
24
|
+
const lines = [`Ship mode: ${data.mode}${data.chosen ? '' : ' (default — not chosen yet)'}`];
|
|
25
|
+
if (data.sentence) lines.push(data.sentence);
|
|
26
|
+
lines.push(data.mode === 'manual'
|
|
27
|
+
? 'Change it: node scripts/gds/shipping-mode.js auto'
|
|
28
|
+
: 'Change it: node scripts/gds/shipping-mode.js manual');
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main(argv) {
|
|
33
|
+
const args = argv.filter((a) => a !== '--json');
|
|
34
|
+
const json = argv.includes('--json');
|
|
35
|
+
if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); return 0; }
|
|
36
|
+
if (args.length > 1) { console.error(USAGE); return 2; }
|
|
37
|
+
|
|
38
|
+
await requireSession();
|
|
39
|
+
|
|
40
|
+
if (args.length === 0) {
|
|
41
|
+
const r = await apiCall('GET', '/api/bongos/project-settings/ship-mode');
|
|
42
|
+
if (!r.ok) {
|
|
43
|
+
console.error(`Could not read the ship mode (${r.status}${errCode(r.data) ? `: ${errCode(r.data)}` : ''}).`);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
console.log(json ? JSON.stringify(r.data) : describe(r.data));
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mode = String(args[0]).trim().toLowerCase();
|
|
51
|
+
if (mode !== 'auto' && mode !== 'manual') { console.error(USAGE); return 2; }
|
|
52
|
+
const r = await apiCall('PATCH', '/api/bongos/project-settings/ship-mode', { mode });
|
|
53
|
+
if (!r.ok) {
|
|
54
|
+
if (r.status === 403) {
|
|
55
|
+
console.error('Only the project owner (or someone with project settings access) can change the ship mode.');
|
|
56
|
+
} else {
|
|
57
|
+
console.error(`Could not set the ship mode (${r.status}${errCode(r.data) ? `: ${errCode(r.data)}` : ''}).`);
|
|
58
|
+
}
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
console.log(json ? JSON.stringify(r.data) : `Saved. ${describe(r.data)}`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (require.main === module) {
|
|
66
|
+
main(process.argv.slice(2))
|
|
67
|
+
.then((code) => cliExit(code))
|
|
68
|
+
.catch((err) => { console.error('fatal:', err && err.message ? err.message : err); cliExit(1); });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { describe, main };
|
|
@@ -49,8 +49,20 @@ const { logger } = require('./logger');
|
|
|
49
49
|
const SETTING_KEYS = {
|
|
50
50
|
ROT_DAYS: 'attention.rot_days',
|
|
51
51
|
FOCUS_VERSION: 'roadmap.focus_version',
|
|
52
|
+
SHIP_MODE: 'ship.mode',
|
|
52
53
|
};
|
|
53
54
|
|
|
55
|
+
// SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
|
|
56
|
+
// auto — the AI finishes a task, then ships and lands it with no commands from
|
|
57
|
+
// the person. The default, because a new owner who is handed terminal
|
|
58
|
+
// commands at every ship reads the project as broken (owner feedback,
|
|
59
|
+
// 2026-10-08).
|
|
60
|
+
// manual — the AI stops before shipping and the person approves each ship.
|
|
61
|
+
// It is NOT the deploy mode (config/deploy.json, ci|laptop): that one decides the
|
|
62
|
+
// mechanics of landing once a ship starts, is committed config, and is unchanged.
|
|
63
|
+
const SHIP_MODES = Object.freeze(['auto', 'manual']);
|
|
64
|
+
const DEFAULT_SHIP_MODE = 'auto';
|
|
65
|
+
|
|
54
66
|
// The last-resort rot timer, in days. 30 because that is the shortest window in
|
|
55
67
|
// which "nobody has touched this" is evidence of rot rather than evidence of a
|
|
56
68
|
// normal week — a fortnight would fire on every ordinary holiday, and a quarter is
|
|
@@ -202,6 +214,56 @@ async function setFocusVersionId(versionId, builderId = null) {
|
|
|
202
214
|
return String(versionId);
|
|
203
215
|
}
|
|
204
216
|
|
|
217
|
+
// parseShipMode(raw) — one candidate as a ship mode, or why it is unusable. Pure,
|
|
218
|
+
// never throws, case-insensitive and trimmed so a hand-typed "Manual " still reads.
|
|
219
|
+
function parseShipMode(raw) {
|
|
220
|
+
if (raw == null || String(raw).trim() === '') return { reason: 'empty' };
|
|
221
|
+
const v = String(raw).trim().toLowerCase();
|
|
222
|
+
if (!SHIP_MODES.includes(v)) return { reason: `must be one of ${SHIP_MODES.join(' | ')}` };
|
|
223
|
+
return { mode: v };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// resolveShipMode() — the project's ship mode and where it came from.
|
|
227
|
+
// Returns { mode, source, chosen, invalid }:
|
|
228
|
+
// mode — always 'auto' or 'manual'
|
|
229
|
+
// source — 'project_settings' | 'default'
|
|
230
|
+
// chosen — true only when someone actually SET a usable value. "Never chosen" is
|
|
231
|
+
// a real answer the first-ship prompt (task 1004630) asks about, so it
|
|
232
|
+
// must not be confused with "chose the default".
|
|
233
|
+
// invalid — null, or { where, raw, reason } for a stored value that is unusable.
|
|
234
|
+
// A database that cannot be read answers the default rather than failing a ship —
|
|
235
|
+
// the same reporting posture as resolveRotDays.
|
|
236
|
+
async function resolveShipMode() {
|
|
237
|
+
let row = null;
|
|
238
|
+
try {
|
|
239
|
+
row = await getSetting(SETTING_KEYS.SHIP_MODE);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger.error(`project_settings read failed; falling back to the default ship mode: ${err && err.message}`);
|
|
242
|
+
return { mode: DEFAULT_SHIP_MODE, source: 'default', chosen: false, invalid: null };
|
|
243
|
+
}
|
|
244
|
+
if (row && row.value != null && row.value !== '') {
|
|
245
|
+
const got = parseShipMode(row.value);
|
|
246
|
+
if (got.mode) return { mode: got.mode, source: 'project_settings', chosen: true, invalid: null };
|
|
247
|
+
return {
|
|
248
|
+
mode: DEFAULT_SHIP_MODE,
|
|
249
|
+
source: 'default',
|
|
250
|
+
chosen: false,
|
|
251
|
+
invalid: { where: `project_settings['${SETTING_KEYS.SHIP_MODE}']`, raw: String(row.value), reason: got.reason },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { mode: DEFAULT_SHIP_MODE, source: 'default', chosen: false, invalid: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// describeShipMode(resolved) — one plain sentence for a person or an AI session.
|
|
258
|
+
function describeShipMode(resolved) {
|
|
259
|
+
const r = resolved || {};
|
|
260
|
+
const what = r.mode === 'manual'
|
|
261
|
+
? 'Manual: the AI stops before each ship and waits for a person to approve it.'
|
|
262
|
+
: 'Auto: when the AI finishes a task it ships and lands it with no commands from a person.';
|
|
263
|
+
const how = r.chosen ? '' : ' (the default — nobody has chosen yet)';
|
|
264
|
+
return `${what}${how}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
205
267
|
// parseDays is exported for the tests only — the range check and the default are
|
|
206
268
|
// asserted against the SAME constants the resolver reads, because a test that
|
|
207
269
|
// re-typed 30 or 365 would be the second copy of the number this file exists to
|
|
@@ -218,4 +280,9 @@ module.exports = {
|
|
|
218
280
|
describeRotTimer,
|
|
219
281
|
getFocusVersionId,
|
|
220
282
|
setFocusVersionId,
|
|
283
|
+
SHIP_MODES,
|
|
284
|
+
DEFAULT_SHIP_MODE,
|
|
285
|
+
parseShipMode,
|
|
286
|
+
resolveShipMode,
|
|
287
|
+
describeShipMode,
|
|
221
288
|
};
|
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.74'; // 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');
|
|
@@ -462,6 +462,12 @@ module.exports = {
|
|
|
462
462
|
// page's own anti-drift rule forbids, caught one layer up on task 1003280's grade.
|
|
463
463
|
get MIN_ROT_DAYS() { return require('./bongos/project-settings').MIN_ROT_DAYS; },
|
|
464
464
|
get MAX_ROT_DAYS() { return require('./bongos/project-settings').MAX_ROT_DAYS; },
|
|
465
|
+
// Ship mode (task 1004628, ADR 0369): who starts a ship — the AI (auto) or a
|
|
466
|
+
// person (manual). The settings page writes it; the CLI and the AI read it.
|
|
467
|
+
get SHIP_MODES() { return require('./bongos/project-settings').SHIP_MODES; },
|
|
468
|
+
get parseShipMode() { return require('./bongos/project-settings').parseShipMode; },
|
|
469
|
+
get resolveShipMode() { return require('./bongos/project-settings').resolveShipMode; },
|
|
470
|
+
get describeShipMode() { return require('./bongos/project-settings').describeShipMode; },
|
|
465
471
|
},
|
|
466
472
|
readConfigFileSync: instanceConfig.readConfigFileSync,
|
|
467
473
|
configHome: instanceConfig.configHome,
|
|
@@ -63,8 +63,10 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
63
63
|
resolveOptional: api.resolveOptional,
|
|
64
64
|
list: gc.listCategories,
|
|
65
65
|
set: api.projectSettings.set,
|
|
66
|
+
resolveShipMode: api.projectSettings.resolveShipMode,
|
|
66
67
|
};
|
|
67
68
|
api.resolveRotDays = async () => timer;
|
|
69
|
+
api.projectSettings.resolveShipMode = async () => ({ mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
68
70
|
// reward: WORKING_REWARD (default) | null (port absent) | 'throws' (rewardConfig raises).
|
|
69
71
|
const rewardPort = reward === 'throws'
|
|
70
72
|
? { rewardConfig: () => { throw new Error('reward boom'); } }
|
|
@@ -80,6 +82,7 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
80
82
|
api.resolveOptional = saved.resolveOptional;
|
|
81
83
|
gc.listCategories = saved.list;
|
|
82
84
|
api.projectSettings.set = saved.set;
|
|
85
|
+
api.projectSettings.resolveShipMode = saved.resolveShipMode;
|
|
83
86
|
};
|
|
84
87
|
// The handler is the LAST layer on the matching route (requireBuilder and the
|
|
85
88
|
// permission gate sit in front); calling it directly is what keeps this a unit test
|
|
@@ -99,22 +102,22 @@ const CATS = [
|
|
|
99
102
|
{ id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
|
|
100
103
|
];
|
|
101
104
|
|
|
102
|
-
test('one read returns all
|
|
105
|
+
test('one read returns all six fundamentals, each naming where its value came from', async () => {
|
|
103
106
|
const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
|
|
104
107
|
try {
|
|
105
108
|
const res = fakeRes();
|
|
106
109
|
await handlerFor('get', '/project-settings')({ query: {} }, res);
|
|
107
110
|
const rows = res.payload.rows;
|
|
108
|
-
assert.equal(rows.length,
|
|
111
|
+
assert.equal(rows.length, 6, 'the page draws six rows, so the read returns six');
|
|
109
112
|
assert.deepEqual(rows.map((r) => r.key),
|
|
110
|
-
['rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
113
|
+
['ship_mode', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
111
114
|
// Provenance is the point of the page: every row says where its value came from.
|
|
112
115
|
for (const r of rows) {
|
|
113
116
|
assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
|
|
114
117
|
assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
|
|
115
118
|
}
|
|
116
|
-
//
|
|
117
|
-
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['rot_timer', 'work_categories']);
|
|
119
|
+
// Three knobs: the criterion's two, plus ship mode (task 1004628).
|
|
120
|
+
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'rot_timer', 'work_categories']);
|
|
118
121
|
// A no-store receipt: a cached settings read is wrong the moment a knob turns.
|
|
119
122
|
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
120
123
|
} finally { restore(); }
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// tests/ship_mode.mjs — the project's ship mode: who starts a ship (task 1004628, ADR 0369).
|
|
2
|
+
//
|
|
3
|
+
// Pinned here:
|
|
4
|
+
// 1. the parser accepts exactly auto|manual (trimmed, any case) and explains a refusal;
|
|
5
|
+
// 2. the resolver defaults to AUTO, tells "never chosen" apart from "chose auto",
|
|
6
|
+
// reports an unusable stored value instead of throwing, and survives a dead DB;
|
|
7
|
+
// 3. the routes: any builder can READ it (the AI must know), the write validates
|
|
8
|
+
// through the same parser and echoes what the system will now read;
|
|
9
|
+
// 4. the CLI verb is registered and its text names how to change the mode.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/ship_mode.mjs
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
const poolMod = require(path.join(ROOT, 'src', 'bongos', 'pool.js'));
|
|
22
|
+
// Destructured from a literal relative path, so the dead-code scan (knip) can see
|
|
23
|
+
// these exports are used — a computed path.join() is invisible to it.
|
|
24
|
+
const {
|
|
25
|
+
SHIP_MODES, DEFAULT_SHIP_MODE, parseShipMode, resolveShipMode, describeShipMode,
|
|
26
|
+
} = require('../src/bongos/project-settings.js');
|
|
27
|
+
const ps = { SHIP_MODES, DEFAULT_SHIP_MODE, parseShipMode, resolveShipMode, describeShipMode };
|
|
28
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
29
|
+
|
|
30
|
+
// Swap the pool's query for the duration of one call; project-settings reads
|
|
31
|
+
// `pool.query` at call time, so this reaches the real SQL path.
|
|
32
|
+
async function withPool(query, fn) {
|
|
33
|
+
const pool = poolMod.pool;
|
|
34
|
+
const saved = pool.query;
|
|
35
|
+
pool.query = query;
|
|
36
|
+
try { return await fn(); } finally { pool.query = saved; }
|
|
37
|
+
}
|
|
38
|
+
const rowWith = (value) => async () => ({ rows: value == null ? [] : [{ key: 'ship.mode', value, updated_at: null, updated_by: 1 }] });
|
|
39
|
+
|
|
40
|
+
test('parseShipMode accepts exactly auto|manual, trimmed and in any case', () => {
|
|
41
|
+
assert.deepEqual([...ps.SHIP_MODES], ['auto', 'manual']);
|
|
42
|
+
assert.deepEqual(ps.parseShipMode('auto'), { mode: 'auto' });
|
|
43
|
+
assert.deepEqual(ps.parseShipMode(' Manual '), { mode: 'manual' });
|
|
44
|
+
assert.equal(ps.parseShipMode('').reason, 'empty');
|
|
45
|
+
assert.equal(ps.parseShipMode(null).reason, 'empty');
|
|
46
|
+
assert.match(ps.parseShipMode('yolo').reason, /auto \| manual/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the default is auto, and an unset project reads as NOT chosen', async () => {
|
|
50
|
+
assert.equal(ps.DEFAULT_SHIP_MODE, 'auto');
|
|
51
|
+
const r = await withPool(rowWith(null), () => ps.resolveShipMode());
|
|
52
|
+
assert.deepEqual(r, { mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a stored choice is read back as chosen — even when it equals the default', async () => {
|
|
56
|
+
const auto = await withPool(rowWith('auto'), () => ps.resolveShipMode());
|
|
57
|
+
assert.equal(auto.mode, 'auto');
|
|
58
|
+
assert.equal(auto.chosen, true, 'choosing auto is different from never choosing');
|
|
59
|
+
assert.equal(auto.source, 'project_settings');
|
|
60
|
+
const manual = await withPool(rowWith('manual'), () => ps.resolveShipMode());
|
|
61
|
+
assert.equal(manual.mode, 'manual');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an unusable stored value falls back to auto and is reported, not thrown', async () => {
|
|
65
|
+
const r = await withPool(rowWith('sometimes'), () => ps.resolveShipMode());
|
|
66
|
+
assert.equal(r.mode, 'auto');
|
|
67
|
+
assert.equal(r.chosen, false);
|
|
68
|
+
assert.equal(r.invalid.raw, 'sometimes');
|
|
69
|
+
assert.match(r.invalid.where, /ship\.mode/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a database that cannot be read answers the default instead of failing a ship', async () => {
|
|
73
|
+
const r = await withPool(async () => { throw new Error('db down'); }, () => ps.resolveShipMode());
|
|
74
|
+
assert.deepEqual(r, { mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('describeShipMode says what each mode means in plain words', () => {
|
|
78
|
+
assert.match(ps.describeShipMode({ mode: 'auto', chosen: true }), /ships and lands it/);
|
|
79
|
+
assert.match(ps.describeShipMode({ mode: 'manual', chosen: true }), /waits for a person/);
|
|
80
|
+
assert.match(ps.describeShipMode({ mode: 'auto', chosen: false }), /nobody has chosen yet/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---- the routes -------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const fakeRes = () => ({
|
|
86
|
+
code: null, statusCode: 200, payload: null, headers: {},
|
|
87
|
+
fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code, ...(opts || {}) }; return this; },
|
|
88
|
+
status(c) { this.statusCode = c; return this; },
|
|
89
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
90
|
+
json(p) { this.payload = p; return this; },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function buildRouter({ resolved }) {
|
|
94
|
+
const saved = { resolve: api.projectSettings.resolveShipMode, set: api.projectSettings.set };
|
|
95
|
+
const writes = [];
|
|
96
|
+
let current = resolved;
|
|
97
|
+
api.projectSettings.resolveShipMode = async () => current;
|
|
98
|
+
api.projectSettings.set = async (key, value, by) => {
|
|
99
|
+
writes.push({ key, value, by });
|
|
100
|
+
current = { mode: value, source: 'project_settings', chosen: true, invalid: null };
|
|
101
|
+
return { key, value };
|
|
102
|
+
};
|
|
103
|
+
const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
|
|
104
|
+
delete require.cache[require.resolve(file)];
|
|
105
|
+
const router = require(file)();
|
|
106
|
+
const route = (method, p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]).route;
|
|
107
|
+
const restore = () => { api.projectSettings.resolveShipMode = saved.resolve; api.projectSettings.set = saved.set; };
|
|
108
|
+
return { route, writes, restore };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('GET /project-settings/ship-mode is open to any signed-in builder, not gated Metic+', () => {
|
|
112
|
+
const { route, restore } = buildRouter({ resolved: { mode: 'auto', source: 'default', chosen: false, invalid: null } });
|
|
113
|
+
try {
|
|
114
|
+
const names = route('get', '/project-settings/ship-mode').stack.map((l) => l.name);
|
|
115
|
+
assert.equal(names.length, 2, 'requireBuilder + the handler, and no permission gate');
|
|
116
|
+
const write = route('patch', '/project-settings/ship-mode').stack;
|
|
117
|
+
assert.equal(write.length, 3, 'the write carries requireBuilder + the settings permission + the handler');
|
|
118
|
+
} finally { restore(); }
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('the read returns the mode, whether it was chosen, and the sentence to show', async () => {
|
|
122
|
+
const { route, restore } = buildRouter({ resolved: { mode: 'auto', source: 'default', chosen: false, invalid: null } });
|
|
123
|
+
try {
|
|
124
|
+
const res = fakeRes();
|
|
125
|
+
const stack = route('get', '/project-settings/ship-mode').stack;
|
|
126
|
+
await stack[stack.length - 1].handle({}, res);
|
|
127
|
+
assert.equal(res.payload.mode, 'auto');
|
|
128
|
+
assert.equal(res.payload.chosen, false);
|
|
129
|
+
assert.match(res.payload.sentence, /nobody has chosen yet/);
|
|
130
|
+
assert.deepEqual(res.payload.choices.map((c) => c.mode), ['auto', 'manual']);
|
|
131
|
+
assert.match(res.payload.choices[0].label, /recommended/i);
|
|
132
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
133
|
+
} finally { restore(); }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the write refuses an unknown mode and saves a good one, echoing what is now read', async () => {
|
|
137
|
+
const { route, writes, restore } = buildRouter({ resolved: { mode: 'auto', source: 'default', chosen: false, invalid: null } });
|
|
138
|
+
try {
|
|
139
|
+
const stack = route('patch', '/project-settings/ship-mode').stack;
|
|
140
|
+
const handle = stack[stack.length - 1].handle;
|
|
141
|
+
|
|
142
|
+
const bad = fakeRes();
|
|
143
|
+
await handle({ body: { mode: 'sometimes' }, builder: { id: 7 } }, bad);
|
|
144
|
+
assert.equal(bad.code, 'bad_ship_mode');
|
|
145
|
+
assert.equal(writes.length, 0, 'a refused mode writes nothing');
|
|
146
|
+
|
|
147
|
+
const good = fakeRes();
|
|
148
|
+
await handle({ body: { mode: 'Manual' }, builder: { id: 7 } }, good);
|
|
149
|
+
assert.equal(good.code, null);
|
|
150
|
+
assert.deepEqual(writes, [{ key: 'ship.mode', value: 'manual', by: 7 }]);
|
|
151
|
+
assert.equal(good.payload.mode, 'manual');
|
|
152
|
+
assert.equal(good.payload.chosen, true);
|
|
153
|
+
} finally { restore(); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---- the CLI ----------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test('bongos ship-mode is a registered verb wired to shipping-mode.js', () => {
|
|
159
|
+
const cli = require(path.join(ROOT, 'bin', 'bongos.js'));
|
|
160
|
+
const def = cli.VERBS['ship-mode'];
|
|
161
|
+
assert.ok(def, 'bongos ship-mode must be a registered verb');
|
|
162
|
+
assert.equal(def.script, 'shipping-mode.js');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the CLI text names the mode and how to switch it', () => {
|
|
166
|
+
const { describe } = require(path.join(ROOT, 'scripts', 'gds', 'shipping-mode.js'));
|
|
167
|
+
const auto = describe({ mode: 'auto', chosen: false, sentence: 'Auto: …' });
|
|
168
|
+
assert.match(auto, /Ship mode: auto \(default — not chosen yet\)/);
|
|
169
|
+
assert.match(auto, /shipping-mode\.js manual/);
|
|
170
|
+
assert.match(describe({ mode: 'manual', chosen: true }), /shipping-mode\.js auto/);
|
|
171
|
+
});
|