@ours.network/fleet 0.9.4 → 0.9.7
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 +148 -30
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +238 -26
- package/dist/config.d.ts +39 -1
- package/dist/config.js +126 -3
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +34 -0
- package/dist/docs.js +309 -0
- package/dist/doctor.js +123 -21
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +145 -13
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +89 -4
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +59 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +44 -2
- package/dist/monitor.js +177 -42
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +307 -32
- package/dist/session/acp.d.ts +70 -0
- package/dist/session/acp.js +364 -0
- package/dist/session/control.d.ts +89 -0
- package/dist/session/control.js +322 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +27 -0
- package/dist/session/tmux.js +76 -0
- package/dist/session/types.d.ts +138 -0
- package/dist/session/types.js +42 -0
- package/dist/spawn.d.ts +32 -2
- package/dist/spawn.js +177 -16
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +7 -2
package/dist/spawn.d.ts
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
|
+
import type { IsolationConfig } from './isolation/types.js';
|
|
2
|
+
import { type ApprovalMode, type FilesystemMode, type SessionBackendId, type UnattendedMode } from './config.js';
|
|
1
3
|
import { type OpsDeps } from './ops.js';
|
|
4
|
+
import { type CreationDeps, type CreationProvenance } from './creation.js';
|
|
5
|
+
/**
|
|
6
|
+
* The provenance record written by the most recent spawn in this process, so
|
|
7
|
+
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
8
|
+
*/
|
|
9
|
+
export declare let lastProvenance: CreationProvenance | undefined;
|
|
2
10
|
export interface SpawnOpts {
|
|
3
11
|
name: string;
|
|
4
12
|
temp?: boolean;
|
|
5
13
|
harness?: string;
|
|
14
|
+
session?: SessionBackendId;
|
|
6
15
|
mission?: string;
|
|
7
16
|
identity?: string;
|
|
8
17
|
cwd?: string;
|
|
9
18
|
coordinator?: string;
|
|
10
19
|
model?: string;
|
|
11
20
|
permissionMode?: string;
|
|
21
|
+
approval?: ApprovalMode;
|
|
22
|
+
filesystem?: FilesystemMode;
|
|
23
|
+
unattended?: UnattendedMode;
|
|
12
24
|
sandbox?: string;
|
|
13
25
|
profile?: string;
|
|
14
26
|
launcher?: string;
|
|
@@ -18,12 +30,30 @@ export interface SpawnOpts {
|
|
|
18
30
|
monitor?: boolean;
|
|
19
31
|
bioFile?: string;
|
|
20
32
|
personaFile?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Path to a file holding exactly the existing `isolation:` mapping — the same
|
|
35
|
+
* schema fleet.yaml uses, not a second policy language. The ONE new operator
|
|
36
|
+
* input in this release (6.3).
|
|
37
|
+
*/
|
|
38
|
+
isolationFile?: string;
|
|
21
39
|
overseeInterval?: string;
|
|
22
40
|
configPath?: string;
|
|
23
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Read and validate an `--isolation-file`. The file is the existing
|
|
44
|
+
* `isolation:` mapping and nothing else — the same schema, the same validator
|
|
45
|
+
* (`validateIsolationConfig`), so a policy written here cannot mean something
|
|
46
|
+
* different from the identical block in fleet.yaml.
|
|
47
|
+
*
|
|
48
|
+
* Called BEFORE the creation transaction reserves anything: an invalid file
|
|
49
|
+
* must fail before any artifact exists.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readIsolationFile(path: string): IsolationConfig;
|
|
52
|
+
/** The ours identity a spawn will bind: explicit, else the role name. */
|
|
53
|
+
export declare const effectiveIdentity: (o: SpawnOpts) => string;
|
|
24
54
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
25
|
-
export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps): Promise<string>;
|
|
55
|
+
export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: CreationDeps): Promise<string>;
|
|
26
56
|
/** Launches the detached temp supervisor (`_run-temp <name>`). Injectable for tests. */
|
|
27
57
|
export type SupervisorLauncher = (binPath: string, args: string[], dir: string) => void;
|
|
28
58
|
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
29
|
-
export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher): Promise<string>;
|
|
59
|
+
export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;
|
package/dist/spawn.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { spawn as spawnChild } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { stringify } from 'yaml';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
|
-
import {
|
|
6
|
+
import { validateIsolationConfig } from './isolation/policy.js';
|
|
7
|
+
import { loadConfig, resolveMonitorConfig, resolvePermissions, } from './config.js';
|
|
7
8
|
import { applyRole, up } from './ops.js';
|
|
8
9
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
|
+
import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
11
|
+
import { VERSION } from './version.js';
|
|
12
|
+
/**
|
|
13
|
+
* The provenance record written by the most recent spawn in this process, so
|
|
14
|
+
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
15
|
+
*/
|
|
16
|
+
export let lastProvenance;
|
|
9
17
|
function roleFromOpts(o, defaultHarness) {
|
|
10
18
|
const r = {};
|
|
11
19
|
if (o.harness)
|
|
12
20
|
r.harness = o.harness;
|
|
21
|
+
if (o.session)
|
|
22
|
+
r.session = o.session;
|
|
13
23
|
if (o.identity)
|
|
14
24
|
r.identity = o.identity;
|
|
15
25
|
if (o.cwd)
|
|
@@ -40,12 +50,62 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
40
50
|
harnessOptions.monitor = true;
|
|
41
51
|
if (Object.keys(harnessOptions).length)
|
|
42
52
|
r.harness_options = harnessOptions;
|
|
53
|
+
if (o.approval || o.filesystem || o.unattended) {
|
|
54
|
+
r.permissions = {
|
|
55
|
+
...(o.approval ? { approval: o.approval } : {}),
|
|
56
|
+
...(o.filesystem ? { filesystem: o.filesystem } : {}),
|
|
57
|
+
...(o.unattended ? { unattended: o.unattended } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
43
60
|
if (o.bioFile)
|
|
44
61
|
r.bio = readFileSync(o.bioFile, 'utf8').trim();
|
|
45
62
|
if (o.personaFile)
|
|
46
63
|
r.persona = readFileSync(o.personaFile, 'utf8').trim();
|
|
64
|
+
if (o.isolationFile)
|
|
65
|
+
r.isolation = readIsolationFile(o.isolationFile);
|
|
47
66
|
return r;
|
|
48
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Read and validate an `--isolation-file`. The file is the existing
|
|
70
|
+
* `isolation:` mapping and nothing else — the same schema, the same validator
|
|
71
|
+
* (`validateIsolationConfig`), so a policy written here cannot mean something
|
|
72
|
+
* different from the identical block in fleet.yaml.
|
|
73
|
+
*
|
|
74
|
+
* Called BEFORE the creation transaction reserves anything: an invalid file
|
|
75
|
+
* must fail before any artifact exists.
|
|
76
|
+
*/
|
|
77
|
+
export function readIsolationFile(path) {
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = parse(readFileSync(path, 'utf8'));
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
throw new Error(`--isolation-file ${path}: ${e.message}`);
|
|
84
|
+
}
|
|
85
|
+
// A file holding only comments parses to null; treat it as an empty policy,
|
|
86
|
+
// which is a meaningful request ("sandbox me with defaults").
|
|
87
|
+
const cfg = (raw ?? {});
|
|
88
|
+
const problems = validateIsolationConfig(cfg);
|
|
89
|
+
if (problems.length)
|
|
90
|
+
throw new Error(`--isolation-file ${path}: ${problems.join('; ')}`);
|
|
91
|
+
return cfg;
|
|
92
|
+
}
|
|
93
|
+
function validateSpawnOpts(o) {
|
|
94
|
+
if (o.session && !['tmux', 'acp'].includes(o.session))
|
|
95
|
+
throw new Error(`invalid --session '${o.session}'; allowed: tmux, acp`);
|
|
96
|
+
if (o.approval && !['ask', 'allow', 'deny'].includes(o.approval))
|
|
97
|
+
throw new Error(`invalid --approval '${o.approval}'; allowed: ask, allow, deny`);
|
|
98
|
+
if (o.filesystem && !['read-only', 'workspace', 'unrestricted'].includes(o.filesystem))
|
|
99
|
+
throw new Error(`invalid --filesystem '${o.filesystem}'; allowed: read-only, workspace, unrestricted`);
|
|
100
|
+
if (o.unattended && !['deny', 'wait'].includes(o.unattended))
|
|
101
|
+
throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reject names that are already USED. This is a precondition, not a claim: it
|
|
105
|
+
* runs INSIDE the creation transaction, after both names are reserved, so the
|
|
106
|
+
* gap between checking and creating that let two spawns both succeed is closed
|
|
107
|
+
* by the reservation rather than by this function.
|
|
108
|
+
*/
|
|
49
109
|
function assertNameFree(o) {
|
|
50
110
|
const cfg = loadConfig(o.configPath);
|
|
51
111
|
if (cfg.roles.some(r => r.name === o.name))
|
|
@@ -53,17 +113,96 @@ function assertNameFree(o) {
|
|
|
53
113
|
if (existsSync(agentDir(o.name)) || existsSync(agentDir(o.name, true)))
|
|
54
114
|
throw new Error(`agent dir for '${o.name}' already exists — pick another name or 'ours-fleet rm ${o.name}'`);
|
|
55
115
|
}
|
|
116
|
+
/** The ours identity a spawn will bind: explicit, else the role name. */
|
|
117
|
+
export const effectiveIdentity = (o) => o.identity ?? o.name;
|
|
118
|
+
/**
|
|
119
|
+
* Which settings came from the operator, from fleet defaults, or from a
|
|
120
|
+
* built-in (6.6). Built while the options are still separable — once they are
|
|
121
|
+
* merged into a ResolvedRole the distinction is gone.
|
|
122
|
+
*
|
|
123
|
+
* `env`, `bio`, `persona` and `harness_options` are deliberately absent: the
|
|
124
|
+
* record exists to be read, and must not become a place credentials collect.
|
|
125
|
+
*/
|
|
126
|
+
function provenanceSettings(o, defaults) {
|
|
127
|
+
const perms = (defaults.permissions ?? {});
|
|
128
|
+
return {
|
|
129
|
+
harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
|
|
130
|
+
session: provenanceOf(o.session, defaults.session, 'tmux'),
|
|
131
|
+
identity: o.identity
|
|
132
|
+
? { value: o.identity, source: 'cli' }
|
|
133
|
+
: { value: o.name, source: 'built-in' }, // defaults to the role name
|
|
134
|
+
cwd: provenanceOf(o.cwd, undefined, undefined),
|
|
135
|
+
model: provenanceOf(o.model?.trim(), defaults.model, undefined),
|
|
136
|
+
coordinator: provenanceOf(o.coordinator, undefined, undefined),
|
|
137
|
+
approval: provenanceOf(o.approval, perms.approval, 'ask'),
|
|
138
|
+
filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
|
|
139
|
+
unattended: provenanceOf(o.unattended, perms.unattended, 'deny'),
|
|
140
|
+
isolation: o.isolationFile
|
|
141
|
+
? { value: 'declared via --isolation-file', source: 'cli' }
|
|
142
|
+
: { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
56
145
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
57
|
-
export async function spawnPermanent(o, deps) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
146
|
+
export async function spawnPermanent(o, deps, creation = {}) {
|
|
147
|
+
validateSpawnOpts(o);
|
|
148
|
+
if (o.isolationFile)
|
|
149
|
+
readIsolationFile(o.isolationFile); // fail before reserving
|
|
150
|
+
// Name AND identity reserved together, before anything is written or started
|
|
151
|
+
// (6.4). A loser of the race creates no config, no state, no service.
|
|
152
|
+
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
153
|
+
assertNameFree(o);
|
|
154
|
+
const cfg = loadConfig(o.configPath);
|
|
155
|
+
// Establish the identity BEFORE the service is enabled (7.3), and record
|
|
156
|
+
// what was actually guaranteed so the briefing can say something true.
|
|
157
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
|
|
158
|
+
persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
159
|
+
if (guarantee.state === 'created')
|
|
160
|
+
// We minted it; a failed creation must not leave an orphan identity
|
|
161
|
+
// behind. Only ever removes an identity THIS transaction created.
|
|
162
|
+
tx.record({
|
|
163
|
+
stage: `ours identity ${effectiveIdentity(o)}`,
|
|
164
|
+
undo: async () => {
|
|
165
|
+
await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
mkdirSync(fleetDDir(), { recursive: true });
|
|
169
|
+
const file = join(fleetDDir(), `${o.name}.yaml`);
|
|
170
|
+
writeRoleFile(tx, file, stringify({
|
|
171
|
+
roles: { [o.name]: roleFromOpts(o, cfg.defaults.harness) },
|
|
172
|
+
}));
|
|
173
|
+
// `up` materialises the state dir and registers the service. Journal the
|
|
174
|
+
// dir before it exists so a failure leaves the name genuinely reusable
|
|
175
|
+
// rather than blocked by a half-built directory.
|
|
176
|
+
const stateDir = agentDir(o.name);
|
|
177
|
+
const stateExisted = existsSync(stateDir);
|
|
178
|
+
tx.record({
|
|
179
|
+
stage: `state dir ${stateDir}`,
|
|
180
|
+
undo: () => { if (!stateExisted)
|
|
181
|
+
rmSync(stateDir, { recursive: true, force: true }); },
|
|
182
|
+
});
|
|
183
|
+
// Journal the service registration BEFORE it happens, and undo only the
|
|
184
|
+
// registrations this transaction actually created (6.2). `registered` is
|
|
185
|
+
// filled by `up`'s onInstalled hook at the moment each registration is
|
|
186
|
+
// made — not from its return value, which never arrives when `up` throws
|
|
187
|
+
// after registering.
|
|
188
|
+
const registered = [];
|
|
189
|
+
tx.record({
|
|
190
|
+
stage: `service registration for ${o.name}`,
|
|
191
|
+
undo: async () => { for (const n of registered)
|
|
192
|
+
await deps.backend.uninstall(n); },
|
|
193
|
+
});
|
|
194
|
+
// Provenance is written BEFORE the role starts, so a role that fails to
|
|
195
|
+
// launch still records how it was asked for (6.6).
|
|
196
|
+
const provenance = buildProvenance({
|
|
197
|
+
role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
|
|
198
|
+
settings: provenanceSettings(o, cfg.defaults),
|
|
199
|
+
});
|
|
200
|
+
mkdirSync(agentDir(o.name), { recursive: true });
|
|
201
|
+
writeProvenance(agentDir(o.name), provenance);
|
|
202
|
+
await up(loadConfig(o.configPath), [o.name], { ...deps, onInstalled: outcome => registered.push(outcome.role) }, o.configPath, guarantee.state);
|
|
203
|
+
lastProvenance = provenance;
|
|
204
|
+
return file;
|
|
205
|
+
}, creation);
|
|
67
206
|
}
|
|
68
207
|
const detachedSupervisor = (binPath, args, dir) => {
|
|
69
208
|
// Log to the temp dir; the fd stays valid even after runTemp removes the dir.
|
|
@@ -75,7 +214,19 @@ const detachedSupervisor = (binPath, args, dir) => {
|
|
|
75
214
|
child.unref();
|
|
76
215
|
};
|
|
77
216
|
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
78
|
-
export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
217
|
+
export async function spawnTemp(o, binPath, launch = detachedSupervisor, creation = {}) {
|
|
218
|
+
validateSpawnOpts(o);
|
|
219
|
+
if (o.isolationFile)
|
|
220
|
+
readIsolationFile(o.isolationFile); // fail before reserving
|
|
221
|
+
// Temporary roles go through the SAME reservation boundary as permanent ones
|
|
222
|
+
// (6.4): a temp agent competes for the same names.
|
|
223
|
+
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
224
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
|
|
225
|
+
persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
|
|
226
|
+
return spawnTempInner(o, binPath, launch, tx, guarantee);
|
|
227
|
+
}, creation);
|
|
228
|
+
}
|
|
229
|
+
async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
79
230
|
assertNameFree(o);
|
|
80
231
|
const cfg = loadConfig(o.configPath);
|
|
81
232
|
const defaultHarness = cfg.defaults.harness;
|
|
@@ -85,17 +236,27 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
|
85
236
|
...(fromOpts.harness_options ?? {}),
|
|
86
237
|
};
|
|
87
238
|
const role = {
|
|
88
|
-
...fromOpts,
|
|
239
|
+
...fromOpts, // includes `isolation` when --isolation-file was given
|
|
89
240
|
name: o.name,
|
|
90
241
|
harness: o.harness ?? defaultHarness ?? 'claude-code',
|
|
242
|
+
session: o.session ?? cfg.defaults.session ?? 'tmux',
|
|
91
243
|
identity: o.identity ?? o.name,
|
|
92
244
|
model: o.model?.trim() || cfg.defaults.model,
|
|
93
245
|
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
246
|
+
permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
|
|
247
|
+
permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
94
248
|
// Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
|
|
95
249
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
|
|
96
250
|
sourceFile: '(temp)',
|
|
97
251
|
};
|
|
98
|
-
const dir = applyRole(role, { temp: true });
|
|
252
|
+
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
253
|
+
const provenance = buildProvenance({
|
|
254
|
+
role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
|
|
255
|
+
settings: provenanceSettings(o, cfg.defaults),
|
|
256
|
+
});
|
|
257
|
+
writeProvenance(dir, provenance);
|
|
258
|
+
lastProvenance = provenance;
|
|
259
|
+
tx.record({ stage: `temp state dir ${dir}`, undo: () => rmSync(dir, { recursive: true, force: true }) });
|
|
99
260
|
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
100
261
|
// Snapshot the fleet start-stagger so the detached temp supervisor (no config path
|
|
101
262
|
// threaded through it) honors the same launch gate — a burst of temp spawns spaces
|
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
2
|
import type { SupervisorBackend } from './types.js';
|
|
3
3
|
export declare const labelFor: (name: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* What `launchctl print` said about a job, parsed ONCE so that the two questions
|
|
6
|
+
* asked of it cannot drift apart. They are not the same question:
|
|
7
|
+
*
|
|
8
|
+
* - `liveness` asks "does this role's context still exist" — a loaded job counts,
|
|
9
|
+
* including one waiting between KeepAlive restarts (1.1);
|
|
10
|
+
* - `install` asks "did the job I just bootstrapped actually START" — for which a
|
|
11
|
+
* job that is loaded, not running, and has already exited once is a failure.
|
|
12
|
+
*
|
|
13
|
+
* On systemd one `ActiveState` answers both. Here the answers differ, so what is
|
|
14
|
+
* shared is the READING of launchd's output, not its classification.
|
|
15
|
+
*/
|
|
16
|
+
export interface LaunchdJob {
|
|
17
|
+
/** `launchctl print` exited 0 — the job is loaded in the domain. */
|
|
18
|
+
loaded: boolean;
|
|
19
|
+
/** launchd's own `state = …`, e.g. `running`, `waiting`, `not running`. */
|
|
20
|
+
state?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `last exit code|status|reason = …`. Present only once the program has RUN
|
|
23
|
+
* and exited — which is what separates "died" from "has not started yet".
|
|
24
|
+
*/
|
|
25
|
+
lastExit?: string;
|
|
26
|
+
/** The domain has no such service: a definite negative, not a failed probe. */
|
|
27
|
+
notFound: boolean;
|
|
28
|
+
/** Why the probe itself could not be read, when it could not. */
|
|
29
|
+
failure?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Did the job we just bootstrapped actually start?
|
|
33
|
+
*
|
|
34
|
+
* `launchctl bootstrap` exits 0 once the job is LOADED. With `RunAtLoad` the
|
|
35
|
+
* program then starts asynchronously, so a zero exit is a statement about the
|
|
36
|
+
* load, not about the program — the same shape of lie that `systemctl enable
|
|
37
|
+
* --now` tells on systemd 255, where the exit code is 0 and the unit is dead.
|
|
38
|
+
*
|
|
39
|
+
* Only a DEFINITE stop counts as a failed start, exactly as on systemd:
|
|
40
|
+
*
|
|
41
|
+
* - not loaded at all, though bootstrap said it worked → definite;
|
|
42
|
+
* - loaded, `state` is not running, AND launchd already has an exit status for
|
|
43
|
+
* it → it ran and died → definite;
|
|
44
|
+
* - loaded and running, or waiting for a KeepAlive restart, or not running with
|
|
45
|
+
* nothing exited yet (it simply has not been spawned yet — the asynchrony
|
|
46
|
+
* RunAtLoad introduces) → NOT a failure;
|
|
47
|
+
* - an unreadable probe → `unknown`, never a failure (1.1). launchd may be fine
|
|
48
|
+
* and the tool merely unable to answer.
|
|
49
|
+
*/
|
|
50
|
+
export declare function classifyStart(job: LaunchdJob): {
|
|
51
|
+
started: 'yes' | 'no' | 'unknown';
|
|
52
|
+
detail: string;
|
|
53
|
+
};
|
|
4
54
|
export declare function makeLaunchdBackend(exec?: Exec, uid?: number): SupervisorBackend;
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { home, logsRoot } from '../paths.js';
|
|
4
4
|
import { realExec } from '../exec.js';
|
|
5
5
|
export const labelFor = (name) => `network.ours.fleet.${name}`;
|
|
6
|
+
/**
|
|
7
|
+
* The spelling of the last-exit line is not stable across macOS releases
|
|
8
|
+
* (`last exit code`, `last exit status`, `last exit reason`), so match the
|
|
9
|
+
* family rather than one member. This is the load-bearing signal in
|
|
10
|
+
* `classifyStart`, so it errs towards NOT matching: an unrecognised spelling
|
|
11
|
+
* yields `unknown`, never a false failure.
|
|
12
|
+
*/
|
|
13
|
+
const LAST_EXIT_RE = /^\s*last exit (?:code|status|reason)\s*=\s*(.+?)\s*$/mi;
|
|
14
|
+
const STATE_RE = /^\s*state\s*=\s*(.+?)\s*$/m;
|
|
6
15
|
const agentsDir = () => join(home(), 'Library', 'LaunchAgents');
|
|
7
16
|
const plistPath = (name) => join(agentsDir(), `${labelFor(name)}.plist`);
|
|
8
17
|
function plist(name, binPath) {
|
|
@@ -14,7 +23,10 @@ function plist(name, binPath) {
|
|
|
14
23
|
<key>Label</key><string>${labelFor(name)}</string>
|
|
15
24
|
<key>ProgramArguments</key>
|
|
16
25
|
<array><string>${binPath}</string><string>_run</string><string>${name}</string></array>
|
|
17
|
-
|
|
26
|
+
<!-- The runner owns the child-session restart loop (3.2). launchd must only
|
|
27
|
+
recover the runner PROCESS crashing: a bare KeepAlive would resume the
|
|
28
|
+
uncounted relaunch loop and restart a deliberately held-down agent. -->
|
|
29
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
18
30
|
<key>RunAtLoad</key><true/>
|
|
19
31
|
<key>StandardOutPath</key><string>${log}</string>
|
|
20
32
|
<key>StandardErrorPath</key><string>${log}</string>
|
|
@@ -22,8 +34,59 @@ function plist(name, binPath) {
|
|
|
22
34
|
</plist>
|
|
23
35
|
`;
|
|
24
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Did the job we just bootstrapped actually start?
|
|
39
|
+
*
|
|
40
|
+
* `launchctl bootstrap` exits 0 once the job is LOADED. With `RunAtLoad` the
|
|
41
|
+
* program then starts asynchronously, so a zero exit is a statement about the
|
|
42
|
+
* load, not about the program — the same shape of lie that `systemctl enable
|
|
43
|
+
* --now` tells on systemd 255, where the exit code is 0 and the unit is dead.
|
|
44
|
+
*
|
|
45
|
+
* Only a DEFINITE stop counts as a failed start, exactly as on systemd:
|
|
46
|
+
*
|
|
47
|
+
* - not loaded at all, though bootstrap said it worked → definite;
|
|
48
|
+
* - loaded, `state` is not running, AND launchd already has an exit status for
|
|
49
|
+
* it → it ran and died → definite;
|
|
50
|
+
* - loaded and running, or waiting for a KeepAlive restart, or not running with
|
|
51
|
+
* nothing exited yet (it simply has not been spawned yet — the asynchrony
|
|
52
|
+
* RunAtLoad introduces) → NOT a failure;
|
|
53
|
+
* - an unreadable probe → `unknown`, never a failure (1.1). launchd may be fine
|
|
54
|
+
* and the tool merely unable to answer.
|
|
55
|
+
*/
|
|
56
|
+
export function classifyStart(job) {
|
|
57
|
+
if (job.notFound)
|
|
58
|
+
return { started: 'no', detail: 'the service is not loaded in the domain' };
|
|
59
|
+
if (!job.loaded)
|
|
60
|
+
return { started: 'unknown', detail: job.failure ?? 'launchctl print could not be read' };
|
|
61
|
+
// Deliberately narrow: only launchd's own `not running` is read as stopped.
|
|
62
|
+
// Any state this does not recognise falls through to "started", because a
|
|
63
|
+
// wrong guess here rolls back a role that is in fact fine.
|
|
64
|
+
const stopped = job.state !== undefined && /^not running$/i.test(job.state.trim());
|
|
65
|
+
if (stopped && job.lastExit !== undefined)
|
|
66
|
+
return { started: 'no', detail: `state = ${job.state}, last exit = ${job.lastExit}` };
|
|
67
|
+
if (stopped)
|
|
68
|
+
return { started: 'unknown', detail: `state = ${job.state}, but nothing has exited yet` };
|
|
69
|
+
return { started: 'yes', detail: job.state ? `state = ${job.state}` : 'loaded' };
|
|
70
|
+
}
|
|
25
71
|
export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ?? 501) {
|
|
26
72
|
const domain = `gui/${uid}`;
|
|
73
|
+
/** Read `launchctl print` once; both callers classify it for their own question. */
|
|
74
|
+
const printJob = async (name) => {
|
|
75
|
+
const r = await exec('launchctl', ['print', `${domain}/${labelFor(name)}`]);
|
|
76
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
77
|
+
if (r.code !== 0)
|
|
78
|
+
return {
|
|
79
|
+
loaded: false,
|
|
80
|
+
notFound: /could not find service|no such process/i.test(out),
|
|
81
|
+
failure: `launchctl print ${labelFor(name)} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.code}`}`,
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
loaded: true,
|
|
85
|
+
notFound: false,
|
|
86
|
+
state: STATE_RE.exec(r.stdout)?.[1],
|
|
87
|
+
lastExit: LAST_EXIT_RE.exec(r.stdout)?.[1],
|
|
88
|
+
};
|
|
89
|
+
};
|
|
27
90
|
return {
|
|
28
91
|
id: 'launchd',
|
|
29
92
|
async init() {
|
|
@@ -37,11 +100,50 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
37
100
|
async install(name, binPath) {
|
|
38
101
|
mkdirSync(agentsDir(), { recursive: true });
|
|
39
102
|
mkdirSync(logsRoot(), { recursive: true });
|
|
103
|
+
// The plist's prior existence is the record of whether we created this.
|
|
104
|
+
const existed = existsSync(plistPath(name));
|
|
40
105
|
writeFileSync(plistPath(name), plist(name, binPath));
|
|
41
106
|
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // best-effort refresh
|
|
107
|
+
// Undo only what WE wrote. A plist that was already there belongs to
|
|
108
|
+
// whoever put it there, and rollback may never remove it (6.2).
|
|
109
|
+
const undo = async () => {
|
|
110
|
+
if (existed)
|
|
111
|
+
return;
|
|
112
|
+
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]);
|
|
113
|
+
rmSync(plistPath(name), { force: true });
|
|
114
|
+
};
|
|
42
115
|
const r = await exec('launchctl', ['bootstrap', domain, plistPath(name)]);
|
|
43
|
-
if (r.code !== 0)
|
|
116
|
+
if (r.code !== 0) {
|
|
117
|
+
// The plist is already on disk, carrying RunAtLoad. Throwing here means
|
|
118
|
+
// `install` never returns `{created: true}`, so the creation transaction
|
|
119
|
+
// records nothing and its rollback removes nothing — and a spawn that
|
|
120
|
+
// failed at registration leaves a launch artifact behind (6.2). Undo our
|
|
121
|
+
// own partial write before throwing, and only when WE wrote it.
|
|
122
|
+
await undo();
|
|
44
123
|
throw new Error(`launchctl bootstrap ${labelFor(name)} failed: ${r.stderr.trim()}`);
|
|
124
|
+
}
|
|
125
|
+
// THE EXIT CODE IS NOT THE SIGNAL — the launchd half of the same fix made
|
|
126
|
+
// for systemd in 4023e72.
|
|
127
|
+
//
|
|
128
|
+
// `bootstrap` exits 0 when the job is LOADED. `RunAtLoad` then starts the
|
|
129
|
+
// program asynchronously, so a zero exit says nothing about whether the
|
|
130
|
+
// program ran: "bootstrap exits 0, job immediately dead" is available on
|
|
131
|
+
// macOS for the same reason "enable --now exits 0, unit dead" is on
|
|
132
|
+
// systemd 255. Trusting it means `ours-fleet spawn` reports a created role
|
|
133
|
+
// whose job is loaded and not running.
|
|
134
|
+
//
|
|
135
|
+
// A failed start takes the SAME rollback path as a failed bootstrap, so
|
|
136
|
+
// nothing is left behind either way.
|
|
137
|
+
const start = classifyStart(await printJob(name));
|
|
138
|
+
if (start.started === 'no') {
|
|
139
|
+
await undo();
|
|
140
|
+
throw new Error(`launchctl bootstrap ${labelFor(name)} reported success but the job is not running `
|
|
141
|
+
+ `(${start.detail}); bootstrap exits 0 once the job is loaded, which does not report a `
|
|
142
|
+
+ `failed start. Check: launchctl print ${domain}/${labelFor(name)}`);
|
|
143
|
+
}
|
|
144
|
+
return existed
|
|
145
|
+
? { created: false, detail: `${labelFor(name)} was already installed (${start.detail})` }
|
|
146
|
+
: { created: true, detail: `installed ${labelFor(name)} (${start.detail})` };
|
|
45
147
|
},
|
|
46
148
|
async start(name) {
|
|
47
149
|
const r = await exec('launchctl', ['bootstrap', domain, plistPath(name)]);
|
|
@@ -64,9 +166,24 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
64
166
|
return `not loaded (${labelFor(name)})`;
|
|
65
167
|
return r.stdout.split('\n').slice(0, 12).join('\n');
|
|
66
168
|
},
|
|
169
|
+
async liveness(name) {
|
|
170
|
+
const job = await printJob(name);
|
|
171
|
+
// Loaded. `state = waiting` is a KeepAlive service between restarts —
|
|
172
|
+
// still supervised, so its context stands. Unchanged by the install-time
|
|
173
|
+
// start check above, which asks a different question of the same output.
|
|
174
|
+
if (job.loaded)
|
|
175
|
+
return { state: 'running', detail: job.state ? `loaded (state = ${job.state})` : 'loaded' };
|
|
176
|
+
if (job.notFound)
|
|
177
|
+
return { state: 'stopped', detail: `not loaded (${labelFor(name)})` };
|
|
178
|
+
return { state: 'unknown', detail: job.failure ?? `launchctl print ${labelFor(name)} failed` };
|
|
179
|
+
},
|
|
67
180
|
async uninstall(name) {
|
|
68
|
-
|
|
181
|
+
const existed = existsSync(plistPath(name));
|
|
182
|
+
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // idempotent
|
|
69
183
|
rmSync(plistPath(name), { force: true });
|
|
184
|
+
return existed
|
|
185
|
+
? { removed: true, detail: `removed ${labelFor(name)}` }
|
|
186
|
+
: { removed: false, detail: `${labelFor(name)} was not installed` };
|
|
70
187
|
},
|
|
71
188
|
logsArgs(name, follow) {
|
|
72
189
|
const log = join(logsRoot(), `${name}.log`);
|
package/dist/supervisor/none.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Tmux } from '../tmux.js';
|
|
1
|
+
import { Tmux, tmuxArgs } from '../tmux.js';
|
|
2
2
|
import { realExec, shq } from '../exec.js';
|
|
3
3
|
/**
|
|
4
4
|
* No supervision: sessions are plain tmux, nothing survives a reboot and
|
|
@@ -11,14 +11,32 @@ export function makeNoneBackend(exec = realExec) {
|
|
|
11
11
|
id: 'none',
|
|
12
12
|
async init() { return ['no supervisor: sessions are plain tmux (no reboot survival)']; },
|
|
13
13
|
async install(name, binPath) {
|
|
14
|
-
await tmux.kill(name);
|
|
14
|
+
const existed = await tmux.kill(name); // true when a session was there
|
|
15
15
|
await tmux.newSession(name, process.cwd(), `${shq(binPath)} _run ${shq(name)}`);
|
|
16
|
+
return existed
|
|
17
|
+
? { created: false, detail: `replaced the existing tmux session '${name}'` }
|
|
18
|
+
: { created: true, detail: `created tmux session '${name}'` };
|
|
16
19
|
},
|
|
17
20
|
async start(name) { throw new Error(`'${name}' has no unit under the none backend — use install/spawn`); },
|
|
18
21
|
async stop(name) { await tmux.kill(name); },
|
|
19
22
|
async restart(name) { throw new Error(`restart unsupported under the none backend — stop + install '${name}'`); },
|
|
20
23
|
async status(name) { return (await tmux.has(name)) ? `tmux session '${name}' running` : `'${name}' not running`; },
|
|
21
|
-
async
|
|
22
|
-
|
|
24
|
+
async liveness(name) {
|
|
25
|
+
// Report the tmux probe directly: 0 = session exists, 1 = it does not.
|
|
26
|
+
// Any other code (127 = no tmux binary) is a failed probe, not a stop.
|
|
27
|
+
const r = await exec('tmux', tmuxArgs(name, ['has-session', '-t', name]));
|
|
28
|
+
if (r.code === 0)
|
|
29
|
+
return { state: 'running', detail: `tmux session '${name}' exists` };
|
|
30
|
+
if (r.code === 1)
|
|
31
|
+
return { state: 'stopped', detail: `no tmux session '${name}'` };
|
|
32
|
+
return { state: 'unknown', detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}` };
|
|
33
|
+
},
|
|
34
|
+
async uninstall(name) {
|
|
35
|
+
const killed = await tmux.kill(name); // idempotent
|
|
36
|
+
return killed
|
|
37
|
+
? { removed: true, detail: `killed tmux session '${name}'` }
|
|
38
|
+
: { removed: false, detail: `no tmux session '${name}'` };
|
|
39
|
+
},
|
|
40
|
+
logsArgs(name) { return { cmd: 'tmux', args: tmuxArgs(name, ['capture-pane', '-t', name, '-p']) }; },
|
|
23
41
|
};
|
|
24
42
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
|
-
import type { SupervisorBackend } from './types.js';
|
|
2
|
+
import type { LivenessState, SupervisorBackend } from './types.js';
|
|
3
3
|
export declare const UNIT_TEMPLATE = "ours-fleet-agent@.service";
|
|
4
4
|
/**
|
|
5
5
|
* Actionable hint when systemctl cannot reach the user bus. After the cli.ts
|
|
@@ -9,4 +9,11 @@ export declare const UNIT_TEMPLATE = "ours-fleet-agent@.service";
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const busHint: (stderr: string) => string;
|
|
11
11
|
export declare const unitFor: (name: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* systemd's own ActiveState vocabulary, classified. `activating` covers
|
|
14
|
+
* `auto-restart` — the unit is mid-restart, not stopped, so its context stands.
|
|
15
|
+
* `deactivating`/`reloading` still have a process. Only `inactive` and `failed`
|
|
16
|
+
* are definite stops. Anything systemd did not report is `unknown`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function classifyActiveState(activeState: string): LivenessState;
|
|
12
19
|
export declare function makeSystemdBackend(exec?: Exec): SupervisorBackend;
|