@checkstack/automation-backend 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. package/tsconfig.json +24 -0
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { Logger } from "@checkstack/backend-api";
3
+ import {
4
+ enrichScopeWithState,
5
+ MAX_RESOLVED_SYSTEMS,
6
+ type ScopeHealthNamespace,
7
+ } from "./state-scope";
8
+
9
+ const noopLogger = {
10
+ debug: () => {},
11
+ info: () => {},
12
+ warn: () => {},
13
+ error: () => {},
14
+ } as unknown as Logger;
15
+
16
+ function collectingLogger(): { logger: Logger; warnings: string[] } {
17
+ const warnings: string[] = [];
18
+ const logger = {
19
+ debug: () => {},
20
+ info: () => {},
21
+ warn: (msg: string) => warnings.push(msg),
22
+ error: () => {},
23
+ } as unknown as Logger;
24
+ return { logger, warnings };
25
+ }
26
+
27
+ function makeState(over: Record<string, unknown> = {}) {
28
+ return {
29
+ status: "unhealthy",
30
+ inStatusSince: new Date("2026-05-30T11:00:00.000Z"),
31
+ inStatusForMs: 3_600_000,
32
+ latencyMs: 42,
33
+ avgLatencyMs: 50,
34
+ p95LatencyMs: 120,
35
+ successRate: 0.9,
36
+ lastRunAt: new Date("2026-05-30T11:59:00.000Z"),
37
+ inMaintenance: false,
38
+ transitionsInWindow: 0,
39
+ transitionWindowMinutes: 60,
40
+ evaluatedAt: new Date("2026-05-30T12:00:00.000Z"),
41
+ ...over,
42
+ };
43
+ }
44
+
45
+ /** Minimal mock health client recording the systemIds it was asked for. */
46
+ function makeClient(
47
+ states: Record<string, ReturnType<typeof makeState>>,
48
+ opts?: { throws?: boolean },
49
+ ) {
50
+ const calls: string[][] = [];
51
+ const windows: Array<number | undefined> = [];
52
+ return {
53
+ calls,
54
+ windows,
55
+ client: {
56
+ getBulkHealthState: async ({
57
+ systemIds,
58
+ transitionWindowMinutes,
59
+ }: {
60
+ systemIds: string[];
61
+ transitionWindowMinutes?: number;
62
+ }) => {
63
+ calls.push(systemIds);
64
+ windows.push(transitionWindowMinutes);
65
+ if (opts?.throws) throw new Error("provider down");
66
+ const out: Record<string, unknown> = {};
67
+ for (const id of systemIds) if (states[id]) out[id] = states[id];
68
+ return { states: out };
69
+ },
70
+ } as never,
71
+ };
72
+ }
73
+
74
+ function getHealth(scope: Record<string, unknown>): ScopeHealthNamespace {
75
+ return scope.health as ScopeHealthNamespace;
76
+ }
77
+
78
+ describe("enrichScopeWithState", () => {
79
+ it("folds the context system under health.system and health.systems[id]", async () => {
80
+ const { client, calls } = makeClient({ "sys-1": makeState() });
81
+ const scope: Record<string, unknown> = {};
82
+ await enrichScopeWithState({
83
+ scope,
84
+ client,
85
+ logger: noopLogger,
86
+ contextKey: "sys-1",
87
+ });
88
+ expect(calls).toEqual([["sys-1"]]);
89
+ const health = getHealth(scope);
90
+ expect(health.system?.status).toBe("unhealthy");
91
+ expect(health.system?.in_status_for_ms).toBe(3_600_000);
92
+ expect(health.systems["sys-1"]?.status).toBe("unhealthy");
93
+ });
94
+
95
+ it("folds the windowed transition count + window minutes and forwards the window", async () => {
96
+ const { client, windows } = makeClient({
97
+ "sys-1": makeState({ transitionsInWindow: 5, transitionWindowMinutes: 90 }),
98
+ });
99
+ const scope: Record<string, unknown> = {};
100
+ await enrichScopeWithState({
101
+ scope,
102
+ client,
103
+ logger: noopLogger,
104
+ contextKey: "sys-1",
105
+ transitionWindowMinutes: 90,
106
+ });
107
+ const health = getHealth(scope);
108
+ expect(health.system?.transitions_in_window).toBe(5);
109
+ expect(health.system?.transition_window_minutes).toBe(90);
110
+ // the per-automation window is forwarded to the provider
111
+ expect(windows).toEqual([90]);
112
+ });
113
+
114
+ it("converts Date fields to ISO strings (for duration filters)", async () => {
115
+ const { client } = makeClient({ "sys-1": makeState() });
116
+ const scope: Record<string, unknown> = {};
117
+ await enrichScopeWithState({
118
+ scope,
119
+ client,
120
+ logger: noopLogger,
121
+ contextKey: "sys-1",
122
+ });
123
+ const health = getHealth(scope);
124
+ expect(health.system?.in_status_since).toBe("2026-05-30T11:00:00.000Z");
125
+ expect(health.system?.last_run_at).toBe("2026-05-30T11:59:00.000Z");
126
+ expect(health.system?.evaluated_at).toBe("2026-05-30T12:00:00.000Z");
127
+ });
128
+
129
+ it("preserves null inStatusSince as null", async () => {
130
+ const { client } = makeClient({
131
+ "sys-1": makeState({ inStatusSince: null }),
132
+ });
133
+ const scope: Record<string, unknown> = {};
134
+ await enrichScopeWithState({
135
+ scope,
136
+ client,
137
+ logger: noopLogger,
138
+ contextKey: "sys-1",
139
+ });
140
+ expect(getHealth(scope).system?.in_status_since).toBeNull();
141
+ });
142
+
143
+ it("resolves uses_state ids in addition to the context system, de-duped", async () => {
144
+ const { client, calls } = makeClient({
145
+ "sys-1": makeState(),
146
+ "sys-2": makeState({ status: "degraded" }),
147
+ });
148
+ const scope: Record<string, unknown> = {};
149
+ await enrichScopeWithState({
150
+ scope,
151
+ client,
152
+ logger: noopLogger,
153
+ contextKey: "sys-1",
154
+ usesState: ["sys-2", "sys-1"], // sys-1 duplicate dropped
155
+ });
156
+ expect(calls).toEqual([["sys-1", "sys-2"]]);
157
+ const health = getHealth(scope);
158
+ expect(health.systems["sys-2"]?.status).toBe("degraded");
159
+ // system still points at the context system
160
+ expect(health.system?.status).toBe("unhealthy");
161
+ });
162
+
163
+ it("exposes an empty namespace when there is no context key and no uses_state", async () => {
164
+ const { client, calls } = makeClient({});
165
+ const scope: Record<string, unknown> = {};
166
+ await enrichScopeWithState({
167
+ scope,
168
+ client,
169
+ logger: noopLogger,
170
+ contextKey: null,
171
+ });
172
+ expect(calls).toEqual([]); // no query issued
173
+ expect(getHealth(scope)).toEqual({ systems: {} });
174
+ });
175
+
176
+ it("exposes an empty namespace when no client is wired (fail-open)", async () => {
177
+ const scope: Record<string, unknown> = {};
178
+ await enrichScopeWithState({
179
+ scope,
180
+ client: undefined,
181
+ logger: noopLogger,
182
+ contextKey: "sys-1",
183
+ });
184
+ expect(getHealth(scope)).toEqual({ systems: {} });
185
+ });
186
+
187
+ it("fails open to an empty namespace and warns on provider error", async () => {
188
+ const { client } = makeClient({}, { throws: true });
189
+ const { logger, warnings } = collectingLogger();
190
+ const scope: Record<string, unknown> = {};
191
+ await enrichScopeWithState({
192
+ scope,
193
+ client,
194
+ logger,
195
+ contextKey: "sys-1",
196
+ });
197
+ expect(getHealth(scope)).toEqual({ systems: {} });
198
+ expect(warnings.some((w) => w.includes("failed to resolve"))).toBe(true);
199
+ });
200
+
201
+ it("caps the resolved set and warns when over the limit", async () => {
202
+ const big = Array.from(
203
+ { length: MAX_RESOLVED_SYSTEMS + 5 },
204
+ (_, i) => `sys-${i}`,
205
+ );
206
+ const { client, calls } = makeClient({});
207
+ const { logger, warnings } = collectingLogger();
208
+ const scope: Record<string, unknown> = {};
209
+ await enrichScopeWithState({
210
+ scope,
211
+ client,
212
+ logger,
213
+ contextKey: big[0]!,
214
+ usesState: big.slice(1),
215
+ });
216
+ expect(calls[0]?.length).toBe(MAX_RESOLVED_SYSTEMS);
217
+ expect(warnings.some((w) => w.includes("cap reached"))).toBe(true);
218
+ });
219
+
220
+ it("leaves health.system undefined when the context system has no state", async () => {
221
+ // client returns nothing for the requested id
222
+ const { client } = makeClient({});
223
+ const scope: Record<string, unknown> = {};
224
+ await enrichScopeWithState({
225
+ scope,
226
+ client,
227
+ logger: noopLogger,
228
+ contextKey: "sys-1",
229
+ });
230
+ const health = getHealth(scope);
231
+ expect(health.system).toBeUndefined();
232
+ expect(health.systems).toEqual({});
233
+ });
234
+ });
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Live-state pre-resolution for the sensing layer (Wave 2 Phase 14).
3
+ *
4
+ * The template engine is strictly synchronous and has no call syntax, so
5
+ * a template can never query the database inline. Instead, live health
6
+ * state is resolved up front (one batched query per evaluation) and
7
+ * folded into the scope under a `health` namespace, then read as plain
8
+ * data: `{{ health.system.status }}`, `{{ health.system.in_status_since }}`.
9
+ *
10
+ * This mirrors `resolveConsumedArtifacts` (which awaits the artifact
11
+ * store and folds the result into scope before an action runs) and HA's
12
+ * approach of resolving trigger entities up front rather than lazily.
13
+ *
14
+ * Resolution policy (decision D2): implicitly resolve the system named by
15
+ * the trigger's `contextKey`, plus any ids listed in the automation's
16
+ * `uses_state` escape hatch. The resolved set is bounded; truncation is
17
+ * logged, never silent.
18
+ */
19
+ import type { Logger } from "@checkstack/backend-api";
20
+ import type { InferClient } from "@checkstack/common";
21
+ import type { HealthCheckApi } from "@checkstack/healthcheck-common";
22
+
23
+ type HealthCheckClient = InferClient<typeof HealthCheckApi>;
24
+
25
+ /**
26
+ * Hard cap on systems resolved per evaluation. `uses_state` is already
27
+ * schema-capped at 50; this is the runtime backstop including the
28
+ * implicit context system.
29
+ */
30
+ export const MAX_RESOLVED_SYSTEMS = 50;
31
+
32
+ /** snake_case state shape exposed to templates under `health.*`. */
33
+ export interface ScopeHealthState {
34
+ status: string;
35
+ in_status_since: string | null;
36
+ in_status_for_ms: number;
37
+ latency_ms?: number;
38
+ avg_latency_ms?: number;
39
+ p95_latency_ms?: number;
40
+ success_rate?: number;
41
+ last_run_at?: string;
42
+ in_maintenance: boolean;
43
+ /** Status changes in the trailing window (generalized flapping). */
44
+ transitions_in_window: number;
45
+ /** The window (minutes) `transitions_in_window` was counted over. */
46
+ transition_window_minutes: number;
47
+ evaluated_at: string;
48
+ }
49
+
50
+ /** The `health` namespace folded into scope. */
51
+ export interface ScopeHealthNamespace {
52
+ /** State of the system named by the trigger's contextKey, if resolvable. */
53
+ system?: ScopeHealthState;
54
+ /** State of every resolved system, keyed by system id. */
55
+ systems: Record<string, ScopeHealthState>;
56
+ }
57
+
58
+ /**
59
+ * Map a wire `HealthStateResponse` (Date fields, camelCase) into the
60
+ * snake_case, ISO-string shape templates read. ISO strings (not Date
61
+ * objects) so the duration filters (`older_than`, `duration_since`)
62
+ * receive parseable values and snapshots serialise cleanly.
63
+ */
64
+ function toScopeState(state: {
65
+ status: string;
66
+ inStatusSince: Date | string | null;
67
+ inStatusForMs: number;
68
+ latencyMs?: number;
69
+ avgLatencyMs?: number;
70
+ p95LatencyMs?: number;
71
+ successRate?: number;
72
+ lastRunAt?: Date | string;
73
+ inMaintenance: boolean;
74
+ transitionsInWindow: number;
75
+ transitionWindowMinutes: number;
76
+ evaluatedAt: Date | string;
77
+ }): ScopeHealthState {
78
+ const iso = (v: Date | string | null | undefined): string | undefined => {
79
+ if (v == null) return undefined;
80
+ return v instanceof Date ? v.toISOString() : v;
81
+ };
82
+ return {
83
+ status: state.status,
84
+ in_status_since: iso(state.inStatusSince) ?? null,
85
+ in_status_for_ms: state.inStatusForMs,
86
+ latency_ms: state.latencyMs,
87
+ avg_latency_ms: state.avgLatencyMs,
88
+ p95_latency_ms: state.p95LatencyMs,
89
+ success_rate: state.successRate,
90
+ last_run_at: iso(state.lastRunAt),
91
+ in_maintenance: state.inMaintenance,
92
+ transitions_in_window: state.transitionsInWindow,
93
+ transition_window_minutes: state.transitionWindowMinutes,
94
+ evaluated_at: iso(state.evaluatedAt) ?? new Date().toISOString(),
95
+ };
96
+ }
97
+
98
+ export interface EnrichScopeArgs {
99
+ /** The mutable scope to fold `health` into. Returned for convenience. */
100
+ scope: Record<string, unknown>;
101
+ client: HealthCheckClient | undefined;
102
+ logger: Logger;
103
+ /** Resolved trigger context key — treated as the implicit system id. */
104
+ contextKey: string | null;
105
+ /** Extra system ids from the automation's `uses_state` escape hatch. */
106
+ usesState?: ReadonlyArray<string>;
107
+ /**
108
+ * Trailing window (minutes) for the folded `transitions_in_window`
109
+ * count (the automation's `state_window_minutes`). Provider default
110
+ * (60) applies when omitted.
111
+ */
112
+ transitionWindowMinutes?: number;
113
+ }
114
+
115
+ /**
116
+ * Resolve live health state for the implicit context system + any
117
+ * `uses_state` ids and fold it into `scope.health`. One batched
118
+ * `getBulkHealthState` call. Fail-open: a missing client or a provider
119
+ * error yields an empty `health` namespace and a warn-log — a
120
+ * healthcheck outage never wedges unrelated automations.
121
+ */
122
+ export async function enrichScopeWithState(
123
+ args: EnrichScopeArgs,
124
+ ): Promise<Record<string, unknown>> {
125
+ const { scope, client, logger, contextKey, usesState, transitionWindowMinutes } =
126
+ args;
127
+
128
+ // Build the bounded, de-duplicated id set: implicit context system first.
129
+ const ids: string[] = [];
130
+ const seen = new Set<string>();
131
+ const add = (id: string) => {
132
+ if (id.length === 0 || seen.has(id)) return;
133
+ seen.add(id);
134
+ ids.push(id);
135
+ };
136
+ if (contextKey) add(contextKey);
137
+ for (const id of usesState ?? []) add(id);
138
+
139
+ // Nothing to resolve, or no client wired — still expose an empty
140
+ // namespace so templates referencing `health.systems` don't throw.
141
+ const emptyNamespace: ScopeHealthNamespace = { systems: {} };
142
+ if (ids.length === 0 || !client) {
143
+ scope.health = emptyNamespace;
144
+ return scope;
145
+ }
146
+
147
+ let resolveIds = ids;
148
+ if (ids.length > MAX_RESOLVED_SYSTEMS) {
149
+ logger.warn(
150
+ `enrichScopeWithState: resolving only the first ${MAX_RESOLVED_SYSTEMS} of ${ids.length} requested systems (cap reached)`,
151
+ );
152
+ resolveIds = ids.slice(0, MAX_RESOLVED_SYSTEMS);
153
+ }
154
+
155
+ try {
156
+ const { states } = await client.getBulkHealthState({
157
+ systemIds: resolveIds,
158
+ transitionWindowMinutes,
159
+ });
160
+ const systems: Record<string, ScopeHealthState> = {};
161
+ for (const [id, state] of Object.entries(states)) {
162
+ systems[id] = toScopeState(state);
163
+ }
164
+ const namespace: ScopeHealthNamespace = {
165
+ systems,
166
+ system: contextKey ? systems[contextKey] : undefined,
167
+ };
168
+ scope.health = namespace;
169
+ return scope;
170
+ } catch (error) {
171
+ logger.warn(
172
+ `enrichScopeWithState: failed to resolve health state; falling back to empty namespace: ${
173
+ (error as Error).message
174
+ }`,
175
+ );
176
+ scope.health = emptyNamespace;
177
+ return scope;
178
+ }
179
+ }
180
+
181
+ // ─── Generic entity scope enrichment (reactive automation engine §3.6) ──────
182
+ //
183
+ // Two complementary projections of live state coexist by design, not as a
184
+ // migration shim:
185
+ //
186
+ // - `scope.health.*` (rich condition snapshot) — populated by
187
+ // `enrichScopeWithState` above. It carries the FULL health aggregate
188
+ // (status, latency_ms, p95_latency_ms, success_rate, in_status_since,
189
+ // in_status_for_ms, in_maintenance, transitions_in_window, …) that the
190
+ // structured `state` / `numeric_state` condition evaluators read. The
191
+ // health aggregate is not stored as a framework entity row, so it is
192
+ // resolved through the healthcheck RPC, not the entity store.
193
+ //
194
+ // - `scope.state.<kind>.<id>.<field>` (minimal reactive entity view) —
195
+ // populated here. It carries the small reactive subset each kind's
196
+ // `defineEntity` exposes through its plugin `read` accessor (e.g. an
197
+ // incident's `{ status, severity }`, or health's
198
+ // `{ status, healthyChecks, totalChecks }`). This is what the reactive
199
+ // `wait_until` wake re-eval resolves so a wait on any entity kind sees
200
+ // current state after a change.
201
+ //
202
+ // The two are kept strictly separate: this generic path folds ONLY
203
+ // `scope.state.<kind>.<id>` and never writes `scope.health`. The wait
204
+ // re-eval (`reEnrichWaitScope`) resolves health via the RICH RPC path and
205
+ // EXCLUDES the `health` kind from the refs it passes here, so health is
206
+ // resolved at most once per scope build and conditions always read the rich
207
+ // `scope.health` snapshot.
208
+
209
+ /** A `{kind, id}` entity reference to pre-resolve into scope. */
210
+ export interface EntityRef {
211
+ kind: string;
212
+ id: string;
213
+ }
214
+
215
+ /**
216
+ * Batched, per-kind resolver — `getMany(ids)` for a single kind, mirroring
217
+ * `getBulkHealthState`. Missing ids are simply absent from the result.
218
+ */
219
+ export type EntityKindResolver = (
220
+ ids: ReadonlyArray<string>,
221
+ ) => Promise<Record<string, Record<string, unknown>>>;
222
+
223
+ export interface EnrichScopeWithEntitiesArgs {
224
+ /** The mutable scope to fold `state.<kind>.<id>` into. Returned for convenience. */
225
+ scope: Record<string, unknown>;
226
+ logger: Logger;
227
+ /** The refs to resolve (deduped + bounded internally). */
228
+ refs: ReadonlyArray<EntityRef>;
229
+ /** Resolve a `getMany` resolver for a kind, or undefined if unknown. */
230
+ resolverFor: (kind: string) => EntityKindResolver | undefined;
231
+ }
232
+
233
+ /** Read the `state` namespace off scope, creating it if absent. */
234
+ function stateNamespace(
235
+ scope: Record<string, unknown>,
236
+ ): Record<string, Record<string, Record<string, unknown>>> {
237
+ const existing = scope.state;
238
+ if (
239
+ typeof existing === "object" &&
240
+ existing !== null &&
241
+ !Array.isArray(existing)
242
+ ) {
243
+ return existing as Record<
244
+ string,
245
+ Record<string, Record<string, unknown>>
246
+ >;
247
+ }
248
+ const fresh: Record<string, Record<string, Record<string, unknown>>> = {};
249
+ scope.state = fresh;
250
+ return fresh;
251
+ }
252
+
253
+ /**
254
+ * Resolve the given entity refs through their per-kind resolvers and fold
255
+ * them into `scope.state.<kind>.<id>`. Refs are de-duplicated per kind and
256
+ * the total resolved set is bounded by {@link MAX_RESOLVED_SYSTEMS} (the
257
+ * runtime backstop). A kind with no resolver, or a resolver error, is
258
+ * fail-open: the kind is left absent and a warn is logged — one kind's
259
+ * outage never wedges unrelated automations.
260
+ *
261
+ * This folds ONLY `scope.state.<kind>.<id>`. The rich `scope.health.*`
262
+ * condition snapshot is owned exclusively by `enrichScopeWithState` (the
263
+ * health aggregate is computed on read via the healthcheck RPC, not stored
264
+ * as a framework entity row), so this generic path never touches it.
265
+ */
266
+ export async function enrichScopeWithEntities(
267
+ args: EnrichScopeWithEntitiesArgs,
268
+ ): Promise<Record<string, unknown>> {
269
+ const { scope, logger, refs, resolverFor } = args;
270
+ const state = stateNamespace(scope);
271
+
272
+ // Group de-duplicated ids per kind, bounded overall.
273
+ const byKind = new Map<string, string[]>();
274
+ const seen = new Set<string>();
275
+ let total = 0;
276
+ let capped = false;
277
+ for (const { kind, id } of refs) {
278
+ if (kind.length === 0 || id.length === 0) continue;
279
+ const key = `${kind}:${id}`;
280
+ if (seen.has(key)) continue;
281
+ if (total >= MAX_RESOLVED_SYSTEMS) {
282
+ capped = true;
283
+ break;
284
+ }
285
+ seen.add(key);
286
+ total += 1;
287
+ const ids = byKind.get(kind);
288
+ if (ids) ids.push(id);
289
+ else byKind.set(kind, [id]);
290
+ }
291
+ if (capped) {
292
+ logger.warn(
293
+ `enrichScopeWithEntities: resolving only the first ${MAX_RESOLVED_SYSTEMS} refs (cap reached)`,
294
+ );
295
+ }
296
+
297
+ for (const [kind, ids] of byKind) {
298
+ const resolver = resolverFor(kind);
299
+ if (!resolver) {
300
+ logger.warn(
301
+ `enrichScopeWithEntities: no resolver for kind "${kind}"; leaving it unresolved`,
302
+ );
303
+ continue;
304
+ }
305
+ try {
306
+ const resolved = await resolver(ids);
307
+ const kindBucket = state[kind] ?? {};
308
+ for (const [id, entityState] of Object.entries(resolved)) {
309
+ kindBucket[id] = entityState;
310
+ }
311
+ state[kind] = kindBucket;
312
+ } catch (error) {
313
+ logger.warn(
314
+ `enrichScopeWithEntities: failed to resolve kind "${kind}": ${
315
+ (error as Error).message
316
+ }`,
317
+ );
318
+ }
319
+ }
320
+
321
+ return scope;
322
+ }