@checkstack/healthcheck-backend 1.16.0 → 1.18.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 (39) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/package.json +30 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  9. package/src/healthcheck-gitops-kinds.ts +17 -13
  10. package/src/index.ts +87 -6
  11. package/src/migration-chain-contract.test.ts +7 -1
  12. package/src/notification-policy.test.ts +19 -0
  13. package/src/notification-policy.ts +26 -0
  14. package/src/queue-executor.test.ts +391 -338
  15. package/src/queue-executor.ts +395 -294
  16. package/src/realtime-aggregation.ts +9 -2
  17. package/src/rollup-consumer.test.ts +191 -0
  18. package/src/rollup-consumer.ts +160 -0
  19. package/src/router.ts +103 -19
  20. package/src/schedule-jitter.test.ts +69 -0
  21. package/src/schedule-jitter.ts +50 -0
  22. package/src/schedule-reconciler.it.test.ts +453 -0
  23. package/src/schedule-reconciler.test.ts +418 -0
  24. package/src/schedule-reconciler.ts +304 -0
  25. package/src/service-batching.test.ts +98 -0
  26. package/src/service-ordering.test.ts +4 -0
  27. package/src/service-paused-filter.test.ts +14 -7
  28. package/src/service-rollup-worst-wins.test.ts +37 -4
  29. package/src/service.ts +348 -145
  30. package/src/slow-check-admission.test.ts +184 -0
  31. package/src/slow-check-admission.ts +101 -0
  32. package/src/slow-check-classifier.test.ts +155 -0
  33. package/src/slow-check-classifier.ts +137 -0
  34. package/src/slow-check-config.ts +102 -0
  35. package/src/status-page/widgets.ts +11 -1
  36. package/src/suspect-lane.test.ts +50 -0
  37. package/src/suspect-lane.ts +61 -0
  38. package/src/system-health-override.test.ts +94 -0
  39. package/src/system-health-override.ts +93 -0
@@ -10,6 +10,7 @@ import {
10
10
  assignmentArtifactType,
11
11
  checkFailedTrigger,
12
12
  createHealthCheckActions,
13
+ type HealthCheckActionDeps,
13
14
  healthCheckTriggers,
14
15
  systemDegradedTrigger,
15
16
  systemHealthChangedTrigger,
@@ -146,13 +147,18 @@ describe("assignmentArtifactType", () => {
146
147
 
147
148
  function makeService(args: {
148
149
  setAssignmentEnabledReturn?: boolean;
150
+ enqueueEnvironmentIds?: (string | null)[];
149
151
  }): HealthCheckService & { setMock: ReturnType<typeof mock> } {
150
152
  const setMock = mock(
151
153
  async (_sysId: string, _cfgId: string, _enabled: boolean) =>
152
154
  args.setAssignmentEnabledReturn ?? true,
153
155
  );
156
+ const resolveEnqueueEnvironmentIds = mock(
157
+ async () => args.enqueueEnvironmentIds ?? [null],
158
+ );
154
159
  return {
155
160
  setAssignmentEnabled: setMock,
161
+ resolveEnqueueEnvironmentIds,
156
162
  setMock,
157
163
  } as unknown as HealthCheckService & { setMock: ReturnType<typeof mock> };
158
164
  }
@@ -174,6 +180,11 @@ function makeQueueManager(): QueueEnqueueRecorder {
174
180
  return { queueManager, enqueueMock };
175
181
  }
176
182
 
183
+ // The actions only need a catalog client shape for `run_now`, which delegates
184
+ // environment resolution to the service mock, so a bare stub suffices.
185
+ const catalogClientStub =
186
+ {} as unknown as HealthCheckActionDeps["catalogClient"];
187
+
177
188
  describe("healthcheck.run_now", () => {
178
189
  it("enqueues a one-off job and emits an enqueued=true artifact", async () => {
179
190
  const service = makeService({});
@@ -182,6 +193,7 @@ describe("healthcheck.run_now", () => {
182
193
  const [runNow] = createHealthCheckActions({
183
194
  service,
184
195
  queueManager,
196
+ catalogClient: catalogClientStub,
185
197
  emitHook: emitHook as never,
186
198
  });
187
199
 
@@ -198,10 +210,42 @@ describe("healthcheck.run_now", () => {
198
210
  expect(enqueueMock.mock.calls[0]![0]).toEqual({
199
211
  configId: "cfg-1",
200
212
  systemId: "sys-1",
213
+ environmentId: null,
201
214
  });
202
215
  // run_now doesn't mutate any DB row → no hook to emit.
203
216
  expect(emitHook).not.toHaveBeenCalled();
204
217
  });
218
+
219
+ it("enqueues one job per effective environment slice", async () => {
220
+ const service = makeService({ enqueueEnvironmentIds: ["prod", "staging"] });
221
+ const { queueManager, enqueueMock } = makeQueueManager();
222
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
223
+ const [runNow] = createHealthCheckActions({
224
+ service,
225
+ queueManager,
226
+ catalogClient: catalogClientStub,
227
+ emitHook: emitHook as never,
228
+ });
229
+
230
+ const result = await runNow!.execute({
231
+ ...ctxBase,
232
+ consumedArtifacts: {},
233
+ config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
234
+ });
235
+
236
+ expect(result.success).toBe(true);
237
+ expect(enqueueMock).toHaveBeenCalledTimes(2);
238
+ expect(enqueueMock.mock.calls[0]![0]).toEqual({
239
+ configId: "cfg-1",
240
+ systemId: "sys-1",
241
+ environmentId: "prod",
242
+ });
243
+ expect(enqueueMock.mock.calls[1]![0]).toEqual({
244
+ configId: "cfg-1",
245
+ systemId: "sys-1",
246
+ environmentId: "staging",
247
+ });
248
+ });
205
249
  });
206
250
 
207
251
  describe("healthcheck.enable_assignment", () => {
@@ -212,6 +256,7 @@ describe("healthcheck.enable_assignment", () => {
212
256
  const [, enable] = createHealthCheckActions({
213
257
  service,
214
258
  queueManager,
259
+ catalogClient: catalogClientStub,
215
260
  emitHook: emitHook as never,
216
261
  });
217
262
 
@@ -236,6 +281,7 @@ describe("healthcheck.enable_assignment", () => {
236
281
  const [, enable] = createHealthCheckActions({
237
282
  service,
238
283
  queueManager,
284
+ catalogClient: catalogClientStub,
239
285
  emitHook: emitHook as never,
240
286
  });
241
287
 
@@ -260,6 +306,7 @@ describe("healthcheck.disable_assignment", () => {
260
306
  const [, , disable] = createHealthCheckActions({
261
307
  service,
262
308
  queueManager,
309
+ catalogClient: catalogClientStub,
263
310
  emitHook: emitHook as never,
264
311
  });
265
312
 
@@ -26,6 +26,8 @@
26
26
  import { z } from "zod";
27
27
  import { Versioned, type Hook } from "@checkstack/backend-api";
28
28
  import type { QueueManager } from "@checkstack/queue-api";
29
+ import type { InferClient } from "@checkstack/common";
30
+ import type { CatalogApi } from "@checkstack/catalog-common";
29
31
  import type {
30
32
  ActionDefinition,
31
33
  TriggerDefinition,
@@ -234,6 +236,7 @@ export const assignmentArtifactType = {
234
236
  export interface HealthCheckActionDeps {
235
237
  service: HealthCheckService;
236
238
  queueManager: QueueManager;
239
+ catalogClient: InferClient<typeof CatalogApi>;
237
240
  emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
238
241
  }
239
242
 
@@ -256,12 +259,25 @@ export function createHealthCheckActions(
256
259
  const queue = deps.queueManager.getQueue<HealthCheckJobPayload>(
257
260
  HEALTH_CHECK_QUEUE,
258
261
  );
259
- await queue.enqueue({
260
- configId: config.configurationId,
262
+ // Enqueue one one-off job per effective environment slice so a manual
263
+ // run covers exactly the same slices the recurring schedule does. An
264
+ // assignment with no effective environments enqueues a single env-less
265
+ // run (`environmentId: null`).
266
+ const environmentIds = await deps.service.resolveEnqueueEnvironmentIds({
261
267
  systemId: config.systemId,
268
+ configurationId: config.configurationId,
269
+ catalogClient: deps.catalogClient,
270
+ logger,
262
271
  });
272
+ for (const environmentId of environmentIds) {
273
+ await queue.enqueue({
274
+ configId: config.configurationId,
275
+ systemId: config.systemId,
276
+ environmentId,
277
+ });
278
+ }
263
279
  logger.info(
264
- `Automation enqueued run for ${config.systemId}:${config.configurationId}`,
280
+ `Automation enqueued ${environmentIds.length} run(s) for ${config.systemId}:${config.configurationId}`,
265
281
  );
266
282
  return {
267
283
  success: true,
@@ -20,6 +20,34 @@ import { Versioned } from "@checkstack/backend-api";
20
20
  * System → healthchecks extension in isolation with mock services.
21
21
  */
22
22
 
23
+ // The System extension's reconcile converges the per-env recurring jobs via
24
+ // `reconcileHealthCheckJobs`. That path has its own dedicated tests
25
+ // (schedule-reconciler.test.ts); here we feed it EMPTY db/catalog/queue stubs so
26
+ // it is a safe no-op and these tests can focus on the association logic.
27
+ // (A `mock.module` would leak process-wide and break schedule-reconciler.test.)
28
+ function emptyReconcileDbStub() {
29
+ return {
30
+ select: () => ({
31
+ from: () => ({
32
+ innerJoin: () => ({ where: () => Promise.resolve([]) }),
33
+ groupBy: () => Promise.resolve([]),
34
+ }),
35
+ }),
36
+ } as never;
37
+ }
38
+ function emptyReconcileQueueManager() {
39
+ return {
40
+ getQueue: () => ({
41
+ scheduleRecurring: async () => "job-123",
42
+ listRecurringJobs: async () => [],
43
+ getRecurringJobDetails: async () => undefined,
44
+ cancelRecurring: async () => {},
45
+ }),
46
+ } as never;
47
+ }
48
+ const emptyCatalogClient = () =>
49
+ ({ resolveSystemEnvironments: async () => [] }) as never;
50
+
23
51
  // ─── Mock Healthcheck Service ──────────────────────────────────────────────
24
52
 
25
53
  interface MockConfig {
@@ -231,7 +259,9 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
231
259
  createService: () => mockService as any,
232
260
  getHealthCheckRegistry: () => mockHCRegistry as any,
233
261
  getCollectorRegistry: () => mockCollectorRegistry as any,
234
- getQueueManager: () => ({ getQueue: () => ({ scheduleRecurring: async () => "job-123" }) } as any),
262
+ getQueueManager: () => emptyReconcileQueueManager(),
263
+ getDb: () => emptyReconcileDbStub(),
264
+ getCatalogClient: () => emptyCatalogClient(),
235
265
  };
236
266
  return buildHealthcheckKind(mockDeps);
237
267
  }
@@ -594,7 +624,9 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
594
624
  }) as never,
595
625
  getHealthCheckRegistry: () => createMockHealthCheckRegistry() as never,
596
626
  getCollectorRegistry: () => createMockCollectorRegistry() as never,
597
- getQueueManager: () => ({ getQueue: () => ({ scheduleRecurring: async () => "job-123" }) } as any),
627
+ getQueueManager: () => emptyReconcileQueueManager(),
628
+ getDb: () => emptyReconcileDbStub(),
629
+ getCatalogClient: () => emptyCatalogClient(),
598
630
  });
599
631
  }
600
632
 
@@ -25,7 +25,11 @@ import {
25
25
  enumField,
26
26
  } from "@checkstack/backend-api";
27
27
  import type { QueueManager } from "@checkstack/queue-api";
28
- import { scheduleHealthCheck } from "./queue-executor";
28
+ import type { SafeDatabase } from "@checkstack/backend-api";
29
+ import type { InferClient } from "@checkstack/common";
30
+ import type { CatalogApi } from "@checkstack/catalog-common";
31
+ import type * as schema from "./schema";
32
+ import { reconcileHealthCheckJobs } from "./schedule-reconciler";
29
33
 
30
34
  /**
31
35
  * Lazy accessor functions — populated during init(), consumed during reconcile.
@@ -37,6 +41,8 @@ interface HealthcheckGitOpsKindsDeps {
37
41
  getHealthCheckRegistry: () => HealthCheckRegistry;
38
42
  getCollectorRegistry: () => CollectorRegistry;
39
43
  getQueueManager: () => QueueManager;
44
+ getDb: () => SafeDatabase<typeof schema>;
45
+ getCatalogClient: () => InferClient<typeof CatalogApi>;
40
46
  }
41
47
 
42
48
  // ─── Healthcheck Spec Schema ───────────────────────────────────────────────
@@ -382,18 +388,16 @@ export function buildSystemHealthcheckExtension(
382
388
  notificationPolicy,
383
389
  });
384
390
 
385
- // Retrieve config to get the interval for scheduling
386
- const config = await service.getConfiguration(configId);
387
- if (config) {
388
- await scheduleHealthCheck({
389
- queueManager: deps.getQueueManager(),
390
- payload: {
391
- configId,
392
- systemId: systemEntityId,
393
- },
394
- intervalSeconds: config.intervalSeconds,
395
- });
396
- }
391
+ // Reconcile this system's per-env recurring jobs so the new
392
+ // association starts probing right away (system-scoped: add/update
393
+ // only, orphan cleanup is owned by the periodic full reconcile).
394
+ await reconcileHealthCheckJobs({
395
+ db: deps.getDb(),
396
+ queueManager: deps.getQueueManager(),
397
+ catalogClient: deps.getCatalogClient(),
398
+ logger: context.logger,
399
+ systemId: systemEntityId,
400
+ });
397
401
 
398
402
  context.logger.info(
399
403
  `GitOps: associated ${entry.ref.kind} "${entry.ref.name}" (${configId}) with System "${entity.metadata.name}" and scheduled execution`,
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  setupHealthCheckWorker,
3
- bootstrapHealthChecks,
4
3
  recomputeSystemRollupHealth,
5
4
  } from "./queue-executor";
5
+ import { reconcileHealthCheckJobs } from "./schedule-reconciler";
6
6
  import { setupRetentionJob } from "./retention-job";
7
7
  import * as schema from "./schema";
8
8
  import {
@@ -38,6 +38,7 @@ import {
38
38
  type SafeDatabase,
39
39
  type HealthCheckRegistry,
40
40
  type CollectorRegistry,
41
+ type AdvisoryLockService,
41
42
  } from "@checkstack/backend-api";
42
43
  import type { QueueManager } from "@checkstack/queue-api";
43
44
  import {
@@ -75,6 +76,9 @@ import { IncidentApi } from "@checkstack/incident-common";
75
76
  import { GitOpsApi } from "@checkstack/gitops-common";
76
77
  import { registerSearchProvider } from "@checkstack/command-backend";
77
78
  import { resolveRoute } from "@checkstack/common";
79
+ import type { InferClient } from "@checkstack/common";
80
+ import type { SignalService } from "@checkstack/signal-common";
81
+ import { setupRollupConsumer } from "./rollup-consumer";
78
82
  import { createHealthCheckCache } from "./cache";
79
83
  import { inArray, ilike } from "drizzle-orm";
80
84
 
@@ -174,6 +178,12 @@ export default createBackendPlugin({
174
178
  let gitopsCollectorRegistry: CollectorRegistry | undefined;
175
179
  let gitopsQueueManager: QueueManager | undefined;
176
180
  let gitopsConfigSecrets: HealthCheckSecretsDeps | undefined;
181
+ let gitopsCatalogClient: InferClient<typeof CatalogApi> | undefined;
182
+ // Resolved AdvisoryLockService captured in init() for use in
183
+ // afterPluginsReady (the boot reconcile serializes across pods on it).
184
+ let resolvedAdvisoryLock: AdvisoryLockService | undefined;
185
+ // SignalService captured in init() for the afterPluginsReady rollup consumer.
186
+ let resolvedSignalService: SignalService | undefined;
177
187
  let healthCheckCache:
178
188
  | ReturnType<typeof createHealthCheckCache>
179
189
  | undefined;
@@ -211,6 +221,15 @@ export default createBackendPlugin({
211
221
  throw new Error("QueueManager not initialized");
212
222
  return gitopsQueueManager;
213
223
  },
224
+ getDb: () => {
225
+ if (!gitopsDb) throw new Error("Healthcheck database not initialized");
226
+ return gitopsDb;
227
+ },
228
+ getCatalogClient: () => {
229
+ if (!gitopsCatalogClient)
230
+ throw new Error("Catalog client not initialized");
231
+ return gitopsCatalogClient;
232
+ },
214
233
  });
215
234
 
216
235
  env.registerInit({
@@ -317,6 +336,9 @@ export default createBackendPlugin({
317
336
  gitopsHealthCheckRegistry = healthCheckRegistry;
318
337
  gitopsCollectorRegistry = collectorRegistry;
319
338
  gitopsQueueManager = queueManager;
339
+ gitopsCatalogClient = rpcClient.forPlugin(CatalogApi);
340
+ resolvedAdvisoryLock = advisoryLock;
341
+ resolvedSignalService = signalService;
320
342
 
321
343
  // Bind the COMPUTE-ON-READ accessor's db + service for the `health`
322
344
  // entity (defined in register()). From here onward the entity `read`
@@ -524,8 +546,37 @@ export default createBackendPlugin({
524
546
  logger,
525
547
  signalService,
526
548
  configSecrets,
527
- recomputeSystemRollupHealth: (systemId) =>
528
- recomputeSystemRollupHealth({
549
+ // Fold active incident health overrides into the user-facing system
550
+ // health reads (worst-wins). Reuses the incident client already built
551
+ // above; maps the incident rows to the source-agnostic override shape
552
+ // the fold expects. Kept out of the shared deriver so SLO/AI/entity
553
+ // paths stay checks-only (see router.ts).
554
+ incidentHealthOverrideReader: {
555
+ getActiveOverrides: async (systemIds) => {
556
+ const { overrides } =
557
+ await incidentClient.getActiveHealthOverrides({ systemIds });
558
+ const mapped: Record<
559
+ string,
560
+ {
561
+ status: "degraded" | "unhealthy";
562
+ source: string;
563
+ reason: string;
564
+ sourceId?: string;
565
+ }[]
566
+ > = {};
567
+ for (const [systemId, list] of Object.entries(overrides)) {
568
+ mapped[systemId] = list.map((o) => ({
569
+ status: o.status,
570
+ source: "incident",
571
+ reason: o.incidentTitle,
572
+ sourceId: o.incidentId,
573
+ }));
574
+ }
575
+ return mapped;
576
+ },
577
+ },
578
+ recomputeSystemRollupHealth: async (systemId) => {
579
+ await recomputeSystemRollupHealth({
529
580
  systemId,
530
581
  // Reuse the COMPUTE-ON-READ service instance bound to the
531
582
  // `health` entity read accessor — it's the same db/registry
@@ -534,7 +585,8 @@ export default createBackendPlugin({
534
585
  getHealthEntity: () => healthEntity,
535
586
  advisoryLock,
536
587
  logger,
537
- }),
588
+ });
589
+ },
538
590
  });
539
591
  rpc.registerRouter(healthCheckRouter, healthCheckContract);
540
592
 
@@ -578,11 +630,17 @@ export default createBackendPlugin({
578
630
  }) => {
579
631
  // Store emitHook for the queue worker (Closure-based Hook Getter pattern)
580
632
  storedEmitHook = emitHook;
581
- // Bootstrap all enabled health checks
582
- await bootstrapHealthChecks({
633
+ // Converge the per-environment recurring job set at boot (schedule
634
+ // desired (config, system, env) jobs, cancel orphans incl. old-format
635
+ // ones). The periodic reconcile below keeps it converged as catalog
636
+ // membership changes.
637
+ const reconcileCatalogClient = rpcClient.forPlugin(CatalogApi);
638
+ await reconcileHealthCheckJobs({
583
639
  db: database,
584
640
  queueManager,
641
+ catalogClient: reconcileCatalogClient,
585
642
  logger,
643
+ advisoryLock: resolvedAdvisoryLock,
586
644
  });
587
645
 
588
646
  // Notification subscription specs. Per-resource group lifecycle
@@ -620,11 +678,34 @@ export default createBackendPlugin({
620
678
  for (const action of createHealthCheckActions({
621
679
  service,
622
680
  queueManager,
681
+ catalogClient: reconcileCatalogClient,
623
682
  emitHook,
624
683
  })) {
625
684
  automationActions.registerAction(action, pluginMetadata);
626
685
  }
627
686
 
687
+ // Phase 2: event-driven debounced system-rollup consumer. Under
688
+ // per-environment jobs each run writes only its own env entity; this
689
+ // subscribes to per-env `health` changes and debounces a recompute of
690
+ // the bare `<systemId>` rollup entity (+ SYSTEM_STATUS_CHANGED). Needs
691
+ // the cache/signal/lock resolved in init().
692
+ if (resolvedAdvisoryLock && resolvedSignalService && healthCheckCache) {
693
+ await setupRollupConsumer({
694
+ queueManager,
695
+ onEntityChanged: entityPoint.onEntityChanged,
696
+ service,
697
+ advisoryLock: resolvedAdvisoryLock,
698
+ signalService: resolvedSignalService,
699
+ cache: healthCheckCache,
700
+ getHealthEntity: () => healthEntity,
701
+ logger,
702
+ });
703
+ } else {
704
+ logger.warn(
705
+ "Health rollup consumer NOT wired: advisoryLock/signalService/cache unresolved after init",
706
+ );
707
+ }
708
+
628
709
  // React to catalog system deletion (tombstone) via the reactive
629
710
  // `catalog-system` entity instead of the (removed) `system.deleted`
630
711
  // hook (§10.4). `work-queue` delivery preserved: association cleanup
@@ -19,7 +19,10 @@ import { describe, expect, it } from "bun:test";
19
19
  import type { QueueManager } from "@checkstack/queue-api";
20
20
  import type { Hook } from "@checkstack/backend-api";
21
21
  import { stateThresholds } from "./state-thresholds-migrations";
22
- import { createHealthCheckActions } from "./automations";
22
+ import {
23
+ createHealthCheckActions,
24
+ type HealthCheckActionDeps,
25
+ } from "./automations";
23
26
  import type { HealthCheckService } from "./service";
24
27
 
25
28
  // `createHealthCheckActions` only constructs the action definitions; the deps
@@ -27,6 +30,8 @@ import type { HealthCheckService } from "./service";
27
30
  // Stubs are sufficient.
28
31
  const stubService = {} as unknown as HealthCheckService;
29
32
  const stubQueueManager = {} as unknown as QueueManager;
33
+ const stubCatalogClient =
34
+ {} as unknown as HealthCheckActionDeps["catalogClient"];
30
35
  const stubEmitHook = async <T>(_hook: Hook<T>, _payload: T): Promise<void> => {};
31
36
 
32
37
  describe("healthcheck config migration-chain contract", () => {
@@ -42,6 +47,7 @@ describe("healthcheck config migration-chain contract", () => {
42
47
  const actions = createHealthCheckActions({
43
48
  service: stubService,
44
49
  queueManager: stubQueueManager,
50
+ catalogClient: stubCatalogClient,
45
51
  emitHook: stubEmitHook,
46
52
  });
47
53
  expect(actions.length).toBeGreaterThan(0);
@@ -5,6 +5,7 @@ import type {
5
5
  } from "@checkstack/healthcheck-common";
6
6
  import {
7
7
  classifyTransition,
8
+ shouldEmitRollupNotification,
8
9
  shouldNotifyTransition,
9
10
  type TransitionKind,
10
11
  } from "./notification-policy";
@@ -79,6 +80,24 @@ describe("shouldNotifyTransition", () => {
79
80
  });
80
81
  });
81
82
 
83
+ describe("shouldEmitRollupNotification", () => {
84
+ // Regression guard for the duplicate-notification bug: a fanned-out system
85
+ // whose environment goes unhealthy notified once per env ("... in env X").
86
+ // The rollup notification ("... is unhealthy") describes the same outage, so
87
+ // it must be suppressed whenever an environment already notified this tick.
88
+ it("suppresses the rollup notification when an environment already notified", () => {
89
+ expect(
90
+ shouldEmitRollupNotification({ anyEnvironmentNotified: true }),
91
+ ).toBe(false);
92
+ });
93
+
94
+ it("emits the rollup notification as a fallback when no environment notified", () => {
95
+ expect(
96
+ shouldEmitRollupNotification({ anyEnvironmentNotified: false }),
97
+ ).toBe(true);
98
+ });
99
+ });
100
+
82
101
  describe("flapping scenario from the bug report", () => {
83
102
  // healthy → degraded → unhealthy → degraded → healthy
84
103
  //
@@ -54,3 +54,29 @@ export function shouldNotifyTransition(
54
54
  if (kind === "deescalation" && policy.suppressDeEscalations) return false;
55
55
  return true;
56
56
  }
57
+
58
+ /**
59
+ * Decide whether the system-ROLLUP notification should fire for a fanned-out
60
+ * system in a given tick.
61
+ *
62
+ * When a check fans out to environments, each environment that changes status
63
+ * emits its own notification ("system unhealthy in env X"). The post-loop
64
+ * rollup transition ("system unhealthy") then describes the SAME underlying
65
+ * outage, so firing it too produces the duplicate notification pair users see.
66
+ *
67
+ * The rollup change is always driven by the very environment(s) that already
68
+ * notified this tick, so the rollup notification is redundant whenever any
69
+ * environment notified. It is only emitted as a fallback when NO environment
70
+ * notified (e.g. every per-env delivery was suppressed or threw) so a real
71
+ * system status change never goes entirely unannounced.
72
+ *
73
+ * The rollup TRANSITION record and the `SYSTEM_STATUS_CHANGED` signal are
74
+ * emitted regardless — only the user-facing notification is deduplicated.
75
+ */
76
+ export function shouldEmitRollupNotification({
77
+ anyEnvironmentNotified,
78
+ }: {
79
+ anyEnvironmentNotified: boolean;
80
+ }): boolean {
81
+ return !anyEnvironmentNotified;
82
+ }