@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.
- package/CHANGELOG.md +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- 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
|
+
}
|