@celilo/cli 0.13.2 → 0.14.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 (89) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/ansible/inventory.ts +2 -2
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/monitor.ts +178 -0
  17. package/src/cli/commands/notify-config.ts +453 -0
  18. package/src/cli/commands/system-audit.ts +2 -0
  19. package/src/cli/commands/system-update.ts +1 -0
  20. package/src/cli/completion.ts +26 -0
  21. package/src/cli/generate-zsh-completion.ts +2 -0
  22. package/src/cli/index.ts +58 -0
  23. package/src/cli/tui/audit-state.ts +2 -0
  24. package/src/db/schema.ts +358 -0
  25. package/src/hooks/capability-loader.ts +158 -46
  26. package/src/hooks/capability-map-coverage.test.ts +101 -0
  27. package/src/manifest/schema.ts +60 -1
  28. package/src/services/alerting/ack.test.ts +212 -0
  29. package/src/services/alerting/ack.ts +119 -0
  30. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  31. package/src/services/alerting/builtin-monitors.ts +84 -0
  32. package/src/services/alerting/builtin-source.ts +82 -0
  33. package/src/services/alerting/coverage-source.ts +38 -0
  34. package/src/services/alerting/deferral.test.ts +161 -0
  35. package/src/services/alerting/delivery-loop.test.ts +396 -0
  36. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  37. package/src/services/alerting/deploy-hooks.ts +111 -0
  38. package/src/services/alerting/escalation.test.ts +207 -0
  39. package/src/services/alerting/escalation.ts +151 -0
  40. package/src/services/alerting/format.test.ts +193 -0
  41. package/src/services/alerting/format.ts +150 -0
  42. package/src/services/alerting/health-coverage.ts +81 -0
  43. package/src/services/alerting/inbound-poller.test.ts +298 -0
  44. package/src/services/alerting/inbound-poller.ts +236 -0
  45. package/src/services/alerting/inbound.test.ts +201 -0
  46. package/src/services/alerting/inbound.ts +112 -0
  47. package/src/services/alerting/interview-responder.test.ts +169 -0
  48. package/src/services/alerting/interview-responder.ts +158 -0
  49. package/src/services/alerting/keys.test.ts +155 -0
  50. package/src/services/alerting/keys.ts +190 -0
  51. package/src/services/alerting/monitors.ts +185 -0
  52. package/src/services/alerting/notification-responder.test.ts +290 -0
  53. package/src/services/alerting/notification-responder.ts +260 -0
  54. package/src/services/alerting/notifier.ts +219 -0
  55. package/src/services/alerting/people.ts +178 -0
  56. package/src/services/alerting/quiet-hours.test.ts +140 -0
  57. package/src/services/alerting/quiet-hours.ts +99 -0
  58. package/src/services/alerting/reconcile.test.ts +190 -0
  59. package/src/services/alerting/reconcile.ts +166 -0
  60. package/src/services/alerting/run-monitor.test.ts +185 -0
  61. package/src/services/alerting/run-monitor.ts +177 -0
  62. package/src/services/alerting/store.test.ts +222 -0
  63. package/src/services/alerting/store.ts +289 -0
  64. package/src/services/alerting/suppression.test.ts +228 -0
  65. package/src/services/alerting/suppression.ts +142 -0
  66. package/src/services/alerting/sweep-runner.test.ts +229 -0
  67. package/src/services/alerting/sweep-runner.ts +204 -0
  68. package/src/services/alerting/sweep.test.ts +61 -0
  69. package/src/services/alerting/sweep.ts +41 -0
  70. package/src/services/alerting/tokens.test.ts +152 -0
  71. package/src/services/alerting/tokens.ts +119 -0
  72. package/src/services/alerting/transport-loader.ts +48 -0
  73. package/src/services/aspect-runner.ts +2 -2
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/module-deploy.ts +32 -3
  83. package/src/services/ssh-key-manager.test.ts +14 -0
  84. package/src/services/ssh-key-manager.ts +12 -0
  85. package/src/services/system-config-validator.test.ts +31 -1
  86. package/src/services/trusted-sources.test.ts +221 -0
  87. package/src/services/trusted-sources.ts +159 -0
  88. package/src/services/update/orchestrator.test.ts +1 -0
  89. package/src/templates/generator.ts +6 -29
@@ -1,5 +1,8 @@
1
- import { getDb } from '../../db/client';
2
- import { modules } from '../../db/schema';
1
+ import { eq } from 'drizzle-orm';
2
+ import { type DbClient, getDb } from '../../db/client';
3
+ import { modules, modules as modulesTable, monitors } from '../../db/schema';
4
+ import { moduleHealthCell } from '../../services/alerting/format';
5
+ import { loadAllLiveAlerts, summariseByModule } from '../../services/alerting/store';
3
6
  import { hasFlag } from '../parser';
4
7
  import type { CommandResult } from '../types';
5
8
 
@@ -36,10 +39,18 @@ export async function handleModuleList(
36
39
  };
37
40
  }
38
41
 
42
+ // Observed health, derived from live alerts rather than from module state.
43
+ // The two answer different questions: `state` says whether someone
44
+ // deliberately verified this module, `health` says whether it is working
45
+ // right now. "not observed" is a finding, not a blank — see design D15.
46
+ const health = loadObservedHealth(db);
47
+
39
48
  // Format module list
40
49
  const lines = ['Installed modules:', ''];
41
50
  for (const module of moduleRows) {
42
- lines.push(`${module.id} (v${module.version}) - ${module.state}`);
51
+ const observed = health.get(module.id);
52
+ const healthNote = observed ? ` [${observed}]` : '';
53
+ lines.push(`${module.id} (v${module.version}) - ${module.state}${healthNote}`);
43
54
  if (module.description) {
44
55
  lines.push(` ${module.description}`);
45
56
  }
@@ -55,3 +66,39 @@ export async function handleModuleList(
55
66
  data: moduleRows,
56
67
  };
57
68
  }
69
+
70
+ /**
71
+ * Observed health per module: `ok`, `N firing`, `suppressed`, or
72
+ * `not observed`.
73
+ *
74
+ * Only computed for DEPLOYED modules — a module still being imported has
75
+ * nothing to observe, and reporting it as unwatched would be noise rather
76
+ * than a finding.
77
+ */
78
+ function loadObservedHealth(db: DbClient): Map<string, string> {
79
+ const monitored = new Set(
80
+ db
81
+ .select({ target: monitors.target })
82
+ .from(monitors)
83
+ .where(eq(monitors.enabled, true))
84
+ .all()
85
+ .map((m) => m.target),
86
+ );
87
+ const byModule = summariseByModule(loadAllLiveAlerts(db));
88
+
89
+ const result = new Map<string, string>();
90
+ for (const module of db.select().from(modulesTable).all()) {
91
+ if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
92
+
93
+ const summary = byModule.get(module.id) ?? [];
94
+ result.set(
95
+ module.id,
96
+ moduleHealthCell({
97
+ monitored: monitored.has(module.id),
98
+ firingCount: summary.filter((s) => !s.suppressed).length,
99
+ suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
100
+ }),
101
+ );
102
+ }
103
+ return result;
104
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `celilo monitor` — what celilo is watching, and running a check on demand.
3
+ *
4
+ * Thin adapter (Rule 10.5): parse, delegate, format. The scheduling decisions
5
+ * live in services/alerting/, and this command composes the real health runner
6
+ * and audit checks into the injectable deps `runOneMonitor` expects.
7
+ */
8
+
9
+ import { defineEvents, openBus } from '@celilo/event-bus';
10
+ import { getEventBusPath } from '../../config/paths';
11
+ import { getDb } from '../../db/client';
12
+ import type { MonitorKind } from '../../db/schema';
13
+ import { parseIntervalMinutes } from '../../manifest/schema';
14
+ import { runBuiltinCheckForMonitor } from '../../services/alerting/builtin-source';
15
+ import { loadModuleCoverage } from '../../services/alerting/coverage-source';
16
+ import { HEALTH_COVERAGE_CHECK } from '../../services/alerting/health-coverage';
17
+ import {
18
+ createMonitor,
19
+ ensureSweepSubscriber,
20
+ findMonitorByTarget,
21
+ listMonitors,
22
+ setMonitorEnabled,
23
+ } from '../../services/alerting/monitors';
24
+ import { runOneMonitor } from '../../services/alerting/run-monitor';
25
+ import { promoteReadyAlerts } from '../../services/alerting/store';
26
+ import type { DriftCategory } from '../../services/audit/types';
27
+ import { runModuleHealthCheck } from '../../services/health-runner';
28
+ import type { CommandResult } from '../types';
29
+
30
+ const NO_SCHEMAS = defineEvents({});
31
+
32
+ /** Grace window before a newly-fired alert may notify. */
33
+ const DEFAULT_GRACE_MS = 60_000;
34
+
35
+ function buildDeps() {
36
+ const db = getDb();
37
+ return {
38
+ runModuleCheck: (moduleId: string) =>
39
+ runModuleHealthCheck(moduleId, db, { unattended: true, noInteractive: true }),
40
+ runBuiltinCheck: (category: DriftCategory) => runBuiltinCheckForMonitor(category),
41
+ loadModuleCoverage: () => loadModuleCoverage(db),
42
+ now: () => new Date(),
43
+ graceMs: DEFAULT_GRACE_MS,
44
+ };
45
+ }
46
+
47
+ function handleList(): CommandResult {
48
+ const rows = listMonitors(getDb());
49
+ if (rows.length === 0) {
50
+ console.log('\nNo monitors configured.\n');
51
+ console.log('A module declaring hooks.health_check.interval gets one on deploy,');
52
+ console.log('or add one directly: celilo monitor add <module> --interval 15m\n');
53
+ return { success: true, message: 'No monitors configured' };
54
+ }
55
+
56
+ const width = Math.max(6, ...rows.map((r) => r.target.length));
57
+ console.log('');
58
+ console.log(`${'TARGET'.padEnd(width)} ${'KIND'.padEnd(14)} ${'EVERY'.padEnd(6)} STATE`);
59
+ for (const monitor of rows) {
60
+ const state = monitor.enabled ? 'enabled' : 'disabled';
61
+ const suffix = monitor.lastRunAt ? '' : ' (never run)';
62
+ const every = `${monitor.intervalMinutes}m`;
63
+ console.log(
64
+ `${monitor.target.padEnd(width)} ${monitor.kind.padEnd(14)} ${every.padEnd(6)} ${state}${suffix}`,
65
+ );
66
+ }
67
+ console.log('');
68
+ return { success: true, message: `${rows.length} monitor(s)` };
69
+ }
70
+
71
+ function handleAdd(args: string[], flags: Record<string, boolean | string>): CommandResult {
72
+ const target = args[0];
73
+ if (!target) {
74
+ return { success: false, error: 'Usage: celilo monitor add <target> [--interval 15m]' };
75
+ }
76
+
77
+ const db = getDb();
78
+ if (findMonitorByTarget(db, target)) {
79
+ return { success: false, error: `A monitor for "${target}" already exists.` };
80
+ }
81
+
82
+ const interval = typeof flags.interval === 'string' ? flags.interval : '15m';
83
+ const intervalMinutes = parseIntervalMinutes(interval);
84
+ if (intervalMinutes === null) {
85
+ return {
86
+ success: false,
87
+ error: `Invalid --interval "${interval}". Use a duration like "15m", "1h", or "1d".`,
88
+ };
89
+ }
90
+
91
+ // A target naming an audit category is a built-in check; anything else is a
92
+ // module's health_check hook.
93
+ const kind: MonitorKind =
94
+ target === HEALTH_COVERAGE_CHECK || target.includes('_') ? 'builtin_check' : 'module_hook';
95
+
96
+ createMonitor(db, { kind, target, intervalMinutes });
97
+
98
+ // Creating the first monitor is also what switches the sweep on. Registering
99
+ // here rather than at install means a celilo with no monitors carries no
100
+ // subscriber and does no periodic work.
101
+ const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
102
+ try {
103
+ ensureSweepSubscriber(bus);
104
+ } finally {
105
+ bus.close();
106
+ }
107
+
108
+ return {
109
+ success: true,
110
+ message: `Monitoring ${target} every ${interval} (sweep runs on timer.tick.5m)`,
111
+ };
112
+ }
113
+
114
+ async function handleRun(args: string[]): Promise<CommandResult> {
115
+ const target = args[0];
116
+ if (!target) return { success: false, error: 'Usage: celilo monitor run <target>' };
117
+
118
+ const db = getDb();
119
+ const monitor = findMonitorByTarget(db, target);
120
+ if (!monitor) {
121
+ return {
122
+ success: false,
123
+ error: `No monitor for "${target}".\n\nRun "celilo monitor list" to see configured monitors.`,
124
+ };
125
+ }
126
+
127
+ const summary = await runOneMonitor(db, monitor, buildDeps());
128
+ promoteReadyAlerts(db, new Date());
129
+
130
+ if (summary.outcome === 'error') {
131
+ return {
132
+ success: true,
133
+ message: `${target}: check could not run — ${summary.errorMessage ?? 'unknown error'}`,
134
+ };
135
+ }
136
+ return {
137
+ success: true,
138
+ message: `${target}: ${summary.failingKeyCount} failing, ${summary.resolvedIds.length} resolved`,
139
+ };
140
+ }
141
+
142
+ function handleToggle(args: string[], enabled: boolean): CommandResult {
143
+ const target = args[0];
144
+ const verb = enabled ? 'enable' : 'disable';
145
+ if (!target) return { success: false, error: `Usage: celilo monitor ${verb} <target>` };
146
+
147
+ const db = getDb();
148
+ const monitor = findMonitorByTarget(db, target);
149
+ if (!monitor) return { success: false, error: `No monitor for "${target}".` };
150
+
151
+ setMonitorEnabled(db, monitor.id, enabled, new Date());
152
+ return { success: true, message: `Monitor for ${target} ${enabled ? 'enabled' : 'disabled'}` };
153
+ }
154
+
155
+ export async function handleMonitor(
156
+ subcommand: string | undefined,
157
+ args: string[],
158
+ flags: Record<string, boolean | string> = {},
159
+ ): Promise<CommandResult> {
160
+ switch (subcommand) {
161
+ case undefined:
162
+ case 'list':
163
+ return handleList();
164
+ case 'add':
165
+ return handleAdd(args, flags);
166
+ case 'run':
167
+ return handleRun(args);
168
+ case 'enable':
169
+ return handleToggle(args, true);
170
+ case 'disable':
171
+ return handleToggle(args, false);
172
+ default:
173
+ return {
174
+ success: false,
175
+ error: `Unknown monitor subcommand: ${subcommand}\n\nUse: list, add, run, enable, disable`,
176
+ };
177
+ }
178
+ }
@@ -0,0 +1,453 @@
1
+ /**
2
+ * `celilo person` / `route` / `escalation-policy` — who celilo can reach.
3
+ *
4
+ * One file because the three are a single concept split across three tables,
5
+ * and splitting the CLI too would mean three copies of the same lookup-by-name
6
+ * and table-printing code.
7
+ *
8
+ * Everything is addressed by name. A UUID never reaches the operator
9
+ * (CLAUDE.md), and a route is identified by `<person>/<transport>` rather than
10
+ * by an id, because that is what someone actually knows about it.
11
+ */
12
+
13
+ import { defineEvents, openBus } from '@celilo/event-bus';
14
+ import { eq } from 'drizzle-orm';
15
+ import { getEventBusPath } from '../../config/paths';
16
+ import { getDb } from '../../db/client';
17
+ import { type AlertSeverity, modules, monitors } from '../../db/schema';
18
+ import { ensureInboundSubscriber, findMonitorByTarget } from '../../services/alerting/monitors';
19
+ import {
20
+ addPolicyStep,
21
+ createPerson,
22
+ createPolicy,
23
+ createRoute,
24
+ deletePerson,
25
+ deletePolicy,
26
+ deleteRoute,
27
+ findPerson,
28
+ findPolicy,
29
+ findRoute,
30
+ listPeople,
31
+ listPolicies,
32
+ listPolicySteps,
33
+ listRoutes,
34
+ } from '../../services/alerting/people';
35
+ import { parseClockTime } from '../../services/alerting/quiet-hours';
36
+ import type { CommandResult } from '../types';
37
+
38
+ const NO_SCHEMAS = defineEvents({});
39
+
40
+ function table(headers: string[], rows: string[][]): string {
41
+ if (rows.length === 0) return '';
42
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
43
+ const line = (cells: string[]) =>
44
+ cells
45
+ .map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i])))
46
+ .join(' ')
47
+ .trimEnd();
48
+ return [line(headers), ...rows.map(line)].join('\n');
49
+ }
50
+
51
+ const flagString = (flags: Record<string, boolean | string>, name: string): string | undefined =>
52
+ typeof flags[name] === 'string' ? (flags[name] as string) : undefined;
53
+
54
+ /**
55
+ * Whether a module declares the `notification` capability.
56
+ *
57
+ * Read from the manifest rather than the capabilities table: a module can be
58
+ * imported but not yet deployed, and configuring a route ahead of deploying
59
+ * the transport is a reasonable order to work in.
60
+ */
61
+ function providesNotification(db: ReturnType<typeof getDb>, moduleId: string): boolean {
62
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
63
+ if (!module) return false;
64
+ const manifest = module.manifestData as {
65
+ provides?: { capabilities?: { name?: string }[] };
66
+ };
67
+ return Boolean(manifest.provides?.capabilities?.some((c) => c.name === 'notification'));
68
+ }
69
+
70
+ // ── celilo person ───────────────────────────────────────────────────────────
71
+
72
+ function personAdd(args: string[], flags: Record<string, boolean | string>): CommandResult {
73
+ const name = args[0];
74
+ if (!name) {
75
+ return {
76
+ success: false,
77
+ error: 'Usage: celilo person add <name> --timezone <IANA> [--quiet-hours 22:00-07:00]',
78
+ };
79
+ }
80
+
81
+ const db = getDb();
82
+ if (findPerson(db, name)) return { success: false, error: `Person "${name}" already exists.` };
83
+
84
+ const timezone = flagString(flags, 'timezone');
85
+ if (!timezone) {
86
+ return {
87
+ success: false,
88
+ error: 'A --timezone is required (e.g. America/Los_Angeles) — quiet hours are local to it.',
89
+ };
90
+ }
91
+
92
+ let quietStart: string | null = null;
93
+ let quietEnd: string | null = null;
94
+ const quiet = flagString(flags, 'quiet-hours');
95
+ if (quiet) {
96
+ const [start, end] = quiet.split('-');
97
+ if (!start || !end || parseClockTime(start) === null || parseClockTime(end) === null) {
98
+ return {
99
+ success: false,
100
+ error: `Invalid --quiet-hours "${quiet}". Use HH:MM-HH:MM, e.g. 22:00-07:00.`,
101
+ };
102
+ }
103
+ quietStart = start;
104
+ quietEnd = end;
105
+ }
106
+
107
+ createPerson(db, { name, timezone, quietHoursStart: quietStart, quietHoursEnd: quietEnd });
108
+ const quietNote = quiet ? `, quiet ${quiet}` : ', always reachable';
109
+ return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
110
+ }
111
+
112
+ function personList(): CommandResult {
113
+ const people = listPeople(getDb());
114
+ if (people.length === 0) {
115
+ console.log('\nNobody configured.\n');
116
+ console.log(
117
+ ' celilo person add peter --timezone America/Los_Angeles --quiet-hours 22:00-07:00\n',
118
+ );
119
+ return { success: true, message: 'No people configured' };
120
+ }
121
+
122
+ console.log('');
123
+ console.log(
124
+ table(
125
+ ['NAME', 'TIMEZONE', 'QUIET HOURS'],
126
+ people.map((p) => [
127
+ p.name,
128
+ p.timezone,
129
+ p.quietHoursStart && p.quietHoursEnd
130
+ ? `${p.quietHoursStart}-${p.quietHoursEnd}`
131
+ : 'always reachable',
132
+ ]),
133
+ ),
134
+ );
135
+ console.log('');
136
+ return { success: true, message: `${people.length} person(s)` };
137
+ }
138
+
139
+ function personRemove(args: string[]): CommandResult {
140
+ const name = args[0];
141
+ if (!name) return { success: false, error: 'Usage: celilo person remove <name>' };
142
+
143
+ const db = getDb();
144
+ const person = findPerson(db, name);
145
+ if (!person) return { success: false, error: `No person named "${name}".` };
146
+
147
+ // Routes cascade, and so do the escalation steps pointing at them — a step
148
+ // aimed at a deleted route would otherwise skip silently at page time.
149
+ deletePerson(db, person.id);
150
+ return { success: true, message: `Removed ${name} and their routes` };
151
+ }
152
+
153
+ export async function handlePerson(
154
+ subcommand: string | undefined,
155
+ args: string[],
156
+ flags: Record<string, boolean | string> = {},
157
+ ): Promise<CommandResult> {
158
+ switch (subcommand) {
159
+ case undefined:
160
+ case 'list':
161
+ return personList();
162
+ case 'add':
163
+ return personAdd(args, flags);
164
+ case 'remove':
165
+ return personRemove(args);
166
+ default:
167
+ return {
168
+ success: false,
169
+ error: `Unknown person subcommand: ${subcommand}\n\nUse: list, add, remove`,
170
+ };
171
+ }
172
+ }
173
+
174
+ // ── celilo route ────────────────────────────────────────────────────────────
175
+
176
+ function routeAdd(args: string[], flags: Record<string, boolean | string>): CommandResult {
177
+ const [personName, transport] = args;
178
+ const address = flagString(flags, 'address');
179
+ if (!personName || !transport || !address) {
180
+ return {
181
+ success: false,
182
+ error:
183
+ 'Usage: celilo route add <person> <transport-module> --address <addr> [--severity-floor warning|critical]',
184
+ };
185
+ }
186
+
187
+ const db = getDb();
188
+ const person = findPerson(db, personName);
189
+ if (!person) return { success: false, error: `No person named "${personName}".` };
190
+
191
+ // Check the transport BEFORE inserting. The routes table has a foreign key
192
+ // to modules, so a typo would otherwise surface as a raw
193
+ // SQLITE_CONSTRAINT_FOREIGNKEY stack trace — which tells an operator
194
+ // nothing about what they got wrong.
195
+ const module = db.select().from(modules).where(eq(modules.id, transport)).get();
196
+ if (!module) {
197
+ const available = db.select({ id: modules.id }).from(modules).all();
198
+ const providers = available.filter((m) => providesNotification(db, m.id)).map((m) => m.id);
199
+ const hint = providers.length
200
+ ? `Available notification transports: ${providers.join(', ')}`
201
+ : 'No module providing the `notification` capability is installed yet.';
202
+ return { success: false, error: `No module "${transport}" is installed.\n\n${hint}` };
203
+ }
204
+
205
+ // A route to a module that cannot send is a route that silently never pages.
206
+ if (!providesNotification(db, transport)) {
207
+ return {
208
+ success: false,
209
+ error: `Module "${transport}" does not provide the \`notification\` capability, so it cannot page anyone.`,
210
+ };
211
+ }
212
+
213
+ if (findRoute(db, person.id, transport)) {
214
+ return { success: false, error: `${personName} already has a ${transport} route.` };
215
+ }
216
+
217
+ const floor = flagString(flags, 'severity-floor') ?? 'warning';
218
+ if (floor !== 'warning' && floor !== 'critical') {
219
+ return { success: false, error: `--severity-floor must be "warning" or "critical".` };
220
+ }
221
+
222
+ // A transport that can receive is one whose module is deployed AND provides
223
+ // a receive path. Until the inbound poller lands we record the operator's
224
+ // intent; a route that cannot ack simply never stops escalation.
225
+ const canAck = flags['can-ack'] === true;
226
+
227
+ createRoute(db, {
228
+ personId: person.id,
229
+ transportModuleId: transport,
230
+ address,
231
+ severityFloor: floor as AlertSeverity,
232
+ canAck,
233
+ });
234
+ // A route that can receive is what makes inbound polling worth doing, so
235
+ // that is when the subscriber is registered.
236
+ if (canAck) {
237
+ const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
238
+ try {
239
+ ensureInboundSubscriber(bus);
240
+ } finally {
241
+ bus.close();
242
+ }
243
+ }
244
+
245
+ return {
246
+ success: true,
247
+ message: `Reaching ${personName} via ${transport} at ${address} (floor: ${floor})`,
248
+ };
249
+ }
250
+
251
+ function routeList(): CommandResult {
252
+ const db = getDb();
253
+ const routes = listRoutes(db);
254
+ if (routes.length === 0) {
255
+ console.log('\nNo routes configured — nothing can be paged.\n');
256
+ console.log(' celilo route add peter signal --address +15551234567 --can-ack\n');
257
+ return { success: true, message: 'No routes configured' };
258
+ }
259
+
260
+ const people = new Map(listPeople(db).map((p) => [p.id, p.name]));
261
+ console.log('');
262
+ console.log(
263
+ table(
264
+ ['PERSON', 'TRANSPORT', 'ADDRESS', 'FLOOR', 'ACK', 'STATE'],
265
+ routes.map((r) => [
266
+ people.get(r.personId) ?? '(unknown)',
267
+ r.transportModuleId,
268
+ r.address,
269
+ r.severityFloor,
270
+ r.canAck ? 'yes' : 'no',
271
+ r.enabled ? 'enabled' : 'disabled',
272
+ ]),
273
+ ),
274
+ );
275
+ console.log('');
276
+ return { success: true, message: `${routes.length} route(s)` };
277
+ }
278
+
279
+ function routeRemove(args: string[]): CommandResult {
280
+ const [personName, transport] = args;
281
+ if (!personName || !transport) {
282
+ return { success: false, error: 'Usage: celilo route remove <person> <transport-module>' };
283
+ }
284
+
285
+ const db = getDb();
286
+ const person = findPerson(db, personName);
287
+ if (!person) return { success: false, error: `No person named "${personName}".` };
288
+ const route = findRoute(db, person.id, transport);
289
+ if (!route) return { success: false, error: `${personName} has no ${transport} route.` };
290
+
291
+ deleteRoute(db, route.id);
292
+ return { success: true, message: `Removed ${personName}'s ${transport} route` };
293
+ }
294
+
295
+ export async function handleRoute(
296
+ subcommand: string | undefined,
297
+ args: string[],
298
+ flags: Record<string, boolean | string> = {},
299
+ ): Promise<CommandResult> {
300
+ switch (subcommand) {
301
+ case undefined:
302
+ case 'list':
303
+ return routeList();
304
+ case 'add':
305
+ return routeAdd(args, flags);
306
+ case 'remove':
307
+ return routeRemove(args);
308
+ default:
309
+ return {
310
+ success: false,
311
+ error: `Unknown route subcommand: ${subcommand}\n\nUse: list, add, remove`,
312
+ };
313
+ }
314
+ }
315
+
316
+ // ── celilo escalation-policy ────────────────────────────────────────────────
317
+
318
+ function policyAdd(args: string[]): CommandResult {
319
+ const name = args[0];
320
+ if (!name) return { success: false, error: 'Usage: celilo escalation-policy add <name>' };
321
+
322
+ const db = getDb();
323
+ if (findPolicy(db, name)) return { success: false, error: `Policy "${name}" already exists.` };
324
+
325
+ createPolicy(db, name);
326
+ return {
327
+ success: true,
328
+ message: `Created policy "${name}" — add steps with: celilo escalation-policy step ${name} <person> <transport> --after 0m`,
329
+ };
330
+ }
331
+
332
+ function policyStep(args: string[], flags: Record<string, boolean | string>): CommandResult {
333
+ const [policyName, personName, transport] = args;
334
+ if (!policyName || !personName || !transport) {
335
+ return {
336
+ success: false,
337
+ error:
338
+ 'Usage: celilo escalation-policy step <policy> <person> <transport-module> --after <minutes>',
339
+ };
340
+ }
341
+
342
+ const db = getDb();
343
+ const policy = findPolicy(db, policyName);
344
+ if (!policy) return { success: false, error: `No policy named "${policyName}".` };
345
+ const person = findPerson(db, personName);
346
+ if (!person) return { success: false, error: `No person named "${personName}".` };
347
+ const route = findRoute(db, person.id, transport);
348
+ if (!route) {
349
+ return {
350
+ success: false,
351
+ error: `${personName} has no ${transport} route. Add one first:\n celilo route add ${personName} ${transport} --address <addr>`,
352
+ };
353
+ }
354
+
355
+ const after = flagString(flags, 'after') ?? '0';
356
+ const delayMinutes = Number.parseInt(after.replace(/m$/, ''), 10);
357
+ if (!Number.isFinite(delayMinutes) || delayMinutes < 0) {
358
+ return { success: false, error: `Invalid --after "${after}". Use minutes, e.g. 0, 10, 30.` };
359
+ }
360
+
361
+ const step = addPolicyStep(db, policy.id, route.id, delayMinutes);
362
+ return {
363
+ success: true,
364
+ message: `Step ${step.stepIndex}: ${personName} via ${transport} after ${delayMinutes}m`,
365
+ };
366
+ }
367
+
368
+ function policyList(): CommandResult {
369
+ const db = getDb();
370
+ const policies = listPolicies(db);
371
+ if (policies.length === 0) {
372
+ console.log('\nNo escalation policies.\n');
373
+ console.log(' celilo escalation-policy add default\n');
374
+ return { success: true, message: 'No policies configured' };
375
+ }
376
+
377
+ const people = new Map(listPeople(db).map((p) => [p.id, p.name]));
378
+ const routes = new Map(listRoutes(db).map((r) => [r.id, r]));
379
+
380
+ console.log('');
381
+ for (const policy of policies) {
382
+ const steps = listPolicySteps(db, policy.id);
383
+ console.log(`${policy.name}${policy.bypassQuietHours ? ' (bypasses quiet hours)' : ''}`);
384
+ if (steps.length === 0) {
385
+ console.log(' (no steps — nothing will be paged)');
386
+ }
387
+ for (const step of steps) {
388
+ const route = routes.get(step.routeId);
389
+ const who = route ? (people.get(route.personId) ?? '(unknown)') : '(deleted route)';
390
+ const via = route ? route.transportModuleId : '?';
391
+ console.log(` ${step.stepIndex}. ${who} via ${via} after ${step.delayMinutes}m`);
392
+ }
393
+ console.log('');
394
+ }
395
+ return { success: true, message: `${policies.length} polic(ies)` };
396
+ }
397
+
398
+ function policyRemove(args: string[]): CommandResult {
399
+ const name = args[0];
400
+ if (!name) return { success: false, error: 'Usage: celilo escalation-policy remove <name>' };
401
+
402
+ const db = getDb();
403
+ const policy = findPolicy(db, name);
404
+ if (!policy) return { success: false, error: `No policy named "${name}".` };
405
+
406
+ deletePolicy(db, policy.id);
407
+ return { success: true, message: `Removed policy "${name}"` };
408
+ }
409
+
410
+ function policyAssign(args: string[]): CommandResult {
411
+ const [policyName, target] = args;
412
+ if (!policyName || !target) {
413
+ return { success: false, error: 'Usage: celilo escalation-policy assign <policy> <monitor>' };
414
+ }
415
+
416
+ const db = getDb();
417
+ const policy = findPolicy(db, policyName);
418
+ if (!policy) return { success: false, error: `No policy named "${policyName}".` };
419
+ const monitor = findMonitorByTarget(db, target);
420
+ if (!monitor) return { success: false, error: `No monitor for "${target}".` };
421
+
422
+ db.update(monitors)
423
+ .set({ escalationPolicyId: policy.id })
424
+ .where(eq(monitors.id, monitor.id))
425
+ .run();
426
+
427
+ return { success: true, message: `${target} now escalates via "${policyName}"` };
428
+ }
429
+
430
+ export async function handleEscalationPolicy(
431
+ subcommand: string | undefined,
432
+ args: string[],
433
+ flags: Record<string, boolean | string> = {},
434
+ ): Promise<CommandResult> {
435
+ switch (subcommand) {
436
+ case undefined:
437
+ case 'list':
438
+ return policyList();
439
+ case 'add':
440
+ return policyAdd(args);
441
+ case 'step':
442
+ return policyStep(args, flags);
443
+ case 'assign':
444
+ return policyAssign(args);
445
+ case 'remove':
446
+ return policyRemove(args);
447
+ default:
448
+ return {
449
+ success: false,
450
+ error: `Unknown escalation-policy subcommand: ${subcommand}\n\nUse: list, add, step, assign, remove`,
451
+ };
452
+ }
453
+ }