@bongos/core 1.21.126 → 1.21.128
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 +73 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
- package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
- package/docs/api/openapi.json +147 -2
- package/docs/api-reference.md +5 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/onboarding/howto-state.js +108 -0
- package/modules/onboarding/migrations/onboarding_001_howto_reads.sql +33 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/onboarding.js +48 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/module-error-report.js +148 -0
- package/scripts/gds/module.js +1 -0
- package/scripts/gds/provision-caps.js +63 -0
- package/scripts/gds/provision-process.js +3 -0
- package/scripts/gds/provision-units.js +3 -0
- package/scripts/gds/provision.js +3 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/error-tally.js +188 -0
- package/src/module-loader/loader.js +52 -4
- package/src/platform-server.js +7 -0
- package/tests/howto_state.mjs +178 -0
- package/tests/module_error_counts.mjs +265 -0
- package/tests/own_scope_guard.mjs +6 -0
- package/tests/provision.mjs +2 -0
- package/tests/provision_resource_caps.mjs +117 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// modules/onboarding/howto-state.js — each person's read/unread state for the how-to steps (task 1004634)
|
|
2
|
+
//
|
|
3
|
+
// WHY. The how-to guide (howto-guide.js, task 1004627) is shown one step at a time
|
|
4
|
+
// at the moment it is useful, and a step once read must never be shown again — on
|
|
5
|
+
// this computer or any other, in Claude or another AI tool. So the read marks live in
|
|
6
|
+
// Bongos, per person (owner decision 2026-10-08), in `onboarding_howto_reads`
|
|
7
|
+
// (migrations/onboarding_001_howto_reads.sql): one row per step read, unread = no row.
|
|
8
|
+
//
|
|
9
|
+
// Three operations, served by routes/onboarding.js under /me/howto:
|
|
10
|
+
// • readState — every step with read / skipped / unread, the count, and the next unread
|
|
11
|
+
// • markRead — mark steps read (or skipped); `all: true` skips the whole guide
|
|
12
|
+
// • reset — forget some steps, or all of them, so they show again
|
|
13
|
+
// Step ids are checked against the guide: an id it does not name is refused, so a
|
|
14
|
+
// typo cannot store a mark nothing will ever read. Marking is idempotent and keeps the
|
|
15
|
+
// FIRST read time (a re-mark does not move it), except that reading a step you had
|
|
16
|
+
// skipped upgrades it to read.
|
|
17
|
+
//
|
|
18
|
+
// Pure of core require(): the pool comes from the doorway, or is injected (tests).
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const guide = require('./howto-guide');
|
|
23
|
+
|
|
24
|
+
const HOWS = Object.freeze(['read', 'skipped']);
|
|
25
|
+
|
|
26
|
+
function poolFor(deps) {
|
|
27
|
+
if (deps && deps.pool) return deps.pool;
|
|
28
|
+
return require('../../src/module-api').pool;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Validate a list of step ids against the guide. Returns { ok, ids } or { ok:false, unknown }.
|
|
32
|
+
function checkSteps(ids) {
|
|
33
|
+
const list = [...new Set((Array.isArray(ids) ? ids : [ids]).map((s) => String(s || '').trim()).filter(Boolean))];
|
|
34
|
+
const unknown = list.filter((id) => !guide.stepById(id));
|
|
35
|
+
return unknown.length ? { ok: false, unknown } : { ok: true, ids: list };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pure: shape stored rows into the API answer, in guide order.
|
|
39
|
+
function shapeState(rows) {
|
|
40
|
+
const byId = new Map((rows || []).map((r) => [r.step_id, r]));
|
|
41
|
+
const steps = guide.STEPS.map((s) => {
|
|
42
|
+
const r = byId.get(s.id);
|
|
43
|
+
return {
|
|
44
|
+
id: s.id, moment: s.moment, title: s.title,
|
|
45
|
+
state: r ? (r.how === 'skipped' ? 'skipped' : 'read') : 'unread',
|
|
46
|
+
read_at: r ? new Date(r.read_at).toISOString() : null,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
const done = steps.filter((s) => s.state !== 'unread');
|
|
50
|
+
const next = guide.nextUnread(done.map((s) => s.id));
|
|
51
|
+
return {
|
|
52
|
+
steps,
|
|
53
|
+
total: steps.length,
|
|
54
|
+
read: steps.filter((s) => s.state === 'read').length,
|
|
55
|
+
skipped: steps.filter((s) => s.state === 'skipped').length,
|
|
56
|
+
unread: steps.length - done.length,
|
|
57
|
+
next_unread: next ? next.id : null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readState(builderId, deps = {}) {
|
|
62
|
+
const pool = poolFor(deps);
|
|
63
|
+
const { rows } = await pool.query(
|
|
64
|
+
'SELECT step_id, how, read_at FROM onboarding_howto_reads WHERE builder_id = $1',
|
|
65
|
+
[builderId],
|
|
66
|
+
);
|
|
67
|
+
return shapeState(rows);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// { steps: [...] } or { all: true }; how = 'read' | 'skipped'.
|
|
71
|
+
async function markRead(builderId, { steps, all = false, how = 'read' } = {}, deps = {}) {
|
|
72
|
+
if (!HOWS.includes(how)) return { ok: false, reason: 'bad_how' };
|
|
73
|
+
let ids;
|
|
74
|
+
if (all) ids = guide.STEPS.map((s) => s.id);
|
|
75
|
+
else {
|
|
76
|
+
const c = checkSteps(steps);
|
|
77
|
+
if (!c.ok) return { ok: false, reason: 'unknown_step', unknown: c.unknown };
|
|
78
|
+
if (!c.ids.length) return { ok: false, reason: 'no_steps' };
|
|
79
|
+
ids = c.ids;
|
|
80
|
+
}
|
|
81
|
+
const pool = poolFor(deps);
|
|
82
|
+
// One statement: first read time is kept; a 'skipped' row becomes 'read' when the
|
|
83
|
+
// step is read for real, never the other way round.
|
|
84
|
+
await pool.query(
|
|
85
|
+
`INSERT INTO onboarding_howto_reads (builder_id, step_id, how)
|
|
86
|
+
SELECT $1, s, $3 FROM unnest($2::text[]) AS s
|
|
87
|
+
ON CONFLICT (builder_id, step_id) DO UPDATE
|
|
88
|
+
SET how = 'read'
|
|
89
|
+
WHERE onboarding_howto_reads.how = 'skipped' AND EXCLUDED.how = 'read'`,
|
|
90
|
+
[builderId, ids, how],
|
|
91
|
+
);
|
|
92
|
+
return { ok: true, state: await readState(builderId, deps) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// { steps: [...] } forgets those; no steps (or all: true) forgets every mark.
|
|
96
|
+
async function reset(builderId, { steps, all = false } = {}, deps = {}) {
|
|
97
|
+
const pool = poolFor(deps);
|
|
98
|
+
if (all || steps == null || (Array.isArray(steps) && steps.length === 0)) {
|
|
99
|
+
await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id = $1', [builderId]);
|
|
100
|
+
} else {
|
|
101
|
+
const c = checkSteps(steps);
|
|
102
|
+
if (!c.ok) return { ok: false, reason: 'unknown_step', unknown: c.unknown };
|
|
103
|
+
await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id = $1 AND step_id = ANY($2::text[])', [builderId, c.ids]);
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, state: await readState(builderId, deps) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { HOWS, checkSteps, shapeState, readState, markRead, reset };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- onboarding_001_howto_reads.sql — which how-to steps each person has read (task 1004634).
|
|
2
|
+
--
|
|
3
|
+
-- The in-session how-to guide (modules/onboarding/howto-guide.js, task 1004627) is
|
|
4
|
+
-- walked one step at a time, and a step once read must never be shown again. The
|
|
5
|
+
-- owner's call (2026-10-08): the read state is PER PERSON and lives in Bongos, so it
|
|
6
|
+
-- follows them across computers and AI tools.
|
|
7
|
+
--
|
|
8
|
+
-- One row per (builder, step) that has been read. Unread = no row, so a step added to
|
|
9
|
+
-- the guide later is unread for everyone with no backfill, and a reset is a DELETE.
|
|
10
|
+
-- `step_id` is the guide's stable id; it is not constrained here because the guide is
|
|
11
|
+
-- code — the API refuses an id the guide does not name, and a row for a step the guide
|
|
12
|
+
-- later retires is simply ignored on read. `how` records whether the person read the
|
|
13
|
+
-- step or skipped it (task 1004636's "skip the tutorial"), so progress can tell them
|
|
14
|
+
-- apart.
|
|
15
|
+
--
|
|
16
|
+
-- ADDITIVE, module-owned (ADR 0083 §Decision #5). Idempotent: safe to re-run.
|
|
17
|
+
|
|
18
|
+
BEGIN;
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS onboarding_howto_reads (
|
|
21
|
+
builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
|
|
22
|
+
step_id text NOT NULL,
|
|
23
|
+
how text NOT NULL DEFAULT 'read',
|
|
24
|
+
read_at timestamptz NOT NULL DEFAULT now(),
|
|
25
|
+
PRIMARY KEY (builder_id, step_id),
|
|
26
|
+
CONSTRAINT onboarding_howto_reads_how_chk CHECK (how IN ('read', 'skipped')),
|
|
27
|
+
CONSTRAINT onboarding_howto_reads_step_chk CHECK (step_id ~ '^[a-z][a-z0-9_]{2,39}$')
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
INSERT INTO schema_migrations (version) VALUES ('onboarding_001_howto_reads')
|
|
31
|
+
ON CONFLICT (version) DO NOTHING;
|
|
32
|
+
|
|
33
|
+
COMMIT;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"coreVersion": "^1.19.532",
|
|
7
7
|
"default": true,
|
|
8
8
|
"maintenance": { "status": "core-maintained" },
|
|
9
|
-
"contributes": { "routes": ["onboarding"] },
|
|
9
|
+
"contributes": { "routes": ["onboarding"], "migrations": true },
|
|
10
10
|
"provides": ["onboarding"],
|
|
11
11
|
"consumes": []
|
|
12
12
|
}
|
|
@@ -37,6 +37,7 @@ const buildAccessRequestsRouter = require('./access-requests');
|
|
|
37
37
|
const foundingBuilders = require('../founding-builders');
|
|
38
38
|
const genesis = require('../genesis-home');
|
|
39
39
|
const foundingBirth = require('../founding-birth');
|
|
40
|
+
const howtoState = require('../howto-state');
|
|
40
41
|
|
|
41
42
|
// Register the `onboarding` kernel PORT. See the header + port.js for the full
|
|
42
43
|
// consumer list and the graceful-degradation contract.
|
|
@@ -58,15 +59,61 @@ module.exports = function buildOnboardingRouter() {
|
|
|
58
59
|
// ranks page and the home's founding wall; an empty list on a project that was
|
|
59
60
|
// never founded, so those surfaces render exactly as before. Signed-in, like the
|
|
60
61
|
// hall pages that read it; it carries nothing the roster does not already show.
|
|
61
|
-
router.get('/founding/builders', api.requireBuilder, async (
|
|
62
|
+
router.get('/founding/builders', api.requireBuilder, async (_req, res) => {
|
|
62
63
|
const builders = await foundingBuilders.foundingBuildersFor();
|
|
63
64
|
res.set('Cache-Control', 'private, max-age=60');
|
|
64
65
|
res.json({ builders });
|
|
65
66
|
});
|
|
66
67
|
mountGenesisRoutes(router);
|
|
68
|
+
mountHowtoRoutes(router);
|
|
67
69
|
return router;
|
|
68
70
|
};
|
|
69
71
|
|
|
72
|
+
// ── the how-to read state (task 1004634) ─────────────────────────────────────
|
|
73
|
+
// Each person's own read/unread marks for the in-session how-to guide. Self-scoped:
|
|
74
|
+
// every route reads and writes only req.builder's rows, so any signed-in builder may
|
|
75
|
+
// call them (the /me/wandering posture). The AI marks a step read after walking the
|
|
76
|
+
// person through it (task 1004635); reopen / continue / skip use the same three calls
|
|
77
|
+
// (task 1004636).
|
|
78
|
+
const HOWTO_REFUSALS = { unknown_step: 422, no_steps: 422, bad_how: 422 };
|
|
79
|
+
|
|
80
|
+
function mountHowtoRoutes(router) {
|
|
81
|
+
const handle = (fn) => async (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const r = await fn(req);
|
|
84
|
+
if (r && r.ok === false) {
|
|
85
|
+
return res.status(HOWTO_REFUSALS[r.reason] || 422).json({
|
|
86
|
+
error: { code: r.reason, message: r.unknown ? `not a how-to step: ${r.unknown.join(', ')}` : r.reason },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
res.set('Cache-Control', 'no-store');
|
|
90
|
+
res.json(r && r.state ? r.state : r);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
api.logger('onboarding').error('[onboarding] howto', err);
|
|
93
|
+
res.status(500).json({ error: { code: 'howto_failed', message: 'internal error' } });
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
// rank: any-builder — the caller's own read state.
|
|
97
|
+
router.get('/me/howto', api.requireBuilder, handle((req) => howtoState.readState(req.builder.id)));
|
|
98
|
+
// rank: any-builder — marks the caller's own steps read ({ steps }) or skips the guide ({ all: true, how: 'skipped' }).
|
|
99
|
+
router.post('/me/howto/read', api.requireBuilder, (req, res, next) => {
|
|
100
|
+
if (api.validateOrRespond(req, res, {
|
|
101
|
+
steps: { type: 'array', maxItems: 100 },
|
|
102
|
+
all: { type: 'boolean' },
|
|
103
|
+
how: { type: 'string', maxLength: 16 },
|
|
104
|
+
})) return;
|
|
105
|
+
next();
|
|
106
|
+
}, handle((req) => howtoState.markRead(req.builder.id, req.body || {})));
|
|
107
|
+
// rank: any-builder — forgets the caller's own marks ({ steps }, or everything when none are named).
|
|
108
|
+
router.post('/me/howto/reset', api.requireBuilder, (req, res, next) => {
|
|
109
|
+
if (api.validateOrRespond(req, res, {
|
|
110
|
+
steps: { type: 'array', maxItems: 100 },
|
|
111
|
+
all: { type: 'boolean' },
|
|
112
|
+
})) return;
|
|
113
|
+
next();
|
|
114
|
+
}, handle((req) => howtoState.reset(req.builder.id, req.body || {})));
|
|
115
|
+
}
|
|
116
|
+
|
|
70
117
|
// ── the genesis home (task 1004423) ──────────────────────────────────────────
|
|
71
118
|
// Read by any signed-in builder of a founding project (everyone sees where genesis
|
|
72
119
|
// is); every write is the project owner's, behind the same atom that guards the
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.128",
|
|
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.128",
|
|
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.128",
|
|
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
|
@@ -9750,5 +9750,21 @@
|
|
|
9750
9750
|
"id": "1004738",
|
|
9751
9751
|
"text": "A goal's page is much shorter now: task dependencies sit in a small card at the top where Conflicts used to be, and the full dependency picture and conflict list are one click away."
|
|
9752
9752
|
}
|
|
9753
|
+
],
|
|
9754
|
+
"1.21.127": [
|
|
9755
|
+
{
|
|
9756
|
+
"id": "1004637",
|
|
9757
|
+
"text": "Projects now count each store module's errors per day and send the count to the store, so a module's reliability score comes from real use. It sends with a builder's sign-in on that machine and respects the reporting switch in"
|
|
9758
|
+
},
|
|
9759
|
+
{
|
|
9760
|
+
"id": "1004414",
|
|
9761
|
+
"text": "Each hosted project now has a memory and CPU limit, so one runaway project can no longer slow down every other project or the hub."
|
|
9762
|
+
}
|
|
9763
|
+
],
|
|
9764
|
+
"1.21.128": [
|
|
9765
|
+
{
|
|
9766
|
+
"id": "1004634",
|
|
9767
|
+
"text": "Bongos now remembers, for each person, which how-to steps they have already seen, so the AI never shows the same tip twice, on any computer."
|
|
9768
|
+
}
|
|
9753
9769
|
]
|
|
9754
9770
|
}
|
|
@@ -103,7 +103,7 @@ const EXEMPTIONS = [
|
|
|
103
103
|
+ 'absorbed into this check.',
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
at: 'src/module-loader/loader.js:
|
|
106
|
+
at: 'src/module-loader/loader.js:350',
|
|
107
107
|
why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
|
|
108
108
|
+ 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
|
|
109
109
|
+ 'change to either call re-opens only that one.',
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -524,6 +524,7 @@ const KERNEL_FILES = [
|
|
|
524
524
|
'src/module-loader/loader.js', // the disk loader (discover → validate → mount)
|
|
525
525
|
'src/module-loader/manifest-schema.js', // the manifest validator
|
|
526
526
|
'src/module-loader/semver.js', // coreVersion compatibility
|
|
527
|
+
'src/module-loader/error-tally.js', // per-module runtime-error counts the loader feeds (task 1004637)
|
|
527
528
|
'src/bongos/module-scope-map.js', // the module→path registry (it NAMES every module — exempt below)
|
|
528
529
|
// trust primitives (ADR 0091 §1) — the authz boundary + data substrate
|
|
529
530
|
'src/bongos/auth.js', // authz gates (requireBuilder/requireRank, ADR 0016)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/module-error-report.js — send this instance's daily runtime-error
|
|
5
|
+
// count for each store module to the store, with the builder sign-in on this box
|
|
6
|
+
// (task 1004637, follow-up to task 1003793; ADR 0343 D1).
|
|
7
|
+
//
|
|
8
|
+
// WHY. The store scores a module's Reliability from daily error counts, and nothing
|
|
9
|
+
// sent one. The server keeps the tally (src/module-loader/error-tally.js writes
|
|
10
|
+
// module-error-counts.json in the instance config dir); this script sends every
|
|
11
|
+
// finished day it has not sent yet — including a 0, because a clean day is what
|
|
12
|
+
// Reliability measures — and remembers which days went in module-error-sent.json.
|
|
13
|
+
//
|
|
14
|
+
// WHO SENDS (owner, 2026-10-15): a builder's sign-in on this box, because the store's
|
|
15
|
+
// report routes take a builder and no project-to-store credential exists (that option
|
|
16
|
+
// is parked as task 1004740). No sign-in here means nothing is sent, quietly.
|
|
17
|
+
//
|
|
18
|
+
// PRIVACY (owner, 2026-10-01): counts only, on by default, with an opt-out. If the
|
|
19
|
+
// signed-in builder switched reporting off in Settings, nothing is sent and the days
|
|
20
|
+
// are marked done; the store also drops a report from an opted-out holder, so the
|
|
21
|
+
// rule holds whichever client sends.
|
|
22
|
+
//
|
|
23
|
+
// The server runs this every few hours (error-tally.js startDailySend); it is safe to
|
|
24
|
+
// run by hand too:
|
|
25
|
+
// node scripts/gds/module-error-report.js [--dry-run] [--quiet]
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const { readTally, utcDay, FILE_NAME } = require('../../src/module-loader/error-tally');
|
|
31
|
+
|
|
32
|
+
const SENT_FILE = 'module-error-sent.json';
|
|
33
|
+
// The store takes days up to 30 back (module-usage-reports.js ERROR_DAY_WINDOW).
|
|
34
|
+
const WINDOW_DAYS = 30;
|
|
35
|
+
const ENTRY_RE = /^([a-z0-9][a-z0-9-]*)@(\d+\.\d+\.\d+)$/;
|
|
36
|
+
|
|
37
|
+
function daysAgo(today, n) { return utcDay(Date.parse(`${today}T00:00:00Z`) - n * 86_400_000); }
|
|
38
|
+
|
|
39
|
+
// The finished days still to send, oldest first. Pure.
|
|
40
|
+
function pendingDays(days, sent, today) {
|
|
41
|
+
const oldest = daysAgo(today, WINDOW_DAYS);
|
|
42
|
+
return Object.keys(days || {}).filter((d) => d < today && d >= oldest && !(sent && sent[d])).sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Send what is pending. `call(method, path, { json })` is the store client (module.js
|
|
46
|
+
// storeRequest). Never throws for a store answer: a day is marked done only when
|
|
47
|
+
// every count in it got a final answer — recorded, or refused for good (4xx: the store
|
|
48
|
+
// does not have that version, or this builder does not hold the module). A 5xx or an
|
|
49
|
+
// unreachable store leaves the day for the next run. Returns a summary.
|
|
50
|
+
async function sendPending({ days, sent = {}, today, call, dryRun = false }) {
|
|
51
|
+
const out = { pending: pendingDays(days, sent, today), sentDays: [], posted: 0, skipped: 0, optedOut: false, retryLater: false };
|
|
52
|
+
if (!out.pending.length) return out;
|
|
53
|
+
|
|
54
|
+
const pref = await call('GET', '/store/usage-reporting');
|
|
55
|
+
if (!pref.ok) { out.retryLater = true; return out; }
|
|
56
|
+
if (pref.data && pref.data.enabled === false) {
|
|
57
|
+
out.optedOut = true;
|
|
58
|
+
if (!dryRun) out.sentDays = out.pending.slice();
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const entries = new Set();
|
|
63
|
+
for (const d of out.pending) for (const e of Object.keys(days[d] || {})) if (ENTRY_RE.test(e)) entries.add(e);
|
|
64
|
+
const keys = [...new Set([...entries].map((e) => e.match(ENTRY_RE)[1]))];
|
|
65
|
+
let known = new Set();
|
|
66
|
+
if (keys.length) {
|
|
67
|
+
const res = await call('GET', `/store/modules/latest?keys=${keys.map(encodeURIComponent).join(',')}`);
|
|
68
|
+
if (!res.ok) { out.retryLater = true; return out; }
|
|
69
|
+
known = new Set(Object.keys((res.data && res.data.latest) || {}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const day of out.pending) {
|
|
73
|
+
let done = true;
|
|
74
|
+
for (const [e, count] of Object.entries(days[day] || {})) {
|
|
75
|
+
const m = e.match(ENTRY_RE);
|
|
76
|
+
if (!m || !known.has(m[1]) || !Number.isInteger(count) || count < 0) { out.skipped += 1; continue; }
|
|
77
|
+
if (dryRun) { out.posted += 1; continue; }
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await call('POST', `/store/modules/${encodeURIComponent(m[1])}/versions/${encodeURIComponent(m[2])}/error-count`, { json: { day, count } });
|
|
81
|
+
} catch { res = { ok: false, status: 0 }; }
|
|
82
|
+
if (res.ok) out.posted += 1;
|
|
83
|
+
else if (res.status >= 400 && res.status < 500) out.skipped += 1;
|
|
84
|
+
else done = false;
|
|
85
|
+
}
|
|
86
|
+
if (done && !dryRun) out.sentDays.push(day);
|
|
87
|
+
else if (!done) out.retryLater = true;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSent(file) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
95
|
+
return parsed && parsed.sent && typeof parsed.sent === 'object' ? parsed.sent : {};
|
|
96
|
+
} catch { return {}; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Record the days sent, dropping any older than the store's window.
|
|
100
|
+
function writeSent(file, sent, newDays, today) {
|
|
101
|
+
const oldest = daysAgo(today, WINDOW_DAYS + 1);
|
|
102
|
+
const next = {};
|
|
103
|
+
for (const d of [...Object.keys(sent), ...newDays]) if (d >= oldest) next[d] = true;
|
|
104
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
106
|
+
fs.writeFileSync(tmp, JSON.stringify({ sent: next }, null, 2));
|
|
107
|
+
fs.renameSync(tmp, file);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function main(argv = process.argv.slice(2), {
|
|
111
|
+
log = console.log, request, tallyFile, sentFile, today = utcDay(),
|
|
112
|
+
} = {}) {
|
|
113
|
+
const quiet = argv.includes('--quiet');
|
|
114
|
+
const dryRun = argv.includes('--dry-run');
|
|
115
|
+
const say = (m) => { if (!quiet) log(m); };
|
|
116
|
+
const { configPath } = require('../../src/instance-config');
|
|
117
|
+
const tFile = tallyFile || configPath(FILE_NAME);
|
|
118
|
+
const sFile = sentFile || configPath(SENT_FILE);
|
|
119
|
+
|
|
120
|
+
const days = readTally(tFile);
|
|
121
|
+
const sent = readSent(sFile);
|
|
122
|
+
if (!pendingDays(days, sent, today).length) { say('module error counts: nothing to send.'); return 0; }
|
|
123
|
+
|
|
124
|
+
let call = request;
|
|
125
|
+
if (!call) {
|
|
126
|
+
try { call = require('./module').storeRequest(); }
|
|
127
|
+
catch { say('module error counts: no Bongos sign-in on this machine, so nothing was sent.'); return 0; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let out;
|
|
131
|
+
try { out = await sendPending({ days, sent, today, call, dryRun }); }
|
|
132
|
+
catch (e) { say(`module error counts: the store could not be reached (${e.message}); will try again later.`); return 1; }
|
|
133
|
+
if (out.sentDays.length) writeSent(sFile, sent, out.sentDays, today);
|
|
134
|
+
|
|
135
|
+
if (out.optedOut) say('module error counts: reporting is switched off in your Settings, so nothing was sent.');
|
|
136
|
+
else say(`module error counts: ${dryRun ? 'would send' : 'sent'} ${out.posted} count(s) for ${out.pending.length} day(s); ${out.skipped} skipped (not a store module you hold)${out.retryLater ? '; some will be retried later' : ''}.`);
|
|
137
|
+
return out.retryLater ? 1 : 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { pendingDays, sendPending, main };
|
|
141
|
+
|
|
142
|
+
if (require.main === module) {
|
|
143
|
+
// Set the exit code and drain, never a hard exit after a request (task 1002734).
|
|
144
|
+
Promise.resolve(main()).then(
|
|
145
|
+
(code) => { process.exitCode = code; },
|
|
146
|
+
(e) => { console.error(e && e.message ? e.message : e); process.exitCode = 1; },
|
|
147
|
+
);
|
|
148
|
+
}
|
package/scripts/gds/module.js
CHANGED
|
@@ -1350,6 +1350,7 @@ async function main(argv) {
|
|
|
1350
1350
|
|
|
1351
1351
|
module.exports = {
|
|
1352
1352
|
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1353
|
+
storeRequest,
|
|
1353
1354
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
1354
1355
|
scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
|
|
1355
1356
|
formatMaintenanceHint, isPastRemoveAfter,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// scripts/gds/provision-caps.js — the resource caps every hosted project's service carries
|
|
2
|
+
// (task 1004414). A cloud-host project runs its owner's code on the box that also serves the
|
|
3
|
+
// hub, and ADR 0354 lets any signed-in builder host one, so a runaway project must not be
|
|
4
|
+
// able to starve the others or the hub.
|
|
5
|
+
//
|
|
6
|
+
// The numbers are the owner's (2026-10-15), sized from ADR 0285's measurement: a project
|
|
7
|
+
// idles at ~45-70 MB and peaks at 118-134 MB at boot (page cache included), and uses a
|
|
8
|
+
// fraction of a percent of one core. MemoryHigh throttles and reclaims before MemoryMax
|
|
9
|
+
// kills; a killed project comes back through the unit's Restart=always. Too tight a cap
|
|
10
|
+
// takes a healthy project down, so change these only as a decision.
|
|
11
|
+
//
|
|
12
|
+
// A NEW unit renders them (serviceUnit). An EXISTING unit takes them with `systemctl
|
|
13
|
+
// set-property`, which applies live and persists a drop-in under /etc/systemd/system.control/,
|
|
14
|
+
// so nothing restarts and no unit file is rewritten — rewriting would move a project made
|
|
15
|
+
// before the per-instance accounts (task 1003369) onto an account that does not exist.
|
|
16
|
+
// systemd only: the supervisord image has no cgroups to cap.
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const RESOURCE_CAPS = Object.freeze({
|
|
21
|
+
MemoryHigh: '160M',
|
|
22
|
+
MemoryMax: '256M',
|
|
23
|
+
CPUQuota: '50%',
|
|
24
|
+
TasksMax: '256',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function capDirectives() {
|
|
28
|
+
return Object.entries(RESOURCE_CAPS).map(([key, value]) => `${key}=${value}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The slug is interpolated into a root shell command, so it is held to the create route's
|
|
32
|
+
// charset here too rather than trusted from the row.
|
|
33
|
+
const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
34
|
+
|
|
35
|
+
function setCapsCmd(inst, { sudoP = '' } = {}) {
|
|
36
|
+
if (!SAFE_SLUG.test(String(inst.slug))) throw new Error(`refusing to cap an unsafe slug: ${JSON.stringify(inst.slug)}`);
|
|
37
|
+
return `${sudoP}systemctl set-property ${inst.slug}.service ${capDirectives().join(' ')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `provision.js apply-caps [--apply]` — cap every running hosted project now. A cloud-host
|
|
41
|
+
// always runs on the control plane (capacity.placementBox), so this uses controlExec. One
|
|
42
|
+
// project that refuses never stops the rest; the result names it.
|
|
43
|
+
async function cmdApplyCaps(deps) {
|
|
44
|
+
const { db, log, privileged } = deps;
|
|
45
|
+
const exec = deps.controlExec || deps.exec;
|
|
46
|
+
const sudoP = privileged ? 'sudo ' : '';
|
|
47
|
+
const { rows } = await db.query(
|
|
48
|
+
`SELECT slug FROM provisioning_instances
|
|
49
|
+
WHERE hosting_shape = 'cloud-host' AND status = 'active'
|
|
50
|
+
ORDER BY slug`);
|
|
51
|
+
const capped = [], failed = [];
|
|
52
|
+
for (const { slug } of rows) {
|
|
53
|
+
// makeExec answers an allowFail'd failure ok:true + softFailed:true (softFailResult)
|
|
54
|
+
let r;
|
|
55
|
+
try { r = exec(setCapsCmd({ slug }, { sudoP }), { allowFail: true }); } catch { r = null; }
|
|
56
|
+
(!r || r.ok === false || r.softFailed ? failed : capped).push(slug);
|
|
57
|
+
}
|
|
58
|
+
log(`${deps.apply ? 'capped' : 'would cap (dry-run — add --apply)'} ${capped.length} of ${rows.length} active hosted project(s) at ${capDirectives().join(' ')}`);
|
|
59
|
+
if (failed.length) log(` ✖ could not cap: ${failed.join(', ')} — re-run once each unit is loaded`);
|
|
60
|
+
return { ok: failed.length === 0, capped, failed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { RESOURCE_CAPS, capDirectives, setCapsCmd, cmdApplyCaps };
|
|
@@ -59,6 +59,9 @@ const systemd = {
|
|
|
59
59
|
exec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
|
|
60
60
|
exec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
|
|
61
61
|
exec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
|
|
62
|
+
// the caps drop-in `provision.js apply-caps` wrote (task 1004414, provision-caps.js)
|
|
63
|
+
exec(`${sudoP}rm -f /etc/systemd/system.control/${inst.slug}.service.d/50-*.conf`, { allowFail: true });
|
|
64
|
+
exec(`${sudoP}rmdir /etc/systemd/system.control/${inst.slug}.service.d`, { allowFail: true });
|
|
62
65
|
exec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
|
|
63
66
|
exec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
|
|
64
67
|
},
|
|
@@ -12,6 +12,7 @@ const fs = require('node:fs');
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
14
14
|
const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
|
|
15
|
+
const { capDirectives } = require('./provision-caps.js');
|
|
15
16
|
|
|
16
17
|
function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
17
18
|
const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
|
|
@@ -61,6 +62,8 @@ ProtectSystem=strict
|
|
|
61
62
|
ProtectHome=read-only
|
|
62
63
|
PrivateTmp=true
|
|
63
64
|
ReadWritePaths=${stateDir} -${workDir}/config
|
|
65
|
+
# Resource caps (task 1004414; the numbers and their reasons are in provision-caps.js).
|
|
66
|
+
${capDirectives().join('\n')}
|
|
64
67
|
|
|
65
68
|
[Install]
|
|
66
69
|
WantedBy=multi-user.target
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1348,6 +1348,7 @@ Commands:
|
|
|
1348
1348
|
teardown <slug> tear down one instance by slug
|
|
1349
1349
|
reconcile re-enqueue instances stuck mid-flight with no open intent
|
|
1350
1350
|
status list provisioned instances + estimated run-rate
|
|
1351
|
+
apply-caps cap every active hosted project's memory/CPU/tasks live (task 1004414)
|
|
1351
1352
|
dns-check <domain> PREFLIGHT: verify the control-plane Cloudflare token can reach
|
|
1352
1353
|
the domain's zone BEFORE the address step (network-only, no DB)
|
|
1353
1354
|
|
|
@@ -1397,7 +1398,7 @@ async function main() {
|
|
|
1397
1398
|
process.exit(1);
|
|
1398
1399
|
}
|
|
1399
1400
|
|
|
1400
|
-
const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status'];
|
|
1401
|
+
const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status', 'apply-caps'];
|
|
1401
1402
|
if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
|
|
1402
1403
|
const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1403
1404
|
if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
|
|
@@ -1432,6 +1433,7 @@ async function main() {
|
|
|
1432
1433
|
else if (cmd === 'teardown') { const inst = await provisioning.getInstanceBySlug(pool, slug); if (!inst) { console.error(`no instance with slug '${slug}'`); await pool.end().catch(() => {}); process.exit(1); } result = await teardownInstance(inst, deps); }
|
|
1433
1434
|
else if (cmd === 'reconcile') result = await cmdReconcile(deps);
|
|
1434
1435
|
else if (cmd === 'status') result = await cmdStatus(deps);
|
|
1436
|
+
else if (cmd === 'apply-caps') result = await require('./provision-caps.js').cmdApplyCaps(deps);
|
|
1435
1437
|
} catch (e) {
|
|
1436
1438
|
console.error('fatal:', e && e.message ? e.message : e);
|
|
1437
1439
|
await pool.end().catch(() => {});
|
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.128'; // 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');
|