@bongos/core 1.24.76 → 1.24.78
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 +129 -49
- 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 +45 -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/gauge.js +25 -1
- 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-bootstrap.ps1 +169 -0
- package/scripts/bongos/autobongos-bootstrap.sh +151 -0
- package/scripts/bongos/autobongos-host.js +883 -0
- package/scripts/bongos/autobongos-run.js +25 -4
- package/scripts/bongos/autonomy-gauge.js +11 -3
- package/scripts/bongos/cli-lib.js +5 -1
- package/scripts/bongos/stranded-rows.js +39 -2
- package/scripts/gds/autobongos-bootstrap.ps1 +8 -0
- package/scripts/gds/autobongos-bootstrap.sh +8 -0
- package/scripts/gds/autobongos-host.js +7 -0
- package/scripts/hall-preview/server.js +7 -0
- package/src/instance-config.js +22 -3
- package/src/module-api.js +1 -1
- package/tests/autobongos_host.mjs +647 -0
- package/tests/autobongos_loop.mjs +11 -0
- package/tests/autonomy_config_idle.mjs +9 -4
- package/tests/autonomy_gauge.mjs +43 -0
- package/tests/autonomy_runner_device.mjs +444 -0
- package/tests/autonomy_runner_identity.mjs +3 -1
- package/tests/cli_sessions.mjs +16 -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
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/bongos/autobongos-host.js — the Autobongos runner ON THIS DEVICE: set it
|
|
5
|
+
// up, keep it running, stop it honestly (task 1005095, goal 1000139, ADR 0307 §5).
|
|
6
|
+
//
|
|
7
|
+
// node scripts/bongos/autobongos-host.js setup --code abr_… --api-base https://…
|
|
8
|
+
// node scripts/bongos/autobongos-host.js status [--json]
|
|
9
|
+
// node scripts/bongos/autobongos-host.js start | stop
|
|
10
|
+
// node scripts/bongos/autobongos-host.js install | uninstall
|
|
11
|
+
// node scripts/bongos/autobongos-host.js digest [--since 24h] # the night, in five answers
|
|
12
|
+
// node scripts/bongos/autobongos-host.js run # what the start entry runs
|
|
13
|
+
//
|
|
14
|
+
// WHY. The runner could only be hosted one way: a Windows scheduled task
|
|
15
|
+
// registered from an Administrator shell (autobongos-service.ps1 + .cmd), written
|
|
16
|
+
// for one always-on PC. Owner direction 2026-11-09: it must run on ANY device —
|
|
17
|
+
// Windows or Mac, always-on or a laptop — from one button. So this is one Node
|
|
18
|
+
// program for both platforms, and nothing in it needs an administrator:
|
|
19
|
+
//
|
|
20
|
+
// · It starts when the person SIGNS IN to the device (Windows: a per-user Run
|
|
21
|
+
// entry; Mac: a per-user launchd agent). A laptop that sleeps simply stops
|
|
22
|
+
// checking in and picks up again when it wakes; the hall says so.
|
|
23
|
+
// · `run` is the outer ring the .cmd used to be: bring the runner's own copy of
|
|
24
|
+
// the project current, launch the supervisor (autobongos-run.js --forever),
|
|
25
|
+
// relaunch it on its upgrade exit (75) or after a crash, with a growing wait.
|
|
26
|
+
// · The runner has ITS OWN login and files (BONGOS_CONFIG_ROOT), apart from the
|
|
27
|
+
// builder's CLI session on the same device, so adding a laptop as a runner
|
|
28
|
+
// never signs the builder's own tools out.
|
|
29
|
+
// · `stop` and `uninstall` WATCH the processes exit before saying so. The
|
|
30
|
+
// Windows stop once printed "stopped" over a live runner (task 1004375).
|
|
31
|
+
// · While the runner is allowed to work the device is kept from idle-sleeping,
|
|
32
|
+
// and released the moment the fence stops it.
|
|
33
|
+
//
|
|
34
|
+
// THE ONE THING IT REFUSES. `run` hard-resets its checkout to origin/main, so it
|
|
35
|
+
// only ever does that in a copy marked as the runner's own (`setup
|
|
36
|
+
// --mark-runner-copy`, which the bootstrap passes right after it clones). Run from
|
|
37
|
+
// a builder's working copy it stops with a sentence instead of eating their work.
|
|
38
|
+
//
|
|
39
|
+
// The boot-time scheduled task (autobongos-service.ps1) stays for an always-on PC
|
|
40
|
+
// that must come back with nobody signed in; it needs an elevated shell once.
|
|
41
|
+
|
|
42
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
43
|
+
const fs = require('node:fs');
|
|
44
|
+
const path = require('node:path');
|
|
45
|
+
const os = require('node:os');
|
|
46
|
+
const crypto = require('node:crypto');
|
|
47
|
+
// The one shared sleep and the one shared escaper (tasks 1004893, 1004876).
|
|
48
|
+
const { sleep: sharedSleep } = require('../../src/sleep');
|
|
49
|
+
const { escapeHtml } = require('../../src/html-escape');
|
|
50
|
+
|
|
51
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
52
|
+
const LABEL = 'com.cloudbongos.autobongos'; // the launchd agent
|
|
53
|
+
const RUN_VALUE = 'Autobongos'; // the Windows Run entry
|
|
54
|
+
const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
55
|
+
const RUNNER_COPY_MARK = 'autobongos-runner-copy';
|
|
56
|
+
// The supervisor's one deliberate exit (autobongos-run.js UPGRADE_EXIT_CODE).
|
|
57
|
+
// Pinned against it in tests/autobongos_host.mjs rather than required from it:
|
|
58
|
+
// that file loads the whole CLI, and this loop must be able to start without it.
|
|
59
|
+
const UPGRADE_EXIT_CODE = 75;
|
|
60
|
+
const MIN_BACKOFF_S = 5;
|
|
61
|
+
const MAX_BACKOFF_S = 300;
|
|
62
|
+
const HEALTHY_RUN_MS = 10 * 60 * 1000; // a run this long resets the crash backoff
|
|
63
|
+
const LOCK_BEAT_MS = 30 * 1000;
|
|
64
|
+
const LOCK_STALE_MS = 2 * 60 * 1000;
|
|
65
|
+
const STOP_WAIT_MS = 20 * 1000;
|
|
66
|
+
const LOG_ROTATE_BYTES = 20 * 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
// ── where the runner's own files live ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
// The runner's folder. AUTOBONGOS_HOME wins; otherwise a marked runner copy that
|
|
71
|
+
// sits at <folder>/repo (where both bootstraps put it) IS inside its folder, so
|
|
72
|
+
// the start entry needs no environment of its own to find the right files; and
|
|
73
|
+
// only then the default.
|
|
74
|
+
function runnerHome(env = process.env, home = os.homedir(), repoRoot = REPO_ROOT) {
|
|
75
|
+
if (env.AUTOBONGOS_HOME) return path.resolve(env.AUTOBONGOS_HOME);
|
|
76
|
+
if (path.basename(repoRoot) === 'repo' && isRunnerCopy(repoRoot)) return path.dirname(repoRoot);
|
|
77
|
+
return path.join(home, '.cloudbongos-runner');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// hostPaths — every file this program owns, from one root. `config` is handed to
|
|
81
|
+
// the supervisor as BONGOS_CONFIG_ROOT, which is what keeps its login apart.
|
|
82
|
+
function hostPaths(env = process.env, home = os.homedir()) {
|
|
83
|
+
const root = runnerHome(env, home);
|
|
84
|
+
return {
|
|
85
|
+
root,
|
|
86
|
+
configRoot: path.join(root, 'config'),
|
|
87
|
+
logDir: path.join(root, 'logs'),
|
|
88
|
+
log: path.join(root, 'logs', 'autobongos.log'),
|
|
89
|
+
lock: path.join(root, 'host.json'),
|
|
90
|
+
depsStamp: path.join(root, 'deps.sha'),
|
|
91
|
+
plist: path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The env the supervisor and its workers run under. The gauge file is pinned to
|
|
96
|
+
// where a statusLine on this device would write it (the builder's own config
|
|
97
|
+
// dir), so a device that does have a reading still uses it.
|
|
98
|
+
function supervisorEnv(env = process.env, paths = hostPaths(env), configDirName = 'cloudbongos') {
|
|
99
|
+
const gauge = env.AUTOBONGOS_GAUGE_FILE
|
|
100
|
+
|| path.join(os.homedir(), '.config', configDirName, 'autobongos-gauge.json');
|
|
101
|
+
return { ...env, BONGOS_CONFIG_ROOT: paths.configRoot, AUTOBONGOS_GAUGE_FILE: gauge };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── plain-language stops ───────────────────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// Every failure a person can do something about is a Stop: what happened, and
|
|
107
|
+
// what to do, in words. main() prints those two lines and nothing else — never a
|
|
108
|
+
// stack or raw tool output.
|
|
109
|
+
class Stop extends Error {
|
|
110
|
+
constructor(what, fix) { super(what); this.name = 'Stop'; this.fix = fix || null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── the runner's own copy ──────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function runnerCopyMarkPath(repoRoot = REPO_ROOT) { return path.join(repoRoot, '.git', RUNNER_COPY_MARK); }
|
|
116
|
+
|
|
117
|
+
// A linked worktree has a .git FILE, so the mark can only exist in a real clone.
|
|
118
|
+
function isRunnerCopy(repoRoot = REPO_ROOT) {
|
|
119
|
+
try { return fs.statSync(runnerCopyMarkPath(repoRoot)).isFile(); } catch (_) { return false; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertRunnerCopy(repoRoot = REPO_ROOT) {
|
|
123
|
+
if (isRunnerCopy(repoRoot)) return;
|
|
124
|
+
throw new Stop(
|
|
125
|
+
`${repoRoot} is not the runner's own copy of the project.`,
|
|
126
|
+
'The runner resets its copy to the latest code every time it starts, so it never runs in a working copy. Use the setup command from Settings → Your Autobongos runner; it makes the runner its own copy.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── the lock: one outer loop per device ────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; } }
|
|
133
|
+
|
|
134
|
+
function pidAlive(pid) {
|
|
135
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
136
|
+
try { process.kill(pid, 0); return true; } catch (e) { return !!e && e.code === 'EPERM'; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Is another outer loop running? A fresh beat AND a live pid AND not us — a pid
|
|
140
|
+
// alone can be a number the system has since handed to something else.
|
|
141
|
+
function lockHolder(lock, { now = Date.now(), alive = pidAlive, self = process.pid, takeover = null } = {}) {
|
|
142
|
+
if (!lock || !Number.isInteger(lock.pid)) return null;
|
|
143
|
+
if (lock.pid === self || lock.pid === takeover) return null;
|
|
144
|
+
if (!Number.isFinite(Number(lock.at)) || now - Number(lock.at) > LOCK_STALE_MS) return null;
|
|
145
|
+
return alive(lock.pid) ? lock.pid : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function writeLock(paths, fields = {}, now = Date.now()) {
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
151
|
+
fs.writeFileSync(paths.lock, JSON.stringify({ pid: process.pid, at: now, repo: REPO_ROOT, ...fields }));
|
|
152
|
+
return true;
|
|
153
|
+
} catch (_) { return false; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── keep the device awake while the runner may work ────────────────────────
|
|
157
|
+
|
|
158
|
+
// wantsAwake — from the supervisor's own rows. `fenced` means it is not allowed
|
|
159
|
+
// to work (the switch is off, the owner paused it, no goals), and a device held
|
|
160
|
+
// awake for a runner that is stopped is a laptop that never sleeps for nothing.
|
|
161
|
+
// Anything else — working, waiting for work, resting until a usage reset — is a
|
|
162
|
+
// runner the person pressed Run on, so the device stays up.
|
|
163
|
+
function wantsAwake(event, prev = false) {
|
|
164
|
+
if (event === 'fenced') return false;
|
|
165
|
+
if (!event || event === 'boot_reconcile') return prev;
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The command that holds the device awake for as long as `pid` lives, or null
|
|
170
|
+
// where there is none. Mac ships caffeinate; Windows needs the one kernel call.
|
|
171
|
+
function keepAwakeCommand(platform = process.platform, pid = process.pid) {
|
|
172
|
+
if (platform === 'darwin') return { cmd: 'caffeinate', args: ['-i', '-w', String(pid)] };
|
|
173
|
+
if (platform === 'win32') {
|
|
174
|
+
// ES_CONTINUOUS | ES_SYSTEM_REQUIRED: no idle sleep; the screen may still turn off.
|
|
175
|
+
const ps = [
|
|
176
|
+
"$sig = '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint f);'",
|
|
177
|
+
'$k = Add-Type -MemberDefinition $sig -Name AutobongosAwake -Namespace Win32 -PassThru',
|
|
178
|
+
'[void]$k::SetThreadExecutionState(2147483649)',
|
|
179
|
+
`try { Wait-Process -Id ${Number(pid)} -ErrorAction Stop } catch {}`,
|
|
180
|
+
].join('; ');
|
|
181
|
+
return { cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', ps] };
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function makeKeepAwake({ platform = process.platform, env = process.env, spawnFn = spawn, pid = process.pid } = {}) {
|
|
187
|
+
let child = null;
|
|
188
|
+
const off = String(env.AUTOBONGOS_KEEP_AWAKE || '') === '0';
|
|
189
|
+
return {
|
|
190
|
+
get on() { return !!child; },
|
|
191
|
+
set(want) {
|
|
192
|
+
if (off) return;
|
|
193
|
+
if (want && !child) {
|
|
194
|
+
const c = keepAwakeCommand(platform, pid);
|
|
195
|
+
if (!c) return;
|
|
196
|
+
try {
|
|
197
|
+
child = spawnFn(c.cmd, c.args, { stdio: 'ignore', windowsHide: true });
|
|
198
|
+
child.on('error', () => { child = null; });
|
|
199
|
+
child.on('exit', () => { child = null; });
|
|
200
|
+
} catch (_) { child = null; }
|
|
201
|
+
} else if (!want && child) {
|
|
202
|
+
try { child.kill(); } catch (_) { /* already gone */ }
|
|
203
|
+
child = null;
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── the outer loop ─────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
// runLoop — refresh, launch the supervisor, and decide what its exit means.
|
|
212
|
+
//
|
|
213
|
+
// Holds no I/O of its own: every effect is in `deps`, so the three behaviours
|
|
214
|
+
// that matter are testable without a process — an upgrade exit relaunches at
|
|
215
|
+
// once, any other exit relaunches after a wait that grows (and resets after a
|
|
216
|
+
// healthy run), and a stop request ends it. Returns 'stopped', 'relaunch_self'
|
|
217
|
+
// (this file changed under it; the caller starts a fresh copy), or the last exit
|
|
218
|
+
// code when `maxCycles` bounds it for a test.
|
|
219
|
+
async function runLoop(opts = {}, deps = {}) {
|
|
220
|
+
const log = deps.log || (() => {});
|
|
221
|
+
const now = deps.now || Date.now;
|
|
222
|
+
let backoffS = MIN_BACKOFF_S;
|
|
223
|
+
let cycles = 0;
|
|
224
|
+
for (;;) {
|
|
225
|
+
if (deps.shouldStop && deps.shouldStop()) return 'stopped';
|
|
226
|
+
// Best effort, both: a fetch that fails must not stop the runner starting on
|
|
227
|
+
// the code it has. Being a commit behind beats not running at all.
|
|
228
|
+
try { await deps.refresh(); } catch (e) { log(`refresh failed: ${e && e.message ? e.message : e}`); }
|
|
229
|
+
try { await deps.ensureDeps(); } catch (e) { log(`dependency install failed: ${e && e.message ? e.message : e}`); }
|
|
230
|
+
log('==== autobongos starting ====');
|
|
231
|
+
const started = now();
|
|
232
|
+
let code = 1;
|
|
233
|
+
try { code = await deps.spawnSupervisor(); } catch (e) { log(`could not start the supervisor: ${e && e.message ? e.message : e}`); }
|
|
234
|
+
cycles += 1;
|
|
235
|
+
if (deps.shouldStop && deps.shouldStop()) return 'stopped';
|
|
236
|
+
if (code === UPGRADE_EXIT_CODE) {
|
|
237
|
+
log('==== autobongos relaunching for an upgrade ====');
|
|
238
|
+
if (deps.selfChanged && deps.selfChanged()) return 'relaunch_self';
|
|
239
|
+
backoffS = MIN_BACKOFF_S;
|
|
240
|
+
} else {
|
|
241
|
+
if (now() - started >= HEALTHY_RUN_MS) backoffS = MIN_BACKOFF_S;
|
|
242
|
+
log(`==== autobongos exited ${code}; starting again in ${backoffS}s ====`);
|
|
243
|
+
if (opts.maxCycles && cycles >= opts.maxCycles) return code;
|
|
244
|
+
await deps.sleep(backoffS * 1000);
|
|
245
|
+
backoffS = Math.min(backoffS * 2, MAX_BACKOFF_S);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (opts.maxCycles && cycles >= opts.maxCycles) return code;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function makeLogger(paths, { echo = false } = {}) {
|
|
253
|
+
return (line) => {
|
|
254
|
+
const text = `${new Date().toISOString()} ${line}\n`;
|
|
255
|
+
try {
|
|
256
|
+
fs.mkdirSync(paths.logDir, { recursive: true });
|
|
257
|
+
fs.appendFileSync(paths.log, text);
|
|
258
|
+
} catch (_) { /* a runner that cannot log still runs */ }
|
|
259
|
+
if (echo) process.stdout.write(text);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function rotateLog(paths) {
|
|
264
|
+
try {
|
|
265
|
+
if (fs.statSync(paths.log).size > LOG_ROTATE_BYTES) fs.renameSync(paths.log, `${paths.log}.1`);
|
|
266
|
+
} catch (_) { /* no log yet */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function git(args, opts = {}) {
|
|
270
|
+
return spawnSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8', windowsHide: true, ...opts });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// refreshCheckout — the .cmd's fetch + reset, on the runner's own copy only.
|
|
274
|
+
function refreshCheckout(log = () => {}) {
|
|
275
|
+
assertRunnerCopy();
|
|
276
|
+
const f = git(['fetch', '--quiet', 'origin', 'main']);
|
|
277
|
+
if (f.status !== 0) { log(`git fetch failed — running the code already here (${String(f.stderr || '').trim().slice(0, 200)})`); return false; }
|
|
278
|
+
const r = git(['reset', '--quiet', '--hard', 'origin/main']);
|
|
279
|
+
if (r.status !== 0) { log(`git reset failed (${String(r.stderr || '').trim().slice(0, 200)})`); return false; }
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fileSha(p) {
|
|
284
|
+
try { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } catch (_) { return null; }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ensureDeps — `npm ci` when the lockfile is not the one last installed. The
|
|
288
|
+
// runner's copy moves with main, and a supervisor started against yesterday's
|
|
289
|
+
// node_modules fails in ways that look like anything but a missing package.
|
|
290
|
+
function ensureDeps(paths, log = () => {}, { stdio = 'ignore' } = {}) {
|
|
291
|
+
const want = fileSha(path.join(REPO_ROOT, 'package-lock.json'));
|
|
292
|
+
const have = (() => { try { return fs.readFileSync(paths.depsStamp, 'utf8').trim(); } catch (_) { return null; } })();
|
|
293
|
+
if (want && want === have && fs.existsSync(path.join(REPO_ROOT, 'node_modules'))) return { installed: false };
|
|
294
|
+
log('installing dependencies (npm ci)');
|
|
295
|
+
// One fixed command line through the shell, because npm is a .cmd on Windows.
|
|
296
|
+
const r = spawnSync('npm ci --no-audit --no-fund', { cwd: REPO_ROOT, stdio, shell: true, windowsHide: true });
|
|
297
|
+
if (r.status !== 0) throw new Error(`npm ci exited ${r.status}`);
|
|
298
|
+
if (want) { fs.mkdirSync(paths.root, { recursive: true }); fs.writeFileSync(paths.depsStamp, want); }
|
|
299
|
+
return { installed: true };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Kill a process and everything under it. The supervisor's workers are sessions
|
|
303
|
+
// with children of their own, and a worker left behind keeps building against a
|
|
304
|
+
// claim nothing is watching.
|
|
305
|
+
function killTree(pid, { platform = process.platform, force = false } = {}) {
|
|
306
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
307
|
+
if (platform === 'win32') {
|
|
308
|
+
// Always /F, whatever `force` says. Without it taskkill posts a close message
|
|
309
|
+
// to a window, and this tree has none (it runs under a headless console), so
|
|
310
|
+
// the polite form ends nothing and only spends the wait before the forced one.
|
|
311
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const sig = force ? 'SIGKILL' : 'SIGTERM';
|
|
315
|
+
// A detached child leads its own process group, so the negative pid reaches
|
|
316
|
+
// the whole tree; fall back to the one process when it does not.
|
|
317
|
+
try { process.kill(-pid, sig); } catch (_) { try { process.kill(pid, sig); } catch (__) { /* gone */ } }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// spawnSupervisor — one supervisor run. Its output goes to the log; its rows
|
|
321
|
+
// (--json prints one per iteration) decide whether the device is held awake.
|
|
322
|
+
function spawnSupervisorReal({ paths, env, log, keepAwake, state }) {
|
|
323
|
+
return new Promise((resolve) => {
|
|
324
|
+
const child = spawn(process.execPath, [path.join(REPO_ROOT, 'scripts', 'bongos', 'autobongos-run.js'), '--forever', '--json'], {
|
|
325
|
+
cwd: REPO_ROOT, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
326
|
+
detached: process.platform !== 'win32',
|
|
327
|
+
});
|
|
328
|
+
state.child = child;
|
|
329
|
+
let awake = false;
|
|
330
|
+
let buf = '';
|
|
331
|
+
const out = (d) => {
|
|
332
|
+
try { fs.appendFileSync(paths.log, d); } catch (_) { /* keep running */ }
|
|
333
|
+
buf += String(d);
|
|
334
|
+
let nl;
|
|
335
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
336
|
+
const line = buf.slice(0, nl).trim();
|
|
337
|
+
buf = buf.slice(nl + 1);
|
|
338
|
+
if (!line.startsWith('{')) continue;
|
|
339
|
+
let row = null;
|
|
340
|
+
try { row = JSON.parse(line); } catch (_) { row = null; }
|
|
341
|
+
if (row && typeof row.event === 'string') {
|
|
342
|
+
const next = wantsAwake(row.event, awake);
|
|
343
|
+
if (next !== awake) { awake = next; keepAwake.set(awake); }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (buf.length > 1024 * 1024) buf = '';
|
|
347
|
+
};
|
|
348
|
+
child.stdout.on('data', out);
|
|
349
|
+
child.stderr.on('data', (d) => { try { fs.appendFileSync(paths.log, d); } catch (_) { /* keep running */ } });
|
|
350
|
+
child.on('error', (e) => { state.child = null; keepAwake.set(false); log(`supervisor error: ${e.message}`); resolve(1); });
|
|
351
|
+
child.on('close', (code, signal) => { state.child = null; keepAwake.set(false); resolve(code === null ? (signal ? 1 : 0) : code); });
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The boot-time scheduled task autobongos-service.ps1 registers. Two runners on
|
|
356
|
+
// one device share a host name, so each would overwrite the other's check-in and
|
|
357
|
+
// reconcile the other's claims away.
|
|
358
|
+
function legacyBootTaskInstalled(platform = process.platform, run = spawnSync) {
|
|
359
|
+
if (platform !== 'win32') return false;
|
|
360
|
+
return run('schtasks', ['/Query', '/TN', 'Autobongos'], { stdio: 'ignore', windowsHide: true }).status === 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── starting it, now and at sign-in ────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
function conhostPath(env = process.env) {
|
|
366
|
+
return path.join(env.SystemRoot || env.windir || 'C:\\Windows', 'System32', 'conhost.exe');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The argv that runs `run` with no console window on Windows. A detached node has
|
|
370
|
+
// NO console, so every git and claude it starts would open one of its own;
|
|
371
|
+
// conhost's headless mode gives the whole tree one invisible console instead.
|
|
372
|
+
function windowsRunArgv({ node = process.execPath, script = __filename, conhost = conhostPath(), hasConhost = fs.existsSync(conhost) } = {}) {
|
|
373
|
+
return hasConhost ? [conhost, '--headless', node, script, 'run'] : [node, script, 'run'];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// One command line, each part quoted, for the Run entry.
|
|
377
|
+
function windowsRunCommand(argv = windowsRunArgv()) {
|
|
378
|
+
return argv.map((a) => (/^[A-Za-z0-9_\-./\\:]+$/.test(a) ? a : `"${a}"`)).join(' ');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// windowsStartSpawn — how THIS program launches that argv so it outlives it.
|
|
382
|
+
//
|
|
383
|
+
// FOUND BY RUNNING IT. Spawning conhost straight from node starts nothing: node
|
|
384
|
+
// always hands a child its standard handles, and a headless conhost given handles
|
|
385
|
+
// treats them as its terminal, finds they are not one, and exits 0 having run
|
|
386
|
+
// nothing — no error anywhere. `start` creates the process the way the sign-in
|
|
387
|
+
// entry does, with no handles to inherit, and that runs. Without conhost the plain
|
|
388
|
+
// node argv is started the same way.
|
|
389
|
+
function windowsStartSpawn(argv = windowsRunArgv()) {
|
|
390
|
+
return {
|
|
391
|
+
cmd: 'cmd.exe',
|
|
392
|
+
args: ['/d', '/s', '/c', `"start "" ${argv.map((a) => `"${a}"`).join(' ')}"`],
|
|
393
|
+
options: { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// A plist is XML, and the five characters XML needs escaped are the ones HTML does.
|
|
398
|
+
const xml = escapeHtml;
|
|
399
|
+
|
|
400
|
+
// launchdPlist — the Mac start entry. A LaunchAgent (the person's own session),
|
|
401
|
+
// not a LaunchDaemon: the runner needs their Claude sign-in and git credential,
|
|
402
|
+
// both of which live in the login keychain. PATH is captured at install time
|
|
403
|
+
// because launchd starts agents with a bare one and node, git and claude are
|
|
404
|
+
// wherever this person's shell found them.
|
|
405
|
+
function launchdPlist({ node = process.execPath, script = __filename, cwd = REPO_ROOT, paths = hostPaths(), envPath = process.env.PATH || '', home = os.homedir() } = {}) {
|
|
406
|
+
const env = { PATH: envPath, HOME: home, AUTOBONGOS_SUPERVISOR: 'launchd' };
|
|
407
|
+
if (process.env.AUTOBONGOS_HOME) env.AUTOBONGOS_HOME = paths.root;
|
|
408
|
+
const dict = Object.entries(env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join('\n');
|
|
409
|
+
return [
|
|
410
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
411
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
412
|
+
'<plist version="1.0">',
|
|
413
|
+
' <dict>',
|
|
414
|
+
` <key>Label</key><string>${LABEL}</string>`,
|
|
415
|
+
' <key>ProgramArguments</key>',
|
|
416
|
+
' <array>',
|
|
417
|
+
...[node, script, 'run'].map((a) => ` <string>${xml(a)}</string>`),
|
|
418
|
+
' </array>',
|
|
419
|
+
` <key>WorkingDirectory</key><string>${xml(cwd)}</string>`,
|
|
420
|
+
' <key>EnvironmentVariables</key>',
|
|
421
|
+
' <dict>',
|
|
422
|
+
dict,
|
|
423
|
+
' </dict>',
|
|
424
|
+
' <key>RunAtLoad</key><true/>',
|
|
425
|
+
// Restarted whenever it exits: the outer loop only returns to hand over to a
|
|
426
|
+
// newer copy of itself, or because something killed it.
|
|
427
|
+
' <key>KeepAlive</key><true/>',
|
|
428
|
+
` <key>StandardOutPath</key><string>${xml(path.join(paths.logDir, 'launchd.log'))}</string>`,
|
|
429
|
+
` <key>StandardErrorPath</key><string>${xml(path.join(paths.logDir, 'launchd.log'))}</string>`,
|
|
430
|
+
' </dict>',
|
|
431
|
+
'</plist>',
|
|
432
|
+
'',
|
|
433
|
+
].join('\n');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function launchctl(args) { return spawnSync('launchctl', args, { encoding: 'utf8' }); }
|
|
437
|
+
const guiDomain = () => `gui/${typeof process.getuid === 'function' ? process.getuid() : 0}`;
|
|
438
|
+
|
|
439
|
+
function installedState(platform = process.platform, paths = hostPaths(), run = spawnSync) {
|
|
440
|
+
if (platform === 'win32') {
|
|
441
|
+
const r = run('reg', ['query', RUN_KEY, '/v', RUN_VALUE], { encoding: 'utf8', windowsHide: true });
|
|
442
|
+
return r.status === 0;
|
|
443
|
+
}
|
|
444
|
+
if (platform === 'darwin') return fs.existsSync(paths.plist);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// install — register the start-at-sign-in entry. No administrator on either
|
|
449
|
+
// platform: both entries belong to this person's own account.
|
|
450
|
+
function install({ platform = process.platform, paths = hostPaths(), run = spawnSync, repoRoot = REPO_ROOT } = {}) {
|
|
451
|
+
assertRunnerCopy(repoRoot);
|
|
452
|
+
fs.mkdirSync(paths.logDir, { recursive: true });
|
|
453
|
+
if (platform === 'win32') {
|
|
454
|
+
const r = run('reg', ['add', RUN_KEY, '/v', RUN_VALUE, '/t', 'REG_SZ', '/d', windowsRunCommand(), '/f'], { encoding: 'utf8', windowsHide: true });
|
|
455
|
+
if (r.status !== 0 || !installedState(platform, paths, run)) {
|
|
456
|
+
throw new Stop('Windows would not save the start-at-sign-in entry.', 'Run the setup command again from an ordinary (not Administrator) PowerShell window signed in as you.');
|
|
457
|
+
}
|
|
458
|
+
return { kind: 'windows-run-entry' };
|
|
459
|
+
}
|
|
460
|
+
if (platform === 'darwin') {
|
|
461
|
+
fs.mkdirSync(path.dirname(paths.plist), { recursive: true });
|
|
462
|
+
fs.writeFileSync(paths.plist, launchdPlist({ paths }));
|
|
463
|
+
return { kind: 'launchd-agent' };
|
|
464
|
+
}
|
|
465
|
+
return { kind: 'none' };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// removeStartEntry — take the start-at-sign-in entry away, and LOOK afterwards.
|
|
469
|
+
// Returns true only when the entry is really gone, so uninstall can say "could
|
|
470
|
+
// not remove it" instead of promising a device that will not start again.
|
|
471
|
+
function removeStartEntry({ platform = process.platform, paths = hostPaths(), run = spawnSync } = {}) {
|
|
472
|
+
if (platform === 'win32') run('reg', ['delete', RUN_KEY, '/v', RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
|
|
473
|
+
if (platform === 'darwin') { try { fs.rmSync(paths.plist, { force: true }); } catch (_) { /* gone */ } }
|
|
474
|
+
return !installedState(platform, paths, run);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// startDetached — launch the outer loop so it outlives this command.
|
|
478
|
+
function startDetached({ platform = process.platform, paths = hostPaths(), takeover = null } = {}) {
|
|
479
|
+
const env = { ...process.env, ...(takeover ? { AUTOBONGOS_TAKEOVER: String(takeover) } : {}) };
|
|
480
|
+
if (platform === 'darwin' && fs.existsSync(paths.plist) && !takeover) {
|
|
481
|
+
// Loaded into the person's session, so launchd restarts it if it dies.
|
|
482
|
+
launchctl(['bootout', `${guiDomain()}/${LABEL}`]);
|
|
483
|
+
const r = launchctl(['bootstrap', guiDomain(), paths.plist]);
|
|
484
|
+
if (r.status === 0) return { via: 'launchd' };
|
|
485
|
+
}
|
|
486
|
+
if (platform === 'win32') {
|
|
487
|
+
const { cmd, args, options } = windowsStartSpawn();
|
|
488
|
+
spawn(cmd, args, { cwd: REPO_ROOT, env, ...options }).unref();
|
|
489
|
+
return { via: 'start' };
|
|
490
|
+
}
|
|
491
|
+
const child = spawn(process.execPath, [__filename, 'run'], { cwd: REPO_ROOT, env, detached: true, stdio: 'ignore' });
|
|
492
|
+
child.unref();
|
|
493
|
+
return { via: 'spawn', pid: child.pid };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── stop, and say what actually happened ───────────────────────────────────
|
|
497
|
+
|
|
498
|
+
function supervisorHeartbeatFile(paths = hostPaths(), configDirName = 'cloudbongos') {
|
|
499
|
+
return path.join(paths.configRoot, configDirName, 'autobongos-heartbeat.json');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// stopRunner — ask everything to exit, WATCH it exit, and report one of:
|
|
503
|
+
// stopped the processes were alive and were watched to exit
|
|
504
|
+
// still_running at least one outlived the wait (the caller exits non-zero)
|
|
505
|
+
// not_running nothing was running to begin with
|
|
506
|
+
// It never says "stopped" about a process it has not seen gone (task 1004375).
|
|
507
|
+
async function stopRunner({
|
|
508
|
+
platform = process.platform, paths = hostPaths(), configDirName = 'cloudbongos',
|
|
509
|
+
alive = pidAlive, kill = killTree, sleep = sharedSleep,
|
|
510
|
+
waitMs = STOP_WAIT_MS, unload = null,
|
|
511
|
+
} = {}) {
|
|
512
|
+
const lock = readJson(paths.lock);
|
|
513
|
+
const hb = readJson(supervisorHeartbeatFile(paths, configDirName));
|
|
514
|
+
const pids = [...new Set([lock && lock.pid, hb && hb.pid].filter((p) => Number.isInteger(p) && p > 0 && p !== process.pid))];
|
|
515
|
+
// Unload first on a Mac, or launchd restarts the loop the moment it dies.
|
|
516
|
+
if (unload) unload();
|
|
517
|
+
else if (platform === 'darwin') launchctl(['bootout', `${guiDomain()}/${LABEL}`]);
|
|
518
|
+
const running = pids.filter((p) => alive(p));
|
|
519
|
+
if (!running.length) return { outcome: 'not_running', pids };
|
|
520
|
+
for (const p of running) kill(p, { platform, force: false });
|
|
521
|
+
const deadline = Date.now() + waitMs;
|
|
522
|
+
let forced = false;
|
|
523
|
+
while (running.some((p) => alive(p)) && Date.now() < deadline) {
|
|
524
|
+
await sleep(250);
|
|
525
|
+
if (!forced && Date.now() > deadline - waitMs / 2) {
|
|
526
|
+
forced = true;
|
|
527
|
+
for (const p of running) if (alive(p)) kill(p, { platform, force: true });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const left = running.filter((p) => alive(p));
|
|
531
|
+
if (!left.length) { try { fs.rmSync(paths.lock, { force: true }); } catch (_) { /* gone */ } }
|
|
532
|
+
return { outcome: left.length ? 'still_running' : 'stopped', pids: running, left };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function stopReport(result, { platform = process.platform, installed = false } = {}) {
|
|
536
|
+
if (result.outcome === 'stopped') {
|
|
537
|
+
return [
|
|
538
|
+
`Stopped. The runner's processes (${result.pids.join(', ')}) have exited — watched, not assumed.`,
|
|
539
|
+
installed
|
|
540
|
+
? 'It starts again the next time you sign in to this device. To keep it from working, use Stop on its card in Settings, or run uninstall.'
|
|
541
|
+
: 'It will not start again by itself.',
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
if (result.outcome === 'still_running') {
|
|
545
|
+
const how = platform === 'win32' ? `taskkill /F /T /PID ${result.left[0]}` : `kill -9 ${result.left.join(' ')}`;
|
|
546
|
+
return [
|
|
547
|
+
`NOT STOPPED: process ${result.left.join(', ')} is still running after ${Math.round(STOP_WAIT_MS / 1000)} seconds.`,
|
|
548
|
+
`End it yourself with: ${how} — or restart this device.`,
|
|
549
|
+
];
|
|
550
|
+
}
|
|
551
|
+
return ['The runner was not running on this device.'];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── status ─────────────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
// status — what this device can say about its own runner, from its own files:
|
|
557
|
+
// is the loop running (a fresh lock held by a live process), does it start at
|
|
558
|
+
// sign-in, and which login does it hold. Never the token, only whose it is.
|
|
559
|
+
function status({ platform = process.platform, paths = hostPaths(), configDirName = 'cloudbongos', now = Date.now(), run = spawnSync, alive = pidAlive } = {}) {
|
|
560
|
+
const lock = readJson(paths.lock);
|
|
561
|
+
const holder = lockHolder(lock, { now, self: -1, alive });
|
|
562
|
+
const session = readJson(path.join(paths.configRoot, configDirName, 'bongos-session.json'))
|
|
563
|
+
|| readJson(path.join(paths.configRoot, configDirName, 'gds-session.json'));
|
|
564
|
+
return {
|
|
565
|
+
device: os.hostname(),
|
|
566
|
+
runner_copy: isRunnerCopy(),
|
|
567
|
+
starts_at_sign_in: installedState(platform, paths, run),
|
|
568
|
+
running: !!holder,
|
|
569
|
+
pid: holder || null,
|
|
570
|
+
signed_in_as: session && session.builder ? session.builder.github_login || null : null,
|
|
571
|
+
project: session ? session.api_base || null : null,
|
|
572
|
+
log: paths.log,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── setup ──────────────────────────────────────────────────────────────────
|
|
577
|
+
|
|
578
|
+
function arg(argv, name) {
|
|
579
|
+
const i = argv.indexOf(name);
|
|
580
|
+
if (i !== -1 && argv[i + 1] !== undefined) return argv[i + 1];
|
|
581
|
+
const eq = argv.find((a) => a.startsWith(`${name}=`));
|
|
582
|
+
return eq ? eq.slice(name.length + 1) : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// The project address the code is traded at. HTTPS only: the one-time code goes
|
|
586
|
+
// out and the runner's login comes back on this request, and neither may cross a
|
|
587
|
+
// network in the clear. Plain http is accepted for this machine alone (localhost,
|
|
588
|
+
// 127.0.0.1, a .localhost or .test name), which is a developer's own instance.
|
|
589
|
+
function cleanBase(raw) {
|
|
590
|
+
const s = String(raw || '').trim().replace(/\/+$/, '');
|
|
591
|
+
if (/^https:\/\/[^\s/]+$/i.test(s)) return s;
|
|
592
|
+
return /^http:\/\/(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9.-]+\.(localhost|test))(:\d+)?$/i.test(s) ? s : null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// exchangeCode — trade the one-time code for the runner's own login.
|
|
596
|
+
async function exchangeCode({ apiBase, code, fetchImpl = fetch }) {
|
|
597
|
+
let res;
|
|
598
|
+
try {
|
|
599
|
+
res = await fetchImpl(`${apiBase}/api/bongos/auth/runner-code/exchange`, {
|
|
600
|
+
method: 'POST',
|
|
601
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': `autobongos-host/${os.hostname().replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64)}` },
|
|
602
|
+
body: JSON.stringify({ code }),
|
|
603
|
+
});
|
|
604
|
+
} catch (_) {
|
|
605
|
+
throw new Stop(`This device could not reach ${apiBase}.`, 'Check it is online, then run the setup command again.');
|
|
606
|
+
}
|
|
607
|
+
if (res.status === 401) {
|
|
608
|
+
throw new Stop('That setup code is wrong, already used, or older than 15 minutes.', 'Go back to Settings → Your Autobongos runner, press Add a device again, and paste the new command.');
|
|
609
|
+
}
|
|
610
|
+
if (!res.ok) {
|
|
611
|
+
throw new Stop(`The project would not accept the setup code (it answered ${res.status}).`, 'Wait a minute and run the setup command again. If it keeps happening, tell the project owner.');
|
|
612
|
+
}
|
|
613
|
+
const data = await res.json();
|
|
614
|
+
if (!data || !data.token) throw new Stop('The project answered without a login for this device.', 'Run the setup command again with a new code from Settings.');
|
|
615
|
+
return data;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// sessionStillGood — does the project still accept the login this device has?
|
|
619
|
+
// Asked with that login itself. Only an explicit yes counts: a refusal, an error
|
|
620
|
+
// and an unreachable project all read as "no", and the caller then needs a code.
|
|
621
|
+
async function sessionStillGood(session, fetchImpl = fetch) {
|
|
622
|
+
if (!session || !session.token || !session.api_base) return false;
|
|
623
|
+
try {
|
|
624
|
+
const res = await fetchImpl(`${session.api_base}/api/bongos/me`, { headers: { Authorization: `Bearer ${session.token}` } });
|
|
625
|
+
return res.ok === true;
|
|
626
|
+
} catch (_) { return false; }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// signIn — which login this device ends setup with: the one it already has, or a
|
|
630
|
+
// new one bought with the code. Returns { session, fresh }.
|
|
631
|
+
//
|
|
632
|
+
// THE SETUP COMMAND IS RUN MORE THAN ONCE, and its code only works once. So a
|
|
633
|
+
// device that already holds a login the project still accepts KEEPS it and never
|
|
634
|
+
// spends the code: the second run of the same pasted line then changes nothing,
|
|
635
|
+
// instead of stopping on "that code is already used" (grade finding, round 1).
|
|
636
|
+
// The code is only traded when there is no login, the login is for another
|
|
637
|
+
// project, or the project has since refused it (the device was removed).
|
|
638
|
+
async function signIn({ code, apiBase, session, fetchImpl = fetch, hostName = os.hostname() } = {}) {
|
|
639
|
+
const base = apiBase == null ? null : cleanBase(apiBase);
|
|
640
|
+
if (code && !base) {
|
|
641
|
+
throw new Stop('The project address in the setup command is missing or is not a secure (https) address.', 'Copy the whole command again from Settings → Your Autobongos runner.');
|
|
642
|
+
}
|
|
643
|
+
const sameProject = session && session.token && (!base || String(session.api_base || '').replace(/\/+$/, '') === base);
|
|
644
|
+
if (sameProject && await sessionStillGood(session, fetchImpl)) return { session, fresh: false };
|
|
645
|
+
if (!code) {
|
|
646
|
+
throw new Stop(
|
|
647
|
+
session && session.token ? 'The project no longer accepts this device’s login (it was removed, or signed out).' : 'This device has no runner login yet.',
|
|
648
|
+
'Open Settings → Your Autobongos runner in the hall, press Add a device, and run the command it shows.',
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const out = await exchangeCode({ apiBase: base, code, fetchImpl });
|
|
652
|
+
return {
|
|
653
|
+
fresh: true,
|
|
654
|
+
session: { token: out.token, api_base: out.api_base || base, builder: { github_login: out.builder_login || null }, runner: { label: out.label || hostName, session_id: out.session_id || null } },
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Is Claude Code here and signed in? `claude auth status` answers in JSON on
|
|
659
|
+
// current versions; an older one that does not know the command falls back to
|
|
660
|
+
// the sign-in Claude Code records on disk.
|
|
661
|
+
function claudeState({ resolveBin, claudeAccountLabel, run = spawnSync } = {}) {
|
|
662
|
+
const { bin, shell } = resolveBin();
|
|
663
|
+
const v = run(bin, ['--version'], { encoding: 'utf8', shell, windowsHide: true, timeout: 30000 });
|
|
664
|
+
if (v.error || v.status !== 0) return { installed: false, signedIn: false, bin, shell };
|
|
665
|
+
const a = run(bin, ['auth', 'status'], { encoding: 'utf8', shell, windowsHide: true, timeout: 30000 });
|
|
666
|
+
let signedIn = null;
|
|
667
|
+
try { const j = JSON.parse(String(a.stdout || '')); if (typeof j.loggedIn === 'boolean') signedIn = j.loggedIn; } catch (_) { signedIn = null; }
|
|
668
|
+
if (signedIn === null) signedIn = !!claudeAccountLabel();
|
|
669
|
+
return { installed: true, signedIn, bin, shell };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function setup(argv, { paths = hostPaths(), say = console.log } = {}) {
|
|
673
|
+
if (argv.includes('--mark-runner-copy')) {
|
|
674
|
+
// Only the bootstrap passes this, right after it clones: it is the statement
|
|
675
|
+
// "this copy was made for the runner and nothing else lives in it".
|
|
676
|
+
if (!fs.statSync(path.join(REPO_ROOT, '.git')).isDirectory()) throw new Stop('This folder is a linked working tree, not a copy of its own.', 'Use the setup command from Settings; it makes the runner its own copy.');
|
|
677
|
+
fs.writeFileSync(runnerCopyMarkPath(), `marked ${new Date().toISOString()}\n`);
|
|
678
|
+
}
|
|
679
|
+
assertRunnerCopy();
|
|
680
|
+
|
|
681
|
+
const ic = require('../../src/instance-config');
|
|
682
|
+
const dirName = ic.configDirName();
|
|
683
|
+
process.env.BONGOS_CONFIG_ROOT = paths.configRoot; // before anything reads a session
|
|
684
|
+
fs.mkdirSync(paths.configRoot, { recursive: true });
|
|
685
|
+
|
|
686
|
+
say('1/5 Installing the project’s tools (a few minutes the first time)…');
|
|
687
|
+
try { ensureDeps(paths, () => {}, { stdio: 'inherit' }); } catch (_) {
|
|
688
|
+
throw new Stop('The project’s tools would not install.', 'Check this device is online and has a few gigabytes free, then run the setup command again.');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
say('2/5 Signing this device in to the project…');
|
|
692
|
+
const cli = require('./cli-lib');
|
|
693
|
+
const signed = await signIn({
|
|
694
|
+
code: arg(argv, '--code'), apiBase: arg(argv, '--api-base'),
|
|
695
|
+
session: await cli.loadSession().catch(() => null),
|
|
696
|
+
});
|
|
697
|
+
const session = signed.session;
|
|
698
|
+
if (signed.fresh) await cli.saveSession(session);
|
|
699
|
+
else say(' Already signed in on this device; keeping that login.');
|
|
700
|
+
|
|
701
|
+
say('3/5 Checking Claude Code…');
|
|
702
|
+
const runner = require('./autobongos-run.js');
|
|
703
|
+
let claude = claudeState({ resolveBin: runner.resolveWorkerBin, claudeAccountLabel: runner.claudeAccountLabel });
|
|
704
|
+
if (!claude.installed) throw new Stop('Claude Code is not installed on this device.', 'Install it from https://claude.com/claude-code, open a new terminal window, and run the setup command again.');
|
|
705
|
+
if (!claude.signedIn && process.stdin.isTTY) {
|
|
706
|
+
say(' Claude Code needs you to sign in. A sign-in page will open; come back here when it is done.');
|
|
707
|
+
spawnSync(claude.bin, ['auth', 'login'], { stdio: 'inherit', shell: claude.shell });
|
|
708
|
+
claude = claudeState({ resolveBin: runner.resolveWorkerBin, claudeAccountLabel: runner.claudeAccountLabel });
|
|
709
|
+
}
|
|
710
|
+
if (!claude.signedIn) throw new Stop('Claude Code is not signed in on this device.', 'Run claude auth login in a terminal, finish the sign-in, then run the setup command again.');
|
|
711
|
+
|
|
712
|
+
say('4/5 Checking this device can fetch the project’s code…');
|
|
713
|
+
if (git(['ls-remote', 'origin', 'refs/heads/main'], { timeout: 60000 }).status !== 0) {
|
|
714
|
+
throw new Stop('This device could not fetch the project’s code from GitHub.', 'Sign in to GitHub on this device (run git fetch in the runner’s folder and follow the sign-in), then run the setup command again.');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
say('5/5 Starting the runner…');
|
|
718
|
+
if (legacyBootTaskInstalled()) {
|
|
719
|
+
throw new Stop(
|
|
720
|
+
'this PC already runs Autobongos as a scheduled task (the always-on install).',
|
|
721
|
+
'One runner per device. Keep that one, or remove it first from an Administrator PowerShell: powershell -ExecutionPolicy Bypass -File scripts\\bongos\\autobongos-service.ps1 uninstall — then run the setup command again.',
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const entry = install({ paths });
|
|
725
|
+
const lock = readJson(paths.lock);
|
|
726
|
+
if (!lockHolder(lock) && !argv.includes('--no-start')) startDetached({ paths });
|
|
727
|
+
|
|
728
|
+
let seen = argv.includes('--no-start');
|
|
729
|
+
if (!seen) seen = await waitForCheckIn({ session, dirName });
|
|
730
|
+
say('');
|
|
731
|
+
if (seen) say(`Done. This device is connected as an Autobongos runner for ${session.builder && session.builder.github_login ? session.builder.github_login : 'your account'}.`);
|
|
732
|
+
else say(`The runner is started but has not checked in yet. Give it a minute; if its card does not appear, look at ${paths.log}`);
|
|
733
|
+
if (entry.kind === 'none') say('It runs until this device restarts. Run this again after a restart (this system has no start-at-sign-in entry yet).');
|
|
734
|
+
else say('It starts by itself whenever you sign in to this device.');
|
|
735
|
+
say('Keep this device on and plugged in while it runs. If it sleeps, the runner pauses and picks up when it wakes.');
|
|
736
|
+
say(`Next: open ${session.api_base}/builders/settings#autobongos — choose its goals and press Run.`);
|
|
737
|
+
return 0;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Wait for the supervisor's first check-in to reach the server, asked with the
|
|
741
|
+
// runner's own login. Ninety seconds: a boot is a fetch, a reset and one request.
|
|
742
|
+
async function waitForCheckIn({ session, fetchImpl = fetch, timeoutMs = 90000, sleep = sharedSleep }) {
|
|
743
|
+
const host = os.hostname().toLowerCase();
|
|
744
|
+
const until = Date.now() + timeoutMs;
|
|
745
|
+
while (Date.now() < until) {
|
|
746
|
+
try {
|
|
747
|
+
const res = await fetchImpl(`${session.api_base}/api/bongos/autonomy/runners`, { headers: { Authorization: `Bearer ${session.token}` } });
|
|
748
|
+
if (res.ok) {
|
|
749
|
+
const rows = ((await res.json()) || {}).runners || [];
|
|
750
|
+
const mine = rows.find((r) => String(r.host || '').toLowerCase() === host);
|
|
751
|
+
if (mine && Date.now() - Date.parse(mine.last_seen_at || '') < 5 * 60 * 1000) return true;
|
|
752
|
+
}
|
|
753
|
+
} catch (_) { /* not yet */ }
|
|
754
|
+
await sleep(3000);
|
|
755
|
+
}
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// The digest CLI and its arguments, for `digest`: the same file a builder would
|
|
760
|
+
// run by hand, with whatever they passed handed through.
|
|
761
|
+
function digestArgv(rest = []) {
|
|
762
|
+
return [path.join(REPO_ROOT, 'scripts', 'bongos', 'autobongos-digest.js'), ...rest];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ── the commands ───────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
async function runCommand({ paths = hostPaths() } = {}) {
|
|
768
|
+
assertRunnerCopy();
|
|
769
|
+
const takeover = Number(process.env.AUTOBONGOS_TAKEOVER) || null;
|
|
770
|
+
if (takeover) {
|
|
771
|
+
// Handed over by an older copy of this loop: wait for it to be gone.
|
|
772
|
+
for (let i = 0; i < 40 && pidAlive(takeover); i++) await sharedSleep(250);
|
|
773
|
+
}
|
|
774
|
+
if (lockHolder(readJson(paths.lock), { takeover })) return 0; // already running: one loop per device
|
|
775
|
+
const ic = require('../../src/instance-config');
|
|
776
|
+
const dirName = ic.configDirName();
|
|
777
|
+
const log = makeLogger(paths, { echo: !!process.stdout.isTTY });
|
|
778
|
+
const env = supervisorEnv(process.env, paths, dirName);
|
|
779
|
+
const state = { child: null, stopping: false };
|
|
780
|
+
const keepAwake = makeKeepAwake();
|
|
781
|
+
const selfSha = fileSha(__filename);
|
|
782
|
+
writeLock(paths);
|
|
783
|
+
const beat = setInterval(() => writeLock(paths), LOCK_BEAT_MS);
|
|
784
|
+
const shutdown = () => {
|
|
785
|
+
state.stopping = true;
|
|
786
|
+
keepAwake.set(false);
|
|
787
|
+
if (state.child) killTree(state.child.pid, { force: false });
|
|
788
|
+
};
|
|
789
|
+
process.on('SIGTERM', shutdown);
|
|
790
|
+
process.on('SIGINT', shutdown);
|
|
791
|
+
|
|
792
|
+
rotateLog(paths);
|
|
793
|
+
const out = await runLoop({}, {
|
|
794
|
+
log, shouldStop: () => state.stopping,
|
|
795
|
+
refresh: () => refreshCheckout(log),
|
|
796
|
+
ensureDeps: () => ensureDeps(paths, log),
|
|
797
|
+
spawnSupervisor: () => spawnSupervisorReal({ paths, env, log, keepAwake, state }),
|
|
798
|
+
selfChanged: () => fileSha(__filename) !== selfSha,
|
|
799
|
+
sleep: sharedSleep,
|
|
800
|
+
});
|
|
801
|
+
clearInterval(beat);
|
|
802
|
+
keepAwake.set(false);
|
|
803
|
+
if (out === 'relaunch_self') {
|
|
804
|
+
log('==== this program changed; handing over to the new copy ====');
|
|
805
|
+
// Under launchd, exiting IS the hand-over: KeepAlive starts the new file.
|
|
806
|
+
if (process.env.AUTOBONGOS_SUPERVISOR !== 'launchd') startDetached({ paths, takeover: process.pid });
|
|
807
|
+
}
|
|
808
|
+
try { if ((readJson(paths.lock) || {}).pid === process.pid) fs.rmSync(paths.lock, { force: true }); } catch (_) { /* gone */ }
|
|
809
|
+
return 0;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function main(argv) {
|
|
813
|
+
const cmd = argv[0] || 'status';
|
|
814
|
+
const paths = hostPaths();
|
|
815
|
+
if (cmd === 'setup') return setup(argv.slice(1), { paths });
|
|
816
|
+
if (cmd === 'run') return runCommand({ paths });
|
|
817
|
+
const dirName = (() => { try { return require('../../src/instance-config').configDirName(); } catch (_) { return 'cloudbongos'; } })();
|
|
818
|
+
if (cmd === 'status') {
|
|
819
|
+
const s = status({ paths, configDirName: dirName });
|
|
820
|
+
if (argv.includes('--json')) { console.log(JSON.stringify(s, null, 2)); return 0; }
|
|
821
|
+
console.log(`device : ${s.device}`);
|
|
822
|
+
console.log(`signed in as : ${s.signed_in_as || '(no runner login — run the setup command from Settings)'}`);
|
|
823
|
+
console.log(`running : ${s.running ? `yes (process ${s.pid})` : 'no'}`);
|
|
824
|
+
console.log(`starts at sign-in : ${s.starts_at_sign_in ? 'yes' : 'no'}`);
|
|
825
|
+
console.log(`log : ${s.log}`);
|
|
826
|
+
console.log('Whether it is WORKING is on its card in Settings: the card reads its check-ins.');
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
if (cmd === 'digest') {
|
|
830
|
+
// The runner's run log lives under ITS config root, so the digest has to be
|
|
831
|
+
// run with that root or it reads the builder's own (empty) one and reports a
|
|
832
|
+
// missing log (task 1005097).
|
|
833
|
+
const r = spawnSync(process.execPath, digestArgv(argv.slice(1)), { stdio: 'inherit', env: supervisorEnv(process.env, paths, dirName) });
|
|
834
|
+
return r.status === null ? 1 : r.status;
|
|
835
|
+
}
|
|
836
|
+
if (cmd === 'install') {
|
|
837
|
+
const e = install({ paths });
|
|
838
|
+
console.log(e.kind === 'none' ? 'This system has no start-at-sign-in entry yet; use start after each restart.' : 'The runner now starts whenever you sign in to this device.');
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
if (cmd === 'start') {
|
|
842
|
+
assertRunnerCopy();
|
|
843
|
+
if (lockHolder(readJson(paths.lock))) { console.log('The runner is already running on this device.'); return 0; }
|
|
844
|
+
startDetached({ paths });
|
|
845
|
+
console.log('Started. Its card in Settings shows it checking in within a minute.');
|
|
846
|
+
return 0;
|
|
847
|
+
}
|
|
848
|
+
if (cmd === 'stop' || cmd === 'uninstall') {
|
|
849
|
+
const result = await stopRunner({ paths, configDirName: dirName });
|
|
850
|
+
let installed = installedState(process.platform, paths);
|
|
851
|
+
if (cmd === 'uninstall') {
|
|
852
|
+
const gone = removeStartEntry({ paths });
|
|
853
|
+
installed = !gone;
|
|
854
|
+
console.log(gone ? 'Removed the start-at-sign-in entry.' : 'COULD NOT remove the start-at-sign-in entry — it will start again at sign-in.');
|
|
855
|
+
}
|
|
856
|
+
for (const line of stopReport(result, { installed })) console.log(line);
|
|
857
|
+
return result.outcome === 'still_running' || (cmd === 'uninstall' && installed) ? 1 : 0;
|
|
858
|
+
}
|
|
859
|
+
console.error('usage: autobongos-host.js setup --code <code> --api-base <url> | status [--json] | start | stop | install | uninstall | digest [--since 24h] | run');
|
|
860
|
+
return 2;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (require.main === module) {
|
|
864
|
+
main(process.argv.slice(2)).then((c) => { process.exitCode = c; }).catch((e) => {
|
|
865
|
+
if (e && e.name === 'Stop') {
|
|
866
|
+
console.error(`\nAutobongos stopped here: ${e.message}`);
|
|
867
|
+
if (e.fix) console.error(`What to do: ${e.fix}`);
|
|
868
|
+
} else {
|
|
869
|
+
console.error(`\nAutobongos stopped on something unexpected: ${e && e.message ? e.message : e}`);
|
|
870
|
+
console.error('What to do: run the same command again. If it stops here twice, send this line to the project owner.');
|
|
871
|
+
}
|
|
872
|
+
process.exitCode = 1;
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
module.exports = {
|
|
877
|
+
LABEL, UPGRADE_EXIT_CODE, HEALTHY_RUN_MS, LOCK_STALE_MS,
|
|
878
|
+
hostPaths, supervisorEnv, isRunnerCopy, assertRunnerCopy, runnerCopyMarkPath,
|
|
879
|
+
pidAlive, lockHolder, wantsAwake, keepAwakeCommand, makeKeepAwake, runLoop,
|
|
880
|
+
windowsRunArgv, windowsRunCommand, windowsStartSpawn, legacyBootTaskInstalled, launchdPlist,
|
|
881
|
+
stopRunner, stopReport, exchangeCode, claudeState, waitForCheckIn,
|
|
882
|
+
signIn, cleanBase, install, removeStartEntry, installedState, status, digestArgv,
|
|
883
|
+
};
|