@checkstack/automation-backend 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +453 -0
  2. package/drizzle/0000_acoustic_diamondback.sql +80 -0
  3. package/drizzle/0001_mute_vindicator.sql +12 -0
  4. package/drizzle/0002_silky_omega_red.sql +12 -0
  5. package/drizzle/meta/0000_snapshot.json +688 -0
  6. package/drizzle/meta/0001_snapshot.json +785 -0
  7. package/drizzle/meta/0002_snapshot.json +861 -0
  8. package/drizzle/meta/_journal.json +27 -0
  9. package/drizzle.config.ts +12 -0
  10. package/package.json +41 -0
  11. package/src/action-registry.ts +83 -0
  12. package/src/action-types.ts +324 -0
  13. package/src/artifact-store.ts +140 -0
  14. package/src/artifact-type-registry.ts +64 -0
  15. package/src/automation-store.ts +227 -0
  16. package/src/builtin-actions.test.ts +185 -0
  17. package/src/builtin-actions.ts +132 -0
  18. package/src/builtin-triggers.test.ts +264 -0
  19. package/src/builtin-triggers.ts +365 -0
  20. package/src/dispatch/action-kind.ts +44 -0
  21. package/src/dispatch/condition.ts +61 -0
  22. package/src/dispatch/delay-queue.ts +91 -0
  23. package/src/dispatch/engine.test.ts +1198 -0
  24. package/src/dispatch/engine.ts +1672 -0
  25. package/src/dispatch/path-nav.ts +65 -0
  26. package/src/dispatch/render.test.ts +75 -0
  27. package/src/dispatch/render.ts +136 -0
  28. package/src/dispatch/run-state-store.ts +143 -0
  29. package/src/dispatch/run-state.ts +298 -0
  30. package/src/dispatch/scope.test.ts +40 -0
  31. package/src/dispatch/scope.ts +125 -0
  32. package/src/dispatch/stalled-sweeper.ts +164 -0
  33. package/src/dispatch/test-fixtures.ts +558 -0
  34. package/src/dispatch/trigger-subscriber.ts +397 -0
  35. package/src/dispatch/types.ts +259 -0
  36. package/src/extension-points.ts +88 -0
  37. package/src/index.ts +379 -0
  38. package/src/migration/from-webhook-subscriptions.test.ts +237 -0
  39. package/src/migration/from-webhook-subscriptions.ts +398 -0
  40. package/src/registries.test.ts +357 -0
  41. package/src/router.test.ts +724 -0
  42. package/src/router.ts +556 -0
  43. package/src/schema.ts +310 -0
  44. package/src/trigger-registry.ts +99 -0
  45. package/src/validate-definition.test.ts +306 -0
  46. package/src/validate-definition.ts +304 -0
  47. package/tsconfig.json +41 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Trigger fan-in for the automation dispatch engine.
3
+ *
4
+ * Subscribes to every registered hook-backed trigger in `afterPluginsReady`,
5
+ * routes each emission to the matching enabled automations, and respects
6
+ * each automation's concurrency mode (single / parallel / queued / restart).
7
+ *
8
+ * Also handles `wait_for_trigger` resumption: every incoming event is
9
+ * cross-referenced against `automation_wait_locks` and matching waits
10
+ * are woken up.
11
+ */
12
+ import type {
13
+ AutomationMode,
14
+ Trigger,
15
+ } from "@checkstack/automation-common";
16
+ import type {
17
+ HookEventMeta,
18
+ HookSubscribeOptions,
19
+ Logger,
20
+ } from "@checkstack/backend-api";
21
+ import { SYSTEM_ACTOR, type Actor } from "@checkstack/common";
22
+
23
+ import type { AutomationStore } from "../automation-store";
24
+ import { dispatchTrigger, resumeRun } from "./engine";
25
+ import { evaluateCondition } from "./condition";
26
+ import { renderString } from "./render";
27
+ import { buildInitialScope } from "./scope";
28
+ import type {
29
+ DispatchDeps,
30
+ LoadedAutomation,
31
+ } from "./types";
32
+
33
+ /**
34
+ * Subscription handle returned by `setupTriggerSubscriptions`. Calling
35
+ * `dispose()` unsubscribes every hook and tears down every setup-backed
36
+ * trigger. Used at shutdown.
37
+ */
38
+ export interface TriggerSubscriptions {
39
+ dispose: () => Promise<void>;
40
+ }
41
+
42
+ /**
43
+ * Type of `onHook` injected by the plugin runtime in afterPluginsReady.
44
+ * Mirrors the shape from `@checkstack/backend-api` — note that the
45
+ * call returns a sync `HookUnsubscribe` (an async function), not a
46
+ * Promise<HookUnsubscribe>.
47
+ */
48
+ export type OnHookFn = <T>(
49
+ hook: { id: string; _type?: T },
50
+ listener: (payload: T, meta?: HookEventMeta) => Promise<void>,
51
+ options?: HookSubscribeOptions,
52
+ ) => () => Promise<void>;
53
+
54
+ export interface SetupTriggerSubscriptionsArgs {
55
+ deps: DispatchDeps;
56
+ onHook: OnHookFn;
57
+ automationStore: AutomationStore;
58
+ logger: Logger;
59
+ }
60
+
61
+ export async function setupTriggerSubscriptions(
62
+ args: SetupTriggerSubscriptionsArgs,
63
+ ): Promise<TriggerSubscriptions> {
64
+ const teardowns: Array<() => Promise<void>> = [];
65
+
66
+ const triggers = args.deps.registries.triggers.getTriggers();
67
+ args.logger.debug(
68
+ `⚙️ Setting up ${triggers.length} automation trigger subscriptions`,
69
+ );
70
+
71
+ for (const trigger of triggers) {
72
+ if (trigger.hook) {
73
+ const teardown = args.onHook(
74
+ trigger.hook,
75
+ async (payload, meta) => {
76
+ await handleTriggerFiring({
77
+ deps: args.deps,
78
+ automationStore: args.automationStore,
79
+ qualifiedEventId: trigger.qualifiedId,
80
+ triggerPayload: payload as Record<string, unknown>,
81
+ actor: meta?.actor ?? SYSTEM_ACTOR,
82
+ contextKey:
83
+ (trigger.contextKey?.(payload) ?? null),
84
+ });
85
+ },
86
+ {
87
+ mode: "work-queue",
88
+ workerGroup: `automation-trigger-${trigger.qualifiedId}`,
89
+ },
90
+ );
91
+ teardowns.push(teardown);
92
+ continue;
93
+ }
94
+
95
+ // Setup-backed triggers (cron, interval, template) — instantiate one
96
+ // setup per (automation, triggerConfig) pair across enabled
97
+ // automations referencing this trigger.
98
+ if (trigger.setup) {
99
+ const automations = await args.automationStore.listEnabled();
100
+ const referencing = automations.filter((a) =>
101
+ a.definition.triggers.some((t) => t.event === trigger.qualifiedId),
102
+ );
103
+ for (const automation of referencing) {
104
+ for (const t of automation.definition.triggers.filter(
105
+ (t) => t.event === trigger.qualifiedId,
106
+ )) {
107
+ const triggerId = t.id ?? deriveTriggerId(t);
108
+ const teardown = await trigger.setup({
109
+ config: t.config as never,
110
+ identity: {
111
+ automationId: automation.id,
112
+ triggerId,
113
+ },
114
+ fire: async (payload) => {
115
+ await handleTriggerFiring({
116
+ deps: args.deps,
117
+ automationStore: args.automationStore,
118
+ qualifiedEventId: trigger.qualifiedId,
119
+ triggerPayload: payload as Record<string, unknown>,
120
+ // Setup-backed triggers (cron / interval / template) fire on a
121
+ // schedule with no acting caller — the system is the actor.
122
+ actor: SYSTEM_ACTOR,
123
+ contextKey:
124
+ trigger.contextKey?.(payload) ?? null,
125
+ });
126
+ },
127
+ logger: args.logger,
128
+ });
129
+ teardowns.push(teardown);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ return {
136
+ dispose: async () => {
137
+ for (const teardown of teardowns.toReversed()) {
138
+ try {
139
+ await teardown();
140
+ } catch (error) {
141
+ args.logger.warn(
142
+ `Failed to tear down trigger subscription: ${(error as Error).message}`,
143
+ );
144
+ }
145
+ }
146
+ },
147
+ };
148
+ }
149
+
150
+ interface HandleTriggerFiringArgs {
151
+ deps: DispatchDeps;
152
+ automationStore: AutomationStore;
153
+ qualifiedEventId: string;
154
+ triggerPayload: Record<string, unknown>;
155
+ actor: Actor;
156
+ contextKey: string | null;
157
+ }
158
+
159
+ async function handleTriggerFiring(
160
+ args: HandleTriggerFiringArgs,
161
+ ): Promise<void> {
162
+ // ── Step 1: resume any waiting runs ──
163
+ await wakeWaitingRuns(args);
164
+
165
+ // ── Step 2: route to fresh runs for automations referencing this event ──
166
+ const matches = await args.automationStore.findEnabledByTriggerEvent(
167
+ args.qualifiedEventId,
168
+ );
169
+
170
+ for (const automation of matches) {
171
+ for (const trigger of automation.definition.triggers.filter(
172
+ (t) => t.event === args.qualifiedEventId,
173
+ )) {
174
+ await maybeStartRun({
175
+ deps: args.deps,
176
+ automation,
177
+ trigger,
178
+ triggerPayload: args.triggerPayload,
179
+ actor: args.actor,
180
+ contextKey: args.contextKey,
181
+ eventId: args.qualifiedEventId,
182
+ });
183
+ }
184
+ }
185
+ }
186
+
187
+ async function wakeWaitingRuns(args: HandleTriggerFiringArgs): Promise<void> {
188
+ const matches = await args.deps.runStore.findWaitLocksFor(
189
+ args.qualifiedEventId,
190
+ args.contextKey,
191
+ );
192
+ if (matches.length === 0) return;
193
+
194
+ for (const lock of matches) {
195
+ // Evaluate filter template if present.
196
+ if (lock.filterTemplate) {
197
+ try {
198
+ const ctx = buildInitialScope({
199
+ triggerId: "wait",
200
+ triggerEventId: args.qualifiedEventId,
201
+ payload: args.triggerPayload,
202
+ actor: args.actor,
203
+ startedAt: new Date(),
204
+ });
205
+ const pass = evaluateCondition(
206
+ lock.filterTemplate,
207
+ ctx,
208
+ args.deps.filters,
209
+ );
210
+ if (!pass) continue;
211
+ } catch (error) {
212
+ args.deps.logger.warn(
213
+ `wait_for_trigger filter failed to evaluate; skipping resume: ${(error as Error).message}`,
214
+ );
215
+ continue;
216
+ }
217
+ }
218
+
219
+ // Load the automation so we have the definition to resume against.
220
+ const run = await args.deps.runStore.loadRun(lock.runId);
221
+ if (!run) continue;
222
+ const automation = await args.automationStore.getById(run.automationId);
223
+ if (!automation) continue;
224
+
225
+ await args.deps.runStore.deleteWaitLock(lock.id);
226
+ await resumeRun(args.deps, {
227
+ runId: lock.runId,
228
+ automation: {
229
+ id: automation.id,
230
+ name: automation.name,
231
+ status: automation.status,
232
+ definition: automation.definition,
233
+ },
234
+ waitedAtPath: lock.actionPath,
235
+ payload: args.triggerPayload,
236
+ });
237
+ }
238
+ }
239
+
240
+ interface MaybeStartRunArgs {
241
+ deps: DispatchDeps;
242
+ automation: LoadedAutomation;
243
+ trigger: Trigger;
244
+ triggerPayload: Record<string, unknown>;
245
+ actor: Actor;
246
+ contextKey: string | null;
247
+ eventId: string;
248
+ }
249
+
250
+ async function maybeStartRun(args: MaybeStartRunArgs): Promise<void> {
251
+ // Trigger-level filter check.
252
+ if (args.trigger.filter) {
253
+ const ctx = buildInitialScope({
254
+ triggerId: args.trigger.id ?? deriveTriggerId(args.trigger),
255
+ triggerEventId: args.eventId,
256
+ payload: args.triggerPayload,
257
+ actor: args.actor,
258
+ startedAt: new Date(),
259
+ });
260
+ let pass: boolean;
261
+ try {
262
+ pass = evaluateCondition(
263
+ args.trigger.filter,
264
+ ctx,
265
+ args.deps.filters,
266
+ );
267
+ } catch (error) {
268
+ args.deps.logger.warn(
269
+ `Trigger filter failed to evaluate; skipping firing: ${(error as Error).message}`,
270
+ );
271
+ return;
272
+ }
273
+ if (!pass) return;
274
+ }
275
+
276
+ // Top-level conditions gate the run.
277
+ if (args.automation.definition.conditions.length > 0) {
278
+ const ctx = buildInitialScope({
279
+ triggerId: args.trigger.id ?? deriveTriggerId(args.trigger),
280
+ triggerEventId: args.eventId,
281
+ payload: args.triggerPayload,
282
+ actor: args.actor,
283
+ startedAt: new Date(),
284
+ });
285
+ for (const condition of args.automation.definition.conditions) {
286
+ try {
287
+ const pass = evaluateCondition(
288
+ condition,
289
+ ctx,
290
+ args.deps.filters,
291
+ );
292
+ if (!pass) return;
293
+ } catch {
294
+ return;
295
+ }
296
+ }
297
+ }
298
+
299
+ await respectConcurrencyMode({
300
+ deps: args.deps,
301
+ automationId: args.automation.id,
302
+ mode: args.automation.definition.mode,
303
+ maxRuns: args.automation.definition.max_runs,
304
+ triggerId: args.trigger.id ?? deriveTriggerId(args.trigger),
305
+ triggerEventId: args.eventId,
306
+ triggerPayload: args.triggerPayload,
307
+ actor: args.actor,
308
+ contextKey: args.contextKey,
309
+ automation: args.automation,
310
+ });
311
+ }
312
+
313
+ interface RespectConcurrencyArgs {
314
+ deps: DispatchDeps;
315
+ automationId: string;
316
+ automation: LoadedAutomation;
317
+ mode: AutomationMode;
318
+ maxRuns: number;
319
+ triggerId: string;
320
+ triggerEventId: string;
321
+ triggerPayload: Record<string, unknown>;
322
+ actor: Actor;
323
+ contextKey: string | null;
324
+ }
325
+
326
+ async function respectConcurrencyMode(
327
+ args: RespectConcurrencyArgs,
328
+ ): Promise<void> {
329
+ switch (args.mode) {
330
+ case "single": {
331
+ const active = await args.deps.runStore.hasActiveRun(args.automationId);
332
+ if (active) {
333
+ args.deps.logger.debug(
334
+ `Skipping trigger for ${args.automationId} — single mode and a run is active`,
335
+ );
336
+ return;
337
+ }
338
+ break;
339
+ }
340
+ case "parallel": {
341
+ const count = await args.deps.runStore.countActiveRuns(args.automationId);
342
+ if (count >= args.maxRuns) {
343
+ args.deps.logger.debug(
344
+ `Skipping trigger for ${args.automationId} — parallel limit reached (${count}/${args.maxRuns})`,
345
+ );
346
+ return;
347
+ }
348
+ break;
349
+ }
350
+ case "queued": {
351
+ // v1: queued behaves like parallel within max_runs. Real FIFO
352
+ // queueing requires its own coordination queue, which we add in a
353
+ // follow-up. Behaviour stays correct (no double-fire) under the
354
+ // existing work-queue mode.
355
+ const count = await args.deps.runStore.countActiveRuns(args.automationId);
356
+ if (count >= args.maxRuns) return;
357
+ break;
358
+ }
359
+ case "restart": {
360
+ const cancelled = await args.deps.runStore.cancelActiveRuns(
361
+ args.automationId,
362
+ "restart — superseded by newer trigger",
363
+ );
364
+ if (cancelled.length > 0) {
365
+ args.deps.logger.debug(
366
+ `Restart mode: cancelled ${cancelled.length} prior run(s) for ${args.automationId}`,
367
+ );
368
+ }
369
+ break;
370
+ }
371
+ }
372
+
373
+ await dispatchTrigger(args.deps, {
374
+ automation: args.automation,
375
+ triggerId: args.triggerId,
376
+ triggerEventId: args.triggerEventId,
377
+ payload: args.triggerPayload,
378
+ actor: args.actor,
379
+ contextKey: args.contextKey,
380
+ });
381
+ }
382
+
383
+ // Suppress unused warning for renderString — the helper is exported via
384
+ // scope.ts and used by the engine's primitives; trigger-subscriber
385
+ // doesn't currently render templates beyond conditions, but the import
386
+ // is convenient for future filter expressions.
387
+ void renderString;
388
+
389
+ /**
390
+ * Derive a stable trigger id from the trigger declaration when the
391
+ * operator hasn't assigned one. Slugifies the event id; collisions
392
+ * within a single automation are the operator's responsibility (the
393
+ * editor surfaces the `id` field as soon as a duplicate appears).
394
+ */
395
+ function deriveTriggerId(trigger: Trigger): string {
396
+ return trigger.event.replaceAll(/[^a-z0-9]+/gi, "_").toLowerCase();
397
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Internal types for the automation dispatch engine.
3
+ *
4
+ * These are kept private to `automation-backend` — public API lives in
5
+ * `@checkstack/automation-common` and the package's index re-exports.
6
+ */
7
+ import type { Logger, ServiceRef } from "@checkstack/backend-api";
8
+ import type { AutomationDefinition } from "@checkstack/automation-common";
9
+ import type { QueueManager } from "@checkstack/queue-api";
10
+ import type { FilterRegistry } from "@checkstack/template-engine";
11
+
12
+ import type { ActionRegistry } from "../action-registry";
13
+ import type { ArtifactTypeRegistry } from "../artifact-type-registry";
14
+ import type { TriggerRegistry } from "../trigger-registry";
15
+ import type { ArtifactStore } from "../artifact-store";
16
+
17
+ import type { RunStateStore } from "./run-state-store";
18
+
19
+ /**
20
+ * Persistent dependency bundle threaded through the dispatch engine.
21
+ * Provided once by the plugin entry; reused for every run / step.
22
+ */
23
+ export interface DispatchDeps {
24
+ logger: Logger;
25
+ filters: FilterRegistry;
26
+ registries: {
27
+ triggers: TriggerRegistry;
28
+ actions: ActionRegistry;
29
+ artifactTypes: ArtifactTypeRegistry;
30
+ };
31
+ artifactStore: ArtifactStore;
32
+ /** Resolve a platform service ref — passed through to action `execute`. */
33
+ getService: <T>(ref: ServiceRef<T>) => Promise<T>;
34
+ /** Persistence backend for runs / steps / wait locks. */
35
+ runStore: RunStore;
36
+ /** Per-run scope snapshot + heartbeat + advisory-lock helpers. */
37
+ runStateStore: RunStateStore;
38
+ /**
39
+ * Queue manager for crash-safe time-based suspension (`delay` action)
40
+ * and any future queue-backed continuations.
41
+ */
42
+ queueManager: QueueManager;
43
+ }
44
+
45
+ /**
46
+ * A node-style action path. Strings index into "actions", "sequence",
47
+ * etc.; numbers index array positions.
48
+ *
49
+ * ["actions", 0] // first top-level action
50
+ * ["actions", 1, "choose", 0, "sequence", 2] // 3rd action in first choose branch of 2nd action
51
+ * ["actions", 3, "parallel", 1] // 2nd parallel branch of 4th action
52
+ * ["actions", 4, "repeat", "sequence", 0] // 1st action in repeat sequence of 5th action
53
+ */
54
+ export type ActionPath = ReadonlyArray<string | number>;
55
+
56
+ /**
57
+ * Serialize an action path for persistence / display. We keep it
58
+ * URL-ish for readability in the run-detail UI.
59
+ *
60
+ * ["actions", 0] → "actions[0]"
61
+ * ["actions", 1, "choose", 0, "sequence", 2] → "actions[1].choose[0].sequence[2]"
62
+ */
63
+ export function formatActionPath(path: ActionPath): string {
64
+ let out = "";
65
+ for (const segment of path) {
66
+ if (typeof segment === "number") {
67
+ out += `[${segment}]`;
68
+ } else {
69
+ out += out.length === 0 ? segment : `.${segment}`;
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * The terminal outcomes of executing a single action.
77
+ */
78
+ export type StepOutcome =
79
+ | { kind: "ok" }
80
+ | { kind: "skipped"; reason: string }
81
+ | { kind: "failed"; error: string }
82
+ | { kind: "stopped"; reason?: string; error?: boolean }
83
+ | { kind: "suspended"; stepId: string };
84
+
85
+ /**
86
+ * The outcomes of walking a sequence (top-level `actions:`, a `choose`
87
+ * branch, a `parallel` branch, a `repeat` iteration body, etc.).
88
+ */
89
+ export type SequenceOutcome =
90
+ | { kind: "completed" }
91
+ | { kind: "stopped"; reason?: string; error?: boolean }
92
+ | { kind: "suspended"; suspendingStepId: string };
93
+
94
+ /**
95
+ * Loaded automation row + parsed definition. Convenience wrapper to keep
96
+ * call sites tidy.
97
+ */
98
+ export interface LoadedAutomation {
99
+ id: string;
100
+ name: string;
101
+ status: "enabled" | "disabled";
102
+ definition: AutomationDefinition;
103
+ }
104
+
105
+ /**
106
+ * Top-level run identity carried through the walker.
107
+ */
108
+ export interface RunIdentity {
109
+ runId: string;
110
+ automation: LoadedAutomation;
111
+ /** Operator-assigned trigger id or auto-derived (`event` slug). */
112
+ triggerId: string;
113
+ /** Fully qualified event id that fired. */
114
+ triggerEventId: string;
115
+ contextKey: string | null;
116
+ startedAt: Date;
117
+ }
118
+
119
+ /**
120
+ * Execution context handed to each primitive handler.
121
+ *
122
+ * - `scope` is the template-engine variable bag for the current position
123
+ * in the tree. The walker rebuilds this when entering / exiting
124
+ * nested blocks (variables, repeat iterations, etc.).
125
+ * - `payload` is the original trigger payload — convenient for actions
126
+ * that want to reach it directly.
127
+ * - `consumedArtifacts` is a lazy view — the dispatcher resolves it
128
+ * when an action declares `consumes` rather than scanning every step.
129
+ */
130
+ export interface DispatchContext {
131
+ deps: DispatchDeps;
132
+ run: RunIdentity;
133
+ payload: Record<string, unknown>;
134
+ /** Mutable variable scope. Modified by `variables` / `repeat`. */
135
+ scope: Record<string, unknown>;
136
+ /** When inside `wait_for_trigger`'s resume path, set to true. */
137
+ resuming: boolean;
138
+ }
139
+
140
+ // ─── Run-store interface ─────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Persistence operations the dispatch engine needs. Implemented in
144
+ * `run-state.ts` against the Drizzle schema.
145
+ */
146
+ export interface RunStore {
147
+ // Runs
148
+ createRun(input: CreateRunInput): Promise<string>;
149
+ updateRunStatus(
150
+ runId: string,
151
+ status: "running" | "waiting" | "success" | "failed" | "cancelled" | "skipped",
152
+ errorMessage?: string,
153
+ ): Promise<void>;
154
+ loadRun(runId: string): Promise<LoadedRun | undefined>;
155
+ countActiveRuns(automationId: string): Promise<number>;
156
+ /** Used by `mode: "single"` to detect a pre-existing run. */
157
+ hasActiveRun(automationId: string): Promise<boolean>;
158
+ /** Used by `mode: "restart"` to abort prior runs. */
159
+ cancelActiveRuns(automationId: string, reason: string): Promise<string[]>;
160
+
161
+ // Steps
162
+ createStep(input: CreateStepInput): Promise<string>;
163
+ updateStep(
164
+ stepId: string,
165
+ patch: {
166
+ status: "running" | "success" | "failed" | "skipped" | "waiting";
167
+ errorMessage?: string;
168
+ resultPayload?: Record<string, unknown>;
169
+ incrementAttempts?: boolean;
170
+ },
171
+ ): Promise<void>;
172
+ /**
173
+ * Find the most recent step row for a given `(runId, actionPath)`.
174
+ * Used by container resumes (parallel / repeat) to look up the
175
+ * step they wrote at suspension so they can read accumulated state
176
+ * (branch outcomes, iteration list) from its `result_payload`.
177
+ */
178
+ findStepByPath(
179
+ runId: string,
180
+ actionPath: string,
181
+ ): Promise<LoadedStep | undefined>;
182
+
183
+ // Wait locks (for wait_for_trigger + delay durability)
184
+ createWaitLock(input: CreateWaitLockInput): Promise<string>;
185
+ loadWaitLock(id: string): Promise<LoadedWaitLock | undefined>;
186
+ findWaitLocksFor(
187
+ eventId: string,
188
+ contextKey: string | null,
189
+ ): Promise<LoadedWaitLock[]>;
190
+ deleteWaitLock(id: string): Promise<void>;
191
+ sweepExpiredWaitLocks(now: Date): Promise<LoadedWaitLock[]>;
192
+ }
193
+
194
+ export interface CreateRunInput {
195
+ automationId: string;
196
+ triggerId: string;
197
+ triggerEventId: string;
198
+ triggerPayload: Record<string, unknown>;
199
+ contextKey: string | null;
200
+ }
201
+
202
+ export interface LoadedRun {
203
+ id: string;
204
+ automationId: string;
205
+ triggerId: string;
206
+ triggerEventId: string;
207
+ triggerPayload: Record<string, unknown>;
208
+ contextKey: string | null;
209
+ status: string;
210
+ errorMessage: string | null;
211
+ startedAt: Date;
212
+ finishedAt: Date | null;
213
+ }
214
+
215
+ export interface CreateStepInput {
216
+ runId: string;
217
+ actionPath: string;
218
+ actionId: string | null;
219
+ actionKind: string;
220
+ providerActionId: string | null;
221
+ }
222
+
223
+ export interface LoadedStep {
224
+ id: string;
225
+ runId: string;
226
+ actionPath: string;
227
+ actionId: string | null;
228
+ actionKind: string;
229
+ status: string;
230
+ attempts: number;
231
+ errorMessage: string | null;
232
+ resultPayload: Record<string, unknown> | null;
233
+ startedAt: Date;
234
+ finishedAt: Date | null;
235
+ }
236
+
237
+ export type WaitLockKind = "trigger" | "delay";
238
+
239
+ export interface CreateWaitLockInput {
240
+ runId: string;
241
+ actionPath: string;
242
+ kind: WaitLockKind;
243
+ eventId: string;
244
+ contextKey: string | null;
245
+ filterTemplate: string | null;
246
+ timeoutAt: Date | null;
247
+ }
248
+
249
+ export interface LoadedWaitLock {
250
+ id: string;
251
+ runId: string;
252
+ actionPath: string;
253
+ kind: WaitLockKind;
254
+ eventId: string;
255
+ contextKey: string | null;
256
+ filterTemplate: string | null;
257
+ timeoutAt: Date | null;
258
+ createdAt: Date;
259
+ }