@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,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage-1 routing (reactive automation engine §7, §13).
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the internal `ENTITY_CHANGED` hook in work-queue mode
|
|
5
|
+
* (`workerGroup: "automation-entity-route"`) so exactly ONE instance claims
|
|
6
|
+
* each change. The claimer does only cheap, indexed routing:
|
|
7
|
+
*
|
|
8
|
+
* (a) Waiting runs — wake-index intersection lookup (§8.2): every
|
|
9
|
+
* `kind:"until"` wait lock whose dependency set includes the changed
|
|
10
|
+
* `${kind}:${id}` ref (or the kind-level wildcard). One Stage-2 `wake`
|
|
11
|
+
* job is enqueued per matching lock.
|
|
12
|
+
* (b) Fresh-run triggers — the change is run through the per-kind
|
|
13
|
+
* trigger-event deriver registry (§ change-derivers) to get the
|
|
14
|
+
* qualified trigger event id(s); each id is fanned out to the enabled
|
|
15
|
+
* automations referencing it (`findEnabledByTriggerEvent`). One
|
|
16
|
+
* Stage-2 `trigger` job is enqueued per (automation, event) match.
|
|
17
|
+
*
|
|
18
|
+
* Stage 1 never executes a run — it only enqueues Stage-2 jobs onto the
|
|
19
|
+
* `automation-dispatch` queue, keeping the claim fast.
|
|
20
|
+
*/
|
|
21
|
+
import type { HookEventMeta, Logger } from "@checkstack/backend-api";
|
|
22
|
+
import {
|
|
23
|
+
EntityChangedSchema,
|
|
24
|
+
type DispatchJob,
|
|
25
|
+
type EntityChanged,
|
|
26
|
+
} from "@checkstack/automation-common";
|
|
27
|
+
|
|
28
|
+
import type { AutomationStore } from "../automation-store";
|
|
29
|
+
import type { ChangeDeriverRegistry } from "../entity/change-derivers";
|
|
30
|
+
import { ENTITY_CHANGED_HOOK } from "../entity/hook";
|
|
31
|
+
import { DISPATCH_QUEUE_NAME } from "./stage2-dispatch";
|
|
32
|
+
import type { DispatchDeps } from "./types";
|
|
33
|
+
|
|
34
|
+
/** Worker group for the Stage-1 claim (reactive automation engine §13.1). */
|
|
35
|
+
export const ENTITY_ROUTE_WORKER_GROUP = "automation-entity-route";
|
|
36
|
+
|
|
37
|
+
/** Type of `onHook` injected in afterPluginsReady (mirrors backend-api). */
|
|
38
|
+
export type OnHookFn = <T>(
|
|
39
|
+
hook: { id: string; _type?: T },
|
|
40
|
+
listener: (payload: T, meta?: HookEventMeta) => Promise<void>,
|
|
41
|
+
options?:
|
|
42
|
+
| { mode?: "broadcast"; maxRetries?: number }
|
|
43
|
+
| { mode: "work-queue"; workerGroup: string; maxRetries?: number }
|
|
44
|
+
| { mode: "instance-local" },
|
|
45
|
+
) => () => Promise<void>;
|
|
46
|
+
|
|
47
|
+
export interface Stage1RouterArgs {
|
|
48
|
+
deps: DispatchDeps;
|
|
49
|
+
automationStore: AutomationStore;
|
|
50
|
+
changeDerivers: ChangeDeriverRegistry;
|
|
51
|
+
onHook: OnHookFn;
|
|
52
|
+
logger: Logger;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Stage1Router {
|
|
56
|
+
dispose: () => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Route one validated entity change to Stage-2 jobs. Exported so tests can
|
|
61
|
+
* drive routing directly (without a real hook bus); returns the jobs it
|
|
62
|
+
* enqueued for assertion.
|
|
63
|
+
*/
|
|
64
|
+
export async function routeEntityChange(args: {
|
|
65
|
+
deps: DispatchDeps;
|
|
66
|
+
automationStore: AutomationStore;
|
|
67
|
+
changeDerivers: ChangeDeriverRegistry;
|
|
68
|
+
changed: EntityChanged;
|
|
69
|
+
}): Promise<DispatchJob[]> {
|
|
70
|
+
const { deps, automationStore, changeDerivers, changed } = args;
|
|
71
|
+
const ref = `${changed.kind}:${changed.id}`;
|
|
72
|
+
const queue = deps.queueManager.getQueue<DispatchJob>(DISPATCH_QUEUE_NAME);
|
|
73
|
+
const enqueued: DispatchJob[] = [];
|
|
74
|
+
|
|
75
|
+
// (a) Waiting runs depending on this ref (or the kind wildcard).
|
|
76
|
+
const waits = await deps.runStore.findWaitLocksByWakeRef(ref);
|
|
77
|
+
for (const lock of waits) {
|
|
78
|
+
const job: DispatchJob = {
|
|
79
|
+
reason: "wake",
|
|
80
|
+
runId: lock.runId,
|
|
81
|
+
waitLockId: lock.id,
|
|
82
|
+
ref,
|
|
83
|
+
changed,
|
|
84
|
+
};
|
|
85
|
+
await queue.enqueue(job, {
|
|
86
|
+
// One in-flight wake per (lock, ref) — a duplicate change for the same
|
|
87
|
+
// ref collapses onto the same job id.
|
|
88
|
+
jobId: `wake:${lock.id}:${ref}`,
|
|
89
|
+
});
|
|
90
|
+
enqueued.push(job);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// (b) Fresh-run triggers — derive the qualified trigger event id(s).
|
|
94
|
+
const eventIds = changeDerivers.derive(changed);
|
|
95
|
+
for (const eventId of eventIds) {
|
|
96
|
+
const automations = await automationStore.findEnabledByTriggerEvent(eventId);
|
|
97
|
+
for (const automation of automations) {
|
|
98
|
+
const job: DispatchJob = {
|
|
99
|
+
reason: "trigger",
|
|
100
|
+
automationId: automation.id,
|
|
101
|
+
triggerId: eventId,
|
|
102
|
+
ref,
|
|
103
|
+
changed,
|
|
104
|
+
};
|
|
105
|
+
await queue.enqueue(job, {
|
|
106
|
+
// Dedup REDELIVERIES of one change (stable `changeId`) but distinguish
|
|
107
|
+
// two DISTINCT changes to the same entity — even within one
|
|
108
|
+
// millisecond, where `occurredAt` collides (§13.2). Fall back to
|
|
109
|
+
// `occurredAt` only for legacy payloads emitted before `changeId`.
|
|
110
|
+
jobId: `trigger:${automation.id}:${eventId}:${ref}:${changed.changeId ?? changed.occurredAt}`,
|
|
111
|
+
});
|
|
112
|
+
enqueued.push(job);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (enqueued.length > 0) {
|
|
117
|
+
deps.logger.debug(
|
|
118
|
+
`stage1: routed ${ref} → ${enqueued.length} dispatch job(s) (${waits.length} wake, ${enqueued.length - waits.length} trigger)`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return enqueued;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function startStage1Router(
|
|
125
|
+
args: Stage1RouterArgs,
|
|
126
|
+
): Promise<Stage1Router> {
|
|
127
|
+
const unsub = args.onHook(
|
|
128
|
+
ENTITY_CHANGED_HOOK,
|
|
129
|
+
async (payload) => {
|
|
130
|
+
const parsed = EntityChangedSchema.safeParse(payload);
|
|
131
|
+
if (!parsed.success) {
|
|
132
|
+
args.logger.warn(
|
|
133
|
+
`stage1: dropping malformed ENTITY_CHANGED payload: ${parsed.error.message}`,
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await routeEntityChange({
|
|
138
|
+
deps: args.deps,
|
|
139
|
+
automationStore: args.automationStore,
|
|
140
|
+
changeDerivers: args.changeDerivers,
|
|
141
|
+
changed: parsed.data,
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
{ mode: "work-queue", workerGroup: ENTITY_ROUTE_WORKER_GROUP },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
dispose: async () => {
|
|
149
|
+
await unsub();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test (real Redis / BullMQ) for Stage-1 routing exactly-once.
|
|
3
|
+
*
|
|
4
|
+
* Part of the surgical integration lane (plan §14.4 #4). The Stage-1 router
|
|
5
|
+
* subscribes to `ENTITY_CHANGED` in work-queue mode
|
|
6
|
+
* (`workerGroup: "automation-entity-route"`); the event-bus realises that as
|
|
7
|
+
* a BullMQ consumer group, so exactly one of N competing workers claims a
|
|
8
|
+
* given message. This pins that third-party contract directly against
|
|
9
|
+
* BullMQ: two workers sharing one consumer group on one queue → a single
|
|
10
|
+
* enqueued job is processed EXACTLY ONCE.
|
|
11
|
+
*
|
|
12
|
+
* Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it. The
|
|
13
|
+
* `integration` CI job sets the flag and provides a real Redis service.
|
|
14
|
+
* Connection comes from `CHECKSTACK_IT_REDIS_URL` (defaulting to the
|
|
15
|
+
* `docker-compose-dev.yml` Redis port). Each run uses a unique queue name +
|
|
16
|
+
* key prefix and tears the queue down afterwards.
|
|
17
|
+
*/
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
19
|
+
import { Queue, Worker, type ConnectionOptions } from "bullmq";
|
|
20
|
+
|
|
21
|
+
function redisConnection(): ConnectionOptions {
|
|
22
|
+
const url = new URL(
|
|
23
|
+
process.env.CHECKSTACK_IT_REDIS_URL ?? "redis://localhost:6379",
|
|
24
|
+
);
|
|
25
|
+
return {
|
|
26
|
+
host: url.hostname,
|
|
27
|
+
port: Number(url.port || 6379),
|
|
28
|
+
password: url.password || undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const QUEUE = `it_stage1_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
33
|
+
const PREFIX = `it:${crypto.randomUUID().replace(/-/g, "")}`;
|
|
34
|
+
|
|
35
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
36
|
+
"Stage-1 routing exactly-once (real Redis)",
|
|
37
|
+
() => {
|
|
38
|
+
let queue: Queue;
|
|
39
|
+
const workers: Worker[] = [];
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
queue = new Queue(QUEUE, {
|
|
43
|
+
connection: redisConnection(),
|
|
44
|
+
prefix: PREFIX,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
for (const w of workers) await w.close();
|
|
50
|
+
await queue.obliterate({ force: true }).catch(() => {});
|
|
51
|
+
await queue.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("one ENTITY_CHANGED-style job runs the routing handler exactly once across two workers", async () => {
|
|
55
|
+
let processed = 0;
|
|
56
|
+
const seen: string[] = [];
|
|
57
|
+
|
|
58
|
+
// Two competing workers in the SAME consumer group (the event-bus uses
|
|
59
|
+
// the worker-group name as the BullMQ group; two workers on the same
|
|
60
|
+
// queue compete, so only one claims a given job).
|
|
61
|
+
const makeWorker = () =>
|
|
62
|
+
new Worker(
|
|
63
|
+
QUEUE,
|
|
64
|
+
async (job) => {
|
|
65
|
+
processed += 1;
|
|
66
|
+
seen.push(String(job.data.ref));
|
|
67
|
+
},
|
|
68
|
+
{ connection: redisConnection(), prefix: PREFIX },
|
|
69
|
+
);
|
|
70
|
+
workers.push(makeWorker(), makeWorker());
|
|
71
|
+
|
|
72
|
+
// Wait for both workers to be ready before enqueuing.
|
|
73
|
+
await Promise.all(workers.map((w) => w.waitUntilReady()));
|
|
74
|
+
|
|
75
|
+
await queue.add("entity-changed", { ref: "health:sys-1" });
|
|
76
|
+
|
|
77
|
+
// Give the workers time to claim + process.
|
|
78
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
79
|
+
|
|
80
|
+
expect(processed).toBe(1);
|
|
81
|
+
expect(seen).toEqual(["health:sys-1"]);
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
);
|
|
@@ -0,0 +1,285 @@
|
|
|
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 { makeDispatchDeps, makeRecordingAction, testPlugin } from "./test-fixtures";
|
|
8
|
+
import { handleDispatchJob } from "./stage2-dispatch";
|
|
9
|
+
import { createChangeDeriverRegistry } from "../entity/change-derivers";
|
|
10
|
+
import type { AutomationStore } from "../automation-store";
|
|
11
|
+
import type { LoadedAutomation } from "./types";
|
|
12
|
+
|
|
13
|
+
/** An empty registry: every change falls back to the generic payload shape. */
|
|
14
|
+
function emptyDerivers() {
|
|
15
|
+
return createChangeDeriverRegistry();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
|
|
19
|
+
return {
|
|
20
|
+
kind: "fake",
|
|
21
|
+
id: "ent-1",
|
|
22
|
+
prev: null,
|
|
23
|
+
next: { status: "open", severity: "high" },
|
|
24
|
+
delta: { status: "open" },
|
|
25
|
+
changedFields: ["status"],
|
|
26
|
+
actor: SYSTEM_ACTOR,
|
|
27
|
+
occurredAt: new Date().toISOString(),
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function automationFor(event: string): LoadedAutomation {
|
|
33
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
34
|
+
name: "A",
|
|
35
|
+
triggers: [{ event }],
|
|
36
|
+
conditions: [],
|
|
37
|
+
actions: [{ action: "test.record", config: { value: "{{ trigger.payload.status }}" } }],
|
|
38
|
+
mode: "single",
|
|
39
|
+
max_runs: 10,
|
|
40
|
+
});
|
|
41
|
+
return { id: "auto-1", name: "A", status: "enabled", definition };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function storeFor(auto: LoadedAutomation): AutomationStore {
|
|
45
|
+
return {
|
|
46
|
+
create: async () => {
|
|
47
|
+
throw new Error("nope");
|
|
48
|
+
},
|
|
49
|
+
update: async () => {
|
|
50
|
+
throw new Error("nope");
|
|
51
|
+
},
|
|
52
|
+
delete: async () => {},
|
|
53
|
+
toggle: async () => {
|
|
54
|
+
throw new Error("nope");
|
|
55
|
+
},
|
|
56
|
+
getById: async (id) =>
|
|
57
|
+
id === auto.id
|
|
58
|
+
? {
|
|
59
|
+
id: auto.id,
|
|
60
|
+
name: auto.name,
|
|
61
|
+
description: undefined,
|
|
62
|
+
status: auto.status,
|
|
63
|
+
definition: auto.definition,
|
|
64
|
+
managedBy: undefined,
|
|
65
|
+
createdAt: new Date(),
|
|
66
|
+
updatedAt: new Date(),
|
|
67
|
+
}
|
|
68
|
+
: undefined,
|
|
69
|
+
list: async () => ({ items: [], total: 0 }),
|
|
70
|
+
listGroups: async () => [],
|
|
71
|
+
findEnabledByTriggerEvent: async () => [auto],
|
|
72
|
+
listEnabled: async () => [auto],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("Stage-2 handleDispatchJob — reason: trigger", () => {
|
|
77
|
+
it("starts a fresh run for the matched automation, with the change as payload", async () => {
|
|
78
|
+
const actions = createActionRegistry();
|
|
79
|
+
const rec = makeRecordingAction();
|
|
80
|
+
actions.register(rec.definition, testPlugin);
|
|
81
|
+
const { deps, runs } = makeDispatchDeps({ actions });
|
|
82
|
+
|
|
83
|
+
const auto = automationFor("fake.opened");
|
|
84
|
+
const job: DispatchJob = {
|
|
85
|
+
reason: "trigger",
|
|
86
|
+
automationId: "auto-1",
|
|
87
|
+
triggerId: "fake.opened",
|
|
88
|
+
ref: "fake:ent-1",
|
|
89
|
+
changed: change(),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: emptyDerivers(), job });
|
|
93
|
+
|
|
94
|
+
// The action ran with the entity-change's `next.status` in payload.
|
|
95
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["open"]);
|
|
96
|
+
// A run row exists and reached a terminal status.
|
|
97
|
+
expect([...runs.runs.values()].map((r) => r.status)).toEqual(["success"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("drops the job when the automation is gone", async () => {
|
|
101
|
+
const { deps, runs } = makeDispatchDeps();
|
|
102
|
+
const job: DispatchJob = {
|
|
103
|
+
reason: "trigger",
|
|
104
|
+
automationId: "missing",
|
|
105
|
+
triggerId: "fake.opened",
|
|
106
|
+
ref: "fake:ent-1",
|
|
107
|
+
changed: change(),
|
|
108
|
+
};
|
|
109
|
+
await handleDispatchJob({
|
|
110
|
+
deps,
|
|
111
|
+
automationStore: storeFor(automationFor("fake.opened")),
|
|
112
|
+
changeDerivers: emptyDerivers(),
|
|
113
|
+
job,
|
|
114
|
+
});
|
|
115
|
+
expect(runs.runs.size).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not run a disabled automation", async () => {
|
|
119
|
+
const actions = createActionRegistry();
|
|
120
|
+
const rec = makeRecordingAction();
|
|
121
|
+
actions.register(rec.definition, testPlugin);
|
|
122
|
+
const { deps, runs } = makeDispatchDeps({ actions });
|
|
123
|
+
const auto = automationFor("fake.opened");
|
|
124
|
+
auto.status = "disabled";
|
|
125
|
+
const job: DispatchJob = {
|
|
126
|
+
reason: "trigger",
|
|
127
|
+
automationId: "auto-1",
|
|
128
|
+
triggerId: "fake.opened",
|
|
129
|
+
ref: "fake:ent-1",
|
|
130
|
+
changed: change(),
|
|
131
|
+
};
|
|
132
|
+
await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: emptyDerivers(), job });
|
|
133
|
+
expect(rec.calls).toHaveLength(0);
|
|
134
|
+
expect(runs.runs.size).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("Stage-2 handleDispatchJob — reason: wake", () => {
|
|
139
|
+
it("resumes a suspended wait_until whose condition now holds", async () => {
|
|
140
|
+
const actions = createActionRegistry();
|
|
141
|
+
const rec = makeRecordingAction();
|
|
142
|
+
actions.register(rec.definition, testPlugin);
|
|
143
|
+
|
|
144
|
+
// Health client that reports healthy, so the wait re-eval passes.
|
|
145
|
+
const healthClient = {
|
|
146
|
+
getHealthState: async () => ({
|
|
147
|
+
status: "healthy",
|
|
148
|
+
inStatusSince: new Date(),
|
|
149
|
+
inStatusForMs: 0,
|
|
150
|
+
inMaintenance: false,
|
|
151
|
+
evaluatedAt: new Date(),
|
|
152
|
+
}),
|
|
153
|
+
getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
|
|
154
|
+
const states: Record<string, unknown> = {};
|
|
155
|
+
for (const id of systemIds) {
|
|
156
|
+
states[id] = {
|
|
157
|
+
status: "healthy",
|
|
158
|
+
inStatusSince: new Date(),
|
|
159
|
+
inStatusForMs: 0,
|
|
160
|
+
inMaintenance: false,
|
|
161
|
+
evaluatedAt: new Date(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { states };
|
|
165
|
+
},
|
|
166
|
+
} as never;
|
|
167
|
+
|
|
168
|
+
const { deps, runs } = makeDispatchDeps({ actions, healthCheckClient: healthClient });
|
|
169
|
+
|
|
170
|
+
// Build an automation with a wait_until then a recording action, suspend
|
|
171
|
+
// it by dispatching while unhealthy is irrelevant — we seed the lock
|
|
172
|
+
// directly and drive the wake.
|
|
173
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
174
|
+
name: "WU",
|
|
175
|
+
triggers: [{ event: "test.event" }],
|
|
176
|
+
conditions: [],
|
|
177
|
+
actions: [
|
|
178
|
+
{ wait_until: { condition: "health.system.status == 'healthy'" } },
|
|
179
|
+
{ action: "test.record", config: { value: "woke" } },
|
|
180
|
+
],
|
|
181
|
+
mode: "single",
|
|
182
|
+
max_runs: 10,
|
|
183
|
+
});
|
|
184
|
+
const auto: LoadedAutomation = {
|
|
185
|
+
id: "auto-1",
|
|
186
|
+
name: "WU",
|
|
187
|
+
status: "enabled",
|
|
188
|
+
definition,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Suspend the run via the engine so the lock + scope snapshot exist.
|
|
192
|
+
const unhealthyDeps = makeDispatchDeps({
|
|
193
|
+
actions,
|
|
194
|
+
healthCheckClient: {
|
|
195
|
+
getHealthState: async () => ({
|
|
196
|
+
status: "unhealthy",
|
|
197
|
+
inStatusSince: new Date(),
|
|
198
|
+
inStatusForMs: 0,
|
|
199
|
+
inMaintenance: false,
|
|
200
|
+
evaluatedAt: new Date(),
|
|
201
|
+
}),
|
|
202
|
+
getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
|
|
203
|
+
const states: Record<string, unknown> = {};
|
|
204
|
+
for (const id of systemIds) {
|
|
205
|
+
states[id] = {
|
|
206
|
+
status: "unhealthy",
|
|
207
|
+
inStatusSince: new Date(),
|
|
208
|
+
inStatusForMs: 0,
|
|
209
|
+
inMaintenance: false,
|
|
210
|
+
evaluatedAt: new Date(),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { states };
|
|
214
|
+
},
|
|
215
|
+
} as never,
|
|
216
|
+
});
|
|
217
|
+
// Use one shared deps so the run/lock seeded by the engine is the one the
|
|
218
|
+
// wake reads. Re-dispatch on `deps` (healthy) would immediately satisfy,
|
|
219
|
+
// so instead seed with unhealthy deps and then wake with healthy deps
|
|
220
|
+
// pointing at the SAME stores.
|
|
221
|
+
void unhealthyDeps;
|
|
222
|
+
|
|
223
|
+
// Simplest faithful path: dispatch on `deps` but with health unhealthy
|
|
224
|
+
// first is not possible (deps is healthy). Instead seed the lock + run
|
|
225
|
+
// manually and drive the wake re-eval (healthy → resume).
|
|
226
|
+
await deps.runStore.createRun({
|
|
227
|
+
automationId: "auto-1",
|
|
228
|
+
triggerId: "t",
|
|
229
|
+
triggerEventId: "test.event",
|
|
230
|
+
triggerPayload: { id: "sys-1" },
|
|
231
|
+
contextKey: "sys-1",
|
|
232
|
+
});
|
|
233
|
+
await deps.runStore.updateRunStatus("run-1", "waiting");
|
|
234
|
+
await deps.runStateStore.upsert({
|
|
235
|
+
runId: "run-1",
|
|
236
|
+
scopeSnapshot: { trigger: { payload: { id: "sys-1" } } },
|
|
237
|
+
lastActionPath: null,
|
|
238
|
+
});
|
|
239
|
+
const lockId = await deps.runStore.createWaitLockWithWakeRefs({
|
|
240
|
+
runId: "run-1",
|
|
241
|
+
actionPath: "actions[0]",
|
|
242
|
+
eventId: "@@until",
|
|
243
|
+
contextKey: "sys-1",
|
|
244
|
+
timeoutAt: null,
|
|
245
|
+
waitConfig: {
|
|
246
|
+
condition: "health.system.status == 'healthy'",
|
|
247
|
+
continueOnTimeout: true,
|
|
248
|
+
},
|
|
249
|
+
wakeRefs: ["health:*"],
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const job: DispatchJob = {
|
|
253
|
+
reason: "wake",
|
|
254
|
+
runId: "run-1",
|
|
255
|
+
waitLockId: lockId,
|
|
256
|
+
ref: "health:sys-1",
|
|
257
|
+
changed: change({ kind: "health", id: "sys-1" }),
|
|
258
|
+
};
|
|
259
|
+
await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: emptyDerivers(), job });
|
|
260
|
+
|
|
261
|
+
// Re-eval passed (healthy) → resumed → recording action ran.
|
|
262
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["woke"]);
|
|
263
|
+
expect(runs.runs.get("run-1")?.status).toBe("success");
|
|
264
|
+
expect(runs.waitLocks.size).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("drops a wake job whose lock is gone", async () => {
|
|
268
|
+
const { deps } = makeDispatchDeps();
|
|
269
|
+
const job: DispatchJob = {
|
|
270
|
+
reason: "wake",
|
|
271
|
+
runId: "run-x",
|
|
272
|
+
waitLockId: "missing",
|
|
273
|
+
ref: "fake:ent-1",
|
|
274
|
+
changed: change(),
|
|
275
|
+
};
|
|
276
|
+
// No throw, no-op.
|
|
277
|
+
await handleDispatchJob({
|
|
278
|
+
deps,
|
|
279
|
+
automationStore: storeFor(automationFor("x")),
|
|
280
|
+
changeDerivers: emptyDerivers(),
|
|
281
|
+
job,
|
|
282
|
+
});
|
|
283
|
+
expect(true).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
});
|