@checkstack/automation-backend 0.2.0 → 0.3.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 (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. package/tsconfig.json +24 -0
package/src/schema.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  jsonb,
6
6
  integer,
7
7
  index,
8
+ uniqueIndex,
8
9
  } from "drizzle-orm/pg-core";
9
10
 
10
11
  /**
@@ -20,6 +21,12 @@ export const automations = pgTable(
20
21
  .$defaultFn(() => crypto.randomUUID()),
21
22
  name: text("name").notNull(),
22
23
  description: text("description"),
24
+ /**
25
+ * Optional grouping label (HA-style "category"). A row field like
26
+ * name/description — NOT part of the definition JSON. `group` is a SQL
27
+ * reserved word; drizzle quotes the column name so it is safe.
28
+ */
29
+ group: text("group"),
23
30
  /** "enabled" | "disabled" */
24
31
  status: text("status").notNull().default("enabled"),
25
32
  /** Validated AutomationDefinition (zod-checked on write). */
@@ -38,6 +45,7 @@ export const automations = pgTable(
38
45
  (t) => ({
39
46
  statusIdx: index("automations_status_idx").on(t.status),
40
47
  managedByIdx: index("automations_managed_by_idx").on(t.managedBy),
48
+ groupIdx: index("automations_group_idx").on(t.group),
41
49
  }),
42
50
  );
43
51
 
@@ -200,19 +208,35 @@ export const automationWaitLocks = pgTable(
200
208
  .references(() => automationRuns.id, { onDelete: "cascade" }),
201
209
  /** Action path of the suspended node — used to resume from the next sibling. */
202
210
  actionPath: text("action_path").notNull(),
203
- /** Discriminator: "trigger" (wait_for_trigger) or "delay" (queue-backed sleep). */
211
+ /**
212
+ * Discriminator:
213
+ * - "trigger" — wait_for_trigger (woken by a matching event)
214
+ * - "delay" — queue-backed sleep
215
+ * - "until" — wait_until: polled condition re-check on an interval
216
+ */
204
217
  kind: text("kind").notNull().default("trigger"),
205
- /** Fully qualified event id being awaited (only meaningful when kind = "trigger"). */
218
+ /**
219
+ * Fully qualified event id being awaited (only meaningful when
220
+ * kind = "trigger"). For "delay" / "until" a synthetic marker.
221
+ */
206
222
  eventId: text("event_id").notNull(),
207
223
  /** Optional context-key filter (e.g. same incidentId). */
208
224
  contextKey: text("context_key"),
209
225
  /** Optional template that must evaluate truthy on the arriving payload. */
210
226
  filterTemplate: text("filter_template"),
227
+ /**
228
+ * Config for `kind = "until"` — the condition to re-evaluate, the
229
+ * poll interval, and the timeout behaviour. JSON because the
230
+ * condition may be a structured object, not just a template string.
231
+ */
232
+ waitConfig: jsonb("wait_config").$type<Record<string, unknown>>(),
211
233
  /**
212
234
  * Absolute deadline. For `kind = "trigger"`: nullable; if set, the
213
235
  * sweeper fails the run when exceeded. For `kind = "delay"`:
214
236
  * required; the firing time after which the run should resume even
215
- * if the queue job is lost.
237
+ * if the queue job is lost. For `kind = "until"`: optional wait
238
+ * deadline (null = wait forever); the sweeper re-ticks "until" locks
239
+ * regardless, so a lost re-check job still self-heals.
216
240
  */
217
241
  timeoutAt: timestamp("timeout_at"),
218
242
  createdAt: timestamp("created_at").defaultNow().notNull(),
@@ -227,6 +251,8 @@ export const automationWaitLocks = pgTable(
227
251
  timeoutIdx: index("automation_wait_locks_timeout_idx").on(t.timeoutAt),
228
252
  /** Powers the run-detail UI's "what are we waiting on?" view. */
229
253
  runIdx: index("automation_wait_locks_run_idx").on(t.runId),
254
+ /** Powers the sweeper's "re-tick all until locks" scan. */
255
+ kindIdx: index("automation_wait_locks_kind_idx").on(t.kind),
230
256
  }),
231
257
  );
232
258
 
@@ -271,6 +297,139 @@ export const automationRunState = pgTable(
271
297
  }),
272
298
  );
273
299
 
300
+ /**
301
+ * Pre-run dwell timers — the durable backing for a trigger's `for:`
302
+ * dwell ("fire only if the matched state still holds after Y").
303
+ *
304
+ * A dwell is a property of the *trigger* and arms BEFORE any run exists,
305
+ * so it cannot reuse `automation_wait_locks` (whose `runId` is NOT NULL).
306
+ * The row is the source of truth; an `automation-dwell` queue job is just
307
+ * the wake signal. Cancellation is DB-side (delete the row) — the queue
308
+ * job pops later and no-ops because the row is gone (constraint 2).
309
+ *
310
+ * - `armedStatus` — the status snapshotted at arm time; the expiry
311
+ * re-confirm proceeds only if the system is still in this status.
312
+ * - `fireAt` — absolute deadline; the queue job carries the matching
313
+ * `startDelay`, and the sweeper catches expired rows whose job was
314
+ * lost.
315
+ * - `payloadSnapshot` / `actorSnapshot` — the firing event's payload +
316
+ * actor, replayed into the run when the dwell fires.
317
+ *
318
+ * Unique on `(automationId, triggerId, contextKey)` so a re-fire re-arms
319
+ * the same row (pushes `fireAt`) rather than stacking duplicate timers.
320
+ */
321
+ export const automationDwellTimers = pgTable(
322
+ "automation_dwell_timers",
323
+ {
324
+ id: text("id")
325
+ .primaryKey()
326
+ .$defaultFn(() => crypto.randomUUID()),
327
+ automationId: text("automation_id")
328
+ .notNull()
329
+ .references(() => automations.id, { onDelete: "cascade" }),
330
+ /** Operator-assigned or derived trigger id this dwell belongs to. */
331
+ triggerId: text("trigger_id").notNull(),
332
+ /** Fully qualified event id that armed the dwell. */
333
+ eventId: text("event_id").notNull(),
334
+ /** Durable context key (typically the systemId). Nullable. */
335
+ contextKey: text("context_key"),
336
+ /**
337
+ * Status the system was in when the dwell armed. The expiry
338
+ * re-confirm fires the run only if the system is still in this
339
+ * status. Null when no live status was resolvable at arm time
340
+ * (re-confirm then proceeds without a status gate).
341
+ */
342
+ armedStatus: text("armed_status"),
343
+ /** Firing event payload, replayed into the run on fire. */
344
+ payloadSnapshot: jsonb("payload_snapshot")
345
+ .notNull()
346
+ .$type<Record<string, unknown>>(),
347
+ /** Firing event actor, replayed into the run on fire. */
348
+ actorSnapshot: jsonb("actor_snapshot")
349
+ .notNull()
350
+ .$type<Record<string, unknown>>(),
351
+ /** Absolute deadline (now + for). */
352
+ fireAt: timestamp("fire_at").notNull(),
353
+ createdAt: timestamp("created_at").defaultNow().notNull(),
354
+ },
355
+ (t) => ({
356
+ /**
357
+ * Addressable for re-arm + cancellation. Postgres treats NULLs as
358
+ * distinct in a unique index, so the rare null-context-key dwell
359
+ * cannot rely on ON CONFLICT to de-dupe; the store handles that case
360
+ * with an explicit find-then-update (see dwell-store.ts). Non-null
361
+ * keys (the common systemId case) de-dupe via this index.
362
+ */
363
+ keyUnique: uniqueIndex("automation_dwell_timers_key_unique").on(
364
+ t.automationId,
365
+ t.triggerId,
366
+ t.contextKey,
367
+ ),
368
+ /** Powers the sweeper's expired-dwell scan. */
369
+ fireAtIdx: index("automation_dwell_timers_fire_at_idx").on(t.fireAt),
370
+ /** Powers cleanup of dwells for a deleted/disabled automation. */
371
+ automationIdx: index("automation_dwell_timers_automation_idx").on(
372
+ t.automationId,
373
+ ),
374
+ }),
375
+ );
376
+
377
+ /**
378
+ * Windowed-count / rate trigger occurrence log.
379
+ *
380
+ * Backs a trigger's `window: { count, minutes, refire }` gate. One row is
381
+ * appended per QUALIFYING occurrence (the structured config gate + the
382
+ * operator's `filter` have already passed) on the single pod that claimed
383
+ * the emission from the work queue, then the engine counts rows within the
384
+ * trailing window to decide whether to fire.
385
+ *
386
+ * Append-log (not a rolling counter) because a SLIDING window is "count rows
387
+ * newer than now - M": a counter has no per-occurrence timestamps to age out
388
+ * and so can only tumble, not slide. This mirrors the former healthcheck
389
+ * `health_check_unhealthy_transitions` shape, relocated into the engine so
390
+ * any trigger can use it (generic mechanism).
391
+ *
392
+ * State-and-scale: the row is durable Postgres; the COUNT read is pure SQL,
393
+ * so every pod computes the same answer. The single INSERT happens on the
394
+ * work-queue-claiming pod, so there is no double-count. Pruned by the
395
+ * stalled-sweeper (rows older than the 24h schema cap are dead). The FK
396
+ * cascade is the entire delete-lifecycle (exactly like `automation_dwell_timers`).
397
+ */
398
+ export const automationWindowEvents = pgTable(
399
+ "automation_window_events",
400
+ {
401
+ id: text("id")
402
+ .primaryKey()
403
+ .$defaultFn(() => crypto.randomUUID()),
404
+ automationId: text("automation_id")
405
+ .notNull()
406
+ .references(() => automations.id, { onDelete: "cascade" }),
407
+ /** Operator-assigned or derived trigger id this window belongs to. */
408
+ triggerId: text("trigger_id").notNull(),
409
+ /** Fully qualified base event id whose occurrences are being counted. */
410
+ eventId: text("event_id").notNull(),
411
+ /** Durable context key (typically the systemId). Nullable. */
412
+ contextKey: text("context_key"),
413
+ /** When the qualifying occurrence happened. */
414
+ occurredAt: timestamp("occurred_at").defaultNow().notNull(),
415
+ },
416
+ (t) => ({
417
+ /**
418
+ * Powers the in-window COUNT(*): rows for one
419
+ * `(automationId, triggerId, contextKey)` whose `occurredAt` is within the
420
+ * trailing window. `occurredAt` is the trailing-range column.
421
+ */
422
+ countIdx: index("automation_window_events_count_idx").on(
423
+ t.automationId,
424
+ t.triggerId,
425
+ t.contextKey,
426
+ t.occurredAt,
427
+ ),
428
+ /** Powers the sweeper's TTL prune scan (delete rows older than the cap). */
429
+ pruneIdx: index("automation_window_events_prune_idx").on(t.occurredAt),
430
+ }),
431
+ );
432
+
274
433
  /**
275
434
  * Subscription-migration failures.
276
435
  *
@@ -308,3 +467,77 @@ export const automationMigrationFailures = pgTable(
308
467
  createdAt: timestamp("created_at").defaultNow().notNull(),
309
468
  },
310
469
  );
470
+
471
+ /**
472
+ * Generic transition log — generalizes Phase 13's
473
+ * `health_check_state_transitions` to ANY entity field. One row is
474
+ * appended per changed tracked field on a real diff; powers
475
+ * `inStateSince` / `inStateForMs` / `transitionCount` for arbitrary
476
+ * entities.
477
+ */
478
+ export const entityTransitions = pgTable(
479
+ "entity_transitions",
480
+ {
481
+ id: text("id")
482
+ .primaryKey()
483
+ .$defaultFn(() => crypto.randomUUID()),
484
+ kind: text("kind").notNull(),
485
+ entityId: text("entity_id").notNull(),
486
+ field: text("field").notNull(),
487
+ fromValue: text("from_value"),
488
+ toValue: text("to_value").notNull(),
489
+ transitionedAt: timestamp("transitioned_at").defaultNow().notNull(),
490
+ },
491
+ (t) => ({
492
+ // Generalizes health_check_state_transitions lookup idx
493
+ // (healthcheck schema.ts:176): "most recent transition into status X".
494
+ lookupIdx: index("entity_transitions_lookup_idx").on(
495
+ t.kind,
496
+ t.entityId,
497
+ t.field,
498
+ t.transitionedAt,
499
+ ),
500
+ }),
501
+ );
502
+
503
+ /**
504
+ * Wake-index — the reactive `wait_until` dependency set (reactive
505
+ * automation engine §8.1). A suspended `wait_until` extracts the
506
+ * `state.*` refs its condition reads and inserts one row here per ref,
507
+ * all pointing at the owning `automation_wait_locks` row (`kind = "until"`).
508
+ *
509
+ * Rather than overloading the single `(eventId, contextKey)` columns on
510
+ * `automation_wait_locks`, this child table lets one wait depend on a SET
511
+ * of refs across any kinds. Stage-1 routing answers "which waits depend on
512
+ * this just-changed `kind:id` ref?" with an indexed intersection join (§8.2).
513
+ *
514
+ * `ref` is `${kind}:${id}` (e.g. "incident:abc", "health:sys-1"), or the
515
+ * kind-level wildcard `${kind}:*` when extraction couldn't resolve a
516
+ * concrete id (§8.3 — the wait then wakes on ANY change of that kind and
517
+ * re-evaluates). The `(waitLockId, ref)` pair is uniquely indexed so a
518
+ * concurrent arm race can't double-insert the same dependency (§14.4 #3).
519
+ */
520
+ export const automationWakeIndex = pgTable(
521
+ "automation_wake_index",
522
+ {
523
+ id: text("id")
524
+ .primaryKey()
525
+ .$defaultFn(() => crypto.randomUUID()),
526
+ waitLockId: text("wait_lock_id")
527
+ .notNull()
528
+ .references(() => automationWaitLocks.id, { onDelete: "cascade" }),
529
+ /** The dependency ref: `${kind}:${id}` or the kind wildcard `${kind}:*`. */
530
+ ref: text("ref").notNull(),
531
+ },
532
+ (t) => ({
533
+ /** Stage-1 lookup: "which waits depend on this just-changed ref?" */
534
+ refIdx: index("automation_wake_index_ref_idx").on(t.ref),
535
+ /** Cascade-friendly per-lock lookups + the arm-race uniqueness guard. */
536
+ lockIdx: index("automation_wake_index_lock_idx").on(t.waitLockId),
537
+ /** A wait depends on each distinct ref at most once (ON CONFLICT arm). */
538
+ lockRefUnique: uniqueIndex("automation_wake_index_lock_ref_unique").on(
539
+ t.waitLockId,
540
+ t.ref,
541
+ ),
542
+ }),
543
+ );
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildReplayArtifacts,
4
+ buildReplayContext,
5
+ } from "./script-test-replay";
6
+
7
+ describe("buildReplayArtifacts", () => {
8
+ test("keys by artifact type and by action id", () => {
9
+ const out = buildReplayArtifacts([
10
+ { artifactType: "jira.issue", actionId: "create_issue", data: { key: "P-1" } },
11
+ ]);
12
+ expect(out["jira.issue"]).toEqual({ key: "P-1" });
13
+ expect(out.create_issue).toEqual({ key: "P-1" });
14
+ });
15
+
16
+ test("omits the action-id key when actionId is null", () => {
17
+ const out = buildReplayArtifacts([
18
+ { artifactType: "shell_result", actionId: null, data: { exitCode: 0 } },
19
+ ]);
20
+ expect(out.shell_result).toEqual({ exitCode: 0 });
21
+ expect(Object.keys(out)).toEqual(["shell_result"]);
22
+ });
23
+
24
+ test("later artifacts of the same key win", () => {
25
+ const out = buildReplayArtifacts([
26
+ { artifactType: "jira.issue", actionId: null, data: { key: "OLD" } },
27
+ { artifactType: "jira.issue", actionId: null, data: { key: "NEW" } },
28
+ ]);
29
+ expect(out["jira.issue"]).toEqual({ key: "NEW" });
30
+ });
31
+ });
32
+
33
+ describe("buildReplayContext", () => {
34
+ const run = {
35
+ triggerEventId: "incident.incident.created",
36
+ triggerPayload: { id: "INC-7", severity: "high" },
37
+ };
38
+
39
+ test("builds trigger + artifacts; empty var/no repeat without a snapshot", () => {
40
+ const ctx = buildReplayContext({
41
+ run,
42
+ artifacts: [
43
+ { artifactType: "jira.issue", actionId: "j", data: { key: "P-9" } },
44
+ ],
45
+ });
46
+ expect(ctx.trigger).toEqual({
47
+ event: "incident.incident.created",
48
+ payload: { id: "INC-7", severity: "high" },
49
+ });
50
+ expect(ctx.artifacts).toEqual({
51
+ "jira.issue": { key: "P-9" },
52
+ j: { key: "P-9" },
53
+ });
54
+ expect(ctx.var).toEqual({});
55
+ expect("repeat" in ctx).toBe(false);
56
+ });
57
+
58
+ test("pulls var and repeat from an in-flight scope snapshot", () => {
59
+ const ctx = buildReplayContext({
60
+ run,
61
+ artifacts: [],
62
+ scopeSnapshot: {
63
+ vars: { attempts: 2 },
64
+ repeat: { index: 1, item: { name: "b" } },
65
+ },
66
+ });
67
+ expect(ctx.var).toEqual({ attempts: 2 });
68
+ expect(ctx.repeat).toEqual({ index: 1, item: { name: "b" } });
69
+ });
70
+
71
+ test("ignores a malformed repeat in the snapshot", () => {
72
+ const ctx = buildReplayContext({
73
+ run,
74
+ artifacts: [],
75
+ scopeSnapshot: { repeat: { item: "no-index" } },
76
+ });
77
+ expect("repeat" in ctx).toBe(false);
78
+ });
79
+
80
+ test("ignores a non-object vars snapshot", () => {
81
+ const ctx = buildReplayContext({
82
+ run,
83
+ artifacts: [],
84
+ scopeSnapshot: { vars: "nope" },
85
+ });
86
+ expect(ctx.var).toEqual({});
87
+ });
88
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Replay support for the in-UI script test panel.
3
+ *
4
+ * Reconstructs an editable {@link ScriptTestContext} from a real automation
5
+ * run so an operator can debug a `run_script` / `run_shell` action against
6
+ * the data a past (or in-flight) run actually saw, instead of a synthetic
7
+ * sample.
8
+ *
9
+ * Data sources, in order of durability:
10
+ * - `automation_runs` — always present; gives `trigger.event` +
11
+ * `trigger.payload`.
12
+ * - `automation_artifacts` — persisted per run; rebuilt into the
13
+ * `artifacts` record keyed by artifact type (and by action id), matching
14
+ * what `run_script` exposes as `context.artifacts`.
15
+ * - `automation_run_state.scopeSnapshot` — present only while the run is
16
+ * in-flight / suspended (cleared at terminal status). When available it
17
+ * supplies `var` and `repeat`; otherwise those are empty, which is fine
18
+ * for editing a fresh test.
19
+ *
20
+ * Pure data-shaping over already-fetched rows so it is unit-testable without
21
+ * a DB.
22
+ */
23
+ import type { ScriptTestContext } from "@checkstack/automation-common";
24
+
25
+ export interface ReplayRunRow {
26
+ triggerEventId: string;
27
+ triggerPayload: Record<string, unknown>;
28
+ }
29
+
30
+ export interface ReplayArtifactRow {
31
+ artifactType: string;
32
+ actionId: string | null;
33
+ data: Record<string, unknown>;
34
+ }
35
+
36
+ /**
37
+ * Build the `artifacts` record a script sees, keyed by artifact type and
38
+ * (when set) by action id. Mirrors the dispatch engine's exposure so a
39
+ * replayed context matches runtime. Later artifacts of the same key win
40
+ * (callers pass rows in chronological order).
41
+ */
42
+ export function buildReplayArtifacts(
43
+ rows: ReplayArtifactRow[],
44
+ ): Record<string, unknown> {
45
+ const artifacts: Record<string, unknown> = {};
46
+ for (const row of rows) {
47
+ artifacts[row.artifactType] = row.data;
48
+ if (row.actionId) {
49
+ artifacts[row.actionId] = row.data;
50
+ }
51
+ }
52
+ return artifacts;
53
+ }
54
+
55
+ function isRecord(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value);
57
+ }
58
+
59
+ function extractRepeat(
60
+ snapshot: Record<string, unknown> | undefined,
61
+ ): ScriptTestContext["repeat"] {
62
+ const repeat = snapshot?.repeat;
63
+ if (!isRecord(repeat)) return undefined;
64
+ if (typeof repeat.index !== "number") return undefined;
65
+ return {
66
+ index: repeat.index,
67
+ ...(repeat.item === undefined ? {} : { item: repeat.item }),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Assemble the editable replay context from already-fetched run rows.
73
+ *
74
+ * @param run - the `automation_runs` row (trigger source of truth)
75
+ * @param artifacts - persisted artifacts for the run, chronological
76
+ * @param scopeSnapshot - `automation_run_state.scopeSnapshot` if the run is
77
+ * still in-flight / suspended, else undefined
78
+ */
79
+ export function buildReplayContext({
80
+ run,
81
+ artifacts,
82
+ scopeSnapshot,
83
+ }: {
84
+ run: ReplayRunRow;
85
+ artifacts: ReplayArtifactRow[];
86
+ scopeSnapshot?: Record<string, unknown>;
87
+ }): ScriptTestContext {
88
+ const vars = isRecord(scopeSnapshot?.vars) ? scopeSnapshot.vars : {};
89
+ const repeat = extractRepeat(scopeSnapshot);
90
+
91
+ return {
92
+ trigger: {
93
+ event: run.triggerEventId,
94
+ payload: run.triggerPayload,
95
+ },
96
+ artifacts: buildReplayArtifacts(artifacts),
97
+ var: vars,
98
+ ...(repeat ? { repeat } : {}),
99
+ };
100
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { flattenScopeToShellEnv } from "./script-test-shell-env";
3
+
4
+ describe("flattenScopeToShellEnv (test context)", () => {
5
+ test("emits trigger.event even for an empty context", () => {
6
+ expect(flattenScopeToShellEnv(undefined)).toEqual({
7
+ CHECKSTACK_TRIGGER_EVENT: "",
8
+ });
9
+ });
10
+
11
+ test("flattens nested payload objects into dotted env keys", () => {
12
+ const env = flattenScopeToShellEnv({
13
+ trigger: { event: "incident.created", payload: { id: "INC-1", nested: { level: 2 } } },
14
+ });
15
+ expect(env.CHECKSTACK_TRIGGER_EVENT).toBe("incident.created");
16
+ expect(env.CHECKSTACK_TRIGGER_PAYLOAD_ID).toBe("INC-1");
17
+ expect(env.CHECKSTACK_TRIGGER_PAYLOAD_NESTED_LEVEL).toBe("2");
18
+ });
19
+
20
+ test("indexes array elements and emits a newline-joined whole-array var", () => {
21
+ const env = flattenScopeToShellEnv({
22
+ trigger: { event: "e", payload: { tags: ["a", "b"] } },
23
+ });
24
+ expect(env.CHECKSTACK_TRIGGER_PAYLOAD_TAGS).toBe("a\nb");
25
+ expect(env.CHECKSTACK_TRIGGER_PAYLOAD_TAGS_0).toBe("a");
26
+ expect(env.CHECKSTACK_TRIGGER_PAYLOAD_TAGS_1).toBe("b");
27
+ });
28
+
29
+ test("flattens artifacts, vars and repeat", () => {
30
+ const env = flattenScopeToShellEnv({
31
+ trigger: { event: "e" },
32
+ artifacts: { jira_issue: { key: "PROJ-1" } },
33
+ var: { count: 5 },
34
+ repeat: { index: 3, item: { name: "x" } },
35
+ });
36
+ expect(env.CHECKSTACK_ARTIFACT_JIRA_ISSUE_KEY).toBe("PROJ-1");
37
+ expect(env.CHECKSTACK_VAR_COUNT).toBe("5");
38
+ expect(env.CHECKSTACK_REPEAT_INDEX).toBe("3");
39
+ expect(env.CHECKSTACK_REPEAT_ITEM_NAME).toBe("x");
40
+ });
41
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Flatten a sample test context into the `CHECKSTACK_*` environment
3
+ * variables a `run_shell` script receives.
4
+ *
5
+ * Mirrors `flattenScopeToShellEnv` in `@checkstack/integration-script-backend`
6
+ * (which operates on a real `ActionRunScope`) but works on the editable
7
+ * {@link ScriptTestContext} the test panel sends. Names are produced via the
8
+ * shared {@link toShellEnvKey} rule so the injected vars match the editor's
9
+ * `$` autocomplete suggestions exactly.
10
+ */
11
+ import { toShellEnvKey } from "@checkstack/automation-common";
12
+ import type { ScriptTestContext } from "./script-test";
13
+
14
+ function isScalar(value: unknown): value is string | number | boolean {
15
+ return (
16
+ typeof value === "string" ||
17
+ typeof value === "number" ||
18
+ typeof value === "boolean"
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Walk a value, writing one env var per scalar leaf keyed by its dotted
24
+ * path. Plain objects recurse; arrays emit a single newline-separated var
25
+ * at the current path AND recurse into each element by numeric index.
26
+ */
27
+ function flattenInto(
28
+ value: unknown,
29
+ path: string,
30
+ out: Record<string, string>,
31
+ ): void {
32
+ if (isScalar(value)) {
33
+ out[toShellEnvKey(path)] = String(value);
34
+ return;
35
+ }
36
+ if (value === null || value === undefined) {
37
+ return;
38
+ }
39
+ if (Array.isArray(value)) {
40
+ out[toShellEnvKey(path)] = value
41
+ .map((element) =>
42
+ isScalar(element) ? String(element) : JSON.stringify(element),
43
+ )
44
+ .join("\n");
45
+ for (const [index, element] of value.entries()) {
46
+ flattenInto(element, `${path}[${index}]`, out);
47
+ }
48
+ return;
49
+ }
50
+ if (typeof value === "object") {
51
+ for (const [key, child] of Object.entries(
52
+ value as Record<string, unknown>,
53
+ )) {
54
+ flattenInto(child, `${path}.${key}`, out);
55
+ }
56
+ return;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Build the `CHECKSTACK_*` env var map for a shell test run from the
62
+ * editable sample context. Paths mirror the editor's scope field paths
63
+ * (`trigger.event`, `trigger.payload.*`, `artifact.<type>.*`, `var.*`,
64
+ * `repeat.*`).
65
+ */
66
+ export function flattenScopeToShellEnv(
67
+ context: ScriptTestContext | undefined,
68
+ ): Record<string, string> {
69
+ const out: Record<string, string> = {
70
+ [toShellEnvKey("trigger.event")]: context?.trigger?.event ?? "",
71
+ };
72
+
73
+ flattenInto(context?.trigger?.payload ?? {}, "trigger.payload", out);
74
+
75
+ for (const [type, data] of Object.entries(context?.artifacts ?? {})) {
76
+ flattenInto(data, `artifact.${type}`, out);
77
+ }
78
+ for (const [name, value] of Object.entries(context?.var ?? {})) {
79
+ flattenInto(value, `var.${name}`, out);
80
+ }
81
+ if (context?.repeat) {
82
+ out[toShellEnvKey("repeat.index")] = String(context.repeat.index);
83
+ if (context.repeat.item !== undefined) {
84
+ flattenInto(context.repeat.item, "repeat.item", out);
85
+ }
86
+ }
87
+
88
+ return out;
89
+ }