@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
package/src/manager/main.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { ProviderRegistry } from './providers.mjs';
|
|
8
|
-
import { Environment } from './environment.mjs';
|
|
8
|
+
import { Environment, probePathEnv } from './environment.mjs';
|
|
9
9
|
import { SessionManager } from './session-manager.mjs';
|
|
10
10
|
import { UsageMonitor } from './usage.mjs';
|
|
11
11
|
import { SessionHistory } from './session-history.mjs';
|
|
@@ -20,8 +20,9 @@ import { SelfUpdate } from './self-update.mjs';
|
|
|
20
20
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
21
21
|
import { writeReportShims } from './report-shims.mjs';
|
|
22
22
|
import { SessionHooks } from './session-hooks.mjs';
|
|
23
|
-
import { launcherPath,
|
|
23
|
+
import { launcherPath, nextManager } from './launch.mjs';
|
|
24
24
|
import { createAutostart } from './autostart.mjs';
|
|
25
|
+
import { bootSupported, createBootService } from './systemd-service.mjs';
|
|
25
26
|
import {
|
|
26
27
|
DEFAULT_HOST,
|
|
27
28
|
PACKAGE_FILE,
|
|
@@ -42,8 +43,11 @@ const rootDir = path.resolve(here, '../..');
|
|
|
42
43
|
|
|
43
44
|
const VERSION_REFRESH_MS = 60 * 60 * 1000;
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* `version`, `packageFile`, `github` (GitHub's URLs and client id), `autostart` and `boot` stand in for the real ones in tests.
|
|
48
|
+
* `supervised`: started by the Linux boot service, so systemd starts the next manager after a restart.
|
|
49
|
+
*/
|
|
50
|
+
export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {}, remoteAccess: remoteOptions = {}, autostart = null, boot, supervised = false } = {}) {
|
|
47
51
|
// Validate before creating files, processes or timers, so a typo fails startup cleanly.
|
|
48
52
|
const remoteSettings = loadRemoteAccess(paths.remoteAccess);
|
|
49
53
|
ensureDataDir();
|
|
@@ -79,6 +83,14 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
79
83
|
const manager = new SessionManager({
|
|
80
84
|
registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore,
|
|
81
85
|
});
|
|
86
|
+
environment.setSessionLookup((id) => {
|
|
87
|
+
let session;
|
|
88
|
+
try { session = manager.get(id); } catch { return null; }
|
|
89
|
+
return { multiplexer: Boolean(session.multiplexer), spawnCwd: session.cwd, pathEnv: probePathEnv(session.env) };
|
|
90
|
+
});
|
|
91
|
+
manager.on('event', (event) => {
|
|
92
|
+
if (event.type === 'session.removed') environment.forgetSession(event.sessionId);
|
|
93
|
+
});
|
|
82
94
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
83
95
|
const history = new SessionHistory({ registry, env: baseEnv });
|
|
84
96
|
const modelStats = new ModelStats({ registry });
|
|
@@ -93,6 +105,8 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
93
105
|
getPort: () => api.port,
|
|
94
106
|
unavailable: process.env.AGENT_GUILD_HOME ? 'Not available while AGENT_GUILD_HOME sets the data folder.' : null,
|
|
95
107
|
});
|
|
108
|
+
// The boot service, which starts the next manager after a restart while it is on.
|
|
109
|
+
if (boot === undefined) boot = bootSupported() && !process.env.AGENT_GUILD_HOME ? createBootService({ dataDir: dataDir() }) : null;
|
|
96
110
|
let closing = null;
|
|
97
111
|
|
|
98
112
|
const refreshVersions = () => {
|
|
@@ -105,7 +119,8 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
105
119
|
/**
|
|
106
120
|
* End every session and close the API. With `restart`, a new manager is
|
|
107
121
|
* then started from the package on disk, so it comes up on the version
|
|
108
|
-
* an upgrade installed; clients reconnect to it by themselves.
|
|
122
|
+
* an upgrade installed; clients reconnect to it by themselves. Resolves to
|
|
123
|
+
* the code to exit with, which tells systemd to start a supervised one.
|
|
109
124
|
*/
|
|
110
125
|
const shutdown = (reason = 'shutdown', { restart = false } = {}) => {
|
|
111
126
|
if (closing) return closing;
|
|
@@ -124,13 +139,11 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
124
139
|
}).then(() => {
|
|
125
140
|
// Only once the port is released: the successor listens on the same one,
|
|
126
141
|
// the bound one rather than a configured 0, so clients find it again.
|
|
127
|
-
if (!restart) return;
|
|
128
|
-
|
|
129
|
-
const child = spawnManager({ note: 'restarting manager', env: { ...process.env, AGENT_GUILD_PORT: String(api.port) } });
|
|
130
|
-
console.log(`[manager] started the next manager (pid ${child.pid})`);
|
|
131
|
-
} catch (err) {
|
|
142
|
+
if (!restart) return 0;
|
|
143
|
+
return nextManager({ supervised, boot, port: api.port }).catch((err) => {
|
|
132
144
|
console.error(`[manager] could not start the next manager: ${err.message}`);
|
|
133
|
-
|
|
145
|
+
return 0;
|
|
146
|
+
});
|
|
134
147
|
});
|
|
135
148
|
return closing;
|
|
136
149
|
};
|
|
@@ -157,7 +170,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
157
170
|
autostart,
|
|
158
171
|
notes: createNotesStore(paths.notes),
|
|
159
172
|
launcher: launcherPath(),
|
|
160
|
-
onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(
|
|
173
|
+
onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then((code) => process.exit(code)),
|
|
161
174
|
});
|
|
162
175
|
remoteAccess.attach({ apply: (access) => api.setAccessPolicy(access), getTarget: () => api.url });
|
|
163
176
|
|
package/src/manager/server.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { createFolderOpener } from './folder-opener.mjs';
|
|
|
13
13
|
import { createFolderBrowser } from './folder-browser.mjs';
|
|
14
14
|
import { normalizeAccess } from './access-policy.mjs';
|
|
15
15
|
import { NOTES_BODY_LIMIT, createNotesStore } from './notes.mjs';
|
|
16
|
+
import { MODES as AUTOSTART_MODES } from './autostart.mjs';
|
|
16
17
|
|
|
17
18
|
const require = createRequire(import.meta.url);
|
|
18
19
|
const API = '/api/v1';
|
|
@@ -96,6 +97,34 @@ function readJsonBody(req, max = MAX_BODY) {
|
|
|
96
97
|
});
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
const ENVIRONMENT_SCOPES = new Set(['manager', 'project', 'session', 'launch']);
|
|
101
|
+
|
|
102
|
+
function environmentResult(environment, method, url, body) {
|
|
103
|
+
const has = (key) => method === 'GET' ? url.searchParams.has(key) : Object.hasOwn(body, key);
|
|
104
|
+
const read = (key) => method === 'GET' ? url.searchParams.get(key) : body[key];
|
|
105
|
+
for (const key of ['shell', 'command', 'args', 'env']) {
|
|
106
|
+
if (has(key)) throw new HttpError(400, 'The environment check cannot run a command.', 'bad_request');
|
|
107
|
+
}
|
|
108
|
+
const scope = read('scope') ?? 'manager';
|
|
109
|
+
if (typeof scope !== 'string' || !ENVIRONMENT_SCOPES.has(scope)) throw new HttpError(400, 'Unknown environment scope.', 'bad_request');
|
|
110
|
+
if (scope === 'manager') {
|
|
111
|
+
if (method === 'POST') return environment.refresh();
|
|
112
|
+
if (!environment.snapshot().checkedAt) environment.refresh();
|
|
113
|
+
return environment.snapshot();
|
|
114
|
+
}
|
|
115
|
+
if (scope === 'launch') return environment.openLaunch({ refresh: method === 'POST' });
|
|
116
|
+
if (scope === 'project') {
|
|
117
|
+
const cwd = read('cwd');
|
|
118
|
+
if (typeof cwd !== 'string' || !cwd.trim()) throw new HttpError(400, 'A working folder is required.', 'cwd_required');
|
|
119
|
+
return environment.openProject(cwd, { refresh: method === 'POST' });
|
|
120
|
+
}
|
|
121
|
+
const id = read('id');
|
|
122
|
+
if (typeof id !== 'string' || !/^[a-f0-9]+$/.test(id)) throw new HttpError(400, 'Session id must be hexadecimal.', 'bad_request');
|
|
123
|
+
const snapshot = environment.openSession(id, { refresh: method === 'POST' });
|
|
124
|
+
if (!snapshot) throw new HttpError(404, `no session with id "${id}"`, 'not_found');
|
|
125
|
+
return snapshot;
|
|
126
|
+
}
|
|
127
|
+
|
|
99
128
|
export function createManagerServer({
|
|
100
129
|
manager,
|
|
101
130
|
registry,
|
|
@@ -282,7 +311,11 @@ export function createManagerServer({
|
|
|
282
311
|
return sendJson(res, 200, { autostart: await autostart.describe() });
|
|
283
312
|
}
|
|
284
313
|
if (autostart && route === '/autostart' && method === 'PUT') {
|
|
285
|
-
const { enabled } = await readJsonBody(req);
|
|
314
|
+
const { enabled, mode } = await readJsonBody(req);
|
|
315
|
+
if (mode !== undefined) {
|
|
316
|
+
if (!AUTOSTART_MODES.includes(mode)) throw new HttpError(400, `mode must be one of ${AUTOSTART_MODES.join(', ')}`, 'bad_request');
|
|
317
|
+
return sendJson(res, 200, { autostart: await autostart.setMode(mode) });
|
|
318
|
+
}
|
|
286
319
|
if (typeof enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false', 'bad_request');
|
|
287
320
|
return sendJson(res, 200, { autostart: await autostart.set(enabled) });
|
|
288
321
|
}
|
|
@@ -320,12 +353,10 @@ export function createManagerServer({
|
|
|
320
353
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
321
354
|
}
|
|
322
355
|
if (route === '/environment' && method === 'GET' && environment) {
|
|
323
|
-
|
|
324
|
-
return sendJson(res, 200, environment.snapshot());
|
|
356
|
+
return sendJson(res, 200, await environmentResult(environment, 'GET', url, null));
|
|
325
357
|
}
|
|
326
358
|
if (route === '/environment/refresh' && method === 'POST' && environment) {
|
|
327
|
-
await readJsonBody(req);
|
|
328
|
-
return sendJson(res, 202, environment.refresh());
|
|
359
|
+
return sendJson(res, 202, await environmentResult(environment, 'POST', url, await readJsonBody(req)));
|
|
329
360
|
}
|
|
330
361
|
if (route === '/providers' && method === 'GET') {
|
|
331
362
|
registry.refreshVersions().catch(() => {});
|
|
@@ -565,7 +596,7 @@ export function createManagerServer({
|
|
|
565
596
|
}
|
|
566
597
|
manager.on('event', broadcast);
|
|
567
598
|
registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
|
|
568
|
-
const environmentUpdated = () => broadcast({ type: 'environment.updated', environment: environment.snapshot() });
|
|
599
|
+
const environmentUpdated = (snapshot) => broadcast({ type: 'environment.updated', environment: snapshot ?? environment.snapshot() });
|
|
569
600
|
environment?.on('updated', environmentUpdated);
|
|
570
601
|
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
571
602
|
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Starting the session manager when a Linux computer starts, as a systemd
|
|
2
|
+
// user service.
|
|
3
|
+
//
|
|
4
|
+
// The unit runs a launcher in the data folder, which execs `agent-guild
|
|
5
|
+
// start --service` in the foreground, so systemd's MainPID is the manager
|
|
6
|
+
// and systemd supervises it:
|
|
7
|
+
//
|
|
8
|
+
// * Restart=on-failure restarts a manager that crashed; StartLimitBurst
|
|
9
|
+
// stops a crash loop instead of repeating it every few seconds.
|
|
10
|
+
// * A restart the user asks for exits with EXIT_RESTART after resetting the
|
|
11
|
+
// start count, so any number of restarts never trips that limit.
|
|
12
|
+
// * A manager that finds its port in use exits with EXIT_PORT_IN_USE, which
|
|
13
|
+
// RestartPreventExitStatus turns into a stopped unit, not a restart loop.
|
|
14
|
+
// * KillMode=process stops only the manager. It ends its own sessions, and
|
|
15
|
+
// tmux servers outlive it as they do when no service runs it.
|
|
16
|
+
//
|
|
17
|
+
// The service starts at boot only for a user with lingering on, which only
|
|
18
|
+
// an administrator can always turn on; the state says so instead of failing
|
|
19
|
+
// silently. Once the package script is gone the launcher disables the unit
|
|
20
|
+
// and removes it and itself, as the sign-in entries do.
|
|
21
|
+
//
|
|
22
|
+
// Every systemctl call runs with XDG_RUNTIME_DIR set to the user's runtime
|
|
23
|
+
// folder, which reaches the user manager without a desktop bus, and with TZ
|
|
24
|
+
// and LC_ALL fixed, so its timestamps read the same on every system.
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
|
|
31
|
+
export const SERVICE = 'agent-guild.service';
|
|
32
|
+
export const BOOT_LAUNCHER = 'boot.sh';
|
|
33
|
+
/** A manager under the service exits with this to be started again from the package on disk. */
|
|
34
|
+
export const EXIT_RESTART = 75;
|
|
35
|
+
/** A manager under the service exits with this when its port is taken; systemd then leaves it stopped. */
|
|
36
|
+
export const EXIT_PORT_IN_USE = 78;
|
|
37
|
+
export const LINGER_DIR = '/var/lib/systemd/linger';
|
|
38
|
+
export const SHOW_PROPERTIES = ['LoadState', 'UnitFileState', 'ActiveState', 'Result', 'ExecMainStatus', 'MainPID', 'ExecMainStartTimestamp', 'InactiveEnterTimestamp'];
|
|
39
|
+
export const BOOT_NOTE = 'Starts the session manager as a systemd user service when this computer starts, before anyone signs in, and starts it again if it fails. The page does not open.';
|
|
40
|
+
/** Characters a unit's ExecStart line would read as escapes, variables or a line break. */
|
|
41
|
+
export const UNIT_UNSAFE = /["`$\\\u0000-\u001f]/;
|
|
42
|
+
|
|
43
|
+
const failure = (message) => Object.assign(new Error(message), { status: 500, code: 'autostart_failed' });
|
|
44
|
+
|
|
45
|
+
/** Whether this is a Linux the service can be offered on: WSL starts with Windows, not on its own. */
|
|
46
|
+
export function bootSupported({ platform = process.platform, env = process.env, release = os.release() } = {}) {
|
|
47
|
+
return platform === 'linux' && !(env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The user's name, or their uid where the system has no name for it, as in
|
|
52
|
+
* a container run with a uid of its own: os.userInfo() throws there, and a
|
|
53
|
+
* manager must still start.
|
|
54
|
+
*/
|
|
55
|
+
export function userName(uid = process.getuid?.()) {
|
|
56
|
+
try {
|
|
57
|
+
return os.userInfo().username;
|
|
58
|
+
} catch {
|
|
59
|
+
return String(uid ?? '');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function portString(port) {
|
|
64
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw failure('The manager must be listening before saving its startup port.');
|
|
65
|
+
return String(port);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The sh launcher the unit runs, with Node.js, the package script, the unit
|
|
70
|
+
* file and the port as its arguments. Once the package is gone it disables
|
|
71
|
+
* the unit and removes it and itself, and exits 0 so systemd does not retry.
|
|
72
|
+
*/
|
|
73
|
+
export const BOOT_LAUNCHER_SCRIPT = [
|
|
74
|
+
'#!/bin/sh',
|
|
75
|
+
'# Agent Guild: runs the session manager under systemd. Its unit runs it with',
|
|
76
|
+
'# Node.js, the package script, the unit file and the port.',
|
|
77
|
+
'if [ -f "$2" ]; then AGENT_GUILD_PORT="$4" exec "$1" "$2" start --service; fi',
|
|
78
|
+
'echo "Agent Guild is no longer installed at $2; removing its systemd unit."',
|
|
79
|
+
`systemctl --user disable ${SERVICE} >/dev/null 2>&1`,
|
|
80
|
+
'rm -f "$3" "$0"',
|
|
81
|
+
'systemctl --user daemon-reload >/dev/null 2>&1',
|
|
82
|
+
'exit 0',
|
|
83
|
+
'',
|
|
84
|
+
].join('\n');
|
|
85
|
+
|
|
86
|
+
/** One ExecStart argument: quoted, with `%` doubled so systemd reads no specifier. Paths with UNIT_UNSAFE characters are refused before this. */
|
|
87
|
+
export function unitArg(value) {
|
|
88
|
+
return `"${value.replace(/%/g, '%%')}"`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function serviceUnit({ launcher, execPath, script, file, port }) {
|
|
92
|
+
return [
|
|
93
|
+
'[Unit]',
|
|
94
|
+
'Description=Agent Guild session manager',
|
|
95
|
+
'Documentation=https://github.com/oddessentials/agent-guild#readme',
|
|
96
|
+
'StartLimitIntervalSec=300',
|
|
97
|
+
'StartLimitBurst=5',
|
|
98
|
+
'',
|
|
99
|
+
'[Service]',
|
|
100
|
+
'Type=simple',
|
|
101
|
+
`ExecStart=${['/bin/sh', launcher, execPath, script, file, portString(port)].map(unitArg).join(' ')}`,
|
|
102
|
+
'Restart=on-failure',
|
|
103
|
+
'RestartSec=2',
|
|
104
|
+
`RestartPreventExitStatus=${EXIT_PORT_IN_USE}`,
|
|
105
|
+
'KillMode=process',
|
|
106
|
+
'TimeoutStopSec=30',
|
|
107
|
+
'',
|
|
108
|
+
'[Install]',
|
|
109
|
+
'WantedBy=default.target',
|
|
110
|
+
'',
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The ExecStart arguments of a unit this module wrote, or null. */
|
|
115
|
+
export function unitArgs(text) {
|
|
116
|
+
const line = String(text ?? '').match(/^ExecStart=(.*)$/m)?.[1];
|
|
117
|
+
if (!line) return null;
|
|
118
|
+
return [...line.matchAll(/"((?:[^"])*)"/g)].map((m) => m[1].replace(/%%/g, '%'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The port a unit this module wrote starts the manager on, or null. */
|
|
122
|
+
export function unitPort(text) {
|
|
123
|
+
const port = Number(unitArgs(text)?.[5]);
|
|
124
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `systemctl show` output as an object. */
|
|
128
|
+
export function parseShow(stdout) {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const line of String(stdout).split('\n')) {
|
|
131
|
+
const at = line.indexOf('=');
|
|
132
|
+
if (at > 0) out[line.slice(0, at)] = line.slice(at + 1);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A timestamp systemctl printed with TZ=UTC, such as `Mon 2026-10-05 18:40:29 UTC`, as ISO; null when empty or unrecognized. */
|
|
138
|
+
export function parseTimestamp(value) {
|
|
139
|
+
const m = String(value ?? '').match(/(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) UTC$/);
|
|
140
|
+
return m ? new Date(`${m[1]}T${m[2]}Z`).toISOString() : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What the service is doing, read from `systemctl show`, or null when it is
|
|
145
|
+
* not enabled. `pid` is the manager that answers, or null when none does.
|
|
146
|
+
*
|
|
147
|
+
* - `running`: systemd runs the manager that answers (or, with no pid, one)
|
|
148
|
+
* - `other`: systemd runs a manager, but not the one that answers
|
|
149
|
+
* - `starting`: systemd is starting it, or waiting to start it again
|
|
150
|
+
* - `pending`: enabled, but the manager that answers was started without systemd
|
|
151
|
+
* - `stopped`: enabled and stopped, and no manager answers
|
|
152
|
+
* - `port-in-use`: it last stopped because another manager held its port
|
|
153
|
+
* - `failed`: it stopped after failing, the start limit included
|
|
154
|
+
*/
|
|
155
|
+
export function startupState(show, { pid = null, port = null } = {}) {
|
|
156
|
+
if (!show || show.UnitFileState !== 'enabled') return null;
|
|
157
|
+
const mainPid = Number(show.MainPID) || null;
|
|
158
|
+
const since = parseTimestamp(show.ExecMainStartTimestamp);
|
|
159
|
+
const at = parseTimestamp(show.InactiveEnterTimestamp);
|
|
160
|
+
if (show.ActiveState === 'failed') {
|
|
161
|
+
if (Number(show.ExecMainStatus) === EXIT_PORT_IN_USE) return { kind: 'port-in-use', at, port };
|
|
162
|
+
return { kind: 'failed', at, result: show.Result || null, status: Number(show.ExecMainStatus) || 0 };
|
|
163
|
+
}
|
|
164
|
+
if (show.ActiveState === 'active' || show.ActiveState === 'reloading') {
|
|
165
|
+
if (pid === null || mainPid === pid) return { kind: 'running', since, pid: mainPid };
|
|
166
|
+
return { kind: 'other', since, pid: mainPid, port };
|
|
167
|
+
}
|
|
168
|
+
if (show.ActiveState === 'activating') return { kind: 'starting' };
|
|
169
|
+
return pid === null ? { kind: 'stopped', at } : { kind: 'pending' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const journalCommand = (lines = 50) => `journalctl --user -u ${SERVICE} -n ${lines} --no-pager`;
|
|
173
|
+
export const lingerCommand = (user) => `sudo loginctl enable-linger ${user}`;
|
|
174
|
+
|
|
175
|
+
/** The state in a sentence or two, for the CLI. The page words the same states itself. */
|
|
176
|
+
export function describeStartup(state, { linger = true, user = 'USER' } = {}) {
|
|
177
|
+
if (!state) return null;
|
|
178
|
+
const when = (iso) => (iso ? ` (${iso})` : '');
|
|
179
|
+
const text = {
|
|
180
|
+
running: `Starts when the computer starts; running under systemd${when(state.since)}.`,
|
|
181
|
+
other: `Starts when the computer starts; systemd runs a different session manager (pid ${state.pid}).`,
|
|
182
|
+
starting: 'Starts when the computer starts; systemd is starting it.',
|
|
183
|
+
pending: 'Starts when the computer starts; this manager was started without systemd and hands over at its next restart.',
|
|
184
|
+
stopped: 'Starts when the computer starts; stopped now. `agent-guild open` starts it.',
|
|
185
|
+
'port-in-use': `Starts when the computer starts; did not start${when(state.at)} because another session manager held ${state.port ? `port ${state.port}` : 'its port'}.`,
|
|
186
|
+
failed: `Starts when the computer starts; stopped after failing${when(state.at)}. See: ${journalCommand()}`,
|
|
187
|
+
}[state.kind];
|
|
188
|
+
return linger ? text : `${text} Without lingering it waits for you to sign in; to start before anyone signs in, run: ${lingerCommand(user)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A GET /autostart description in one line, for `agent-guild status`; null when the manager offers none. */
|
|
192
|
+
export function startupSummary(autostart) {
|
|
193
|
+
if (!autostart) return null;
|
|
194
|
+
if (!autostart.available) return autostart.reason ? `Startup: ${autostart.reason}` : null;
|
|
195
|
+
if (autostart.boot?.enabled) {
|
|
196
|
+
const line = describeStartup(autostart.boot.state, { linger: autostart.boot.linger, user: autostart.boot.user });
|
|
197
|
+
return autostart.mode === 'both' ? `${line} A sign-in entry is on as well; choose one under Settings › Startup.` : line;
|
|
198
|
+
}
|
|
199
|
+
return autostart.enabled ? 'Starts when you sign in.' : 'Starts only when you start it.';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Runs systemctl or journalctl for the user manager, with a fixed environment. Resolves `{ status, stdout, stderr, missing }`. */
|
|
203
|
+
export function systemdRunner({ uid = process.getuid?.(), env = process.env } = {}) {
|
|
204
|
+
const fixed = { ...env, XDG_RUNTIME_DIR: `/run/user/${uid}`, TZ: 'UTC', LC_ALL: 'C', SYSTEMD_PAGER: '', SYSTEMD_COLORS: '0' };
|
|
205
|
+
return (command, args) => new Promise((resolve) => {
|
|
206
|
+
execFile(command, args, { env: fixed, timeout: 15000 }, (err, stdout, stderr) => {
|
|
207
|
+
resolve({
|
|
208
|
+
status: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
|
209
|
+
stdout: String(stdout ?? ''),
|
|
210
|
+
stderr: String(stderr || (err && typeof err.code !== 'number' ? err.message : '') || ''),
|
|
211
|
+
missing: err?.code === 'ENOENT',
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function readText(file) {
|
|
218
|
+
try {
|
|
219
|
+
return await fs.promises.readFile(file, 'utf8');
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err.code === 'ENOENT') return null;
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Writes `contents` unless the file already holds exactly that; resolves to whether it changed. */
|
|
227
|
+
async function writeIfChanged(file, contents, mode = 0o644) {
|
|
228
|
+
if ((await readText(file)) === contents) {
|
|
229
|
+
if (((await fs.promises.stat(file)).mode & 0o777) !== mode) await fs.promises.chmod(file, mode);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
233
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
234
|
+
await fs.promises.writeFile(tmp, contents);
|
|
235
|
+
await fs.promises.chmod(tmp, mode);
|
|
236
|
+
await fs.promises.rename(tmp, file);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The user's agent-guild.service.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} opts
|
|
244
|
+
* @param {string} opts.dataDir where the launcher is kept
|
|
245
|
+
* @param {(command: string, args: string[]) => Promise<{ status: number, stdout: string, stderr: string, missing?: boolean }>} [opts.run] systemctl and journalctl, replaceable in tests
|
|
246
|
+
*/
|
|
247
|
+
export function createBootService({
|
|
248
|
+
env = process.env,
|
|
249
|
+
home = os.homedir(),
|
|
250
|
+
user = userName(),
|
|
251
|
+
uid = process.getuid?.(),
|
|
252
|
+
dataDir,
|
|
253
|
+
lingerDir = LINGER_DIR,
|
|
254
|
+
run = systemdRunner({ uid, env }),
|
|
255
|
+
}) {
|
|
256
|
+
const file = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'systemd', 'user', SERVICE);
|
|
257
|
+
const wants = path.join(path.dirname(file), 'default.target.wants', SERVICE);
|
|
258
|
+
const launcher = path.join(dataDir, BOOT_LAUNCHER);
|
|
259
|
+
const systemctl = (...args) => run('systemctl', ['--user', ...args]);
|
|
260
|
+
const must = async (what, ...args) => {
|
|
261
|
+
const r = await systemctl(...args);
|
|
262
|
+
if (r.status !== 0) throw failure(`Could not ${what}: ${(r.stderr || r.stdout).trim() || `systemctl exited with ${r.status}`}`);
|
|
263
|
+
return r;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
file,
|
|
268
|
+
launcher,
|
|
269
|
+
user,
|
|
270
|
+
/** The paths the unit and launcher hold, for the caller's unsafe-path check. */
|
|
271
|
+
paths: [file, launcher],
|
|
272
|
+
/**
|
|
273
|
+
* `{ reachable, reason, show, enabled, linger }`. Unreachable means no
|
|
274
|
+
* systemctl, or a user manager that did not answer; `reason` says which.
|
|
275
|
+
*/
|
|
276
|
+
async read() {
|
|
277
|
+
const r = await systemctl('show', SERVICE, ...SHOW_PROPERTIES.map((p) => `--property=${p}`));
|
|
278
|
+
const linger = fs.existsSync(path.join(lingerDir, user));
|
|
279
|
+
if (r.missing) return { reachable: false, reason: 'Not available because this computer does not use systemd.', show: null, enabled: false, linger };
|
|
280
|
+
if (r.status !== 0) {
|
|
281
|
+
return { reachable: false, reason: `Not available because systemd's user manager did not answer: ${(r.stderr || r.stdout).trim() || `systemctl exited with ${r.status}`}`, show: null, enabled: false, linger };
|
|
282
|
+
}
|
|
283
|
+
const show = parseShow(r.stdout);
|
|
284
|
+
return { reachable: true, reason: null, show, enabled: show.UnitFileState === 'enabled', linger };
|
|
285
|
+
},
|
|
286
|
+
/** The unit's text, or null. */
|
|
287
|
+
text: () => readText(file),
|
|
288
|
+
/** Writes the launcher and unit; resolves to whether the unit changed. */
|
|
289
|
+
async write({ execPath, script, port }) {
|
|
290
|
+
await writeIfChanged(launcher, BOOT_LAUNCHER_SCRIPT, 0o755);
|
|
291
|
+
return writeIfChanged(file, serviceUnit({ launcher, execPath, script, file, port }));
|
|
292
|
+
},
|
|
293
|
+
reload: () => must('reload systemd units', 'daemon-reload'),
|
|
294
|
+
enable: () => must('enable the systemd unit', 'enable', SERVICE),
|
|
295
|
+
/** Disables and removes the unit and launcher whether or not systemd answers; never stops a running manager. */
|
|
296
|
+
async remove() {
|
|
297
|
+
const r = await systemctl('disable', SERVICE);
|
|
298
|
+
await fs.promises.rm(wants, { force: true });
|
|
299
|
+
await fs.promises.rm(file, { force: true });
|
|
300
|
+
await fs.promises.rm(launcher, { force: true });
|
|
301
|
+
if (!r.missing) await systemctl('daemon-reload');
|
|
302
|
+
},
|
|
303
|
+
/** Clears a failed state and the start count, so the next start is not refused. */
|
|
304
|
+
resetFailed: () => systemctl('reset-failed', SERVICE),
|
|
305
|
+
start: ({ block = true } = {}) => must('start the systemd unit', 'start', ...(block ? [] : ['--no-block']), SERVICE),
|
|
306
|
+
/** The last lines the service logged, or '' when the journal cannot be read. */
|
|
307
|
+
async journal(lines = 20) {
|
|
308
|
+
const r = await run('journalctl', ['--user', '-u', SERVICE, '-n', String(lines), '--no-pager', '-o', 'cat']);
|
|
309
|
+
return r.status === 0 ? r.stdout.trim() : '';
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|