@gajae-gateway/cli 0.1.8 → 0.2.1
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/dist/discord-setup.d.ts +41 -0
- package/dist/discord-setup.d.ts.map +1 -0
- package/dist/gjc-install.d.ts +13 -0
- package/dist/gjc-install.d.ts.map +1 -0
- package/dist/main.d.ts +17 -30
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +1318 -582
- package/dist/migrate/hermes.d.ts.map +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/restart-stack.d.ts +27 -13
- package/dist/restart-stack.d.ts.map +1 -1
- package/dist/services.d.ts +9 -6
- package/dist/services.d.ts.map +1 -1
- package/dist/setup.d.ts +14 -3
- package/dist/setup.d.ts.map +1 -1
- package/dist/up.d.ts +20 -0
- package/dist/up.d.ts.map +1 -0
- package/dist/update.d.ts +10 -6
- package/dist/update.d.ts.map +1 -1
- package/package.json +6 -3
- package/src/discord-setup.ts +103 -0
- package/src/gjc-install.ts +148 -0
- package/src/main.ts +296 -151
- package/src/migrate/hermes.ts +0 -2
- package/src/migrate.ts +36 -4
- package/src/restart-stack.ts +309 -92
- package/src/services.ts +75 -23
- package/src/setup.ts +85 -10
- package/src/up.ts +133 -0
- package/src/update.ts +175 -98
- package/dist/upgrade.d.ts +0 -40
- package/dist/upgrade.d.ts.map +0 -1
- package/src/upgrade.ts +0 -205
package/src/restart-stack.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { access, mkdir, readFile, readlink, rename, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
acquireLease,
|
|
7
|
+
homeLayout,
|
|
8
|
+
type Lease,
|
|
9
|
+
LeaseHeldError,
|
|
10
|
+
type LeaseName,
|
|
11
|
+
leaseHeld,
|
|
12
|
+
readLeaseOwner,
|
|
13
|
+
} from "@gajae-gateway/home";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
BROKER_UNIT,
|
|
6
17
|
currentPlatform,
|
|
7
18
|
launchAgentPlistPath,
|
|
8
19
|
restartOrder,
|
|
@@ -31,7 +42,7 @@ export type RestartState = "queued" | "running" | "ok" | "stale" | "failed";
|
|
|
31
42
|
|
|
32
43
|
export interface RestartStep {
|
|
33
44
|
readonly label: string;
|
|
34
|
-
/**
|
|
45
|
+
/** The final service-manager command; update broker receipts separately record agent sync. */
|
|
35
46
|
command?: readonly string[];
|
|
36
47
|
exitStatus?: number;
|
|
37
48
|
result: RestartStepResult;
|
|
@@ -39,34 +50,37 @@ export interface RestartStep {
|
|
|
39
50
|
processStartedAt?: string;
|
|
40
51
|
binary?: string;
|
|
41
52
|
binaryModifiedAt?: string;
|
|
53
|
+
agentSync?: { readonly command: readonly string[]; readonly exitStatus: number; readonly error?: string };
|
|
42
54
|
detail?: string;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export interface RestartReceipt {
|
|
46
58
|
readonly id: string;
|
|
59
|
+
readonly trigger: "update" | "manual";
|
|
47
60
|
state: RestartState;
|
|
48
61
|
readonly platform: ServicePlatform;
|
|
49
62
|
readonly requestedAt: string;
|
|
50
63
|
supervisorPid?: number;
|
|
51
64
|
finishedAt?: string;
|
|
65
|
+
/** Why the sequence never ran, e.g. the supervisor could not be started. */
|
|
66
|
+
detail?: string;
|
|
52
67
|
readonly steps: RestartStep[];
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
/**
|
|
70
|
+
/** The main PID reported by the service manager. */
|
|
56
71
|
export interface ServiceProcess {
|
|
57
72
|
readonly pid: number;
|
|
58
|
-
/** Epoch ms; `ps` reports whole seconds. */
|
|
59
|
-
readonly startedAt: number;
|
|
60
|
-
readonly binary: string;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
export type CommandRunner = (command: readonly string[]) => number | PromiseLike<number>;
|
|
64
76
|
export type ServiceProbe = (label: string) => Promise<ServiceProcess | undefined>;
|
|
65
77
|
export type IsServiceInstalled = (label: string) => Promise<boolean>;
|
|
66
78
|
|
|
79
|
+
/** The trigger is explicit so only update receipts synchronize provisioned agent state. */
|
|
67
80
|
export interface RunRestartOptions {
|
|
68
81
|
readonly home: string;
|
|
69
82
|
readonly id: string;
|
|
83
|
+
readonly trigger?: "update" | "manual";
|
|
70
84
|
readonly platform?: ServicePlatform;
|
|
71
85
|
readonly uid?: number;
|
|
72
86
|
readonly runner?: CommandRunner;
|
|
@@ -84,7 +98,7 @@ export interface RunRestartOptions {
|
|
|
84
98
|
readonly env?: NodeJS.ProcessEnv;
|
|
85
99
|
/** LaunchAgents directory on darwin. Defaults to ~/Library/LaunchAgents. */
|
|
86
100
|
readonly launchAgentsDir?: string;
|
|
87
|
-
|
|
101
|
+
readonly binDir?: string;
|
|
88
102
|
readonly unitDir?: string;
|
|
89
103
|
}
|
|
90
104
|
|
|
@@ -115,16 +129,29 @@ async function writeReceipt(home: string, receipt: RestartReceipt): Promise<void
|
|
|
115
129
|
await rename(temporary, path);
|
|
116
130
|
}
|
|
117
131
|
|
|
118
|
-
function newReceipt(
|
|
132
|
+
function newReceipt(
|
|
133
|
+
id: string,
|
|
134
|
+
platform: ServicePlatform,
|
|
135
|
+
uid: number | undefined,
|
|
136
|
+
now: number,
|
|
137
|
+
trigger: "update" | "manual",
|
|
138
|
+
): RestartReceipt {
|
|
119
139
|
const commands = restartStackCommands(platform, uid);
|
|
120
140
|
return {
|
|
121
141
|
id,
|
|
142
|
+
trigger,
|
|
122
143
|
state: "queued",
|
|
123
144
|
platform,
|
|
124
145
|
requestedAt: new Date(now).toISOString(),
|
|
125
146
|
steps: restartOrder().map((spec, index) => ({
|
|
126
147
|
label: spec.label,
|
|
127
|
-
...(platform === "darwin"
|
|
148
|
+
...(platform === "darwin"
|
|
149
|
+
? { command: commands[index] }
|
|
150
|
+
: spec.id === "broker"
|
|
151
|
+
? { command: commands[0] }
|
|
152
|
+
: spec.id === "gateway"
|
|
153
|
+
? { command: commands[1] }
|
|
154
|
+
: {}),
|
|
128
155
|
result: "pending",
|
|
129
156
|
})),
|
|
130
157
|
};
|
|
@@ -146,32 +173,19 @@ async function capture(command: readonly string[]): Promise<string | undefined>
|
|
|
146
173
|
return (await child.exited) === 0 ? output : undefined;
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
/** Reads the
|
|
150
|
-
export function defaultServiceProbe(platform: ServicePlatform): ServiceProbe {
|
|
176
|
+
/** Reads the current main PID directly from launchd or systemd. */
|
|
177
|
+
export function defaultServiceProbe(platform: ServicePlatform, uid = process.getuid?.() ?? 0): ServiceProbe {
|
|
151
178
|
return async (label) => {
|
|
152
179
|
const spec = serviceSpecs().find((candidate) => candidate.label === label);
|
|
153
180
|
if (spec === undefined) throw new Error(`unknown service label: ${label}`);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return undefined;
|
|
164
|
-
const lstart = await capture(["ps", "-o", "lstart=", "-p", String(pid)]);
|
|
165
|
-
const args = await capture(["ps", "-ww", "-o", "args=", "-p", String(pid)]);
|
|
166
|
-
if (lstart === undefined || args === undefined) return undefined;
|
|
167
|
-
const startedAt = Date.parse(lstart.trim());
|
|
168
|
-
if (!Number.isFinite(startedAt)) return undefined;
|
|
169
|
-
// The service definitions exec `<bin-dir>/<binary> <fixed args>`; stripping
|
|
170
|
-
// the known arguments keeps a bin dir containing spaces intact.
|
|
171
|
-
const suffix = spec.args.length > 0 ? ` ${spec.args.join(" ")}` : "";
|
|
172
|
-
const line = args.trim();
|
|
173
|
-
const binary = suffix && line.endsWith(suffix) ? line.slice(0, -suffix.length) : line;
|
|
174
|
-
return { pid, startedAt, binary };
|
|
181
|
+
const shown =
|
|
182
|
+
platform === "darwin"
|
|
183
|
+
? await capture(["launchctl", "print", `gui/${uid}/${label}`])
|
|
184
|
+
: await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
|
|
185
|
+
const rawPid = platform === "darwin" ? shown?.match(/^\s*pid = (\d+);?\s*$/m)?.[1] : shown?.trim();
|
|
186
|
+
const pid = rawPid === undefined ? undefined : Number(rawPid);
|
|
187
|
+
if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) return undefined;
|
|
188
|
+
return { pid };
|
|
175
189
|
};
|
|
176
190
|
}
|
|
177
191
|
|
|
@@ -215,27 +229,59 @@ function defaultIsServiceInstalled(options: {
|
|
|
215
229
|
};
|
|
216
230
|
}
|
|
217
231
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
232
|
+
const SERVICE_LEASES: Partial<Record<string, LeaseName>> = {
|
|
233
|
+
broker: "broker",
|
|
234
|
+
gateway: "gateway",
|
|
235
|
+
"adapter-discord": "adapter-discord",
|
|
236
|
+
"adapter-slack": "adapter-slack",
|
|
237
|
+
"adapter-telegram": "adapter-telegram",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function serviceStopCommand(platform: ServicePlatform, uid: number | undefined): readonly string[] {
|
|
241
|
+
if (platform === "linux") return ["systemctl", "--user", "stop", BROKER_UNIT];
|
|
242
|
+
const target = uid ?? process.getuid?.() ?? 0;
|
|
243
|
+
return ["launchctl", "bootout", `gui/${target}/dev.gajaeway.broker`];
|
|
227
244
|
}
|
|
228
|
-
const PS_START_SKEW_MS = 1_000;
|
|
229
245
|
|
|
246
|
+
function serviceBootstrapCommand(
|
|
247
|
+
options: RunRestartOptions,
|
|
248
|
+
platform: ServicePlatform,
|
|
249
|
+
label: string,
|
|
250
|
+
): readonly string[] {
|
|
251
|
+
if (platform === "linux") return ["systemctl", "--user", "start", BROKER_UNIT];
|
|
252
|
+
const target = options.uid ?? process.getuid?.() ?? 0;
|
|
253
|
+
const userHome = options.env?.HOME || homedir();
|
|
254
|
+
const agentsDir = options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents");
|
|
255
|
+
const spec = serviceSpecs().find((candidate) => candidate.label === label);
|
|
256
|
+
if (spec === undefined) throw new Error(`unknown service label: ${label}`);
|
|
257
|
+
return ["launchctl", "bootstrap", `gui/${target}`, launchAgentPlistPath(spec, agentsDir)];
|
|
258
|
+
}
|
|
230
259
|
/**
|
|
231
260
|
* Runs the ordered restart, verifying each service before the next command,
|
|
232
261
|
* and persists the receipt after every transition. The first service that is
|
|
233
262
|
* stale or missing aborts the remainder.
|
|
234
263
|
*/
|
|
235
264
|
export async function runRestartStack(options: RunRestartOptions): Promise<RestartReceipt> {
|
|
265
|
+
let lease: Lease;
|
|
266
|
+
try {
|
|
267
|
+
lease = await acquireLease(options.home, "update");
|
|
268
|
+
console.info(`lease_acquired name=update pid=${process.pid}`);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (error instanceof LeaseHeldError) console.error(error.message);
|
|
271
|
+
else console.error(`lease_failed name=update detail=${error instanceof Error ? error.message : String(error)}`);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
return await runRestartStackWithLease(options);
|
|
276
|
+
} finally {
|
|
277
|
+
await lease.release();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runRestartStackWithLease(options: RunRestartOptions): Promise<RestartReceipt> {
|
|
236
282
|
const platform = options.platform ?? currentPlatform();
|
|
237
283
|
const runner = options.runner ?? defaultCommandRunner;
|
|
238
|
-
const probe = options.probe ?? defaultServiceProbe(platform);
|
|
284
|
+
const probe = options.probe ?? defaultServiceProbe(platform, options.uid);
|
|
239
285
|
const modifiedAt = options.binaryModifiedAt ?? defaultBinaryModifiedAt;
|
|
240
286
|
const isInstalled =
|
|
241
287
|
options.isServiceInstalled ??
|
|
@@ -249,12 +295,21 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
249
295
|
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
250
296
|
const timeoutMs = options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
251
297
|
const pollMs = options.pollMs ?? POLL_MS;
|
|
298
|
+
const binDir = options.binDir ?? process.env.GAJAEWAY_BIN_DIR ?? dirname(process.execPath);
|
|
299
|
+
|
|
300
|
+
const originalPids = new Map<string, number | undefined>();
|
|
301
|
+
for (const spec of restartOrder()) {
|
|
302
|
+
if (SERVICE_LEASES[spec.id] === undefined && (await isInstalled(spec.label)))
|
|
303
|
+
originalPids.set(spec.label, (await probe(spec.label))?.pid);
|
|
304
|
+
}
|
|
252
305
|
|
|
253
306
|
const existing = await readRestartReceipt(options.home);
|
|
307
|
+
if (existing?.id === options.id && existing.state !== "queued")
|
|
308
|
+
throw new Error(`restart-stack ${options.id}: receipt is ${existing.state}; refusing to start worker`);
|
|
254
309
|
const receipt =
|
|
255
|
-
existing?.id === options.id
|
|
310
|
+
existing?.id === options.id
|
|
256
311
|
? existing
|
|
257
|
-
: newReceipt(options.id, platform, options.uid, now());
|
|
312
|
+
: newReceipt(options.id, platform, options.uid, now(), options.trigger ?? "manual");
|
|
258
313
|
receipt.state = "running";
|
|
259
314
|
receipt.supervisorPid = process.pid;
|
|
260
315
|
await writeReceipt(options.home, receipt);
|
|
@@ -266,7 +321,15 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
266
321
|
continue;
|
|
267
322
|
}
|
|
268
323
|
try {
|
|
269
|
-
|
|
324
|
+
if (
|
|
325
|
+
step.label === "dev.gajaeway.broker" &&
|
|
326
|
+
!(await Bun.file(homeLayout(options.home).brokerProvisionManifest).exists())
|
|
327
|
+
) {
|
|
328
|
+
step.result = "skipped";
|
|
329
|
+
step.detail = "broker agent is not provisioned";
|
|
330
|
+
await writeReceipt(options.home, receipt);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
270
333
|
const installed = await isInstalled(step.label);
|
|
271
334
|
if (!installed) {
|
|
272
335
|
step.result = "skipped";
|
|
@@ -275,16 +338,76 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
275
338
|
continue;
|
|
276
339
|
}
|
|
277
340
|
|
|
278
|
-
if (step.
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
step.
|
|
282
|
-
|
|
341
|
+
if (step.label === "dev.gajaeway.broker" && receipt.trigger === "update") {
|
|
342
|
+
const stopCommand = serviceStopCommand(platform, options.uid);
|
|
343
|
+
const stopStatus = await runner(stopCommand);
|
|
344
|
+
if (stopStatus !== 0 && !(platform === "darwin" && (await probe(step.label)) === undefined))
|
|
345
|
+
throw new Error(`${stopCommand.join(" ")} exited with status ${stopStatus}`);
|
|
346
|
+
|
|
347
|
+
const provisionCommand = [
|
|
348
|
+
join(binDir, "gajaeway-gateway"),
|
|
349
|
+
"broker-agent",
|
|
350
|
+
"provision",
|
|
351
|
+
"--mode",
|
|
352
|
+
"update",
|
|
353
|
+
"--lease-wait-ms",
|
|
354
|
+
"35000",
|
|
355
|
+
];
|
|
356
|
+
const provisionStatus = await runner(provisionCommand);
|
|
357
|
+
step.agentSync = {
|
|
358
|
+
command: provisionCommand,
|
|
359
|
+
exitStatus: provisionStatus,
|
|
360
|
+
...(provisionStatus === 1 || provisionStatus === 3
|
|
361
|
+
? { error: `broker-agent provision exited with status ${provisionStatus}` }
|
|
362
|
+
: provisionStatus === 4
|
|
363
|
+
? { error: "broker_agent_unrecoverable" }
|
|
364
|
+
: {}),
|
|
365
|
+
};
|
|
366
|
+
if (provisionStatus === 4) {
|
|
367
|
+
delete step.command;
|
|
368
|
+
step.exitStatus = 4;
|
|
283
369
|
step.result = "failed";
|
|
284
|
-
step.detail =
|
|
370
|
+
step.detail = "broker left stopped: broker_agent_unrecoverable; run gajaeway setup --broker-agent";
|
|
371
|
+
} else if (provisionStatus === 0 || provisionStatus === 1 || provisionStatus === 3) {
|
|
372
|
+
const startCommand = serviceBootstrapCommand(options, platform, step.label);
|
|
373
|
+
step.command = startCommand;
|
|
374
|
+
issuedAt = now();
|
|
375
|
+
const startStatus = await runner(startCommand);
|
|
376
|
+
step.exitStatus = startStatus;
|
|
377
|
+
if (startStatus !== 0) {
|
|
378
|
+
step.result = "failed";
|
|
379
|
+
step.detail = `${startCommand.join(" ")} exited with status ${startStatus}`;
|
|
380
|
+
} else await verifyStep(step, issuedAt);
|
|
381
|
+
} else {
|
|
382
|
+
delete step.command;
|
|
383
|
+
step.exitStatus = provisionStatus;
|
|
384
|
+
step.result = "failed";
|
|
385
|
+
step.detail = `broker-agent provision exited with unexpected status ${provisionStatus}`;
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
if (step.command !== undefined) {
|
|
389
|
+
issuedAt = now();
|
|
390
|
+
let command = step.command;
|
|
391
|
+
let status = await runner(command);
|
|
392
|
+
if (
|
|
393
|
+
status !== 0 &&
|
|
394
|
+
receipt.trigger === "update" &&
|
|
395
|
+
platform === "darwin" &&
|
|
396
|
+
(await probe(step.label)) === undefined
|
|
397
|
+
) {
|
|
398
|
+
command = serviceBootstrapCommand(options, platform, step.label);
|
|
399
|
+
step.command = command;
|
|
400
|
+
issuedAt = now();
|
|
401
|
+
status = await runner(command);
|
|
402
|
+
}
|
|
403
|
+
step.exitStatus = status;
|
|
404
|
+
if (status !== 0) {
|
|
405
|
+
step.result = "failed";
|
|
406
|
+
step.detail = `${command.join(" ")} exited with status ${status}`;
|
|
407
|
+
}
|
|
285
408
|
}
|
|
409
|
+
if (step.result === "pending") await verifyStep(step, issuedAt);
|
|
286
410
|
}
|
|
287
|
-
if (step.result === "pending") await verifyStep(step, issuedAt);
|
|
288
411
|
} catch (error) {
|
|
289
412
|
step.result = "failed";
|
|
290
413
|
step.detail = error instanceof Error ? error.message : String(error);
|
|
@@ -300,32 +423,58 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
300
423
|
return receipt;
|
|
301
424
|
|
|
302
425
|
async function verifyStep(step: RestartStep, since: number): Promise<void> {
|
|
426
|
+
const spec = serviceSpecs().find((candidate) => candidate.label === step.label);
|
|
427
|
+
if (spec === undefined) throw new Error(`unknown service label: ${step.label}`);
|
|
428
|
+
const leaseName = SERVICE_LEASES[spec.id];
|
|
429
|
+
const binary = join(binDir, spec.binary);
|
|
430
|
+
const binaryModifiedAt = leaseName === undefined ? undefined : await modifiedAt(binary);
|
|
303
431
|
const deadline = now() + timeoutMs;
|
|
304
432
|
for (;;) {
|
|
305
433
|
const running = await probe(step.label);
|
|
306
|
-
|
|
307
|
-
if (running !== undefined)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
step.
|
|
311
|
-
step.
|
|
312
|
-
step.
|
|
313
|
-
|
|
434
|
+
const owner = leaseName === undefined ? undefined : await readLeaseOwner(options.home, leaseName);
|
|
435
|
+
if (running !== undefined) step.pid = running.pid;
|
|
436
|
+
|
|
437
|
+
if (leaseName !== undefined) {
|
|
438
|
+
step.binary = binary;
|
|
439
|
+
step.binaryModifiedAt = new Date(binaryModifiedAt as number).toISOString();
|
|
440
|
+
if (owner !== undefined) step.processStartedAt = owner.startedAt;
|
|
441
|
+
const startedAt = owner === undefined ? Number.NaN : Date.parse(owner.startedAt);
|
|
442
|
+
if (
|
|
443
|
+
running !== undefined &&
|
|
444
|
+
owner !== undefined &&
|
|
445
|
+
running.pid === owner.pid &&
|
|
446
|
+
Number.isFinite(startedAt) &&
|
|
447
|
+
startedAt >= Math.max(since, binaryModifiedAt as number)
|
|
448
|
+
) {
|
|
314
449
|
step.result = "ok";
|
|
315
450
|
delete step.detail;
|
|
316
451
|
return;
|
|
317
452
|
}
|
|
453
|
+
} else if (running !== undefined && running.pid !== originalPids.get(step.label)) {
|
|
454
|
+
step.result = "ok";
|
|
455
|
+
delete step.detail;
|
|
456
|
+
return;
|
|
318
457
|
}
|
|
458
|
+
|
|
319
459
|
if (now() >= deadline) {
|
|
320
460
|
if (running === undefined) {
|
|
321
461
|
step.result = "failed";
|
|
322
462
|
step.detail = "no running process after restart";
|
|
463
|
+
} else if (leaseName === undefined) {
|
|
464
|
+
step.result = "stale";
|
|
465
|
+
step.detail = "MainPID did not change after restart";
|
|
466
|
+
} else if (owner === undefined) {
|
|
467
|
+
step.result = "stale";
|
|
468
|
+
step.detail = "no lease owner after restart";
|
|
469
|
+
} else if (running.pid !== owner.pid) {
|
|
470
|
+
step.result = "stale";
|
|
471
|
+
step.detail = "lease owner PID does not match service MainPID";
|
|
472
|
+
} else if (Date.parse(owner.startedAt) < (binaryModifiedAt as number)) {
|
|
473
|
+
step.result = "stale";
|
|
474
|
+
step.detail = "process started before the deployed binary was modified";
|
|
323
475
|
} else {
|
|
324
476
|
step.result = "stale";
|
|
325
|
-
step.detail =
|
|
326
|
-
running.startedAt < binaryModifiedAt
|
|
327
|
-
? "process started before the deployed binary was modified"
|
|
328
|
-
: "process was not restarted by this sequence";
|
|
477
|
+
step.detail = "lease owner predates this restart sequence";
|
|
329
478
|
}
|
|
330
479
|
return;
|
|
331
480
|
}
|
|
@@ -336,53 +485,110 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
336
485
|
|
|
337
486
|
export interface LaunchRestartOptions {
|
|
338
487
|
readonly home: string;
|
|
488
|
+
readonly trigger?: "update" | "manual";
|
|
339
489
|
readonly platform?: ServicePlatform;
|
|
340
490
|
readonly uid?: number;
|
|
491
|
+
/** Installed service binary directory used for lease verification. */
|
|
492
|
+
readonly binDir?: string;
|
|
341
493
|
/** Argv that re-enters the CLI; `--run <id>` is appended. */
|
|
342
494
|
readonly worker?: readonly string[];
|
|
343
495
|
readonly env?: NodeJS.ProcessEnv;
|
|
496
|
+
/** Starts systemd-run; injectable for launcher-result handling. */
|
|
497
|
+
readonly systemdRun?: (command: readonly string[]) => Bun.Subprocess;
|
|
498
|
+
/** Maximum time to wait for systemd-run to acknowledge the worker exec. */
|
|
499
|
+
readonly systemdRunTimeoutMs?: number;
|
|
344
500
|
}
|
|
345
501
|
|
|
502
|
+
const SYSTEMD_RUN_TIMEOUT_MS = 30_000;
|
|
503
|
+
|
|
346
504
|
/** The argv that runs this CLI again, whether compiled or run from source. */
|
|
347
505
|
export function cliCommand(): string[] {
|
|
348
506
|
return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
|
|
349
507
|
}
|
|
350
508
|
|
|
351
|
-
function alive(pid: number | undefined): boolean {
|
|
352
|
-
if (pid === undefined) return false;
|
|
353
|
-
try {
|
|
354
|
-
process.kill(pid, 0);
|
|
355
|
-
return true;
|
|
356
|
-
} catch {
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
509
|
/**
|
|
362
|
-
* Queues a restart and hands it to a detached supervisor, returning
|
|
363
|
-
*
|
|
510
|
+
* Queues a restart and hands it to a detached supervisor, returning once the
|
|
511
|
+
* platform launcher acknowledges it so the caller may be killed by the restart.
|
|
364
512
|
*/
|
|
365
513
|
export async function launchRestartStack(
|
|
366
514
|
options: LaunchRestartOptions,
|
|
367
515
|
): Promise<{ readonly receipt: RestartReceipt; readonly supervisorPid: number }> {
|
|
368
516
|
const platform = options.platform ?? currentPlatform();
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
|
|
517
|
+
if (await leaseHeld(options.home, "update"))
|
|
518
|
+
throw new LeaseHeldError("update", await readLeaseOwner(options.home, "update"));
|
|
519
|
+
const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now(), options.trigger ?? "manual");
|
|
373
520
|
await writeReceipt(options.home, receipt);
|
|
374
521
|
const worker = [...(options.worker ?? [...cliCommand(), "ops", "restart-stack"]), "--run", receipt.id];
|
|
375
|
-
const
|
|
522
|
+
const argv = supervisorArgv(platform, worker, options.home, receipt.id, options.binDir);
|
|
523
|
+
const spawnOptions = {
|
|
376
524
|
detached: true,
|
|
377
|
-
stdin: "ignore",
|
|
378
|
-
stdout: "ignore",
|
|
379
|
-
stderr: "ignore",
|
|
380
|
-
env: {
|
|
381
|
-
|
|
525
|
+
stdin: "ignore" as const,
|
|
526
|
+
stdout: "ignore" as const,
|
|
527
|
+
stderr: "ignore" as const,
|
|
528
|
+
env: {
|
|
529
|
+
...(options.env ?? process.env),
|
|
530
|
+
GAJAEWAY_HOME: options.home,
|
|
531
|
+
...(options.binDir === undefined ? {} : { GAJAEWAY_BIN_DIR: options.binDir }),
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
let child: Bun.Subprocess;
|
|
535
|
+
try {
|
|
536
|
+
child =
|
|
537
|
+
platform === "linux"
|
|
538
|
+
? (options.systemdRun ?? ((command) => Bun.spawn([...command], spawnOptions)))(argv)
|
|
539
|
+
: Bun.spawn(argv, spawnOptions);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
const detail =
|
|
542
|
+
platform === "linux"
|
|
543
|
+
? `supervisor systemd-run could not be started (${error instanceof Error ? error.message : String(error)}); without a service manager here, restart through the host's own supervisor`
|
|
544
|
+
: `supervisor ${argv[0]} could not be started (${error instanceof Error ? error.message : String(error)})`;
|
|
545
|
+
await failLaunch(options.home, receipt, detail);
|
|
546
|
+
throw new Error(`restart-stack ${receipt.id}: failed: ${detail}`);
|
|
547
|
+
}
|
|
548
|
+
if (platform === "linux") {
|
|
549
|
+
const outcome = await waitForLauncher(child, options.systemdRunTimeoutMs ?? SYSTEMD_RUN_TIMEOUT_MS);
|
|
550
|
+
if (outcome.kind === "timed_out") {
|
|
551
|
+
const detail = `supervisor systemd-run timed out after ${options.systemdRunTimeoutMs ?? SYSTEMD_RUN_TIMEOUT_MS}ms; handoff outcome is indeterminate`;
|
|
552
|
+
await failLaunch(options.home, receipt, detail);
|
|
553
|
+
throw new Error(`restart-stack ${receipt.id}: failed: ${detail}`);
|
|
554
|
+
}
|
|
555
|
+
if (outcome.exitStatus !== 0) {
|
|
556
|
+
const detail = `supervisor systemd-run exited with status ${outcome.exitStatus}`;
|
|
557
|
+
await failLaunch(options.home, receipt, detail);
|
|
558
|
+
throw new Error(`restart-stack ${receipt.id}: failed: ${detail}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
382
561
|
child.unref();
|
|
383
562
|
return { receipt, supervisorPid: child.pid };
|
|
384
563
|
}
|
|
385
564
|
|
|
565
|
+
async function failLaunch(home: string, receipt: RestartReceipt, detail: string): Promise<void> {
|
|
566
|
+
receipt.state = "failed";
|
|
567
|
+
receipt.finishedAt = new Date().toISOString();
|
|
568
|
+
receipt.detail = detail;
|
|
569
|
+
for (const step of receipt.steps) step.result = "skipped";
|
|
570
|
+
await writeReceipt(home, receipt);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function waitForLauncher(
|
|
574
|
+
child: Bun.Subprocess,
|
|
575
|
+
timeoutMs: number,
|
|
576
|
+
): Promise<{ readonly kind: "exited"; readonly exitStatus: number } | { readonly kind: "timed_out" }> {
|
|
577
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
578
|
+
const outcome = await Promise.race([
|
|
579
|
+
child.exited.then((exitStatus) => ({ kind: "exited" as const, exitStatus })),
|
|
580
|
+
new Promise<{ readonly kind: "timed_out" }>((resolve) => {
|
|
581
|
+
timer = setTimeout(() => resolve({ kind: "timed_out" }), timeoutMs);
|
|
582
|
+
}),
|
|
583
|
+
]);
|
|
584
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
585
|
+
if (outcome.kind === "timed_out") {
|
|
586
|
+
child.kill("SIGKILL");
|
|
587
|
+
void child.exited.catch(() => undefined);
|
|
588
|
+
}
|
|
589
|
+
return outcome;
|
|
590
|
+
}
|
|
591
|
+
|
|
386
592
|
/**
|
|
387
593
|
* A new session escapes the caller's process group, which is enough under
|
|
388
594
|
* launchd. Under systemd the caller usually lives in a service cgroup that the
|
|
@@ -394,6 +600,7 @@ export function supervisorArgv(
|
|
|
394
600
|
worker: readonly string[],
|
|
395
601
|
home: string,
|
|
396
602
|
id: string,
|
|
603
|
+
binDir?: string,
|
|
397
604
|
): string[] {
|
|
398
605
|
if (platform === "darwin") return [...worker];
|
|
399
606
|
return [
|
|
@@ -401,8 +608,10 @@ export function supervisorArgv(
|
|
|
401
608
|
"--user",
|
|
402
609
|
"--collect",
|
|
403
610
|
"--quiet",
|
|
611
|
+
"--service-type=exec",
|
|
404
612
|
`--unit=gajaeway-restart-stack-${id}`,
|
|
405
613
|
`--setenv=GAJAEWAY_HOME=${home}`,
|
|
614
|
+
...(binDir === undefined ? [] : [`--setenv=GAJAEWAY_BIN_DIR=${binDir}`]),
|
|
406
615
|
"--",
|
|
407
616
|
...worker,
|
|
408
617
|
];
|
|
@@ -412,22 +621,30 @@ export function supervisorArgv(
|
|
|
412
621
|
* The receipt's state, except that an unfinished sequence whose supervisor is
|
|
413
622
|
* gone is reported as interrupted rather than left looking in progress.
|
|
414
623
|
*/
|
|
415
|
-
export function effectiveRestartState(
|
|
416
|
-
|
|
624
|
+
export async function effectiveRestartState(
|
|
625
|
+
receipt: RestartReceipt,
|
|
626
|
+
home: string,
|
|
627
|
+
): Promise<RestartState | "interrupted"> {
|
|
628
|
+
if (receipt.state === "running" && !(await leaseHeld(home, "update"))) return "interrupted";
|
|
417
629
|
return receipt.state;
|
|
418
630
|
}
|
|
419
631
|
|
|
420
|
-
export function renderRestartReceipt(
|
|
632
|
+
export function renderRestartReceipt(
|
|
633
|
+
receipt: RestartReceipt,
|
|
634
|
+
state: RestartState | "interrupted" = receipt.state,
|
|
635
|
+
): string[] {
|
|
421
636
|
const lines = [
|
|
422
|
-
`restart-stack ${receipt.id}: ${
|
|
637
|
+
`restart-stack ${receipt.id}: ${state}`,
|
|
423
638
|
`requestedAt: ${receipt.requestedAt}`,
|
|
424
639
|
`finishedAt: ${receipt.finishedAt ?? "-"}`,
|
|
640
|
+
...(receipt.detail === undefined ? [] : [`detail: ${receipt.detail}`]),
|
|
425
641
|
];
|
|
426
642
|
for (const step of receipt.steps) {
|
|
427
643
|
const facts = [
|
|
428
644
|
step.pid === undefined ? undefined : `pid=${step.pid}`,
|
|
429
645
|
step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
|
|
430
646
|
step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
|
|
647
|
+
step.agentSync?.error === undefined ? undefined : `agentSync.error=${step.agentSync.error}`,
|
|
431
648
|
step.detail,
|
|
432
649
|
].filter((fact) => fact !== undefined);
|
|
433
650
|
lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);
|