@checkstack/healthcheck-backend 1.21.2 → 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,12 +27,19 @@ import {
31
27
  SYSTEM_STATUS_CHANGED,
32
28
  ENVIRONMENT_RESOLUTION_FAILED,
33
29
  type HealthCheckStatus,
30
+ type SystemHealthStatus,
34
31
  stripEphemeralFields,
32
+ HEALTH_CHECK_QUEUE,
33
+ type HealthCheckJobPayload,
35
34
  } from "@checkstack/healthcheck-common";
36
- import {
37
- CatalogApi,
38
- type Environment,
39
- } from "@checkstack/catalog-common";
35
+ // The run-queue contract (queue name + payload) now lives in the common leaf so
36
+ // every fast-path enqueuer shares one shape; re-exported from the owner here
37
+ // because this file's many internal importers reference these names.
38
+ export {
39
+ HEALTH_CHECK_QUEUE,
40
+ type HealthCheckJobPayload,
41
+ } from "@checkstack/healthcheck-common";
42
+ import { CatalogApi, type Environment } from "@checkstack/catalog-common";
40
43
  import {
41
44
  resolveEffectiveEnvironments,
42
45
  type EffectiveEnvironment,
@@ -45,7 +48,7 @@ import { buildHealthTransitionNotification } from "./health-notification-content
45
48
  import { MaintenanceApi } from "@checkstack/maintenance-common";
46
49
  import { IncidentApi } from "@checkstack/incident-common";
47
50
  import { NotificationApi } from "@checkstack/notification-common";
48
- import { type InferClient, extractErrorMessage} from "@checkstack/common";
51
+ import { type InferClient, extractErrorMessage } from "@checkstack/common";
49
52
  import { secretEnvMappingSchema } from "@checkstack/secrets-common";
50
53
  import type {
51
54
  SecretResolverService,
@@ -147,39 +150,6 @@ async function fetchRecentRunsForSlice(props: {
147
150
  }));
148
151
  }
149
152
 
150
- /** The known transport timing phase keys, in transport order. */
151
- const RUN_TIMING_KEYS = [
152
- "dnsMs",
153
- "connectMs",
154
- "tlsMs",
155
- "waitMs",
156
- "transferMs",
157
- "processingMs",
158
- ] as const;
159
-
160
- /**
161
- * Build the run's `metadata.timings` from a connected client's surfaced
162
- * transport timings, keeping only present, finite, non-negative phases. Returns
163
- * `undefined` when no usable phase was measured so the field is omitted (old
164
- * runs and single-phase strategies stay on the coarse fallback in the UI).
165
- */
166
- function extractRunTimings(
167
- connectedClient: ConnectedClient<TransportClient<never, unknown>> | undefined,
168
- ): RunTimings | undefined {
169
- const raw: TransportTimings | undefined = connectedClient?.timings;
170
- if (!raw) return undefined;
171
- const result: RunTimings = {};
172
- let any = false;
173
- for (const key of RUN_TIMING_KEYS) {
174
- const value = raw[key];
175
- if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
176
- result[key] = value;
177
- any = true;
178
- }
179
- }
180
- return any ? result : undefined;
181
- }
182
-
183
153
  /**
184
154
  * Emit the checkCompleted hook if available, plus the narrower
185
155
  * `checkFailed` hook when the result wasn't `healthy` (so operators
@@ -244,12 +214,6 @@ async function emitCheckCompletedHook({
244
214
  * The scheduling reconciler owns which (config, system, env) jobs exist; the
245
215
  * `run_now` action enqueues one job per effective environment.
246
216
  */
247
- export interface HealthCheckJobPayload {
248
- configId: string;
249
- systemId: string;
250
- environmentId: string | null;
251
- }
252
-
253
217
  /** Prefix every health-check recurring jobId shares (used for orphan scans). */
254
218
  export const HEALTH_CHECK_JOB_PREFIX = "healthcheck:";
255
219
 
@@ -268,13 +232,6 @@ export function encodeHealthCheckJobId(props: {
268
232
  return environmentId === null ? base : `${base}:${environmentId}`;
269
233
  }
270
234
 
271
- /**
272
- * Queue name for health check execution. Exported so consumers like
273
- * the `healthcheck.run_now` automation action can enqueue a one-off
274
- * job without re-importing the recurring-job factory.
275
- */
276
- export const HEALTH_CHECK_QUEUE = "health-checks";
277
-
278
235
  /**
279
236
  * Worker group for health check execution (work-queue mode)
280
237
  */
@@ -361,7 +318,10 @@ export async function recomputeSystemRollupHealth(args: {
361
318
  */
362
319
  signalService?: SignalService;
363
320
  cache?: HealthCheckCache;
364
- }): Promise<{ previousStatus: HealthCheckStatus; newStatus: HealthCheckStatus } | undefined> {
321
+ }): Promise<
322
+ | { previousStatus: SystemHealthStatus; newStatus: SystemHealthStatus }
323
+ | undefined
324
+ > {
365
325
  const {
366
326
  systemId,
367
327
  service,
@@ -407,7 +367,11 @@ export async function recomputeSystemRollupHealth(args: {
407
367
  // Cache: evict the rollup key + broadcast to the cluster on ANY per-check
408
368
  // vector change — a check that flips while the rollup enum stays put still
409
369
  // changes the rollup's `checkStatuses`, and a reader gets that vector.
410
- await cache?.reconcile({ systemId, previous: previousState, next: newState });
370
+ await cache?.reconcile({
371
+ systemId,
372
+ previous: previousState,
373
+ next: newState,
374
+ });
411
375
  // Frontend signal: only a rollup-enum transition moves the badge, so a
412
376
  // per-check-only change needs no SYSTEM_STATUS_CHANGED refetch signal.
413
377
  if (newState.status !== previousState.status) {
@@ -616,6 +580,239 @@ async function notifyStateChange(props: {
616
580
  }
617
581
  }
618
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
+
619
816
  /**
620
817
  * Execute a health check job
621
818
  */
@@ -952,8 +1149,12 @@ async function executeHealthCheckJob(props: {
952
1149
  // event-driven rollup consumer; an env-less run mutates the bare entity
953
1150
  // (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
954
1151
  // directly.
955
- const runEnvironments: (EffectiveEnvironment | null)[] = [singleEnvironment];
956
- 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.
957
1158
  for (const environment of runEnvironments) {
958
1159
  const environmentId = environment?.id ?? null;
959
1160
  // The env-qualified entity id this run mutates. For the env-less run
@@ -972,7 +1173,8 @@ async function executeHealthCheckJob(props: {
972
1173
  // stale cached status until the TTL. Assigned by whichever branch's
973
1174
  // `apply` runs; used for the transition log AND the cache reconcile.
974
1175
  let previousState!: AggregatedHealth;
975
- let previousStatus!: HealthCheckStatus;
1176
+ // May be `unknown`: the pre-run baseline of a check that had never run.
1177
+ let previousStatus!: SystemHealthStatus;
976
1178
 
977
1179
  // Curated, read-only run-context metadata exposed to collectors.
978
1180
  // Metadata only - never secrets or config. `environment` carries the
@@ -996,91 +1198,44 @@ async function executeHealthCheckJob(props: {
996
1198
  : {}),
997
1199
  };
998
1200
 
999
- // Templating context for the per-env config render pass (§6.3.3).
1000
- // Carries only environment custom fields + curated check/system
1001
- // metadata - never secrets. `{{ environment.baseUrl }}` resolves from
1002
- // the resolved env's verbatim fields; an env-less run gets `{}` so a
1003
- // reference renders to empty string (strict: false); see the debug log
1004
- // below.
1005
- const templateContext = {
1006
- environment: runContext.environment?.fields ?? {},
1007
- check: runContext.check,
1008
- system: runContext.system,
1009
- };
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.
1010
1204
  if (!runContext.environment) {
1011
- // §11.6: render-empty when a run has no environment. An env-less run is
1012
- // a legitimate, documented configuration (the None assignment mode, or
1013
- // All-environments with no membership), and it recurs every interval -
1014
- // so this is `debug`, not `warn`, to avoid spamming the log. When an
1015
- // empty `{{ environment.* }}` render actually matters, the HTTP
1016
- // post-render `.url()` check already fails the run with a concrete
1017
- // "Rendered URL is invalid" error; we do not inspect every field here.
1018
1205
  logger.debug(
1019
1206
  `Health check ${configId} for system ${systemId} ran with no environment; ` +
1020
1207
  `any {{ environment.* }} references render to empty string`,
1021
1208
  );
1022
1209
  }
1023
1210
 
1024
- // (2) Environment/templating pass (NEW) - renders `{{ environment.* }}`
1025
- // etc. in `x-templatable` fields. Runs PER ENVIRONMENT, AFTER the secret
1026
- // resolution (secrets first, templating second - §6.3.4) and BEFORE the
1027
- // strategy client build, so each env gets its own rendered strategy
1028
- // config + client. The collector configs are rendered just before each
1029
- // collector executes (below) so the secretEnv resolution stays first.
1030
- const renderedStrategyConfig = renderTemplatableConfig({
1031
- config: strategyConfig,
1032
- schema: strategy.config.schema,
1033
- context: templateContext,
1034
- }) as BaseStrategyConfig;
1035
-
1036
- // Per-environment isolation: an unexpected failure persisting ONE
1037
- // environment's run must not abort the sibling environments' runs.
1038
- // Each iteration's run is independent (§7.2), so we log and continue.
1039
- try {
1040
- // Execute health check using createClient pattern with unified hard timeout
1041
- const start = performance.now();
1042
- let connectionTimeMs: number | undefined;
1043
- let connectedClient:
1044
- | ConnectedClient<TransportClient<never, unknown>>
1045
- | undefined;
1046
- const collectors = configRow.collectors ?? [];
1047
- const collectorResults: Record<string, unknown> = {};
1048
- let hasCollectorError = false;
1049
- let errorMessage: string | undefined;
1050
-
1051
- try {
1052
- // Platform-level hard timeout wrapping the entire execution sequence
1053
- await Promise.race([
1054
- (async () => {
1055
- // 1. Establish connection. The strategy client build moves INSIDE
1056
- // the per-env loop (§6.3.3): each env gets its own rendered config +
1057
- // client, so a single job no longer bakes in one env's rendered
1058
- // strategy config.
1059
- connectedClient = await strategy.createClient(renderedStrategyConfig);
1060
- connectionTimeMs = Math.round(performance.now() - start);
1061
-
1062
- // 2. Execute collectors in parallel
1063
- const collectorPromises = collectors.map(async (collectorEntry) => {
1064
- const registered = collectorRegistry.getCollector(
1065
- collectorEntry.collectorId,
1066
- );
1067
- if (!registered) {
1068
- logger.warn(
1069
- `Collector ${collectorEntry.collectorId} not found, skipping`,
1070
- );
1071
- return { storageKey: collectorEntry.id, skipped: true };
1072
- }
1073
-
1074
- const storageKey = collectorEntry.id;
1075
-
1076
- try {
1077
- // Resolve the collector's declared secretEnv for THIS run
1078
- // (central execution). The collector injects it and masks the
1079
- // values out of its output. A missing required secret throws
1080
- // and fails the collector clearly.
1081
- 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) => {
1082
1237
  const declared = secretEnvMappingSchema.safeParse(
1083
- (collectorEntry.config as { secretEnv?: unknown }).secretEnv,
1238
+ (entry.config as { secretEnv?: unknown }).secretEnv,
1084
1239
  );
1085
1240
  if (
1086
1241
  secretResolver &&
@@ -1090,96 +1245,64 @@ async function executeHealthCheckJob(props: {
1090
1245
  const resolved = await secretResolver.resolveForRun({
1091
1246
  secretEnv: declared.data,
1092
1247
  });
1093
- secretEnv = resolved.env;
1248
+ return resolved.env;
1094
1249
  }
1095
-
1096
- // Migrate the stored (UNVERSIONED) collector config via
1097
- // assume-v1-on-read: runs the declared migration chain, then
1098
- // validates. Migrations are idempotent, so an already-current
1099
- // config is a no-op. This runs BEFORE templating so the render
1100
- // pass sees the migrated shape; the secretEnv resolution above
1101
- // reads the raw `secretEnv` mapping (a constant string field
1102
- // unaffected by the strategy/collector reshapes), keeping the
1103
- // migrate -> secret resolve -> render -> execute order intact.
1104
- // Inflate this entry's secret markers / references (memory
1105
- // only) before its migrate+validate parse, mirroring the
1106
- // strategy-config inflation above.
1107
- 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;
1108
1256
  if (internalSecrets && secretResolver) {
1109
1257
  const inflated = await inflateConfigSecrets({
1110
1258
  configurationId: configId,
1111
- scope: {
1112
- kind: "collector",
1113
- entryId: collectorEntry.id,
1114
- },
1259
+ scope: { kind: "collector", entryId: entry.id },
1115
1260
  schema: registered.collector.config.schema,
1116
- config: collectorEntry.config,
1261
+ config: entry.config,
1117
1262
  deps: { internalSecrets, secretResolver },
1118
1263
  });
1119
1264
  rawCollectorConfig = inflated.config;
1120
1265
  }
1121
- 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 =
1122
1270
  await registered.collector.config.parseAssumingV1(
1123
1271
  rawCollectorConfig,
1124
1272
  );
1125
-
1126
- // (2) Environment/templating pass for the collector config -
1127
- // runs AFTER the secretEnv resolution above (secrets first,
1128
- // templating second) and renders `{{ environment.* }}` in this
1129
- // collector's `x-templatable` fields against the per-env context.
1130
- const renderedCollectorConfig = renderTemplatableConfig({
1131
- config: migratedCollectorConfig,
1132
- schema: registered.collector.config.schema,
1133
- context: templateContext,
1134
- });
1135
-
1136
- const collectorResult = await registered.collector.execute({
1137
- config: renderedCollectorConfig,
1138
- client: connectedClient!.client,
1139
- pluginId: configRow.strategyId,
1140
- runContext,
1141
- ...(secretEnv ? { secretEnv } : {}),
1142
- });
1143
-
1144
- // Check for collector-level error
1145
- let collectorError: string | undefined;
1146
- if (collectorResult.error) {
1147
- collectorError = collectorResult.error;
1148
- }
1149
-
1150
- // Evaluate per-collector assertions (plain fields + JSONPath).
1151
- // ALL outcomes are stored (pass AND fail) so assertions are
1152
- // 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;
1153
1277
  let assertionFailed: string | undefined;
1154
1278
  let assertionOutcomes: AssertionOutcome[] = [];
1155
1279
  if (collectorResult.result) {
1156
1280
  const evaluation = evaluateCollectorAssertionOutcomes({
1157
- assertions: collectorEntry.assertions,
1281
+ assertions: entry.assertions,
1158
1282
  result: collectorResult.result as Record<string, unknown>,
1159
1283
  });
1160
1284
  assertionFailed = evaluation.firstFailureMessage;
1161
1285
  assertionOutcomes = evaluation.outcomes;
1162
1286
  if (assertionFailed) {
1163
1287
  logger.debug(
1164
- `Collector ${storageKey} assertion failed: ${assertionFailed}`,
1288
+ `Collector ${entry.id} assertion failed: ${assertionFailed}`,
1165
1289
  );
1166
1290
  }
1167
1291
  }
1168
-
1169
- // Strip ephemeral fields before storage
1170
1292
  const strippedResult = stripEphemeralFields(
1171
1293
  collectorResult.result as Record<string, unknown>,
1172
1294
  registered.collector.result.schema,
1173
1295
  );
1174
-
1175
1296
  return {
1176
- storageKey,
1177
- skipped: false,
1178
- success: true,
1179
- collectorError,
1180
- assertionFailed,
1181
- result: {
1182
- _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,
1183
1306
  _assertionFailed: assertionFailed,
1184
1307
  _collectorError: collectorError,
1185
1308
  ...(assertionOutcomes.length > 0
@@ -1188,417 +1311,266 @@ async function executeHealthCheckJob(props: {
1188
1311
  ...strippedResult,
1189
1312
  },
1190
1313
  };
1191
- } catch (error) {
1192
- const errorStr =
1193
- extractErrorMessage(error);
1194
- logger.debug(`Collector ${storageKey} failed: ${errorStr}`);
1314
+ },
1315
+ mapError: ({ entry, error }) => {
1316
+ const errorStr = extractErrorMessage(error);
1317
+ logger.debug(`Collector ${entry.id} failed: ${errorStr}`);
1195
1318
  return {
1196
- storageKey,
1197
- skipped: false,
1319
+ storageKey: entry.id,
1198
1320
  success: false,
1199
1321
  error: errorStr,
1200
- result: {
1201
- _collectorId: collectorEntry.collectorId,
1322
+ storedResult: {
1323
+ _collectorId: entry.collectorId,
1202
1324
  _assertionFailed: undefined,
1203
1325
  _collectorError: errorStr,
1204
1326
  },
1205
1327
  };
1206
- }
1207
- });
1328
+ },
1329
+ },
1330
+ });
1208
1331
 
1209
- // Wait for all collectors to complete
1210
- 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
+ });
1211
1381
 
1212
- // Process results from all collectors
1213
- for (const settled of settledResults) {
1214
- if (settled.status === "rejected") {
1215
- // This shouldn't happen since we catch errors above, but handle it
1216
- hasCollectorError = true;
1217
- if (!errorMessage) errorMessage = String(settled.reason);
1218
- continue;
1219
- }
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
+ });
1220
1395
 
1221
- const result = settled.value;
1222
- if (result.skipped) continue;
1223
-
1224
- // Store the result
1225
- collectorResults[result.storageKey] = result.result;
1226
-
1227
- // Track errors
1228
- if (
1229
- !result.success ||
1230
- result.collectorError ||
1231
- result.assertionFailed
1232
- ) {
1233
- hasCollectorError = true;
1234
- if (!errorMessage) {
1235
- errorMessage =
1236
- result.error ||
1237
- result.collectorError ||
1238
- (result.assertionFailed
1239
- ? `Assertion failed: ${result.assertionFailed}`
1240
- : undefined);
1241
- }
1242
- }
1243
- }
1244
- })(),
1245
- new Promise<never>((_, reject) =>
1246
- setTimeout(
1247
- () =>
1248
- reject(
1249
- 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,
1250
1410
  ),
1251
- effectiveTimeout,
1252
- ),
1253
- ),
1254
- ]);
1255
- } catch (error) {
1256
- const latencyMs = Math.round(performance.now() - start);
1257
- const caughtError =
1258
- extractErrorMessage(error);
1259
-
1260
- // Use a specific error message if available, otherwise use the caught error
1261
- const finalError = errorMessage || caughtError;
1262
-
1263
- const result = {
1264
- status: "unhealthy" as const,
1265
- latencyMs,
1266
- message: finalError,
1267
- metadata: {
1268
- connected: !!connectedClient,
1269
- error: finalError,
1270
- },
1271
- };
1411
+ });
1412
+
1413
+ logger.debug(
1414
+ `Health check ${configId} for system ${systemId} failed: ${finalError}`,
1415
+ );
1272
1416
 
1273
- // Persist the run + aggregate THROUGH the reactive `health` entity:
1274
- // `apply` does the durable write and returns the freshly-computed view.
1275
- // The framework snapshots `prev` via `read` BEFORE this insert, so a real
1276
- // status change emits exactly one correct `ENTITY_CHANGED` (§10.3). The
1277
- // computed aggregated state is stashed for the transition/notify path.
1278
- let newState!: AggregatedHealth;
1279
- await writeHealthEntity({
1280
- handle: getHealthEntity?.(),
1281
- entityId: envEntityId,
1282
- apply: async () => {
1283
- // In-lock pre-run baseline (see the `previousState` declaration): read
1284
- // here, inside the serialized critical section, before the insert.
1285
- 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({
1286
1424
  systemId,
1287
1425
  environmentId,
1288
- );
1289
- previousStatus = previousState.status;
1290
- // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1291
- // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1292
- // which also makes the run and its aggregate commit atomically.
1293
- await withScopedTransaction(db, async (tx) => {
1294
- await tx.insert(healthCheckRuns).values({
1295
- 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,
1296
1454
  systemId,
1455
+ configurationId: configId,
1297
1456
  environmentId,
1298
- status: result.status,
1299
- latencyMs: result.latencyMs,
1300
- result: { ...result } as Record<string, unknown>,
1301
- sourceId: undefined,
1302
- 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,
1303
1464
  });
1304
1465
 
1305
- await incrementHourlyAggregate({
1306
- db: tx,
1466
+ await notifyStateChange({
1467
+ notificationClient,
1307
1468
  systemId,
1469
+ systemName,
1308
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,
1309
1478
  environmentId,
1310
- status: result.status,
1311
- latencyMs: result.latencyMs,
1312
- runTimestamp: new Date(),
1313
- result: { ...result } as Record<string, unknown>,
1314
- collectorRegistry,
1315
- sourceLabel: "Local",
1479
+ environmentName: environment?.name,
1480
+ service,
1481
+ catalogClient,
1482
+ maintenanceClient,
1483
+ incidentClient,
1484
+ logger,
1316
1485
  });
1317
- });
1318
-
1319
- // Env-scoped view: the per-env entity reflects only this env's runs.
1320
- // Runs as its own batched read AFTER the write commits, so it sees
1321
- // the just-inserted run.
1322
- newState = await service.getSystemHealthStatus(systemId, environmentId);
1323
- return toHealthEntityView(newState);
1324
- },
1325
- serialize: serializeEnvWrite,
1326
- onError: (error) =>
1327
- logger.warn(
1328
- `Failed to mirror health entity for ${envEntityId}`,
1329
- error,
1330
- ),
1331
- });
1486
+ }
1332
1487
 
1333
- logger.debug(
1334
- `Health check ${configId} for system ${systemId} failed: ${finalError}`,
1335
- );
1488
+ // This environment's run is done (failed). Continue to the next
1489
+ // effective environment rather than ending the whole job.
1490
+ continue;
1491
+ }
1336
1492
 
1337
- // Reconcile this environment's cached status: evict + broadcast to the
1338
- // cluster ONLY when the per-check vector actually changed (a run that
1339
- // leaves every check's status unchanged keeps the cache warm instead of
1340
- // thrashing it every tick). The rollup key is reconciled separately by
1341
- // the debounced rollup consumer (recomputeSystemRollupHealth), also
1342
- // vector-gated.
1343
- await cache.reconcile({
1344
- systemId,
1345
- environmentId,
1346
- previous: previousState,
1347
- next: newState,
1348
- });
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
+ }
1349
1518
 
1350
- await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1351
- systemId,
1352
- systemName,
1353
- configurationId: configId,
1354
- configurationName: configRow.configName,
1355
- status: result.status,
1356
- latencyMs: result.latencyMs,
1357
- // Env-scoped fan-out: `environment` is null for the env-less run, so
1358
- // `?.` yields undefined and those runs broadcast exactly as before.
1359
- environmentId: environment?.id,
1360
- environmentName: environment?.name,
1361
- });
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
+ };
1362
1532
 
1363
- if (newState.status !== previousStatus) {
1364
- // Record the aggregate transition so the sensing layer has a
1365
- // reliable "in status since" for every status (Wave 2).
1366
- 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({
1367
1540
  db,
1368
- systemId,
1369
- configurationId: configId,
1370
- environmentId,
1371
- fromStatus: previousStatus,
1372
- toStatus: newState.status,
1373
- });
1374
-
1375
- await notifyStateChange({
1376
- notificationClient,
1377
- systemId,
1378
- systemName,
1379
- configurationId: configId,
1380
- configurationName: configRow.configName,
1381
- previousStatus,
1382
- newStatus: newState.status,
1383
- environmentId,
1384
- environmentName: environment?.name,
1385
1541
  service,
1542
+ cache,
1543
+ signalService,
1544
+ notificationClient,
1386
1545
  catalogClient,
1387
1546
  maintenanceClient,
1388
1547
  incidentClient,
1548
+ getHealthEntity,
1549
+ getEmitHook,
1550
+ collectorRegistry,
1551
+ advisoryLock,
1389
1552
  logger,
1390
- });
1391
- }
1392
-
1393
- // This environment's run is done (failed). Continue to the next
1394
- // effective environment rather than ending the whole job.
1395
- continue;
1396
- } finally {
1397
- if (connectedClient) {
1398
- try {
1399
- connectedClient.close();
1400
- } catch (error) {
1401
- logger.warn(`Failed to close connection: ${error}`);
1402
- }
1403
- }
1404
- }
1405
-
1406
- // Determine health status based on collector results
1407
- const status = hasCollectorError ? "unhealthy" : "healthy";
1408
- const totalLatencyMs = Math.round(performance.now() - start);
1409
-
1410
- // Lift the strategy's structured transport timings (DNS / connect / TLS /
1411
- // wait / transfer / processing) into the run metadata when the connected
1412
- // client surfaced any. Strategies that cannot measure sub-phases leave this
1413
- // undefined and the frontend falls back to the coarse connection split.
1414
- const timings = extractRunTimings(connectedClient);
1415
-
1416
- // Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
1417
- // network sub-phases. The `phase` breakdown is what tells "slow target"
1418
- // (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
1419
- // grow under a same-host stampede) apart from platform delay.
1420
- healthcheckExecutionHistogram().record(totalLatencyMs, { status });
1421
- if (timings) {
1422
- for (const [phase, value] of Object.entries(timings)) {
1423
- if (typeof value === "number" && Number.isFinite(value)) {
1424
- healthcheckPhaseHistogram().record(value, {
1425
- phase: phase.replace(/Ms$/, ""),
1426
- });
1427
- }
1428
- }
1429
- }
1430
-
1431
- const result = {
1432
- status: status as "healthy" | "unhealthy",
1433
- latencyMs: totalLatencyMs,
1434
- message: hasCollectorError
1435
- ? `Check failed: ${errorMessage}`
1436
- : `Completed in ${totalLatencyMs}ms`,
1437
- metadata: {
1438
- connected: true,
1439
- connectionTimeMs,
1440
- ...(timings ? { timings } : {}),
1441
- collectors: collectorResults,
1442
- },
1443
- };
1444
-
1445
- // Persist the run + aggregate THROUGH the reactive `health` entity on
1446
- // every run (§10.3): `apply` does the durable write (insert + hourly
1447
- // aggregate) and returns the freshly-computed view. The framework
1448
- // snapshots `prev` via the COMPUTE-ON-READ accessor BEFORE this insert, so
1449
- // an unchanged aggregate is a no-op and a real status change drives the
1450
- // directional/umbrella trigger events via `deriveHealthTriggerEvents` —
1451
- // exactly one correct `ENTITY_CHANGED` with accurate prev → next.
1452
- let newState!: AggregatedHealth;
1453
- await writeHealthEntity({
1454
- handle: getHealthEntity?.(),
1455
- entityId: envEntityId,
1456
- apply: async () => {
1457
- // In-lock pre-run baseline (see the `previousState` declaration): read
1458
- // here, inside the serialized critical section, before the insert.
1459
- previousState = await service.getSystemHealthStatus(
1460
1553
  systemId,
1554
+ systemName,
1555
+ configId,
1556
+ configName: configRow.configName,
1461
1557
  environmentId,
1462
- );
1463
- previousStatus = previousState.status;
1464
- // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1465
- // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1466
- // which also makes the run and its aggregate commit atomically.
1467
- await withScopedTransaction(db, async (tx) => {
1468
- // Store result (spread to convert structured type to plain record for jsonb)
1469
- await tx.insert(healthCheckRuns).values({
1470
- configurationId: configId,
1471
- systemId,
1472
- environmentId,
1473
- status: result.status,
1474
- latencyMs: result.latencyMs,
1475
- result: { ...result } as Record<string, unknown>,
1476
- sourceId: undefined,
1477
- sourceLabel: "Local",
1478
- });
1479
-
1480
- // Trigger incremental hourly aggregation
1481
- await incrementHourlyAggregate({
1482
- db: tx,
1483
- systemId,
1484
- configurationId: configId,
1485
- environmentId,
1486
- status: result.status,
1487
- latencyMs: result.latencyMs,
1488
- runTimestamp: new Date(),
1489
- result: { ...result } as Record<string, unknown>,
1490
- collectorRegistry,
1491
- sourceLabel: "Local",
1492
- });
1493
- });
1494
-
1495
- // Env-scoped view: the per-env entity reflects only this env's runs.
1496
- // Runs as its own batched read AFTER the write commits, so it sees the
1497
- // just-inserted run.
1498
- newState = await service.getSystemHealthStatus(systemId, environmentId);
1499
- return toHealthEntityView(newState);
1500
- },
1501
- serialize: serializeEnvWrite,
1502
- onError: (error) =>
1503
- logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
1504
- });
1505
-
1506
- logger.debug(
1507
- `Ran health check ${configId} for system ${systemId}: ${result.status}`,
1508
- );
1509
-
1510
- // Reconcile this environment's cached status: evict + broadcast to the
1511
- // cluster ONLY when the per-check vector actually changed (a steady-state
1512
- // healthy run keeps the cache warm). The rollup key is reconciled by the
1513
- // debounced rollup consumer (recomputeSystemRollupHealth), also vector-gated.
1514
- await cache.reconcile({
1515
- systemId,
1516
- environmentId,
1517
- previous: previousState,
1518
- next: newState,
1519
- });
1520
-
1521
- // Broadcast enriched signal for realtime frontend updates (e.g., terminal feed)
1522
- await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1523
- systemId,
1524
- systemName,
1525
- configurationId: configId,
1526
- configurationName: configRow.configName,
1527
- status: result.status,
1528
- latencyMs: result.latencyMs,
1529
- // Env-scoped fan-out: `environment` is null for the env-less run, so
1530
- // `?.` yields undefined and those runs broadcast exactly as before.
1531
- environmentId: environment?.id,
1532
- environmentName: environment?.name,
1533
- });
1534
-
1535
- await emitCheckCompletedHook({
1536
- getEmitHook,
1537
- systemId,
1538
- configurationId: configId,
1539
- status: result.status,
1540
- latencyMs: result.latencyMs,
1541
- result: (result.metadata?.collectors as Record<string, unknown>) ?? undefined,
1542
- environmentId,
1543
- });
1544
-
1545
- if (newState.status !== previousStatus) {
1546
- // Record the aggregate transition so the sensing layer has a
1547
- // reliable "in status since" for every status (Wave 2).
1548
- await recordStateTransition({
1549
- db,
1550
- systemId,
1551
- configurationId: configId,
1552
- environmentId,
1553
- fromStatus: previousStatus,
1554
- toStatus: newState.status,
1555
- });
1556
-
1557
- await notifyStateChange({
1558
- notificationClient,
1559
- systemId,
1560
- systemName,
1561
- configurationId: configId,
1562
- configurationName: configRow.configName,
1563
- previousStatus,
1564
- newStatus: newState.status,
1565
- environmentId,
1566
- environmentName: environment?.name,
1567
- service,
1568
- catalogClient,
1569
- maintenanceClient,
1570
- incidentClient,
1571
- logger,
1572
- });
1573
-
1574
- // The system-level `SYSTEM_STATUS_CHANGED` signal must carry the ROLLUP
1575
- // status, not a per-env status. When fanned out, the post-loop rollup
1576
- // write broadcasts it once with the worst-status rollup; emitting it here
1577
- // per env would send up to N system-level signals/tick carrying per-env
1578
- // status. Only the env-less run (which IS the rollup — `!isFannedOut`)
1579
- // broadcasts the system-level signal from inside the loop.
1580
- if (!isFannedOut) {
1581
- await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
1582
- systemId,
1583
- previousStatus: previousStatus as HealthCheckStatus,
1584
- 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(),
1585
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
+ );
1586
1573
  }
1587
-
1588
- // The directional + umbrella system-health hooks were removed in
1589
- // Phase 4 (§10.3): the `health` entity mirror above is the single
1590
- // source of truth, and its change deriver fires the
1591
- // `healthcheck.system_degraded` / `_healthy` / `_health_changed`
1592
- // trigger events through Stage-1 routing. Nothing to emit here.
1593
- }
1594
- } catch (envError) {
1595
- // Isolate this environment's failure; continue with the next env.
1596
- logger.error(
1597
- `Failed to run health check ${configId} for system ${systemId}` +
1598
- (environmentId ? ` (environment ${environmentId})` : " (env-less)"),
1599
- envError,
1600
- );
1601
- }
1602
1574
  } // end per-environment fan-out loop (for ... of runEnvironments)
1603
1575
 
1604
1576
  // The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
@@ -1628,7 +1600,8 @@ async function executeHealthCheckJob(props: {
1628
1600
  // catastrophic tick for the same system can't commit between the baseline
1629
1601
  // read and this insert and make the cache change-gate miss a transition.
1630
1602
  let rollupPreState!: AggregatedHealth;
1631
- let previousStatus!: HealthCheckStatus;
1603
+ // May be `unknown`: the pre-run baseline of a check that had never run.
1604
+ let previousStatus!: SystemHealthStatus;
1632
1605
  let newState!: AggregatedHealth;
1633
1606
  await writeHealthEntity({
1634
1607
  handle: getHealthEntity?.(),
@@ -1723,14 +1696,16 @@ async function executeHealthCheckJob(props: {
1723
1696
  environmentId: null,
1724
1697
  });
1725
1698
 
1726
- if (newState.status !== previousStatus) {
1699
+ // `newState.status` cannot be `unknown` here (a run just completed).
1700
+ if (newState.status !== previousStatus && newState.status !== "unknown") {
1727
1701
  // Record the aggregate transition so the sensing layer has a
1728
1702
  // reliable "in status since" for every status (Wave 2).
1729
1703
  await recordStateTransition({
1730
1704
  db,
1731
1705
  systemId,
1732
1706
  configurationId: configId,
1733
- fromStatus: previousStatus,
1707
+ // `undefined` records NULL: no prior measured status.
1708
+ fromStatus: previousStatus === "unknown" ? undefined : previousStatus,
1734
1709
  toStatus: newState.status,
1735
1710
  });
1736
1711
 
@@ -1740,7 +1715,9 @@ async function executeHealthCheckJob(props: {
1740
1715
  systemName,
1741
1716
  configurationId: configId,
1742
1717
  configurationName: configName,
1743
- previousStatus,
1718
+ // A first measurement has no previous status to compare against.
1719
+ previousStatus:
1720
+ previousStatus === "unknown" ? "healthy" : previousStatus,
1744
1721
  newStatus: newState.status,
1745
1722
  service,
1746
1723
  catalogClient,
@@ -1859,4 +1836,3 @@ export async function setupHealthCheckWorker(props: {
1859
1836
 
1860
1837
  logger.debug("🎯 Health Check Worker subscribed to queue");
1861
1838
  }
1862
-