@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,176 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { Logger } from "@checkstack/backend-api";
3
+ import {
4
+ enrichScopeWithEntities,
5
+ MAX_RESOLVED_SYSTEMS,
6
+ type EntityKindResolver,
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
+ /** Resolver factory recording the ids each kind was asked for. */
28
+ function makeResolvers(
29
+ data: Record<string, Record<string, Record<string, unknown>>>,
30
+ opts?: { throwsKind?: string },
31
+ ) {
32
+ const calls: Record<string, string[][]> = {};
33
+ const resolverFor = (kind: string): EntityKindResolver | undefined => {
34
+ if (!(kind in data) && kind !== opts?.throwsKind) return undefined;
35
+ return async (ids) => {
36
+ (calls[kind] ??= []).push([...ids]);
37
+ if (opts?.throwsKind === kind) throw new Error("resolver down");
38
+ const out: Record<string, Record<string, unknown>> = {};
39
+ for (const id of ids) {
40
+ const row = data[kind]?.[id];
41
+ if (row) out[id] = row;
42
+ }
43
+ return out;
44
+ };
45
+ };
46
+ return { calls, resolverFor };
47
+ }
48
+
49
+ describe("enrichScopeWithEntities", () => {
50
+ it("folds state.<kind>.<id> for resolved refs", async () => {
51
+ const { resolverFor } = makeResolvers({
52
+ incident: { "inc-1": { status: "open", severity: "high" } },
53
+ });
54
+ const scope: Record<string, unknown> = {};
55
+ await enrichScopeWithEntities({
56
+ scope,
57
+ logger: noopLogger,
58
+ refs: [{ kind: "incident", id: "inc-1" }],
59
+ resolverFor,
60
+ });
61
+ const state = scope.state as Record<
62
+ string,
63
+ Record<string, Record<string, unknown>>
64
+ >;
65
+ expect(state.incident!["inc-1"]).toEqual({ status: "open", severity: "high" });
66
+ });
67
+
68
+ it("groups + de-dupes ids per kind into one resolver call", async () => {
69
+ const { calls, resolverFor } = makeResolvers({
70
+ incident: {
71
+ "inc-1": { status: "open" },
72
+ "inc-2": { status: "resolved" },
73
+ },
74
+ });
75
+ const scope: Record<string, unknown> = {};
76
+ await enrichScopeWithEntities({
77
+ scope,
78
+ logger: noopLogger,
79
+ refs: [
80
+ { kind: "incident", id: "inc-1" },
81
+ { kind: "incident", id: "inc-2" },
82
+ { kind: "incident", id: "inc-1" }, // duplicate
83
+ ],
84
+ resolverFor,
85
+ });
86
+ expect(calls.incident).toEqual([["inc-1", "inc-2"]]);
87
+ });
88
+
89
+ it("folds state.<kind>.<id> only and never sets scope.health", async () => {
90
+ // `scope.health` is owned exclusively by the rich `enrichScopeWithState`
91
+ // path; this generic entity path must never write it, even for an
92
+ // entity kind. (The dispatch wait re-eval also excludes the `health`
93
+ // kind here, so health is resolved at most once per scope build.)
94
+ const { resolverFor } = makeResolvers({
95
+ incident: { "inc-1": { status: "open", severity: "high" } },
96
+ });
97
+ const scope: Record<string, unknown> = {};
98
+ await enrichScopeWithEntities({
99
+ scope,
100
+ logger: noopLogger,
101
+ refs: [{ kind: "incident", id: "inc-1" }],
102
+ resolverFor,
103
+ });
104
+ const state = scope.state as Record<
105
+ string,
106
+ Record<string, Record<string, unknown>>
107
+ >;
108
+ expect(state.incident!["inc-1"]).toEqual({
109
+ status: "open",
110
+ severity: "high",
111
+ });
112
+ expect(scope.health).toBeUndefined();
113
+ });
114
+
115
+ it("fails open and warns when a kind has no resolver", async () => {
116
+ const { resolverFor } = makeResolvers({ incident: {} });
117
+ const { logger, warnings } = collectingLogger();
118
+ const scope: Record<string, unknown> = {};
119
+ await enrichScopeWithEntities({
120
+ scope,
121
+ logger,
122
+ refs: [{ kind: "unknownkind", id: "x" }],
123
+ resolverFor,
124
+ });
125
+ expect(warnings.some((w) => w.includes("no resolver for kind"))).toBe(true);
126
+ expect(scope.state).toEqual({});
127
+ });
128
+
129
+ it("fails open and warns when a resolver throws", async () => {
130
+ const { resolverFor } = makeResolvers(
131
+ { incident: {} },
132
+ { throwsKind: "incident" },
133
+ );
134
+ const { logger, warnings } = collectingLogger();
135
+ const scope: Record<string, unknown> = {};
136
+ await enrichScopeWithEntities({
137
+ scope,
138
+ logger,
139
+ refs: [{ kind: "incident", id: "inc-1" }],
140
+ resolverFor,
141
+ });
142
+ expect(warnings.some((w) => w.includes("failed to resolve kind"))).toBe(true);
143
+ });
144
+
145
+ it("caps the resolved set and warns over the limit", async () => {
146
+ const incident: Record<string, Record<string, unknown>> = {};
147
+ const refs = Array.from({ length: MAX_RESOLVED_SYSTEMS + 5 }, (_, i) => {
148
+ incident[`inc-${i}`] = { status: "open" };
149
+ return { kind: "incident", id: `inc-${i}` };
150
+ });
151
+ const { calls, resolverFor } = makeResolvers({ incident });
152
+ const { logger, warnings } = collectingLogger();
153
+ const scope: Record<string, unknown> = {};
154
+ await enrichScopeWithEntities({ scope, logger, refs, resolverFor });
155
+ expect(calls.incident![0]!.length).toBe(MAX_RESOLVED_SYSTEMS);
156
+ expect(warnings.some((w) => w.includes("cap reached"))).toBe(true);
157
+ });
158
+
159
+ it("preserves an existing scope.state namespace", async () => {
160
+ const { resolverFor } = makeResolvers({
161
+ incident: { "inc-1": { status: "open" } },
162
+ });
163
+ const scope: Record<string, unknown> = {
164
+ state: { maintenance: { "m-1": { status: "scheduled" } } },
165
+ };
166
+ await enrichScopeWithEntities({
167
+ scope,
168
+ logger: noopLogger,
169
+ refs: [{ kind: "incident", id: "inc-1" }],
170
+ resolverFor,
171
+ });
172
+ const state = scope.state as Record<string, Record<string, unknown>>;
173
+ expect(state.maintenance!["m-1"]).toEqual({ status: "scheduled" });
174
+ expect(state.incident!["inc-1"]).toEqual({ status: "open" });
175
+ });
176
+ });
@@ -0,0 +1,318 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { z } from "zod";
3
+ import { Versioned, createServiceRef } from "@checkstack/backend-api";
4
+ // Deep import: the ServiceRegistry is a tiny, side-effect-free class. We use
5
+ // the REAL one (not a stub) so this test proves the production resolution
6
+ // path — `registry.get(ref, { pluginId })` — actually hands a registered
7
+ // service to a provider action at execute time. Importing `@checkstack/backend`'s
8
+ // index would connect to a DB at import; the deep path avoids that.
9
+ import { ServiceRegistry } from "@checkstack/backend/src/services/service-registry";
10
+ import { connectionStoreRef } from "@checkstack/integration-backend";
11
+ import { AutomationDefinitionSchema } from "@checkstack/automation-common";
12
+ import { createActionRegistry } from "../action-registry";
13
+ import { createArtifactTypeRegistry } from "../artifact-type-registry";
14
+ import type { ActionDefinition, ArtifactTypeDefinition } from "../action-types";
15
+ import { dispatchTrigger } from "./engine";
16
+ import { makeDispatchDeps, testPlugin } from "./test-fixtures";
17
+ import { createRunSecretRegistry } from "./run-secret-registry";
18
+ import { assembleDispatchGetService } from "./assemble-get-service";
19
+ import {
20
+ CONNECTION_STORE_REF_ID,
21
+ SECRET_RESOLVER_REF_ID,
22
+ } from "./secret-ref-ids";
23
+ import type { DispatchDeps, LoadedAutomation } from "./types";
24
+
25
+ /**
26
+ * Integration coverage for the cross-plugin service-resolution seam used by
27
+ * the automation dispatch engine.
28
+ *
29
+ * Background: provider actions (Jira / Teams / Webex create-issue etc.) and
30
+ * the `secretEnv` script action resolve their deps through `getService` at
31
+ * EXECUTE time — e.g. `await getService(connectionStoreRef)`. In production
32
+ * the dispatch `getService` is the plugin `env.getService`, which resolves
33
+ * through the real `ServiceRegistry`. The whole dispatch suite stubs
34
+ * `getService` (via `makeDispatchDeps`), so a throwing stub in the
35
+ * production assembly went unnoticed: every provider action would throw at
36
+ * runtime.
37
+ *
38
+ * These tests use a REAL `ServiceRegistry` and build the dispatch
39
+ * `getService` the way the plugin loader assembles `env.getService`:
40
+ * `(ref) => registry.get(ref, { pluginId })`.
41
+ */
42
+
43
+ const CONSUMER_PLUGIN_ID = "automation";
44
+
45
+ /** Build the dispatch `getService` exactly as the plugin loader's `env`. */
46
+ function makeRegistryBackedGetService(registry: ServiceRegistry) {
47
+ return <T>(ref: { id: string; T: T; toString(): string }): Promise<T> =>
48
+ registry.get(ref, { pluginId: CONSUMER_PLUGIN_ID });
49
+ }
50
+
51
+ /** A provider-style fake connection store keyed by the real ref id. */
52
+ interface FakeConnectionStore {
53
+ getConnectionWithCredentials(
54
+ connectionId: string,
55
+ ): Promise<{ config: Record<string, unknown> } | undefined>;
56
+ }
57
+
58
+ function automation(actions: unknown[]): LoadedAutomation {
59
+ const definition = AutomationDefinitionSchema.parse({
60
+ name: "Test",
61
+ triggers: [{ event: "test.event" }],
62
+ conditions: [],
63
+ actions,
64
+ mode: "single",
65
+ max_runs: 10,
66
+ });
67
+ return { id: "auto-1", name: "Test", status: "enabled", definition };
68
+ }
69
+
70
+ /**
71
+ * A provider-style action that resolves the connection store via
72
+ * `getService` (mirroring `integration-jira-backend`'s `create_issue`) and
73
+ * produces an artifact echoing the resolved credential — so we can assert
74
+ * (a) resolution succeeded and (b) the credential is masked in step output.
75
+ */
76
+ function makeProviderAction(opts?: { storeRefId?: string }): {
77
+ definition: ActionDefinition<{ connectionId: string }, { token: string }>;
78
+ artifactType: ArtifactTypeDefinition<{ token: string }>;
79
+ resolved: string[];
80
+ } {
81
+ const resolved: string[] = [];
82
+ const storeRefId = opts?.storeRefId ?? CONNECTION_STORE_REF_ID;
83
+ // Ref typed to the minimal shape the action uses; same id as the real
84
+ // `connectionStoreRef` so the run-secret masking interceptor matches.
85
+ const storeRef = createServiceRef<FakeConnectionStore>(storeRefId);
86
+ return {
87
+ artifactType: {
88
+ id: "issue",
89
+ displayName: "Issue (fake provider)",
90
+ schema: z.object({ token: z.string() }),
91
+ },
92
+ definition: {
93
+ id: "create_issue",
94
+ displayName: "Create Issue (fake provider)",
95
+ produces: "issue",
96
+ config: new Versioned({
97
+ version: 1,
98
+ schema: z.object({ connectionId: z.string() }),
99
+ }),
100
+ execute: async ({ config, getService }) => {
101
+ const store = await getService(storeRef);
102
+ const conn = await store.getConnectionWithCredentials(
103
+ config.connectionId,
104
+ );
105
+ const token = String(conn?.config.token ?? "");
106
+ resolved.push(token);
107
+ return { success: true, artifact: { token } };
108
+ },
109
+ },
110
+ resolved,
111
+ };
112
+ }
113
+
114
+ describe("dispatch getService wiring (cross-plugin resolution)", () => {
115
+ it("resolves a registered connection store through the real ServiceRegistry-backed getService", async () => {
116
+ const registry = new ServiceRegistry();
117
+ const fakeStore: FakeConnectionStore = {
118
+ async getConnectionWithCredentials() {
119
+ return { config: { token: "live-token-123", baseUrl: "https://x" } };
120
+ },
121
+ };
122
+ // Register under the REAL connection-store ref (typed `ConnectionStore`);
123
+ // the action resolves a same-id ref with a minimal structural shape.
124
+ registry.register(
125
+ connectionStoreRef,
126
+ fakeStore as unknown as (typeof connectionStoreRef)["T"],
127
+ );
128
+
129
+ const actions = createActionRegistry();
130
+ const artifactTypes = createArtifactTypeRegistry();
131
+ const provider = makeProviderAction();
132
+ actions.register(
133
+ provider.definition as ActionDefinition<unknown, unknown>,
134
+ testPlugin,
135
+ );
136
+ artifactTypes.register(
137
+ provider.artifactType as ArtifactTypeDefinition<unknown>,
138
+ testPlugin,
139
+ );
140
+
141
+ const { deps } = makeDispatchDeps({ actions, artifactTypes });
142
+ // Replace the test stub with the PRODUCTION assembly: env.getService.
143
+ const wired: DispatchDeps = {
144
+ ...deps,
145
+ getService: makeRegistryBackedGetService(registry),
146
+ };
147
+
148
+ const result = await dispatchTrigger(wired, {
149
+ automation: automation([
150
+ {
151
+ id: "create",
152
+ action: "test.create_issue",
153
+ config: { connectionId: "conn-1" },
154
+ },
155
+ ]),
156
+ triggerId: "test_event",
157
+ triggerEventId: "test.event",
158
+ payload: {},
159
+ contextKey: null,
160
+ });
161
+
162
+ expect(result.status).toBe("success");
163
+ // The action RESOLVED the fake store (did not throw "not yet wired").
164
+ expect(provider.resolved).toEqual(["live-token-123"]);
165
+ });
166
+
167
+ it("captures a credential resolved through the real getService into the run mask set (5c end-to-end)", async () => {
168
+ const registry = new ServiceRegistry();
169
+ const fakeStore: FakeConnectionStore = {
170
+ async getConnectionWithCredentials() {
171
+ return { config: { token: "secret-cred-XYZ" } };
172
+ },
173
+ };
174
+ registry.register(
175
+ connectionStoreRef,
176
+ fakeStore as unknown as (typeof connectionStoreRef)["T"],
177
+ );
178
+
179
+ const actions = createActionRegistry();
180
+ const artifactTypes = createArtifactTypeRegistry();
181
+ const provider = makeProviderAction();
182
+ actions.register(
183
+ provider.definition as ActionDefinition<unknown, unknown>,
184
+ testPlugin,
185
+ );
186
+ artifactTypes.register(
187
+ provider.artifactType as ArtifactTypeDefinition<unknown>,
188
+ testPlugin,
189
+ );
190
+
191
+ const { deps, runs } = makeDispatchDeps({ actions, artifactTypes });
192
+ // Production assembly: real getService + run-secret registry + ref ids.
193
+ // The engine wraps each run's getService (wrapGetServiceForRun): once
194
+ // the real getService is wired, resolving the connection store
195
+ // registers the credential into the run's mask set, which the run-state
196
+ // persistence choke point then masks before write (covered by
197
+ // run-state-masking.test.ts).
198
+ const secretRegistry = createRunSecretRegistry();
199
+ const wired: DispatchDeps = {
200
+ ...deps,
201
+ getService: makeRegistryBackedGetService(registry),
202
+ secretRegistry,
203
+ secretResolverRefId: SECRET_RESOLVER_REF_ID,
204
+ connectionStoreRefId: CONNECTION_STORE_REF_ID,
205
+ };
206
+
207
+ const result = await dispatchTrigger(wired, {
208
+ automation: automation([
209
+ {
210
+ id: "create",
211
+ action: "test.create_issue",
212
+ config: { connectionId: "conn-1" },
213
+ },
214
+ ]),
215
+ triggerId: "test_event",
216
+ triggerEventId: "test.event",
217
+ payload: {},
218
+ contextKey: null,
219
+ });
220
+
221
+ expect(result.status).toBe("success");
222
+ expect(provider.resolved).toEqual(["secret-cred-XYZ"]);
223
+ // The run's mask set captured the resolved credential — proving the
224
+ // real getService activates Phase-5c masking. Without the fix (throwing
225
+ // stub) the action never resolves and nothing is captured.
226
+ const runId = runs.runs.keys().next().value as string;
227
+ expect(secretRegistry.maskText(runId, "token=secret-cred-XYZ")).toBe(
228
+ "token=****",
229
+ );
230
+ });
231
+
232
+ it("throws a clear error when an action resolves an UNregistered ref (fail loud, not undefined)", async () => {
233
+ const registry = new ServiceRegistry();
234
+ // Intentionally register nothing.
235
+ const actions = createActionRegistry();
236
+ const artifactTypes = createArtifactTypeRegistry();
237
+ const provider = makeProviderAction();
238
+ actions.register(
239
+ provider.definition as ActionDefinition<unknown, unknown>,
240
+ testPlugin,
241
+ );
242
+ artifactTypes.register(
243
+ provider.artifactType as ArtifactTypeDefinition<unknown>,
244
+ testPlugin,
245
+ );
246
+
247
+ const { deps, runs } = makeDispatchDeps({ actions, artifactTypes });
248
+ const wired: DispatchDeps = {
249
+ ...deps,
250
+ getService: makeRegistryBackedGetService(registry),
251
+ };
252
+
253
+ const result = await dispatchTrigger(wired, {
254
+ automation: automation([
255
+ {
256
+ id: "create",
257
+ action: "test.create_issue",
258
+ config: { connectionId: "conn-1" },
259
+ },
260
+ ]),
261
+ triggerId: "test_event",
262
+ triggerEventId: "test.event",
263
+ payload: {},
264
+ contextKey: null,
265
+ });
266
+
267
+ // The run fails loudly (resolution threw) rather than the action seeing
268
+ // `undefined` and behaving unpredictably.
269
+ expect(result.status).toBe("failed");
270
+ const step = runs.steps.find((s) => s.actionId === "create");
271
+ expect(step?.status).toBe("failed");
272
+ expect(step?.errorMessage).toContain(CONNECTION_STORE_REF_ID);
273
+ });
274
+ });
275
+
276
+ /**
277
+ * Guard for the EXACT regression class: the production dispatchDeps must
278
+ * assemble a REAL, registry-backed `getService` — never a throwing stub.
279
+ * `index.ts` builds it via `assembleDispatchGetService({ envGetService })`,
280
+ * so exercising that helper with a real ServiceRegistry-backed env proves
281
+ * the assembled value resolves registered refs and fails loud on missing
282
+ * ones.
283
+ */
284
+ describe("assembleDispatchGetService (production dispatchDeps.getService)", () => {
285
+ function envGetServiceFor(registry: ServiceRegistry) {
286
+ return <T>(ref: { id: string; T: T; toString(): string }): Promise<T> =>
287
+ registry.get(ref, { pluginId: CONSUMER_PLUGIN_ID });
288
+ }
289
+
290
+ it("is NOT a throwing stub — resolves a registered ref", async () => {
291
+ const registry = new ServiceRegistry();
292
+ const ref = createServiceRef<{ ok: boolean }>("some.service");
293
+ registry.register(ref, { ok: true });
294
+
295
+ const getService = assembleDispatchGetService({
296
+ envGetService: envGetServiceFor(registry),
297
+ });
298
+ const resolved = await getService(ref);
299
+ expect(resolved.ok).toBe(true);
300
+ });
301
+
302
+ it("propagates a clear error on a missing ref (fail loud)", async () => {
303
+ const registry = new ServiceRegistry();
304
+ const getService = assembleDispatchGetService({
305
+ envGetService: envGetServiceFor(registry),
306
+ });
307
+ const ref = createServiceRef<{ ok: boolean }>("absent.service");
308
+
309
+ let error: unknown;
310
+ try {
311
+ await getService(ref);
312
+ } catch (e) {
313
+ error = e;
314
+ }
315
+ expect(error).toBeInstanceOf(Error);
316
+ expect((error as Error).message).toContain("absent.service");
317
+ });
318
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ extractNumericField,
4
+ matchesThreshold,
5
+ toNumberOrNull,
6
+ } from "./numeric";
7
+
8
+ describe("toNumberOrNull", () => {
9
+ it("passes finite numbers", () => {
10
+ expect(toNumberOrNull(42)).toBe(42);
11
+ expect(toNumberOrNull(0)).toBe(0);
12
+ });
13
+ it("coerces numeric strings", () => {
14
+ expect(toNumberOrNull("500")).toBe(500);
15
+ expect(toNumberOrNull("3.14")).toBeCloseTo(3.14, 5);
16
+ });
17
+ it("rejects non-numeric / empty / nullish", () => {
18
+ expect(toNumberOrNull("abc")).toBeNull();
19
+ expect(toNumberOrNull("")).toBeNull();
20
+ expect(toNumberOrNull(null)).toBeNull();
21
+ expect(toNumberOrNull(undefined)).toBeNull();
22
+ expect(toNumberOrNull({})).toBeNull();
23
+ expect(toNumberOrNull(Number.POSITIVE_INFINITY)).toBeNull();
24
+ });
25
+ });
26
+
27
+ describe("matchesThreshold", () => {
28
+ it("above only", () => {
29
+ expect(matchesThreshold({ value: 600, above: 500 })).toBe(true);
30
+ expect(matchesThreshold({ value: 500, above: 500 })).toBe(false); // strict
31
+ expect(matchesThreshold({ value: 400, above: 500 })).toBe(false);
32
+ });
33
+ it("below only", () => {
34
+ expect(matchesThreshold({ value: 50, below: 100 })).toBe(true);
35
+ expect(matchesThreshold({ value: 100, below: 100 })).toBe(false);
36
+ expect(matchesThreshold({ value: 150, below: 100 })).toBe(false);
37
+ });
38
+ it("both bounds require a band", () => {
39
+ expect(matchesThreshold({ value: 75, above: 50, below: 100 })).toBe(true);
40
+ expect(matchesThreshold({ value: 25, above: 50, below: 100 })).toBe(false);
41
+ expect(matchesThreshold({ value: 125, above: 50, below: 100 })).toBe(false);
42
+ });
43
+ it("null value never matches; no bounds never matches", () => {
44
+ expect(matchesThreshold({ value: null, above: 1 })).toBe(false);
45
+ expect(matchesThreshold({ value: 5 })).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe("extractNumericField", () => {
50
+ it("reads top-level latencyMs", () => {
51
+ expect(extractNumericField({ latencyMs: 123 }, "latencyMs")).toBe(123);
52
+ });
53
+ it("reads a collector field under result (the collectors map)", () => {
54
+ const payload = {
55
+ result: { http: { responseTimeMs: 250 } },
56
+ };
57
+ expect(
58
+ extractNumericField(payload, "collectors.http.responseTimeMs"),
59
+ ).toBe(250);
60
+ // the `collectors.` prefix is optional
61
+ expect(
62
+ extractNumericField(payload, "http.responseTimeMs"),
63
+ ).toBe(250);
64
+ });
65
+ it("returns null for an absent / non-numeric path", () => {
66
+ expect(extractNumericField({}, "latencyMs")).toBeNull();
67
+ expect(
68
+ extractNumericField({ result: {} }, "collectors.x.y"),
69
+ ).toBeNull();
70
+ });
71
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared pure helpers for numeric threshold evaluation, used by both the
3
+ * `numeric_state` trigger and the `numeric_state` condition.
4
+ *
5
+ * No I/O, fully synchronous.
6
+ */
7
+
8
+ /**
9
+ * Resolve a numeric value from an unknown input. Accepts numbers and
10
+ * numeric strings; everything else (null, undefined, non-numeric string,
11
+ * object) is "no value" → null.
12
+ */
13
+ export function toNumberOrNull(value: unknown): number | null {
14
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
15
+ if (typeof value === "string" && value.trim() !== "") {
16
+ const n = Number(value);
17
+ return Number.isFinite(n) ? n : null;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ /**
23
+ * Compare a numeric value against optional `above` / `below` bounds.
24
+ * Returns true when the value is strictly above `above` AND/OR strictly
25
+ * below `below` (whichever bounds are provided). With both bounds set,
26
+ * the value must satisfy BOTH (i.e. `above < value < below` for a band,
27
+ * or `value > above && value < below`). With neither bound, returns
28
+ * false (a threshold with no bounds never matches).
29
+ *
30
+ * A null value never matches.
31
+ */
32
+ export function matchesThreshold(args: {
33
+ value: number | null;
34
+ above?: number;
35
+ below?: number;
36
+ }): boolean {
37
+ const { value, above, below } = args;
38
+ if (value === null) return false;
39
+ if (above === undefined && below === undefined) return false;
40
+ if (above !== undefined && !(value > above)) return false;
41
+ if (below !== undefined && !(value < below)) return false;
42
+ return true;
43
+ }
44
+
45
+ /**
46
+ * Extract a numeric field from a `healthcheck.check.completed` payload.
47
+ *
48
+ * The hook payload's shape is:
49
+ *
50
+ * { systemId, configurationId, status, latencyMs?, result?, timestamp }
51
+ *
52
+ * where `result` is the per-check collectors map
53
+ * (`{ <collectorId>: { <field>: value, … }, … }`).
54
+ *
55
+ * Supported field paths:
56
+ * - `latencyMs` → payload.latencyMs (top-level)
57
+ * - `collectors.<id>.<field>` → payload.result.<id>.<field>
58
+ * - `<id>.<field>` → payload.result.<id>.<field>
59
+ *
60
+ * (`p95LatencyMs` is a windowed aggregate, absent per-check — read it via
61
+ * the `health.*` scope / a `numeric_state` condition instead.)
62
+ *
63
+ * Returns null when the path is absent or non-numeric.
64
+ */
65
+ export function extractNumericField(
66
+ payload: Record<string, unknown>,
67
+ field: string,
68
+ ): number | null {
69
+ // Top-level latency is the common case.
70
+ if (field === "latencyMs") {
71
+ return toNumberOrNull(payload.latencyMs);
72
+ }
73
+
74
+ // Everything else is a collector field under `result` (the collectors
75
+ // map). Accept an optional leading `collectors.` for readability.
76
+ const collectors = isRecord(payload.result) ? payload.result : undefined;
77
+ if (!collectors) return null;
78
+ const path = field.startsWith("collectors.")
79
+ ? field.slice("collectors.".length)
80
+ : field;
81
+ return toNumberOrNull(resolvePath(collectors, path));
82
+ }
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value);
86
+ }
87
+
88
+ /** Resolve a dotted path against a nested record. Returns undefined on miss. */
89
+ function resolvePath(root: Record<string, unknown>, path: string): unknown {
90
+ let current: unknown = root;
91
+ for (const segment of path.split(".")) {
92
+ if (!isRecord(current)) return undefined;
93
+ current = current[segment];
94
+ }
95
+ return current;
96
+ }