@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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behaviour tests for the built-in `time.cron`, `time.interval`, and
|
|
3
|
+
* `template` triggers.
|
|
4
|
+
*
|
|
5
|
+
* Each test exercises one factory, fakes out the queue, runs setup()
|
|
6
|
+
* to register a fire-callback in the module-scoped tick map, and then
|
|
7
|
+
* either (a) inspects the queue arguments, or (b) plays a tick through
|
|
8
|
+
* the recorded callback to verify the fire behaviour (including the
|
|
9
|
+
* template trigger's false → true edge detection).
|
|
10
|
+
*/
|
|
11
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
12
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
13
|
+
import type { QueueManager } from "@checkstack/queue-api";
|
|
14
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
_resetBuiltinTriggerTickHandlersForTests,
|
|
18
|
+
BUILTIN_TRIGGER_QUEUE,
|
|
19
|
+
createTemplateTrigger,
|
|
20
|
+
createTimeCronTrigger,
|
|
21
|
+
createTimeIntervalTrigger,
|
|
22
|
+
registerBuiltinTriggerConsumer,
|
|
23
|
+
type BuiltinTriggerTickPayload,
|
|
24
|
+
} from "./builtin-triggers";
|
|
25
|
+
|
|
26
|
+
interface FakeJob {
|
|
27
|
+
id: string;
|
|
28
|
+
data: BuiltinTriggerTickPayload;
|
|
29
|
+
timestamp: Date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tick(jobId: string): FakeJob {
|
|
33
|
+
return {
|
|
34
|
+
id: jobId,
|
|
35
|
+
data: { jobId },
|
|
36
|
+
timestamp: new Date(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const logger = createMockLogger() as Logger;
|
|
41
|
+
|
|
42
|
+
interface QueueFixture {
|
|
43
|
+
queueManager: QueueManager;
|
|
44
|
+
scheduleMock: ReturnType<typeof mock>;
|
|
45
|
+
cancelMock: ReturnType<typeof mock>;
|
|
46
|
+
consumeMock: ReturnType<typeof mock>;
|
|
47
|
+
/** Last consumer registered via `queue.consume`. */
|
|
48
|
+
consumer?: (job: FakeJob) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeQueueFixture(): QueueFixture {
|
|
52
|
+
const fixture: QueueFixture = {
|
|
53
|
+
queueManager: undefined as never,
|
|
54
|
+
scheduleMock: mock(async (_payload: unknown, options: { jobId: string }) =>
|
|
55
|
+
options.jobId,
|
|
56
|
+
),
|
|
57
|
+
cancelMock: mock(async (_jobId: string) => undefined),
|
|
58
|
+
consumeMock: mock(async () => undefined),
|
|
59
|
+
};
|
|
60
|
+
fixture.consumeMock = mock(
|
|
61
|
+
async (consumer: (job: FakeJob) => Promise<void>) => {
|
|
62
|
+
fixture.consumer = consumer;
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
const queue = {
|
|
66
|
+
scheduleRecurring: fixture.scheduleMock,
|
|
67
|
+
cancelRecurring: fixture.cancelMock,
|
|
68
|
+
consume: fixture.consumeMock,
|
|
69
|
+
};
|
|
70
|
+
fixture.queueManager = {
|
|
71
|
+
getQueue: () => queue,
|
|
72
|
+
} as unknown as QueueManager;
|
|
73
|
+
return fixture;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
_resetBuiltinTriggerTickHandlersForTests();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("automation.cron", () => {
|
|
81
|
+
it("schedules a recurring cron job and fires on each tick", async () => {
|
|
82
|
+
const fx = makeQueueFixture();
|
|
83
|
+
await registerBuiltinTriggerConsumer({
|
|
84
|
+
queueManager: fx.queueManager,
|
|
85
|
+
logger,
|
|
86
|
+
});
|
|
87
|
+
const fired: Array<{ firedAt: string }> = [];
|
|
88
|
+
const fire = mock(async (payload: unknown) => {
|
|
89
|
+
fired.push(payload as { firedAt: string });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const trigger = createTimeCronTrigger({ queueManager: fx.queueManager });
|
|
93
|
+
const teardown = await trigger.setup!({
|
|
94
|
+
config: { cronPattern: "*/5 * * * *" },
|
|
95
|
+
identity: { automationId: "auto-1", triggerId: "t-1" },
|
|
96
|
+
fire,
|
|
97
|
+
logger,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(fx.scheduleMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
const [scheduleData, scheduleOptions] = fx.scheduleMock.mock.calls[0] as [
|
|
102
|
+
BuiltinTriggerTickPayload,
|
|
103
|
+
{ jobId: string; cronPattern?: string },
|
|
104
|
+
];
|
|
105
|
+
expect(scheduleData.jobId).toBe("builtin:cron:auto-1:t-1");
|
|
106
|
+
expect(scheduleOptions.cronPattern).toBe("*/5 * * * *");
|
|
107
|
+
|
|
108
|
+
// The consumer was registered first; play a tick through it and
|
|
109
|
+
// confirm the fire-callback ran.
|
|
110
|
+
expect(fx.consumer).toBeDefined();
|
|
111
|
+
await fx.consumer!(tick("builtin:cron:auto-1:t-1"));
|
|
112
|
+
expect(fire).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(fired[0]?.firedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
114
|
+
|
|
115
|
+
// Teardown cancels the recurring job + drops the handler.
|
|
116
|
+
await teardown();
|
|
117
|
+
expect(fx.cancelMock).toHaveBeenCalledWith("builtin:cron:auto-1:t-1");
|
|
118
|
+
await fx.consumer!(tick("builtin:cron:auto-1:t-1"));
|
|
119
|
+
// No additional fire after teardown — handler was removed.
|
|
120
|
+
expect(fire).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("scopes the jobId by (automationId, triggerId)", async () => {
|
|
124
|
+
const fx = makeQueueFixture();
|
|
125
|
+
const trigger = createTimeCronTrigger({ queueManager: fx.queueManager });
|
|
126
|
+
const teardownA = await trigger.setup!({
|
|
127
|
+
config: { cronPattern: "0 * * * *" },
|
|
128
|
+
identity: { automationId: "auto-A", triggerId: "x" },
|
|
129
|
+
fire: mock(async () => {}),
|
|
130
|
+
logger,
|
|
131
|
+
});
|
|
132
|
+
const teardownB = await trigger.setup!({
|
|
133
|
+
config: { cronPattern: "0 * * * *" },
|
|
134
|
+
identity: { automationId: "auto-B", triggerId: "x" },
|
|
135
|
+
fire: mock(async () => {}),
|
|
136
|
+
logger,
|
|
137
|
+
});
|
|
138
|
+
const jobIds = fx.scheduleMock.mock.calls.map(
|
|
139
|
+
(c) => (c[1] as { jobId: string }).jobId,
|
|
140
|
+
);
|
|
141
|
+
expect(jobIds).toContain("builtin:cron:auto-A:x");
|
|
142
|
+
expect(jobIds).toContain("builtin:cron:auto-B:x");
|
|
143
|
+
await teardownA();
|
|
144
|
+
await teardownB();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("automation.interval", () => {
|
|
149
|
+
it("schedules a recurring interval job with a startDelay equal to the interval", async () => {
|
|
150
|
+
const fx = makeQueueFixture();
|
|
151
|
+
await registerBuiltinTriggerConsumer({
|
|
152
|
+
queueManager: fx.queueManager,
|
|
153
|
+
logger,
|
|
154
|
+
});
|
|
155
|
+
const fire = mock(async (_payload: unknown) => {});
|
|
156
|
+
|
|
157
|
+
const trigger = createTimeIntervalTrigger({
|
|
158
|
+
queueManager: fx.queueManager,
|
|
159
|
+
});
|
|
160
|
+
const teardown = await trigger.setup!({
|
|
161
|
+
config: { intervalSeconds: 30 },
|
|
162
|
+
identity: { automationId: "auto-1", triggerId: "t-1" },
|
|
163
|
+
fire,
|
|
164
|
+
logger,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const [, scheduleOptions] = fx.scheduleMock.mock.calls[0] as [
|
|
168
|
+
BuiltinTriggerTickPayload,
|
|
169
|
+
{ jobId: string; intervalSeconds?: number; startDelay?: number },
|
|
170
|
+
];
|
|
171
|
+
expect(scheduleOptions.intervalSeconds).toBe(30);
|
|
172
|
+
expect(scheduleOptions.startDelay).toBe(30);
|
|
173
|
+
|
|
174
|
+
await fx.consumer!(tick("builtin:interval:auto-1:t-1"));
|
|
175
|
+
expect(fire).toHaveBeenCalledTimes(1);
|
|
176
|
+
|
|
177
|
+
await teardown();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("automation.template", () => {
|
|
182
|
+
it("fires only on the false → true edge", async () => {
|
|
183
|
+
const fx = makeQueueFixture();
|
|
184
|
+
await registerBuiltinTriggerConsumer({
|
|
185
|
+
queueManager: fx.queueManager,
|
|
186
|
+
logger,
|
|
187
|
+
});
|
|
188
|
+
const fire = mock(async (_payload: unknown) => {});
|
|
189
|
+
|
|
190
|
+
// Use a template that toggles based on a flag in the closure.
|
|
191
|
+
// Since the trigger only has access to `{ now }`, we simulate the
|
|
192
|
+
// edge by switching the template via two separate setup calls.
|
|
193
|
+
const trigger = createTemplateTrigger({ queueManager: fx.queueManager });
|
|
194
|
+
const teardown = await trigger.setup!({
|
|
195
|
+
// Truthy from the start. We expect:
|
|
196
|
+
// tick 1 → previousTruthy was false → fire.
|
|
197
|
+
// tick 2 → previousTruthy is now true → no fire.
|
|
198
|
+
config: { value_template: "true", intervalSeconds: 5 },
|
|
199
|
+
identity: { automationId: "auto-1", triggerId: "t-1" },
|
|
200
|
+
fire,
|
|
201
|
+
logger,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await fx.consumer!(tick("builtin:template:auto-1:t-1"));
|
|
205
|
+
await fx.consumer!(tick("builtin:template:auto-1:t-1"));
|
|
206
|
+
expect(fire).toHaveBeenCalledTimes(1);
|
|
207
|
+
|
|
208
|
+
await teardown();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not fire when the template is always falsy", async () => {
|
|
212
|
+
const fx = makeQueueFixture();
|
|
213
|
+
await registerBuiltinTriggerConsumer({
|
|
214
|
+
queueManager: fx.queueManager,
|
|
215
|
+
logger,
|
|
216
|
+
});
|
|
217
|
+
const fire = mock(async (_payload: unknown) => {});
|
|
218
|
+
|
|
219
|
+
const trigger = createTemplateTrigger({ queueManager: fx.queueManager });
|
|
220
|
+
const teardown = await trigger.setup!({
|
|
221
|
+
config: { value_template: "false", intervalSeconds: 5 },
|
|
222
|
+
identity: { automationId: "auto-1", triggerId: "t-1" },
|
|
223
|
+
fire,
|
|
224
|
+
logger,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await fx.consumer!(tick("builtin:template:auto-1:t-1"));
|
|
228
|
+
await fx.consumer!(tick("builtin:template:auto-1:t-1"));
|
|
229
|
+
expect(fire).not.toHaveBeenCalled();
|
|
230
|
+
|
|
231
|
+
await teardown();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("rejects an invalid template at setup time", async () => {
|
|
235
|
+
const fx = makeQueueFixture();
|
|
236
|
+
const trigger = createTemplateTrigger({ queueManager: fx.queueManager });
|
|
237
|
+
await expect(
|
|
238
|
+
trigger.setup!({
|
|
239
|
+
config: { value_template: "((((", intervalSeconds: 5 },
|
|
240
|
+
identity: { automationId: "auto-1", triggerId: "t-1" },
|
|
241
|
+
fire: mock(async () => {}),
|
|
242
|
+
logger,
|
|
243
|
+
}),
|
|
244
|
+
).rejects.toThrow(/invalid value_template/);
|
|
245
|
+
expect(fx.scheduleMock).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("builtin trigger consumer", () => {
|
|
250
|
+
it("logs but does not throw when a tick arrives without a registered handler", async () => {
|
|
251
|
+
const fx = makeQueueFixture();
|
|
252
|
+
await registerBuiltinTriggerConsumer({
|
|
253
|
+
queueManager: fx.queueManager,
|
|
254
|
+
logger,
|
|
255
|
+
});
|
|
256
|
+
await expect(
|
|
257
|
+
fx.consumer!(tick("builtin:cron:unregistered:nope")),
|
|
258
|
+
).resolves.toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("uses the shared queue name", () => {
|
|
262
|
+
expect(BUILTIN_TRIGGER_QUEUE).toBe("automation-builtin-triggers");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in triggers shipped by automation-backend itself.
|
|
3
|
+
*
|
|
4
|
+
* Three triggers, all setup-backed (no plugin hook to subscribe to):
|
|
5
|
+
*
|
|
6
|
+
* - `time.cron` — recurring queue job on a cron pattern.
|
|
7
|
+
* - `time.interval` — recurring queue job on a fixed interval.
|
|
8
|
+
* - `template` — interval-based polling; evaluates a template on each
|
|
9
|
+
* tick and fires on the false → true edge (so an automation
|
|
10
|
+
* subscribing on "value template is truthy" doesn't re-fire while
|
|
11
|
+
* the value stays truthy).
|
|
12
|
+
*
|
|
13
|
+
* All three share the same two-stage runtime structure:
|
|
14
|
+
*
|
|
15
|
+
* 1. A single shared queue (`automation-builtin-triggers`) accepts
|
|
16
|
+
* every recurring tick. A consumer registered at plugin init reads
|
|
17
|
+
* the payload's `jobId` and dispatches to whichever fire-callback
|
|
18
|
+
* was registered for that jobId.
|
|
19
|
+
* 2. `setup()` is invoked once per (automation, trigger-config) pair
|
|
20
|
+
* by the standard trigger-subscriber machinery. It registers a
|
|
21
|
+
* fire-callback in a module-scoped map and calls
|
|
22
|
+
* `queue.scheduleRecurring(...)`; the matching teardown cancels
|
|
23
|
+
* the recurring job and drops the map entry.
|
|
24
|
+
*
|
|
25
|
+
* Restart semantics work the same way regardless of the queue
|
|
26
|
+
* backend: `setupTriggerSubscriptions` re-runs every enabled
|
|
27
|
+
* automation's `setup()` during `afterPluginsReady` on every boot,
|
|
28
|
+
* and `setup()` calls `scheduleRecurring(...)` with a deterministic
|
|
29
|
+
* jobId. On a persistent queue (BullMQ/Redis), the second call is an
|
|
30
|
+
* in-place update of the surviving recurring job. On the in-memory
|
|
31
|
+
* queue — whose recurring-schedule map gets wiped at shutdown — it
|
|
32
|
+
* re-creates the schedule from scratch. Either way the schedule is
|
|
33
|
+
* back in place by the time the consumer would dispatch.
|
|
34
|
+
*/
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
37
|
+
import type { QueueManager } from "@checkstack/queue-api";
|
|
38
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
39
|
+
import {
|
|
40
|
+
evaluateBoolean,
|
|
41
|
+
parseCondition,
|
|
42
|
+
} from "@checkstack/template-engine";
|
|
43
|
+
|
|
44
|
+
import type { TriggerDefinition } from "./action-types";
|
|
45
|
+
|
|
46
|
+
// ─── Shared queue plumbing ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export const BUILTIN_TRIGGER_QUEUE = "automation-builtin-triggers";
|
|
49
|
+
const BUILTIN_TRIGGER_CONSUMER_GROUP = "automation-builtin-triggers-worker";
|
|
50
|
+
|
|
51
|
+
export interface BuiltinTriggerTickPayload {
|
|
52
|
+
/**
|
|
53
|
+
* Stable identifier matching the recurring `jobId` so the consumer
|
|
54
|
+
* can route the tick to the right fire-callback.
|
|
55
|
+
*/
|
|
56
|
+
jobId: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Module-scoped registry of `jobId` → tick handler. Populated by
|
|
61
|
+
* `setup()` on automation start, drained by the teardown returned
|
|
62
|
+
* from `setup()` on disable/delete.
|
|
63
|
+
*
|
|
64
|
+
* Scoped here (not under a `Map` in init's closure) so the queue
|
|
65
|
+
* consumer registered in init can look up the live entries.
|
|
66
|
+
*/
|
|
67
|
+
const tickHandlers = new Map<string, (logger: Logger) => Promise<void>>();
|
|
68
|
+
|
|
69
|
+
export async function registerBuiltinTriggerConsumer(args: {
|
|
70
|
+
queueManager: QueueManager;
|
|
71
|
+
logger: Logger;
|
|
72
|
+
}): Promise<void> {
|
|
73
|
+
const { queueManager, logger } = args;
|
|
74
|
+
const queue = queueManager.getQueue<BuiltinTriggerTickPayload>(
|
|
75
|
+
BUILTIN_TRIGGER_QUEUE,
|
|
76
|
+
);
|
|
77
|
+
await queue.consume(
|
|
78
|
+
async (job) => {
|
|
79
|
+
const handler = tickHandlers.get(job.data.jobId);
|
|
80
|
+
if (!handler) {
|
|
81
|
+
// Schedule may have outlived the trigger's setup — could
|
|
82
|
+
// happen across a restart while jobs flush. Logging is
|
|
83
|
+
// enough; the consumer keeps going.
|
|
84
|
+
logger.debug(
|
|
85
|
+
`Built-in trigger tick for ${job.data.jobId} has no registered handler — skipping`,
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await handler(logger);
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
consumerGroup: BUILTIN_TRIGGER_CONSUMER_GROUP,
|
|
93
|
+
// Time / template ticks are idempotent in spirit but a missed
|
|
94
|
+
// fire is preferable to a duplicate fire (operators wire
|
|
95
|
+
// mutation actions on these). Don't retry.
|
|
96
|
+
maxRetries: 0,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildJobId(args: {
|
|
102
|
+
kind: "cron" | "interval" | "template";
|
|
103
|
+
automationId: string;
|
|
104
|
+
triggerId: string;
|
|
105
|
+
}): string {
|
|
106
|
+
return `builtin:${args.kind}:${args.automationId}:${args.triggerId}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── time.cron ─────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const cronConfigSchema = z.object({
|
|
112
|
+
cronPattern: z
|
|
113
|
+
.string()
|
|
114
|
+
.min(1)
|
|
115
|
+
.describe("Cron expression — fires on each match (e.g. `*/5 * * * *`)"),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const timeTickPayloadSchema = z.object({
|
|
119
|
+
firedAt: z.string().describe("ISO timestamp of when the tick fired"),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export type TimeCronConfig = z.infer<typeof cronConfigSchema>;
|
|
123
|
+
export type TimeTickPayload = z.infer<typeof timeTickPayloadSchema>;
|
|
124
|
+
|
|
125
|
+
export interface BuiltinTriggerDeps {
|
|
126
|
+
queueManager: QueueManager;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createTimeCronTrigger(
|
|
130
|
+
deps: BuiltinTriggerDeps,
|
|
131
|
+
): TriggerDefinition<TimeTickPayload, TimeCronConfig> {
|
|
132
|
+
return {
|
|
133
|
+
id: "cron",
|
|
134
|
+
displayName: "Cron Schedule",
|
|
135
|
+
description:
|
|
136
|
+
"Fires on a recurring cron pattern. Use for daily reports, periodic reconciliation, etc.",
|
|
137
|
+
category: "Time",
|
|
138
|
+
icon: "Clock",
|
|
139
|
+
payloadSchema: timeTickPayloadSchema,
|
|
140
|
+
configSchema: cronConfigSchema,
|
|
141
|
+
setup: async ({ config, identity, fire, logger }) => {
|
|
142
|
+
const jobId = buildJobId({
|
|
143
|
+
kind: "cron",
|
|
144
|
+
automationId: identity.automationId,
|
|
145
|
+
triggerId: identity.triggerId,
|
|
146
|
+
});
|
|
147
|
+
tickHandlers.set(jobId, async () => {
|
|
148
|
+
await fire({ firedAt: new Date().toISOString() });
|
|
149
|
+
});
|
|
150
|
+
const queue = deps.queueManager.getQueue<BuiltinTriggerTickPayload>(
|
|
151
|
+
BUILTIN_TRIGGER_QUEUE,
|
|
152
|
+
);
|
|
153
|
+
await queue.scheduleRecurring(
|
|
154
|
+
{ jobId },
|
|
155
|
+
{ jobId, cronPattern: config.cronPattern },
|
|
156
|
+
);
|
|
157
|
+
logger.debug(
|
|
158
|
+
`Scheduled time.cron trigger for automation ${identity.automationId}: ${config.cronPattern}`,
|
|
159
|
+
);
|
|
160
|
+
return async () => {
|
|
161
|
+
tickHandlers.delete(jobId);
|
|
162
|
+
await queue.cancelRecurring(jobId);
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── time.interval ─────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const intervalConfigSchema = z.object({
|
|
171
|
+
intervalSeconds: z
|
|
172
|
+
.number()
|
|
173
|
+
.int()
|
|
174
|
+
.min(1)
|
|
175
|
+
.max(31_536_000)
|
|
176
|
+
.describe("Interval between fires, in seconds (1s – 1y)"),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export type TimeIntervalConfig = z.infer<typeof intervalConfigSchema>;
|
|
180
|
+
|
|
181
|
+
export function createTimeIntervalTrigger(
|
|
182
|
+
deps: BuiltinTriggerDeps,
|
|
183
|
+
): TriggerDefinition<TimeTickPayload, TimeIntervalConfig> {
|
|
184
|
+
return {
|
|
185
|
+
id: "interval",
|
|
186
|
+
displayName: "Interval",
|
|
187
|
+
description: "Fires on a fixed time interval (seconds).",
|
|
188
|
+
category: "Time",
|
|
189
|
+
icon: "Timer",
|
|
190
|
+
payloadSchema: timeTickPayloadSchema,
|
|
191
|
+
configSchema: intervalConfigSchema,
|
|
192
|
+
setup: async ({ config, identity, fire, logger }) => {
|
|
193
|
+
const jobId = buildJobId({
|
|
194
|
+
kind: "interval",
|
|
195
|
+
automationId: identity.automationId,
|
|
196
|
+
triggerId: identity.triggerId,
|
|
197
|
+
});
|
|
198
|
+
tickHandlers.set(jobId, async () => {
|
|
199
|
+
await fire({ firedAt: new Date().toISOString() });
|
|
200
|
+
});
|
|
201
|
+
const queue = deps.queueManager.getQueue<BuiltinTriggerTickPayload>(
|
|
202
|
+
BUILTIN_TRIGGER_QUEUE,
|
|
203
|
+
);
|
|
204
|
+
await queue.scheduleRecurring(
|
|
205
|
+
{ jobId },
|
|
206
|
+
{
|
|
207
|
+
jobId,
|
|
208
|
+
intervalSeconds: config.intervalSeconds,
|
|
209
|
+
// Delay the first fire by the interval so an operator doesn't
|
|
210
|
+
// see a tick the instant they save the automation.
|
|
211
|
+
startDelay: config.intervalSeconds,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
logger.debug(
|
|
215
|
+
`Scheduled time.interval trigger for automation ${identity.automationId}: every ${config.intervalSeconds}s`,
|
|
216
|
+
);
|
|
217
|
+
return async () => {
|
|
218
|
+
tickHandlers.delete(jobId);
|
|
219
|
+
await queue.cancelRecurring(jobId);
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── template ──────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
const templateConfigSchema = z.object({
|
|
228
|
+
/**
|
|
229
|
+
* Boolean expression evaluated every tick — uses the template
|
|
230
|
+
* engine's condition grammar.
|
|
231
|
+
*
|
|
232
|
+
* The trigger context exposes `{ now }` (ISO string). Anything more
|
|
233
|
+
* elaborate should be reached via the standard automation `variables`
|
|
234
|
+
* block / action pipeline, not from the trigger itself — the
|
|
235
|
+
* tick frequency makes anything I/O-heavy in here costly.
|
|
236
|
+
*/
|
|
237
|
+
value_template: z
|
|
238
|
+
.string()
|
|
239
|
+
.min(1)
|
|
240
|
+
.describe(
|
|
241
|
+
"Boolean template — fires on the false → true edge. Has access to `{ now }`.",
|
|
242
|
+
),
|
|
243
|
+
intervalSeconds: z
|
|
244
|
+
.number()
|
|
245
|
+
.int()
|
|
246
|
+
.min(1)
|
|
247
|
+
.max(86_400)
|
|
248
|
+
.describe("How often to re-evaluate the template (1s – 24h)."),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const templateFiredPayloadSchema = z.object({
|
|
252
|
+
firedAt: z.string(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export type TemplateConfig = z.infer<typeof templateConfigSchema>;
|
|
256
|
+
export type TemplateFiredPayload = z.infer<typeof templateFiredPayloadSchema>;
|
|
257
|
+
|
|
258
|
+
export function createTemplateTrigger(
|
|
259
|
+
deps: BuiltinTriggerDeps,
|
|
260
|
+
): TriggerDefinition<TemplateFiredPayload, TemplateConfig> {
|
|
261
|
+
return {
|
|
262
|
+
id: "template",
|
|
263
|
+
displayName: "Template Condition",
|
|
264
|
+
description:
|
|
265
|
+
"Polls a boolean template on a fixed interval. Fires on the false → true edge so the automation runs once per truthy window, not on every tick.",
|
|
266
|
+
category: "Time",
|
|
267
|
+
icon: "FileCode",
|
|
268
|
+
payloadSchema: templateFiredPayloadSchema,
|
|
269
|
+
configSchema: templateConfigSchema,
|
|
270
|
+
setup: async ({ config, identity, fire, logger }) => {
|
|
271
|
+
const jobId = buildJobId({
|
|
272
|
+
kind: "template",
|
|
273
|
+
automationId: identity.automationId,
|
|
274
|
+
triggerId: identity.triggerId,
|
|
275
|
+
});
|
|
276
|
+
// Each tick lives in this closure — `previousTruthy` survives
|
|
277
|
+
// across ticks for the lifetime of the setup() return. Tearing
|
|
278
|
+
// down resets it via the map-removal.
|
|
279
|
+
let previousTruthy = false;
|
|
280
|
+
let parsed;
|
|
281
|
+
try {
|
|
282
|
+
parsed = parseCondition(config.value_template);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// A malformed expression should fail fast at setup so the
|
|
285
|
+
// operator sees the error in the editor rather than as
|
|
286
|
+
// silently-never-firing.
|
|
287
|
+
throw new Error(
|
|
288
|
+
`template trigger: invalid value_template — ${(error as Error).message}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
tickHandlers.set(jobId, async (tickLogger) => {
|
|
292
|
+
const truthy = (() => {
|
|
293
|
+
try {
|
|
294
|
+
return evaluateBoolean(parsed!, { now: new Date().toISOString() });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
tickLogger.warn(
|
|
297
|
+
`template trigger ${jobId} threw on evaluation — treating as false: ${(error as Error).message}`,
|
|
298
|
+
);
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
})();
|
|
302
|
+
if (truthy && !previousTruthy) {
|
|
303
|
+
await fire({ firedAt: new Date().toISOString() });
|
|
304
|
+
}
|
|
305
|
+
previousTruthy = truthy;
|
|
306
|
+
});
|
|
307
|
+
const queue = deps.queueManager.getQueue<BuiltinTriggerTickPayload>(
|
|
308
|
+
BUILTIN_TRIGGER_QUEUE,
|
|
309
|
+
);
|
|
310
|
+
await queue.scheduleRecurring(
|
|
311
|
+
{ jobId },
|
|
312
|
+
{
|
|
313
|
+
jobId,
|
|
314
|
+
intervalSeconds: config.intervalSeconds,
|
|
315
|
+
startDelay: config.intervalSeconds,
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
logger.debug(
|
|
319
|
+
`Scheduled template trigger for automation ${identity.automationId}: every ${config.intervalSeconds}s`,
|
|
320
|
+
);
|
|
321
|
+
return async () => {
|
|
322
|
+
tickHandlers.delete(jobId);
|
|
323
|
+
await queue.cancelRecurring(jobId);
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Public registry helper ────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Construct all three built-in triggers and register them through the
|
|
333
|
+
* provided callback (which the automation-backend init phase passes
|
|
334
|
+
* straight into the trigger registry).
|
|
335
|
+
*/
|
|
336
|
+
export function registerBuiltinTriggers(args: {
|
|
337
|
+
queueManager: QueueManager;
|
|
338
|
+
pluginMetadata: PluginMetadata;
|
|
339
|
+
registerTrigger: (
|
|
340
|
+
trigger: TriggerDefinition<unknown, unknown>,
|
|
341
|
+
metadata: PluginMetadata,
|
|
342
|
+
) => void;
|
|
343
|
+
}): void {
|
|
344
|
+
const deps: BuiltinTriggerDeps = { queueManager: args.queueManager };
|
|
345
|
+
args.registerTrigger(
|
|
346
|
+
createTimeCronTrigger(deps) as unknown as TriggerDefinition<unknown, unknown>,
|
|
347
|
+
args.pluginMetadata,
|
|
348
|
+
);
|
|
349
|
+
args.registerTrigger(
|
|
350
|
+
createTimeIntervalTrigger(deps) as unknown as TriggerDefinition<unknown, unknown>,
|
|
351
|
+
args.pluginMetadata,
|
|
352
|
+
);
|
|
353
|
+
args.registerTrigger(
|
|
354
|
+
createTemplateTrigger(deps) as unknown as TriggerDefinition<unknown, unknown>,
|
|
355
|
+
args.pluginMetadata,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Test-only helper — exported so the test file can verify the
|
|
361
|
+
* module-scoped map is empty between test cases.
|
|
362
|
+
*/
|
|
363
|
+
export function _resetBuiltinTriggerTickHandlersForTests(): void {
|
|
364
|
+
tickHandlers.clear();
|
|
365
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Action } from "@checkstack/automation-common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The action primitive discriminator kinds. Mirrors the keys in the
|
|
5
|
+
* Home-Assistant-style schema (`action`, `choose`, `parallel`, …).
|
|
6
|
+
*
|
|
7
|
+
* `sequence` is included so a list of actions can be wrapped as a single
|
|
8
|
+
* Action — primarily to support multi-action branches inside `parallel`.
|
|
9
|
+
*/
|
|
10
|
+
export type ActionKind =
|
|
11
|
+
| "action"
|
|
12
|
+
| "choose"
|
|
13
|
+
| "parallel"
|
|
14
|
+
| "delay"
|
|
15
|
+
| "repeat"
|
|
16
|
+
| "variables"
|
|
17
|
+
| "condition"
|
|
18
|
+
| "stop"
|
|
19
|
+
| "wait_for_trigger"
|
|
20
|
+
| "sequence";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decide which primitive an action is by inspecting which discriminator
|
|
24
|
+
* key it carries. The zod schema guarantees exactly one key is present
|
|
25
|
+
* across the union — this function relies on that contract.
|
|
26
|
+
*/
|
|
27
|
+
export function detectActionKind(action: Action): ActionKind {
|
|
28
|
+
const a = action as Record<string, unknown>;
|
|
29
|
+
if ("action" in a) return "action";
|
|
30
|
+
if ("choose" in a) return "choose";
|
|
31
|
+
if ("parallel" in a) return "parallel";
|
|
32
|
+
if ("delay" in a) return "delay";
|
|
33
|
+
if ("repeat" in a) return "repeat";
|
|
34
|
+
if ("variables" in a) return "variables";
|
|
35
|
+
if ("condition" in a) return "condition";
|
|
36
|
+
if ("stop" in a) return "stop";
|
|
37
|
+
if ("wait_for_trigger" in a) return "wait_for_trigger";
|
|
38
|
+
if ("sequence" in a) return "sequence";
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unknown action shape — none of the discriminator keys are present: ${JSON.stringify(
|
|
41
|
+
Object.keys(a),
|
|
42
|
+
)}`,
|
|
43
|
+
);
|
|
44
|
+
}
|