/** * `celilo person` / `route` / `escalation-policy` — who celilo can reach. * * One file because the three are a single concept split across three tables, * and splitting the CLI too would mean three copies of the same lookup-by-name * and table-printing code. * * Everything is addressed by name. A UUID never reaches the operator * (CLAUDE.md), and a route is identified by `/` rather than * by an id, because that is what someone actually knows about it. */ import { defineEvents, openBus } from '@celilo/event-bus'; import { eq } from 'drizzle-orm'; import { getEventBusPath } from '../../config/paths'; import { getDb } from '../../db/client'; import { type AlertSeverity, modules, monitors } from '../../db/schema'; import { ensureInboundSubscriber, findMonitorByTarget } from '../../services/alerting/monitors'; import { addPolicyStep, createPerson, createPolicy, createRoute, deletePerson, deletePolicy, deleteRoute, findPerson, findPolicy, findRoute, listPeople, listPolicies, listPolicySteps, listRoutes, } from '../../services/alerting/people'; import { parseClockTime } from '../../services/alerting/quiet-hours'; import { hasFlag } from '../parser'; import type { CommandResult } from '../types'; const NO_SCHEMAS = defineEvents({}); function table(headers: string[], rows: string[][]): string { if (rows.length === 0) return ''; const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))); const line = (cells: string[]) => cells .map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i]))) .join(' ') .trimEnd(); return [line(headers), ...rows.map(line)].join('\n'); } const flagString = (flags: Record, name: string): string | undefined => typeof flags[name] === 'string' ? (flags[name] as string) : undefined; /** * Whether a module declares the `notification` capability. * * Read from the manifest rather than the capabilities table: a module can be * imported but not yet deployed, and configuring a route ahead of deploying * the transport is a reasonable order to work in. */ function providesNotification(db: ReturnType, moduleId: string): boolean { const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) return false; const manifest = module.manifestData as { provides?: { capabilities?: { name?: string }[] }; }; return Boolean(manifest.provides?.capabilities?.some((c) => c.name === 'notification')); } // ── celilo person ─────────────────────────────────────────────────────────── function personAdd(args: string[], flags: Record): CommandResult { const name = args[0]; if (!name) { return { success: false, error: 'Usage: celilo person add --timezone [--quiet-hours 22:00-07:00]', }; } const db = getDb(); if (findPerson(db, name)) return { success: false, error: `Person "${name}" already exists.` }; const timezone = flagString(flags, 'timezone'); if (!timezone) { return { success: false, error: 'A --timezone is required (e.g. America/Los_Angeles) — quiet hours are local to it.', }; } let quietStart: string | null = null; let quietEnd: string | null = null; const quiet = flagString(flags, 'quiet-hours'); if (quiet) { const [start, end] = quiet.split('-'); if (!start || !end || parseClockTime(start) === null || parseClockTime(end) === null) { return { success: false, error: `Invalid --quiet-hours "${quiet}". Use HH:MM-HH:MM, e.g. 22:00-07:00.`, }; } quietStart = start; quietEnd = end; } createPerson(db, { name, timezone, quietHoursStart: quietStart, quietHoursEnd: quietEnd }); const quietNote = quiet ? `, quiet ${quiet}` : ', always reachable'; return { success: true, message: `Added ${name} (${timezone}${quietNote})` }; } function personList(flags: Record = {}): CommandResult { const people = listPeople(getDb()); // The web console's subject-to-person mapping reads this. It never invents a // person and never lets celilo fall back to `people[0]`, so it has to be able // to ASK who exists. `person:list` is already in the read-only grant set, so // the flag adds a shape rather than an authority. if (hasFlag(flags, 'json')) { const payload = people.map((p) => ({ name: p.name, timezone: p.timezone })); return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload }; } if (people.length === 0) { console.log('\nNobody configured.\n'); console.log( ' celilo person add peter --timezone America/Los_Angeles --quiet-hours 22:00-07:00\n', ); return { success: true, message: 'No people configured' }; } console.log(''); console.log( table( ['NAME', 'TIMEZONE', 'QUIET HOURS'], people.map((p) => [ p.name, p.timezone, p.quietHoursStart && p.quietHoursEnd ? `${p.quietHoursStart}-${p.quietHoursEnd}` : 'always reachable', ]), ), ); console.log(''); return { success: true, message: `${people.length} person(s)` }; } function personRemove(args: string[]): CommandResult { const name = args[0]; if (!name) return { success: false, error: 'Usage: celilo person remove ' }; const db = getDb(); const person = findPerson(db, name); if (!person) return { success: false, error: `No person named "${name}".` }; // Routes cascade, and so do the escalation steps pointing at them — a step // aimed at a deleted route would otherwise skip silently at page time. deletePerson(db, person.id); return { success: true, message: `Removed ${name} and their routes` }; } export async function handlePerson( subcommand: string | undefined, args: string[], flags: Record = {}, ): Promise { switch (subcommand) { case undefined: case 'list': return personList(flags); case 'add': return personAdd(args, flags); case 'remove': return personRemove(args); default: return { success: false, error: `Unknown person subcommand: ${subcommand}\n\nUse: list, add, remove`, }; } } // ── celilo route ──────────────────────────────────────────────────────────── function routeAdd(args: string[], flags: Record): CommandResult { const [personName, transport] = args; const address = flagString(flags, 'address'); if (!personName || !transport || !address) { return { success: false, error: 'Usage: celilo route add --address [--severity-floor warning|critical]', }; } const db = getDb(); const person = findPerson(db, personName); if (!person) return { success: false, error: `No person named "${personName}".` }; // Check the transport BEFORE inserting. The routes table has a foreign key // to modules, so a typo would otherwise surface as a raw // SQLITE_CONSTRAINT_FOREIGNKEY stack trace — which tells an operator // nothing about what they got wrong. const module = db.select().from(modules).where(eq(modules.id, transport)).get(); if (!module) { const available = db.select({ id: modules.id }).from(modules).all(); const providers = available.filter((m) => providesNotification(db, m.id)).map((m) => m.id); const hint = providers.length ? `Available notification transports: ${providers.join(', ')}` : 'No module providing the `notification` capability is installed yet.'; return { success: false, error: `No module "${transport}" is installed.\n\n${hint}` }; } // A route to a module that cannot send is a route that silently never pages. if (!providesNotification(db, transport)) { return { success: false, error: `Module "${transport}" does not provide the \`notification\` capability, so it cannot page anyone.`, }; } if (findRoute(db, person.id, transport)) { return { success: false, error: `${personName} already has a ${transport} route.` }; } const floor = flagString(flags, 'severity-floor') ?? 'warning'; if (floor !== 'warning' && floor !== 'critical') { return { success: false, error: `--severity-floor must be "warning" or "critical".` }; } // A transport that can receive is one whose module is deployed AND provides // a receive path. Until the inbound poller lands we record the operator's // intent; a route that cannot ack simply never stops escalation. const canAck = flags['can-ack'] === true; createRoute(db, { personId: person.id, transportModuleId: transport, address, severityFloor: floor as AlertSeverity, canAck, }); // A route that can receive is what makes inbound polling worth doing, so // that is when the subscriber is registered. if (canAck) { const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS }); try { ensureInboundSubscriber(bus); } finally { bus.close(); } } return { success: true, message: `Reaching ${personName} via ${transport} at ${address} (floor: ${floor})`, }; } function routeList(): CommandResult { const db = getDb(); const routes = listRoutes(db); if (routes.length === 0) { console.log('\nNo routes configured — nothing can be paged.\n'); console.log(' celilo route add peter signal --address +15551234567 --can-ack\n'); return { success: true, message: 'No routes configured' }; } const people = new Map(listPeople(db).map((p) => [p.id, p.name])); console.log(''); console.log( table( ['PERSON', 'TRANSPORT', 'ADDRESS', 'FLOOR', 'ACK', 'STATE'], routes.map((r) => [ people.get(r.personId) ?? '(unknown)', r.transportModuleId, r.address, r.severityFloor, r.canAck ? 'yes' : 'no', r.enabled ? 'enabled' : 'disabled', ]), ), ); console.log(''); return { success: true, message: `${routes.length} route(s)` }; } function routeRemove(args: string[]): CommandResult { const [personName, transport] = args; if (!personName || !transport) { return { success: false, error: 'Usage: celilo route remove ' }; } const db = getDb(); const person = findPerson(db, personName); if (!person) return { success: false, error: `No person named "${personName}".` }; const route = findRoute(db, person.id, transport); if (!route) return { success: false, error: `${personName} has no ${transport} route.` }; deleteRoute(db, route.id); return { success: true, message: `Removed ${personName}'s ${transport} route` }; } export async function handleRoute( subcommand: string | undefined, args: string[], flags: Record = {}, ): Promise { switch (subcommand) { case undefined: case 'list': return routeList(); case 'add': return routeAdd(args, flags); case 'remove': return routeRemove(args); default: return { success: false, error: `Unknown route subcommand: ${subcommand}\n\nUse: list, add, remove`, }; } } // ── celilo escalation-policy ──────────────────────────────────────────────── function policyAdd(args: string[]): CommandResult { const name = args[0]; if (!name) return { success: false, error: 'Usage: celilo escalation-policy add ' }; const db = getDb(); if (findPolicy(db, name)) return { success: false, error: `Policy "${name}" already exists.` }; createPolicy(db, name); return { success: true, message: `Created policy "${name}" — add steps with: celilo escalation-policy step ${name} --after 0m`, }; } function policyStep(args: string[], flags: Record): CommandResult { const [policyName, personName, transport] = args; if (!policyName || !personName || !transport) { return { success: false, error: 'Usage: celilo escalation-policy step --after ', }; } const db = getDb(); const policy = findPolicy(db, policyName); if (!policy) return { success: false, error: `No policy named "${policyName}".` }; const person = findPerson(db, personName); if (!person) return { success: false, error: `No person named "${personName}".` }; const route = findRoute(db, person.id, transport); if (!route) { return { success: false, error: `${personName} has no ${transport} route. Add one first:\n celilo route add ${personName} ${transport} --address `, }; } const after = flagString(flags, 'after') ?? '0'; const delayMinutes = Number.parseInt(after.replace(/m$/, ''), 10); if (!Number.isFinite(delayMinutes) || delayMinutes < 0) { return { success: false, error: `Invalid --after "${after}". Use minutes, e.g. 0, 10, 30.` }; } const step = addPolicyStep(db, policy.id, route.id, delayMinutes); return { success: true, message: `Step ${step.stepIndex}: ${personName} via ${transport} after ${delayMinutes}m`, }; } function policyList(): CommandResult { const db = getDb(); const policies = listPolicies(db); if (policies.length === 0) { console.log('\nNo escalation policies.\n'); console.log(' celilo escalation-policy add default\n'); return { success: true, message: 'No policies configured' }; } const people = new Map(listPeople(db).map((p) => [p.id, p.name])); const routes = new Map(listRoutes(db).map((r) => [r.id, r])); console.log(''); for (const policy of policies) { const steps = listPolicySteps(db, policy.id); console.log(`${policy.name}${policy.bypassQuietHours ? ' (bypasses quiet hours)' : ''}`); if (steps.length === 0) { console.log(' (no steps — nothing will be paged)'); } for (const step of steps) { const route = routes.get(step.routeId); const who = route ? (people.get(route.personId) ?? '(unknown)') : '(deleted route)'; const via = route ? route.transportModuleId : '?'; console.log(` ${step.stepIndex}. ${who} via ${via} after ${step.delayMinutes}m`); } console.log(''); } return { success: true, message: `${policies.length} polic(ies)` }; } function policyRemove(args: string[]): CommandResult { const name = args[0]; if (!name) return { success: false, error: 'Usage: celilo escalation-policy remove ' }; const db = getDb(); const policy = findPolicy(db, name); if (!policy) return { success: false, error: `No policy named "${name}".` }; deletePolicy(db, policy.id); return { success: true, message: `Removed policy "${name}"` }; } function policyAssign(args: string[]): CommandResult { const [policyName, target] = args; if (!policyName || !target) { return { success: false, error: 'Usage: celilo escalation-policy assign ' }; } const db = getDb(); const policy = findPolicy(db, policyName); if (!policy) return { success: false, error: `No policy named "${policyName}".` }; const monitor = findMonitorByTarget(db, target); if (!monitor) return { success: false, error: `No monitor for "${target}".` }; db.update(monitors) .set({ escalationPolicyId: policy.id }) .where(eq(monitors.id, monitor.id)) .run(); return { success: true, message: `${target} now escalates via "${policyName}"` }; } export async function handleEscalationPolicy( subcommand: string | undefined, args: string[], flags: Record = {}, ): Promise { switch (subcommand) { case undefined: case 'list': return policyList(); case 'add': return policyAdd(args); case 'step': return policyStep(args, flags); case 'assign': return policyAssign(args); case 'remove': return policyRemove(args); default: return { success: false, error: `Unknown escalation-policy subcommand: ${subcommand}\n\nUse: list, add, step, assign, remove`, }; } }