@bongos/core 1.24.77 → 1.24.79
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 +91 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +15 -0
- package/docs/api/openapi.json +209 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +109 -92
- package/docs/copy-registry.json +291 -108
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +111 -97
- package/docs/recipes/autobongos-runner-setup.md +148 -0
- package/docs/recipes/autobongos-windows-host.md +10 -7
- package/modules/autonomy/cadence.js +3 -0
- package/modules/autonomy/db.js +86 -7
- package/modules/autonomy/fence.js +20 -5
- package/modules/autonomy/migrations/autonomy_006_runner_device.sql +45 -0
- package/modules/autonomy/module.json +2 -1
- package/modules/autonomy/routes/autonomy.js +165 -3
- package/modules/autonomy/runner-health.js +27 -0
- package/modules/autonomy/skills/autobongos-setup/SKILL.md +86 -0
- package/modules/hall-ui/public/commands-lib.js +1 -1
- package/modules/hall-ui/public/gate.js +12 -5
- package/modules/hall-ui/public/runner-button.css +55 -0
- package/modules/hall-ui/public/runner-button.js +187 -0
- package/modules/hall-ui/public/settings-autobongos.js +349 -63
- package/modules/hall-ui/public/settings.html +23 -10
- package/modules/hall-ui/records/runner-button.md +16 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/autobongos-host.js +35 -6
- package/scripts/bongos/autobongos-run.js +18 -3
- package/scripts/bongos/claude-materialize.js +20 -1
- package/scripts/bongos/stranded-rows.js +39 -2
- package/scripts/hall-preview/server.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_host.mjs +46 -0
- package/tests/autonomy_config_idle.mjs +9 -4
- package/tests/autonomy_runner_device.mjs +444 -0
- package/tests/autonomy_runner_identity.mjs +3 -1
- package/tests/claude_materialize.mjs +74 -0
- package/tests/hall_runner_button.mjs +467 -0
- package/tests/hall_runner_panel_connect.mjs +73 -0
- package/tests/stranded_rows.mjs +50 -0
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# The always-on Windows host: Autobongos that starts at boot
|
|
2
|
+
|
|
3
|
+
> **Most builders want [Running Autobongos on your own device](autobongos-runner-setup.md)
|
|
4
|
+
> instead.** That flow is one copied command and one Run button, works on Windows
|
|
5
|
+
> and Mac, needs no administrator, and starts the runner when you sign in. This
|
|
6
|
+
> page is the advanced path for ONE case it does not cover: a Windows PC that must
|
|
7
|
+
> come back after a reboot or a power cut **with nobody signed in**. It needs an
|
|
8
|
+
> Administrator window once. Use one or the other on a device, never both — the
|
|
9
|
+
> setup command refuses to add a second runner beside this one.
|
|
2
10
|
|
|
3
11
|
The runner is a program that claims tasks and spawns Claude Code sessions to work
|
|
4
12
|
them, on an always-on machine with nobody at the keyboard. This is how it gets
|
|
@@ -7,12 +15,7 @@ installed so it survives a reboot, and how you check on it afterwards.
|
|
|
7
15
|
**Design of record:** [ADR 0307](../adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md).
|
|
8
16
|
Task [1003905](https://cloudbongos.com/builders#/task/1003905).
|
|
9
17
|
|
|
10
|
-
>
|
|
11
|
-
> reviewed on a Mac; the install, the cold reboot and the crash-restart have not
|
|
12
|
-
> been run on the Windows PC, because that machine is not reachable from where
|
|
13
|
-
> this was built. The heartbeat half — the part that tells you whether the runner
|
|
14
|
-
> is alive — *was* verified end to end against a real database. Treat step 3 as
|
|
15
|
-
> the acceptance test, not as a formality.
|
|
18
|
+
> Step 3 is the acceptance test, not a formality: do it after every install.
|
|
16
19
|
|
|
17
20
|
---
|
|
18
21
|
|
|
@@ -164,6 +164,9 @@ function classifyEvent(row = {}) {
|
|
|
164
164
|
// The per-builder stops (task 1004501) are the same kind of fact as the project
|
|
165
165
|
// switch: a person decided, and probing will not change their mind.
|
|
166
166
|
if (code === 'builder_paused' || code === 'not_permitted') return { class: 'stopped', detail: row.reason || 'this builder’s runner is stopped' };
|
|
167
|
+
// The builder's own Stop on this device (task 1005094): the same kind of fact
|
|
168
|
+
// again. It waits, cheaply, for the press of Run.
|
|
169
|
+
if (code === 'device_stopped') return { class: 'stopped', detail: row.reason || 'this device is stopped' };
|
|
167
170
|
if (code === 'grader_unavailable') return { class: 'transient', detail: 'the grader is unavailable, and it is the only review there is' };
|
|
168
171
|
if (code === 'fence_unreadable') return { class: 'transient', detail: 'the fence could not be read' };
|
|
169
172
|
// no_allowlist / goal_not_allowlisted / protected_path: the fence is working
|
package/modules/autonomy/db.js
CHANGED
|
@@ -267,7 +267,7 @@ async function readAllRunnerScopes() {
|
|
|
267
267
|
// hosts can never push a quieter builder out of the owner's view.
|
|
268
268
|
pool.query(
|
|
269
269
|
`SELECT builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
|
|
270
|
-
head, disk_head, main_head, claude_account, last_seen_at
|
|
270
|
+
head, disk_head, main_head, claude_account, last_seen_at, run_requested, hold_until
|
|
271
271
|
FROM (SELECT h.*, row_number() OVER (PARTITION BY builder_id ORDER BY last_seen_at DESC) AS rn
|
|
272
272
|
FROM autonomy_runner_heartbeat h) t
|
|
273
273
|
WHERE rn <= 20
|
|
@@ -302,11 +302,18 @@ async function readAllRunnerScopes() {
|
|
|
302
302
|
// recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
|
|
303
303
|
// case is one write, and `host` is the key so a second runner appears as a second
|
|
304
304
|
// row rather than silently overwriting the first.
|
|
305
|
-
|
|
305
|
+
//
|
|
306
|
+
// THE DEVICE'S RUN SWITCH IS NEVER WRITTEN FROM HERE (task 1005094), with one
|
|
307
|
+
// exception that is not a write of anyone's choice: `startStopped` decides the
|
|
308
|
+
// value a BRAND-NEW row is born with. A device added through the new setup checks
|
|
309
|
+
// in under its own login and starts stopped, so the first press of Run is the
|
|
310
|
+
// person's; an existing row keeps whatever its builder last chose. The conflict
|
|
311
|
+
// arm does not name run_requested at all, so no check-in can ever move it.
|
|
312
|
+
async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null, workingGoalId = null, head = null, diskHead = null, mainHead = null, claudeAccount = null, holdUntil = null, sessionId = null, startStopped = false }) {
|
|
306
313
|
const { rows } = await pool.query(
|
|
307
314
|
`INSERT INTO autonomy_runner_heartbeat
|
|
308
|
-
(builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, last_seen_at, updated_at)
|
|
309
|
-
VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, $10, $11, $12, $13, now(), now())
|
|
315
|
+
(builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, hold_until, session_id, run_requested, last_seen_at, updated_at)
|
|
316
|
+
VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, $10, $11, $12, $13, $14, $15, $16, now(), now())
|
|
310
317
|
ON CONFLICT (builder_id, host) DO UPDATE SET
|
|
311
318
|
pid = EXCLUDED.pid,
|
|
312
319
|
started_at = COALESCE(EXCLUDED.started_at, autonomy_runner_heartbeat.started_at),
|
|
@@ -321,10 +328,15 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
|
|
|
321
328
|
disk_head = EXCLUDED.disk_head,
|
|
322
329
|
main_head = EXCLUDED.main_head,
|
|
323
330
|
claude_account = EXCLUDED.claude_account,
|
|
331
|
+
-- Overwritten too: a runner that has stopped resting must stop saying so,
|
|
332
|
+
-- and a device moved back to its builder's CLI session has no login of
|
|
333
|
+
-- its own for Remove to revoke.
|
|
334
|
+
hold_until = EXCLUDED.hold_until,
|
|
335
|
+
session_id = EXCLUDED.session_id,
|
|
324
336
|
last_seen_at = now(),
|
|
325
337
|
updated_at = now()
|
|
326
|
-
RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, last_seen_at`,
|
|
327
|
-
[String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId, head, diskHead, mainHead, claudeAccount]
|
|
338
|
+
RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, hold_until, run_requested, last_seen_at`,
|
|
339
|
+
[String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId, head, diskHead, mainHead, claudeAccount, holdUntil, sessionId, startStopped !== true]
|
|
328
340
|
);
|
|
329
341
|
return rows[0] || null;
|
|
330
342
|
}
|
|
@@ -340,7 +352,8 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
|
|
|
340
352
|
async function readRunnerHeartbeats(builderId) {
|
|
341
353
|
const { rows } = await pool.query(
|
|
342
354
|
`SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
|
|
343
|
-
head, disk_head, main_head, claude_account, last_seen_at
|
|
355
|
+
head, disk_head, main_head, claude_account, last_seen_at,
|
|
356
|
+
run_requested, run_changed_at, hold_until, session_id
|
|
344
357
|
FROM autonomy_runner_heartbeat
|
|
345
358
|
WHERE builder_id = $1
|
|
346
359
|
ORDER BY last_seen_at DESC
|
|
@@ -351,9 +364,71 @@ async function readRunnerHeartbeats(builderId) {
|
|
|
351
364
|
...r,
|
|
352
365
|
working_task_id: r.working_task_id ? String(r.working_task_id) : null,
|
|
353
366
|
working_goal_id: r.working_goal_id ? String(r.working_goal_id) : null,
|
|
367
|
+
// The caller's OWN session id (a bigint, so a string): what "Remove this
|
|
368
|
+
// device" hands to the own-session revoke. Null when the runner has no login
|
|
369
|
+
// of its own.
|
|
370
|
+
session_id: r.session_id ? String(r.session_id) : null,
|
|
354
371
|
}));
|
|
355
372
|
}
|
|
356
373
|
|
|
374
|
+
// ── each device's Run switch (task 1005094) ──────────────────────────────────
|
|
375
|
+
|
|
376
|
+
const deviceHost = (host) => String(host == null ? '' : host).toLowerCase().slice(0, 255);
|
|
377
|
+
|
|
378
|
+
// readRunnerDevice — one device's switch, for the fence read. Null when that
|
|
379
|
+
// device has never checked in, which the fence reads as "no device layer": a
|
|
380
|
+
// runner's first act is a check-in, so a missing row is a caller that is not one.
|
|
381
|
+
async function readRunnerDevice(builderId, host) {
|
|
382
|
+
const h = deviceHost(host);
|
|
383
|
+
if (!h) return null;
|
|
384
|
+
const { rows } = await pool.query(
|
|
385
|
+
'SELECT host, run_requested, run_changed_at FROM autonomy_runner_heartbeat WHERE builder_id = $1 AND host = $2',
|
|
386
|
+
[builderId, h]
|
|
387
|
+
);
|
|
388
|
+
return rows[0] || null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// readRunnerDeviceBySession — the device a runner's OWN login checks in from.
|
|
392
|
+
// The session id is the server's (it came off the request's session, never the
|
|
393
|
+
// body or the query), so this is the one way to find a runner's device that the
|
|
394
|
+
// runner cannot steer. Null when that login has not checked in yet.
|
|
395
|
+
async function readRunnerDeviceBySession(builderId, sessionId) {
|
|
396
|
+
if (sessionId == null) return null;
|
|
397
|
+
const { rows } = await pool.query(
|
|
398
|
+
`SELECT host, run_requested, run_changed_at FROM autonomy_runner_heartbeat
|
|
399
|
+
WHERE builder_id = $1 AND session_id = $2
|
|
400
|
+
ORDER BY last_seen_at DESC LIMIT 1`,
|
|
401
|
+
[builderId, sessionId]
|
|
402
|
+
);
|
|
403
|
+
return rows[0] || null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// setRunnerRun — the builder presses Run or Stop on ONE of their own devices.
|
|
407
|
+
// Keyed by the caller's id, so nobody can reach another builder's device. Returns
|
|
408
|
+
// null when there is no such device, so a typo is a 404 and never an orphan row.
|
|
409
|
+
async function setRunnerRun({ builderId, host, run }) {
|
|
410
|
+
const { rows } = await pool.query(
|
|
411
|
+
`UPDATE autonomy_runner_heartbeat
|
|
412
|
+
SET run_requested = $3, run_changed_at = now(), updated_at = now()
|
|
413
|
+
WHERE builder_id = $1 AND host = $2
|
|
414
|
+
RETURNING host, run_requested, run_changed_at`,
|
|
415
|
+
[builderId, deviceHost(host), run === true]
|
|
416
|
+
);
|
|
417
|
+
return rows[0] || null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// removeRunner — forget one of the caller's own devices. Returns the row's
|
|
421
|
+
// session id (or null) so the caller can say whether it had a login of its own;
|
|
422
|
+
// revoking that login is the core's own-session route, not this module's.
|
|
423
|
+
async function removeRunner({ builderId, host }) {
|
|
424
|
+
const { rows } = await pool.query(
|
|
425
|
+
'DELETE FROM autonomy_runner_heartbeat WHERE builder_id = $1 AND host = $2 RETURNING host, session_id',
|
|
426
|
+
[builderId, deviceHost(host)]
|
|
427
|
+
);
|
|
428
|
+
const r = rows[0];
|
|
429
|
+
return r ? { host: r.host, session_id: r.session_id ? String(r.session_id) : null } : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
357
432
|
module.exports = {
|
|
358
433
|
recordAutonomyRun,
|
|
359
434
|
lastAutonomyHead,
|
|
@@ -361,6 +436,10 @@ module.exports = {
|
|
|
361
436
|
readRankRoster,
|
|
362
437
|
recordRunnerHeartbeat,
|
|
363
438
|
readRunnerHeartbeats,
|
|
439
|
+
readRunnerDevice,
|
|
440
|
+
readRunnerDeviceBySession,
|
|
441
|
+
setRunnerRun,
|
|
442
|
+
removeRunner,
|
|
364
443
|
readFence,
|
|
365
444
|
setFenceEnabled,
|
|
366
445
|
allowGoal,
|
|
@@ -55,10 +55,11 @@ const REFUSALS = {
|
|
|
55
55
|
builder_paused: 'the owner paused your runner',
|
|
56
56
|
no_builder_scope: 'you have not picked any goals for your runner — choose them in Settings',
|
|
57
57
|
pin_move: 'the worker would be told to move a live core pin, and going live always waits for a person',
|
|
58
|
+
device_stopped: 'this device is stopped — press Run on its card in Settings',
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
// The stop codes effectiveFence may set, and the refusal each one becomes.
|
|
61
|
-
const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused']);
|
|
62
|
+
const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused', 'device_stopped']);
|
|
62
63
|
|
|
63
64
|
// effectiveFence — the fence ONE builder's runner reads (task 1004501).
|
|
64
65
|
//
|
|
@@ -79,7 +80,16 @@ const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused']);
|
|
|
79
80
|
// ({ goals:[ids], owner_paused, owner_paused_reason, updated_at }) or null for a
|
|
80
81
|
// builder with no row; `mayRun` is the autonomy.run answer, where anything but
|
|
81
82
|
// `true` refuses. Returns null when the project fence is null (unreadable).
|
|
82
|
-
|
|
83
|
+
//
|
|
84
|
+
// THE DEVICE'S OWN SWITCH is the last layer (task 1005094, owner direction
|
|
85
|
+
// 2026-11-09: one Run button per device). `device` is db.readRunnerDevice()'s row
|
|
86
|
+
// for the host the runner named, or null — no host named (a runner that predates
|
|
87
|
+
// this), or a device that has never checked in. Only an explicit
|
|
88
|
+
// `run_requested === false` stops it, so a caller that names no device is exactly
|
|
89
|
+
// as fenced as before. It comes last because it is the builder's own choice about
|
|
90
|
+
// one machine: every stop above it is someone else's decision or the project's,
|
|
91
|
+
// and the reason a runner reports should be the widest one that applies.
|
|
92
|
+
function effectiveFence({ project, scope = null, mayRun = false, device = null } = {}) {
|
|
83
93
|
if (!project || typeof project !== 'object') return null;
|
|
84
94
|
const picks = new Set(((scope && scope.goals) || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0));
|
|
85
95
|
const projectGoals = Array.isArray(project.goals) ? project.goals : [];
|
|
@@ -96,11 +106,13 @@ function effectiveFence({ project, scope = null, mayRun = false } = {}) {
|
|
|
96
106
|
} else if (enabled && scope && scope.owner_paused === true) {
|
|
97
107
|
enabled = false; stopCode = 'builder_paused';
|
|
98
108
|
pausedReason = scope.owner_paused_reason ? `${REFUSALS.builder_paused}: ${scope.owner_paused_reason}` : REFUSALS.builder_paused;
|
|
109
|
+
} else if (enabled && device && device.run_requested === false) {
|
|
110
|
+
enabled = false; stopCode = 'device_stopped'; pausedReason = REFUSALS.device_stopped;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
|
-
// The
|
|
102
|
-
// the fence's updated_at exactly as a project change does.
|
|
103
|
-
const stamps = [project.updated_at, scope && scope.updated_at].filter(Boolean).map((t) => new Date(t).getTime()).filter(Number.isFinite);
|
|
113
|
+
// The latest of the rows, so a pick, a pause or a press of Run wakes a runner
|
|
114
|
+
// that waits on the fence's updated_at exactly as a project change does.
|
|
115
|
+
const stamps = [project.updated_at, scope && scope.updated_at, device && device.run_changed_at].filter(Boolean).map((t) => new Date(t).getTime()).filter(Number.isFinite);
|
|
104
116
|
const updatedAt = stamps.length ? new Date(Math.max(...stamps)).toISOString() : (project.updated_at || null);
|
|
105
117
|
|
|
106
118
|
return {
|
|
@@ -119,6 +131,9 @@ function effectiveFence({ project, scope = null, mayRun = false } = {}) {
|
|
|
119
131
|
owner_paused: !!(scope && scope.owner_paused === true),
|
|
120
132
|
owner_paused_reason: (scope && scope.owner_paused_reason) || null,
|
|
121
133
|
},
|
|
134
|
+
// The device the runner named and where its switch stands, so the runner's
|
|
135
|
+
// log can say which machine a stop was about. Null when it named none.
|
|
136
|
+
device: device ? { host: device.host || null, run_requested: device.run_requested !== false } : null,
|
|
122
137
|
// The unnarrowed project fence, for the owner's gate page.
|
|
123
138
|
project,
|
|
124
139
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- autonomy_006_runner_device.sql — each device's own Run switch, and what its
|
|
2
|
+
-- button says while it waits (task 1005094, goal 1000139).
|
|
3
|
+
--
|
|
4
|
+
-- Owner direction, 2026-11-09: running the runner on a device is ONE button on
|
|
5
|
+
-- that device's card, and any cooldown is shown on the button itself. Until now
|
|
6
|
+
-- a builder had no switch of their own at all: a set-up device ran whenever the
|
|
7
|
+
-- project switch and their goal picks allowed it, on every device at once.
|
|
8
|
+
--
|
|
9
|
+
-- run_requested the device's switch. The builder flips it from Settings; the
|
|
10
|
+
-- runner reads it as part of its fence (GET /autonomy/fence
|
|
11
|
+
-- ?host=) and cannot write it — the routes that change it
|
|
12
|
+
-- refuse a runner's own login, so a worker can never press Run
|
|
13
|
+
-- for itself after its builder pressed Stop.
|
|
14
|
+
-- DEFAULT true ON PURPOSE: every runner that already exists
|
|
15
|
+
-- keeps working when this lands. A device added through the
|
|
16
|
+
-- new setup is written false by its first check-in (the route
|
|
17
|
+
-- does that, for a runner's own login only), so the first
|
|
18
|
+
-- press of Run is the person's.
|
|
19
|
+
-- run_changed_at when the switch last moved. It feeds the fence's updated_at,
|
|
20
|
+
-- which a waiting runner polls, so a press reaches the device
|
|
21
|
+
-- in under a minute instead of at its next idle wake; the hall
|
|
22
|
+
-- compares it with last_seen_at to show "Starting".
|
|
23
|
+
-- hold_until when a resting runner will resume: the reset time of the
|
|
24
|
+
-- usage limit or capacity hold it is sleeping to, as the runner
|
|
25
|
+
-- itself reports it. Null when it is not resting. The button
|
|
26
|
+
-- shows this time; it decides nothing.
|
|
27
|
+
-- session_id the runner's OWN login (builder_sessions.id), recorded from
|
|
28
|
+
-- the session that checked in — never from the body — when
|
|
29
|
+
-- that session is a runner's. "Remove this device" revokes
|
|
30
|
+
-- exactly that login. Null for a runner still on its builder's
|
|
31
|
+
-- CLI session, which Remove must not revoke.
|
|
32
|
+
--
|
|
33
|
+
-- No foreign key on session_id: a revoked session's row may be swept, and a
|
|
34
|
+
-- check-in row that outlives it must simply read as "no login of its own".
|
|
35
|
+
-- Additive and idempotent.
|
|
36
|
+
|
|
37
|
+
BEGIN;
|
|
38
|
+
|
|
39
|
+
ALTER TABLE autonomy_runner_heartbeat
|
|
40
|
+
ADD COLUMN IF NOT EXISTS run_requested boolean NOT NULL DEFAULT true,
|
|
41
|
+
ADD COLUMN IF NOT EXISTS run_changed_at timestamptz,
|
|
42
|
+
ADD COLUMN IF NOT EXISTS hold_until timestamptz,
|
|
43
|
+
ADD COLUMN IF NOT EXISTS session_id bigint;
|
|
44
|
+
|
|
45
|
+
COMMIT;
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"maintenance": { "status": "core-maintained" },
|
|
10
10
|
"contributes": {
|
|
11
11
|
"routes": ["autonomy"],
|
|
12
|
-
"pollers": ["routine-timers", "daily-checks"]
|
|
12
|
+
"pollers": ["routine-timers", "daily-checks"],
|
|
13
|
+
"skills": ["autobongos-setup"]
|
|
13
14
|
},
|
|
14
15
|
"provides": [],
|
|
15
16
|
"consumes": []
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// may not do. This route is a thin wrapper over its `precheck`.
|
|
19
19
|
|
|
20
20
|
const express = require('express');
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
21
23
|
// The published doorway — requireBuilder / requireRank live on the doorway surface
|
|
22
24
|
// (a module never reaches into core auth directly; the one-way boundary, ADR 0083).
|
|
23
25
|
const api = require('../../../src/module-api');
|
|
@@ -102,6 +104,40 @@ function commitOrNull(v) {
|
|
|
102
104
|
return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
// holdUntilOrNull — the time a resting runner says it resumes (task 1005094), as a
|
|
108
|
+
// Date, or null. The runner sends epoch SECONDS. Kept only inside a window a real
|
|
109
|
+
// rest can fall in: a usage window is at most seven days, so anything further out,
|
|
110
|
+
// or more than a day in the past, is a clock or a bug and is dropped rather than
|
|
111
|
+
// printed on a button.
|
|
112
|
+
function holdUntilOrNull(v, nowMs = Date.now()) {
|
|
113
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
|
114
|
+
const ms = v * 1000;
|
|
115
|
+
if (ms < nowMs - 24 * 3600 * 1000 || ms > nowMs + 8 * 24 * 3600 * 1000) return null;
|
|
116
|
+
return new Date(ms);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A device name as a path or query value: what os.hostname() can be, and no more.
|
|
120
|
+
// The row key is the lowercased name, so that is what is returned.
|
|
121
|
+
function hostOrNull(v) {
|
|
122
|
+
if (typeof v !== 'string') return null;
|
|
123
|
+
const h = v.trim().toLowerCase();
|
|
124
|
+
return h && h.length <= 255 && /^[a-z0-9._ -]+$/.test(h) ? h : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The two first lines of setup (task 1005095), served as text. They live under
|
|
128
|
+
// scripts/ with the rest of the runner's device program; this module only hands
|
|
129
|
+
// them out, read once per process.
|
|
130
|
+
const BOOTSTRAP_DIR = path.join(__dirname, '..', '..', '..', 'scripts', 'bongos');
|
|
131
|
+
const bootstrapCache = new Map();
|
|
132
|
+
function bootstrapScript(name) {
|
|
133
|
+
if (!bootstrapCache.has(name)) {
|
|
134
|
+
let text = null;
|
|
135
|
+
try { text = fs.readFileSync(path.join(BOOTSTRAP_DIR, name), 'utf8'); } catch (_) { text = null; }
|
|
136
|
+
bootstrapCache.set(name, text);
|
|
137
|
+
}
|
|
138
|
+
return bootstrapCache.get(name);
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
// The governor's docket (task 1004523): the fence + the money reading,
|
|
106
142
|
// contributed from this factory (see ../governor-docket.js).
|
|
107
143
|
const governorDocket = require('../governor-docket');
|
|
@@ -182,17 +218,50 @@ module.exports = function buildAutonomyRouter() {
|
|
|
182
218
|
// narrowed to their own picks (fence.js effectiveFence). Computing it here keeps
|
|
183
219
|
// the runner dumb and means a runner that predates this change is narrowed too.
|
|
184
220
|
// The unnarrowed project fence rides along as `project` for the owner's page.
|
|
221
|
+
//
|
|
222
|
+
// AND THE CALLER'S DEVICE (task 1005094). A runner names the machine it is on
|
|
223
|
+
// (?host=), and that device's own Run switch is the last layer: stopped there,
|
|
224
|
+
// the answer is enabled:false / stop_code device_stopped, whatever every other
|
|
225
|
+
// layer says. A caller that names no host — a runner that predates this, the
|
|
226
|
+
// Settings page — gets no device layer and is exactly as fenced as before. A
|
|
227
|
+
// host that will not parse is treated as not named, never as an error: the one
|
|
228
|
+
// thing this read must not do is fail a runner into "unreadable" over a name.
|
|
229
|
+
//
|
|
230
|
+
// ONE MORE READ, knowingly. holdsPermission above was written so that this
|
|
231
|
+
// route, which every runner asks on every iteration and every waiting poll,
|
|
232
|
+
// adds no query for the permission. The device layer does add one: a single
|
|
233
|
+
// row by primary key (builder, host), or by (builder, session) for a runner
|
|
234
|
+
// login, run beside the other reads rather than after them. It is paid only by
|
|
235
|
+
// a caller that names a device or is a runner login; the Settings page and a
|
|
236
|
+
// runner that predates this pay nothing new.
|
|
237
|
+
//
|
|
238
|
+
// A RUNNER'S OWN LOGIN DOES NOT GET TO CHOOSE. For such a session the device is
|
|
239
|
+
// the one that login checks in from, found by the session id the server holds;
|
|
240
|
+
// the name it sends is not consulted. Otherwise a runner whose builder pressed
|
|
241
|
+
// Stop could read its fence without a name, or under another device's, and be
|
|
242
|
+
// told to carry on. And a runner login with no check-in yet reads as STOPPED:
|
|
243
|
+
// its row will be born stopped anyway, and a login that has not said which
|
|
244
|
+
// device it is must not be answered as if it had none.
|
|
185
245
|
router.get('/autonomy/fence', auth.requireBuilder, async (req, res) => {
|
|
186
246
|
try {
|
|
187
|
-
const
|
|
188
|
-
|
|
247
|
+
const host = hostOrNull(req.query && req.query.host);
|
|
248
|
+
const runnerLogin = req.builder.runner === true;
|
|
249
|
+
const sessionId = req.gdsSession && req.gdsSession.session_id != null ? req.gdsSession.session_id : null;
|
|
250
|
+
const readDevice = async () => {
|
|
251
|
+
if (runnerLogin) {
|
|
252
|
+
return (await db.readRunnerDeviceBySession(req.builder.id, sessionId)) || { host, run_requested: false, run_changed_at: null };
|
|
253
|
+
}
|
|
254
|
+
return host ? db.readRunnerDevice(req.builder.id, host) : null;
|
|
255
|
+
};
|
|
256
|
+
const [project, scope, mayRun, device] = await Promise.all([
|
|
257
|
+
db.readFence(), db.readBuilderScope(req.builder.id), holdsPermission(req, 'autonomy.run'), readDevice(),
|
|
189
258
|
]);
|
|
190
259
|
// Null means the singleton row is missing, i.e. the migration has not run.
|
|
191
260
|
// Say so rather than synthesising a default: a fabricated { enabled: false }
|
|
192
261
|
// is indistinguishable from a real one, and an operator would chase a kill
|
|
193
262
|
// switch nobody threw.
|
|
194
263
|
if (!project) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
|
|
195
|
-
res.json(fence.effectiveFence({ project, scope, mayRun }));
|
|
264
|
+
res.json(fence.effectiveFence({ project, scope, mayRun, device }));
|
|
196
265
|
} catch (err) {
|
|
197
266
|
log.error('[gds] GET /autonomy/fence', err);
|
|
198
267
|
// FAIL CLOSED, unlike the precheck above. That one is fail-open because an
|
|
@@ -378,8 +447,14 @@ module.exports = function buildAutonomyRouter() {
|
|
|
378
447
|
disk_head: { type: 'string', maxLength: 40 },
|
|
379
448
|
main_head: { type: 'string', maxLength: 40 },
|
|
380
449
|
claude_account: { type: 'string', maxLength: 200 },
|
|
450
|
+
// When a resting runner resumes, in epoch seconds (task 1005094).
|
|
451
|
+
hold_until: { type: 'number' },
|
|
381
452
|
})) return;
|
|
382
453
|
const b = req.body || {};
|
|
454
|
+
// The runner's OWN login, when that is what checked in (task 1005094). From
|
|
455
|
+
// the session, never the body: it is what "Remove this device" revokes, and a
|
|
456
|
+
// caller must not be able to point that at a session of their choosing.
|
|
457
|
+
const ownLogin = req.builder.runner === true && req.gdsSession && req.gdsSession.session_id != null;
|
|
383
458
|
try {
|
|
384
459
|
const row = await db.recordRunnerHeartbeat({
|
|
385
460
|
// From the SESSION, never the body. A caller cannot name someone else's
|
|
@@ -399,6 +474,11 @@ module.exports = function buildAutonomyRouter() {
|
|
|
399
474
|
diskHead: commitOrNull(b.disk_head),
|
|
400
475
|
mainHead: commitOrNull(b.main_head),
|
|
401
476
|
claudeAccount: typeof b.claude_account === 'string' && b.claude_account.trim() ? b.claude_account.trim() : null,
|
|
477
|
+
holdUntil: holdUntilOrNull(b.hold_until),
|
|
478
|
+
sessionId: ownLogin ? req.gdsSession.session_id : null,
|
|
479
|
+
// A device set up the new way starts STOPPED, so the first press of Run is
|
|
480
|
+
// the person's. Only ever the birth value of a new row (db.js).
|
|
481
|
+
startStopped: ownLogin,
|
|
402
482
|
});
|
|
403
483
|
res.json({ ok: true, heartbeat: row });
|
|
404
484
|
// AFTER the answer, and never awaited by it: a notice that cannot be posted
|
|
@@ -441,5 +521,87 @@ module.exports = function buildAutonomyRouter() {
|
|
|
441
521
|
}
|
|
442
522
|
});
|
|
443
523
|
|
|
524
|
+
// ── each device's Run button (task 1005094) ────────────────────────────────
|
|
525
|
+
//
|
|
526
|
+
// Owner direction, 2026-11-09: running the runner on a device is ONE button on
|
|
527
|
+
// that device's card. These two routes are that button and its Remove.
|
|
528
|
+
//
|
|
529
|
+
// NEITHER ANSWERS A RUNNER'S OWN LOGIN. A worker holds that login and runs with
|
|
530
|
+
// blanket permissions; if it could reach these, a runner whose builder pressed
|
|
531
|
+
// Stop could press Run for itself, or delete its own row and be born again. So
|
|
532
|
+
// both refuse `req.builder.runner` before anything is read. The builder's own
|
|
533
|
+
// browser or CLI session is the only thing that moves the switch.
|
|
534
|
+
const refuseRunnerLogin = (req, res) => {
|
|
535
|
+
if (req.builder && req.builder.runner === true) {
|
|
536
|
+
res.fail('runner_session', { status: 403, message: 'A runner cannot start, stop or remove itself. Use its card in Settings, signed in as yourself.' });
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
return false;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// Run or Stop ONE of the caller's own devices. Behind `autonomy.run`, like the
|
|
543
|
+
// goal picks: a builder who may not run a runner has nothing to start. Stopping
|
|
544
|
+
// is allowed to the same people and no wider — it is their own machine.
|
|
545
|
+
router.post('/autonomy/runners/:host/run', auth.requireBuilder, auth.requirePermission('autonomy.run'), async (req, res) => {
|
|
546
|
+
if (refuseRunnerLogin(req, res)) return;
|
|
547
|
+
if (validateOrRespond(req, res, { run: { required: true, type: 'boolean' } })) return;
|
|
548
|
+
const host = hostOrNull(req.params.host);
|
|
549
|
+
if (!host) return res.fail('bad_host', { status: 400, message: 'that is not a device name' });
|
|
550
|
+
try {
|
|
551
|
+
const row = await db.setRunnerRun({ builderId: req.builder.id, host, run: req.body.run });
|
|
552
|
+
if (!row) return res.fail('device_not_found', { status: 404, message: 'You have no device by that name. It appears once its runner has checked in.' });
|
|
553
|
+
log.info(`[gds] autonomy: builder ${req.builder.id} pressed ${req.body.run ? 'RUN' : 'STOP'} on device ${host}`);
|
|
554
|
+
res.json({ host: row.host, run_requested: row.run_requested, run_changed_at: row.run_changed_at });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
log.error('[gds] POST /autonomy/runners/:host/run', err);
|
|
557
|
+
res.fail('run_write_failed', { status: 500, message: 'internal error' });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Forget one of the caller's own devices. ANY-BUILDER, deliberately wider than
|
|
562
|
+
// Run: a builder who has since lost `autonomy.run` must still be able to clear
|
|
563
|
+
// their own device off their own page, and the row is keyed by the caller, so
|
|
564
|
+
// the only thing reachable is theirs. It does not revoke the device's login —
|
|
565
|
+
// that is the core's own-session route (POST /me/sessions/revoke), which the
|
|
566
|
+
// Settings page calls first with the `session_id` GET /autonomy/runners gave it.
|
|
567
|
+
// `had_own_login` says whether there was one to revoke.
|
|
568
|
+
router.delete('/autonomy/runners/:host', auth.requireBuilder, async (req, res) => {
|
|
569
|
+
if (refuseRunnerLogin(req, res)) return;
|
|
570
|
+
const host = hostOrNull(req.params.host);
|
|
571
|
+
if (!host) return res.fail('bad_host', { status: 400, message: 'that is not a device name' });
|
|
572
|
+
try {
|
|
573
|
+
const out = await db.removeRunner({ builderId: req.builder.id, host });
|
|
574
|
+
if (!out) return res.fail('device_not_found', { status: 404, message: 'You have no device by that name.' });
|
|
575
|
+
log.info(`[gds] autonomy: builder ${req.builder.id} removed device ${host}`);
|
|
576
|
+
res.json({ removed: true, host: out.host, had_own_login: !!out.session_id });
|
|
577
|
+
} catch (err) {
|
|
578
|
+
log.error('[gds] DELETE /autonomy/runners/:host', err);
|
|
579
|
+
res.fail('remove_failed', { status: 500, message: 'internal error' });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// The first lines of setup, one per platform (task 1005095). The command the
|
|
584
|
+
// Settings page shows fetches one of these and runs it with the one-time code.
|
|
585
|
+
//
|
|
586
|
+
// Not left open-ended: like every read with no limiter of its own they sit under
|
|
587
|
+
// the server's default per-address read ceiling, and each answer is a string
|
|
588
|
+
// already in memory (read once per process), so a flood costs no disk and no
|
|
589
|
+
// database.
|
|
590
|
+
//
|
|
591
|
+
// rank: public — a clean device has no login yet, which is the point of setup.
|
|
592
|
+
// The files hold no secret: they are the same text as scripts/bongos/ in the
|
|
593
|
+
// repository, they take the code, the project address and the repository as
|
|
594
|
+
// arguments, and nothing about this instance is written into them.
|
|
595
|
+
const sendBootstrap = (file, type) => (req, res) => {
|
|
596
|
+
const text = bootstrapScript(file);
|
|
597
|
+
if (!text) return res.fail('setup_script_missing', { status: 503, message: 'this instance cannot serve the runner setup script' });
|
|
598
|
+
res.set('Cache-Control', 'no-store');
|
|
599
|
+
res.type(type).send(text);
|
|
600
|
+
};
|
|
601
|
+
// rank: public — the Windows first lines; see the note above.
|
|
602
|
+
router.get('/autonomy/runner-setup.ps1', sendBootstrap('autobongos-bootstrap.ps1', 'text/plain; charset=utf-8'));
|
|
603
|
+
// rank: public — the Mac first lines; see the note above.
|
|
604
|
+
router.get('/autonomy/runner-setup.sh', sendBootstrap('autobongos-bootstrap.sh', 'text/x-shellscript; charset=utf-8'));
|
|
605
|
+
|
|
444
606
|
return router;
|
|
445
607
|
};
|
|
@@ -30,6 +30,16 @@
|
|
|
30
30
|
// it carries the fix as `remedy`. The runner decides the grace and says
|
|
31
31
|
// `config_idle:<code>` on its heartbeat; only a code config-idle.js knows is
|
|
32
32
|
// honoured, so the owner's kill switch and pause can never read as an alarm.
|
|
33
|
+
// · stopped — alive, and its builder pressed Stop on this device (task
|
|
34
|
+
// 1005094). Not an alarm and not "running": the device is doing what it was
|
|
35
|
+
// told, and its card shows Run. Only a runner that is still checking in can
|
|
36
|
+
// be stopped; a silent one is late or dead first, because a device nobody
|
|
37
|
+
// has heard from cannot vouch for anything, its switch included.
|
|
38
|
+
//
|
|
39
|
+
// Every state also carries `runRequested` (the device's switch, true unless its
|
|
40
|
+
// builder turned it off) and `holdUntil` (when a resting runner resumes, as the
|
|
41
|
+
// runner reported it — only while that time is still ahead), so the Run button
|
|
42
|
+
// can show Stop, Starting or a rest time without a second read.
|
|
33
43
|
|
|
34
44
|
// Reasoned from the runner's own numbers, not picked. The loop beats at the top of
|
|
35
45
|
// every pass and again on each poll inside a wait (every 60s), so a healthy runner
|
|
@@ -118,10 +128,17 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
|
|
|
118
128
|
const mode = heartbeat.mode === 'probing' ? 'probing' : (heartbeat.mode === 'full' ? 'full' : null);
|
|
119
129
|
const working = heartbeat.working_task_id ? String(heartbeat.working_task_id) : null;
|
|
120
130
|
const where = host ? ` on ${host}` : '';
|
|
131
|
+
const runRequested = heartbeat.run_requested !== false;
|
|
132
|
+
// A rest the runner named, kept only while it is still ahead: a time already
|
|
133
|
+
// past is a runner that has resumed, or one that has gone quiet, and either
|
|
134
|
+
// way it is no longer what the button should say.
|
|
135
|
+
const holdMs = heartbeat.hold_until ? Date.parse(heartbeat.hold_until instanceof Date ? heartbeat.hold_until.toISOString() : heartbeat.hold_until) : NaN;
|
|
121
136
|
const ident = {
|
|
122
137
|
host, mode, workingTaskId: working,
|
|
123
138
|
account: typeof heartbeat.claude_account === 'string' && heartbeat.claude_account ? heartbeat.claude_account : null,
|
|
124
139
|
code: describeCode(heartbeat),
|
|
140
|
+
runRequested,
|
|
141
|
+
holdUntil: Number.isFinite(holdMs) && holdMs > nowMs ? new Date(holdMs).toISOString() : null,
|
|
125
142
|
};
|
|
126
143
|
|
|
127
144
|
if (heartbeat.last_event === 'upgrade_exit') {
|
|
@@ -149,6 +166,16 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
|
|
|
149
166
|
};
|
|
150
167
|
}
|
|
151
168
|
|
|
169
|
+
// Stopped on this device by its builder, and not mid-task: the card shows Run.
|
|
170
|
+
// A runner still building when Stop was pressed finishes that task first, and
|
|
171
|
+
// reads as the working runner it is until then.
|
|
172
|
+
if (!runRequested && !working && age <= aliveS) {
|
|
173
|
+
return {
|
|
174
|
+
state: 'stopped', ok: true, ageS: age, age: humanAge(age), ...ident,
|
|
175
|
+
detail: `stopped${where} — press Run on its card to start it`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
152
179
|
if (age <= aliveS) {
|
|
153
180
|
// A setting still inside its grace reads as the fence wait it is, not "running".
|
|
154
181
|
const doing = ACTIVITY[heartbeat.last_event] || (configIdle.pendingCode(heartbeat.last_event) ? ACTIVITY.fenced : null);
|