@celilo/cli 0.18.0 → 0.20.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.
Files changed (34) hide show
  1. package/CELILO_SUBSYSTEMS.md +4 -2
  2. package/package.json +4 -4
  3. package/src/api/remote-client.test.ts +86 -2
  4. package/src/api/serve.ts +242 -38
  5. package/src/api/sessions.test.ts +196 -0
  6. package/src/api/sessions.ts +278 -0
  7. package/src/cli/commands/apt-upgrade.test.ts +20 -1
  8. package/src/cli/commands/apt-upgrade.ts +12 -2
  9. package/src/cli/commands/backup-sweep.ts +25 -9
  10. package/src/cli/commands/events.ts +150 -4
  11. package/src/cli/commands/module-update.test.ts +72 -1
  12. package/src/cli/commands/module-update.ts +68 -22
  13. package/src/cli/commands/system-migrate.test.ts +56 -0
  14. package/src/cli/commands/system-migrate.ts +52 -4
  15. package/src/cli/completion.ts +2 -0
  16. package/src/cli/index.ts +27 -3
  17. package/src/db/migration-status.test.ts +114 -0
  18. package/src/db/migration-status.ts +78 -0
  19. package/src/db/schema-introspection.ts +8 -1
  20. package/src/services/backup-metadata.ts +17 -0
  21. package/src/services/backup-staging.test.ts +98 -0
  22. package/src/services/backup-staging.ts +73 -1
  23. package/src/services/backup-sweep.test.ts +15 -0
  24. package/src/services/backup-sweep.ts +17 -1
  25. package/src/services/bus-interview-park.test.ts +179 -0
  26. package/src/services/bus-interview.ts +17 -6
  27. package/src/services/events-daemon.test.ts +244 -0
  28. package/src/services/events-daemon.ts +295 -8
  29. package/src/services/fleet-checks.test.ts +75 -4
  30. package/src/services/fleet-checks.ts +82 -12
  31. package/src/services/interview-errors.ts +37 -0
  32. package/src/services/remote-responder.test.ts +83 -0
  33. package/src/services/remote-responder.ts +31 -10
  34. package/src/services/responder-probe.ts +3 -1
@@ -7,13 +7,16 @@
7
7
  *
8
8
  * No timeouts: the deploy waits indefinitely for a responder. If
9
9
  * nothing answers, the operator sees the unanswered query via
10
- * `celilo events list-pending` and fixes the responder setup.
10
+ * `celilo events list-unanswered` and answers it with `celilo events
11
+ * reply <id> <value>`. (NOT `events list-pending` — that reads the
12
+ * subscriber `deliveries` table and cannot see an unanswered query.)
11
13
  *
12
14
  * See `infra/openspec/changes/interactive-deploys-via-event-bus/proposal.md`.
13
15
  */
14
16
 
15
17
  import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
16
18
  import { getEventBusPath } from '../config/paths';
19
+ import { InterviewAbandonedError } from './interview-errors';
17
20
  import { ensureResponderForInterview } from './responder-probe';
18
21
 
19
22
  const NO_SCHEMAS = defineEvents({});
@@ -239,6 +242,16 @@ export interface InterviewRequiredPayload {
239
242
  */
240
243
  export interface InterviewReply {
241
244
  value: unknown;
245
+ /**
246
+ * Set instead of `value` when the question was reaped rather than decided —
247
+ * a parked session passed its TTL with nobody answering. `askInterview` turns
248
+ * it into an `InterviewAbandonedError`, which is distinct from both a decline
249
+ * (someone said no) and an unanswered question (still standing).
250
+ *
251
+ * A responder that merely *cannot* decide emits nothing at all: the query
252
+ * stays unanswered and the asking command stays parked (celilo#609).
253
+ */
254
+ abandoned?: { reason: string };
242
255
  }
243
256
 
244
257
  /**
@@ -314,11 +327,9 @@ export async function askInterview(
314
327
  payload: InterviewRequiredPayload,
315
328
  ownerBus?: Bus,
316
329
  ): Promise<unknown> {
317
- const reply = await busInterviewGuarded<InterviewReply>(
318
- EVENT_TYPES.interviewRequired(payload.scope, payload.key),
319
- payload,
320
- ownerBus,
321
- );
330
+ const type = EVENT_TYPES.interviewRequired(payload.scope, payload.key);
331
+ const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus);
332
+ if (reply.abandoned) throw new InterviewAbandonedError(type, reply.abandoned.reason);
322
333
  return reply.value;
323
334
  }
324
335
 
@@ -5,11 +5,17 @@ import { join } from 'node:path';
5
5
  import {
6
6
  getDaemonUnitPath,
7
7
  installDaemon,
8
+ orphanDispatcherPids,
9
+ planDaemonInstall,
8
10
  readInstalledUnit,
9
11
  renderLaunchdPlist,
10
12
  renderSystemdUnit,
13
+ resolveRestartScope,
11
14
  resolveRunAsUser,
15
+ restartDaemon,
16
+ supervisorCommands,
12
17
  uninstallDaemon,
18
+ unitInstalledInAnyScope,
13
19
  } from './events-daemon';
14
20
 
15
21
  describe('renderSystemdUnit', () => {
@@ -166,6 +172,61 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
166
172
  expect(existsSync(installed.unitPath)).toBe(false);
167
173
  });
168
174
 
175
+ // #610 — install-daemon defaults to USER scope, so running it on a box whose
176
+ // system unit Ansible already installed silently produced a second daemon of
177
+ // the same name. That is how celilo-mgr ended up with two dispatchers.
178
+ it('refuses to install when the other scope already has a unit', () => {
179
+ const home = join(dir, 'home');
180
+ const systemRoot = join(dir, 'root');
181
+ installDaemon({
182
+ platform: 'linux',
183
+ scope: 'system',
184
+ home,
185
+ systemRoot,
186
+ celiloPath,
187
+ busDbPath: '/var/lib/celilo/events.db',
188
+ });
189
+
190
+ expect(() =>
191
+ installDaemon({
192
+ platform: 'linux',
193
+ scope: 'user',
194
+ home,
195
+ systemRoot,
196
+ celiloPath,
197
+ busDbPath: '/var/lib/celilo/events.db',
198
+ }),
199
+ ).toThrow(/system-scope unit is already installed/);
200
+ // Nothing written: refusing must not leave the second unit behind.
201
+ expect(existsSync(getDaemonUnitPath('linux', home, 'user'))).toBe(false);
202
+ });
203
+
204
+ // --print creates nothing, and the celilo-mgmt Ansible role captures it —
205
+ // throwing there would wedge the deploy of the tool used to fix the conflict.
206
+ it('reports the conflict from planDaemonInstall without throwing', () => {
207
+ const home = join(dir, 'home');
208
+ const systemRoot = join(dir, 'root');
209
+ installDaemon({
210
+ platform: 'linux',
211
+ scope: 'system',
212
+ home,
213
+ systemRoot,
214
+ celiloPath,
215
+ busDbPath: '/var/lib/celilo/events.db',
216
+ });
217
+
218
+ const plan = planDaemonInstall({
219
+ platform: 'linux',
220
+ scope: 'user',
221
+ home,
222
+ systemRoot,
223
+ celiloPath,
224
+ busDbPath: '/var/lib/celilo/events.db',
225
+ });
226
+ expect(plan.conflict?.scope).toBe('system');
227
+ expect(plan.unitContent).toContain('ExecStart=');
228
+ });
229
+
169
230
  it('writes a launchd plist and uninstall removes it', () => {
170
231
  const home = join(dir, 'home');
171
232
  const installed = installDaemon({
@@ -241,3 +302,186 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
241
302
  ).toThrow(/does not exist/);
242
303
  });
243
304
  });
305
+
306
+ // --- restart (celilo#604) ---------------------------------------------
307
+
308
+ /**
309
+ * A fake bus + supervisor. `restartUnit()` only produces a new dispatcher if no
310
+ * live one is left — that IS the one-dispatcher-per-bus guard (#584), and it's
311
+ * what makes the orphan case fatal rather than merely untidy.
312
+ */
313
+ function fakeFleet(opts: {
314
+ initial: Array<{ pid: number; version: string | null; supervised?: boolean }>;
315
+ newVersion: string;
316
+ }) {
317
+ let live = opts.initial.map((d) => ({ ...d }));
318
+ const supervised = opts.initial.find((d) => d.supervised);
319
+ const killed: number[] = [];
320
+ let nextPid = 9000;
321
+ let restarts = 0;
322
+ return {
323
+ killed,
324
+ restarts: () => restarts,
325
+ deps: {
326
+ liveDispatchers: () => live.map((d) => ({ pid: d.pid, version: d.version })),
327
+ supervisorPid: () => supervised?.pid ?? null,
328
+ kill: (pid: number) => {
329
+ killed.push(pid);
330
+ live = live.filter((d) => d.pid !== pid);
331
+ },
332
+ restartUnit: () => {
333
+ restarts++;
334
+ live = live.filter((d) => d.pid !== supervised?.pid);
335
+ if (live.length > 0) return; // guard refuses: another dispatcher is live
336
+ live = [{ pid: nextPid++, version: opts.newVersion }];
337
+ },
338
+ sleep: async () => {},
339
+ },
340
+ };
341
+ }
342
+
343
+ describe('orphanDispatcherPids', () => {
344
+ it('names every live dispatcher the supervisor does not own', () => {
345
+ expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], 200)).toEqual([100]);
346
+ });
347
+
348
+ it('treats all of them as orphans when the supervisor owns none', () => {
349
+ expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], null)).toEqual([100, 200]);
350
+ });
351
+ });
352
+
353
+ describe('restartDaemon', () => {
354
+ let dir: string;
355
+ let home: string;
356
+
357
+ beforeEach(() => {
358
+ dir = mkdtempSync(join(tmpdir(), 'celilo-restart-'));
359
+ home = join(dir, 'home');
360
+ installDaemon({ platform: 'linux', home, celiloPath: '/bin/sh', busDbPath: '/db' });
361
+ });
362
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
363
+
364
+ it('stops an orphan the supervisor does not own, then brings up new code', async () => {
365
+ // celilo-mgr exactly: PPID 1, stale v0.1.8, systemd owns nothing.
366
+ const fleet = fakeFleet({
367
+ initial: [{ pid: 3639051, version: '0.1.8' }],
368
+ newVersion: '0.2.0',
369
+ });
370
+
371
+ const result = await restartDaemon(
372
+ { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 },
373
+ fleet.deps,
374
+ );
375
+
376
+ expect(fleet.killed).toContain(3639051);
377
+ expect(result.orphansKilled).toEqual([3639051]);
378
+ expect(result.dispatcher.version).toBe('0.2.0');
379
+ expect(result.dispatcher.pid).not.toBe(3639051);
380
+ });
381
+
382
+ it('does not kill the dispatcher the supervisor already owns', async () => {
383
+ const fleet = fakeFleet({
384
+ initial: [{ pid: 4242, version: '0.1.8', supervised: true }],
385
+ newVersion: '0.2.0',
386
+ });
387
+
388
+ const result = await restartDaemon(
389
+ { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 },
390
+ fleet.deps,
391
+ );
392
+
393
+ expect(fleet.killed).toEqual([]);
394
+ expect(result.orphansKilled).toEqual([]);
395
+ expect(result.dispatcher.version).toBe('0.2.0');
396
+ });
397
+
398
+ it('fails when the restarted dispatcher still reports the OLD version', async () => {
399
+ // systemctl returned 0, a dispatcher is live — and it is the stale code.
400
+ // The whole point: never report success off the supervisor's exit code.
401
+ const fleet = fakeFleet({
402
+ initial: [{ pid: 3639051, version: '0.1.8' }],
403
+ newVersion: '0.1.8',
404
+ });
405
+
406
+ await expect(
407
+ restartDaemon(
408
+ { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 },
409
+ fleet.deps,
410
+ ),
411
+ ).rejects.toThrow(/no dispatcher on v0\.2\.0 came up/);
412
+ });
413
+
414
+ it('fails rather than reporting success when nothing comes back at all', async () => {
415
+ const fleet = fakeFleet({ initial: [], newVersion: '0.2.0' });
416
+ fleet.deps.restartUnit = () => {}; // unit crash-loops; bus stays empty
417
+
418
+ await expect(
419
+ restartDaemon(
420
+ { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 },
421
+ fleet.deps,
422
+ ),
423
+ ).rejects.toThrow(/no dispatcher is live on the bus/);
424
+ });
425
+
426
+ it('refuses when no supervisor unit is installed', () => {
427
+ expect(() => resolveRestartScope({ platform: 'linux', home: join(dir, 'empty') })).toThrow(
428
+ /no supervisor unit installed/,
429
+ );
430
+ });
431
+
432
+ // apt-upgrade runs restart-daemon on every box, including ones that never
433
+ // installed the daemon. The CLI branches on this so an upgrade with nothing
434
+ // stale to fix is not failed by a missing unit.
435
+ it('unitInstalledInAnyScope sees an installed unit, and its absence', () => {
436
+ expect(unitInstalledInAnyScope('linux', home)).toBe(true);
437
+ expect(unitInstalledInAnyScope('linux', join(dir, 'empty'))).toBe(false);
438
+ });
439
+ });
440
+
441
+ describe('supervisorCommands', () => {
442
+ it('uses systemctl --user for user scope', () => {
443
+ expect(supervisorCommands('linux', 'user').restart).toEqual([
444
+ 'systemctl',
445
+ '--user',
446
+ 'restart',
447
+ 'celilo-events.service',
448
+ ]);
449
+ });
450
+
451
+ // The system unit is root-owned and celilo is unprivileged. Drop the sudo
452
+ // and every apt-upgrade on celilo-mgr reports "dispatcher still on old
453
+ // code" — honest, and never able to do its job. celilo-bootstrap ships the
454
+ // scoped grant for exactly these two argvs, so they must match it verbatim.
455
+ it('goes through sudo for system scope, matching the shipped sudoers grant', () => {
456
+ expect(supervisorCommands('linux', 'system').restart).toEqual([
457
+ 'sudo',
458
+ 'systemctl',
459
+ 'restart',
460
+ 'celilo-events.service',
461
+ ]);
462
+ expect(supervisorCommands('linux', 'system').mainPid).toEqual([
463
+ 'sudo',
464
+ 'systemctl',
465
+ 'show',
466
+ 'celilo-events.service',
467
+ '-p',
468
+ 'MainPID',
469
+ '--value',
470
+ ]);
471
+ });
472
+
473
+ it('the shipped sudoers grant covers exactly the argvs used', () => {
474
+ const grant = readFileSync(
475
+ join(
476
+ import.meta.dir,
477
+ '../../../../packaging/celilo-bootstrap/conffiles/sudoers.d-celilo-events-restart',
478
+ ),
479
+ 'utf-8',
480
+ );
481
+ const cmds = supervisorCommands('linux', 'system');
482
+ for (const argv of [cmds.restart, cmds.mainPid as string[]]) {
483
+ // `sudo` itself is the invoker, not part of the granted command.
484
+ expect(grant).toContain(`/usr/bin/${argv.slice(1).join(' ')}`);
485
+ }
486
+ });
487
+ });
@@ -50,6 +50,8 @@ export interface InstallDaemonOptions {
50
50
  busDbPath?: string;
51
51
  /** Override the home directory used to compute install paths. */
52
52
  home?: string;
53
+ /** Prefix for system-scope paths. Test seam — see getDaemonUnitPath. */
54
+ systemRoot?: string;
53
55
  }
54
56
 
55
57
  export interface InstallDaemonResult {
@@ -61,9 +63,53 @@ export interface InstallDaemonResult {
61
63
  busDbPath: string;
62
64
  /** Explicit run-as user for system scope; undefined for user scope. */
63
65
  runAsUser?: string;
66
+ /**
67
+ * Set when the OTHER scope already has a unit installed. Both scopes use the
68
+ * same unit name, so installing over that produces two same-named daemons on
69
+ * one bus — see conflictingScopeError.
70
+ */
71
+ conflict?: { scope: SupervisorScope; unitPath: string };
64
72
  nextSteps: string[];
65
73
  }
66
74
 
75
+ export const SUPERVISOR_SCOPES: readonly SupervisorScope[] = ['user', 'system'];
76
+
77
+ /**
78
+ * Both scopes name the unit `celilo-events.service`, so `systemctl status
79
+ * celilo-events.service` and `systemctl --user status celilo-events.service`
80
+ * are different services that look identical in every operator-facing string.
81
+ *
82
+ * That is how celilo-mgr came to run two dispatchers for 40 days (#580, #610):
83
+ * Ansible installed the system unit, someone later ran `celilo events
84
+ * install-daemon` — which defaults to USER scope — and got a second daemon with
85
+ * no warning. `systemctl status celilo-events.service` then reported the dead
86
+ * system unit while the user-scope one served production.
87
+ */
88
+ function conflictingScopeError(conflict: { scope: SupervisorScope; unitPath: string }): Error {
89
+ const flag = conflict.scope === 'system' ? ' --system' : '';
90
+ return new Error(
91
+ [
92
+ `celilo events daemon: a ${conflict.scope}-scope unit is already installed at ${conflict.unitPath}.`,
93
+ 'Both scopes use the same unit name, so installing this one would create a SECOND dispatcher',
94
+ 'on the same bus that `systemctl status` cannot distinguish.',
95
+ `Remove the other first: \`celilo events uninstall-daemon${flag}\` (and disable it in systemd),`,
96
+ 'or keep the existing one.',
97
+ ].join(' '),
98
+ );
99
+ }
100
+
101
+ /** The other scope's unit, if it exists. */
102
+ function findConflictingScope(
103
+ platform: SupervisorPlatform,
104
+ home: string,
105
+ scope: SupervisorScope,
106
+ systemRoot?: string,
107
+ ): { scope: SupervisorScope; unitPath: string } | undefined {
108
+ const other = scope === 'user' ? 'system' : 'user';
109
+ const found = readInstalledUnit({ platform, scope: other, home, systemRoot });
110
+ return found.exists ? { scope: other, unitPath: found.path } : undefined;
111
+ }
112
+
67
113
  export interface UninstallDaemonResult {
68
114
  platform: SupervisorPlatform;
69
115
  scope: SupervisorScope;
@@ -90,20 +136,26 @@ export function detectPlatform(): SupervisorPlatform {
90
136
 
91
137
  /**
92
138
  * Resolve where to write the unit file for a given platform + scope.
93
- * `home` only matters for user scope; system paths are fixed.
139
+ * `home` only matters for user scope.
140
+ *
141
+ * `systemRoot` prefixes the system-scope path. It exists so a test can have
142
+ * BOTH scopes installed at once without writing to the real /etc — the
143
+ * two-units-one-name state that produced #610 is otherwise untestable, since
144
+ * `home` alone can only ever relocate the user unit.
94
145
  */
95
146
  export function getDaemonUnitPath(
96
147
  platform: SupervisorPlatform,
97
148
  home: string,
98
149
  scope: SupervisorScope = 'user',
150
+ systemRoot = '/',
99
151
  ): string {
100
152
  if (platform === 'linux') {
101
153
  return scope === 'system'
102
- ? join('/etc/systemd/system', SYSTEMD_UNIT_NAME)
154
+ ? join(systemRoot, 'etc/systemd/system', SYSTEMD_UNIT_NAME)
103
155
  : join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
104
156
  }
105
157
  return scope === 'system'
106
- ? join('/Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`)
158
+ ? join(systemRoot, 'Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`)
107
159
  : join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
108
160
  }
109
161
 
@@ -286,7 +338,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
286
338
  const concurrency = opts.concurrency ?? 4;
287
339
  const runAsUser = scope === 'system' ? resolveRunAsUser(busDbPath, opts.runAsUser) : undefined;
288
340
 
289
- const unitPath = getDaemonUnitPath(platform, home, scope);
341
+ const unitPath = getDaemonUnitPath(platform, home, scope, opts.systemRoot);
290
342
  const unitInputs: UnitInputs = {
291
343
  celiloPath,
292
344
  busDbPath,
@@ -307,6 +359,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
307
359
  celiloPath,
308
360
  busDbPath,
309
361
  runAsUser,
362
+ conflict: findConflictingScope(platform, home, scope, opts.systemRoot),
310
363
  nextSteps: nextStepsFor(platform, scope, unitPath),
311
364
  };
312
365
  }
@@ -320,18 +373,27 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
320
373
  */
321
374
  export function installDaemon(opts: InstallDaemonOptions = {}): InstallDaemonResult {
322
375
  const plan = planDaemonInstall(opts);
376
+ // Refuse on the WRITE path only. `--print` renders without creating anything,
377
+ // and the celilo-mgmt Ansible role captures it — throwing there would wedge
378
+ // the deploy of the very tool an operator needs to clean the conflict up.
379
+ if (plan.conflict) throw conflictingScopeError(plan.conflict);
323
380
  mkdirSync(dirname(plan.unitPath), { recursive: true });
324
381
  writeFileSync(plan.unitPath, plan.unitContent, { mode: 0o644 });
325
382
  return plan;
326
383
  }
327
384
 
328
385
  export function uninstallDaemon(
329
- opts: { platform?: SupervisorPlatform; scope?: SupervisorScope; home?: string } = {},
386
+ opts: {
387
+ platform?: SupervisorPlatform;
388
+ scope?: SupervisorScope;
389
+ home?: string;
390
+ systemRoot?: string;
391
+ } = {},
330
392
  ): UninstallDaemonResult {
331
393
  const platform = opts.platform ?? detectPlatform();
332
394
  const scope = opts.scope ?? 'user';
333
395
  const home = opts.home ?? homedir();
334
- const unitPath = getDaemonUnitPath(platform, home, scope);
396
+ const unitPath = getDaemonUnitPath(platform, home, scope, opts.systemRoot);
335
397
 
336
398
  let removed = false;
337
399
  if (existsSync(unitPath)) {
@@ -366,13 +428,238 @@ export function uninstallDaemon(
366
428
  return { platform, scope, unitPath, removed, nextSteps };
367
429
  }
368
430
 
431
+ // --- restart (celilo#604) ---------------------------------------------
432
+ //
433
+ // `install-daemon` deliberately never touches supervisor state, so the only
434
+ // supported restart was SSH + systemctl — which the "operate celilo through
435
+ // celilo" model forbids. Restart is the one supervisor verb that has to be a
436
+ // celilo command, because the upgrade path needs it.
437
+ //
438
+ // The trap: on celilo-mgr the running dispatcher was an ORPHAN (PPID 1, not the
439
+ // unit's child). `systemctl restart` could not kill it, the unit crash-looped
440
+ // against the one-dispatcher-per-bus guard (#584), and systemctl reported
441
+ // `activating` while the OLD code kept serving. So a restart that shells out and
442
+ // returns is worthless: it must reconcile the orphan and then verify the bus.
443
+
444
+ export interface RestartDaemonResult {
445
+ platform: SupervisorPlatform;
446
+ scope: SupervisorScope;
447
+ unitPath: string;
448
+ /** Live dispatchers stopped because the supervisor did not own them. */
449
+ orphansKilled: number[];
450
+ /** Version the restarted dispatcher must report (installed @celilo/event-bus). */
451
+ expectedVersion: string;
452
+ /** The dispatcher now serving the bus. */
453
+ dispatcher: { pid: number; version: string | null };
454
+ }
455
+
456
+ export interface RestartDaemonDeps {
457
+ /** Live dispatchers on the bus, by pid + the code version each reports. */
458
+ liveDispatchers: () => Array<{ pid: number; version: string | null }>;
459
+ /** The pid the supervisor owns for the unit, or null if it owns none. */
460
+ supervisorPid: () => number | null;
461
+ /** Stop a process the supervisor does not own. */
462
+ kill: (pid: number, signal: NodeJS.Signals) => void;
463
+ /** Ask the supervisor to (re)start the unit. Throws on failure. */
464
+ restartUnit: () => void;
465
+ sleep: (ms: number) => Promise<void>;
466
+ }
467
+
468
+ export interface RestartDaemonOptions {
469
+ platform?: SupervisorPlatform;
470
+ scope?: SupervisorScope;
471
+ home?: string;
472
+ /** Version the restarted dispatcher must report before we call it a success. */
473
+ expectedVersion: string;
474
+ /** How long to wait for the new dispatcher to appear on the bus. */
475
+ timeoutMs?: number;
476
+ /** Poll interval while waiting. */
477
+ pollMs?: number;
478
+ }
479
+
480
+ /**
481
+ * Which live dispatchers block the unit from starting: every one whose pid the
482
+ * supervisor does not own. Pure — the orphan trap is decided here so it can be
483
+ * tested without a systemd.
484
+ */
485
+ export function orphanDispatcherPids(
486
+ live: Array<{ pid: number }>,
487
+ supervisorPid: number | null,
488
+ ): number[] {
489
+ return live.filter((d) => d.pid !== supervisorPid).map((d) => d.pid);
490
+ }
491
+
492
+ /**
493
+ * Pick the scope to act on: whichever scope has a unit installed. Explicit wins;
494
+ * with units in both scopes, system wins (the management-plane shape).
495
+ */
496
+ /** Is a supervisor unit present in either scope? */
497
+ export function unitInstalledInAnyScope(platform?: SupervisorPlatform, home?: string): boolean {
498
+ return (['system', 'user'] as const).some(
499
+ (scope) => readInstalledUnit({ scope, home, platform }).exists,
500
+ );
501
+ }
502
+
503
+ export function resolveRestartScope(
504
+ opts: { platform?: SupervisorPlatform; home?: string; scope?: SupervisorScope } = {},
505
+ ): SupervisorScope {
506
+ if (opts.scope) return opts.scope;
507
+ const probe = (scope: SupervisorScope) =>
508
+ readInstalledUnit({ scope, home: opts.home, platform: opts.platform }).exists;
509
+ if (probe('system')) return 'system';
510
+ if (probe('user')) return 'user';
511
+ throw new Error(
512
+ 'celilo events restart-daemon: no supervisor unit installed (user or system scope). Run `celilo events install-daemon` first.',
513
+ );
514
+ }
515
+
516
+ /**
517
+ * Stop any unmanaged dispatcher, restart the unit, and confirm on the BUS — not
518
+ * from the supervisor's exit code — that a new process is live on the expected
519
+ * version. Anything less reports success into the crash loop described above.
520
+ */
521
+ export async function restartDaemon(
522
+ opts: RestartDaemonOptions,
523
+ deps: RestartDaemonDeps,
524
+ ): Promise<RestartDaemonResult> {
525
+ const platform = opts.platform ?? detectPlatform();
526
+ const scope = resolveRestartScope({ platform, home: opts.home, scope: opts.scope });
527
+ const unitPath = getDaemonUnitPath(platform, opts.home ?? homedir(), scope);
528
+ const timeoutMs = opts.timeoutMs ?? 60_000;
529
+ const pollMs = opts.pollMs ?? 1000;
530
+
531
+ const before = deps.liveDispatchers();
532
+ const orphans = orphanDispatcherPids(before, deps.supervisorPid());
533
+ for (const pid of orphans) {
534
+ deps.kill(pid, 'SIGTERM');
535
+ }
536
+ // Wait for the guard's view to clear before restarting, else the unit starts
537
+ // into `another dispatcher is already running` and auto-restart backoff eats
538
+ // the window. SIGKILL whatever refuses to go.
539
+ const graceUntil = Date.now() + 10_000;
540
+ while (orphans.length > 0 && Date.now() < graceUntil) {
541
+ if (deps.liveDispatchers().every((d) => !orphans.includes(d.pid))) break;
542
+ await deps.sleep(pollMs);
543
+ }
544
+ for (const pid of deps.liveDispatchers().map((d) => d.pid)) {
545
+ if (orphans.includes(pid)) deps.kill(pid, 'SIGKILL');
546
+ }
547
+
548
+ deps.restartUnit();
549
+
550
+ const beforePids = new Set(before.map((d) => d.pid));
551
+ const deadline = Date.now() + timeoutMs;
552
+ let last: Array<{ pid: number; version: string | null }> = [];
553
+ while (Date.now() < deadline) {
554
+ last = deps.liveDispatchers();
555
+ const fresh = last.filter((d) => !beforePids.has(d.pid));
556
+ if (last.length === 1 && fresh.length === 1 && fresh[0]?.version === opts.expectedVersion) {
557
+ return {
558
+ platform,
559
+ scope,
560
+ unitPath,
561
+ orphansKilled: orphans,
562
+ expectedVersion: opts.expectedVersion,
563
+ dispatcher: fresh[0],
564
+ };
565
+ }
566
+ await deps.sleep(pollMs);
567
+ }
568
+
569
+ const seen =
570
+ last.length === 0
571
+ ? 'no dispatcher is live on the bus'
572
+ : `live: ${last.map((d) => `pid ${d.pid} (v${d.version ?? '?'})`).join(', ')}`;
573
+ throw new Error(
574
+ `celilo events restart-daemon: the supervisor was asked to restart ${unitPath}, but no dispatcher on v${opts.expectedVersion} came up within ${Math.round(timeoutMs / 1000)}s — ${seen}. The old code may still be serving. Check \`celilo events status\` and the unit's logs.`,
575
+ );
576
+ }
577
+
578
+ /**
579
+ * systemctl/launchctl argv for this platform+scope — `restart` and MainPID.
580
+ *
581
+ * System scope goes through `sudo`, and that is not optional on the box this
582
+ * exists for: the unit is root-owned and the command runs as the unprivileged
583
+ * `celilo` user (api-serve's child, the wrapper's sudo-drop target). Without
584
+ * sudo, every apt-upgrade on celilo-mgr would take the honest-but-useless
585
+ * branch — "packages upgraded, dispatcher still on old code" — forever.
586
+ * `celilo-bootstrap` ships the scoped grant (/etc/sudoers.d/celilo-events-restart)
587
+ * for exactly these two invocations. Same shape as `install-daemon`'s own
588
+ * printed next-steps, which already say `sudo systemctl` for system scope.
589
+ */
590
+ export function supervisorCommands(
591
+ platform: SupervisorPlatform,
592
+ scope: SupervisorScope,
593
+ ): { restart: string[]; mainPid: string[] | null } {
594
+ if (platform === 'linux') {
595
+ const sc = scope === 'system' ? ['sudo', 'systemctl'] : ['systemctl', '--user'];
596
+ return {
597
+ restart: [...sc, 'restart', SYSTEMD_UNIT_NAME],
598
+ mainPid: [...sc, 'show', SYSTEMD_UNIT_NAME, '-p', 'MainPID', '--value'],
599
+ };
600
+ }
601
+ const target =
602
+ scope === 'system'
603
+ ? `system/${LAUNCHD_LABEL}`
604
+ : `gui/${process.getuid?.() ?? 501}/${LAUNCHD_LABEL}`;
605
+ return {
606
+ // kickstart -k restarts a loaded job; bootstrap first if it was never loaded.
607
+ restart:
608
+ scope === 'system'
609
+ ? ['sudo', 'launchctl', 'kickstart', '-k', target]
610
+ : ['launchctl', 'kickstart', '-k', target],
611
+ // launchctl print's pid line is not machine-stable across releases; treat
612
+ // launchd as "owns nothing we can name" and let the bus decide.
613
+ // ponytail: no MainPID probe on darwin — celilo-mgr is Linux; add if a mac
614
+ // ever runs the management plane.
615
+ mainPid: null,
616
+ };
617
+ }
618
+
619
+ /**
620
+ * The pid the supervisor currently owns for this scope's unit, or null when
621
+ * that can't be established (launchd, systemd absent, unit inactive, user bus
622
+ * unreachable). `MainPID=0` means inactive — reported as null, not 0.
623
+ *
624
+ * Null is "don't know", never "not supervised": a caller must not accuse a
625
+ * dispatcher of being an orphan on the strength of a failed probe.
626
+ *
627
+ * The argv comes from supervisorCommands (celilo#604) rather than being rebuilt
628
+ * here — that is where the sudo-for-system-scope decision lives, and two copies
629
+ * of it would drift.
630
+ */
631
+ export function unitMainPid(
632
+ scope: SupervisorScope,
633
+ platform: SupervisorPlatform = detectPlatform(),
634
+ ): number | null {
635
+ const { mainPid } = supervisorCommands(platform, scope);
636
+ if (!mainPid) return null;
637
+ const [cmd, ...args] = mainPid;
638
+ if (!cmd) return null;
639
+ try {
640
+ const out = execFileSync(cmd, args, {
641
+ encoding: 'utf-8',
642
+ stdio: ['ignore', 'pipe', 'ignore'],
643
+ });
644
+ const pid = Number(out.trim());
645
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
646
+ } catch {
647
+ return null;
648
+ }
649
+ }
650
+
369
651
  export function readInstalledUnit(
370
- opts: { platform?: SupervisorPlatform; scope?: SupervisorScope; home?: string } = {},
652
+ opts: {
653
+ platform?: SupervisorPlatform;
654
+ scope?: SupervisorScope;
655
+ home?: string;
656
+ systemRoot?: string;
657
+ } = {},
371
658
  ): { exists: true; path: string; content: string } | { exists: false; path: string } {
372
659
  const platform = opts.platform ?? detectPlatform();
373
660
  const scope = opts.scope ?? 'user';
374
661
  const home = opts.home ?? homedir();
375
- const unitPath = getDaemonUnitPath(platform, home, scope);
662
+ const unitPath = getDaemonUnitPath(platform, home, scope, opts.systemRoot);
376
663
  if (!existsSync(unitPath)) return { exists: false, path: unitPath };
377
664
  return { exists: true, path: unitPath, content: readFileSync(unitPath, 'utf-8') };
378
665
  }