@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.
Files changed (35) hide show
  1. package/.bongos-core.json +73 -33
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
  8. package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
  9. package/docs/api/openapi.json +147 -2
  10. package/docs/api-reference.md +5 -2
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/onboarding/howto-state.js +108 -0
  13. package/modules/onboarding/migrations/onboarding_001_howto_reads.sql +33 -0
  14. package/modules/onboarding/module.json +1 -1
  15. package/modules/onboarding/routes/onboarding.js +48 -1
  16. package/package-lock.json +2 -2
  17. package/package.json +1 -1
  18. package/release-notes.json +16 -0
  19. package/scripts/gds/exec-path-guard.js +1 -1
  20. package/scripts/gds/fitness.js +1 -0
  21. package/scripts/gds/module-error-report.js +148 -0
  22. package/scripts/gds/module.js +1 -0
  23. package/scripts/gds/provision-caps.js +63 -0
  24. package/scripts/gds/provision-process.js +3 -0
  25. package/scripts/gds/provision-units.js +3 -0
  26. package/scripts/gds/provision.js +3 -1
  27. package/src/module-api.js +1 -1
  28. package/src/module-loader/error-tally.js +188 -0
  29. package/src/module-loader/loader.js +52 -4
  30. package/src/platform-server.js +7 -0
  31. package/tests/howto_state.mjs +178 -0
  32. package/tests/module_error_counts.mjs +265 -0
  33. package/tests/own_scope_guard.mjs +6 -0
  34. package/tests/provision.mjs +2 -0
  35. 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 (req, res) => {
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.126",
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.126",
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.126",
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",
@@ -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:347',
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.',
@@ -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
+ }
@@ -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
@@ -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.126'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');