@oddessentials/agent-guild 0.33.2 → 0.35.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.
- package/README.md +126 -359
- package/bin/agent-guild.mjs +29 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +104 -20
- package/src/manager/environment-pins.mjs +289 -0
- package/src/manager/environment-probe.mjs +11 -0
- package/src/manager/environment.mjs +320 -15
- package/src/manager/github-views.mjs +34 -2
- package/src/manager/github.mjs +3 -1
- package/src/manager/launch.mjs +93 -1
- package/src/manager/main.mjs +25 -12
- package/src/manager/server.mjs +37 -6
- package/src/manager/systemd-service.mjs +312 -0
- package/web/app.js +163 -9
- package/web/environment.js +239 -34
- package/web/index.html +24 -8
- package/web/styles.css +39 -0
|
@@ -1,37 +1,102 @@
|
|
|
1
|
-
// Read-only
|
|
2
|
-
//
|
|
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.
|
|
19
|
-
|
|
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:
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,7 +122,7 @@ 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((
|
|
125
|
+
if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
|
|
60
126
|
if (Array.isArray(message?.tools)) run.tools = message.tools;
|
|
61
127
|
if (message?.done) finish();
|
|
62
128
|
});
|
|
@@ -69,8 +135,229 @@ export class Environment extends EventEmitter {
|
|
|
69
135
|
run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
|
|
70
136
|
run.timer.unref();
|
|
71
137
|
} catch { finish('Could not start the environment check.'); }
|
|
72
|
-
this.emit('updated');
|
|
73
|
-
return this.snapshot();
|
|
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);
|
|
337
|
+
if (Array.isArray(message?.tools)) run.tools = message.tools;
|
|
338
|
+
if (message?.done) finish();
|
|
339
|
+
});
|
|
340
|
+
run.child.once('error', () => finish('Could not start the environment check.'));
|
|
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
|
+
}));
|
|
345
|
+
run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
|
|
346
|
+
run.timer.unref();
|
|
347
|
+
} catch { finish('Could not start the environment check.'); }
|
|
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);
|
|
74
361
|
}
|
|
75
362
|
|
|
76
363
|
stopWorker(child) {
|
|
@@ -86,5 +373,23 @@ export class Environment extends EventEmitter {
|
|
|
86
373
|
this.stopWorker(this.run.child);
|
|
87
374
|
this.run = null;
|
|
88
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];
|
|
89
393
|
}
|
|
394
|
+
return env;
|
|
90
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
|
};
|
package/src/manager/github.mjs
CHANGED
|
@@ -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;
|
package/src/manager/launch.mjs
CHANGED
|
@@ -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
|
+
}
|