@oddessentials/agent-guild 0.33.1 → 0.34.0

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.
@@ -1,37 +1,102 @@
1
- // Read-only snapshot of the manager environment. Discovery runs outside the
2
- // manager: even a blocked filesystem lookup cannot hold up terminals or HTTP.
1
+ // Read-only environment snapshots. Discovery runs outside the manager: even a
2
+ // blocked filesystem lookup cannot hold up terminals or HTTP. Each scope
3
+ // reports its own fact and never fills a gap from another scope.
4
+ import os from 'node:os';
5
+ import { createHash } from 'node:crypto';
3
6
  import { EventEmitter } from 'node:events';
4
7
  import { fork } from 'node:child_process';
5
8
  import { killWindowsTree } from './command-resolver.mjs';
6
9
  import { RUNTIMES } from './environment-probe.mjs';
10
+ import { resolveProjectCwd } from './environment-pins.mjs';
7
11
 
8
12
  export const ENVIRONMENT_TIMEOUT_MS = 10000;
13
+ export const LAUNCH_DETAIL = 'Launch PATH, profiles not applied. The selected shell is not consulted.';
14
+ export const SESSION_DETAIL = 'Spawn PATH, before the shell startup files.';
15
+ export const MULTIPLEXER_DETAIL = 'This session is tmux or herdr. Its environment is not the spawn record.';
16
+ // Toolchain shims (rustup, pyenv, asdf, mise, volta, nodenv) read their home
17
+ // and selected version from these, so a session probe without them reports
18
+ // a runtime the session can actually run as unavailable.
19
+ const PATH_ENV = [
20
+ 'PATH', 'PATHEXT', 'SYSTEMROOT', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
21
+ 'NVM_DIR', 'VOLTA_HOME', 'NODENV_ROOT', 'NODENV_VERSION', 'PYENV_ROOT', 'PYENV_VERSION',
22
+ 'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'MISE_CONFIG_DIR', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'CARGO_HOME',
23
+ 'GOROOT', 'GOTOOLCHAIN', 'DOTNET_ROOT', 'R_HOME',
24
+ ];
25
+
26
+ function hostName() {
27
+ try { return os.hostname(); } catch { return null; }
28
+ }
29
+
30
+ function cloneRow(row) {
31
+ return {
32
+ ...row,
33
+ ...(row.alternatives ? { alternatives: row.alternatives.map((item) => ({ ...item })) } : {}),
34
+ ...(row.runtimes ? { runtimes: row.runtimes.map((item) => ({ ...item })) } : {}),
35
+ };
36
+ }
37
+
38
+ function pendingRuntimes() {
39
+ return RUNTIMES.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null }));
40
+ }
41
+
42
+ export function probePathEnv(env) {
43
+ const out = {};
44
+ for (const [key, value] of Object.entries(env || {})) {
45
+ if (typeof value === 'string' && PATH_ENV.includes(key.toUpperCase())) out[key] = value;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function pathIdentity(env, cwd) {
51
+ const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH');
52
+ return createHash('sha256').update(`${key ? env[key] : ''}\0${cwd || ''}`).digest('hex').slice(0, 16);
53
+ }
9
54
 
10
55
  export class Environment extends EventEmitter {
11
- constructor({ env, forkWorker = fork, timeoutMs = ENVIRONMENT_TIMEOUT_MS } = {}) {
56
+ constructor({ env, forkWorker = fork, timeoutMs = ENVIRONMENT_TIMEOUT_MS, hostname = hostName(), sessionLookup = () => null } = {}) {
12
57
  super();
13
58
  this.env = env ?? process.env;
14
59
  this.forkWorker = forkWorker;
15
60
  this.timeoutMs = timeoutMs;
61
+ this.host = hostname;
62
+ this.sessionLookup = sessionLookup;
16
63
  this.closed = false;
17
64
  this.run = null;
18
- this.value = {
19
- scope: 'manager', platform: process.platform, revision: 0, refreshing: false, checkedAt: null, error: null,
65
+ this.launch = null;
66
+ this.projects = new Map();
67
+ this.sessions = new Map();
68
+ this.value = this.blankManager();
69
+ }
70
+
71
+ setSessionLookup(sessionLookup) { this.sessionLookup = sessionLookup; }
72
+
73
+ snapshot() { return this.value; }
74
+
75
+ blankManager() {
76
+ return {
77
+ scope: 'manager', host: this.host, platform: process.platform, revision: 0, refreshing: false, checkedAt: null, error: null,
20
78
  managerNode: { version: process.versions.node, path: process.execPath },
21
- runtimes: RUNTIMES.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null })),
79
+ runtimes: pendingRuntimes(),
22
80
  tools: [],
23
81
  };
24
82
  }
25
83
 
26
- snapshot() { return this.value; }
27
-
28
84
  // Never awaits discovery. A refresh reads the manager's current environment;
29
85
  // it does not reload shell profiles, the registry, or a client's working folder.
30
- refresh() {
31
- if (this.closed || this.run) return this.snapshot();
32
- const run = { child: null, timer: null, rows: new Map(), tools: [] };
86
+ // publishLaunch stores a labeled copy when this same helper finishes.
87
+ refresh({ publishLaunch = false } = {}) {
88
+ if (this.closed) return publishLaunch && this.launch ? this.launch : this.snapshot();
89
+ if (this.run) {
90
+ if (publishLaunch && !this.run.publishLaunch) {
91
+ this.run.publishLaunch = true;
92
+ this.armLaunch();
93
+ }
94
+ return publishLaunch ? this.launch : this.snapshot();
95
+ }
96
+ const run = { child: null, timer: null, rows: new Map(), tools: [], publishLaunch };
33
97
  this.run = run;
34
98
  this.value = { ...this.value, revision: this.value.revision + 1, refreshing: true, error: null };
99
+ if (publishLaunch) this.armLaunch();
35
100
  const finish = (error = null) => {
36
101
  if (this.run !== run) return;
37
102
  this.run = null;
@@ -44,7 +109,8 @@ export class Environment extends EventEmitter {
44
109
  }),
45
110
  tools: run.tools,
46
111
  };
47
- if (!this.closed) this.emit('updated');
112
+ if (!this.closed) this.emit('updated', this.snapshot());
113
+ if (run.publishLaunch) this.storeLaunch();
48
114
  };
49
115
  try {
50
116
  // A preload in NODE_OPTIONS must not execute inside a passive scan.
@@ -56,17 +122,242 @@ export class Environment extends EventEmitter {
56
122
  });
57
123
  run.child.on('message', (message) => {
58
124
  if (this.run !== run) return;
59
- if (message?.runtime && RUNTIMES.some((r) => r.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
125
+ if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
126
+ if (Array.isArray(message?.tools)) run.tools = message.tools;
127
+ if (message?.done) finish();
128
+ });
129
+ run.child.once('error', () => finish('Could not start the environment check.'));
130
+ // Let messages already queued win. Exit is a failed check only when a runtime never reported.
131
+ run.child.once('exit', () => setImmediate(() => {
132
+ if (this.run !== run) return;
133
+ finish(run.rows.size === RUNTIMES.length ? null : 'The environment check stopped before finishing.');
134
+ }));
135
+ run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
136
+ run.timer.unref();
137
+ } catch { finish('Could not start the environment check.'); }
138
+ if (!this.closed) this.emit('updated', this.snapshot());
139
+ return publishLaunch ? this.launch : this.snapshot();
140
+ }
141
+
142
+ armLaunch() {
143
+ const previous = this.launch;
144
+ this.launch = {
145
+ scope: 'launch', host: this.host, platform: process.platform,
146
+ revision: (previous?.revision ?? 0) + 1, refreshing: true, checkedAt: previous?.checkedAt ?? null, error: null,
147
+ detail: LAUNCH_DETAIL,
148
+ runtimes: (previous?.runtimes ?? this.value.runtimes).map(cloneRow),
149
+ tools: (previous?.tools ?? this.value.tools).map((tool) => ({ ...tool })),
150
+ };
151
+ if (!this.closed) this.emit('updated', this.launch);
152
+ }
153
+
154
+ storeLaunch() {
155
+ this.launch = {
156
+ scope: 'launch', host: this.host, platform: this.value.platform,
157
+ revision: (this.launch?.revision ?? 0) + 1, refreshing: false, checkedAt: this.value.checkedAt, error: this.value.error,
158
+ detail: LAUNCH_DETAIL,
159
+ runtimes: this.value.runtimes.map(cloneRow),
160
+ tools: this.value.tools.map((tool) => ({ ...tool })),
161
+ };
162
+ if (!this.closed) this.emit('updated', this.launch);
163
+ return this.launch;
164
+ }
165
+
166
+ openLaunch({ refresh = false } = {}) {
167
+ if (refresh) return this.refresh({ publishLaunch: true });
168
+ if (this.launch?.checkedAt) return this.launch;
169
+ if (this.value.checkedAt && !this.run) return this.storeLaunch();
170
+ return this.refresh({ publishLaunch: true });
171
+ }
172
+
173
+ projectKey(dir) {
174
+ return `project:${process.platform === 'win32' ? dir.toLowerCase() : dir}`;
175
+ }
176
+
177
+ openProject(cwd, { refresh = false } = {}) {
178
+ const dir = resolveProjectCwd(cwd);
179
+ const key = this.projectKey(dir);
180
+ let entry = this.projects.get(key);
181
+ if (!entry) {
182
+ entry = { value: this.blankProject(dir), run: null, identityRun: null };
183
+ this.projects.set(key, entry);
184
+ }
185
+ if (refresh || !entry.value.checkedAt) this.startProject(entry, dir, false);
186
+ else this.startProject(entry, dir, true);
187
+ return entry.value;
188
+ }
189
+
190
+ blankProject(cwd) {
191
+ return {
192
+ scope: 'project', host: this.host, platform: process.platform, cwd, revision: 0, refreshing: false,
193
+ checkedAt: null, error: null, stale: false, detail: null, identity: null, pins: [],
194
+ };
195
+ }
196
+
197
+ startProject(entry, dir, identityOnly) {
198
+ if (this.closed) return;
199
+ if (identityOnly) {
200
+ if (entry.run || entry.identityRun) return;
201
+ this.forkPins(entry, dir, true);
202
+ return;
203
+ }
204
+ this.stopWorker(entry.identityRun?.child);
205
+ entry.identityRun = null;
206
+ if (entry.run) return;
207
+ entry.value = { ...entry.value, revision: entry.value.revision + 1, refreshing: true, error: null, stale: false, detail: null };
208
+ this.forkPins(entry, dir, false);
209
+ if (!this.closed) this.emit('updated', entry.value);
210
+ }
211
+
212
+ forkPins(entry, dir, identityOnly) {
213
+ const run = { child: null, timer: null, result: null, revision: entry.value.revision };
214
+ if (identityOnly) entry.identityRun = run;
215
+ else entry.run = run;
216
+ const finish = (error = null) => {
217
+ const current = identityOnly ? entry.identityRun : entry.run;
218
+ if (current !== run) return;
219
+ if (identityOnly) entry.identityRun = null;
220
+ else entry.run = null;
221
+ clearTimeout(run.timer);
222
+ this.stopWorker(run.child);
223
+ if (identityOnly) this.finishIdentity(entry, run, error);
224
+ else this.finishProject(entry, error, run.result);
225
+ };
226
+ try {
227
+ run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), [identityOnly ? '--pin-identity' : '--scan-pins', dir], {
228
+ env: probeProcessEnv(), execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
229
+ windowsHide: true, detached: process.platform !== 'win32',
230
+ });
231
+ run.child.on('message', (message) => {
232
+ if ((identityOnly ? entry.identityRun : entry.run) !== run) return;
233
+ if (message?.done) finish(message.error && !run.result ? message.error : null);
234
+ else if (message?.error) run.result = { error: message.error };
235
+ else if (message?.identity) run.result = message;
236
+ });
237
+ run.child.once('error', () => finish(identityOnly ? 'identity' : 'Could not read the project pins.'));
238
+ run.child.once('exit', () => setImmediate(() => {
239
+ if ((identityOnly ? entry.identityRun : entry.run) !== run) return;
240
+ finish(run.result ? null : (identityOnly ? 'identity' : 'The project check stopped before finishing.'));
241
+ }));
242
+ run.timer = setTimeout(() => finish(identityOnly ? 'identity' : 'The project check timed out.'), this.timeoutMs);
243
+ run.timer.unref();
244
+ } catch { finish(identityOnly ? 'identity' : 'Could not read the project pins.'); }
245
+ }
246
+
247
+ finishProject(entry, error, result) {
248
+ const pins = Array.isArray(result?.pins) ? result.pins : entry.value.pins;
249
+ const identity = result?.identity || entry.value.identity;
250
+ entry.value = {
251
+ ...entry.value, revision: entry.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(),
252
+ error: result?.error || error, stale: false, detail: null, identity, pins,
253
+ };
254
+ if (!this.closed) this.emit('updated', entry.value);
255
+ }
256
+
257
+ finishIdentity(entry, run, error) {
258
+ if (entry.run || entry.value.revision !== run.revision) return;
259
+ if (error || !run.result?.identity || run.result.error) {
260
+ entry.value = { ...entry.value, stale: true, detail: 'Could not confirm the pin files are unchanged.' };
261
+ } else if (run.result.identity !== entry.value.identity) {
262
+ entry.value = { ...entry.value, stale: true, detail: null };
263
+ } else {
264
+ entry.value = { ...entry.value, stale: false, detail: null };
265
+ }
266
+ if (!this.closed) this.emit('updated', entry.value);
267
+ }
268
+
269
+ openSession(id, { refresh = false } = {}) {
270
+ const found = this.sessionLookup(id);
271
+ if (!found) return null;
272
+ if (found.multiplexer) return this.unavailableSession(id, found);
273
+ const key = `session:${id}:${pathIdentity(found.pathEnv, found.spawnCwd)}`;
274
+ for (const other of [...this.sessions.keys()]) {
275
+ if (other.startsWith(`session:${id}:`) && other !== key) this.dropSession(other);
276
+ }
277
+ let entry = this.sessions.get(key);
278
+ if (!entry) {
279
+ entry = { value: this.blankSession(id, found), run: null };
280
+ this.sessions.set(key, entry);
281
+ }
282
+ if (refresh || !entry.value.checkedAt) this.startSession(entry, found);
283
+ return entry.value;
284
+ }
285
+
286
+ blankSession(id, found) {
287
+ return {
288
+ scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
289
+ availability: 'ok', detail: SESSION_DETAIL, revision: 0, refreshing: false, checkedAt: null, error: null,
290
+ runtimes: pendingRuntimes(), tools: [],
291
+ };
292
+ }
293
+
294
+ unavailableSession(id, found) {
295
+ const key = `session:${id}:unavailable`;
296
+ const existing = this.sessions.get(key);
297
+ if (existing) return existing.value;
298
+ for (const other of [...this.sessions.keys()]) if (other.startsWith(`session:${id}:`) && other !== key) this.dropSession(other);
299
+ const value = {
300
+ scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
301
+ availability: 'unavailable', detail: MULTIPLEXER_DETAIL, revision: 1, refreshing: false,
302
+ checkedAt: new Date().toISOString(), error: null, runtimes: [], tools: [],
303
+ };
304
+ this.sessions.set(key, { value, run: null });
305
+ return value;
306
+ }
307
+
308
+ startSession(entry, found) {
309
+ if (this.closed || entry.run) return;
310
+ const run = { child: null, timer: null, rows: new Map(), tools: [] };
311
+ entry.run = run;
312
+ entry.value = { ...entry.value, revision: entry.value.revision + 1, refreshing: true, error: null };
313
+ const finish = (error = null) => {
314
+ if (entry.run !== run) return;
315
+ entry.run = null;
316
+ clearTimeout(run.timer);
317
+ this.stopWorker(run.child);
318
+ entry.value = {
319
+ ...entry.value, revision: entry.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
320
+ runtimes: RUNTIMES.map(({ id, label }) => run.rows.get(id) ?? {
321
+ id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
322
+ }),
323
+ tools: run.tools,
324
+ };
325
+ if (!this.closed) this.emit('updated', entry.value);
326
+ };
327
+ try {
328
+ const env = probePathEnv(found.pathEnv);
329
+ for (const key of Object.keys(env)) if (key.toUpperCase() === 'NODE_OPTIONS') delete env[key];
330
+ run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment'], {
331
+ env, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
332
+ windowsHide: true, detached: process.platform !== 'win32',
333
+ });
334
+ run.child.on('message', (message) => {
335
+ if (entry.run !== run) return;
336
+ if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
60
337
  if (Array.isArray(message?.tools)) run.tools = message.tools;
61
338
  if (message?.done) finish();
62
339
  });
63
340
  run.child.once('error', () => finish('Could not start the environment check.'));
64
- run.child.once('exit', () => finish('The environment check stopped before finishing.'));
341
+ run.child.once('exit', () => setImmediate(() => {
342
+ if (entry.run !== run) return;
343
+ finish(run.rows.size === RUNTIMES.length ? null : 'The environment check stopped before finishing.');
344
+ }));
65
345
  run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
66
346
  run.timer.unref();
67
347
  } catch { finish('Could not start the environment check.'); }
68
- this.emit('updated');
69
- return this.snapshot();
348
+ if (!this.closed) this.emit('updated', entry.value);
349
+ }
350
+
351
+ forgetSession(id) {
352
+ for (const key of [...this.sessions.keys()]) if (key.startsWith(`session:${id}:`)) this.dropSession(key);
353
+ }
354
+
355
+ dropSession(key) {
356
+ const entry = this.sessions.get(key);
357
+ if (!entry) return;
358
+ clearTimeout(entry.run?.timer);
359
+ this.stopWorker(entry.run?.child);
360
+ this.sessions.delete(key);
70
361
  }
71
362
 
72
363
  stopWorker(child) {
@@ -82,5 +373,23 @@ export class Environment extends EventEmitter {
82
373
  this.stopWorker(this.run.child);
83
374
  this.run = null;
84
375
  }
376
+ for (const entry of this.projects.values()) {
377
+ clearTimeout(entry.run?.timer);
378
+ clearTimeout(entry.identityRun?.timer);
379
+ this.stopWorker(entry.run?.child);
380
+ this.stopWorker(entry.identityRun?.child);
381
+ }
382
+ for (const entry of this.sessions.values()) {
383
+ clearTimeout(entry.run?.timer);
384
+ this.stopWorker(entry.run?.child);
385
+ }
386
+ }
387
+ }
388
+
389
+ function probeProcessEnv() {
390
+ const env = {};
391
+ for (const key of ['SystemRoot', 'SYSTEMROOT', 'PATHEXT', 'ComSpec', 'COMSPEC']) {
392
+ if (process.env[key]) env[key] = process.env[key];
85
393
  }
394
+ return env;
86
395
  }
@@ -7,6 +7,10 @@ const PAGE = 30;
7
7
  const RUNNING = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
8
8
  const WEB = 'https://github.com';
9
9
  const ISSUE_RE = /^[1-9]\d{0,9}$/;
10
+ const STATUS_PAGE = 'https://www.githubstatus.com';
11
+ const STATUS_TTL_MS = 60 * 1000;
12
+ const STATUS_TIMEOUT_MS = 5000;
13
+ const OUTAGES = new Set(['degraded_performance', 'partial_outage', 'major_outage', 'under_maintenance']);
10
14
 
11
15
  function refusal(status, code, message) {
12
16
  return Object.assign(new Error(message), { status, code });
@@ -82,6 +86,20 @@ function cleanPull(raw, fullName) {
82
86
  };
83
87
  }
84
88
 
89
+ /** The Actions part of a githubstatus.com summary: null while it is operational or unreadable. */
90
+ export function actionsService(summary) {
91
+ const component = (Array.isArray(summary?.components) ? summary.components : []).find((c) => c?.name === 'Actions' && !c.group_id);
92
+ if (!component || !OUTAGES.has(component.status)) return null;
93
+ const incident = (Array.isArray(summary.incidents) ? summary.incidents : [])
94
+ .find((i) => Array.isArray(i?.components) && i.components.some((c) => c?.id === component.id));
95
+ const name = text(incident?.name, 200).trim();
96
+ return {
97
+ status: component.status,
98
+ incident: name ? { name, url: /^[a-z0-9]{1,32}$/.test(incident.id) ? `${STATUS_PAGE}/incidents/${incident.id}` : STATUS_PAGE } : null,
99
+ url: STATUS_PAGE,
100
+ };
101
+ }
102
+
85
103
  function checkTitle(title) {
86
104
  if (typeof title !== 'string' || !title.trim()) throw refusal(400, 'bad_title', 'title must be a non-empty string');
87
105
  if (title.trim().length > 256) throw refusal(400, 'bad_title', 'Keep the title to 256 characters or fewer.');
@@ -95,7 +113,20 @@ function checkBody(body) {
95
113
  return body;
96
114
  }
97
115
 
98
- export function createViews(github) {
116
+ export function createViews(github, { now = Date.now } = {}) {
117
+ let service = null;
118
+
119
+ /** GitHub's own word on Actions, read without an account and shared by every repository for a minute. */
120
+ function actionsStatus() {
121
+ if (service && now() - service.at < STATUS_TTL_MS) return service.read;
122
+ const read = github.fetchImpl(`${github.statusUrl}/api/v2/summary.json`, {
123
+ headers: { Accept: 'application/json' },
124
+ signal: AbortSignal.timeout(STATUS_TIMEOUT_MS),
125
+ }).then((res) => (res.ok ? res.json() : null)).then(actionsService, () => null);
126
+ service = { at: now(), read };
127
+ return read;
128
+ }
129
+
99
130
  /** One request for the repository's account. GitHub's own refusals become answers the page can show as they are. */
100
131
  async function call(accountId, owner, name, route, init) {
101
132
  const account = github.account(accountId);
@@ -185,11 +216,12 @@ export function createViews(github) {
185
216
  },
186
217
 
187
218
  async actions(accountId, owner, name) {
188
- const { body, res, repo } = await call(accountId, owner, name, `/actions/runs?per_page=${PAGE}`);
219
+ const [{ body, res, repo }, outage] = await Promise.all([call(accountId, owner, name, `/actions/runs?per_page=${PAGE}`), actionsStatus()]);
189
220
  const runs = (Array.isArray(body?.workflow_runs) ? body.workflow_runs : []).map((item) => cleanRun(item, repo.fullName)).filter(Boolean);
190
221
  return {
191
222
  runs,
192
223
  running: runs.some((run) => RUNNING.has(run.status)),
224
+ service: outage,
193
225
  truncated: Boolean(nextLink(res.headers.get('link'))),
194
226
  url: `${WEB}/${repo.fullName}/actions`,
195
227
  };
@@ -14,6 +14,7 @@ export const GITHUB_CLIENT_ID = 'Ov23lif6qqYKtXZTb130';
14
14
  export const GITHUB_SCOPES = ['repo', 'write:public_key'];
15
15
  const API_URL = 'https://api.github.com';
16
16
  const WEB_URL = 'https://github.com';
17
+ const STATUS_URL = 'https://www.githubstatus.com';
17
18
  const SSH_HOST = 'git@github.com';
18
19
 
19
20
  /** GitHub's published SSH host keys (docs: "GitHub's SSH key fingerprints"). */
@@ -241,7 +242,7 @@ function failure(err) {
241
242
 
242
243
  export class GitHub extends EventEmitter {
243
244
  constructor({
244
- dir, registry, clientId = GITHUB_CLIENT_ID, apiUrl = API_URL, webUrl = WEB_URL,
245
+ dir, registry, clientId = GITHUB_CLIENT_ID, apiUrl = API_URL, webUrl = WEB_URL, statusUrl = STATUS_URL,
245
246
  fetchImpl = (...args) => fetch(...args), run = runSpec, hostname = os.hostname(), timeoutMs = FETCH_TIMEOUT_MS,
246
247
  }) {
247
248
  super();
@@ -250,6 +251,7 @@ export class GitHub extends EventEmitter {
250
251
  this.clientId = clientId;
251
252
  this.apiUrl = apiUrl.replace(/\/+$/, '');
252
253
  this.webUrl = webUrl.replace(/\/+$/, '');
254
+ this.statusUrl = statusUrl.replace(/\/+$/, '');
253
255
  this.fetchImpl = fetchImpl;
254
256
  this.run = run;
255
257
  this.hostname = hostname;
@@ -2,12 +2,18 @@
2
2
  // the `agent-guild` CLI, which starts one when none is running, and by a
3
3
  // manager that restarts itself: both run the package on disk, so a restart
4
4
  // after an upgrade comes up on the new version.
5
+ //
6
+ // While the Linux boot service is on, systemd is the one that starts a
7
+ // manager: a supervised manager exits for systemd to start the next one,
8
+ // and one started without systemd hands its restart over to it.
5
9
 
6
10
  import fs from 'node:fs';
7
11
  import path from 'node:path';
8
12
  import { spawn } from 'node:child_process';
9
13
  import { fileURLToPath } from 'node:url';
10
- import { ensureDataDir, paths } from './config.mjs';
14
+ import { dataDir, ensureDataDir, paths } from './config.mjs';
15
+ import { stableExecPath } from './autostart.mjs';
16
+ import { EXIT_RESTART, UNIT_UNSAFE, bootSupported, createBootService, journalCommand, startupState, unitPort } from './systemd-service.mjs';
11
17
 
12
18
  const here = path.dirname(fileURLToPath(import.meta.url));
13
19
  /** The manager entry point, from the package files on disk. */
@@ -62,3 +68,89 @@ export function launcherPath(platform = process.platform, rootDir = ROOT_DIR) {
62
68
  return null;
63
69
  }
64
70
  }
71
+
72
+ /**
73
+ * Starts the manager that follows one restarting on `port`, once that one
74
+ * has released the port, and resolves to the exit code it should leave with.
75
+ *
76
+ * - Under the boot service (`supervised`): EXIT_RESTART, so systemd starts
77
+ * the next one, after resetting its start count so that restarts never
78
+ * use up the limit kept for crash loops.
79
+ * - With the boot service on for this port: systemd starts it, so the next
80
+ * manager is supervised.
81
+ * - Otherwise, or if systemd refuses: a detached manager, as before.
82
+ *
83
+ * @param {{ supervised?: boolean, boot?: object|null, port: number, spawn?: typeof spawnManager, log?: (line: string) => void }} opts
84
+ */
85
+ export async function nextManager({ supervised = false, boot = null, port, spawn = spawnManager, log = console.log }) {
86
+ if (supervised) {
87
+ await boot?.resetFailed().catch(() => {});
88
+ log('[manager] exiting for systemd to start the next manager');
89
+ return EXIT_RESTART;
90
+ }
91
+ if (boot) {
92
+ try {
93
+ const read = await boot.read();
94
+ if (read.enabled && unitPort(await boot.text()) === port) {
95
+ await boot.resetFailed();
96
+ await boot.start({ block: false });
97
+ log('[manager] systemd is starting the next manager');
98
+ return 0;
99
+ }
100
+ } catch (err) {
101
+ log(`[manager] systemd could not start the next manager (${err.message}); starting it directly`);
102
+ }
103
+ }
104
+ const child = spawn({ note: 'restarting manager', env: { ...process.env, AGENT_GUILD_PORT: String(port) } });
105
+ log(`[manager] started the next manager (pid ${child.pid})`);
106
+ return 0;
107
+ }
108
+
109
+ /** The package script a unit runs: this package's, as the manager names it. */
110
+ export const PACKAGE_SCRIPT = path.join(ROOT_DIR, 'bin', 'agent-guild.mjs');
111
+
112
+ /** The boot service on a Linux that can offer it with the default data folder, or null. */
113
+ export function defaultBoot() {
114
+ return bootSupported() && !process.env.AGENT_GUILD_HOME ? createBootService({ dataDir: dataDir() }) : null;
115
+ }
116
+
117
+ /**
118
+ * The boot service when it should start the manager on `port`: on, and for
119
+ * that port. `note` says why an existing unit was passed over, so the
120
+ * caller can say so rather than start an unsupervised manager silently.
121
+ */
122
+ export async function serviceFor(port, boot = defaultBoot()) {
123
+ if (!boot) return { boot: null, note: null };
124
+ const read = await boot.read().catch((err) => ({ reachable: false, reason: err.message, enabled: false }));
125
+ if (!read.reachable) {
126
+ const text = await boot.text().catch(() => null);
127
+ return { boot: null, note: text ? `The boot service could not be used (${read.reason}), so the session manager runs without systemd.` : null };
128
+ }
129
+ if (!read.enabled || unitPort(await boot.text()) !== port) return { boot: null, note: null };
130
+ return { boot, note: null };
131
+ }
132
+
133
+ /**
134
+ * Starts the manager through systemd and resolves once `health` answers at
135
+ * `url`. The unit is first pointed at this Node.js and package, as a manager
136
+ * does at each start, so a Node.js removed since cannot keep it failing; a
137
+ * failed state is cleared so systemd does not refuse the start. Throws with
138
+ * the service's last journal lines when it does not come up.
139
+ */
140
+ export async function startService({ boot, port, url, health, execPath = stableExecPath(process.execPath), script = PACKAGE_SCRIPT, timeoutMs = 20000, pollMs = 250 }) {
141
+ if (![execPath, script].some((p) => UNIT_UNSAFE.test(p)) && await boot.write({ execPath, script, port })) await boot.reload();
142
+ await boot.resetFailed();
143
+ await boot.start({ block: true });
144
+ const deadline = Date.now() + timeoutMs;
145
+ for (;;) {
146
+ const up = await health(url, 500);
147
+ if (up) return { url, started: true, version: up.version };
148
+ const read = await boot.read().catch(() => null);
149
+ const state = read && startupState(read.show, { pid: null, port });
150
+ if (state && ['failed', 'port-in-use', 'stopped'].includes(state.kind)) break;
151
+ if (Date.now() >= deadline) break;
152
+ await new Promise((r) => setTimeout(r, pollMs));
153
+ }
154
+ const journal = await boot.journal(20);
155
+ throw new Error(`the session manager did not start under systemd.${journal ? `\n\nRecent log (${journalCommand(20)}):\n${journal}` : ''}`);
156
+ }