@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,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
+ }