@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
@@ -9,7 +9,8 @@
9
9
  * status bus.health() as JSON
10
10
  * tail recent events
11
11
  * list-subscribers persistent subscribers
12
- * list-pending pending deliveries
12
+ * list-pending pending deliveries (subscriber fan-out — NOT questions)
13
+ * list-unanswered interview questions nobody has answered yet
13
14
  * drain process pending deliveries once
14
15
  * run long-running dispatcher (foreground; SIGINT to stop)
15
16
  * emit <type> [json] emit an event (operator/test path; bypasses schema)
@@ -20,7 +21,9 @@
20
21
  * resume alias for repair (acknowledges halt-on-recovery)
21
22
  */
22
23
 
24
+ import { spawnSync } from 'node:child_process';
23
25
  import {
26
+ BUS_VERSION,
24
27
  defineEvents,
25
28
  drainOnce,
26
29
  openBus,
@@ -28,6 +31,7 @@ import {
28
31
  runDispatcher,
29
32
  } from '@celilo/event-bus';
30
33
  import { eq } from 'drizzle-orm';
34
+ import { sessionParkedOn } from '../../api/sessions';
31
35
  import { getEventBusPath, shortenPath } from '../../config/paths';
32
36
  import { getDb } from '../../db/client';
33
37
  import { modules } from '../../db/schema';
@@ -37,10 +41,15 @@ import type { HookName } from '../../hooks/types';
37
41
  import type { ModuleManifest } from '../../manifest/schema';
38
42
  import type { EnsureRequiredPayload } from '../../services/bus-interview';
39
43
  import {
44
+ detectPlatform,
40
45
  installDaemon,
41
46
  planDaemonInstall,
42
47
  readInstalledUnit,
48
+ resolveRestartScope,
49
+ restartDaemon,
50
+ supervisorCommands,
43
51
  uninstallDaemon,
52
+ unitInstalledInAnyScope,
44
53
  } from '../../services/events-daemon';
45
54
  import { getArg, hasFlag } from '../parser';
46
55
  import type { CommandResult } from '../types';
@@ -234,6 +243,57 @@ export async function handleEventsListPending(
234
243
  }
235
244
  }
236
245
 
246
+ /** One unanswered interview question, as `events list-unanswered` reports it. */
247
+ export interface UnansweredInterview {
248
+ eventId: number;
249
+ type: string;
250
+ family: InterviewFamily;
251
+ /** `<scope>.<key>` — the identity a responder pre-stages an answer under. */
252
+ key: string;
253
+ question: string;
254
+ ageMs: number;
255
+ /** The parked api-serve session waiting on this answer, if any. */
256
+ sessionId: string | null;
257
+ }
258
+
259
+ /**
260
+ * `celilo events list-unanswered` — interview queries with no correlated reply:
261
+ * what is waiting on a decision right now.
262
+ *
263
+ * The instrument celilo#609 lacked. `events list-pending` was reached for and
264
+ * silently answered a different question (it reads subscriber *deliveries*), so
265
+ * a parked command looked like no command at all. Non-empty here for as long as
266
+ * something is parked is the recurrence gate for that whole class of bug.
267
+ */
268
+ export async function handleEventsListUnanswered(
269
+ _args: string[],
270
+ flags: Record<string, string | boolean>,
271
+ ): Promise<CommandResult> {
272
+ const bus = openCliBus();
273
+ try {
274
+ const limit = flags.limit ? Number(flags.limit) : 50;
275
+ const now = Date.now();
276
+ const rows: UnansweredInterview[] = [];
277
+ for (const event of bus.unansweredQueries({ limit })) {
278
+ const family = interviewFamily(event.type);
279
+ if (!family) continue; // e.g. responder.probe — not a question for an operator.
280
+ const payload = (event.payload ?? {}) as { message?: string; description?: string };
281
+ rows.push({
282
+ eventId: event.id,
283
+ type: event.type,
284
+ family,
285
+ key: event.type.slice(`${family}.required.`.length),
286
+ question: payload.message ?? payload.description ?? event.type,
287
+ ageMs: now - event.emittedAt,
288
+ sessionId: sessionParkedOn(String(event.id))?.sessionId ?? null,
289
+ });
290
+ }
291
+ return jsonResult(rows);
292
+ } finally {
293
+ bus.close();
294
+ }
295
+ }
296
+
237
297
  export async function handleEventsDrain(
238
298
  _args: string[],
239
299
  flags: Record<string, string | boolean>,
@@ -405,9 +465,12 @@ function interviewFamily(type: string): InterviewFamily | null {
405
465
  /**
406
466
  * `celilo events reply <query-event-id> <value-json>` — answer ONE pending
407
467
  * interview query by its event id. The one-shot reply primitive a
408
- * `claude-config-responder` uses: read the question with
409
- * `events tail --type 'config.required.*'`, ask the operator, emit the answer
410
- * here. Unlike `events respond` (which must be subscribed BEFORE the query is
468
+ * `claude-config-responder` uses: find the question with
469
+ * `celilo events list-unanswered`, ask the operator, emit the answer here.
470
+ * (It used to say "read the log with `events tail --type '…'`" — hand-scraping
471
+ * the log because no command listed unanswered questions. `list-unanswered` is
472
+ * that command; it also names the parked session, which `tail` cannot.)
473
+ * Unlike `events respond` (which must be subscribed BEFORE the query is
411
474
  * emitted — bus watches don't replay history) this looks the query up by id
412
475
  * and emits a correlated reply carrying `replyFor`, which plain `events emit`
413
476
  * can't set.
@@ -897,6 +960,89 @@ export async function handleEventsUninstallDaemon(
897
960
  }
898
961
  }
899
962
 
963
+ /**
964
+ * `celilo events restart-daemon` — cycle the dispatcher through its supervisor
965
+ * and PROVE the new process is live on the installed code (celilo#604).
966
+ *
967
+ * The interesting case is the orphan: a dispatcher the supervisor doesn't own
968
+ * can't be killed by `systemctl restart`, and the one-dispatcher-per-bus guard
969
+ * (#584) then crash-loops the unit while the old code keeps serving. So this
970
+ * stops unmanaged dispatchers first, and verifies on the bus afterwards rather
971
+ * than trusting systemctl's exit code.
972
+ */
973
+ export async function handleEventsRestartDaemon(
974
+ _args: string[],
975
+ flags: Record<string, string | boolean>,
976
+ ): Promise<CommandResult> {
977
+ const bus = openCliBus();
978
+ try {
979
+ const platform = detectPlatform();
980
+ // No unit installed at all. apt-upgrade runs this on every box, including
981
+ // ones that never installed the daemon — failing those would break an
982
+ // upgrade that has nothing stale to fix. Distinguish the two cases:
983
+ // nothing running is a genuine no-op; something running is unsupervised
984
+ // and may be stale, and there is no supervisor to cycle it through.
985
+ if (!flags.system && !unitInstalledInAnyScope(platform)) {
986
+ const live = bus.liveDispatchers();
987
+ if (live.length === 0) {
988
+ return {
989
+ success: true,
990
+ message: 'No supervisor unit installed and no dispatcher running — nothing to restart.',
991
+ };
992
+ }
993
+ return {
994
+ success: false,
995
+ error: `A dispatcher is running unsupervised (pid ${live.map((d) => d.pid).join(', ')}, code v${live[0]?.version ?? '?'}) and no supervisor unit is installed, so it cannot be cycled — it may be serving stale code. Run \`celilo events install-daemon\`, enable the unit, then retry.`,
996
+ };
997
+ }
998
+ const scope = resolveRestartScope({ platform, scope: flags.system ? 'system' : undefined });
999
+ const cmds = supervisorCommands(platform, scope);
1000
+ const result = await restartDaemon(
1001
+ { platform, scope, expectedVersion: BUS_VERSION },
1002
+ {
1003
+ liveDispatchers: () =>
1004
+ bus.liveDispatchers().map((d) => ({ pid: d.pid, version: d.version })),
1005
+ supervisorPid: () => {
1006
+ if (!cmds.mainPid) return null;
1007
+ const out = spawnSync(cmds.mainPid[0], cmds.mainPid.slice(1), { encoding: 'utf-8' });
1008
+ const pid = Number((out.stdout ?? '').trim());
1009
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
1010
+ },
1011
+ kill: (pid, signal) => {
1012
+ try {
1013
+ process.kill(pid, signal);
1014
+ } catch {
1015
+ // Already gone, or not ours to signal — the bus poll is the arbiter.
1016
+ }
1017
+ },
1018
+ restartUnit: () => {
1019
+ const out = spawnSync(cmds.restart[0], cmds.restart.slice(1), { encoding: 'utf-8' });
1020
+ if (out.status !== 0) {
1021
+ throw new Error(
1022
+ `${cmds.restart.join(' ')} failed (exit ${out.status ?? 'signal'}): ${(out.stderr ?? '').trim()}`,
1023
+ );
1024
+ }
1025
+ },
1026
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
1027
+ },
1028
+ );
1029
+ const lines = [
1030
+ `Dispatcher restarted under the ${result.scope}-scope unit: ${shortenPath(result.unitPath)}`,
1031
+ ` now running: pid ${result.dispatcher.pid}, code v${result.dispatcher.version}`,
1032
+ ...(result.orphansKilled.length > 0
1033
+ ? [
1034
+ ` stopped ${result.orphansKilled.length} unsupervised dispatcher(s): pid ${result.orphansKilled.join(', ')}`,
1035
+ ]
1036
+ : []),
1037
+ ];
1038
+ return { success: true, message: lines.join('\n'), data: result };
1039
+ } catch (err) {
1040
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
1041
+ } finally {
1042
+ bus.close();
1043
+ }
1044
+ }
1045
+
900
1046
  /**
901
1047
  * `celilo events show-daemon` — print whatever unit file is currently
902
1048
  * installed (`--system` for the system-scope unit) so the operator can
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
  import { eq } from 'drizzle-orm';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
- import { classifyVersionChange, updateOne } from './module-update';
14
+ import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
15
15
 
16
16
  describe('classifyVersionChange', () => {
17
17
  test('identical versions are up-to-date', () => {
@@ -250,3 +250,74 @@ subscriptions:
250
250
  }
251
251
  });
252
252
  });
253
+
254
+ /**
255
+ * Regression for the fabricated decline: driven headlessly with no responder,
256
+ * the registry sweep reported a breaking update as "operator declined" — a
257
+ * decision nobody was asked to make. An unanswerable question is not a "no".
258
+ */
259
+ describe('registry sweep — an unanswered breaking update is not a decline', () => {
260
+ let tempDir: string;
261
+ let server: ReturnType<typeof Bun.serve>;
262
+ let registryUrl: string;
263
+
264
+ beforeEach(() => {
265
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-sweep-'));
266
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
267
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
268
+ // Isolated bus with no responder attached — the headless case.
269
+ process.env.EVENT_BUS_DB = join(tempDir, 'events.db');
270
+
271
+ getDb()
272
+ .insert(modules)
273
+ .values({
274
+ id: 'iptables',
275
+ name: 'iptables',
276
+ sourcePath: join(tempDir, 'installed'),
277
+ version: '1.0.2+9',
278
+ manifestData: {
279
+ celilo_contract: '1.0',
280
+ id: 'iptables',
281
+ name: 'iptables',
282
+ version: '1.0.2',
283
+ },
284
+ })
285
+ .run();
286
+
287
+ // Minimal sparse-index server offering a major bump for `iptables`.
288
+ server = Bun.serve({
289
+ port: 0,
290
+ fetch(req) {
291
+ const path = new URL(req.url).pathname;
292
+ if (path === '/index/ip/ta/iptables') {
293
+ return new Response(
294
+ `${JSON.stringify({ name: 'iptables', vers: '2.0.0+1', deps: [], cksum: 'x' })}\n`,
295
+ );
296
+ }
297
+ return new Response('not found', { status: 404 });
298
+ },
299
+ });
300
+ registryUrl = `http://localhost:${server.port}`;
301
+ });
302
+
303
+ afterEach(() => {
304
+ server.stop(true);
305
+ rmSync(tempDir, { recursive: true, force: true });
306
+ process.env.CELILO_DB_PATH = undefined;
307
+ process.env.CELILO_ORIGINAL_CWD = undefined;
308
+ process.env.EVENT_BUS_DB = undefined;
309
+ });
310
+
311
+ test('reports it as unanswered, never as declined, and fails the sweep', async () => {
312
+ const result = await handleModuleUpdate([], { registry: registryUrl });
313
+
314
+ const report = result.success ? (result.message ?? '') : (result.error ?? '');
315
+ expect(report).not.toContain('operator declined');
316
+ expect(report).toContain('NOT declined');
317
+ expect(report).toContain('iptables');
318
+ // A breaking update that silently didn't land must not read as success.
319
+ expect(result.success).toBe(false);
320
+ // And the module is still on the old version — no accidental upgrade.
321
+ expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
322
+ });
323
+ });
@@ -23,6 +23,7 @@ import type { ModuleManifest } from '../../manifest/schema';
23
23
  import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
24
24
  import { RegistryClient } from '../../registry/client';
25
25
  import { askConfirm, withInterviewSession } from '../../services/bus-interview';
26
+ import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors';
26
27
  import { getFlag } from '../parser';
27
28
  import { log } from '../prompts';
28
29
  import type { CommandResult } from '../types';
@@ -496,7 +497,9 @@ async function runRegistrySweep(
496
497
  }
497
498
 
498
499
  let appliedBreaking = 0;
499
- let skippedBreaking = 0;
500
+ let declinedBreaking = 0;
501
+ const unanswered: Array<{ moduleId: string; error: string }> = [];
502
+ const abandoned: Array<{ moduleId: string; error: string }> = [];
500
503
 
501
504
  if (breaking.length > 0) {
502
505
  log.info('\nBreaking updates available — review required (semver-major bump):');
@@ -508,16 +511,39 @@ async function runRegistrySweep(
508
511
  log.message('Each breaking update will be applied only on explicit confirmation.\n');
509
512
 
510
513
  for (const plan of breaking) {
511
- const proceed = await withInterviewSession(() =>
512
- askConfirm({
513
- scope: `module-upgrade:${plan.moduleId}`,
514
- key: 'apply_breaking',
515
- message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
516
- defaultValue: false,
517
- }),
518
- );
514
+ let proceed: boolean;
515
+ try {
516
+ proceed = await withInterviewSession(() =>
517
+ askConfirm({
518
+ scope: `module-upgrade:${plan.moduleId}`,
519
+ key: 'apply_breaking',
520
+ message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
521
+ defaultValue: false,
522
+ }),
523
+ );
524
+ } catch (err) {
525
+ // Neither of these is a decline, and they are not each other either:
526
+ // UNANSWERED = no responder was listening at all (fail-fast probe);
527
+ // ABANDONED = the question stood and its parked session expired.
528
+ // Record and keep going so updates already applied aren't thrown away.
529
+ if (err instanceof InterviewUnansweredError) {
530
+ unanswered.push({ moduleId: plan.moduleId, error: err.message });
531
+ console.log(
532
+ ` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, UNANSWERED)`,
533
+ );
534
+ continue;
535
+ }
536
+ if (err instanceof InterviewAbandonedError) {
537
+ abandoned.push({ moduleId: plan.moduleId, error: err.message });
538
+ console.log(
539
+ ` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, ABANDONED)`,
540
+ );
541
+ continue;
542
+ }
543
+ throw err;
544
+ }
519
545
  if (!proceed) {
520
- skippedBreaking++;
546
+ declinedBreaking++;
521
547
  continue;
522
548
  }
523
549
  const result = await fetchAndUpdate(client, plan.moduleId, plan.targetVersion, db, flags);
@@ -545,8 +571,26 @@ async function runRegistrySweep(
545
571
  } else {
546
572
  summary.push('No updates applied.');
547
573
  }
548
- if (skippedBreaking > 0) {
549
- summary.push(`Skipped ${skippedBreaking} breaking update(s) (operator declined).`);
574
+ if (declinedBreaking > 0) {
575
+ summary.push(`Skipped ${declinedBreaking} breaking update(s) (operator declined).`);
576
+ }
577
+ if (unanswered.length > 0) {
578
+ summary.push(
579
+ `Skipped ${unanswered.length} breaking update(s) — NOT declined: the confirmation could not be answered (${unanswered
580
+ .map((u) => u.moduleId)
581
+ .join(
582
+ ', ',
583
+ )}). Re-run with a responder attached, or pre-stage the answer under "module-upgrade:<module>.apply_breaking".`,
584
+ );
585
+ }
586
+ if (abandoned.length > 0) {
587
+ summary.push(
588
+ `Skipped ${abandoned.length} breaking update(s) — NOT declined: the confirmation was never decided and its session expired (${abandoned
589
+ .map((a) => a.moduleId)
590
+ .join(
591
+ ', ',
592
+ )}). Answer it next time with "celilo events list-unanswered" + "celilo events reply <id> <value>".`,
593
+ );
550
594
  }
551
595
  if (notInRegistry.length > 0) {
552
596
  summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
@@ -556,16 +600,18 @@ async function runRegistrySweep(
556
600
  `Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
557
601
  );
558
602
  }
559
- if (failed.length > 0) {
560
- return {
561
- success: false,
562
- error: [
563
- ...summary,
564
- '',
565
- 'Failures:',
566
- ...failed.map((f) => ` ${f.moduleId}: ${f.error}`),
567
- ].join('\n'),
568
- };
603
+ if (failed.length > 0 || unanswered.length > 0 || abandoned.length > 0) {
604
+ const detail: string[] = [];
605
+ if (failed.length > 0) {
606
+ detail.push('', 'Failures:', ...failed.map((f) => ` ${f.moduleId}: ${f.error}`));
607
+ }
608
+ if (unanswered.length > 0) {
609
+ detail.push('', 'Unanswered:', ...unanswered.map((u) => ` ${u.moduleId}: ${u.error}`));
610
+ }
611
+ if (abandoned.length > 0) {
612
+ detail.push('', 'Abandoned:', ...abandoned.map((a) => ` ${a.moduleId}: ${a.error}`));
613
+ }
614
+ return { success: false, error: [...summary, ...detail].join('\n') };
569
615
  }
570
616
  return { success: true, message: summary.join('\n') };
571
617
  }
@@ -1,3 +1,4 @@
1
+ import { Database } from 'bun:sqlite';
1
2
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
3
  import { mkdtempSync, rmSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
@@ -37,4 +38,59 @@ describe('handleSystemMigrate', () => {
37
38
  const second = await handleSystemMigrate();
38
39
  expect(second.success).toBe(true);
39
40
  });
41
+
42
+ // celilo#604: the runbook asserts "applied 19 -> 20, backups.pid present".
43
+ // Before this, the only answer was a table COUNT, which cannot see a column.
44
+ describe('--status', () => {
45
+ it('names the applied count and the latest applied migration', async () => {
46
+ await handleSystemMigrate();
47
+ closeDb();
48
+
49
+ const result = await handleSystemMigrate([], { status: true });
50
+
51
+ expect(result.success).toBe(true);
52
+ if (result.success) {
53
+ expect(result.message).toMatch(/Applied migrations: \d+/);
54
+ expect(result.message).toContain('0019_backup_pid');
55
+ expect(result.message).toContain('Pending: none');
56
+ expect(result.message).toContain('columns');
57
+ }
58
+ });
59
+
60
+ it('reports a pending migration WITHOUT applying it', async () => {
61
+ await handleSystemMigrate();
62
+ closeDb();
63
+ // Rewind one migration, the way an upgrade that never ran would look.
64
+ const raw = new Database(process.env.CELILO_DB_PATH as string);
65
+ raw.run(
66
+ 'DELETE FROM `__drizzle_migrations` WHERE created_at = (SELECT MAX(created_at) FROM `__drizzle_migrations`)',
67
+ );
68
+ raw.run('ALTER TABLE backups DROP COLUMN pid');
69
+ const countBefore = raw
70
+ .query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
71
+ .get()?.c;
72
+ raw.close();
73
+
74
+ const result = await handleSystemMigrate([], { status: true });
75
+
76
+ expect(result.success).toBe(false);
77
+ if (!result.success) {
78
+ expect(result.error).toContain('0019_backup_pid');
79
+ expect(result.error).toContain('backups.pid');
80
+ }
81
+
82
+ // A status that repaired what it reports would always read clean — the
83
+ // exact placebo this command exists to replace.
84
+ const after = new Database(process.env.CELILO_DB_PATH as string);
85
+ const countAfter = after
86
+ .query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
87
+ .get()?.c;
88
+ const cols = after
89
+ .query<{ name: string }, []>("SELECT name FROM pragma_table_info('backups')")
90
+ .all();
91
+ after.close();
92
+ expect(countAfter).toBe(countBefore as number);
93
+ expect(cols.map((c) => c.name)).not.toContain('pid');
94
+ });
95
+ });
40
96
  });
@@ -10,8 +10,9 @@
10
10
  import type { Database } from 'bun:sqlite';
11
11
  import { defineEvents, openBus } from '@celilo/event-bus';
12
12
  import { getEventBusPath } from '../../config/paths';
13
- import { getDb } from '../../db/client';
13
+ import { createDbClient, findMigrationsFolder, getDb } from '../../db/client';
14
14
  import { runMigrationsOn } from '../../db/migrate';
15
+ import { getMigrationStatus } from '../../db/migration-status';
15
16
  import { findSchemaDrift } from '../../db/schema-introspection';
16
17
  import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
17
18
  import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
@@ -62,7 +63,50 @@ function countApplied(sqlite: Database): number {
62
63
  }
63
64
  }
64
65
 
65
- export async function handleSystemMigrate(): Promise<CommandResult> {
66
+ /**
67
+ * `celilo system migrate --status` — read-only interrogation (celilo#604).
68
+ *
69
+ * A rollout runbook that says "assert applied 19 → 20 and `backups.pid`
70
+ * present" needs a product surface to assert against; the table count that used
71
+ * to be the only answer cannot see a column migration at all.
72
+ */
73
+ export function migrationStatusResult(sqlite: Database): CommandResult {
74
+ const status = getMigrationStatus(sqlite, findMigrationsFolder());
75
+ const missing = [...status.missingTables, ...status.missingColumns];
76
+ const lines = [
77
+ `Applied migrations: ${status.appliedCount}`,
78
+ `Latest applied: ${status.latestApplied ?? '(none)'}`,
79
+ status.pending.length > 0
80
+ ? `Pending: ${status.pending.join(', ')}`
81
+ : 'Pending: none',
82
+ `Schema present: ${status.tableCount} tables, ${status.columnCount} columns`,
83
+ ...(missing.length > 0 ? [`Missing: ${missing.join(', ')}`] : []),
84
+ ];
85
+ if (status.pending.length > 0 || missing.length > 0) {
86
+ return {
87
+ success: false,
88
+ error: `${lines.join('\n')}\n\nRun \`celilo system migrate\` to apply pending migrations on this box.`,
89
+ };
90
+ }
91
+ return { success: true, message: lines.join('\n'), data: status };
92
+ }
93
+
94
+ export async function handleSystemMigrate(
95
+ _args: string[] = [],
96
+ flags: Record<string, string | boolean> = {},
97
+ ): Promise<CommandResult> {
98
+ // --status must NOT migrate. getDb() auto-migrates on open, so a status that
99
+ // went through it would repair the very state it claims to be reporting and
100
+ // could never say "pending" — the placebo shape this command exists to end.
101
+ if (flags.status) {
102
+ const ro = createDbClient({ readonly: true });
103
+ try {
104
+ return migrationStatusResult(ro.$client);
105
+ } finally {
106
+ ro.$client.close();
107
+ }
108
+ }
109
+
66
110
  // getDb() auto-migrates on open; do it inside try so an existing DB that
67
111
  // predates the drizzle-authoritative change fails with an actionable message
68
112
  // instead of a raw migrator error.
@@ -97,9 +141,13 @@ export async function handleSystemMigrate(): Promise<CommandResult> {
97
141
 
98
142
  ensureCoreSubscribers();
99
143
 
144
+ // Name the latest migration, not just a table count: "35 tables" reads the
145
+ // same whether a column migration applied or silently did nothing (celilo#604).
146
+ const status = getMigrationStatus(sqlite, findMigrationsFolder());
100
147
  const lines = [
101
148
  applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
102
- `Schema current: ${drift.tableCount} tables.`,
149
+ `Applied migrations: ${status.appliedCount} (latest: ${status.latestApplied ?? 'none'})`,
150
+ `Schema current: ${drift.tableCount} tables, ${drift.columnCount} columns.`,
103
151
  ];
104
- return { success: true, message: lines.join('\n') };
152
+ return { success: true, message: lines.join('\n'), data: status };
105
153
  }
@@ -113,6 +113,7 @@ export async function getCompletions(words: string[], current: number): Promise<
113
113
  'list-subscribers',
114
114
  'resync-subscriptions',
115
115
  'list-pending',
116
+ 'list-unanswered',
116
117
  'drain',
117
118
  'run',
118
119
  'run-hook',
@@ -125,6 +126,7 @@ export async function getCompletions(words: string[], current: number): Promise<
125
126
  'respond',
126
127
  'install-daemon',
127
128
  'uninstall-daemon',
129
+ 'restart-daemon',
128
130
  'show-daemon',
129
131
  ];
130
132
  return filterSuggestions(subcommands, args[1] || '');
package/src/cli/index.ts CHANGED
@@ -4,7 +4,13 @@
4
4
  * Orchestration function (Rule 10.1) - routes commands to handlers
5
5
  */
6
6
 
7
- import { COMMANDS, type CommandDef, resolveRemote, runRemoteClient } from '@celilo/core';
7
+ import {
8
+ COMMANDS,
9
+ type CommandDef,
10
+ EXIT_BLOCKED,
11
+ resolveRemote,
12
+ runRemoteClient,
13
+ } from '@celilo/core';
8
14
  import * as p from '@clack/prompts';
9
15
  import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
10
16
  import {
@@ -28,9 +34,11 @@ import {
28
34
  handleEventsInstallDaemon,
29
35
  handleEventsListPending,
30
36
  handleEventsListSubscribers,
37
+ handleEventsListUnanswered,
31
38
  handleEventsRepair,
32
39
  handleEventsReply,
33
40
  handleEventsRespond,
41
+ handleEventsRestartDaemon,
34
42
  handleEventsResyncSubscriptions,
35
43
  handleEventsRun,
36
44
  handleEventsRunHook,
@@ -303,6 +311,7 @@ Subcommands:
303
311
  respond Run the terminal responder; answer deploy prompts from another shell
304
312
  install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
305
313
  uninstall-daemon [--system] Remove the installed supervisor unit
314
+ restart-daemon [--system] Restart the dispatcher and verify the new process is on current code
306
315
  show-daemon [--system] Print the currently installed unit file
307
316
 
308
317
  Description:
@@ -1416,6 +1425,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1416
1425
  return handleEventsResyncSubscriptions();
1417
1426
  case 'list-pending':
1418
1427
  return handleEventsListPending(parsed.args, parsed.flags);
1428
+ case 'list-unanswered':
1429
+ return handleEventsListUnanswered(parsed.args, parsed.flags);
1419
1430
  case 'drain':
1420
1431
  return handleEventsDrain(parsed.args, parsed.flags);
1421
1432
  case 'run':
@@ -1439,6 +1450,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1439
1450
  return handleEventsInstallDaemon(parsed.args, parsed.flags);
1440
1451
  case 'uninstall-daemon':
1441
1452
  return handleEventsUninstallDaemon(parsed.args, parsed.flags);
1453
+ case 'restart-daemon':
1454
+ return handleEventsRestartDaemon(parsed.args, parsed.flags);
1442
1455
  case 'show-daemon':
1443
1456
  return handleEventsShowDaemon(parsed.args, parsed.flags);
1444
1457
  default:
@@ -2133,7 +2146,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2133
2146
  }
2134
2147
 
2135
2148
  if (parsed.subcommand === 'migrate') {
2136
- return handleSystemMigrate();
2149
+ return handleSystemMigrate(parsed.args, parsed.flags);
2137
2150
  }
2138
2151
 
2139
2152
  return {
@@ -2423,7 +2436,18 @@ export async function main(): Promise<void> {
2423
2436
  // SSH to the remote celilo-mgr and drive its api-serve over the wire.
2424
2437
  const remote = resolveRemote(process.argv);
2425
2438
  if (remote) {
2426
- process.exit(await runRemoteClient(remote.dest, remote.commandArgv));
2439
+ const outcome = await runRemoteClient(remote.dest, remote.commandArgv);
2440
+ if (outcome.status === 'blocked') {
2441
+ // Not a failure and not a decline: the command is parked server-side on a
2442
+ // question this client couldn't decide, and is still alive.
2443
+ process.stderr.write(
2444
+ `Command is parked on an unanswered question: ${outcome.question}\n` +
2445
+ ` answer it: celilo events reply ${outcome.eventId} <value>\n` +
2446
+ ` it resumes server-side under session ${outcome.sessionId}\n`,
2447
+ );
2448
+ process.exit(EXIT_BLOCKED);
2449
+ }
2450
+ process.exit(outcome.exitCode);
2427
2451
  }
2428
2452
 
2429
2453
  // Normal single-command execution