@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,65 @@
1
+ /**
2
+ * Path navigation utilities for the action tree.
3
+ *
4
+ * Action paths are serialized as strings like
5
+ * `actions[1].choose[0].sequence[2]` and parsed back into structured
6
+ * `ActionPath` arrays.
7
+ */
8
+ import type { ActionPath } from "./types";
9
+
10
+ /**
11
+ * Parse a path string emitted by `formatActionPath`.
12
+ *
13
+ * "actions[1].choose[0].sequence[2]"
14
+ * → ["actions", 1, "choose", 0, "sequence", 2]
15
+ */
16
+ export function parseActionPath(serialized: string): ActionPath {
17
+ const segments: Array<string | number> = [];
18
+ // Either a `word` (key segment) or `[42]` (index segment); the literal
19
+ // `.` separators between them are intentionally ignored.
20
+ const pattern = /([a-z_]+)|\[(\d+)\]/gi;
21
+ let match: RegExpExecArray | null;
22
+ while ((match = pattern.exec(serialized)) !== null) {
23
+ if (match[1] !== undefined) {
24
+ segments.push(match[1]);
25
+ } else if (match[2] !== undefined) {
26
+ segments.push(Number(match[2]));
27
+ }
28
+ }
29
+ return segments;
30
+ }
31
+
32
+ /**
33
+ * Detect whether a serialized path contains any container segment that
34
+ * is forbidden for `wait_for_trigger` / `delay` in v1.
35
+ *
36
+ * Waits and delays are supported in the top-level `actions:` list and
37
+ * inside any depth of nested `choose` branches. They are forbidden
38
+ * inside `parallel` and `repeat` because suspension semantics for those
39
+ * containers require branch-level coordination state which is deferred
40
+ * to a follow-up. Validation throws at edit time and the runtime
41
+ * rejects at execution time as a defence in depth.
42
+ */
43
+ export function isSuspensionAllowedAtPath(path: ActionPath): boolean {
44
+ for (const segment of path) {
45
+ if (segment === "parallel" || segment === "repeat") return false;
46
+ }
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Pop one container worth of segments off the end of the path.
52
+ *
53
+ * Used to walk back out after finishing a nested sequence: the dispatch
54
+ * engine knows the inner sequence is done, and asks the path util to
55
+ * give it the container's own path so it can find a sibling at that
56
+ * level.
57
+ *
58
+ * ["actions", 1, "choose", 0, "sequence", 2] → ["actions", 1, "choose", 0]
59
+ * ["actions", 1, "choose", 0] → ["actions", 1]
60
+ */
61
+ export function popContainer(path: ActionPath): ActionPath {
62
+ // Container segments come in pairs (key, index). We pop two at a time.
63
+ if (path.length < 2) return path;
64
+ return path.slice(0, -2);
65
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ createDefaultFilterRegistry,
4
+ type TemplateContext,
5
+ } from "@checkstack/template-engine";
6
+ import { renderConfig } from "./render";
7
+
8
+ const filters = createDefaultFilterRegistry();
9
+ const context: TemplateContext = {
10
+ trigger: { payload: { title: "Outage" } },
11
+ };
12
+
13
+ describe("renderConfig", () => {
14
+ it("renders {{ }} in ordinary fields", () => {
15
+ const out = renderConfig({
16
+ config: { message: "Incident {{ trigger.payload.title }} fired" },
17
+ jsonSchema: { properties: { message: { type: "string" } } },
18
+ context,
19
+ filters,
20
+ });
21
+ expect(out).toEqual({ message: "Incident Outage fired" });
22
+ });
23
+
24
+ it("leaves native-code fields (shell/typescript/javascript) un-rendered", () => {
25
+ const out = renderConfig({
26
+ config: {
27
+ script: 'echo "{{ trigger.payload.title }}"',
28
+ message: "hi {{ trigger.payload.title }}",
29
+ },
30
+ jsonSchema: {
31
+ properties: {
32
+ script: { "x-editor-types": ["shell"] },
33
+ message: { type: "string" },
34
+ },
35
+ },
36
+ context,
37
+ filters,
38
+ });
39
+ // The code field's template markers stay literal; the plain field renders.
40
+ expect(out).toEqual({
41
+ script: 'echo "{{ trigger.payload.title }}"',
42
+ message: "hi Outage",
43
+ });
44
+ });
45
+
46
+ it("treats typescript and javascript editor types as code too", () => {
47
+ const out = renderConfig({
48
+ config: { ts: "const x = {{ trigger.payload.title }};" },
49
+ jsonSchema: { properties: { ts: { "x-editor-types": ["typescript"] } } },
50
+ context,
51
+ filters,
52
+ });
53
+ expect(out).toEqual({ ts: "const x = {{ trigger.payload.title }};" });
54
+ });
55
+
56
+ it("falls back to plain rendering when no schema is supplied", () => {
57
+ const out = renderConfig({
58
+ config: { a: "{{ trigger.payload.title }}" },
59
+ jsonSchema: undefined,
60
+ context,
61
+ filters,
62
+ });
63
+ expect(out).toEqual({ a: "Outage" });
64
+ });
65
+
66
+ it("passes non-object config straight through the renderer", () => {
67
+ const out = renderConfig({
68
+ config: "{{ trigger.payload.title }}",
69
+ jsonSchema: undefined,
70
+ context,
71
+ filters,
72
+ });
73
+ expect(out).toBe("Outage");
74
+ });
75
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Recursive template rendering for action `config` objects.
3
+ *
4
+ * Action configs are arbitrary JSON: strings, numbers, booleans, arrays,
5
+ * objects. Strings may carry `{{ }}` interpolation that should be
6
+ * rendered against the current scope. This helper walks the object tree
7
+ * and renders every string value in place, leaving non-strings alone.
8
+ *
9
+ * Errors from individual templates surface up — the dispatch engine
10
+ * catches and converts them into a failed step.
11
+ */
12
+ import {
13
+ evaluate as evaluateExpression,
14
+ parseCondition,
15
+ parseTemplate,
16
+ render as renderTemplate,
17
+ type FilterRegistry,
18
+ type TemplateContext,
19
+ } from "@checkstack/template-engine";
20
+
21
+ /**
22
+ * Editor-type tags that denote a native-code field. Such fields are
23
+ * passed through verbatim by {@link renderConfig} — their `{{ }}`, if
24
+ * any, is NOT expanded, because script actions read run data from the
25
+ * typed `context` (TS) or injected env vars (shell), not from templates.
26
+ */
27
+ const CODE_EDITOR_TYPES = new Set(["shell", "typescript", "javascript"]);
28
+
29
+ /**
30
+ * Narrow an unknown JSON value to a string-keyed record after a runtime
31
+ * type check. The single cast is the canonical safe-narrowing pattern —
32
+ * JSON schemas arrive as `Record<string, unknown>`, so reading nested
33
+ * shape requires asserting the post-check type.
34
+ */
35
+ function asRecord(value: unknown): Record<string, unknown> {
36
+ return value !== null && typeof value === "object" && !Array.isArray(value)
37
+ ? (value as Record<string, unknown>)
38
+ : {};
39
+ }
40
+
41
+ function hasCodeEditorType(propSchema: unknown): boolean {
42
+ const types = asRecord(propSchema)["x-editor-types"];
43
+ return (
44
+ Array.isArray(types) &&
45
+ types.some((t) => typeof t === "string" && CODE_EDITOR_TYPES.has(t))
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Render an action's top-level `config`, skipping native-code fields.
51
+ *
52
+ * Identical to {@link renderValue} for every field EXCEPT those whose
53
+ * JSON schema declares an `x-editor-types` of `shell` / `typescript` /
54
+ * `javascript`: those are returned verbatim so any `{{ }}` stays literal.
55
+ * This removes the deprecated "templates inside a code field" path so it
56
+ * can't be used by accident — code fields get run data via `context` /
57
+ * env vars instead. Falls back to {@link renderValue} when the config
58
+ * isn't a plain object or no schema is available.
59
+ */
60
+ export function renderConfig(args: {
61
+ config: unknown;
62
+ jsonSchema: Record<string, unknown> | undefined;
63
+ context: TemplateContext;
64
+ filters: FilterRegistry;
65
+ }): unknown {
66
+ const { config, jsonSchema, context, filters } = args;
67
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
68
+ return renderValue(config, context, filters);
69
+ }
70
+ const properties = asRecord(jsonSchema?.["properties"]);
71
+ const out: Record<string, unknown> = {};
72
+ for (const [key, value] of Object.entries(asRecord(config))) {
73
+ out[key] = hasCodeEditorType(properties[key])
74
+ ? value
75
+ : renderValue(value, context, filters);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Render any value, recursing into objects and arrays. Strings are
82
+ * passed through the template engine; everything else is returned as-is.
83
+ */
84
+ export function renderValue(
85
+ value: unknown,
86
+ context: TemplateContext,
87
+ filters: FilterRegistry,
88
+ ): unknown {
89
+ if (typeof value === "string") {
90
+ return renderString(value, context, filters);
91
+ }
92
+ if (Array.isArray(value)) {
93
+ return value.map((v) => renderValue(v, context, filters));
94
+ }
95
+ if (value && typeof value === "object") {
96
+ const out: Record<string, unknown> = {};
97
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
98
+ out[k] = renderValue(v, context, filters);
99
+ }
100
+ return out;
101
+ }
102
+ return value;
103
+ }
104
+
105
+ /**
106
+ * Render a single string template. If the string contains no `{{`
107
+ * markers, this is effectively a passthrough (the template engine
108
+ * tokenises trivial cases very cheaply).
109
+ *
110
+ * For pure expressions like `"{{ count + 1 }}"` the renderer returns a
111
+ * stringified value — callers that need the typed primitive should use
112
+ * `renderExpression` instead.
113
+ */
114
+ export function renderString(
115
+ template: string,
116
+ context: TemplateContext,
117
+ filters: FilterRegistry,
118
+ ): string {
119
+ if (!template.includes("{{")) return template;
120
+ return renderTemplate(parseTemplate(template), context, { filters });
121
+ }
122
+
123
+ /**
124
+ * Evaluate an expression and return the raw (un-stringified) value.
125
+ *
126
+ * Use this when the dispatched action's schema expects a typed primitive
127
+ * (e.g. `delay: { seconds: "{{ trigger.payload.retryAfter }}" }` — the
128
+ * action expects a number, not a string).
129
+ */
130
+ export function renderExpression(
131
+ source: string,
132
+ context: TemplateContext,
133
+ filters: FilterRegistry,
134
+ ): unknown {
135
+ return evaluateExpression(parseCondition(source), context, { filters });
136
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Drizzle-backed durable scope + heartbeat persistence and Postgres
3
+ * advisory-lock helpers for the dispatch engine.
4
+ *
5
+ * Scope snapshots are written after every successful step so a future
6
+ * process can resume the run exactly where the prior process left off.
7
+ * Heartbeats let the stalled-run sweeper distinguish healthy in-flight
8
+ * runs from runs whose host crashed mid-execution.
9
+ *
10
+ * Advisory locks ensure at most one instance is executing a given run
11
+ * at a time. The lock auto-releases when the holding connection dies —
12
+ * exactly what we want during crash recovery.
13
+ */
14
+ import { lt, eq, sql } from "drizzle-orm";
15
+ import type { SafeDatabase } from "@checkstack/backend-api";
16
+
17
+ import { automationRunState } from "../schema";
18
+
19
+ export interface RunStateSnapshot {
20
+ scopeSnapshot: Record<string, unknown>;
21
+ lastActionPath: string | null;
22
+ lastHeartbeatAt: Date;
23
+ }
24
+
25
+ export interface RunStateStore {
26
+ /**
27
+ * Write or update the per-run durable state. `lastActionPath` is the
28
+ * path of the most recently completed action — resume walks the tree
29
+ * looking for this path and treats the action at it as already done.
30
+ */
31
+ upsert(input: {
32
+ runId: string;
33
+ scopeSnapshot: Record<string, unknown>;
34
+ lastActionPath: string | null;
35
+ }): Promise<void>;
36
+
37
+ load(runId: string): Promise<RunStateSnapshot | undefined>;
38
+
39
+ /** Drop the state row — done at terminal run status. */
40
+ clear(runId: string): Promise<void>;
41
+
42
+ /** Bump only the heartbeat. Used by long-running container handlers. */
43
+ heartbeat(runId: string): Promise<void>;
44
+
45
+ /**
46
+ * Run ids whose heartbeat is older than `threshold`. Returned in
47
+ * heartbeat-ascending order so the sweeper processes the most
48
+ * stale first.
49
+ */
50
+ findStalledRunIds(threshold: Date): Promise<string[]>;
51
+
52
+ /**
53
+ * Try to acquire a Postgres session-level advisory lock for the run.
54
+ * Returns true on acquisition. The lock auto-releases when the holding
55
+ * DB session closes (e.g. on process crash), so dead instances don't
56
+ * leak locks.
57
+ */
58
+ tryAdvisoryLock(runId: string): Promise<boolean>;
59
+
60
+ /** Release a previously-acquired advisory lock. */
61
+ releaseAdvisoryLock(runId: string): Promise<void>;
62
+ }
63
+
64
+ type Schema = { automationRunState: typeof automationRunState };
65
+
66
+ export function createRunStateStore(
67
+ db: SafeDatabase<Schema>,
68
+ ): RunStateStore {
69
+ return {
70
+ async upsert(input) {
71
+ await db
72
+ .insert(automationRunState)
73
+ .values({
74
+ runId: input.runId,
75
+ scopeSnapshot: input.scopeSnapshot,
76
+ lastActionPath: input.lastActionPath,
77
+ })
78
+ .onConflictDoUpdate({
79
+ target: automationRunState.runId,
80
+ set: {
81
+ scopeSnapshot: input.scopeSnapshot,
82
+ lastActionPath: input.lastActionPath,
83
+ lastHeartbeatAt: new Date(),
84
+ updatedAt: new Date(),
85
+ },
86
+ });
87
+ },
88
+
89
+ async load(runId) {
90
+ const rows = await db
91
+ .select()
92
+ .from(automationRunState)
93
+ .where(eq(automationRunState.runId, runId))
94
+ .limit(1);
95
+ const row = rows[0];
96
+ if (!row) return;
97
+ return {
98
+ scopeSnapshot: row.scopeSnapshot,
99
+ lastActionPath: row.lastActionPath,
100
+ lastHeartbeatAt: row.lastHeartbeatAt,
101
+ };
102
+ },
103
+
104
+ async clear(runId) {
105
+ await db
106
+ .delete(automationRunState)
107
+ .where(eq(automationRunState.runId, runId));
108
+ },
109
+
110
+ async heartbeat(runId) {
111
+ await db
112
+ .update(automationRunState)
113
+ .set({ lastHeartbeatAt: new Date() })
114
+ .where(eq(automationRunState.runId, runId));
115
+ },
116
+
117
+ async findStalledRunIds(threshold) {
118
+ const rows = await db
119
+ .select({ runId: automationRunState.runId })
120
+ .from(automationRunState)
121
+ .where(lt(automationRunState.lastHeartbeatAt, threshold))
122
+ .orderBy(automationRunState.lastHeartbeatAt);
123
+ return rows.map((r) => r.runId);
124
+ },
125
+
126
+ async tryAdvisoryLock(runId) {
127
+ // hashtextextended returns int8 in Postgres, which pg_try_advisory_lock
128
+ // accepts directly. Using a deterministic hash means the same runId
129
+ // always maps to the same lock key across processes.
130
+ const result = await db.execute<{ ok: boolean }>(sql`
131
+ SELECT pg_try_advisory_lock(hashtextextended(${runId}, 0)) AS ok
132
+ `);
133
+ const rows = result as unknown as { rows: Array<{ ok: boolean }> };
134
+ return Boolean(rows.rows?.[0]?.ok);
135
+ },
136
+
137
+ async releaseAdvisoryLock(runId) {
138
+ await db.execute(sql`
139
+ SELECT pg_advisory_unlock(hashtextextended(${runId}, 0))
140
+ `);
141
+ },
142
+ };
143
+ }