@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,27 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1780038470237,
9
+ "tag": "0000_acoustic_diamondback",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1780039854282,
16
+ "tag": "0001_mute_vindicator",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1780046001689,
23
+ "tag": "0002_silky_omega_red",
24
+ "breakpoints": true
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ // Driver / credentials are wired via env at migration time; this is
8
+ // generation config only.
9
+ dbCredentials: {
10
+ url: process.env.DATABASE_URL ?? "postgres://localhost/checkstack",
11
+ },
12
+ });
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@checkstack/automation-backend",
3
+ "version": "0.2.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "checkstack": {
8
+ "type": "backend"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsgo -b",
12
+ "generate": "drizzle-kit generate",
13
+ "lint": "bun run lint:code",
14
+ "lint:code": "eslint . --max-warnings 0",
15
+ "test": "bun test"
16
+ },
17
+ "dependencies": {
18
+ "@checkstack/automation-common": "0.1.0",
19
+ "@checkstack/backend-api": "0.17.1",
20
+ "@checkstack/command-backend": "0.1.30",
21
+ "@checkstack/integration-common": "0.5.0",
22
+ "@checkstack/notification-common": "1.2.0",
23
+ "@checkstack/common": "0.11.0",
24
+ "@checkstack/queue-api": "0.3.5",
25
+ "@checkstack/signal-common": "0.2.4",
26
+ "@checkstack/template-engine": "0.1.0",
27
+ "@orpc/server": "^1.13.2",
28
+ "drizzle-orm": "^0.45.0",
29
+ "yaml": "^2.6.1",
30
+ "zod": "^4.2.1"
31
+ },
32
+ "devDependencies": {
33
+ "@checkstack/drizzle-helper": "0.0.5",
34
+ "@checkstack/scripts": "0.3.3",
35
+ "@checkstack/test-utils-backend": "0.1.30",
36
+ "@checkstack/tsconfig": "0.0.7",
37
+ "@types/node": "^20.0.0",
38
+ "drizzle-kit": "^0.31.10",
39
+ "typescript": "^5.0.0"
40
+ }
41
+ }
@@ -0,0 +1,83 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import { toJsonSchema } from "@checkstack/backend-api";
3
+ import type {
4
+ ActionDefinition,
5
+ RegisteredAction,
6
+ } from "./action-types";
7
+
8
+ /**
9
+ * Registry for automation actions. Plugins register actions through
10
+ * `automationActionExtensionPoint`. The dispatch engine resolves actions
11
+ * by their fully qualified id when running provider-action steps.
12
+ */
13
+ export interface ActionRegistry {
14
+ register<TConfig, TArtifact = unknown>(
15
+ definition: ActionDefinition<TConfig, TArtifact>,
16
+ pluginMetadata: PluginMetadata,
17
+ ): void;
18
+
19
+ getActions(): RegisteredAction[];
20
+ getAction(qualifiedId: string): RegisteredAction | undefined;
21
+ getActionsByCategory(): Map<string, RegisteredAction[]>;
22
+ hasAction(qualifiedId: string): boolean;
23
+ }
24
+
25
+ export function createActionRegistry(): ActionRegistry {
26
+ const actions = new Map<string, RegisteredAction>();
27
+
28
+ return {
29
+ register<TConfig, TArtifact = unknown>(
30
+ definition: ActionDefinition<TConfig, TArtifact>,
31
+ pluginMetadata: PluginMetadata,
32
+ ): void {
33
+ const qualifiedId = `${pluginMetadata.pluginId}.${definition.id}`;
34
+ if (actions.has(qualifiedId)) {
35
+ throw new Error(
36
+ `Action ${qualifiedId} already registered — likely a duplicate registration in ${pluginMetadata.pluginId}.`,
37
+ );
38
+ }
39
+
40
+ const configJsonSchema = toJsonSchema(definition.config.schema);
41
+
42
+ const registered: RegisteredAction<TConfig, TArtifact> = {
43
+ ...definition,
44
+ qualifiedId,
45
+ ownerPluginId: pluginMetadata.pluginId,
46
+ configJsonSchema,
47
+ // Qualify the produced artifact type id with the owning plugin,
48
+ // exactly as the artifact-type registry qualifies its own ids, so
49
+ // the two always agree (a hand-written full id would drift). The
50
+ // local `consumes` ids stay as declared and are qualified against
51
+ // this same plugin when resolved at run time.
52
+ produces: definition.produces
53
+ ? `${pluginMetadata.pluginId}.${definition.produces}`
54
+ : undefined,
55
+ };
56
+
57
+ actions.set(qualifiedId, registered as RegisteredAction);
58
+ },
59
+
60
+ getActions(): RegisteredAction[] {
61
+ return [...actions.values()];
62
+ },
63
+
64
+ getAction(qualifiedId: string): RegisteredAction | undefined {
65
+ return actions.get(qualifiedId);
66
+ },
67
+
68
+ getActionsByCategory(): Map<string, RegisteredAction[]> {
69
+ const byCategory = new Map<string, RegisteredAction[]>();
70
+ for (const action of actions.values()) {
71
+ const category = action.category ?? "Uncategorized";
72
+ const existing = byCategory.get(category) ?? [];
73
+ existing.push(action);
74
+ byCategory.set(category, existing);
75
+ }
76
+ return byCategory;
77
+ },
78
+
79
+ hasAction(qualifiedId: string): boolean {
80
+ return actions.has(qualifiedId);
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Action / Trigger / Artifact-Type definition contracts.
3
+ *
4
+ * Plugins contribute to the automation platform by implementing these
5
+ * interfaces and registering them through the corresponding extension
6
+ * points (see `extension-points.ts`). All backend-only types live here;
7
+ * frontend code uses the zod schemas exported from
8
+ * `@checkstack/automation-common`.
9
+ */
10
+ import { z } from "zod";
11
+ import type {
12
+ Hook,
13
+ Logger,
14
+ ServiceRef,
15
+ Versioned,
16
+ } from "@checkstack/backend-api";
17
+ import type { Actor, LucideIconName } from "@checkstack/common";
18
+
19
+ // ─── Artifact types ────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Reference to an artifact type.
23
+ *
24
+ * In a {@link ActionDefinition} (`produces` / `consumes`) this is the
25
+ * **local** artifact-type id (e.g. `"issue"`) — the action registry
26
+ * prefixes it with the owning plugin's id at registration, exactly as it
27
+ * qualifies the action's own `id`, yielding the fully-qualified id (e.g.
28
+ * `"integration-jira.issue"`). On a {@link RegisteredAction}, `produces`
29
+ * is already qualified.
30
+ */
31
+ export type ArtifactTypeRef = string;
32
+
33
+ /**
34
+ * Definition of an artifact type. Plugins register these so their actions
35
+ * can declare `produces` / `consumes` against a typed schema rather than
36
+ * an opaque blob.
37
+ *
38
+ * The `data` field of an artifact is validated against this schema before
39
+ * persistence.
40
+ *
41
+ * @template T — the runtime shape of the artifact `data`.
42
+ */
43
+ export interface ArtifactTypeDefinition<T = unknown> {
44
+ /** Local id; namespaced on registration to `{pluginId}.{id}`. */
45
+ id: string;
46
+ /** Display name for UI listings. */
47
+ displayName: string;
48
+ description?: string;
49
+ /** Zod schema for the artifact's `data` payload. */
50
+ schema: z.ZodType<T>;
51
+ }
52
+
53
+ export interface RegisteredArtifactType<T = unknown>
54
+ extends ArtifactTypeDefinition<T> {
55
+ /** Fully qualified id (`{pluginId}.{id}`). */
56
+ qualifiedId: string;
57
+ ownerPluginId: string;
58
+ /** JSON Schema view of `schema` for UI consumption. */
59
+ jsonSchema: Record<string, unknown>;
60
+ }
61
+
62
+ // ─── Triggers ──────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Optional setup callback used by non-hook triggers (cron, interval,
66
+ * polling templates). The trigger registers its own listening mechanism
67
+ * (queue scheduled jobs, intervals, etc.) and calls `fire` when it should
68
+ * emit an event into the dispatch engine.
69
+ *
70
+ * Returns a cleanup function that must tear the listener down when the
71
+ * automation is disabled or deleted.
72
+ */
73
+ export type TriggerSetupFn<TPayload, TConfig> = (params: {
74
+ /** Per-automation trigger config (e.g. cron pattern). */
75
+ config: TConfig;
76
+ /** Stable identity of the automation + trigger combination this setup is for. */
77
+ identity: {
78
+ automationId: string;
79
+ triggerId: string;
80
+ };
81
+ /** Fire the trigger. Dispatch engine will route the payload. */
82
+ fire: (payload: TPayload) => Promise<void>;
83
+ logger: Logger;
84
+ }) => Promise<TriggerTeardown>;
85
+
86
+ /**
87
+ * Cleanup callback returned by `setup`. Called when the automation that
88
+ * subscribed is disabled, deleted, or its definition changes.
89
+ */
90
+ export type TriggerTeardown = () => Promise<void>;
91
+
92
+ /**
93
+ * Trigger definition. A plugin describes an event the platform can react
94
+ * to. Two flavours:
95
+ *
96
+ * - **Hook-backed:** provide a `hook` reference. The automation backend
97
+ * subscribes via `onHook` in work-queue mode and routes each emission
98
+ * through `fire` for any automation that selected this trigger.
99
+ * - **Setup-backed:** provide `setup` (and optional `configSchema`). The
100
+ * automation backend invokes `setup` per-automation; the trigger
101
+ * manages its own emission mechanism (cron schedule, interval, etc.).
102
+ *
103
+ * Exactly one of `hook` / `setup` should be set. Setting neither means
104
+ * the trigger can only be fired through `manualRun`.
105
+ */
106
+ export interface TriggerDefinition<
107
+ TPayload = unknown,
108
+ TConfig = void,
109
+ > {
110
+ /** Local id; namespaced on registration to `{pluginId}.{id}`. */
111
+ id: string;
112
+ displayName: string;
113
+ description?: string;
114
+ /** UI grouping. Defaults to "Uncategorized". */
115
+ category?: string;
116
+ icon?: LucideIconName;
117
+
118
+ /** Zod schema for the trigger's payload. */
119
+ payloadSchema: z.ZodType<TPayload>;
120
+ /**
121
+ * Optional config schema. Required when `setup` is provided and the
122
+ * trigger needs per-automation configuration (e.g. cron pattern).
123
+ */
124
+ configSchema?: z.ZodType<TConfig>;
125
+
126
+ /**
127
+ * Extract a durable lookup key from the payload — typically the entity
128
+ * id this trigger is "about" (e.g. `incidentId` for incident triggers,
129
+ * `systemId` for system triggers).
130
+ *
131
+ * Used to scope artifact lookups and to match `wait_for_trigger` waits
132
+ * to the right incoming event.
133
+ */
134
+ contextKey?: (payload: TPayload) => string | undefined;
135
+
136
+ /** Hook-backed flavour. */
137
+ hook?: Hook<TPayload>;
138
+ /** Setup-backed flavour. */
139
+ setup?: TriggerSetupFn<TPayload, TConfig>;
140
+ }
141
+
142
+ export interface RegisteredTrigger<TPayload = unknown, TConfig = unknown>
143
+ extends Omit<TriggerDefinition<TPayload, TConfig>, "configSchema"> {
144
+ qualifiedId: string;
145
+ ownerPluginId: string;
146
+ payloadJsonSchema: Record<string, unknown>;
147
+ configJsonSchema?: Record<string, unknown>;
148
+ /** Preserved here so the dispatch engine can re-validate config on load. */
149
+ configSchema?: z.ZodType<TConfig>;
150
+ }
151
+
152
+ // ─── Actions ───────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Result returned by an action's `execute` callback.
156
+ *
157
+ * - `success: true` and an optional `artifact` means the action did its
158
+ * job; the platform persists the artifact (typed against the declared
159
+ * `produces` schema) and continues dispatch.
160
+ * - `success: false` triggers either a halt or, if the action has
161
+ * `continue_on_error: true`, logged-and-skipped behaviour.
162
+ * - `retryAfterMs` is honoured by the queue when set.
163
+ */
164
+ export interface ActionResult<TArtifact = unknown> {
165
+ success: boolean;
166
+ /** Produced artifact data; validated against the action's `produces` type. */
167
+ artifact?: TArtifact;
168
+ /** External system identifier — exposed in run-step logs for audit. */
169
+ externalId?: string;
170
+ error?: string;
171
+ retryAfterMs?: number;
172
+ }
173
+
174
+ /**
175
+ * The full run scope handed to an action's `execute`, mirroring the
176
+ * shape the dispatch engine exposes to `{{ }}` template rendering. Lets
177
+ * broad-context actions (notably the script actions) build a typed
178
+ * `context` object or flatten the scope into shell env vars, without
179
+ * having to declare every artifact type in `consumes`.
180
+ *
181
+ * Distinct from {@link ActionExecutionContext.consumedArtifacts}, which
182
+ * is the narrow, explicitly-declared `consumes` subset.
183
+ */
184
+ export interface ActionRunScope {
185
+ /** The trigger that started this run, plus its raw payload. */
186
+ trigger: {
187
+ /**
188
+ * The id of the specific trigger declaration that fired. Distinguishes
189
+ * triggers within an automation - including two triggers on the same
190
+ * `event` - so scripts/templates can branch on which one fired.
191
+ */
192
+ id: string;
193
+ /** Fully-qualified trigger event id (e.g. `incident.created`). */
194
+ event: string;
195
+ /** Who/what caused the event (system, user, application, or service). */
196
+ actor: Actor;
197
+ /** Trigger payload exactly as emitted. */
198
+ payload: Record<string, unknown>;
199
+ };
200
+ /**
201
+ * Artifacts produced by upstream actions in this run, keyed by artifact
202
+ * type (`jira.issue`) and, when the producing action set an `id`, by
203
+ * that id too.
204
+ */
205
+ artifacts: Record<string, unknown>;
206
+ /** Variables declared upstream via `variables` actions. */
207
+ vars: Record<string, unknown>;
208
+ /**
209
+ * Present only when the action runs inside a `repeat` block: the
210
+ * current iteration index and (for `for_each`) the current item.
211
+ */
212
+ repeat?: { index: number; item?: unknown };
213
+ }
214
+
215
+ /**
216
+ * Execution context handed to an action's `execute` callback.
217
+ *
218
+ * `config` is already rendered — any `{{ }}` interpolation in the source
219
+ * automation has been resolved against the run scope before this call.
220
+ * Note: fields flagged as native-code editors (`x-editor-types` of
221
+ * `shell` / `typescript` / `javascript`) are passed through verbatim and
222
+ * are NOT template-rendered — script actions read run data from `scope`
223
+ * (TS `context`) or injected env vars (shell), not from `{{ }}`.
224
+ *
225
+ * `consumedArtifacts` is the dispatch engine's resolution of upstream
226
+ * artifacts the action declared in `consumes`. Lookup is by artifact type
227
+ * within the current run's scope (`(automationId, contextKey, type)`).
228
+ */
229
+ export interface ActionExecutionContext<TConfig = unknown> {
230
+ /** Resolved config — templates rendered against the run scope. */
231
+ config: TConfig;
232
+ /**
233
+ * Upstream artifacts the action consumes, keyed by their type
234
+ * (`jira.issue`, `teams.message`, …). Undefined entries mean "not
235
+ * found" — the action decides how to react.
236
+ */
237
+ consumedArtifacts: Record<string, unknown>;
238
+ /**
239
+ * Full run scope (trigger + all upstream artifacts + vars). Use this
240
+ * for actions that need broad context (e.g. a script action building a
241
+ * `context` object or shell env vars). Prefer `consumedArtifacts` when
242
+ * you only need specific declared artifact types.
243
+ *
244
+ * Always populated by the dispatch engine at run time; optional only so
245
+ * unit tests that exercise an action's `execute` directly may omit it.
246
+ */
247
+ scope?: ActionRunScope;
248
+
249
+ /** Run identity. */
250
+ runId: string;
251
+ automationId: string;
252
+ /** Durable lookup key for the current run (typically `incidentId`). */
253
+ contextKey: string | null;
254
+
255
+ /** Scoped logger — log lines surface in the run-detail UI. */
256
+ logger: Logger;
257
+ /**
258
+ * Resolve a platform service. Mirrors the standard plugin DI pattern so
259
+ * actions can use, e.g., the integration connection store.
260
+ */
261
+ getService: <T>(ref: ServiceRef<T>) => Promise<T>;
262
+ }
263
+
264
+ /**
265
+ * Action definition. Plugins register actions to expose callable work to
266
+ * the automation editor.
267
+ *
268
+ * @template TConfig — per-action-invocation configuration.
269
+ * @template TArtifact — shape of artifact this action produces, if any.
270
+ */
271
+ export interface ActionDefinition<
272
+ TConfig = unknown,
273
+ TArtifact = unknown,
274
+ > {
275
+ /** Local id; namespaced on registration to `{pluginId}.{id}`. */
276
+ id: string;
277
+ displayName: string;
278
+ description?: string;
279
+ /** UI grouping. Defaults to "Uncategorized". */
280
+ category?: string;
281
+ icon?: LucideIconName;
282
+
283
+ /** Per-invocation configuration schema. Versioned for safe migration. */
284
+ config: Versioned<TConfig>;
285
+
286
+ /**
287
+ * Local id of the artifact type this action produces, if any (e.g.
288
+ * `"issue"`). The registry qualifies it with the owning plugin id.
289
+ */
290
+ produces?: ArtifactTypeRef;
291
+ /**
292
+ * Local ids of the artifact types this action consumes — resolved from
293
+ * upstream scope within the owning plugin's namespace. `consumedArtifacts`
294
+ * is keyed by these same local ids.
295
+ */
296
+ consumes?: ArtifactTypeRef[];
297
+
298
+ /**
299
+ * Connection-backed actions (e.g. Jira) set this to the fully-qualified
300
+ * integration provider id (`{pluginId}.{providerId}`) whose connection
301
+ * store + `getConnectionOptions` resolvers supply this action's config
302
+ * dropdowns. When present, the editor renders a connection picker and
303
+ * bridges the action's `x-options-resolver` config fields to that
304
+ * provider. Derive it from the provider plugin's own `pluginMetadata`
305
+ * rather than a hardcoded string so it can't drift if the plugin is
306
+ * renamed.
307
+ */
308
+ connectionProviderId?: string;
309
+
310
+ /**
311
+ * Run the action.
312
+ */
313
+ execute: (
314
+ context: ActionExecutionContext<TConfig>,
315
+ ) => Promise<ActionResult<TArtifact>>;
316
+ }
317
+
318
+ export interface RegisteredAction<TConfig = unknown, TArtifact = unknown>
319
+ extends ActionDefinition<TConfig, TArtifact> {
320
+ qualifiedId: string;
321
+ ownerPluginId: string;
322
+ /** JSON Schema view of the config schema. */
323
+ configJsonSchema: Record<string, unknown>;
324
+ }
@@ -0,0 +1,140 @@
1
+ import { and, desc, eq, isNull } from "drizzle-orm";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import { automationArtifacts } from "./schema";
4
+
5
+ /**
6
+ * Inputs for recording a new artifact.
7
+ */
8
+ export interface RecordArtifactInput {
9
+ automationId: string;
10
+ runId: string;
11
+ stepId: string;
12
+ /** Operator-assigned action id, if any. */
13
+ actionId: string | null;
14
+ /** Fully qualified artifact type (e.g. "jira.issue"). */
15
+ artifactType: string;
16
+ /** Free-form data — caller is responsible for prior validation. */
17
+ data: Record<string, unknown>;
18
+ /** Durable lookup key (typically incidentId). */
19
+ contextKey: string | null;
20
+ }
21
+
22
+ /**
23
+ * Lookup parameters for finding an artifact. Each field narrows the
24
+ * search; at minimum `automationId` is required. The most recent match
25
+ * (by `created_at`) is returned, optionally restricted to still-open
26
+ * artifacts (i.e. `closed_at` is NULL).
27
+ */
28
+ export interface FindArtifactInput {
29
+ automationId: string;
30
+ contextKey?: string | null;
31
+ artifactType?: string;
32
+ actionId?: string;
33
+ /** When true, ignore artifacts that have been closed. Defaults to true. */
34
+ onlyOpen?: boolean;
35
+ }
36
+
37
+ export interface PersistedArtifact {
38
+ id: string;
39
+ automationId: string;
40
+ runId: string;
41
+ stepId: string;
42
+ actionId: string | null;
43
+ artifactType: string;
44
+ data: Record<string, unknown>;
45
+ contextKey: string | null;
46
+ closedAt: Date | null;
47
+ createdAt: Date;
48
+ }
49
+
50
+ /**
51
+ * Persistence interface for artifacts. The dispatch engine writes a row
52
+ * on every successful action that declares `produces`; consumers
53
+ * (downstream actions, the run-detail UI) read through `find` and
54
+ * `markClosed`.
55
+ */
56
+ export interface ArtifactStore {
57
+ record(input: RecordArtifactInput): Promise<PersistedArtifact>;
58
+ find(input: FindArtifactInput): Promise<PersistedArtifact | undefined>;
59
+ findAll(input: FindArtifactInput): Promise<PersistedArtifact[]>;
60
+ markClosed(artifactId: string): Promise<void>;
61
+ }
62
+
63
+ export function createArtifactStore(
64
+ db: SafeDatabase<{ automationArtifacts: typeof automationArtifacts }>,
65
+ ): ArtifactStore {
66
+ const mapRow = (
67
+ row: typeof automationArtifacts.$inferSelect,
68
+ ): PersistedArtifact => ({
69
+ id: row.id,
70
+ automationId: row.automationId,
71
+ runId: row.runId,
72
+ stepId: row.stepId,
73
+ actionId: row.actionId,
74
+ artifactType: row.artifactType,
75
+ data: row.data,
76
+ contextKey: row.contextKey,
77
+ closedAt: row.closedAt,
78
+ createdAt: row.createdAt,
79
+ });
80
+
81
+ return {
82
+ async record(input) {
83
+ const [row] = await db
84
+ .insert(automationArtifacts)
85
+ .values({
86
+ automationId: input.automationId,
87
+ runId: input.runId,
88
+ stepId: input.stepId,
89
+ actionId: input.actionId,
90
+ artifactType: input.artifactType,
91
+ data: input.data,
92
+ contextKey: input.contextKey,
93
+ })
94
+ .returning();
95
+ if (!row) {
96
+ throw new Error(
97
+ "Failed to record artifact — insert returned no rows",
98
+ );
99
+ }
100
+ return mapRow(row);
101
+ },
102
+
103
+ async find(input) {
104
+ const rows = await this.findAll(input);
105
+ return rows[0];
106
+ },
107
+
108
+ async findAll(input) {
109
+ const onlyOpen = input.onlyOpen ?? true;
110
+ const filters = [eq(automationArtifacts.automationId, input.automationId)];
111
+ if (input.contextKey !== undefined && input.contextKey !== null) {
112
+ filters.push(eq(automationArtifacts.contextKey, input.contextKey));
113
+ }
114
+ if (input.artifactType) {
115
+ filters.push(eq(automationArtifacts.artifactType, input.artifactType));
116
+ }
117
+ if (input.actionId) {
118
+ filters.push(eq(automationArtifacts.actionId, input.actionId));
119
+ }
120
+ if (onlyOpen) {
121
+ filters.push(isNull(automationArtifacts.closedAt));
122
+ }
123
+
124
+ const rows = await db
125
+ .select()
126
+ .from(automationArtifacts)
127
+ .where(and(...filters))
128
+ .orderBy(desc(automationArtifacts.createdAt));
129
+
130
+ return rows.map((row) => mapRow(row));
131
+ },
132
+
133
+ async markClosed(artifactId) {
134
+ await db
135
+ .update(automationArtifacts)
136
+ .set({ closedAt: new Date() })
137
+ .where(eq(automationArtifacts.id, artifactId));
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,64 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import { toJsonSchema } from "@checkstack/backend-api";
3
+ import type {
4
+ ArtifactTypeDefinition,
5
+ RegisteredArtifactType,
6
+ } from "./action-types";
7
+
8
+ /**
9
+ * Registry for artifact types — typed payloads that actions produce and
10
+ * consume. Used by the dispatch engine to validate artifact `data` against
11
+ * the declaring plugin's schema, and by the editor to surface field-aware
12
+ * intellisense for `artifacts.<type>.*` references.
13
+ */
14
+ export interface ArtifactTypeRegistry {
15
+ register<T>(
16
+ definition: ArtifactTypeDefinition<T>,
17
+ pluginMetadata: PluginMetadata,
18
+ ): void;
19
+
20
+ getArtifactTypes(): RegisteredArtifactType[];
21
+ getArtifactType(qualifiedId: string): RegisteredArtifactType | undefined;
22
+ hasArtifactType(qualifiedId: string): boolean;
23
+ }
24
+
25
+ export function createArtifactTypeRegistry(): ArtifactTypeRegistry {
26
+ const types = new Map<string, RegisteredArtifactType>();
27
+
28
+ return {
29
+ register<T>(
30
+ definition: ArtifactTypeDefinition<T>,
31
+ pluginMetadata: PluginMetadata,
32
+ ): void {
33
+ const qualifiedId = `${pluginMetadata.pluginId}.${definition.id}`;
34
+ if (types.has(qualifiedId)) {
35
+ throw new Error(
36
+ `Artifact type ${qualifiedId} already registered — likely a duplicate registration in ${pluginMetadata.pluginId}.`,
37
+ );
38
+ }
39
+
40
+ const jsonSchema = toJsonSchema(definition.schema);
41
+
42
+ const registered: RegisteredArtifactType<T> = {
43
+ ...definition,
44
+ qualifiedId,
45
+ ownerPluginId: pluginMetadata.pluginId,
46
+ jsonSchema,
47
+ };
48
+
49
+ types.set(qualifiedId, registered as RegisteredArtifactType);
50
+ },
51
+
52
+ getArtifactTypes(): RegisteredArtifactType[] {
53
+ return [...types.values()];
54
+ },
55
+
56
+ getArtifactType(qualifiedId: string): RegisteredArtifactType | undefined {
57
+ return types.get(qualifiedId);
58
+ },
59
+
60
+ hasArtifactType(qualifiedId: string): boolean {
61
+ return types.has(qualifiedId);
62
+ },
63
+ };
64
+ }