@celilo/cli 0.14.1 → 0.14.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -152,5 +152,35 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
152
152
  `${report.unsuppressed} unsuppressed`,
153
153
  `${report.notified} notified`,
154
154
  ];
155
- return { success: true, message: `alert sweep: ${parts.join(', ')}` };
155
+ // Only shown when non-zero: a quiet sweep should stay quiet. But a delivery
156
+ // that failed, was deferred, or was declined must never render as `0 notified`
157
+ // and nothing else — that is indistinguishable from "nothing needed sending",
158
+ // which is exactly how a transport that has stopped paging looks like a quiet
159
+ // night (#450).
160
+ if (report.deferred > 0) parts.push(`${report.deferred} deferred`);
161
+ if (report.deferredDelivered > 0) parts.push(`${report.deferredDelivered} deferred-delivered`);
162
+ if (report.failed > 0) parts.push(`${report.failed} FAILED`);
163
+ if (report.noPolicy > 0) parts.push(`${report.noPolicy} no-policy`);
164
+
165
+ const lines = [`alert sweep: ${parts.join(', ')}`];
166
+
167
+ // The reason escalation declined is the single most useful fact when someone
168
+ // asks "why was I not paged", so name it rather than aggregating it away.
169
+ const skipped = Object.entries(report.skipped).sort(([, a], [, b]) => b - a);
170
+ if (skipped.length > 0) {
171
+ lines.push(` not delivered: ${skipped.map(([r, n]) => `${r}×${n}`).join(', ')}`);
172
+ }
173
+ // The error itself, not just a count: the transport is loaded lazily inside
174
+ // the send, so a capability that will not load produces no other record
175
+ // anywhere — nothing ever reaches the transport's own logs.
176
+ for (const failure of report.failures) {
177
+ lines.push(` FAILED ${failure}`);
178
+ }
179
+ if (report.noPolicy > 0) {
180
+ lines.push(
181
+ ` ${report.noPolicy} live alert(s) have no escalation policy — assign one with:\n celilo escalation-policy assign <policy> <monitor>`,
182
+ );
183
+ }
184
+
185
+ return { success: true, message: lines.join('\n') };
156
186
  }
@@ -226,4 +226,94 @@ describe('runSweep', () => {
226
226
  expect(db.select().from(monitors).get()?.lastRunAt).toEqual(NOW);
227
227
  expect(monitor.lastRunAt).toBeNull();
228
228
  });
229
+
230
+ // A delivery that never happened must be distinguishable from one that was
231
+ // never needed. Both used to render as `notified: 0` and nothing else, which
232
+ // is how a firing-but-undelivered alert became undebuggable (#450).
233
+ describe('undelivered alerts are accounted for, not silently dropped', () => {
234
+ test('an alert with no escalation policy is counted, not skipped in silence', async () => {
235
+ // `notifyDepsFor` returning null IS "nobody is configured to be told" —
236
+ // the default in every other test here, which is why this went unnoticed.
237
+ const report = await runSweep(db, currentMonitors(), deps());
238
+
239
+ expect(liveAlerts()).toHaveLength(1);
240
+ expect(report.notified).toBe(0);
241
+ expect(report.noPolicy).toBe(1);
242
+ });
243
+
244
+ test('escalation declining to notify records WHICH reason', async () => {
245
+ // A fresh alert is inside its grace window, so escalation declines with
246
+ // `within_grace` — a real skip reason reached through the real code path
247
+ // rather than a stubbed outcome.
248
+ const notifyDeps = {
249
+ steps: [{ stepIndex: 0, routeId: 'route-1', delayMinutes: 0 }],
250
+ routes: new Map([['route-1', { id: 'route-1', severityFloor: 'warning', enabled: true }]]),
251
+ routeDetails: new Map([
252
+ [
253
+ 'route-1',
254
+ { id: 'route-1', personId: 'p1', address: '+15550000000', canAck: false } as never,
255
+ ],
256
+ ]),
257
+ quietHoursByPerson: new Map(),
258
+ bypassQuietHours: false,
259
+ transportFor: () => {
260
+ throw new Error('transport must not be reached for a skipped delivery');
261
+ },
262
+ mintToken: () => 'tok',
263
+ now: NOW,
264
+ } as never;
265
+
266
+ const report = await runSweep(
267
+ db,
268
+ currentMonitors(),
269
+ deps({ notifyDepsFor: () => notifyDeps }),
270
+ );
271
+
272
+ expect(report.notified).toBe(0);
273
+ expect(report.noPolicy).toBe(0);
274
+ expect(report.skipped.within_grace).toBe(1);
275
+ });
276
+
277
+ test('a transport that cannot be loaded records the error, not just a count', async () => {
278
+ // The transport is resolved lazily INSIDE the send, so a capability that
279
+ // will not load never reaches the transport's own logs. If the sweep does
280
+ // not carry the message, it exists nowhere.
281
+ const notifyDeps = (now: Date) =>
282
+ ({
283
+ steps: [{ stepIndex: 0, routeId: 'route-1', delayMinutes: 0 }],
284
+ routes: new Map([
285
+ ['route-1', { id: 'route-1', severityFloor: 'warning', enabled: true }],
286
+ ]),
287
+ routeDetails: new Map([
288
+ [
289
+ 'route-1',
290
+ { id: 'route-1', personId: 'p1', address: '+15550000000', canAck: false } as never,
291
+ ],
292
+ ]),
293
+ quietHoursByPerson: new Map(),
294
+ bypassQuietHours: false,
295
+ transportFor: () => {
296
+ throw new Error('does not provide the notification capability');
297
+ },
298
+ mintToken: () => 'tok',
299
+ now,
300
+ }) as never;
301
+
302
+ // First sweep creates the alert; the second is past the grace window, so
303
+ // escalation actually reaches the transport.
304
+ await runSweep(db, currentMonitors(), deps());
305
+ const at = later(20);
306
+ const report = await runSweep(
307
+ db,
308
+ currentMonitors(),
309
+ deps({ notifyDepsFor: () => notifyDeps(at) }, failing, at),
310
+ );
311
+
312
+ expect(report.notified).toBe(0);
313
+ expect(report.failed).toBe(1);
314
+ expect(report.failures).toHaveLength(1);
315
+ expect(report.failures[0]).toContain(PORT_CHECK);
316
+ expect(report.failures[0]).toContain('does not provide the notification capability');
317
+ });
318
+ });
229
319
  });
@@ -61,6 +61,34 @@ export interface SweepReport {
61
61
  /** Messages held over quiet hours and delivered now that the window ended. */
62
62
  deferredDelivered: number;
63
63
  failed: number;
64
+ /**
65
+ * Live alerts nobody is configured to be told about — no escalation policy on
66
+ * the monitor, so `notifyDepsFor` returns null.
67
+ *
68
+ * Counted rather than skipped in silence: "nothing needed sending" and "an
69
+ * alert is firing and no policy points at anyone" are opposite situations that
70
+ * previously rendered identically as `0 notified`.
71
+ */
72
+ noPolicy: number;
73
+ /**
74
+ * Deliveries escalation declined, keyed by its reason (`within_grace`,
75
+ * `no_eligible_route`, …).
76
+ *
77
+ * `notifyAlert` returns the reason precisely so the caller can record it — its
78
+ * own contract says a silent skip is indistinguishable from a bug. Dropping it
79
+ * here is what made a firing-but-undelivered alert undebuggable (#450).
80
+ */
81
+ skipped: Record<string, number>;
82
+ /**
83
+ * Why each failed delivery failed, as `<alert key>: <error>`.
84
+ *
85
+ * A count alone does not answer the only question that matters after a page
86
+ * did not arrive. The transport is loaded lazily *inside* `notifyAlert`'s try
87
+ * block, so "the capability would not load" and "Signal rejected the message"
88
+ * both surface here and nowhere else — there is no daemon-side log for the
89
+ * former, because nothing ever reached the daemon.
90
+ */
91
+ failures: string[];
64
92
  }
65
93
 
66
94
  /**
@@ -85,6 +113,9 @@ export async function runSweep(
85
113
  deferred: 0,
86
114
  deferredDelivered: 0,
87
115
  failed: 0,
116
+ noPolicy: 0,
117
+ skipped: {},
118
+ failures: [],
88
119
  };
89
120
 
90
121
  // 1. Run due monitors.
@@ -162,24 +193,36 @@ export async function runSweep(
162
193
  try {
163
194
  const outcome = await deliverDeferred(alert, route, notifyDeps);
164
195
  if (outcome.result === 'sent') report.deferredDelivered++;
165
- else if (outcome.result === 'failed') report.failed++;
166
- } catch {
196
+ else if (outcome.result === 'failed') {
197
+ report.failed++;
198
+ report.failures.push(`${alert.key} (deferred): ${outcome.error}`);
199
+ }
200
+ } catch (error) {
167
201
  report.failed++;
202
+ report.failures.push(
203
+ `${alert.key} (deferred): ${error instanceof Error ? error.message : String(error)}`,
204
+ );
168
205
  }
169
206
  }
170
207
 
171
208
  // 5. Notify. Re-read: the steps above changed state under us.
172
209
  for (const alert of loadAllLiveAlerts(db)) {
173
210
  const notifyDeps = deps.notifyDepsFor(alert);
174
- if (!notifyDeps) continue;
211
+ if (!notifyDeps) {
212
+ report.noPolicy++;
213
+ continue;
214
+ }
175
215
 
176
216
  let outcome: NotifyOutcome;
177
217
  try {
178
218
  outcome = await notifyAlert(alert, notifyDeps);
179
- } catch {
219
+ } catch (error) {
180
220
  // notifyAlert already converts transport errors into a `failed` outcome;
181
221
  // reaching here means something above the transport broke.
182
222
  report.failed++;
223
+ report.failures.push(
224
+ `${alert.key}: ${error instanceof Error ? error.message : String(error)}`,
225
+ );
183
226
  continue;
184
227
  }
185
228
 
@@ -197,6 +240,9 @@ export async function runSweep(
197
240
  report.deferred++;
198
241
  } else if (outcome.result === 'failed') {
199
242
  report.failed++;
243
+ report.failures.push(`${alert.key}: ${outcome.error}`);
244
+ } else if (outcome.result === 'skipped') {
245
+ report.skipped[outcome.reason] = (report.skipped[outcome.reason] ?? 0) + 1;
200
246
  }
201
247
  }
202
248