@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.
- package/CHANGELOG.md +453 -0
- package/drizzle/0000_acoustic_diamondback.sql +80 -0
- package/drizzle/0001_mute_vindicator.sql +12 -0
- package/drizzle/0002_silky_omega_red.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +688 -0
- package/drizzle/meta/0001_snapshot.json +785 -0
- package/drizzle/meta/0002_snapshot.json +861 -0
- package/drizzle/meta/_journal.json +27 -0
- package/drizzle.config.ts +12 -0
- package/package.json +41 -0
- package/src/action-registry.ts +83 -0
- package/src/action-types.ts +324 -0
- package/src/artifact-store.ts +140 -0
- package/src/artifact-type-registry.ts +64 -0
- package/src/automation-store.ts +227 -0
- package/src/builtin-actions.test.ts +185 -0
- package/src/builtin-actions.ts +132 -0
- package/src/builtin-triggers.test.ts +264 -0
- package/src/builtin-triggers.ts +365 -0
- package/src/dispatch/action-kind.ts +44 -0
- package/src/dispatch/condition.ts +61 -0
- package/src/dispatch/delay-queue.ts +91 -0
- package/src/dispatch/engine.test.ts +1198 -0
- package/src/dispatch/engine.ts +1672 -0
- package/src/dispatch/path-nav.ts +65 -0
- package/src/dispatch/render.test.ts +75 -0
- package/src/dispatch/render.ts +136 -0
- package/src/dispatch/run-state-store.ts +143 -0
- package/src/dispatch/run-state.ts +298 -0
- package/src/dispatch/scope.test.ts +40 -0
- package/src/dispatch/scope.ts +125 -0
- package/src/dispatch/stalled-sweeper.ts +164 -0
- package/src/dispatch/test-fixtures.ts +558 -0
- package/src/dispatch/trigger-subscriber.ts +397 -0
- package/src/dispatch/types.ts +259 -0
- package/src/extension-points.ts +88 -0
- package/src/index.ts +379 -0
- package/src/migration/from-webhook-subscriptions.test.ts +237 -0
- package/src/migration/from-webhook-subscriptions.ts +398 -0
- package/src/registries.test.ts +357 -0
- package/src/router.test.ts +724 -0
- package/src/router.ts +556 -0
- package/src/schema.ts +310 -0
- package/src/trigger-registry.ts +99 -0
- package/src/validate-definition.test.ts +306 -0
- package/src/validate-definition.ts +304 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory test fixtures for the dispatch engine.
|
|
3
|
+
*
|
|
4
|
+
* Provides minimal implementations of `RunStore`, `ArtifactStore`, and
|
|
5
|
+
* registries so engine tests can run without a real database or queue.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { Versioned, createHook } from "@checkstack/backend-api";
|
|
9
|
+
import { createDefaultFilterRegistry } from "@checkstack/template-engine";
|
|
10
|
+
import type {
|
|
11
|
+
ActionDefinition,
|
|
12
|
+
TriggerDefinition,
|
|
13
|
+
} from "../action-types";
|
|
14
|
+
import { createActionRegistry, type ActionRegistry } from "../action-registry";
|
|
15
|
+
import {
|
|
16
|
+
createArtifactTypeRegistry,
|
|
17
|
+
type ArtifactTypeRegistry,
|
|
18
|
+
} from "../artifact-type-registry";
|
|
19
|
+
import { createTriggerRegistry, type TriggerRegistry } from "../trigger-registry";
|
|
20
|
+
import type { ArtifactStore, PersistedArtifact } from "../artifact-store";
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
CreateRunInput,
|
|
24
|
+
CreateStepInput,
|
|
25
|
+
CreateWaitLockInput,
|
|
26
|
+
DispatchDeps,
|
|
27
|
+
LoadedRun,
|
|
28
|
+
LoadedWaitLock,
|
|
29
|
+
RunStore,
|
|
30
|
+
} from "./types";
|
|
31
|
+
import type { RunStateSnapshot, RunStateStore } from "./run-state-store";
|
|
32
|
+
|
|
33
|
+
export function createInMemoryRunStore(): {
|
|
34
|
+
store: RunStore;
|
|
35
|
+
runs: Map<string, LoadedRun>;
|
|
36
|
+
steps: Array<{
|
|
37
|
+
id: string;
|
|
38
|
+
runId: string;
|
|
39
|
+
actionPath: string;
|
|
40
|
+
actionId: string | null;
|
|
41
|
+
actionKind: string;
|
|
42
|
+
providerActionId: string | null;
|
|
43
|
+
status: string;
|
|
44
|
+
attempts: number;
|
|
45
|
+
errorMessage?: string;
|
|
46
|
+
resultPayload?: Record<string, unknown>;
|
|
47
|
+
}>;
|
|
48
|
+
waitLocks: Map<string, LoadedWaitLock>;
|
|
49
|
+
} {
|
|
50
|
+
const runs = new Map<string, LoadedRun>();
|
|
51
|
+
const steps: Array<{
|
|
52
|
+
id: string;
|
|
53
|
+
runId: string;
|
|
54
|
+
actionPath: string;
|
|
55
|
+
actionId: string | null;
|
|
56
|
+
actionKind: string;
|
|
57
|
+
providerActionId: string | null;
|
|
58
|
+
status: string;
|
|
59
|
+
attempts: number;
|
|
60
|
+
errorMessage?: string;
|
|
61
|
+
resultPayload?: Record<string, unknown>;
|
|
62
|
+
}> = [];
|
|
63
|
+
const waitLocks = new Map<string, LoadedWaitLock>();
|
|
64
|
+
let runCounter = 0;
|
|
65
|
+
let stepCounter = 0;
|
|
66
|
+
let lockCounter = 0;
|
|
67
|
+
|
|
68
|
+
const store: RunStore = {
|
|
69
|
+
async createRun(input: CreateRunInput): Promise<string> {
|
|
70
|
+
const id = `run-${++runCounter}`;
|
|
71
|
+
runs.set(id, {
|
|
72
|
+
id,
|
|
73
|
+
automationId: input.automationId,
|
|
74
|
+
triggerId: input.triggerId,
|
|
75
|
+
triggerEventId: input.triggerEventId,
|
|
76
|
+
triggerPayload: input.triggerPayload,
|
|
77
|
+
contextKey: input.contextKey,
|
|
78
|
+
status: "running",
|
|
79
|
+
errorMessage: null,
|
|
80
|
+
startedAt: new Date(),
|
|
81
|
+
finishedAt: null,
|
|
82
|
+
});
|
|
83
|
+
return id;
|
|
84
|
+
},
|
|
85
|
+
async updateRunStatus(runId, status, errorMessage) {
|
|
86
|
+
const r = runs.get(runId);
|
|
87
|
+
if (!r) return;
|
|
88
|
+
r.status = status;
|
|
89
|
+
r.errorMessage = errorMessage ?? null;
|
|
90
|
+
r.finishedAt = ["success", "failed", "cancelled", "skipped"].includes(
|
|
91
|
+
status,
|
|
92
|
+
)
|
|
93
|
+
? new Date()
|
|
94
|
+
: null;
|
|
95
|
+
},
|
|
96
|
+
async loadRun(runId) {
|
|
97
|
+
return runs.get(runId);
|
|
98
|
+
},
|
|
99
|
+
async countActiveRuns(automationId) {
|
|
100
|
+
let count = 0;
|
|
101
|
+
for (const r of runs.values()) {
|
|
102
|
+
if (
|
|
103
|
+
r.automationId === automationId &&
|
|
104
|
+
["pending", "running", "waiting"].includes(r.status)
|
|
105
|
+
) {
|
|
106
|
+
count += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
110
|
+
},
|
|
111
|
+
async hasActiveRun(automationId) {
|
|
112
|
+
return (await this.countActiveRuns(automationId)) > 0;
|
|
113
|
+
},
|
|
114
|
+
async cancelActiveRuns(automationId, reason) {
|
|
115
|
+
const cancelled: string[] = [];
|
|
116
|
+
for (const r of runs.values()) {
|
|
117
|
+
if (
|
|
118
|
+
r.automationId === automationId &&
|
|
119
|
+
["pending", "running", "waiting"].includes(r.status)
|
|
120
|
+
) {
|
|
121
|
+
r.status = "cancelled";
|
|
122
|
+
r.errorMessage = reason;
|
|
123
|
+
r.finishedAt = new Date();
|
|
124
|
+
cancelled.push(r.id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return cancelled;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async createStep(input: CreateStepInput) {
|
|
131
|
+
const id = `step-${++stepCounter}`;
|
|
132
|
+
steps.push({
|
|
133
|
+
id,
|
|
134
|
+
runId: input.runId,
|
|
135
|
+
actionPath: input.actionPath,
|
|
136
|
+
actionId: input.actionId,
|
|
137
|
+
actionKind: input.actionKind,
|
|
138
|
+
providerActionId: input.providerActionId,
|
|
139
|
+
status: "running",
|
|
140
|
+
attempts: 1,
|
|
141
|
+
});
|
|
142
|
+
return id;
|
|
143
|
+
},
|
|
144
|
+
async updateStep(stepId, patch) {
|
|
145
|
+
const step = steps.find((s) => s.id === stepId);
|
|
146
|
+
if (!step) return;
|
|
147
|
+
step.status = patch.status;
|
|
148
|
+
step.errorMessage = patch.errorMessage;
|
|
149
|
+
step.resultPayload = patch.resultPayload;
|
|
150
|
+
if (patch.incrementAttempts) step.attempts += 1;
|
|
151
|
+
},
|
|
152
|
+
async findStepByPath(runId, actionPath) {
|
|
153
|
+
const matches = steps.filter(
|
|
154
|
+
(s) => s.runId === runId && s.actionPath === actionPath,
|
|
155
|
+
);
|
|
156
|
+
const last = matches.at(-1);
|
|
157
|
+
if (!last) return;
|
|
158
|
+
return {
|
|
159
|
+
id: last.id,
|
|
160
|
+
runId: last.runId,
|
|
161
|
+
actionPath: last.actionPath,
|
|
162
|
+
actionId: last.actionId,
|
|
163
|
+
actionKind: last.actionKind,
|
|
164
|
+
status: last.status,
|
|
165
|
+
attempts: last.attempts,
|
|
166
|
+
errorMessage: last.errorMessage ?? null,
|
|
167
|
+
resultPayload: last.resultPayload ?? null,
|
|
168
|
+
startedAt: new Date(),
|
|
169
|
+
finishedAt: null,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async createWaitLock(input: CreateWaitLockInput) {
|
|
174
|
+
const id = `lock-${++lockCounter}`;
|
|
175
|
+
waitLocks.set(id, {
|
|
176
|
+
id,
|
|
177
|
+
runId: input.runId,
|
|
178
|
+
actionPath: input.actionPath,
|
|
179
|
+
kind: input.kind,
|
|
180
|
+
eventId: input.eventId,
|
|
181
|
+
contextKey: input.contextKey,
|
|
182
|
+
filterTemplate: input.filterTemplate,
|
|
183
|
+
timeoutAt: input.timeoutAt,
|
|
184
|
+
createdAt: new Date(),
|
|
185
|
+
});
|
|
186
|
+
return id;
|
|
187
|
+
},
|
|
188
|
+
async loadWaitLock(id) {
|
|
189
|
+
return waitLocks.get(id);
|
|
190
|
+
},
|
|
191
|
+
async findWaitLocksFor(eventId, contextKey) {
|
|
192
|
+
const matches: LoadedWaitLock[] = [];
|
|
193
|
+
for (const lock of waitLocks.values()) {
|
|
194
|
+
if (lock.eventId === eventId && lock.contextKey === contextKey) {
|
|
195
|
+
matches.push(lock);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return matches;
|
|
199
|
+
},
|
|
200
|
+
async deleteWaitLock(id) {
|
|
201
|
+
waitLocks.delete(id);
|
|
202
|
+
},
|
|
203
|
+
async sweepExpiredWaitLocks(now) {
|
|
204
|
+
const expired: LoadedWaitLock[] = [];
|
|
205
|
+
for (const lock of waitLocks.values()) {
|
|
206
|
+
if (lock.timeoutAt && lock.timeoutAt.getTime() <= now.getTime()) {
|
|
207
|
+
expired.push(lock);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return expired;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return { store, runs, steps, waitLocks };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createInMemoryArtifactStore(): {
|
|
218
|
+
store: ArtifactStore;
|
|
219
|
+
artifacts: PersistedArtifact[];
|
|
220
|
+
} {
|
|
221
|
+
const artifacts: PersistedArtifact[] = [];
|
|
222
|
+
let counter = 0;
|
|
223
|
+
|
|
224
|
+
const store: ArtifactStore = {
|
|
225
|
+
async record(input) {
|
|
226
|
+
const artifact: PersistedArtifact = {
|
|
227
|
+
id: `artifact-${++counter}`,
|
|
228
|
+
automationId: input.automationId,
|
|
229
|
+
runId: input.runId,
|
|
230
|
+
stepId: input.stepId,
|
|
231
|
+
actionId: input.actionId,
|
|
232
|
+
artifactType: input.artifactType,
|
|
233
|
+
data: input.data,
|
|
234
|
+
contextKey: input.contextKey,
|
|
235
|
+
closedAt: null,
|
|
236
|
+
createdAt: new Date(),
|
|
237
|
+
};
|
|
238
|
+
artifacts.push(artifact);
|
|
239
|
+
return artifact;
|
|
240
|
+
},
|
|
241
|
+
async find(input) {
|
|
242
|
+
const all = await this.findAll(input);
|
|
243
|
+
return all[0];
|
|
244
|
+
},
|
|
245
|
+
async findAll(input) {
|
|
246
|
+
return artifacts
|
|
247
|
+
.filter((a) => a.automationId === input.automationId)
|
|
248
|
+
.filter(
|
|
249
|
+
(a) =>
|
|
250
|
+
input.contextKey === undefined ||
|
|
251
|
+
input.contextKey === null ||
|
|
252
|
+
a.contextKey === input.contextKey,
|
|
253
|
+
)
|
|
254
|
+
.filter(
|
|
255
|
+
(a) => !input.artifactType || a.artifactType === input.artifactType,
|
|
256
|
+
)
|
|
257
|
+
.filter((a) => !input.actionId || a.actionId === input.actionId)
|
|
258
|
+
.filter((a) => !(input.onlyOpen ?? true) || a.closedAt === null)
|
|
259
|
+
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
260
|
+
},
|
|
261
|
+
async markClosed(artifactId) {
|
|
262
|
+
const a = artifacts.find((x) => x.id === artifactId);
|
|
263
|
+
if (a) a.closedAt = new Date();
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return { store, artifacts };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function createInMemoryRunStateStore(): {
|
|
271
|
+
store: RunStateStore;
|
|
272
|
+
states: Map<string, RunStateSnapshot>;
|
|
273
|
+
locks: Set<string>;
|
|
274
|
+
} {
|
|
275
|
+
const states = new Map<string, RunStateSnapshot>();
|
|
276
|
+
const locks = new Set<string>();
|
|
277
|
+
|
|
278
|
+
const store: RunStateStore = {
|
|
279
|
+
async upsert(input) {
|
|
280
|
+
states.set(input.runId, {
|
|
281
|
+
scopeSnapshot: input.scopeSnapshot,
|
|
282
|
+
lastActionPath: input.lastActionPath,
|
|
283
|
+
lastHeartbeatAt: new Date(),
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
async load(runId) {
|
|
287
|
+
return states.get(runId);
|
|
288
|
+
},
|
|
289
|
+
async clear(runId) {
|
|
290
|
+
states.delete(runId);
|
|
291
|
+
},
|
|
292
|
+
async heartbeat(runId) {
|
|
293
|
+
const s = states.get(runId);
|
|
294
|
+
if (s) s.lastHeartbeatAt = new Date();
|
|
295
|
+
},
|
|
296
|
+
async findStalledRunIds(threshold) {
|
|
297
|
+
const ids: string[] = [];
|
|
298
|
+
for (const [runId, s] of states.entries()) {
|
|
299
|
+
if (s.lastHeartbeatAt < threshold) ids.push(runId);
|
|
300
|
+
}
|
|
301
|
+
return ids;
|
|
302
|
+
},
|
|
303
|
+
async tryAdvisoryLock(runId) {
|
|
304
|
+
if (locks.has(runId)) return false;
|
|
305
|
+
locks.add(runId);
|
|
306
|
+
return true;
|
|
307
|
+
},
|
|
308
|
+
async releaseAdvisoryLock(runId) {
|
|
309
|
+
locks.delete(runId);
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return { store, states, locks };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Minimal in-memory queue manager stub for engine tests. Records enqueued
|
|
318
|
+
* jobs so a test can fire them synchronously to simulate the delay
|
|
319
|
+
* scheduler.
|
|
320
|
+
*/
|
|
321
|
+
export interface FakeQueueManager {
|
|
322
|
+
manager: DispatchDeps["queueManager"];
|
|
323
|
+
jobs: Array<{
|
|
324
|
+
queue: string;
|
|
325
|
+
data: unknown;
|
|
326
|
+
startDelay?: number;
|
|
327
|
+
jobId?: string;
|
|
328
|
+
}>;
|
|
329
|
+
fireAll: () => Promise<void>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function createFakeQueueManager(opts?: {
|
|
333
|
+
onJob?: (queue: string, data: unknown) => Promise<void> | void;
|
|
334
|
+
}): FakeQueueManager {
|
|
335
|
+
const jobs: FakeQueueManager["jobs"] = [];
|
|
336
|
+
const consumers = new Map<
|
|
337
|
+
string,
|
|
338
|
+
(job: { id: string; data: unknown; timestamp: Date; attempts?: number }) => Promise<void>
|
|
339
|
+
>();
|
|
340
|
+
|
|
341
|
+
const queueFor = <T>(name: string) => ({
|
|
342
|
+
async enqueue(
|
|
343
|
+
data: T,
|
|
344
|
+
options?: { startDelay?: number; jobId?: string; priority?: number },
|
|
345
|
+
) {
|
|
346
|
+
jobs.push({
|
|
347
|
+
queue: name,
|
|
348
|
+
data,
|
|
349
|
+
startDelay: options?.startDelay,
|
|
350
|
+
jobId: options?.jobId,
|
|
351
|
+
});
|
|
352
|
+
await opts?.onJob?.(name, data);
|
|
353
|
+
return options?.jobId ?? `job-${jobs.length}`;
|
|
354
|
+
},
|
|
355
|
+
async consume(
|
|
356
|
+
consumer: (job: {
|
|
357
|
+
id: string;
|
|
358
|
+
data: unknown;
|
|
359
|
+
timestamp: Date;
|
|
360
|
+
attempts?: number;
|
|
361
|
+
}) => Promise<void>,
|
|
362
|
+
) {
|
|
363
|
+
consumers.set(name, consumer);
|
|
364
|
+
},
|
|
365
|
+
async scheduleRecurring() {
|
|
366
|
+
return "test-recurring";
|
|
367
|
+
},
|
|
368
|
+
async cancelRecurring() {},
|
|
369
|
+
async listRecurringJobs() {
|
|
370
|
+
return [];
|
|
371
|
+
},
|
|
372
|
+
async getRecurringJobDetails() {
|
|
373
|
+
return;
|
|
374
|
+
},
|
|
375
|
+
async getInFlightCount() {
|
|
376
|
+
return 0;
|
|
377
|
+
},
|
|
378
|
+
async testConnection() {},
|
|
379
|
+
async stop() {},
|
|
380
|
+
async getStats() {
|
|
381
|
+
return {
|
|
382
|
+
pending: 0,
|
|
383
|
+
processing: 0,
|
|
384
|
+
completed: 0,
|
|
385
|
+
failed: 0,
|
|
386
|
+
consumerGroups: 0,
|
|
387
|
+
scope: "instance" as const,
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
async listJobs() {
|
|
391
|
+
return { items: [], total: 0, cursor: null };
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const manager = {
|
|
396
|
+
getQueue: queueFor,
|
|
397
|
+
getActivePlugin: () => "memory",
|
|
398
|
+
getActiveConfig: () => ({}),
|
|
399
|
+
setActiveBackend: async () => ({
|
|
400
|
+
previousPlugin: "memory",
|
|
401
|
+
newPlugin: "memory",
|
|
402
|
+
migratedJobs: 0,
|
|
403
|
+
}),
|
|
404
|
+
getInFlightJobCount: async () => 0,
|
|
405
|
+
getAggregatedStats: async () => ({
|
|
406
|
+
pending: 0,
|
|
407
|
+
processing: 0,
|
|
408
|
+
completed: 0,
|
|
409
|
+
failed: 0,
|
|
410
|
+
consumerGroups: 0,
|
|
411
|
+
scope: "instance" as const,
|
|
412
|
+
}),
|
|
413
|
+
listJobs: async () => ({ items: [], total: 0, cursor: null }),
|
|
414
|
+
listAllRecurringJobs: async () => [],
|
|
415
|
+
startPolling: () => {},
|
|
416
|
+
shutdown: async () => {},
|
|
417
|
+
} as unknown as DispatchDeps["queueManager"];
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
manager,
|
|
421
|
+
jobs,
|
|
422
|
+
async fireAll() {
|
|
423
|
+
for (const job of jobs.splice(0)) {
|
|
424
|
+
const consumer = consumers.get(job.queue);
|
|
425
|
+
if (!consumer) continue;
|
|
426
|
+
await consumer({
|
|
427
|
+
id: job.jobId ?? "job",
|
|
428
|
+
data: job.data,
|
|
429
|
+
timestamp: new Date(),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Compose a complete `DispatchDeps` for tests. Callers can pre-populate
|
|
438
|
+
* the action / artifact-type / trigger registries before invoking the
|
|
439
|
+
* engine.
|
|
440
|
+
*/
|
|
441
|
+
export function makeDispatchDeps(opts?: {
|
|
442
|
+
actions?: ActionRegistry;
|
|
443
|
+
artifactTypes?: ArtifactTypeRegistry;
|
|
444
|
+
triggers?: TriggerRegistry;
|
|
445
|
+
}): {
|
|
446
|
+
deps: DispatchDeps;
|
|
447
|
+
runs: ReturnType<typeof createInMemoryRunStore>;
|
|
448
|
+
artifacts: ReturnType<typeof createInMemoryArtifactStore>;
|
|
449
|
+
state: ReturnType<typeof createInMemoryRunStateStore>;
|
|
450
|
+
queue: FakeQueueManager;
|
|
451
|
+
} {
|
|
452
|
+
const runs = createInMemoryRunStore();
|
|
453
|
+
const artifacts = createInMemoryArtifactStore();
|
|
454
|
+
const state = createInMemoryRunStateStore();
|
|
455
|
+
const queue = createFakeQueueManager();
|
|
456
|
+
const noopLogger = {
|
|
457
|
+
debug: () => {},
|
|
458
|
+
info: () => {},
|
|
459
|
+
warn: () => {},
|
|
460
|
+
error: () => {},
|
|
461
|
+
} as unknown as DispatchDeps["logger"];
|
|
462
|
+
const deps: DispatchDeps = {
|
|
463
|
+
logger: noopLogger,
|
|
464
|
+
filters: createDefaultFilterRegistry(),
|
|
465
|
+
registries: {
|
|
466
|
+
triggers: opts?.triggers ?? createTriggerRegistry(),
|
|
467
|
+
actions: opts?.actions ?? createActionRegistry(),
|
|
468
|
+
artifactTypes: opts?.artifactTypes ?? createArtifactTypeRegistry(),
|
|
469
|
+
},
|
|
470
|
+
runStore: runs.store,
|
|
471
|
+
artifactStore: artifacts.store,
|
|
472
|
+
runStateStore: state.store,
|
|
473
|
+
queueManager: queue.manager,
|
|
474
|
+
getService: async () => {
|
|
475
|
+
throw new Error("getService not stubbed for this test");
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
return { deps, runs, artifacts, state, queue };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ─── Shared fixtures ────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
export const testPlugin = { pluginId: "test" } as const;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* A no-op action that just records its config and returns success.
|
|
487
|
+
*
|
|
488
|
+
* By default this is a NON-producing action (no `produces`), so it can be
|
|
489
|
+
* used freely as a "did it run" probe without an action `id`. Pass
|
|
490
|
+
* `{ produces: true }` to make it emit a `recorded` artifact — producers
|
|
491
|
+
* MUST be given an `id` in the automation so the artifact is referenceable
|
|
492
|
+
* as `artifacts.<id>.recorded.*`.
|
|
493
|
+
*/
|
|
494
|
+
export function makeRecordingAction(opts?: { produces?: boolean }): {
|
|
495
|
+
definition: ActionDefinition<{ value: string }, { recorded: string }>;
|
|
496
|
+
calls: Array<{ value: string; consumedArtifacts: Record<string, unknown> }>;
|
|
497
|
+
} {
|
|
498
|
+
const calls: Array<{
|
|
499
|
+
value: string;
|
|
500
|
+
consumedArtifacts: Record<string, unknown>;
|
|
501
|
+
}> = [];
|
|
502
|
+
const produces = opts?.produces ?? false;
|
|
503
|
+
return {
|
|
504
|
+
definition: {
|
|
505
|
+
id: "record",
|
|
506
|
+
displayName: "Record",
|
|
507
|
+
config: new Versioned({
|
|
508
|
+
version: 1,
|
|
509
|
+
schema: z.object({ value: z.string() }),
|
|
510
|
+
}),
|
|
511
|
+
// Local artifact id — the registry qualifies it to `test.recorded`
|
|
512
|
+
// (pluginId `test`).
|
|
513
|
+
...(produces ? { produces: "recorded" } : {}),
|
|
514
|
+
execute: async (ctx) => {
|
|
515
|
+
calls.push({
|
|
516
|
+
value: ctx.config.value,
|
|
517
|
+
consumedArtifacts: ctx.consumedArtifacts,
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
success: true,
|
|
521
|
+
artifact: { recorded: ctx.config.value },
|
|
522
|
+
};
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
calls,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* A failing action — always returns success: false.
|
|
531
|
+
*/
|
|
532
|
+
export function makeFailingAction(): ActionDefinition<{ reason: string }> {
|
|
533
|
+
return {
|
|
534
|
+
id: "fail",
|
|
535
|
+
displayName: "Fail",
|
|
536
|
+
config: new Versioned({
|
|
537
|
+
version: 1,
|
|
538
|
+
schema: z.object({ reason: z.string() }),
|
|
539
|
+
}),
|
|
540
|
+
execute: async (ctx) => ({
|
|
541
|
+
success: false,
|
|
542
|
+
error: ctx.config.reason,
|
|
543
|
+
}),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Hook used by tests that need a registered hook reference. */
|
|
548
|
+
export const testHook = createHook<{ id: string }>("test.event");
|
|
549
|
+
|
|
550
|
+
export function makeTrigger(): TriggerDefinition<{ id: string }> {
|
|
551
|
+
return {
|
|
552
|
+
id: "event",
|
|
553
|
+
displayName: "Test event",
|
|
554
|
+
payloadSchema: z.object({ id: z.string() }),
|
|
555
|
+
hook: testHook,
|
|
556
|
+
contextKey: (p) => p.id,
|
|
557
|
+
};
|
|
558
|
+
}
|