@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,138 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createRunStateStore } from "./run-state-store";
|
|
3
|
+
import { createRunSecretRegistry } from "./run-secret-registry";
|
|
4
|
+
import { createArtifactStore } from "../artifact-store";
|
|
5
|
+
import type { AdvisoryLockService } from "@checkstack/backend-api";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* H2 — the run-wide masking choke point must ALSO cover the scope snapshot
|
|
9
|
+
* (run-state) and produced artifacts. A resolved connection credential
|
|
10
|
+
* threaded into `scope.variables` / an artifact's `data` must be redacted
|
|
11
|
+
* BEFORE persist, so `getRunScopeForReplay` (which reads those rows
|
|
12
|
+
* verbatim, gated only on automationAccess.read) can never hand a
|
|
13
|
+
* read-only user the live value.
|
|
14
|
+
*
|
|
15
|
+
* Uses a capturing fake `db` (the established boundary for these store
|
|
16
|
+
* tests) so we assert on exactly what the store writes to the DB layer —
|
|
17
|
+
* which is what the replay endpoint later reads back.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface Captured {
|
|
21
|
+
inserts: Array<{ values: Record<string, unknown> }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function capturingDb(captured: Captured) {
|
|
25
|
+
const chain = {
|
|
26
|
+
values(v: Record<string, unknown>) {
|
|
27
|
+
captured.inserts.push({ values: v });
|
|
28
|
+
return {
|
|
29
|
+
returning() {
|
|
30
|
+
return Promise.resolve([{ id: "generated-id", ...v }]);
|
|
31
|
+
},
|
|
32
|
+
onConflictDoUpdate() {
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const db = {
|
|
39
|
+
insert() {
|
|
40
|
+
return chain;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const noopAdvisoryLock: AdvisoryLockService = {
|
|
47
|
+
tryAcquire: async () => ({ release: async () => {} }),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("H2 — scope snapshot masking (run-state choke point)", () => {
|
|
51
|
+
it("masks a resolved credential surfaced into scope.variables before persist", async () => {
|
|
52
|
+
const captured: Captured = { inserts: [] };
|
|
53
|
+
const registry = createRunSecretRegistry();
|
|
54
|
+
// The run resolved this connection credential during execution (the
|
|
55
|
+
// wrapped connection store registered it).
|
|
56
|
+
registry.register("run-1", ["super-secret-token-123"]);
|
|
57
|
+
|
|
58
|
+
const store = createRunStateStore(
|
|
59
|
+
capturingDb(captured) as unknown as Parameters<
|
|
60
|
+
typeof createRunStateStore
|
|
61
|
+
>[0],
|
|
62
|
+
noopAdvisoryLock,
|
|
63
|
+
registry,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await store.upsert({
|
|
67
|
+
runId: "run-1",
|
|
68
|
+
scopeSnapshot: {
|
|
69
|
+
variables: { apiKey: "super-secret-token-123" },
|
|
70
|
+
artifacts: {},
|
|
71
|
+
},
|
|
72
|
+
lastActionPath: "actions[0]",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const persisted = captured.inserts.at(-1)!.values;
|
|
76
|
+
// This is exactly the row getRunScopeForReplay would read back.
|
|
77
|
+
expect(JSON.stringify(persisted.scopeSnapshot)).not.toContain(
|
|
78
|
+
"super-secret-token-123",
|
|
79
|
+
);
|
|
80
|
+
expect(persisted.scopeSnapshot).toEqual({
|
|
81
|
+
variables: { apiKey: "****" },
|
|
82
|
+
artifacts: {},
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("least-privilege: a value not resolved in the run is left intact", async () => {
|
|
87
|
+
const captured: Captured = { inserts: [] };
|
|
88
|
+
const registry = createRunSecretRegistry();
|
|
89
|
+
const store = createRunStateStore(
|
|
90
|
+
capturingDb(captured) as unknown as Parameters<
|
|
91
|
+
typeof createRunStateStore
|
|
92
|
+
>[0],
|
|
93
|
+
noopAdvisoryLock,
|
|
94
|
+
registry,
|
|
95
|
+
);
|
|
96
|
+
await store.upsert({
|
|
97
|
+
runId: "run-x",
|
|
98
|
+
scopeSnapshot: { variables: { note: "not-a-secret" } },
|
|
99
|
+
lastActionPath: null,
|
|
100
|
+
});
|
|
101
|
+
const persisted = captured.inserts.at(-1)!.values;
|
|
102
|
+
expect(persisted.scopeSnapshot).toEqual({
|
|
103
|
+
variables: { note: "not-a-secret" },
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("H2 — artifact data masking (artifact-store choke point)", () => {
|
|
109
|
+
it("masks a resolved credential surfaced into a produced artifact before insert", async () => {
|
|
110
|
+
const captured: Captured = { inserts: [] };
|
|
111
|
+
const registry = createRunSecretRegistry();
|
|
112
|
+
registry.register("run-2", ["resolved-cred-XYZ"]);
|
|
113
|
+
|
|
114
|
+
const store = createArtifactStore(
|
|
115
|
+
capturingDb(captured) as unknown as Parameters<
|
|
116
|
+
typeof createArtifactStore
|
|
117
|
+
>[0],
|
|
118
|
+
registry,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
await store.record({
|
|
122
|
+
automationId: "auto-1",
|
|
123
|
+
runId: "run-2",
|
|
124
|
+
stepId: "step-1",
|
|
125
|
+
actionId: "a1",
|
|
126
|
+
artifactType: "integration-jira.issue",
|
|
127
|
+
data: { url: "https://x", auth: "Bearer resolved-cred-XYZ" },
|
|
128
|
+
contextKey: "incident-1",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const persisted = captured.inserts.at(-1)!.values;
|
|
132
|
+
expect(JSON.stringify(persisted.data)).not.toContain("resolved-cred-XYZ");
|
|
133
|
+
expect(persisted.data).toEqual({
|
|
134
|
+
url: "https://x",
|
|
135
|
+
auth: "Bearer ****",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { secretResolverRef } from "@checkstack/secrets-backend";
|
|
3
|
+
import { connectionStoreRef } from "@checkstack/integration-backend";
|
|
4
|
+
import {
|
|
5
|
+
SECRET_RESOLVER_REF_ID,
|
|
6
|
+
CONNECTION_STORE_REF_ID,
|
|
7
|
+
} from "./secret-ref-ids";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drift guard: the dispatch core intercepts these service refs by literal
|
|
11
|
+
* id (to avoid a hard dependency cycle). If either ref is renamed, this
|
|
12
|
+
* test fails so run-wide masking can't silently stop capturing values.
|
|
13
|
+
*/
|
|
14
|
+
describe("secret ref id constants", () => {
|
|
15
|
+
it("match the real secretResolverRef / connectionStoreRef ids", () => {
|
|
16
|
+
expect(SECRET_RESOLVER_REF_ID).toBe(secretResolverRef.id);
|
|
17
|
+
expect(CONNECTION_STORE_REF_ID).toBe(connectionStoreRef.id);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-ref ids the dispatch engine intercepts to capture resolved
|
|
3
|
+
* secret values for run-wide output masking.
|
|
4
|
+
*
|
|
5
|
+
* These are kept as plain string constants (rather than importing the
|
|
6
|
+
* `secretResolverRef` / `connectionStoreRef` objects) so the dispatch core
|
|
7
|
+
* does NOT take a hard dependency on `@checkstack/secrets-backend` or
|
|
8
|
+
* `@checkstack/integration-backend` (which would risk a dependency cycle).
|
|
9
|
+
* A unit test asserts these match the real ref ids, so a rename in either
|
|
10
|
+
* package fails CI rather than silently disabling masking.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Must equal `secretResolverRef.id` in `@checkstack/secrets-backend`. */
|
|
14
|
+
export const SECRET_RESOLVER_REF_ID = "secrets.resolver";
|
|
15
|
+
|
|
16
|
+
/** Must equal `connectionStoreRef.id` in `@checkstack/integration-backend`. */
|
|
17
|
+
export const CONNECTION_STORE_REF_ID = "integration.connectionStore";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
3
|
+
import { parseActorSnapshot, parseWaitConfig } from "./snapshots";
|
|
4
|
+
|
|
5
|
+
const silentLogger = {
|
|
6
|
+
debug: () => {},
|
|
7
|
+
info: () => {},
|
|
8
|
+
warn: () => {},
|
|
9
|
+
error: () => {},
|
|
10
|
+
} as unknown as Parameters<typeof parseActorSnapshot>[0]["logger"];
|
|
11
|
+
|
|
12
|
+
describe("parseActorSnapshot", () => {
|
|
13
|
+
it("returns a valid stored actor unchanged", () => {
|
|
14
|
+
const actor = { type: "user" as const, id: "u-1" };
|
|
15
|
+
const parsed = parseActorSnapshot({
|
|
16
|
+
value: actor,
|
|
17
|
+
logger: silentLogger,
|
|
18
|
+
context: "test",
|
|
19
|
+
});
|
|
20
|
+
expect(parsed).toEqual(actor);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("degrades a malformed/drifted snapshot to the system actor", () => {
|
|
24
|
+
// A hand-edited / drifted row: wrong shape. Must NOT flow through as a
|
|
25
|
+
// bogus Actor — degrades safely (and would log a warning).
|
|
26
|
+
const parsed = parseActorSnapshot({
|
|
27
|
+
value: { type: "not-a-real-type", whoops: true },
|
|
28
|
+
logger: silentLogger,
|
|
29
|
+
context: "Dwell d-1",
|
|
30
|
+
});
|
|
31
|
+
expect(parsed).toEqual(SYSTEM_ACTOR);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("degrades a non-object snapshot to the system actor", () => {
|
|
35
|
+
expect(
|
|
36
|
+
parseActorSnapshot({ value: "garbage", context: "x" }),
|
|
37
|
+
).toEqual(SYSTEM_ACTOR);
|
|
38
|
+
expect(parseActorSnapshot({ value: null, context: "x" })).toEqual(
|
|
39
|
+
SYSTEM_ACTOR,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("parseWaitConfig", () => {
|
|
45
|
+
const valid = {
|
|
46
|
+
condition: "trigger.payload.x == 1",
|
|
47
|
+
continueOnTimeout: false,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it("returns a valid stored wait config unchanged", () => {
|
|
51
|
+
const parsed = parseWaitConfig({
|
|
52
|
+
value: valid,
|
|
53
|
+
logger: silentLogger,
|
|
54
|
+
context: "test",
|
|
55
|
+
});
|
|
56
|
+
expect(parsed).toEqual(valid);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("resumes an old suspended wait that still carries pollSeconds", () => {
|
|
60
|
+
// Back-compat: rows written before the reactive engine dropped polling
|
|
61
|
+
// still carry a `pollSeconds` key. The schema is a non-strict z.object,
|
|
62
|
+
// so the extra key is stripped and the wait resumes cleanly.
|
|
63
|
+
const parsed = parseWaitConfig({
|
|
64
|
+
value: { ...valid, pollSeconds: 30 },
|
|
65
|
+
logger: silentLogger,
|
|
66
|
+
context: "test",
|
|
67
|
+
});
|
|
68
|
+
expect(parsed).toEqual(valid);
|
|
69
|
+
expect(parsed).not.toHaveProperty("pollSeconds");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("treats null/undefined as no config", () => {
|
|
73
|
+
expect(parseWaitConfig({ value: null, context: "x" })).toBeNull();
|
|
74
|
+
expect(parseWaitConfig({ value: undefined, context: "x" })).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns null for a malformed/drifted config (engine treats as gone)", () => {
|
|
78
|
+
// Missing required fields — must not be trusted as an UntilWaitConfig.
|
|
79
|
+
const parsed = parseWaitConfig({
|
|
80
|
+
value: { continueOnTimeout: "nope" },
|
|
81
|
+
logger: silentLogger,
|
|
82
|
+
context: "Wait lock w-1",
|
|
83
|
+
});
|
|
84
|
+
expect(parsed).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for snapshots that were written to jsonb columns and are read
|
|
3
|
+
* back later (dwell `actorSnapshot`, wait-lock `waitConfig`).
|
|
4
|
+
*
|
|
5
|
+
* These columns are typed `unknown`/`Record<string, unknown>` at the DB
|
|
6
|
+
* boundary, so the engine previously trusted them via `as unknown as ...`
|
|
7
|
+
* casts on LOAD. A drifted schema or a hand-edited row would then flow
|
|
8
|
+
* through as the wrong type and blow up far from the cause. We instead
|
|
9
|
+
* `safeParse` on load and degrade safely (the write side stays as-is — it
|
|
10
|
+
* writes known-good typed data).
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { type Actor, ActorSchema, SYSTEM_ACTOR } from "@checkstack/common";
|
|
14
|
+
import { ConditionSchema } from "@checkstack/automation-common";
|
|
15
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
16
|
+
|
|
17
|
+
import type { UntilWaitConfig } from "./types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stored config for a `kind: "until"` wait lock (jsonb).
|
|
21
|
+
*
|
|
22
|
+
* NOTE: this is a non-strict `z.object`, so old persisted rows that still
|
|
23
|
+
* carry a `pollSeconds` key (written before the reactive engine dropped
|
|
24
|
+
* polling) parse cleanly — zod silently strips the unknown key. Do NOT make
|
|
25
|
+
* this `.strict()` or resuming an already-suspended wait would fail.
|
|
26
|
+
*/
|
|
27
|
+
export const UntilWaitConfigSchema: z.ZodType<UntilWaitConfig> = z.object({
|
|
28
|
+
condition: ConditionSchema,
|
|
29
|
+
continueOnTimeout: z.boolean(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a stored `actorSnapshot`. Degrades to {@link SYSTEM_ACTOR} on a
|
|
34
|
+
* malformed/drifted row rather than crashing the fire — actor is used for
|
|
35
|
+
* attribution, and dropping a legitimate alert because of a bad snapshot is
|
|
36
|
+
* worse than attributing it to the system. Logs a warning so the drift is
|
|
37
|
+
* visible.
|
|
38
|
+
*/
|
|
39
|
+
export function parseActorSnapshot({
|
|
40
|
+
value,
|
|
41
|
+
logger,
|
|
42
|
+
context,
|
|
43
|
+
}: {
|
|
44
|
+
value: unknown;
|
|
45
|
+
logger?: Logger;
|
|
46
|
+
context: string;
|
|
47
|
+
}): Actor {
|
|
48
|
+
const result = ActorSchema.safeParse(value);
|
|
49
|
+
if (result.success) return result.data;
|
|
50
|
+
logger?.warn(
|
|
51
|
+
`${context}: stored actorSnapshot is malformed (${result.error.message}); ` +
|
|
52
|
+
`falling back to the system actor`,
|
|
53
|
+
);
|
|
54
|
+
return SYSTEM_ACTOR;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a stored wait-lock `waitConfig`. Returns `null` (which the engine
|
|
59
|
+
* treats as a gone/invalid `until` lock and fails the run cleanly) on a
|
|
60
|
+
* malformed/drifted row, rather than trusting wrongly-typed data downstream.
|
|
61
|
+
*/
|
|
62
|
+
export function parseWaitConfig({
|
|
63
|
+
value,
|
|
64
|
+
logger,
|
|
65
|
+
context,
|
|
66
|
+
}: {
|
|
67
|
+
value: unknown;
|
|
68
|
+
logger?: Logger;
|
|
69
|
+
context: string;
|
|
70
|
+
}): UntilWaitConfig | null {
|
|
71
|
+
if (value === null || value === undefined) return null;
|
|
72
|
+
const result = UntilWaitConfigSchema.safeParse(value);
|
|
73
|
+
if (result.success) return result.data;
|
|
74
|
+
logger?.warn(
|
|
75
|
+
`${context}: stored waitConfig is malformed (${result.error.message}); ` +
|
|
76
|
+
`treating the wait lock as invalid`,
|
|
77
|
+
);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { AutomationDefinitionSchema } from "@checkstack/automation-common";
|
|
3
|
+
import type { EntityChanged, DispatchJob } from "@checkstack/automation-common";
|
|
4
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
5
|
+
|
|
6
|
+
import { createActionRegistry } from "../action-registry";
|
|
7
|
+
import { createChangeDeriverRegistry } from "../entity/change-derivers";
|
|
8
|
+
import { makeDispatchDeps, makeRecordingAction, testPlugin } from "./test-fixtures";
|
|
9
|
+
import { routeEntityChange, ENTITY_ROUTE_WORKER_GROUP } from "./stage1-router";
|
|
10
|
+
import { DISPATCH_QUEUE_NAME } from "./stage2-dispatch";
|
|
11
|
+
import type { AutomationStore } from "../automation-store";
|
|
12
|
+
import type { LoadedAutomation } from "./types";
|
|
13
|
+
|
|
14
|
+
function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
|
|
15
|
+
return {
|
|
16
|
+
kind: "fake",
|
|
17
|
+
id: "ent-1",
|
|
18
|
+
prev: null,
|
|
19
|
+
next: { status: "open" },
|
|
20
|
+
delta: { status: "open" },
|
|
21
|
+
changedFields: ["status"],
|
|
22
|
+
actor: SYSTEM_ACTOR,
|
|
23
|
+
occurredAt: new Date().toISOString(),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fakeAutomation(): LoadedAutomation {
|
|
29
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
30
|
+
name: "A",
|
|
31
|
+
triggers: [{ event: "fake.opened" }],
|
|
32
|
+
conditions: [],
|
|
33
|
+
actions: [{ action: "test.record", config: { value: "ran" } }],
|
|
34
|
+
mode: "single",
|
|
35
|
+
max_runs: 10,
|
|
36
|
+
});
|
|
37
|
+
return { id: "auto-1", name: "A", status: "enabled", definition };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function storeWith(autos: LoadedAutomation[]): AutomationStore {
|
|
41
|
+
return {
|
|
42
|
+
create: async () => {
|
|
43
|
+
throw new Error("nope");
|
|
44
|
+
},
|
|
45
|
+
update: async () => {
|
|
46
|
+
throw new Error("nope");
|
|
47
|
+
},
|
|
48
|
+
delete: async () => {},
|
|
49
|
+
toggle: async () => {
|
|
50
|
+
throw new Error("nope");
|
|
51
|
+
},
|
|
52
|
+
getById: async (id) => {
|
|
53
|
+
const a = autos.find((x) => x.id === id);
|
|
54
|
+
return a
|
|
55
|
+
? {
|
|
56
|
+
id: a.id,
|
|
57
|
+
name: a.name,
|
|
58
|
+
description: undefined,
|
|
59
|
+
status: a.status,
|
|
60
|
+
definition: a.definition,
|
|
61
|
+
managedBy: undefined,
|
|
62
|
+
createdAt: new Date(),
|
|
63
|
+
updatedAt: new Date(),
|
|
64
|
+
}
|
|
65
|
+
: undefined;
|
|
66
|
+
},
|
|
67
|
+
list: async () => ({ items: [], total: 0 }),
|
|
68
|
+
listGroups: async () => [],
|
|
69
|
+
findEnabledByTriggerEvent: async (eventId) =>
|
|
70
|
+
autos.filter((a) => a.definition.triggers.some((t) => t.event === eventId)),
|
|
71
|
+
listEnabled: async () => autos,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("ENTITY_ROUTE_WORKER_GROUP", () => {
|
|
76
|
+
it("is the documented Stage-1 worker group", () => {
|
|
77
|
+
expect(ENTITY_ROUTE_WORKER_GROUP).toBe("automation-entity-route");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("Stage-1 routeEntityChange — fresh triggers via deriver", () => {
|
|
82
|
+
it("routes a change to a Stage-2 trigger job via a registered fake deriver", async () => {
|
|
83
|
+
const actions = createActionRegistry();
|
|
84
|
+
actions.register(makeRecordingAction().definition, testPlugin);
|
|
85
|
+
const { deps, queue } = makeDispatchDeps({ actions });
|
|
86
|
+
|
|
87
|
+
const changeDerivers = createChangeDeriverRegistry();
|
|
88
|
+
changeDerivers.register({
|
|
89
|
+
kind: "fake",
|
|
90
|
+
derive: (c) => (c.next?.status === "open" ? ["fake.opened"] : []),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const auto = fakeAutomation();
|
|
94
|
+
const jobs = await routeEntityChange({
|
|
95
|
+
deps,
|
|
96
|
+
automationStore: storeWith([auto]),
|
|
97
|
+
changeDerivers,
|
|
98
|
+
changed: change(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(jobs).toHaveLength(1);
|
|
102
|
+
const job = jobs[0]!;
|
|
103
|
+
expect(job.reason).toBe("trigger");
|
|
104
|
+
if (job.reason === "trigger") {
|
|
105
|
+
expect(job.automationId).toBe("auto-1");
|
|
106
|
+
expect(job.triggerId).toBe("fake.opened");
|
|
107
|
+
expect(job.ref).toBe("fake:ent-1");
|
|
108
|
+
}
|
|
109
|
+
// The job was enqueued onto the dispatch queue.
|
|
110
|
+
expect(queue.jobs.some((j) => j.queue === DISPATCH_QUEUE_NAME)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("routes nothing when no deriver is registered (Phase-5 production default)", async () => {
|
|
114
|
+
const { deps } = makeDispatchDeps();
|
|
115
|
+
const changeDerivers = createChangeDeriverRegistry();
|
|
116
|
+
const jobs = await routeEntityChange({
|
|
117
|
+
deps,
|
|
118
|
+
automationStore: storeWith([fakeAutomation()]),
|
|
119
|
+
changeDerivers,
|
|
120
|
+
changed: change(),
|
|
121
|
+
});
|
|
122
|
+
expect(jobs).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("derives multiple event ids and fans out to each referencing automation", async () => {
|
|
126
|
+
const { deps } = makeDispatchDeps();
|
|
127
|
+
const changeDerivers = createChangeDeriverRegistry();
|
|
128
|
+
changeDerivers.register({
|
|
129
|
+
kind: "fake",
|
|
130
|
+
derive: () => ["fake.opened", "fake.touched"],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const a1 = fakeAutomation();
|
|
134
|
+
const a2def = AutomationDefinitionSchema.parse({
|
|
135
|
+
name: "B",
|
|
136
|
+
triggers: [{ event: "fake.touched" }],
|
|
137
|
+
conditions: [],
|
|
138
|
+
actions: [{ action: "test.record", config: { value: "ran" } }],
|
|
139
|
+
mode: "single",
|
|
140
|
+
max_runs: 10,
|
|
141
|
+
});
|
|
142
|
+
const a2: LoadedAutomation = {
|
|
143
|
+
id: "auto-2",
|
|
144
|
+
name: "B",
|
|
145
|
+
status: "enabled",
|
|
146
|
+
definition: a2def,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const jobs = await routeEntityChange({
|
|
150
|
+
deps,
|
|
151
|
+
automationStore: storeWith([a1, a2]),
|
|
152
|
+
changeDerivers,
|
|
153
|
+
changed: change(),
|
|
154
|
+
});
|
|
155
|
+
const triggerJobs = jobs.filter((j): j is Extract<DispatchJob, { reason: "trigger" }> => j.reason === "trigger");
|
|
156
|
+
expect(triggerJobs.map((j) => j.automationId).toSorted()).toEqual([
|
|
157
|
+
"auto-1",
|
|
158
|
+
"auto-2",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("Stage-1 trigger jobId — dedup by changeId, not occurredAt", () => {
|
|
164
|
+
/** Re-route the same logical change and collect the trigger job ids. */
|
|
165
|
+
async function triggerJobIdsFor(changed: EntityChanged): Promise<string[]> {
|
|
166
|
+
const actions = createActionRegistry();
|
|
167
|
+
actions.register(makeRecordingAction().definition, testPlugin);
|
|
168
|
+
const { deps, queue } = makeDispatchDeps({ actions });
|
|
169
|
+
const changeDerivers = createChangeDeriverRegistry();
|
|
170
|
+
changeDerivers.register({
|
|
171
|
+
kind: "fake",
|
|
172
|
+
derive: (c) => (c.next?.status === "open" ? ["fake.opened"] : []),
|
|
173
|
+
});
|
|
174
|
+
await routeEntityChange({
|
|
175
|
+
deps,
|
|
176
|
+
automationStore: storeWith([fakeAutomation()]),
|
|
177
|
+
changeDerivers,
|
|
178
|
+
changed,
|
|
179
|
+
});
|
|
180
|
+
return queue.jobs
|
|
181
|
+
.filter((j) => j.queue === DISPATCH_QUEUE_NAME && j.jobId?.startsWith("trigger:"))
|
|
182
|
+
.map((j) => j.jobId!)
|
|
183
|
+
.filter((id): id is string => id !== undefined);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
it("two DISTINCT changes within one ms (distinct changeId) get distinct jobIds", async () => {
|
|
187
|
+
// Same entity, same occurredAt (ms granularity collides), distinct change.
|
|
188
|
+
const at = new Date().toISOString();
|
|
189
|
+
const idsA = await triggerJobIdsFor(
|
|
190
|
+
change({ occurredAt: at, changeId: "chg-A" }),
|
|
191
|
+
);
|
|
192
|
+
const idsB = await triggerJobIdsFor(
|
|
193
|
+
change({ occurredAt: at, changeId: "chg-B" }),
|
|
194
|
+
);
|
|
195
|
+
expect(idsA).toHaveLength(1);
|
|
196
|
+
expect(idsB).toHaveLength(1);
|
|
197
|
+
// The wall-clock occurredAt is identical, but the changeId distinguishes
|
|
198
|
+
// them → distinct jobIds → BullMQ keeps both runs.
|
|
199
|
+
expect(idsA[0]).not.toBe(idsB[0]);
|
|
200
|
+
expect(idsA[0]).toContain("chg-A");
|
|
201
|
+
expect(idsB[0]).toContain("chg-B");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("a REDELIVERY of one change (same changeId) derives the SAME jobId (deduped)", async () => {
|
|
205
|
+
const changed = change({ changeId: "chg-redelivered" });
|
|
206
|
+
const first = await triggerJobIdsFor(changed);
|
|
207
|
+
// A redelivery carries the same payload (changeId travels with it).
|
|
208
|
+
const second = await triggerJobIdsFor(changed);
|
|
209
|
+
expect(first[0]).toBe(second[0]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("falls back to occurredAt for legacy payloads without a changeId", async () => {
|
|
213
|
+
const at = new Date().toISOString();
|
|
214
|
+
const ids = await triggerJobIdsFor(change({ occurredAt: at }));
|
|
215
|
+
expect(ids[0]).toContain(at);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("Stage-1 routeEntityChange — waiting runs via wake-index", () => {
|
|
220
|
+
it("routes a Stage-2 wake job for a wait whose ref matches", async () => {
|
|
221
|
+
const { deps, runs } = makeDispatchDeps();
|
|
222
|
+
|
|
223
|
+
// Seed a reactive until-lock depending on `fake:ent-1`.
|
|
224
|
+
await deps.runStore.createRun({
|
|
225
|
+
automationId: "auto-1",
|
|
226
|
+
triggerId: "t",
|
|
227
|
+
triggerEventId: "test.event",
|
|
228
|
+
triggerPayload: {},
|
|
229
|
+
contextKey: "ent-1",
|
|
230
|
+
});
|
|
231
|
+
const lockId = await deps.runStore.createWaitLockWithWakeRefs({
|
|
232
|
+
runId: "run-1",
|
|
233
|
+
actionPath: "actions[0]",
|
|
234
|
+
eventId: "@@until",
|
|
235
|
+
contextKey: "ent-1",
|
|
236
|
+
timeoutAt: null,
|
|
237
|
+
waitConfig: {
|
|
238
|
+
condition: "state.fake['ent-1'].status == 'open'",
|
|
239
|
+
continueOnTimeout: true,
|
|
240
|
+
},
|
|
241
|
+
wakeRefs: ["fake:ent-1"],
|
|
242
|
+
});
|
|
243
|
+
void lockId;
|
|
244
|
+
|
|
245
|
+
const changeDerivers = createChangeDeriverRegistry();
|
|
246
|
+
const jobs = await routeEntityChange({
|
|
247
|
+
deps,
|
|
248
|
+
automationStore: storeWith([]),
|
|
249
|
+
changeDerivers,
|
|
250
|
+
changed: change(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(jobs).toHaveLength(1);
|
|
254
|
+
expect(jobs[0]?.reason).toBe("wake");
|
|
255
|
+
if (jobs[0]?.reason === "wake") {
|
|
256
|
+
expect(jobs[0].runId).toBe("run-1");
|
|
257
|
+
expect(jobs[0].ref).toBe("fake:ent-1");
|
|
258
|
+
}
|
|
259
|
+
expect(runs.waitLocks.size).toBe(1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("matches a kind-level wildcard wait", async () => {
|
|
263
|
+
const { deps } = makeDispatchDeps();
|
|
264
|
+
await deps.runStore.createRun({
|
|
265
|
+
automationId: "auto-1",
|
|
266
|
+
triggerId: "t",
|
|
267
|
+
triggerEventId: "test.event",
|
|
268
|
+
triggerPayload: {},
|
|
269
|
+
contextKey: null,
|
|
270
|
+
});
|
|
271
|
+
await deps.runStore.createWaitLockWithWakeRefs({
|
|
272
|
+
runId: "run-1",
|
|
273
|
+
actionPath: "actions[0]",
|
|
274
|
+
eventId: "@@until",
|
|
275
|
+
contextKey: null,
|
|
276
|
+
timeoutAt: null,
|
|
277
|
+
waitConfig: {
|
|
278
|
+
condition: "state.fake[trigger.id].status == 'open'",
|
|
279
|
+
continueOnTimeout: true,
|
|
280
|
+
},
|
|
281
|
+
wakeRefs: ["fake:*"],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const jobs = await routeEntityChange({
|
|
285
|
+
deps,
|
|
286
|
+
automationStore: storeWith([]),
|
|
287
|
+
changeDerivers: createChangeDeriverRegistry(),
|
|
288
|
+
changed: change({ id: "ent-99" }),
|
|
289
|
+
});
|
|
290
|
+
expect(jobs).toHaveLength(1);
|
|
291
|
+
expect(jobs[0]?.reason).toBe("wake");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("does not match a wait depending on a different ref", async () => {
|
|
295
|
+
const { deps } = makeDispatchDeps();
|
|
296
|
+
await deps.runStore.createRun({
|
|
297
|
+
automationId: "auto-1",
|
|
298
|
+
triggerId: "t",
|
|
299
|
+
triggerEventId: "test.event",
|
|
300
|
+
triggerPayload: {},
|
|
301
|
+
contextKey: null,
|
|
302
|
+
});
|
|
303
|
+
await deps.runStore.createWaitLockWithWakeRefs({
|
|
304
|
+
runId: "run-1",
|
|
305
|
+
actionPath: "actions[0]",
|
|
306
|
+
eventId: "@@until",
|
|
307
|
+
contextKey: null,
|
|
308
|
+
timeoutAt: null,
|
|
309
|
+
waitConfig: {
|
|
310
|
+
condition: "state.fake['other'].status == 'open'",
|
|
311
|
+
continueOnTimeout: true,
|
|
312
|
+
},
|
|
313
|
+
wakeRefs: ["fake:other"],
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const jobs = await routeEntityChange({
|
|
317
|
+
deps,
|
|
318
|
+
automationStore: storeWith([]),
|
|
319
|
+
changeDerivers: createChangeDeriverRegistry(),
|
|
320
|
+
changed: change({ id: "ent-1" }),
|
|
321
|
+
});
|
|
322
|
+
expect(jobs).toHaveLength(0);
|
|
323
|
+
});
|
|
324
|
+
});
|