@checkstack/healthcheck-backend 1.21.3 → 1.22.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.
@@ -5,18 +5,14 @@ import {
5
5
  type CollectorRegistry,
6
6
  type SafeDatabase,
7
7
  type BaseStrategyConfig,
8
- type ConnectedClient,
9
- type TransportClient,
10
- type TransportTimings,
11
8
  type CollectorRunContext,
12
9
  type AdvisoryLockService,
13
- renderTemplatableConfig,
14
10
  withScopedTransaction,
15
11
  healthcheckExecutionHistogram,
16
12
  healthcheckPhaseHistogram,
17
13
  healthcheckDeferredCounter,
18
14
  } from "@checkstack/backend-api";
19
- import type { RunTimings } from "@checkstack/healthcheck-common";
15
+ import { runHealthCheckCollection } from "@checkstack/healthcheck-execution";
20
16
  import { QueueManager } from "@checkstack/queue-api";
21
17
  import {
22
18
  healthCheckConfigurations,
@@ -31,6 +27,7 @@ import {
31
27
  SYSTEM_STATUS_CHANGED,
32
28
  ENVIRONMENT_RESOLUTION_FAILED,
33
29
  type HealthCheckStatus,
30
+ type SystemHealthStatus,
34
31
  stripEphemeralFields,
35
32
  HEALTH_CHECK_QUEUE,
36
33
  type HealthCheckJobPayload,
@@ -42,10 +39,7 @@ export {
42
39
  HEALTH_CHECK_QUEUE,
43
40
  type HealthCheckJobPayload,
44
41
  } from "@checkstack/healthcheck-common";
45
- import {
46
- CatalogApi,
47
- type Environment,
48
- } from "@checkstack/catalog-common";
42
+ import { CatalogApi, type Environment } from "@checkstack/catalog-common";
49
43
  import {
50
44
  resolveEffectiveEnvironments,
51
45
  type EffectiveEnvironment,
@@ -54,7 +48,7 @@ import { buildHealthTransitionNotification } from "./health-notification-content
54
48
  import { MaintenanceApi } from "@checkstack/maintenance-common";
55
49
  import { IncidentApi } from "@checkstack/incident-common";
56
50
  import { NotificationApi } from "@checkstack/notification-common";
57
- import { type InferClient, extractErrorMessage} from "@checkstack/common";
51
+ import { type InferClient, extractErrorMessage } from "@checkstack/common";
58
52
  import { secretEnvMappingSchema } from "@checkstack/secrets-common";
59
53
  import type {
60
54
  SecretResolverService,
@@ -156,39 +150,6 @@ async function fetchRecentRunsForSlice(props: {
156
150
  }));
157
151
  }
158
152
 
159
- /** The known transport timing phase keys, in transport order. */
160
- const RUN_TIMING_KEYS = [
161
- "dnsMs",
162
- "connectMs",
163
- "tlsMs",
164
- "waitMs",
165
- "transferMs",
166
- "processingMs",
167
- ] as const;
168
-
169
- /**
170
- * Build the run's `metadata.timings` from a connected client's surfaced
171
- * transport timings, keeping only present, finite, non-negative phases. Returns
172
- * `undefined` when no usable phase was measured so the field is omitted (old
173
- * runs and single-phase strategies stay on the coarse fallback in the UI).
174
- */
175
- function extractRunTimings(
176
- connectedClient: ConnectedClient<TransportClient<never, unknown>> | undefined,
177
- ): RunTimings | undefined {
178
- const raw: TransportTimings | undefined = connectedClient?.timings;
179
- if (!raw) return undefined;
180
- const result: RunTimings = {};
181
- let any = false;
182
- for (const key of RUN_TIMING_KEYS) {
183
- const value = raw[key];
184
- if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
185
- result[key] = value;
186
- any = true;
187
- }
188
- }
189
- return any ? result : undefined;
190
- }
191
-
192
153
  /**
193
154
  * Emit the checkCompleted hook if available, plus the narrower
194
155
  * `checkFailed` hook when the result wasn't `healthy` (so operators
@@ -357,7 +318,10 @@ export async function recomputeSystemRollupHealth(args: {
357
318
  */
358
319
  signalService?: SignalService;
359
320
  cache?: HealthCheckCache;
360
- }): Promise<{ previousStatus: HealthCheckStatus; newStatus: HealthCheckStatus } | undefined> {
321
+ }): Promise<
322
+ | { previousStatus: SystemHealthStatus; newStatus: SystemHealthStatus }
323
+ | undefined
324
+ > {
361
325
  const {
362
326
  systemId,
363
327
  service,
@@ -403,7 +367,11 @@ export async function recomputeSystemRollupHealth(args: {
403
367
  // Cache: evict the rollup key + broadcast to the cluster on ANY per-check
404
368
  // vector change — a check that flips while the rollup enum stays put still
405
369
  // changes the rollup's `checkStatuses`, and a reader gets that vector.
406
- await cache?.reconcile({ systemId, previous: previousState, next: newState });
370
+ await cache?.reconcile({
371
+ systemId,
372
+ previous: previousState,
373
+ next: newState,
374
+ });
407
375
  // Frontend signal: only a rollup-enum transition moves the badge, so a
408
376
  // per-check-only change needs no SYSTEM_STATUS_CHANGED refetch signal.
409
377
  if (newState.status !== previousState.status) {
@@ -612,6 +580,239 @@ async function notifyStateChange(props: {
612
580
  }
613
581
  }
614
582
 
583
+ /**
584
+ * Persist ONE completed health-check run (for one system + environment +
585
+ * source) and drive EVERYTHING that must react to it, in the correct order:
586
+ * the reactive `health` entity write (which does the durable run insert +
587
+ * hourly-aggregate increment and fires the authoritative `ENTITY_CHANGED`),
588
+ * the cache reconcile, the realtime run signal, the checkCompleted/checkFailed
589
+ * automation hooks, and - on a real status transition - the transition record,
590
+ * the subscriber notification, and (for an env-less run) the system-status
591
+ * signal.
592
+ *
593
+ * This is the SINGLE post-run path. A local run (the queue executor) and a
594
+ * SATELLITE run (ingested over RPC) both call it, so a satellite-detected
595
+ * outage fires the same notifications, automations, transitions, and signals a
596
+ * local one does - previously ingest only inserted the row, so satellite runs
597
+ * were silent. Keeping it in one function is what stops that from drifting
598
+ * again; the only difference between the two callers is the `sourceId` /
599
+ * `sourceLabel` / `runTimestamp` of the run, passed in.
600
+ */
601
+ export async function persistRunAndReact(params: {
602
+ db: Db;
603
+ service: HealthCheckService;
604
+ cache: HealthCheckCache;
605
+ signalService: SignalService;
606
+ notificationClient: NotificationClient;
607
+ catalogClient: CatalogClient;
608
+ maintenanceClient: MaintenanceClient;
609
+ incidentClient: IncidentClient;
610
+ getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
611
+ getEmitHook: () => EmitHookFn | undefined;
612
+ collectorRegistry: CollectorRegistry;
613
+ advisoryLock: AdvisoryLockService;
614
+ logger: Logger;
615
+ systemId: string;
616
+ systemName: string;
617
+ configId: string;
618
+ configName?: string;
619
+ /** `null` is the env-less slice, which IS the system rollup. */
620
+ environmentId: string | null;
621
+ environmentName?: string;
622
+ status: HealthCheckStatus;
623
+ latencyMs?: number;
624
+ /** The full run result record persisted to `health_check_runs.result`. */
625
+ result: Record<string, unknown>;
626
+ /** `undefined` = local core; a satellite id otherwise. */
627
+ sourceId?: string;
628
+ sourceLabel: string;
629
+ /** Timestamp used for the hourly aggregate bucket (the run's execution time). */
630
+ runTimestamp: Date;
631
+ }): Promise<void> {
632
+ const {
633
+ db,
634
+ service,
635
+ cache,
636
+ signalService,
637
+ notificationClient,
638
+ catalogClient,
639
+ maintenanceClient,
640
+ incidentClient,
641
+ getHealthEntity,
642
+ getEmitHook,
643
+ collectorRegistry,
644
+ advisoryLock,
645
+ logger,
646
+ systemId,
647
+ systemName,
648
+ configId,
649
+ configName,
650
+ environmentId,
651
+ environmentName,
652
+ status,
653
+ latencyMs,
654
+ result,
655
+ sourceId,
656
+ sourceLabel,
657
+ runTimestamp,
658
+ } = params;
659
+
660
+ const envEntityId = encodeHealthEntityId({ systemId, environmentId });
661
+ const serializeEnvWrite = createHealthEntitySerializer({ advisoryLock })(
662
+ envEntityId,
663
+ );
664
+ // An env-less run IS the system rollup, so it broadcasts the system-level
665
+ // signal directly; a fanned-out env run leaves the rollup to the debounced
666
+ // rollup consumer (driven by this write's ENTITY_CHANGED).
667
+ const isFannedOut = environmentId !== null;
668
+
669
+ let previousState!: AggregatedHealth;
670
+ let previousStatus!: SystemHealthStatus;
671
+ let newState!: AggregatedHealth;
672
+ await writeHealthEntity({
673
+ handle: getHealthEntity?.(),
674
+ entityId: envEntityId,
675
+ apply: async () => {
676
+ // In-lock pre-run baseline: read inside the serialized critical section,
677
+ // before the insert, so a concurrent same-slice run cannot commit between
678
+ // the baseline read and this insert and make the cache gate miss a change.
679
+ previousState = await service.getSystemHealthStatus(
680
+ systemId,
681
+ environmentId,
682
+ );
683
+ previousStatus = previousState.status;
684
+ // Batch the run INSERT + aggregate SELECT/UPSERT under ONE scoped
685
+ // transaction so they commit atomically.
686
+ await withScopedTransaction(db, async (tx) => {
687
+ await tx.insert(healthCheckRuns).values({
688
+ configurationId: configId,
689
+ systemId,
690
+ environmentId,
691
+ status,
692
+ latencyMs,
693
+ result,
694
+ sourceId,
695
+ sourceLabel,
696
+ });
697
+ await incrementHourlyAggregate({
698
+ db: tx,
699
+ systemId,
700
+ configurationId: configId,
701
+ environmentId,
702
+ status,
703
+ latencyMs,
704
+ runTimestamp,
705
+ result,
706
+ collectorRegistry,
707
+ sourceLabel,
708
+ });
709
+ });
710
+ newState = await service.getSystemHealthStatus(systemId, environmentId);
711
+ return toHealthEntityView(newState);
712
+ },
713
+ serialize: serializeEnvWrite,
714
+ onError: (error) =>
715
+ logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
716
+ });
717
+
718
+ logger.debug(
719
+ `Ran health check ${configId} for system ${systemId}: ${status}`,
720
+ );
721
+
722
+ await cache.reconcile({
723
+ systemId,
724
+ environmentId,
725
+ previous: previousState,
726
+ next: newState,
727
+ });
728
+
729
+ await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
730
+ systemId,
731
+ systemName,
732
+ configurationId: configId,
733
+ // The realtime signal names the check; fall back to its id when the name
734
+ // could not be resolved (best-effort, as elsewhere).
735
+ configurationName: configName ?? configId,
736
+ status,
737
+ latencyMs,
738
+ environmentId: environmentId ?? undefined,
739
+ environmentName,
740
+ });
741
+
742
+ await emitCheckCompletedHook({
743
+ getEmitHook,
744
+ systemId,
745
+ configurationId: configId,
746
+ status,
747
+ latencyMs,
748
+ result:
749
+ (result.metadata as { collectors?: Record<string, unknown> } | undefined)
750
+ ?.collectors ?? undefined,
751
+ environmentId,
752
+ });
753
+
754
+ // `newState.status` cannot be `unknown` here (a run just completed).
755
+ if (newState.status !== previousStatus && newState.status !== "unknown") {
756
+ await recordStateTransition({
757
+ db,
758
+ systemId,
759
+ configurationId: configId,
760
+ environmentId,
761
+ fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
762
+ toStatus: newState.status,
763
+ });
764
+
765
+ await notifyStateChange({
766
+ notificationClient,
767
+ systemId,
768
+ systemName,
769
+ configurationId: configId,
770
+ configurationName: configName,
771
+ previousStatus: previousStatus === "unknown" ? "healthy" : previousStatus,
772
+ newStatus: newState.status,
773
+ environmentId,
774
+ environmentName,
775
+ service,
776
+ catalogClient,
777
+ maintenanceClient,
778
+ incidentClient,
779
+ logger,
780
+ });
781
+
782
+ if (!isFannedOut) {
783
+ await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
784
+ systemId,
785
+ previousStatus,
786
+ newStatus: newState.status,
787
+ });
788
+ }
789
+ }
790
+ }
791
+
792
+ /**
793
+ * The per-run portion of {@link persistRunAndReact}: everything that varies per
794
+ * run, WITHOUT the service dependencies (which the plugin binds once via a
795
+ * closure). The plugin hands the router a reactor of this shape so a satellite
796
+ * result drives the exact same post-run path as a local run - the deps are
797
+ * captured once, so the two callers cannot pass a different set and drift.
798
+ */
799
+ export type HealthRunReaction = Omit<
800
+ Parameters<typeof persistRunAndReact>[0],
801
+ | "db"
802
+ | "service"
803
+ | "cache"
804
+ | "signalService"
805
+ | "notificationClient"
806
+ | "catalogClient"
807
+ | "maintenanceClient"
808
+ | "incidentClient"
809
+ | "getHealthEntity"
810
+ | "getEmitHook"
811
+ | "collectorRegistry"
812
+ | "advisoryLock"
813
+ | "logger"
814
+ >;
815
+
615
816
  /**
616
817
  * Execute a health check job
617
818
  */
@@ -948,8 +1149,12 @@ async function executeHealthCheckJob(props: {
948
1149
  // event-driven rollup consumer; an env-less run mutates the bare entity
949
1150
  // (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
950
1151
  // directly.
951
- const runEnvironments: (EffectiveEnvironment | null)[] = [singleEnvironment];
952
- const isFannedOut = targetEnvironmentId !== null;
1152
+ const runEnvironments: (EffectiveEnvironment | null)[] = [
1153
+ singleEnvironment,
1154
+ ];
1155
+ // Whether this run fans out to a concrete environment is now derived inside
1156
+ // `persistRunAndReact` (an env-less run IS the rollup); nothing in the loop
1157
+ // body needs it directly.
953
1158
  for (const environment of runEnvironments) {
954
1159
  const environmentId = environment?.id ?? null;
955
1160
  // The env-qualified entity id this run mutates. For the env-less run
@@ -968,7 +1173,8 @@ async function executeHealthCheckJob(props: {
968
1173
  // stale cached status until the TTL. Assigned by whichever branch's
969
1174
  // `apply` runs; used for the transition log AND the cache reconcile.
970
1175
  let previousState!: AggregatedHealth;
971
- let previousStatus!: HealthCheckStatus;
1176
+ // May be `unknown`: the pre-run baseline of a check that had never run.
1177
+ let previousStatus!: SystemHealthStatus;
972
1178
 
973
1179
  // Curated, read-only run-context metadata exposed to collectors.
974
1180
  // Metadata only - never secrets or config. `environment` carries the
@@ -992,91 +1198,44 @@ async function executeHealthCheckJob(props: {
992
1198
  : {}),
993
1199
  };
994
1200
 
995
- // Templating context for the per-env config render pass (§6.3.3).
996
- // Carries only environment custom fields + curated check/system
997
- // metadata - never secrets. `{{ environment.baseUrl }}` resolves from
998
- // the resolved env's verbatim fields; an env-less run gets `{}` so a
999
- // reference renders to empty string (strict: false); see the debug log
1000
- // below.
1001
- const templateContext = {
1002
- environment: runContext.environment?.fields ?? {},
1003
- check: runContext.check,
1004
- system: runContext.system,
1005
- };
1201
+ // An env-less run renders any {{ environment.* }} reference to empty
1202
+ // string (the engine's buildTemplateContext maps a missing environment to
1203
+ // {}). Log it once at debug so it is visible without spamming every tick.
1006
1204
  if (!runContext.environment) {
1007
- // §11.6: render-empty when a run has no environment. An env-less run is
1008
- // a legitimate, documented configuration (the None assignment mode, or
1009
- // All-environments with no membership), and it recurs every interval -
1010
- // so this is `debug`, not `warn`, to avoid spamming the log. When an
1011
- // empty `{{ environment.* }}` render actually matters, the HTTP
1012
- // post-render `.url()` check already fails the run with a concrete
1013
- // "Rendered URL is invalid" error; we do not inspect every field here.
1014
1205
  logger.debug(
1015
1206
  `Health check ${configId} for system ${systemId} ran with no environment; ` +
1016
1207
  `any {{ environment.* }} references render to empty string`,
1017
1208
  );
1018
1209
  }
1019
1210
 
1020
- // (2) Environment/templating pass (NEW) - renders `{{ environment.* }}`
1021
- // etc. in `x-templatable` fields. Runs PER ENVIRONMENT, AFTER the secret
1022
- // resolution (secrets first, templating second - §6.3.4) and BEFORE the
1023
- // strategy client build, so each env gets its own rendered strategy
1024
- // config + client. The collector configs are rendered just before each
1025
- // collector executes (below) so the secretEnv resolution stays first.
1026
- const renderedStrategyConfig = renderTemplatableConfig({
1027
- config: strategyConfig,
1028
- schema: strategy.config.schema,
1029
- context: templateContext,
1030
- }) as BaseStrategyConfig;
1031
-
1032
- // Per-environment isolation: an unexpected failure persisting ONE
1033
- // environment's run must not abort the sibling environments' runs.
1034
- // Each iteration's run is independent (§7.2), so we log and continue.
1035
- try {
1036
- // Execute health check using createClient pattern with unified hard timeout
1037
- const start = performance.now();
1038
- let connectionTimeMs: number | undefined;
1039
- let connectedClient:
1040
- | ConnectedClient<TransportClient<never, unknown>>
1041
- | undefined;
1042
- const collectors = configRow.collectors ?? [];
1043
- const collectorResults: Record<string, unknown> = {};
1044
- let hasCollectorError = false;
1045
- let errorMessage: string | undefined;
1046
-
1047
- try {
1048
- // Platform-level hard timeout wrapping the entire execution sequence
1049
- await Promise.race([
1050
- (async () => {
1051
- // 1. Establish connection. The strategy client build moves INSIDE
1052
- // the per-env loop (§6.3.3): each env gets its own rendered config +
1053
- // client, so a single job no longer bakes in one env's rendered
1054
- // strategy config.
1055
- connectedClient = await strategy.createClient(renderedStrategyConfig);
1056
- connectionTimeMs = Math.round(performance.now() - start);
1057
-
1058
- // 2. Execute collectors in parallel
1059
- const collectorPromises = collectors.map(async (collectorEntry) => {
1060
- const registered = collectorRegistry.getCollector(
1061
- collectorEntry.collectorId,
1062
- );
1063
- if (!registered) {
1064
- logger.warn(
1065
- `Collector ${collectorEntry.collectorId} not found, skipping`,
1066
- );
1067
- return { storageKey: collectorEntry.id, skipped: true };
1068
- }
1069
-
1070
- const storageKey = collectorEntry.id;
1071
-
1072
- try {
1073
- // Resolve the collector's declared secretEnv for THIS run
1074
- // (central execution). The collector injects it and masks the
1075
- // values out of its output. A missing required secret throws
1076
- // and fails the collector clearly.
1077
- let secretEnv: Record<string, string> | undefined;
1211
+ // Per-environment isolation: an unexpected failure persisting ONE
1212
+ // environment's run must not abort the sibling environments' runs.
1213
+ // Each iteration's run is independent (§7.2), so we log and continue.
1214
+ try {
1215
+ // Execute through the SHARED engine (@checkstack/healthcheck-execution):
1216
+ // it renders the strategy + collector `x-templatable` fields against this
1217
+ // env/system's context, builds the transport client, runs the collectors,
1218
+ // and closes the client. This is the SAME engine the satellite uses, so
1219
+ // templating, secret/template ordering, and the per-collector fan-out
1220
+ // cannot drift between core and satellite - the drift that hid custom-
1221
+ // field templates on satellite runs. The core's own edges stay here as
1222
+ // hooks: DB-backed secret resolution, migrate-on-read, and the
1223
+ // assertion/ephemeral-strip post-processing.
1224
+ const outcome = await runHealthCheckCollection({
1225
+ strategy,
1226
+ strategyConfig,
1227
+ collectors: configRow.collectors ?? [],
1228
+ runContext,
1229
+ pluginId: configRow.strategyId,
1230
+ logger,
1231
+ timeoutMs: effectiveTimeout,
1232
+ hooks: {
1233
+ getCollector: (entry) =>
1234
+ collectorRegistry.getCollector(entry.collectorId),
1235
+ storageKeyOf: (entry) => entry.id,
1236
+ resolveSecretEnv: async (entry) => {
1078
1237
  const declared = secretEnvMappingSchema.safeParse(
1079
- (collectorEntry.config as { secretEnv?: unknown }).secretEnv,
1238
+ (entry.config as { secretEnv?: unknown }).secretEnv,
1080
1239
  );
1081
1240
  if (
1082
1241
  secretResolver &&
@@ -1086,96 +1245,64 @@ async function executeHealthCheckJob(props: {
1086
1245
  const resolved = await secretResolver.resolveForRun({
1087
1246
  secretEnv: declared.data,
1088
1247
  });
1089
- secretEnv = resolved.env;
1248
+ return resolved.env;
1090
1249
  }
1091
-
1092
- // Migrate the stored (UNVERSIONED) collector config via
1093
- // assume-v1-on-read: runs the declared migration chain, then
1094
- // validates. Migrations are idempotent, so an already-current
1095
- // config is a no-op. This runs BEFORE templating so the render
1096
- // pass sees the migrated shape; the secretEnv resolution above
1097
- // reads the raw `secretEnv` mapping (a constant string field
1098
- // unaffected by the strategy/collector reshapes), keeping the
1099
- // migrate -> secret resolve -> render -> execute order intact.
1100
- // Inflate this entry's secret markers / references (memory
1101
- // only) before its migrate+validate parse, mirroring the
1102
- // strategy-config inflation above.
1103
- let rawCollectorConfig = collectorEntry.config;
1250
+ return;
1251
+ },
1252
+ prepareCollectorConfig: async (entry, registered) => {
1253
+ // Inflate secret markers (memory-only) then migrate-on-read, so the
1254
+ // engine templates + executes the migrated, secret-resolved shape.
1255
+ let rawCollectorConfig = entry.config;
1104
1256
  if (internalSecrets && secretResolver) {
1105
1257
  const inflated = await inflateConfigSecrets({
1106
1258
  configurationId: configId,
1107
- scope: {
1108
- kind: "collector",
1109
- entryId: collectorEntry.id,
1110
- },
1259
+ scope: { kind: "collector", entryId: entry.id },
1111
1260
  schema: registered.collector.config.schema,
1112
- config: collectorEntry.config,
1261
+ config: entry.config,
1113
1262
  deps: { internalSecrets, secretResolver },
1114
1263
  });
1115
1264
  rawCollectorConfig = inflated.config;
1116
1265
  }
1117
- const migratedCollectorConfig =
1266
+ // `parseAssumingV1` returns the collector's own (generic
1267
+ // `unknown`) config type; the engine templates it as a record, so
1268
+ // narrow to the object shape every collector config actually is.
1269
+ const parsed =
1118
1270
  await registered.collector.config.parseAssumingV1(
1119
1271
  rawCollectorConfig,
1120
1272
  );
1121
-
1122
- // (2) Environment/templating pass for the collector config -
1123
- // runs AFTER the secretEnv resolution above (secrets first,
1124
- // templating second) and renders `{{ environment.* }}` in this
1125
- // collector's `x-templatable` fields against the per-env context.
1126
- const renderedCollectorConfig = renderTemplatableConfig({
1127
- config: migratedCollectorConfig,
1128
- schema: registered.collector.config.schema,
1129
- context: templateContext,
1130
- });
1131
-
1132
- const collectorResult = await registered.collector.execute({
1133
- config: renderedCollectorConfig,
1134
- client: connectedClient!.client,
1135
- pluginId: configRow.strategyId,
1136
- runContext,
1137
- ...(secretEnv ? { secretEnv } : {}),
1138
- });
1139
-
1140
- // Check for collector-level error
1141
- let collectorError: string | undefined;
1142
- if (collectorResult.error) {
1143
- collectorError = collectorResult.error;
1144
- }
1145
-
1146
- // Evaluate per-collector assertions (plain fields + JSONPath).
1147
- // ALL outcomes are stored (pass AND fail) so assertions are
1148
- // analyzable over time, not only visible on failure.
1273
+ return parsed as Record<string, unknown>;
1274
+ },
1275
+ mapResult: ({ entry, registered, collectorResult }) => {
1276
+ const collectorError = collectorResult.error;
1149
1277
  let assertionFailed: string | undefined;
1150
1278
  let assertionOutcomes: AssertionOutcome[] = [];
1151
1279
  if (collectorResult.result) {
1152
1280
  const evaluation = evaluateCollectorAssertionOutcomes({
1153
- assertions: collectorEntry.assertions,
1281
+ assertions: entry.assertions,
1154
1282
  result: collectorResult.result as Record<string, unknown>,
1155
1283
  });
1156
1284
  assertionFailed = evaluation.firstFailureMessage;
1157
1285
  assertionOutcomes = evaluation.outcomes;
1158
1286
  if (assertionFailed) {
1159
1287
  logger.debug(
1160
- `Collector ${storageKey} assertion failed: ${assertionFailed}`,
1288
+ `Collector ${entry.id} assertion failed: ${assertionFailed}`,
1161
1289
  );
1162
1290
  }
1163
1291
  }
1164
-
1165
- // Strip ephemeral fields before storage
1166
1292
  const strippedResult = stripEphemeralFields(
1167
1293
  collectorResult.result as Record<string, unknown>,
1168
1294
  registered.collector.result.schema,
1169
1295
  );
1170
-
1171
1296
  return {
1172
- storageKey,
1173
- skipped: false,
1174
- success: true,
1175
- collectorError,
1176
- assertionFailed,
1177
- result: {
1178
- _collectorId: collectorEntry.collectorId,
1297
+ storageKey: entry.id,
1298
+ success: !collectorError && !assertionFailed,
1299
+ error:
1300
+ collectorError ??
1301
+ (assertionFailed
1302
+ ? `Assertion failed: ${assertionFailed}`
1303
+ : undefined),
1304
+ storedResult: {
1305
+ _collectorId: entry.collectorId,
1179
1306
  _assertionFailed: assertionFailed,
1180
1307
  _collectorError: collectorError,
1181
1308
  ...(assertionOutcomes.length > 0
@@ -1184,417 +1311,266 @@ async function executeHealthCheckJob(props: {
1184
1311
  ...strippedResult,
1185
1312
  },
1186
1313
  };
1187
- } catch (error) {
1188
- const errorStr =
1189
- extractErrorMessage(error);
1190
- logger.debug(`Collector ${storageKey} failed: ${errorStr}`);
1314
+ },
1315
+ mapError: ({ entry, error }) => {
1316
+ const errorStr = extractErrorMessage(error);
1317
+ logger.debug(`Collector ${entry.id} failed: ${errorStr}`);
1191
1318
  return {
1192
- storageKey,
1193
- skipped: false,
1319
+ storageKey: entry.id,
1194
1320
  success: false,
1195
1321
  error: errorStr,
1196
- result: {
1197
- _collectorId: collectorEntry.collectorId,
1322
+ storedResult: {
1323
+ _collectorId: entry.collectorId,
1198
1324
  _assertionFailed: undefined,
1199
1325
  _collectorError: errorStr,
1200
1326
  },
1201
1327
  };
1202
- }
1203
- });
1328
+ },
1329
+ },
1330
+ });
1204
1331
 
1205
- // Wait for all collectors to complete
1206
- const settledResults = await Promise.allSettled(collectorPromises);
1332
+ if (outcome.aborted) {
1333
+ // The transport itself failed: the client build threw, or the hard
1334
+ // timeout fired. This is a transport failure, distinct from a completed
1335
+ // run whose collectors reported problems, so it takes the failure
1336
+ // result shape and (deliberately, matching prior behaviour) skips the
1337
+ // checkCompleted hook + SYSTEM_STATUS_CHANGED signal the success path
1338
+ // emits.
1339
+ const finalError = outcome.errorMessage;
1340
+
1341
+ const result = {
1342
+ status: "unhealthy" as const,
1343
+ latencyMs: outcome.latencyMs,
1344
+ message: finalError,
1345
+ metadata: {
1346
+ connected: outcome.connected,
1347
+ error: finalError,
1348
+ },
1349
+ };
1350
+ // Persist the run + aggregate THROUGH the reactive `health` entity:
1351
+ // `apply` does the durable write and returns the freshly-computed view.
1352
+ // The framework snapshots `prev` via `read` BEFORE this insert, so a real
1353
+ // status change emits exactly one correct `ENTITY_CHANGED` (§10.3). The
1354
+ // computed aggregated state is stashed for the transition/notify path.
1355
+ let newState!: AggregatedHealth;
1356
+ await writeHealthEntity({
1357
+ handle: getHealthEntity?.(),
1358
+ entityId: envEntityId,
1359
+ apply: async () => {
1360
+ // In-lock pre-run baseline (see the `previousState` declaration): read
1361
+ // here, inside the serialized critical section, before the insert.
1362
+ previousState = await service.getSystemHealthStatus(
1363
+ systemId,
1364
+ environmentId,
1365
+ );
1366
+ previousStatus = previousState.status;
1367
+ // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1368
+ // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1369
+ // which also makes the run and its aggregate commit atomically.
1370
+ await withScopedTransaction(db, async (tx) => {
1371
+ await tx.insert(healthCheckRuns).values({
1372
+ configurationId: configId,
1373
+ systemId,
1374
+ environmentId,
1375
+ status: result.status,
1376
+ latencyMs: result.latencyMs,
1377
+ result: { ...result } as Record<string, unknown>,
1378
+ sourceId: undefined,
1379
+ sourceLabel: "Local",
1380
+ });
1207
1381
 
1208
- // Process results from all collectors
1209
- for (const settled of settledResults) {
1210
- if (settled.status === "rejected") {
1211
- // This shouldn't happen since we catch errors above, but handle it
1212
- hasCollectorError = true;
1213
- if (!errorMessage) errorMessage = String(settled.reason);
1214
- continue;
1215
- }
1382
+ await incrementHourlyAggregate({
1383
+ db: tx,
1384
+ systemId,
1385
+ configurationId: configId,
1386
+ environmentId,
1387
+ status: result.status,
1388
+ latencyMs: result.latencyMs,
1389
+ runTimestamp: new Date(),
1390
+ result: { ...result } as Record<string, unknown>,
1391
+ collectorRegistry,
1392
+ sourceLabel: "Local",
1393
+ });
1394
+ });
1216
1395
 
1217
- const result = settled.value;
1218
- if (result.skipped) continue;
1219
-
1220
- // Store the result
1221
- collectorResults[result.storageKey] = result.result;
1222
-
1223
- // Track errors
1224
- if (
1225
- !result.success ||
1226
- result.collectorError ||
1227
- result.assertionFailed
1228
- ) {
1229
- hasCollectorError = true;
1230
- if (!errorMessage) {
1231
- errorMessage =
1232
- result.error ||
1233
- result.collectorError ||
1234
- (result.assertionFailed
1235
- ? `Assertion failed: ${result.assertionFailed}`
1236
- : undefined);
1237
- }
1238
- }
1239
- }
1240
- })(),
1241
- new Promise<never>((_, reject) =>
1242
- setTimeout(
1243
- () =>
1244
- reject(
1245
- new Error(`Execution timeout after ${effectiveTimeout}ms`),
1396
+ // Env-scoped view: the per-env entity reflects only this env's runs.
1397
+ // Runs as its own batched read AFTER the write commits, so it sees
1398
+ // the just-inserted run.
1399
+ newState = await service.getSystemHealthStatus(
1400
+ systemId,
1401
+ environmentId,
1402
+ );
1403
+ return toHealthEntityView(newState);
1404
+ },
1405
+ serialize: serializeEnvWrite,
1406
+ onError: (error) =>
1407
+ logger.warn(
1408
+ `Failed to mirror health entity for ${envEntityId}`,
1409
+ error,
1246
1410
  ),
1247
- effectiveTimeout,
1248
- ),
1249
- ),
1250
- ]);
1251
- } catch (error) {
1252
- const latencyMs = Math.round(performance.now() - start);
1253
- const caughtError =
1254
- extractErrorMessage(error);
1255
-
1256
- // Use a specific error message if available, otherwise use the caught error
1257
- const finalError = errorMessage || caughtError;
1258
-
1259
- const result = {
1260
- status: "unhealthy" as const,
1261
- latencyMs,
1262
- message: finalError,
1263
- metadata: {
1264
- connected: !!connectedClient,
1265
- error: finalError,
1266
- },
1267
- };
1411
+ });
1412
+
1413
+ logger.debug(
1414
+ `Health check ${configId} for system ${systemId} failed: ${finalError}`,
1415
+ );
1268
1416
 
1269
- // Persist the run + aggregate THROUGH the reactive `health` entity:
1270
- // `apply` does the durable write and returns the freshly-computed view.
1271
- // The framework snapshots `prev` via `read` BEFORE this insert, so a real
1272
- // status change emits exactly one correct `ENTITY_CHANGED` (§10.3). The
1273
- // computed aggregated state is stashed for the transition/notify path.
1274
- let newState!: AggregatedHealth;
1275
- await writeHealthEntity({
1276
- handle: getHealthEntity?.(),
1277
- entityId: envEntityId,
1278
- apply: async () => {
1279
- // In-lock pre-run baseline (see the `previousState` declaration): read
1280
- // here, inside the serialized critical section, before the insert.
1281
- previousState = await service.getSystemHealthStatus(
1417
+ // Reconcile this environment's cached status: evict + broadcast to the
1418
+ // cluster ONLY when the per-check vector actually changed (a run that
1419
+ // leaves every check's status unchanged keeps the cache warm instead of
1420
+ // thrashing it every tick). The rollup key is reconciled separately by
1421
+ // the debounced rollup consumer (recomputeSystemRollupHealth), also
1422
+ // vector-gated.
1423
+ await cache.reconcile({
1282
1424
  systemId,
1283
1425
  environmentId,
1284
- );
1285
- previousStatus = previousState.status;
1286
- // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1287
- // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1288
- // which also makes the run and its aggregate commit atomically.
1289
- await withScopedTransaction(db, async (tx) => {
1290
- await tx.insert(healthCheckRuns).values({
1291
- configurationId: configId,
1426
+ previous: previousState,
1427
+ next: newState,
1428
+ });
1429
+
1430
+ await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1431
+ systemId,
1432
+ systemName,
1433
+ configurationId: configId,
1434
+ configurationName: configRow.configName,
1435
+ status: result.status,
1436
+ latencyMs: result.latencyMs,
1437
+ // Env-scoped fan-out: `environment` is null for the env-less run, so
1438
+ // `?.` yields undefined and those runs broadcast exactly as before.
1439
+ environmentId: environment?.id,
1440
+ environmentName: environment?.name,
1441
+ });
1442
+
1443
+ // `newState.status` cannot be `unknown` here - a run just completed, so
1444
+ // the check has a measurement - but narrowing it keeps that guarantee
1445
+ // explicit rather than asserted with a cast.
1446
+ if (
1447
+ newState.status !== previousStatus &&
1448
+ newState.status !== "unknown"
1449
+ ) {
1450
+ // Record the aggregate transition so the sensing layer has a
1451
+ // reliable "in status since" for every status (Wave 2).
1452
+ await recordStateTransition({
1453
+ db,
1292
1454
  systemId,
1455
+ configurationId: configId,
1293
1456
  environmentId,
1294
- status: result.status,
1295
- latencyMs: result.latencyMs,
1296
- result: { ...result } as Record<string, unknown>,
1297
- sourceId: undefined,
1298
- sourceLabel: "Local",
1457
+ // NULL means "no prior measured status" - the column is nullable for
1458
+ // exactly this first-measurement case, so a system whose checks had
1459
+ // never run records an honest `null -> healthy` rather than
1460
+ // pretending it was healthy all along.
1461
+ fromStatus:
1462
+ previousStatus === "unknown" ? undefined : previousStatus,
1463
+ toStatus: newState.status,
1299
1464
  });
1300
1465
 
1301
- await incrementHourlyAggregate({
1302
- db: tx,
1466
+ await notifyStateChange({
1467
+ notificationClient,
1303
1468
  systemId,
1469
+ systemName,
1304
1470
  configurationId: configId,
1471
+ configurationName: configRow.configName,
1472
+ // A first measurement is not a transition anyone asked to hear about
1473
+ // when it lands healthy; `notifyStateChange` decides, and it needs a
1474
+ // concrete previous status to compare against.
1475
+ previousStatus:
1476
+ previousStatus === "unknown" ? "healthy" : previousStatus,
1477
+ newStatus: newState.status,
1305
1478
  environmentId,
1306
- status: result.status,
1307
- latencyMs: result.latencyMs,
1308
- runTimestamp: new Date(),
1309
- result: { ...result } as Record<string, unknown>,
1310
- collectorRegistry,
1311
- sourceLabel: "Local",
1479
+ environmentName: environment?.name,
1480
+ service,
1481
+ catalogClient,
1482
+ maintenanceClient,
1483
+ incidentClient,
1484
+ logger,
1312
1485
  });
1313
- });
1314
-
1315
- // Env-scoped view: the per-env entity reflects only this env's runs.
1316
- // Runs as its own batched read AFTER the write commits, so it sees
1317
- // the just-inserted run.
1318
- newState = await service.getSystemHealthStatus(systemId, environmentId);
1319
- return toHealthEntityView(newState);
1320
- },
1321
- serialize: serializeEnvWrite,
1322
- onError: (error) =>
1323
- logger.warn(
1324
- `Failed to mirror health entity for ${envEntityId}`,
1325
- error,
1326
- ),
1327
- });
1486
+ }
1328
1487
 
1329
- logger.debug(
1330
- `Health check ${configId} for system ${systemId} failed: ${finalError}`,
1331
- );
1488
+ // This environment's run is done (failed). Continue to the next
1489
+ // effective environment rather than ending the whole job.
1490
+ continue;
1491
+ }
1332
1492
 
1333
- // Reconcile this environment's cached status: evict + broadcast to the
1334
- // cluster ONLY when the per-check vector actually changed (a run that
1335
- // leaves every check's status unchanged keeps the cache warm instead of
1336
- // thrashing it every tick). The rollup key is reconciled separately by
1337
- // the debounced rollup consumer (recomputeSystemRollupHealth), also
1338
- // vector-gated.
1339
- await cache.reconcile({
1340
- systemId,
1341
- environmentId,
1342
- previous: previousState,
1343
- next: newState,
1344
- });
1493
+ // A COMPLETED run: the client built and the collectors ran. Its status is
1494
+ // decided by the collectors - a collector error or failed assertion
1495
+ // downgrades it - exactly as before.
1496
+ const status = outcome.hasCollectorError ? "unhealthy" : "healthy";
1497
+ const totalLatencyMs = outcome.latencyMs;
1498
+
1499
+ // Transport sub-phase timings measured AT THE PROBE and already filtered by
1500
+ // the engine to present phases. The satellite surfaces the same shape for
1501
+ // remote runs, so a run's `metadata.timings` is identical wherever it ran.
1502
+ const timings = outcome.clientTimings;
1503
+
1504
+ // Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
1505
+ // network sub-phases. The `phase` breakdown tells "slow target" (`wait`
1506
+ // grows) apart from "slow connection" (`connect`/`tls` grow) apart from
1507
+ // platform delay.
1508
+ healthcheckExecutionHistogram().record(totalLatencyMs, { status });
1509
+ if (timings) {
1510
+ for (const [phase, value] of Object.entries(timings)) {
1511
+ if (typeof value === "number" && Number.isFinite(value)) {
1512
+ healthcheckPhaseHistogram().record(value, {
1513
+ phase: phase.replace(/Ms$/, ""),
1514
+ });
1515
+ }
1516
+ }
1517
+ }
1345
1518
 
1346
- await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1347
- systemId,
1348
- systemName,
1349
- configurationId: configId,
1350
- configurationName: configRow.configName,
1351
- status: result.status,
1352
- latencyMs: result.latencyMs,
1353
- // Env-scoped fan-out: `environment` is null for the env-less run, so
1354
- // `?.` yields undefined and those runs broadcast exactly as before.
1355
- environmentId: environment?.id,
1356
- environmentName: environment?.name,
1357
- });
1519
+ const result = {
1520
+ status: status as "healthy" | "unhealthy",
1521
+ latencyMs: totalLatencyMs,
1522
+ message: outcome.hasCollectorError
1523
+ ? `Check failed: ${outcome.errorMessage}`
1524
+ : `Completed in ${totalLatencyMs}ms`,
1525
+ metadata: {
1526
+ connected: true,
1527
+ connectionTimeMs: outcome.connectionTimeMs,
1528
+ ...(timings ? { timings } : {}),
1529
+ collectors: outcome.collectorResults,
1530
+ },
1531
+ };
1358
1532
 
1359
- if (newState.status !== previousStatus) {
1360
- // Record the aggregate transition so the sensing layer has a
1361
- // reliable "in status since" for every status (Wave 2).
1362
- await recordStateTransition({
1533
+ // Persist this run and drive everything that reacts to it - the reactive
1534
+ // entity write, cache reconcile, realtime signal, automation hooks,
1535
+ // transition record, and subscriber notification - through the ONE
1536
+ // shared post-run path. Satellite-result ingest calls the same function,
1537
+ // so a satellite-detected change reacts identically and the two paths
1538
+ // cannot drift.
1539
+ await persistRunAndReact({
1363
1540
  db,
1364
- systemId,
1365
- configurationId: configId,
1366
- environmentId,
1367
- fromStatus: previousStatus,
1368
- toStatus: newState.status,
1369
- });
1370
-
1371
- await notifyStateChange({
1372
- notificationClient,
1373
- systemId,
1374
- systemName,
1375
- configurationId: configId,
1376
- configurationName: configRow.configName,
1377
- previousStatus,
1378
- newStatus: newState.status,
1379
- environmentId,
1380
- environmentName: environment?.name,
1381
1541
  service,
1542
+ cache,
1543
+ signalService,
1544
+ notificationClient,
1382
1545
  catalogClient,
1383
1546
  maintenanceClient,
1384
1547
  incidentClient,
1548
+ getHealthEntity,
1549
+ getEmitHook,
1550
+ collectorRegistry,
1551
+ advisoryLock,
1385
1552
  logger,
1386
- });
1387
- }
1388
-
1389
- // This environment's run is done (failed). Continue to the next
1390
- // effective environment rather than ending the whole job.
1391
- continue;
1392
- } finally {
1393
- if (connectedClient) {
1394
- try {
1395
- connectedClient.close();
1396
- } catch (error) {
1397
- logger.warn(`Failed to close connection: ${error}`);
1398
- }
1399
- }
1400
- }
1401
-
1402
- // Determine health status based on collector results
1403
- const status = hasCollectorError ? "unhealthy" : "healthy";
1404
- const totalLatencyMs = Math.round(performance.now() - start);
1405
-
1406
- // Lift the strategy's structured transport timings (DNS / connect / TLS /
1407
- // wait / transfer / processing) into the run metadata when the connected
1408
- // client surfaced any. Strategies that cannot measure sub-phases leave this
1409
- // undefined and the frontend falls back to the coarse connection split.
1410
- const timings = extractRunTimings(connectedClient);
1411
-
1412
- // Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
1413
- // network sub-phases. The `phase` breakdown is what tells "slow target"
1414
- // (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
1415
- // grow under a same-host stampede) apart from platform delay.
1416
- healthcheckExecutionHistogram().record(totalLatencyMs, { status });
1417
- if (timings) {
1418
- for (const [phase, value] of Object.entries(timings)) {
1419
- if (typeof value === "number" && Number.isFinite(value)) {
1420
- healthcheckPhaseHistogram().record(value, {
1421
- phase: phase.replace(/Ms$/, ""),
1422
- });
1423
- }
1424
- }
1425
- }
1426
-
1427
- const result = {
1428
- status: status as "healthy" | "unhealthy",
1429
- latencyMs: totalLatencyMs,
1430
- message: hasCollectorError
1431
- ? `Check failed: ${errorMessage}`
1432
- : `Completed in ${totalLatencyMs}ms`,
1433
- metadata: {
1434
- connected: true,
1435
- connectionTimeMs,
1436
- ...(timings ? { timings } : {}),
1437
- collectors: collectorResults,
1438
- },
1439
- };
1440
-
1441
- // Persist the run + aggregate THROUGH the reactive `health` entity on
1442
- // every run (§10.3): `apply` does the durable write (insert + hourly
1443
- // aggregate) and returns the freshly-computed view. The framework
1444
- // snapshots `prev` via the COMPUTE-ON-READ accessor BEFORE this insert, so
1445
- // an unchanged aggregate is a no-op and a real status change drives the
1446
- // directional/umbrella trigger events via `deriveHealthTriggerEvents` —
1447
- // exactly one correct `ENTITY_CHANGED` with accurate prev → next.
1448
- let newState!: AggregatedHealth;
1449
- await writeHealthEntity({
1450
- handle: getHealthEntity?.(),
1451
- entityId: envEntityId,
1452
- apply: async () => {
1453
- // In-lock pre-run baseline (see the `previousState` declaration): read
1454
- // here, inside the serialized critical section, before the insert.
1455
- previousState = await service.getSystemHealthStatus(
1456
1553
  systemId,
1554
+ systemName,
1555
+ configId,
1556
+ configName: configRow.configName,
1457
1557
  environmentId,
1458
- );
1459
- previousStatus = previousState.status;
1460
- // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1461
- // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1462
- // which also makes the run and its aggregate commit atomically.
1463
- await withScopedTransaction(db, async (tx) => {
1464
- // Store result (spread to convert structured type to plain record for jsonb)
1465
- await tx.insert(healthCheckRuns).values({
1466
- configurationId: configId,
1467
- systemId,
1468
- environmentId,
1469
- status: result.status,
1470
- latencyMs: result.latencyMs,
1471
- result: { ...result } as Record<string, unknown>,
1472
- sourceId: undefined,
1473
- sourceLabel: "Local",
1474
- });
1475
-
1476
- // Trigger incremental hourly aggregation
1477
- await incrementHourlyAggregate({
1478
- db: tx,
1479
- systemId,
1480
- configurationId: configId,
1481
- environmentId,
1482
- status: result.status,
1483
- latencyMs: result.latencyMs,
1484
- runTimestamp: new Date(),
1485
- result: { ...result } as Record<string, unknown>,
1486
- collectorRegistry,
1487
- sourceLabel: "Local",
1488
- });
1489
- });
1490
-
1491
- // Env-scoped view: the per-env entity reflects only this env's runs.
1492
- // Runs as its own batched read AFTER the write commits, so it sees the
1493
- // just-inserted run.
1494
- newState = await service.getSystemHealthStatus(systemId, environmentId);
1495
- return toHealthEntityView(newState);
1496
- },
1497
- serialize: serializeEnvWrite,
1498
- onError: (error) =>
1499
- logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
1500
- });
1501
-
1502
- logger.debug(
1503
- `Ran health check ${configId} for system ${systemId}: ${result.status}`,
1504
- );
1505
-
1506
- // Reconcile this environment's cached status: evict + broadcast to the
1507
- // cluster ONLY when the per-check vector actually changed (a steady-state
1508
- // healthy run keeps the cache warm). The rollup key is reconciled by the
1509
- // debounced rollup consumer (recomputeSystemRollupHealth), also vector-gated.
1510
- await cache.reconcile({
1511
- systemId,
1512
- environmentId,
1513
- previous: previousState,
1514
- next: newState,
1515
- });
1516
-
1517
- // Broadcast enriched signal for realtime frontend updates (e.g., terminal feed)
1518
- await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1519
- systemId,
1520
- systemName,
1521
- configurationId: configId,
1522
- configurationName: configRow.configName,
1523
- status: result.status,
1524
- latencyMs: result.latencyMs,
1525
- // Env-scoped fan-out: `environment` is null for the env-less run, so
1526
- // `?.` yields undefined and those runs broadcast exactly as before.
1527
- environmentId: environment?.id,
1528
- environmentName: environment?.name,
1529
- });
1530
-
1531
- await emitCheckCompletedHook({
1532
- getEmitHook,
1533
- systemId,
1534
- configurationId: configId,
1535
- status: result.status,
1536
- latencyMs: result.latencyMs,
1537
- result: (result.metadata?.collectors as Record<string, unknown>) ?? undefined,
1538
- environmentId,
1539
- });
1540
-
1541
- if (newState.status !== previousStatus) {
1542
- // Record the aggregate transition so the sensing layer has a
1543
- // reliable "in status since" for every status (Wave 2).
1544
- await recordStateTransition({
1545
- db,
1546
- systemId,
1547
- configurationId: configId,
1548
- environmentId,
1549
- fromStatus: previousStatus,
1550
- toStatus: newState.status,
1551
- });
1552
-
1553
- await notifyStateChange({
1554
- notificationClient,
1555
- systemId,
1556
- systemName,
1557
- configurationId: configId,
1558
- configurationName: configRow.configName,
1559
- previousStatus,
1560
- newStatus: newState.status,
1561
- environmentId,
1562
- environmentName: environment?.name,
1563
- service,
1564
- catalogClient,
1565
- maintenanceClient,
1566
- incidentClient,
1567
- logger,
1568
- });
1569
-
1570
- // The system-level `SYSTEM_STATUS_CHANGED` signal must carry the ROLLUP
1571
- // status, not a per-env status. When fanned out, the post-loop rollup
1572
- // write broadcasts it once with the worst-status rollup; emitting it here
1573
- // per env would send up to N system-level signals/tick carrying per-env
1574
- // status. Only the env-less run (which IS the rollup — `!isFannedOut`)
1575
- // broadcasts the system-level signal from inside the loop.
1576
- if (!isFannedOut) {
1577
- await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
1578
- systemId,
1579
- previousStatus: previousStatus as HealthCheckStatus,
1580
- newStatus: newState.status,
1558
+ environmentName: environment?.name,
1559
+ status: result.status,
1560
+ latencyMs: result.latencyMs,
1561
+ result: { ...result },
1562
+ sourceId: undefined,
1563
+ sourceLabel: "Local",
1564
+ runTimestamp: new Date(),
1581
1565
  });
1566
+ } catch (envError) {
1567
+ // Isolate this environment's failure; continue with the next env.
1568
+ logger.error(
1569
+ `Failed to run health check ${configId} for system ${systemId}` +
1570
+ (environmentId ? ` (environment ${environmentId})` : " (env-less)"),
1571
+ envError,
1572
+ );
1582
1573
  }
1583
-
1584
- // The directional + umbrella system-health hooks were removed in
1585
- // Phase 4 (§10.3): the `health` entity mirror above is the single
1586
- // source of truth, and its change deriver fires the
1587
- // `healthcheck.system_degraded` / `_healthy` / `_health_changed`
1588
- // trigger events through Stage-1 routing. Nothing to emit here.
1589
- }
1590
- } catch (envError) {
1591
- // Isolate this environment's failure; continue with the next env.
1592
- logger.error(
1593
- `Failed to run health check ${configId} for system ${systemId}` +
1594
- (environmentId ? ` (environment ${environmentId})` : " (env-less)"),
1595
- envError,
1596
- );
1597
- }
1598
1574
  } // end per-environment fan-out loop (for ... of runEnvironments)
1599
1575
 
1600
1576
  // The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
@@ -1624,7 +1600,8 @@ async function executeHealthCheckJob(props: {
1624
1600
  // catastrophic tick for the same system can't commit between the baseline
1625
1601
  // read and this insert and make the cache change-gate miss a transition.
1626
1602
  let rollupPreState!: AggregatedHealth;
1627
- let previousStatus!: HealthCheckStatus;
1603
+ // May be `unknown`: the pre-run baseline of a check that had never run.
1604
+ let previousStatus!: SystemHealthStatus;
1628
1605
  let newState!: AggregatedHealth;
1629
1606
  await writeHealthEntity({
1630
1607
  handle: getHealthEntity?.(),
@@ -1719,14 +1696,16 @@ async function executeHealthCheckJob(props: {
1719
1696
  environmentId: null,
1720
1697
  });
1721
1698
 
1722
- if (newState.status !== previousStatus) {
1699
+ // `newState.status` cannot be `unknown` here (a run just completed).
1700
+ if (newState.status !== previousStatus && newState.status !== "unknown") {
1723
1701
  // Record the aggregate transition so the sensing layer has a
1724
1702
  // reliable "in status since" for every status (Wave 2).
1725
1703
  await recordStateTransition({
1726
1704
  db,
1727
1705
  systemId,
1728
1706
  configurationId: configId,
1729
- fromStatus: previousStatus,
1707
+ // `undefined` records NULL: no prior measured status.
1708
+ fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
1730
1709
  toStatus: newState.status,
1731
1710
  });
1732
1711
 
@@ -1736,7 +1715,9 @@ async function executeHealthCheckJob(props: {
1736
1715
  systemName,
1737
1716
  configurationId: configId,
1738
1717
  configurationName: configName,
1739
- previousStatus,
1718
+ // A first measurement has no previous status to compare against.
1719
+ previousStatus:
1720
+ previousStatus === "unknown" ? "healthy" : previousStatus,
1740
1721
  newStatus: newState.status,
1741
1722
  service,
1742
1723
  catalogClient,
@@ -1855,4 +1836,3 @@ export async function setupHealthCheckWorker(props: {
1855
1836
 
1856
1837
  logger.debug("🎯 Health Check Worker subscribed to queue");
1857
1838
  }
1858
-