@gajae-gateway/cli 0.1.8 → 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.
@@ -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
- /** Absent on systemd, where the gateway unit's restart carries its dependents. */
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
- /** A running service process as the service manager and `ps` report it. */
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
- /** systemd unit directory on linux. Defaults to $XDG_CONFIG_HOME/systemd/user. */
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(id: string, platform: ServicePlatform, uid: number | undefined, now: number): RestartReceipt {
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" ? { command: commands[index] } : index === 0 ? { command: commands[0] } : {}),
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 live pid from the service manager, then its start time and executable from `ps`. */
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
- let pid: number | undefined;
155
- if (platform === "darwin") {
156
- const listed = await capture(["launchctl", "list", label]);
157
- const match = listed?.match(/"PID"\s*=\s*(\d+);/);
158
- pid = match ? Number(match[1]) : undefined;
159
- } else {
160
- const shown = await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
161
- pid = shown === undefined ? undefined : Number(shown.trim());
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
- * `ps -o lstart` is whole seconds, and on Linux it is derived from the boot
220
- * time plus clock ticks, so it can read up to one second EARLY. Measured on
221
- * gaebal-gajae 2026-09-30: systemd logged `Started` at 10:55:50.170 while
222
- * `ps` reported 10:55:49, and the freshly restarted gateway was judged stale,
223
- * aborting the rest of the stack. The bound is floored and allowed that second.
224
- */
225
- function psStartBound(ms: number): number {
226
- return Math.floor(ms / 1_000) * 1_000 - PS_START_SKEW_MS;
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
- // Check if the service is installed before trying to restart it
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.command !== undefined) {
279
- issuedAt = now();
280
- const status = await runner(step.command);
281
- step.exitStatus = status;
282
- if (status !== 0) {
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 = `${step.command.join(" ")} exited with status ${status}`;
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
- let binaryModifiedAt = 0;
307
- if (running !== undefined) {
308
- binaryModifiedAt = await modifiedAt(running.binary);
309
- step.pid = running.pid;
310
- step.processStartedAt = new Date(running.startedAt).toISOString();
311
- step.binary = running.binary;
312
- step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
313
- if (running.startedAt >= psStartBound(Math.max(binaryModifiedAt, since))) {
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
- const existing = await readRestartReceipt(options.home);
370
- if (existing?.state === "running" && alive(existing.supervisorPid))
371
- throw new Error(`restart-stack ${existing.id} is still running (supervisor pid ${existing.supervisorPid})`);
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 child = Bun.spawn(supervisorArgv(platform, worker, options.home, receipt.id), {
376
- detached: true,
377
- stdin: "ignore",
378
- stdout: "ignore",
379
- stderr: "ignore",
380
- env: { ...(options.env ?? process.env), GAJAEWAY_HOME: options.home },
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(receipt: RestartReceipt): RestartState | "interrupted" {
416
- if (receipt.state === "running" && !alive(receipt.supervisorPid)) return "interrupted";
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(receipt: RestartReceipt): string[] {
583
+ export function renderRestartReceipt(
584
+ receipt: RestartReceipt,
585
+ state: RestartState | "interrupted" = receipt.state,
586
+ ): string[] {
421
587
  const lines = [
422
- `restart-stack ${receipt.id}: ${effectiveRestartState(receipt)}`,
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(" ")}` : ""}`);