@gajae-gateway/cli 0.1.7 → 0.2.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/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 +6 -32
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +1195 -580
- package/dist/migrate/hermes.d.ts.map +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/restart-stack.d.ts +21 -11
- 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 +178 -142
- package/src/migrate/hermes.ts +0 -2
- package/src/migrate.ts +36 -4
- package/src/restart-stack.ts +258 -90
- package/src/services.ts +75 -23
- package/src/setup.ts +85 -10
- package/src/up.ts +133 -0
- package/src/update.ts +170 -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,19 @@ 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);
|
|
254
307
|
const receipt =
|
|
255
308
|
existing?.id === options.id && existing.state === "queued"
|
|
256
309
|
? existing
|
|
257
|
-
: newReceipt(options.id, platform, options.uid, now());
|
|
310
|
+
: newReceipt(options.id, platform, options.uid, now(), options.trigger ?? "manual");
|
|
258
311
|
receipt.state = "running";
|
|
259
312
|
receipt.supervisorPid = process.pid;
|
|
260
313
|
await writeReceipt(options.home, receipt);
|
|
@@ -266,7 +319,15 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
266
319
|
continue;
|
|
267
320
|
}
|
|
268
321
|
try {
|
|
269
|
-
|
|
322
|
+
if (
|
|
323
|
+
step.label === "dev.gajaeway.broker" &&
|
|
324
|
+
!(await Bun.file(homeLayout(options.home).brokerProvisionManifest).exists())
|
|
325
|
+
) {
|
|
326
|
+
step.result = "skipped";
|
|
327
|
+
step.detail = "broker agent is not provisioned";
|
|
328
|
+
await writeReceipt(options.home, receipt);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
270
331
|
const installed = await isInstalled(step.label);
|
|
271
332
|
if (!installed) {
|
|
272
333
|
step.result = "skipped";
|
|
@@ -275,16 +336,76 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
275
336
|
continue;
|
|
276
337
|
}
|
|
277
338
|
|
|
278
|
-
if (step.
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
step.
|
|
282
|
-
|
|
339
|
+
if (step.label === "dev.gajaeway.broker" && receipt.trigger === "update") {
|
|
340
|
+
const stopCommand = serviceStopCommand(platform, options.uid);
|
|
341
|
+
const stopStatus = await runner(stopCommand);
|
|
342
|
+
if (stopStatus !== 0 && !(platform === "darwin" && (await probe(step.label)) === undefined))
|
|
343
|
+
throw new Error(`${stopCommand.join(" ")} exited with status ${stopStatus}`);
|
|
344
|
+
|
|
345
|
+
const provisionCommand = [
|
|
346
|
+
join(binDir, "gajaeway-gateway"),
|
|
347
|
+
"broker-agent",
|
|
348
|
+
"provision",
|
|
349
|
+
"--mode",
|
|
350
|
+
"update",
|
|
351
|
+
"--lease-wait-ms",
|
|
352
|
+
"35000",
|
|
353
|
+
];
|
|
354
|
+
const provisionStatus = await runner(provisionCommand);
|
|
355
|
+
step.agentSync = {
|
|
356
|
+
command: provisionCommand,
|
|
357
|
+
exitStatus: provisionStatus,
|
|
358
|
+
...(provisionStatus === 1 || provisionStatus === 3
|
|
359
|
+
? { error: `broker-agent provision exited with status ${provisionStatus}` }
|
|
360
|
+
: provisionStatus === 4
|
|
361
|
+
? { error: "broker_agent_unrecoverable" }
|
|
362
|
+
: {}),
|
|
363
|
+
};
|
|
364
|
+
if (provisionStatus === 4) {
|
|
365
|
+
delete step.command;
|
|
366
|
+
step.exitStatus = 4;
|
|
283
367
|
step.result = "failed";
|
|
284
|
-
step.detail =
|
|
368
|
+
step.detail = "broker left stopped: broker_agent_unrecoverable; run gajaeway setup --broker-agent";
|
|
369
|
+
} else if (provisionStatus === 0 || provisionStatus === 1 || provisionStatus === 3) {
|
|
370
|
+
const startCommand = serviceBootstrapCommand(options, platform, step.label);
|
|
371
|
+
step.command = startCommand;
|
|
372
|
+
issuedAt = now();
|
|
373
|
+
const startStatus = await runner(startCommand);
|
|
374
|
+
step.exitStatus = startStatus;
|
|
375
|
+
if (startStatus !== 0) {
|
|
376
|
+
step.result = "failed";
|
|
377
|
+
step.detail = `${startCommand.join(" ")} exited with status ${startStatus}`;
|
|
378
|
+
} else await verifyStep(step, issuedAt);
|
|
379
|
+
} else {
|
|
380
|
+
delete step.command;
|
|
381
|
+
step.exitStatus = provisionStatus;
|
|
382
|
+
step.result = "failed";
|
|
383
|
+
step.detail = `broker-agent provision exited with unexpected status ${provisionStatus}`;
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
if (step.command !== undefined) {
|
|
387
|
+
issuedAt = now();
|
|
388
|
+
let command = step.command;
|
|
389
|
+
let status = await runner(command);
|
|
390
|
+
if (
|
|
391
|
+
status !== 0 &&
|
|
392
|
+
receipt.trigger === "update" &&
|
|
393
|
+
platform === "darwin" &&
|
|
394
|
+
(await probe(step.label)) === undefined
|
|
395
|
+
) {
|
|
396
|
+
command = serviceBootstrapCommand(options, platform, step.label);
|
|
397
|
+
step.command = command;
|
|
398
|
+
issuedAt = now();
|
|
399
|
+
status = await runner(command);
|
|
400
|
+
}
|
|
401
|
+
step.exitStatus = status;
|
|
402
|
+
if (status !== 0) {
|
|
403
|
+
step.result = "failed";
|
|
404
|
+
step.detail = `${command.join(" ")} exited with status ${status}`;
|
|
405
|
+
}
|
|
285
406
|
}
|
|
407
|
+
if (step.result === "pending") await verifyStep(step, issuedAt);
|
|
286
408
|
}
|
|
287
|
-
if (step.result === "pending") await verifyStep(step, issuedAt);
|
|
288
409
|
} catch (error) {
|
|
289
410
|
step.result = "failed";
|
|
290
411
|
step.detail = error instanceof Error ? error.message : String(error);
|
|
@@ -300,32 +421,58 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
300
421
|
return receipt;
|
|
301
422
|
|
|
302
423
|
async function verifyStep(step: RestartStep, since: number): Promise<void> {
|
|
424
|
+
const spec = serviceSpecs().find((candidate) => candidate.label === step.label);
|
|
425
|
+
if (spec === undefined) throw new Error(`unknown service label: ${step.label}`);
|
|
426
|
+
const leaseName = SERVICE_LEASES[spec.id];
|
|
427
|
+
const binary = join(binDir, spec.binary);
|
|
428
|
+
const binaryModifiedAt = leaseName === undefined ? undefined : await modifiedAt(binary);
|
|
303
429
|
const deadline = now() + timeoutMs;
|
|
304
430
|
for (;;) {
|
|
305
431
|
const running = await probe(step.label);
|
|
306
|
-
|
|
307
|
-
if (running !== undefined)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
step.
|
|
311
|
-
step.
|
|
312
|
-
step.
|
|
313
|
-
|
|
432
|
+
const owner = leaseName === undefined ? undefined : await readLeaseOwner(options.home, leaseName);
|
|
433
|
+
if (running !== undefined) step.pid = running.pid;
|
|
434
|
+
|
|
435
|
+
if (leaseName !== undefined) {
|
|
436
|
+
step.binary = binary;
|
|
437
|
+
step.binaryModifiedAt = new Date(binaryModifiedAt as number).toISOString();
|
|
438
|
+
if (owner !== undefined) step.processStartedAt = owner.startedAt;
|
|
439
|
+
const startedAt = owner === undefined ? Number.NaN : Date.parse(owner.startedAt);
|
|
440
|
+
if (
|
|
441
|
+
running !== undefined &&
|
|
442
|
+
owner !== undefined &&
|
|
443
|
+
running.pid === owner.pid &&
|
|
444
|
+
Number.isFinite(startedAt) &&
|
|
445
|
+
startedAt >= Math.max(since, binaryModifiedAt as number)
|
|
446
|
+
) {
|
|
314
447
|
step.result = "ok";
|
|
315
448
|
delete step.detail;
|
|
316
449
|
return;
|
|
317
450
|
}
|
|
451
|
+
} else if (running !== undefined && running.pid !== originalPids.get(step.label)) {
|
|
452
|
+
step.result = "ok";
|
|
453
|
+
delete step.detail;
|
|
454
|
+
return;
|
|
318
455
|
}
|
|
456
|
+
|
|
319
457
|
if (now() >= deadline) {
|
|
320
458
|
if (running === undefined) {
|
|
321
459
|
step.result = "failed";
|
|
322
460
|
step.detail = "no running process after restart";
|
|
461
|
+
} else if (leaseName === undefined) {
|
|
462
|
+
step.result = "stale";
|
|
463
|
+
step.detail = "MainPID did not change after restart";
|
|
464
|
+
} else if (owner === undefined) {
|
|
465
|
+
step.result = "stale";
|
|
466
|
+
step.detail = "no lease owner after restart";
|
|
467
|
+
} else if (running.pid !== owner.pid) {
|
|
468
|
+
step.result = "stale";
|
|
469
|
+
step.detail = "lease owner PID does not match service MainPID";
|
|
470
|
+
} else if (Date.parse(owner.startedAt) < (binaryModifiedAt as number)) {
|
|
471
|
+
step.result = "stale";
|
|
472
|
+
step.detail = "process started before the deployed binary was modified";
|
|
323
473
|
} else {
|
|
324
474
|
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";
|
|
475
|
+
step.detail = "lease owner predates this restart sequence";
|
|
329
476
|
}
|
|
330
477
|
return;
|
|
331
478
|
}
|
|
@@ -336,8 +483,11 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
336
483
|
|
|
337
484
|
export interface LaunchRestartOptions {
|
|
338
485
|
readonly home: string;
|
|
486
|
+
readonly trigger?: "update" | "manual";
|
|
339
487
|
readonly platform?: ServicePlatform;
|
|
340
488
|
readonly uid?: number;
|
|
489
|
+
/** Installed service binary directory used for lease verification. */
|
|
490
|
+
readonly binDir?: string;
|
|
341
491
|
/** Argv that re-enters the CLI; `--run <id>` is appended. */
|
|
342
492
|
readonly worker?: readonly string[];
|
|
343
493
|
readonly env?: NodeJS.ProcessEnv;
|
|
@@ -348,16 +498,6 @@ export function cliCommand(): string[] {
|
|
|
348
498
|
return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
|
|
349
499
|
}
|
|
350
500
|
|
|
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
501
|
/**
|
|
362
502
|
* Queues a restart and hands it to a detached supervisor, returning as soon as
|
|
363
503
|
* the supervisor is spawned so the caller may be killed by the restart itself.
|
|
@@ -366,19 +506,37 @@ export async function launchRestartStack(
|
|
|
366
506
|
options: LaunchRestartOptions,
|
|
367
507
|
): Promise<{ readonly receipt: RestartReceipt; readonly supervisorPid: number }> {
|
|
368
508
|
const platform = options.platform ?? currentPlatform();
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
|
|
509
|
+
if (await leaseHeld(options.home, "update"))
|
|
510
|
+
throw new LeaseHeldError("update", await readLeaseOwner(options.home, "update"));
|
|
511
|
+
const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now(), options.trigger ?? "manual");
|
|
373
512
|
await writeReceipt(options.home, receipt);
|
|
374
513
|
const worker = [...(options.worker ?? [...cliCommand(), "ops", "restart-stack"]), "--run", receipt.id];
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
514
|
+
const argv = supervisorArgv(platform, worker, options.home, receipt.id, options.binDir);
|
|
515
|
+
let child: Bun.Subprocess;
|
|
516
|
+
try {
|
|
517
|
+
child = Bun.spawn(argv, {
|
|
518
|
+
detached: true,
|
|
519
|
+
stdin: "ignore",
|
|
520
|
+
stdout: "ignore",
|
|
521
|
+
stderr: "ignore",
|
|
522
|
+
env: {
|
|
523
|
+
...(options.env ?? process.env),
|
|
524
|
+
GAJAEWAY_HOME: options.home,
|
|
525
|
+
...(options.binDir === undefined ? {} : { GAJAEWAY_BIN_DIR: options.binDir }),
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
} catch (error) {
|
|
529
|
+
// Nothing will ever pick this receipt up: leaving it `queued` reads as a
|
|
530
|
+
// restart still on its way. A host without a systemd user manager (a
|
|
531
|
+
// container) has no `systemd-run`, and its restart belongs to whatever
|
|
532
|
+
// supervises that host.
|
|
533
|
+
receipt.state = "failed";
|
|
534
|
+
receipt.finishedAt = new Date().toISOString();
|
|
535
|
+
receipt.detail = `supervisor ${argv[0]} could not be started (${error instanceof Error ? error.message : String(error)}); without a service manager here, restart through the host's own supervisor`;
|
|
536
|
+
for (const step of receipt.steps) step.result = "skipped";
|
|
537
|
+
await writeReceipt(options.home, receipt);
|
|
538
|
+
throw new Error(`restart-stack ${receipt.id}: failed: ${receipt.detail}`);
|
|
539
|
+
}
|
|
382
540
|
child.unref();
|
|
383
541
|
return { receipt, supervisorPid: child.pid };
|
|
384
542
|
}
|
|
@@ -394,6 +552,7 @@ export function supervisorArgv(
|
|
|
394
552
|
worker: readonly string[],
|
|
395
553
|
home: string,
|
|
396
554
|
id: string,
|
|
555
|
+
binDir?: string,
|
|
397
556
|
): string[] {
|
|
398
557
|
if (platform === "darwin") return [...worker];
|
|
399
558
|
return [
|
|
@@ -403,6 +562,7 @@ export function supervisorArgv(
|
|
|
403
562
|
"--quiet",
|
|
404
563
|
`--unit=gajaeway-restart-stack-${id}`,
|
|
405
564
|
`--setenv=GAJAEWAY_HOME=${home}`,
|
|
565
|
+
...(binDir === undefined ? [] : [`--setenv=GAJAEWAY_BIN_DIR=${binDir}`]),
|
|
406
566
|
"--",
|
|
407
567
|
...worker,
|
|
408
568
|
];
|
|
@@ -412,22 +572,30 @@ export function supervisorArgv(
|
|
|
412
572
|
* The receipt's state, except that an unfinished sequence whose supervisor is
|
|
413
573
|
* gone is reported as interrupted rather than left looking in progress.
|
|
414
574
|
*/
|
|
415
|
-
export function effectiveRestartState(
|
|
416
|
-
|
|
575
|
+
export async function effectiveRestartState(
|
|
576
|
+
receipt: RestartReceipt,
|
|
577
|
+
home: string,
|
|
578
|
+
): Promise<RestartState | "interrupted"> {
|
|
579
|
+
if (receipt.state === "running" && !(await leaseHeld(home, "update"))) return "interrupted";
|
|
417
580
|
return receipt.state;
|
|
418
581
|
}
|
|
419
582
|
|
|
420
|
-
export function renderRestartReceipt(
|
|
583
|
+
export function renderRestartReceipt(
|
|
584
|
+
receipt: RestartReceipt,
|
|
585
|
+
state: RestartState | "interrupted" = receipt.state,
|
|
586
|
+
): string[] {
|
|
421
587
|
const lines = [
|
|
422
|
-
`restart-stack ${receipt.id}: ${
|
|
588
|
+
`restart-stack ${receipt.id}: ${state}`,
|
|
423
589
|
`requestedAt: ${receipt.requestedAt}`,
|
|
424
590
|
`finishedAt: ${receipt.finishedAt ?? "-"}`,
|
|
591
|
+
...(receipt.detail === undefined ? [] : [`detail: ${receipt.detail}`]),
|
|
425
592
|
];
|
|
426
593
|
for (const step of receipt.steps) {
|
|
427
594
|
const facts = [
|
|
428
595
|
step.pid === undefined ? undefined : `pid=${step.pid}`,
|
|
429
596
|
step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
|
|
430
597
|
step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
|
|
598
|
+
step.agentSync?.error === undefined ? undefined : `agentSync.error=${step.agentSync.error}`,
|
|
431
599
|
step.detail,
|
|
432
600
|
].filter((fact) => fact !== undefined);
|
|
433
601
|
lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);
|