@bongos/core 1.20.80 → 1.20.82

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 (56) hide show
  1. package/.bongos-core.json +98 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +245 -4
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +84 -71
  10. package/docs/copy-registry.json +271 -145
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +2 -1
  13. package/docs/page-readings.json +139 -119
  14. package/modules/autonomy/cadence.js +3 -0
  15. package/modules/autonomy/db.js +136 -0
  16. package/modules/autonomy/fence.js +88 -3
  17. package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
  18. package/modules/autonomy/routes/autonomy.js +123 -3
  19. package/modules/economy/earnings.js +117 -0
  20. package/modules/economy/module.json +1 -1
  21. package/modules/economy/routes/earnings.js +47 -0
  22. package/modules/government/catalog.js +9 -1
  23. package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
  24. package/modules/hall-ui/public/gate.css +49 -0
  25. package/modules/hall-ui/public/gate.html +8 -7
  26. package/modules/hall-ui/public/gate.js +272 -115
  27. package/modules/hall-ui/public/profile-charts.js +206 -104
  28. package/modules/hall-ui/public/profile.css +23 -0
  29. package/modules/hall-ui/public/profile.js +3 -3
  30. package/modules/hall-ui/public/profile.states.json +8 -4
  31. package/modules/hall-ui/public/settings-autobongos.js +156 -0
  32. package/modules/hall-ui/public/settings.html +19 -0
  33. package/modules/hall-ui/public/settings.js +1 -0
  34. package/modules/hall-ui/records/profile-roles.md +21 -0
  35. package/modules/lifecycle/db-goals.js +6 -0
  36. package/modules/lifecycle/db-versions.js +3 -0
  37. package/modules/lifecycle/done-when.js +4 -0
  38. package/modules/lifecycle/goal-close-sweep.js +34 -0
  39. package/modules/provisioning/app-status.js +17 -4
  40. package/modules/public-landing/public/projects.html +7 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/release-notes.json +28 -0
  44. package/scripts/hall-preview/server.js +4 -0
  45. package/src/module-api.js +1 -1
  46. package/tests/auto_satisfy_criteria.mjs +8 -6
  47. package/tests/autonomy_builder_scope.mjs +474 -0
  48. package/tests/autonomy_fence_priority.mjs +7 -3
  49. package/tests/autonomy_runner_identity.mjs +1 -1
  50. package/tests/criterion_goal_attach.mjs +5 -1
  51. package/tests/goal_close_cancels_requests.mjs +82 -0
  52. package/tests/goal_closure_invariants.mjs +5 -2
  53. package/tests/hall_profile_world.mjs +57 -58
  54. package/tests/profile_earnings.mjs +237 -0
  55. package/tests/projects_hub_app_status.mjs +17 -0
  56. package/tests/provisioning_app_status.mjs +25 -1
@@ -161,6 +161,9 @@ function classifyEvent(row = {}) {
161
161
 
162
162
  if (event === 'fenced') {
163
163
  if (code === 'kill_switch') return { class: 'stopped', detail: row.reason || 'an owner stopped the runner' };
164
+ // The per-builder stops (task 1004501) are the same kind of fact as the project
165
+ // switch: a person decided, and probing will not change their mind.
166
+ if (code === 'builder_paused' || code === 'not_permitted') return { class: 'stopped', detail: row.reason || 'this builder’s runner is stopped' };
164
167
  if (code === 'grader_unavailable') return { class: 'transient', detail: 'the grader is unavailable, and it is the only review there is' };
165
168
  if (code === 'fence_unreadable') return { class: 'transient', detail: 'the fence could not be read' };
166
169
  // no_allowlist / goal_not_allowlisted / protected_path: the fence is working
@@ -157,6 +157,138 @@ async function disallowGoal(goalId) {
157
157
  return { removed: rowCount > 0 };
158
158
  }
159
159
 
160
+ // ── each builder's runner scope (task 1004501) ───────────────────────────────
161
+ //
162
+ // The builder's half of the fence: which allowlisted goals THEIR runner works, and
163
+ // whether the owner has paused it. The composition with the project fence is pure
164
+ // and lives in ../fence.js (effectiveFence); these stay dumb.
165
+
166
+ // readBuilderScope — one builder's picks and pause, or null when they have neither
167
+ // (which effectiveFence reads as "no goals", by owner ruling). The picks join the
168
+ // allowlist so a pick can never outlive its goal there, on top of the CASCADE.
169
+ async function readBuilderScope(builderId) {
170
+ const [scope, goals] = await Promise.all([
171
+ pool.query('SELECT owner_paused, owner_paused_reason, owner_paused_by, updated_at FROM autonomy_builder_scope WHERE builder_id = $1', [builderId]),
172
+ pool.query(
173
+ `SELECT bg.goal_id FROM autonomy_builder_goals bg
174
+ JOIN autonomy_allowed_goals ag ON ag.goal_id = bg.goal_id
175
+ WHERE bg.builder_id = $1 ORDER BY bg.goal_id`,
176
+ [builderId]
177
+ ),
178
+ ]);
179
+ const row = scope.rows[0];
180
+ if (!row && !goals.rows.length) return null;
181
+ return {
182
+ goals: goals.rows.map((g) => Number(g.goal_id)),
183
+ owner_paused: !!(row && row.owner_paused === true),
184
+ owner_paused_reason: (row && row.owner_paused_reason) || null,
185
+ owner_paused_by: row && row.owner_paused_by ? String(row.owner_paused_by) : null,
186
+ updated_at: row ? row.updated_at : null,
187
+ };
188
+ }
189
+
190
+ // setBuilderGoals — replace one builder's picks with `goalIds`, in one transaction.
191
+ // Every id must already be on the allowlist; if any is not, NOTHING is written and
192
+ // `{ notAllowed: [...] }` says which, so the Settings page can name them. An empty
193
+ // list is a valid answer and means "my runner takes no work".
194
+ async function setBuilderGoals({ builderId, goalIds }) {
195
+ const ids = [...new Set((goalIds || []).map(Number))];
196
+ const client = await pool.connect();
197
+ try {
198
+ await client.query('BEGIN');
199
+ if (ids.length) {
200
+ const { rows } = await client.query('SELECT goal_id FROM autonomy_allowed_goals WHERE goal_id = ANY($1::bigint[])', [ids]);
201
+ const allowed = new Set(rows.map((r) => Number(r.goal_id)));
202
+ const notAllowed = ids.filter((g) => !allowed.has(g));
203
+ if (notAllowed.length) { await client.query('ROLLBACK'); return { notAllowed }; }
204
+ }
205
+ await client.query('DELETE FROM autonomy_builder_goals WHERE builder_id = $1', [builderId]);
206
+ if (ids.length) {
207
+ await client.query(
208
+ 'INSERT INTO autonomy_builder_goals (builder_id, goal_id) SELECT $1, unnest($2::bigint[])',
209
+ [builderId, ids]
210
+ );
211
+ }
212
+ // Bump the scope row so the change reaches a waiting runner as a signal.
213
+ await client.query(
214
+ `INSERT INTO autonomy_builder_scope (builder_id, updated_at) VALUES ($1, now())
215
+ ON CONFLICT (builder_id) DO UPDATE SET updated_at = now()`,
216
+ [builderId]
217
+ );
218
+ await client.query('COMMIT');
219
+ return { goals: ids.slice().sort((a, b) => a - b) };
220
+ } catch (err) {
221
+ try { await client.query('ROLLBACK'); } catch (_) { /* the original error is the one to report */ }
222
+ throw err;
223
+ } finally {
224
+ client.release();
225
+ }
226
+ }
227
+
228
+ // setBuilderPause — the owner pauses or resumes ONE builder's runner. The reason
229
+ // is kept only while paused, like the project switch. Returns null when no such
230
+ // builder exists, so a typo in the id is a 404 rather than an inert orphan row.
231
+ async function setBuilderPause({ builderId, paused, reason = null, byBuilderId = null }) {
232
+ const { rows } = await pool.query(
233
+ `INSERT INTO autonomy_builder_scope (builder_id, owner_paused, owner_paused_reason, owner_paused_by, updated_at)
234
+ SELECT $1, $2, $3, $4, now() WHERE EXISTS (SELECT 1 FROM builders WHERE id = $1)
235
+ ON CONFLICT (builder_id) DO UPDATE SET
236
+ owner_paused = EXCLUDED.owner_paused,
237
+ owner_paused_reason = EXCLUDED.owner_paused_reason,
238
+ owner_paused_by = EXCLUDED.owner_paused_by,
239
+ updated_at = now()
240
+ RETURNING builder_id, owner_paused, owner_paused_reason, updated_at`,
241
+ [builderId, paused === true, paused === true ? (reason || 'paused by the owner') : null, byBuilderId]
242
+ );
243
+ const r = rows[0];
244
+ return r ? { ...r, builder_id: String(r.builder_id) } : null;
245
+ }
246
+
247
+ // readAllRunnerScopes — the owner's view: every builder who has a runner scope or
248
+ // has ever had a runner check in, with their picks, pause, and runners. Three
249
+ // small reads joined here rather than one wide SQL join, so a builder with picks
250
+ // but no runner and one with a runner but no picks both appear.
251
+ async function readAllRunnerScopes() {
252
+ const [scopes, picks, beats] = await Promise.all([
253
+ pool.query('SELECT builder_id, owner_paused, owner_paused_reason, updated_at FROM autonomy_builder_scope'),
254
+ pool.query(
255
+ `SELECT bg.builder_id, bg.goal_id FROM autonomy_builder_goals bg
256
+ JOIN autonomy_allowed_goals ag ON ag.goal_id = bg.goal_id ORDER BY bg.goal_id`
257
+ ),
258
+ // Capped PER BUILDER, at readRunnerHeartbeats' own 20, so one builder's many
259
+ // hosts can never push a quieter builder out of the owner's view.
260
+ pool.query(
261
+ `SELECT builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
262
+ head, disk_head, main_head, claude_account, last_seen_at
263
+ FROM (SELECT h.*, row_number() OVER (PARTITION BY builder_id ORDER BY last_seen_at DESC) AS rn
264
+ FROM autonomy_runner_heartbeat h) t
265
+ WHERE rn <= 20
266
+ ORDER BY last_seen_at DESC`
267
+ ),
268
+ ]);
269
+ const byId = new Map();
270
+ const entry = (id) => {
271
+ const k = String(id);
272
+ if (!byId.has(k)) byId.set(k, { builder_id: k, goals: [], owner_paused: false, owner_paused_reason: null, runners: [] });
273
+ return byId.get(k);
274
+ };
275
+ for (const s of scopes.rows) Object.assign(entry(s.builder_id), { owner_paused: s.owner_paused === true, owner_paused_reason: s.owner_paused_reason || null });
276
+ for (const p of picks.rows) entry(p.builder_id).goals.push(Number(p.goal_id));
277
+ for (const b of beats.rows) {
278
+ entry(b.builder_id).runners.push({
279
+ ...b, builder_id: undefined,
280
+ working_task_id: b.working_task_id ? String(b.working_task_id) : null,
281
+ working_goal_id: b.working_goal_id ? String(b.working_goal_id) : null,
282
+ });
283
+ }
284
+ const ids = [...byId.keys()];
285
+ if (ids.length) {
286
+ const { rows } = await pool.query('SELECT id, github_login, display_name, rank FROM builders WHERE id = ANY($1::bigint[])', [ids]);
287
+ for (const r of rows) Object.assign(byId.get(String(r.id)), { github_login: r.github_login, display_name: r.display_name, rank: r.rank });
288
+ }
289
+ return [...byId.values()];
290
+ }
291
+
160
292
  // ── the runner heartbeat (task 1003905) ──────────────────────────────────────
161
293
 
162
294
  // recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
@@ -225,4 +357,8 @@ module.exports = {
225
357
  allowGoal,
226
358
  disallowGoal,
227
359
  setPriorityGoal,
360
+ readBuilderScope,
361
+ setBuilderGoals,
362
+ setBuilderPause,
363
+ readAllRunnerScopes,
228
364
  };
@@ -49,8 +49,78 @@ const REFUSALS = {
49
49
  goal_not_allowlisted: 'that goal is not on the allowlist',
50
50
  protected_path: 'the task declares work on a permission, migration, infrastructure or deploy-pipeline surface',
51
51
  grader_unavailable: 'the grader is bypassed, and it is the only review between an unattended worker and main',
52
+ not_permitted: 'your rank does not hold autonomy.run, so your runner may not work on this project',
53
+ builder_paused: 'the owner paused your runner',
54
+ no_builder_scope: 'you have not picked any goals for your runner — choose them in Settings',
52
55
  };
53
56
 
57
+ // The stop codes effectiveFence may set, and the refusal each one becomes.
58
+ const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused']);
59
+
60
+ // effectiveFence — the fence ONE builder's runner reads (task 1004501).
61
+ //
62
+ // The project fence is one switch and one allowlist; each builder narrows it to the
63
+ // goals they picked, and the owner can pause one builder without throwing the
64
+ // master switch. This composes the three on the SERVER, so the runner stays dumb:
65
+ // it reads `enabled` and `goals` exactly as before and cannot compute its own
66
+ // widening, and a runner that predates this change gets the narrowing for free.
67
+ //
68
+ // The order is the order an operator asks "why is it idle": the project switch
69
+ // first (it stops everyone), then whether this builder may run at all, then the
70
+ // owner's pause on this builder. Each one wins over the ones after it.
71
+ //
72
+ // Empty picks mean NO goals, by owner ruling (2026-10-01): a builder who has set
73
+ // nothing gets no autonomous work. Never "the whole allowlist".
74
+ //
75
+ // `project` is db.readFence()'s shape; `scope` is db.readBuilderScope()'s
76
+ // ({ goals:[ids], owner_paused, owner_paused_reason, updated_at }) or null for a
77
+ // builder with no row; `mayRun` is the autonomy.run answer, where anything but
78
+ // `true` refuses. Returns null when the project fence is null (unreadable).
79
+ function effectiveFence({ project, scope = null, mayRun = false } = {}) {
80
+ if (!project || typeof project !== 'object') return null;
81
+ const picks = new Set(((scope && scope.goals) || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0));
82
+ const projectGoals = Array.isArray(project.goals) ? project.goals : [];
83
+ const goals = projectGoals.filter((g) => picks.has(Number(g && g.goal_id)));
84
+ const ids = goals.map((g) => Number(g.goal_id));
85
+ const priority = project.priority_goal_id != null && ids.includes(Number(project.priority_goal_id))
86
+ ? Number(project.priority_goal_id) : null;
87
+
88
+ let enabled = project.enabled === true;
89
+ let stopCode = enabled ? null : 'kill_switch';
90
+ let pausedReason = enabled ? null : (project.paused_reason || null);
91
+ if (enabled && mayRun !== true) {
92
+ enabled = false; stopCode = 'not_permitted'; pausedReason = REFUSALS.not_permitted;
93
+ } else if (enabled && scope && scope.owner_paused === true) {
94
+ enabled = false; stopCode = 'builder_paused';
95
+ pausedReason = scope.owner_paused_reason ? `${REFUSALS.builder_paused}: ${scope.owner_paused_reason}` : REFUSALS.builder_paused;
96
+ }
97
+
98
+ // The later of the two rows, so a pick or a pause wakes a runner that waits on
99
+ // the fence's updated_at exactly as a project change does.
100
+ const stamps = [project.updated_at, scope && scope.updated_at].filter(Boolean).map((t) => new Date(t).getTime()).filter(Number.isFinite);
101
+ const updatedAt = stamps.length ? new Date(Math.max(...stamps)).toISOString() : (project.updated_at || null);
102
+
103
+ return {
104
+ enabled,
105
+ paused_reason: pausedReason,
106
+ stop_code: stopCode,
107
+ priority_goal_id: priority,
108
+ updated_at: updatedAt,
109
+ updated_by: project.updated_by ?? null,
110
+ goals,
111
+ // What the caller chose and may do, so the Settings panel and the runner log
112
+ // can say WHY the goals are what they are without a second read.
113
+ scope: {
114
+ may_run: mayRun === true,
115
+ goals: [...picks],
116
+ owner_paused: !!(scope && scope.owner_paused === true),
117
+ owner_paused_reason: (scope && scope.owner_paused_reason) || null,
118
+ },
119
+ // The unnarrowed project fence, for the owner's gate page.
120
+ project,
121
+ };
122
+ }
123
+
54
124
  // normaliseFence — turn whatever the route returned into something decidable, or
55
125
  // null for "unreadable". Deliberately strict: a shape this does not recognise is
56
126
  // unreadable, never a permissive default.
@@ -81,9 +151,18 @@ function normaliseFence(raw) {
81
151
  // the old behaviour, not a refusal: the allowlist alone is still a safe answer.
82
152
  const p = Number(raw.priority_goal_id);
83
153
  const priorityGoal = raw.priority_goal_id != null && Number.isSafeInteger(p) && p > 0 && ids.includes(p) ? p : null;
154
+ // Which layer turned it off (task 1004501). Only the known codes are honoured;
155
+ // anything else, including a server that predates them, reads as the kill switch,
156
+ // which refuses just the same.
157
+ const stopCode = STOP_CODES.has(raw.stop_code) ? raw.stop_code : 'kill_switch';
158
+ // A builder who has picked nothing gets nothing (owner ruling, 2026-10-01). Said
159
+ // as its own refusal so the log does not blame an empty project allowlist.
160
+ const noScope = !!(raw.scope && Array.isArray(raw.scope.goals) && raw.scope.goals.length === 0);
84
161
  return {
85
162
  enabled: raw.enabled === true,
86
163
  pausedReason: typeof raw.paused_reason === 'string' ? raw.paused_reason : null,
164
+ stopCode,
165
+ noScope,
87
166
  goals: ids,
88
167
  priorityGoal,
89
168
  };
@@ -129,6 +208,11 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
129
208
  if (!fence) return { go: false, code: 'fence_unreadable', reason: REFUSALS.fence_unreadable, goals: [] };
130
209
 
131
210
  if (!fence.enabled) {
211
+ // The builder-level stops carry their full sentence already (effectiveFence
212
+ // wrote it); only the project switch is prefixed, as before.
213
+ if (fence.stopCode !== 'kill_switch') {
214
+ return { go: false, code: fence.stopCode, goals: [], reason: fence.pausedReason || REFUSALS[fence.stopCode] };
215
+ }
132
216
  return {
133
217
  go: false, code: 'kill_switch', goals: [],
134
218
  reason: fence.pausedReason ? `${REFUSALS.kill_switch}: ${fence.pausedReason}` : REFUSALS.kill_switch,
@@ -148,12 +232,13 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
148
232
 
149
233
  const { goals, refused } = allowedGoals(fence, requestedGoals);
150
234
  if (!goals.length) {
235
+ const code = refused.length ? 'goal_not_allowlisted' : (fence.noScope ? 'no_builder_scope' : 'no_allowlist');
151
236
  return {
152
237
  go: false, goals: [], refusedGoals: refused,
153
- code: refused.length ? 'goal_not_allowlisted' : 'no_allowlist',
238
+ code,
154
239
  reason: refused.length
155
240
  ? `${REFUSALS.goal_not_allowlisted} (asked for ${refused.join(', ')})`
156
- : REFUSALS.no_allowlist,
241
+ : REFUSALS[code],
157
242
  };
158
243
  }
159
244
  // priorityGoal is reported only when it survived the intersection, so a row can
@@ -191,4 +276,4 @@ function decideTask({ task, goals = [], protectedHits = [] } = {}) {
191
276
  return { go: true, code: null, reason: null };
192
277
  }
193
278
 
194
- module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, decideRun, decideTask };
279
+ module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, effectiveFence, decideRun, decideTask };
@@ -0,0 +1,55 @@
1
+ -- autonomy_005_builder_scope.sql — each builder's own runner scope, and the
2
+ -- owner's per-builder pause (task 1004501).
3
+ --
4
+ -- Owner ruling, 2026-10-01: every builder says which work THEIR runner takes, and
5
+ -- a builder who has said nothing gets NO autonomous work. Until now the fence was
6
+ -- one switch and one list for every runner on the instance, so the owner could not
7
+ -- stop one builder's machine without stopping all of them, and a builder could not
8
+ -- narrow theirs to the goals they care about.
9
+ --
10
+ -- A NARROWING, NEVER A WIDENING. A builder's picks are a subset of the project
11
+ -- allowlist, and the database holds that rather than a route remembering it:
12
+ -- goal_id references autonomy_allowed_goals, so a goal that is not allowlisted
13
+ -- cannot be picked at all. ON DELETE CASCADE is the safe direction: revoking a
14
+ -- goal from the project removes it from every builder's picks, and re-allowing it
15
+ -- later does NOT silently put it back. The runner's effective goals are
16
+ -- project allowlist ∩ the caller's picks, computed on the server.
17
+ --
18
+ -- ON THE SERVER, for autonomy_001's reason. These rows bound what a machine nobody
19
+ -- is watching may do; a pick list in a file on that machine would be writable by
20
+ -- the very worker it bounds. The builder writes their own picks through an
21
+ -- authenticated route keyed by their session, never by a body field.
22
+ --
23
+ -- autonomy_builder_scope holds the one row per builder that is not a list:
24
+ -- owner_paused the owner stopped THIS builder's runner. Written only by
25
+ -- the autonomy.fence.manage route; the builder cannot clear
26
+ -- it, because a pause the paused party can lift is not one.
27
+ -- owner_paused_reason why, shown to the builder and printed by their runner.
28
+ -- owner_paused_by who.
29
+ -- updated_at bumped on every pick or pause change. The route folds it
30
+ -- into the fence's updated_at, which is how a change wakes
31
+ -- a runner that is waiting for a signal.
32
+ --
33
+ -- No foreign key to builders, like autonomy_002: a row for a removed builder is
34
+ -- inert, because no session can ever read it.
35
+ --
36
+ -- Additive and idempotent (ADR 0083 §Decision #5): safe to re-run.
37
+
38
+ BEGIN;
39
+
40
+ CREATE TABLE IF NOT EXISTS autonomy_builder_goals (
41
+ builder_id bigint NOT NULL,
42
+ goal_id bigint NOT NULL REFERENCES autonomy_allowed_goals (goal_id) ON DELETE CASCADE,
43
+ added_at timestamptz NOT NULL DEFAULT now(),
44
+ PRIMARY KEY (builder_id, goal_id)
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS autonomy_builder_scope (
48
+ builder_id bigint PRIMARY KEY,
49
+ owner_paused boolean NOT NULL DEFAULT false,
50
+ owner_paused_reason text,
51
+ owner_paused_by bigint,
52
+ updated_at timestamptz NOT NULL DEFAULT now()
53
+ );
54
+
55
+ COMMIT;
@@ -28,6 +28,7 @@ const auth = api;
28
28
  // neither core internals nor a sibling module, so importing it here is allowed.
29
29
  const autonomyGate = require('../../../scripts/gds/autonomy-gate');
30
30
  const db = require('../db');
31
+ const fence = require('../fence');
31
32
  const runnerHealth = require('../runner-health');
32
33
  // The doorway's namespaced logger and strict body validator. Raw console.* is
33
34
  // ratcheted repo-wide (fitness.js console_call_count) and unstructured besides.
@@ -52,6 +53,49 @@ function runsLimit(raw) {
52
53
  return Math.min(RUNS_MAX_LIMIT, Math.max(1, n));
53
54
  }
54
55
 
56
+ // holdsPermission — does this builder hold `key`? Asked of the government port
57
+ // rather than mounted as a gate, because GET /autonomy/fence must answer EVERY
58
+ // builder (a runner must always be able to learn it should stop); only the answer
59
+ // changes. Fails closed: no port, or a resolver that throws, reads as "not held",
60
+ // which turns the caller's fence off rather than on. The rot.js precedent.
61
+ //
62
+ // THE FOLD FIRST. This runs on the runner's read, every iteration of every runner,
63
+ // so it must not add a query. requireBuilder already carries the session's grant
64
+ // keys (`req.rawPermissionKeys`, the session-grants fold of task 1002556), and the
65
+ // government port folds them into the held set in memory — the same path
66
+ // requirePermission takes. The DB resolve is only the fallback for a session row
67
+ // that did not carry the keys, exactly as in requirePermission.
68
+ async function holdsPermission(req, key) {
69
+ try {
70
+ const governance = api.resolveOptional('government');
71
+ if (!governance) return false;
72
+ if (Array.isArray(req.rawPermissionKeys) && typeof governance.effectivePermissions === 'function') {
73
+ try {
74
+ return new Set(governance.effectivePermissions(req.rawPermissionKeys)).has(key);
75
+ } catch (err) {
76
+ log.error(`autonomy: permission fold failed for ${key} — falling back to the resolve: ${err && err.message}`);
77
+ }
78
+ }
79
+ if (typeof governance.builderHasPermissions !== 'function') return false;
80
+ return (await governance.builderHasPermissions(Number(req.builder.id), [key])) === true;
81
+ } catch (err) {
82
+ log.error(`autonomy: permission resolve failed for ${key} — denying fail-closed: ${err && err.message}`);
83
+ return false;
84
+ }
85
+ }
86
+
87
+ // goalIdsOrNull — a list of positive integer goal ids, or null for a malformed one.
88
+ function goalIdsOrNull(v) {
89
+ if (!Array.isArray(v) || v.length > 200) return null;
90
+ const out = [];
91
+ for (const g of v) {
92
+ const n = Number(g);
93
+ if (!Number.isSafeInteger(n) || n <= 0) return null;
94
+ out.push(n);
95
+ }
96
+ return out;
97
+ }
98
+
55
99
  // commitOrNull — a full lowercase commit sha, or null (task 1004407).
56
100
  function commitOrNull(v) {
57
101
  return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
@@ -102,15 +146,24 @@ module.exports = function buildAutonomyRouter() {
102
146
  // you must stop should never require an elevated session, because the failure
103
147
  // mode of "could not read the fence" is a machine that keeps going. The body
104
148
  // carries no secret — it is a boolean, a reason string and a list of goal ids.
149
+ //
150
+ // THE CALLER'S fence, not the project's (task 1004501). The top-level `enabled`
151
+ // and `goals` are what THIS builder's runner may do: the project switch, then
152
+ // `autonomy.run`, then the owner's pause on this builder, and the allowlist
153
+ // narrowed to their own picks (fence.js effectiveFence). Computing it here keeps
154
+ // the runner dumb and means a runner that predates this change is narrowed too.
155
+ // The unnarrowed project fence rides along as `project` for the owner's page.
105
156
  router.get('/autonomy/fence', auth.requireBuilder, async (req, res) => {
106
157
  try {
107
- const fence = await db.readFence();
158
+ const [project, scope, mayRun] = await Promise.all([
159
+ db.readFence(), db.readBuilderScope(req.builder.id), holdsPermission(req, 'autonomy.run'),
160
+ ]);
108
161
  // Null means the singleton row is missing, i.e. the migration has not run.
109
162
  // Say so rather than synthesising a default: a fabricated { enabled: false }
110
163
  // is indistinguishable from a real one, and an operator would chase a kill
111
164
  // switch nobody threw.
112
- if (!fence) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
113
- res.json(fence);
165
+ if (!project) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
166
+ res.json(fence.effectiveFence({ project, scope, mayRun }));
114
167
  } catch (err) {
115
168
  log.error('[gds] GET /autonomy/fence', err);
116
169
  // FAIL CLOSED, unlike the precheck above. That one is fail-open because an
@@ -202,6 +255,73 @@ module.exports = function buildAutonomyRouter() {
202
255
  }
203
256
  });
204
257
 
258
+ // ── each builder's own runner scope (task 1004501) ─────────────────────────
259
+
260
+ // A builder picks which allowlisted goals THEIR runner works. Replaces the whole
261
+ // list; `[]` means "my runner takes no work". Keyed by the session, never the
262
+ // body, so nobody can set another builder's picks. Behind `autonomy.run`: a
263
+ // builder who may not run a runner has nothing to scope. A goal not on the
264
+ // project allowlist is refused with 409 and nothing is written — the pick can
265
+ // narrow the project fence and never widen it.
266
+ router.put('/autonomy/me/goals', auth.requireBuilder, auth.requirePermission('autonomy.run'), async (req, res) => {
267
+ if (validateOrRespond(req, res, { goal_ids: { required: true } })) return;
268
+ const goalIds = goalIdsOrNull(req.body && req.body.goal_ids);
269
+ if (!goalIds) return res.fail('goal_ids_required', { status: 400, message: 'pass { "goal_ids": [<number>, …] } — an empty list means no work' });
270
+ try {
271
+ const out = await db.setBuilderGoals({ builderId: req.builder.id, goalIds });
272
+ if (out.notAllowed) return res.fail('goal_not_allowlisted', { status: 409, message: `not on the project allowlist: ${out.notAllowed.join(', ')}` });
273
+ log.info(`[gds] autonomy scope: builder ${req.builder.id} picked goals [${out.goals.join(', ')}]`);
274
+ res.json({ goals: out.goals });
275
+ } catch (err) {
276
+ log.error('[gds] PUT /autonomy/me/goals', err);
277
+ res.fail('scope_write_failed', { status: 500, message: 'internal error' });
278
+ }
279
+ });
280
+
281
+ // The owner pauses or resumes ONE builder's runner without throwing the master
282
+ // switch. Same atom as the kill switch: stopping a machine is the same act
283
+ // whether it is everyone's or one person's, and the paused builder must not be
284
+ // able to lift it (autonomy.run does not reach this route).
285
+ router.post('/autonomy/builders/:builderId/pause', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
286
+ if (validateOrRespond(req, res, {
287
+ paused: { required: true, type: 'boolean' },
288
+ reason: { type: 'string', maxLength: 500 },
289
+ })) return;
290
+ const builderId = Number(req.params.builderId);
291
+ if (!Number.isSafeInteger(builderId) || builderId <= 0) return res.fail('bad_builder_id', 400);
292
+ const reason = typeof req.body.reason === 'string' ? req.body.reason : null;
293
+ try {
294
+ const row = await db.setBuilderPause({ builderId, paused: req.body.paused, reason, byBuilderId: req.builder.id });
295
+ if (!row) return res.fail('builder_not_found', { status: 404, message: `no builder ${builderId}` });
296
+ log.info(`[gds] autonomy: builder ${builderId}'s runner ${req.body.paused ? 'PAUSED' : 'RESUMED'} by builder ${req.builder.id} (${req.builder.rank})${reason ? `: ${reason}` : ''}`);
297
+ res.json(row);
298
+ } catch (err) {
299
+ log.error('[gds] POST /autonomy/builders/:builderId/pause', err);
300
+ res.fail('pause_write_failed', { status: 500, message: 'internal error' });
301
+ }
302
+ });
303
+
304
+ // The owner's view of every builder's runners: who, which goals they picked,
305
+ // whether they are paused, and each runner's health — the server's verdict, as
306
+ // GET /autonomy/runners attaches it. Owner-only: a heartbeat names a machine
307
+ // and its hostname, which is each builder's own business (task 1003905), and the
308
+ // owner is the one person who needs every row to run the fence.
309
+ router.get('/autonomy/runners/all', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
310
+ try {
311
+ const rows = await db.readAllRunnerScopes();
312
+ const nowMs = Date.now();
313
+ res.json({
314
+ builders: rows.map((b) => ({
315
+ ...b,
316
+ runners: b.runners.map((r) => ({ ...r, health: runnerHealth.describeRunner(r, { nowMs }) })),
317
+ })),
318
+ });
319
+ } catch (err) {
320
+ log.error('[gds] GET /autonomy/runners/all', err);
321
+ res.fail('runners_unreadable', { status: 503, message: 'could not read the runner scopes' });
322
+ }
323
+ });
324
+
205
325
  // ── the runner heartbeat (task 1003905) ────────────────────────────────────
206
326
  //
207
327
  // A dead runner must be VISIBLE, not silent. The runner also writes a heartbeat
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ // modules/economy/earnings.js — WHAT EARNED A BUILDER'S CREDITS, DAY BY DAY
4
+ // (task 1004436 / WA6.RP06, goal 1000095, criterion wa6-roles-read-true-in-public).
5
+ //
6
+ // WHY. The profile's earnings chart drew only for someone who had shipped a task
7
+ // (profile-charts.js stopped at "No works shipped yet" before it read a credit),
8
+ // and its line came from the itemised ledger, which another builder may read only
9
+ // with builder.roster.read. So an artist or an ideator with a full credit history
10
+ // got no chart at all, and a signed-in viewer below Metic got an estimate summed
11
+ // from shipped tasks, which for those two crafts is nothing. The owner's decision
12
+ // (a), 2026-09-30, is that earnings are a SIGNED-IN read, for everyone.
13
+ //
14
+ // This is that read: per day, per KIND of credit, summed. No row, no task, no
15
+ // session and no description leaves here, so it carries none of the itemisation
16
+ // the ledger route gates. The total already sits on the public leaderboard; what
17
+ // this adds is when it was earned and by what kind of work.
18
+ //
19
+ // THE KINDS, per credit_log row (first match wins):
20
+ //
21
+ // task.shipped · task.confirmed task Tasks shipped
22
+ // session.token_reward session Session rewards
23
+ // tweak.page_approved:<task> page Pages approved
24
+ // idea.credit.task_ship: (a descent hop) idea-descent Work split from their ideas
25
+ // idea.credit.task_ship: · idea_promotion_ idea-ship Ideas that became shipped work
26
+ // bonus
27
+ // idea.credit.full_pass: idea-full Full Ideas passed
28
+ // idea.credit.governance_pass: idea-board Ratified by the board
29
+ // ship.net_negative net-negative Simplification bonuses
30
+ // anything else other achievements, rank, a grant
31
+ //
32
+ // A descent payment shares stream 1's reason key by design (credits.js: one
33
+ // payment per origin per ship, however the descent is re-walked), so the reason
34
+ // cannot tell it apart. Its description can: the descent writer, and only it,
35
+ // says the task shipped "N hop(s) below idea #". DESCENT_MARK is that phrase, and
36
+ // tests/profile_earnings.mjs holds it against the writer's own text.
37
+ //
38
+ // The words for each kind live with the picture (profile-charts.js); this file
39
+ // owns only which kind a credit is.
40
+ //
41
+ // ONLY READS. Nothing here writes, and nothing is reachable from a ship path.
42
+
43
+ const api = require('../../src/module-api');
44
+ const { pool } = api;
45
+ const { ARTIST_PAGE_REASON_PREFIX, IDEA_STREAM_REASON_PREFIXES } = require('./credits');
46
+
47
+ const KINDS = Object.freeze([
48
+ 'task', 'session', 'page', 'idea-ship', 'idea-descent', 'idea-full', 'idea-board', 'net-negative', 'other',
49
+ ]);
50
+
51
+ const DESCENT_MARK = ' below idea #';
52
+
53
+ // The prefixes go into the SQL as LIKE patterns, so their underscores (a LIKE
54
+ // wildcard) are escaped. Checked at load: they are economy's own constants,
55
+ // never a request value, and the check keeps it that way.
56
+ function likePrefix(prefix) {
57
+ if (!/^[a-z0-9_.]+:$/i.test(prefix)) {
58
+ throw new Error(`economy/earnings: reason prefix is not a plain literal: ${prefix}`);
59
+ }
60
+ return prefix.replace(/([\\%_])/g, '\\$1') + '%';
61
+ }
62
+ const LIKE = Object.freeze({
63
+ page: likePrefix(ARTIST_PAGE_REASON_PREFIX),
64
+ ship: likePrefix(IDEA_STREAM_REASON_PREFIXES.task_ship),
65
+ full: likePrefix(IDEA_STREAM_REASON_PREFIXES.full_pass),
66
+ board: likePrefix(IDEA_STREAM_REASON_PREFIXES.governance_pass),
67
+ });
68
+
69
+ // One statement, one builder: idx_credit_log_builder (builder_id, recorded_at)
70
+ // serves the WHERE, so the read is that person's rows, never the whole ledger.
71
+ // The day is the UTC day, the one the page's axis and the ledger fixture use.
72
+ const EARNINGS_SQL = `
73
+ SELECT to_char((recorded_at AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS day,
74
+ CASE
75
+ WHEN reason IN ('task.shipped', 'task.confirmed') THEN 'task'
76
+ WHEN reason = 'session.token_reward' THEN 'session'
77
+ WHEN reason LIKE $2 THEN 'page'
78
+ WHEN reason LIKE $3 AND COALESCE(description, '') LIKE $6 THEN 'idea-descent'
79
+ WHEN reason LIKE $3 OR reason = 'idea_promotion_bonus' THEN 'idea-ship'
80
+ WHEN reason LIKE $4 THEN 'idea-full'
81
+ WHEN reason LIKE $5 THEN 'idea-board'
82
+ WHEN reason = 'ship.net_negative' THEN 'net-negative'
83
+ ELSE 'other'
84
+ END AS kind,
85
+ SUM(delta)::float8 AS credits
86
+ FROM credit_log
87
+ WHERE builder_id = $1
88
+ GROUP BY 1, 2
89
+ ORDER BY 1, 2`;
90
+
91
+ // foldEarnings — the grouped rows into the route's shape. Pure. Days ascending;
92
+ // `totals` names every kind (0 where nothing was earned, so a reader never has
93
+ // to guess whether a missing key means zero); `total` is the sum of the parts,
94
+ // which is the ledger's own sum for this builder.
95
+ function foldEarnings(rows) {
96
+ const totals = {};
97
+ for (const k of KINDS) totals[k] = 0;
98
+ const days = [];
99
+ for (const r of rows || []) {
100
+ const kind = KINDS.includes(r.kind) ? r.kind : 'other';
101
+ const credits = Math.round(Number(r.credits) || 0);
102
+ if (!r.day || credits === 0) continue;
103
+ days.push({ day: String(r.day), kind, credits });
104
+ totals[kind] += credits;
105
+ }
106
+ days.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)));
107
+ const total = Object.values(totals).reduce((a, b) => a + b, 0);
108
+ return { days, totals, total };
109
+ }
110
+
111
+ async function readEarnings(builderId, { client = null } = {}) {
112
+ const q = client || pool;
113
+ const { rows } = await q.query(EARNINGS_SQL, [builderId, LIKE.page, LIKE.ship, LIKE.full, LIKE.board, `%${DESCENT_MARK}%`]);
114
+ return foldEarnings(rows);
115
+ }
116
+
117
+ module.exports = { readEarnings, foldEarnings, KINDS, DESCENT_MARK, EARNINGS_SQL };
@@ -6,7 +6,7 @@
6
6
  "coreVersion": "^1.7.0",
7
7
  "default": true,
8
8
  "maintenance": { "status": "core-maintained" },
9
- "contributes": { "routes": ["cost", "credits", "leaderboard", "reward"] },
9
+ "contributes": { "routes": ["cost", "credits", "earnings", "leaderboard", "reward"] },
10
10
  "provides": ["reward"],
11
11
  "consumes": []
12
12
  }