@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
@@ -0,0 +1,205 @@
1
+ /**
2
+ * One-time migration: rewrite legacy `healthcheck.flapping_detected` triggers
3
+ * onto the generic windowed-count gate.
4
+ *
5
+ * The pre-derived `flapping_detected` trigger (+ its `{ transitions,
6
+ * windowMinutes }` config) was removed. Flapping is now expressed as a window
7
+ * over the raw `healthcheck.system_health_changed` change event:
8
+ *
9
+ * event: healthcheck.flapping_detected
10
+ * config: { transitions, windowMinutes }
11
+ * ↓
12
+ * event: healthcheck.system_health_changed
13
+ * filter: trigger.payload.newStatus != "healthy"
14
+ * window: { count: transitions ?? 3, minutes: windowMinutes ?? 60, refire: "once" }
15
+ *
16
+ * Targets USER-created automations only — the seeded `auto-incident:*` rows
17
+ * were already deleted (migration 0008). Idempotent: an automation whose
18
+ * triggers no longer reference the legacy event is left untouched.
19
+ *
20
+ * Safe-option note on a PRE-EXISTING filter: a legacy flapping trigger could
21
+ * (rarely) already carry an operator `filter`. The unhealthy-transition filter
22
+ * is the load-bearing semantic of the new flapping shape, so we REPLACE any
23
+ * pre-existing filter with the canonical one rather than silently AND-combining
24
+ * (which could change behaviour in surprising ways). This is logged per row so
25
+ * an operator can re-add a bespoke clause if they had one.
26
+ */
27
+ import { eq } from "drizzle-orm";
28
+ import type { Logger, SafeDatabase } from "@checkstack/backend-api";
29
+
30
+ import * as schema from "../schema";
31
+
32
+ type Db = SafeDatabase<typeof schema>;
33
+
34
+ /** Legacy + new event ids (string-compared against stored `trigger.event`). */
35
+ export const LEGACY_FLAPPING_EVENT = "healthcheck.flapping_detected";
36
+ export const HEALTH_CHANGED_EVENT = "healthcheck.system_health_changed";
37
+
38
+ /** Canonical filter for the windowed flapping shape (unhealthy transitions). */
39
+ export const FLAPPING_FILTER = 'trigger.payload.newStatus != "healthy"';
40
+
41
+ /** Defaults applied when a legacy trigger carried no / partial config. */
42
+ export const DEFAULT_FLAPPING_COUNT = 3;
43
+ export const DEFAULT_FLAPPING_MINUTES = 60;
44
+
45
+ interface RewriteOutcome {
46
+ /** The (possibly) rewritten definition. */
47
+ definition: Record<string, unknown>;
48
+ /** Number of flapping triggers rewritten in this definition. */
49
+ rewritten: number;
50
+ /** True if any rewritten trigger had a pre-existing filter we replaced. */
51
+ replacedFilter: boolean;
52
+ }
53
+
54
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value)
56
+ ? (value as Record<string, unknown>)
57
+ : undefined;
58
+ }
59
+
60
+ function readPositiveInt(value: unknown, fallback: number): number {
61
+ return typeof value === "number" && Number.isInteger(value) && value >= 1
62
+ ? value
63
+ : fallback;
64
+ }
65
+
66
+ /**
67
+ * Rewrite every legacy `flapping_detected` trigger in a stored automation
68
+ * definition. Pure — no I/O — so the mapping (config → window, defaults,
69
+ * filter replacement) is unit-testable in isolation. Returns the new
70
+ * definition plus counters; `rewritten === 0` means "leave the row untouched".
71
+ */
72
+ export function rewriteFlappingTriggers(
73
+ definition: Record<string, unknown>,
74
+ ): RewriteOutcome {
75
+ const triggers = definition["triggers"];
76
+ if (!Array.isArray(triggers)) {
77
+ return { definition, rewritten: 0, replacedFilter: false };
78
+ }
79
+
80
+ let rewritten = 0;
81
+ let replacedFilter = false;
82
+
83
+ const nextTriggers = triggers.map((raw) => {
84
+ const trigger = asRecord(raw);
85
+ if (!trigger || trigger["event"] !== LEGACY_FLAPPING_EVENT) return raw;
86
+
87
+ const config = asRecord(trigger["config"]);
88
+ const count = readPositiveInt(config?.["transitions"], DEFAULT_FLAPPING_COUNT);
89
+ const minutes = readPositiveInt(
90
+ config?.["windowMinutes"],
91
+ DEFAULT_FLAPPING_MINUTES,
92
+ );
93
+
94
+ if (typeof trigger["filter"] === "string") replacedFilter = true;
95
+ rewritten += 1;
96
+
97
+ // Drop `config`; set the new event + canonical filter + window. Preserve
98
+ // any other fields the operator set (id, for, etc.).
99
+ const { config: _dropped, ...rest } = trigger;
100
+ void _dropped;
101
+ return {
102
+ ...rest,
103
+ event: HEALTH_CHANGED_EVENT,
104
+ filter: FLAPPING_FILTER,
105
+ window: { count, minutes, refire: "once" },
106
+ };
107
+ });
108
+
109
+ if (rewritten === 0) {
110
+ return { definition, rewritten: 0, replacedFilter: false };
111
+ }
112
+ return {
113
+ definition: { ...definition, triggers: nextTriggers },
114
+ rewritten,
115
+ replacedFilter,
116
+ };
117
+ }
118
+
119
+ export interface FlappingMigrationResult {
120
+ /** Automations scanned. */
121
+ scanned: number;
122
+ /** Automations whose definition was rewritten + persisted. */
123
+ migrated: number;
124
+ /** Total flapping triggers rewritten across all rows. */
125
+ triggersRewritten: number;
126
+ }
127
+
128
+ /**
129
+ * Scan every automation and rewrite legacy flapping triggers. Idempotent:
130
+ * rows with no flapping trigger are skipped; rows already migrated have no
131
+ * `flapping_detected` event and are skipped too. Safe to run on every boot.
132
+ *
133
+ * After the rewrite, logs a WARNING if any enabled automation still references
134
+ * the legacy event (should never happen post-rewrite — a leftover indicates a
135
+ * definition the migration couldn't parse).
136
+ */
137
+ export async function runFlappingAutomationMigration(args: {
138
+ db: Db;
139
+ logger: Logger;
140
+ }): Promise<FlappingMigrationResult> {
141
+ const { db, logger } = args;
142
+
143
+ const rows = await db
144
+ .select({
145
+ id: schema.automations.id,
146
+ name: schema.automations.name,
147
+ status: schema.automations.status,
148
+ definition: schema.automations.definition,
149
+ })
150
+ .from(schema.automations);
151
+
152
+ let migrated = 0;
153
+ let triggersRewritten = 0;
154
+ // Enabled rows that STILL reference the dead event after the rewrite pass —
155
+ // i.e. the rewrite couldn't convert them (unparseable definition). These
156
+ // would never fire again, so they get a per-boot warning.
157
+ const leftover: string[] = [];
158
+
159
+ for (const row of rows) {
160
+ const outcome = rewriteFlappingTriggers(row.definition);
161
+ if (outcome.rewritten === 0) {
162
+ // Untouched: flag it only if it (still) points at the dead event while
163
+ // enabled — the rewrite found nothing to convert there.
164
+ const triggers = row.definition["triggers"];
165
+ if (
166
+ row.status === "enabled" &&
167
+ Array.isArray(triggers) &&
168
+ triggers.some((t) => asRecord(t)?.["event"] === LEGACY_FLAPPING_EVENT)
169
+ ) {
170
+ leftover.push(row.id);
171
+ }
172
+ continue;
173
+ }
174
+
175
+ await db
176
+ .update(schema.automations)
177
+ .set({ definition: outcome.definition, updatedAt: new Date() })
178
+ .where(eq(schema.automations.id, row.id));
179
+
180
+ migrated += 1;
181
+ triggersRewritten += outcome.rewritten;
182
+ logger.info(
183
+ `flapping migration: rewrote ${outcome.rewritten} flapping trigger(s) on automation ${row.id} (${row.name}) → windowed system_health_changed${
184
+ outcome.replacedFilter
185
+ ? "; a pre-existing trigger filter was replaced with the canonical unhealthy-transition filter"
186
+ : ""
187
+ }`,
188
+ );
189
+ }
190
+
191
+ if (leftover.length > 0) {
192
+ logger.warn(
193
+ `flapping migration: ${leftover.length} enabled automation(s) still reference the removed "${LEGACY_FLAPPING_EVENT}" event after migration and will not fire: ${leftover.join(
194
+ ", ",
195
+ )}`,
196
+ );
197
+ }
198
+
199
+ if (migrated > 0) {
200
+ logger.debug(
201
+ `flapping migration: migrated ${migrated} automation(s) (${triggersRewritten} trigger(s))`,
202
+ );
203
+ }
204
+ return { scanned: rows.length, migrated, triggersRewritten };
205
+ }
@@ -69,6 +69,7 @@ const sampleTrigger: TriggerDefinition<{ incidentId: string }> = {
69
69
  payloadSchema: samplePayloadSchema,
70
70
  hook: sampleHook,
71
71
  contextKey: (p) => p.incidentId,
72
+ contextKeyLabel: "incident",
72
73
  };
73
74
 
74
75
  const sampleAction: ActionDefinition<{ message: string }, { id: string }> = {
@@ -97,6 +98,7 @@ const sampleDefinition: AutomationDefinition = {
97
98
  conditions: [],
98
99
  actions: [],
99
100
  mode: "single",
101
+ concurrency_scope: "automation",
100
102
  max_runs: 10,
101
103
  };
102
104
 
@@ -117,6 +119,7 @@ function createInMemoryAutomationStore(): {
117
119
  id,
118
120
  name: input.name,
119
121
  description: input.description,
122
+ group: input.group,
120
123
  status: input.status,
121
124
  definition: input.definition,
122
125
  createdAt: now(),
@@ -132,6 +135,8 @@ function createInMemoryAutomationStore(): {
132
135
  ...existing,
133
136
  name: input.name ?? existing.name,
134
137
  description: input.description ?? existing.description,
138
+ // null clears, undefined leaves unchanged, string sets.
139
+ group: input.group === undefined ? existing.group : (input.group ?? undefined),
135
140
  status: input.status ?? existing.status,
136
141
  definition: input.definition ?? existing.definition,
137
142
  updatedAt: now(),
@@ -160,13 +165,20 @@ function createInMemoryAutomationStore(): {
160
165
  const limit = filter?.limit ?? 50;
161
166
  const offset = filter?.offset ?? 0;
162
167
  const all = [...rows.values()].filter(
163
- (a) => !filter?.status || a.status === filter.status,
168
+ (a) =>
169
+ (!filter?.status || a.status === filter.status) &&
170
+ (!filter?.group || a.group === filter.group),
164
171
  );
165
172
  return {
166
173
  items: all.slice(offset, offset + limit),
167
174
  total: all.length,
168
175
  };
169
176
  },
177
+ async listGroups() {
178
+ const groups = new Set<string>();
179
+ for (const a of rows.values()) if (a.group) groups.add(a.group);
180
+ return [...groups].sort();
181
+ },
170
182
  async findEnabledByTriggerEvent() {
171
183
  return [];
172
184
  },
@@ -239,6 +251,7 @@ interface RouterHarness {
239
251
  signalService: MockSignalService;
240
252
  automationRows: Map<string, Automation>;
241
253
  db: ReturnType<typeof createMockDbForRouter>;
254
+ dispatchDeps: ReturnType<typeof makeDispatchDeps>["deps"];
242
255
  }
243
256
 
244
257
  function createMockDbForRouter() {
@@ -295,6 +308,7 @@ function makeRouter(): RouterHarness {
295
308
  signalService,
296
309
  automationRows,
297
310
  db,
311
+ dispatchDeps,
298
312
  };
299
313
  }
300
314
 
@@ -348,6 +362,56 @@ describe("Automation Router", () => {
348
362
  expect(res.total).toBe(1);
349
363
  expect(res.items[0]?.name).toBe("A");
350
364
  });
365
+
366
+ it("threads the group filter through to the store", async () => {
367
+ await h.automationStore.create({
368
+ name: "A",
369
+ group: "Alerting",
370
+ status: "enabled",
371
+ definition: sampleDefinition,
372
+ });
373
+ await h.automationStore.create({
374
+ name: "B",
375
+ group: "Networking",
376
+ status: "enabled",
377
+ definition: sampleDefinition,
378
+ });
379
+ const res = await call(
380
+ h.router.listAutomations,
381
+ { limit: 50, offset: 0, group: "Alerting" },
382
+ { context: h.context },
383
+ );
384
+ expect(res.total).toBe(1);
385
+ expect(res.items[0]?.name).toBe("A");
386
+ });
387
+ });
388
+
389
+ describe("listAutomationGroups", () => {
390
+ it("returns the distinct non-null group values", async () => {
391
+ await h.automationStore.create({
392
+ name: "A",
393
+ group: "Networking",
394
+ status: "enabled",
395
+ definition: sampleDefinition,
396
+ });
397
+ await h.automationStore.create({
398
+ name: "B",
399
+ group: "Alerting",
400
+ status: "enabled",
401
+ definition: sampleDefinition,
402
+ });
403
+ await h.automationStore.create({
404
+ name: "C",
405
+ status: "enabled",
406
+ definition: sampleDefinition,
407
+ });
408
+ const res = await call(
409
+ h.router.listAutomationGroups,
410
+ {},
411
+ { context: h.context },
412
+ );
413
+ expect(res.groups).toEqual(["Alerting", "Networking"]);
414
+ });
351
415
  });
352
416
 
353
417
  describe("getAutomation", () => {
@@ -521,6 +585,9 @@ describe("Automation Router", () => {
521
585
  expect(res.items).toHaveLength(1);
522
586
  expect(res.items[0]?.qualifiedId).toBe("test.incident.created");
523
587
  expect(res.items[0]?.payloadSchema).toBeDefined();
588
+ // contextKeyLabel flows through to the wire format for the editor's
589
+ // window "Partition by" default hint.
590
+ expect(res.items[0]?.contextKeyLabel).toBe("incident");
524
591
  });
525
592
  });
526
593
 
@@ -721,4 +788,118 @@ describe("Automation Router", () => {
721
788
  expect(typeof res.error?.column).toBe("number");
722
789
  });
723
790
  });
791
+
792
+ describe("testScript", () => {
793
+ it("runs a shell script against the flattened sample context", async () => {
794
+ const res = await call(
795
+ h.router.testScript,
796
+ {
797
+ kind: "shell",
798
+ script: 'echo "$CHECKSTACK_TRIGGER_PAYLOAD_ID"',
799
+ context: { trigger: { event: "incident.created", payload: { id: "INC-7" } } },
800
+ timeoutMs: 10_000,
801
+ },
802
+ { context: h.context },
803
+ );
804
+ expect(res.exitCode).toBe(0);
805
+ expect(res.stdout).toBe("INC-7");
806
+ expect(res.error).toBeUndefined();
807
+ expect(res.timedOut).toBe(false);
808
+ });
809
+
810
+ it("surfaces a non-zero shell exit code as an error", async () => {
811
+ const res = await call(
812
+ h.router.testScript,
813
+ { kind: "shell", script: "exit 3", timeoutMs: 10_000 },
814
+ { context: h.context },
815
+ );
816
+ expect(res.exitCode).toBe(3);
817
+ expect(res.error).toContain("exited with code 3");
818
+ });
819
+
820
+ it("runs a typescript script and returns its default export", async () => {
821
+ const res = await call(
822
+ h.router.testScript,
823
+ {
824
+ kind: "typescript",
825
+ script: "export default { ok: context.trigger.payload.id };",
826
+ context: { trigger: { event: "e", payload: { id: "INC-9" } } },
827
+ timeoutMs: 10_000,
828
+ },
829
+ { context: h.context },
830
+ );
831
+ expect(res.result).toEqual({ ok: "INC-9" });
832
+ expect(res.error).toBeUndefined();
833
+ });
834
+ });
835
+
836
+ describe("getRunScopeForReplay", () => {
837
+ it("reconstructs trigger + artifacts and reports snapshot availability", async () => {
838
+ // First select() → the run row; second select() → artifact rows.
839
+ const runRow = {
840
+ id: "run-1",
841
+ triggerEventId: "incident.incident.created",
842
+ triggerPayload: { id: "INC-7" },
843
+ };
844
+ const artifactRows = [
845
+ { artifactType: "jira.issue", actionId: "j", data: { key: "P-1" } },
846
+ ];
847
+ h.db.select = mock(() => fluentSelect([runRow]))
848
+ .mockImplementationOnce(() => fluentSelect([runRow]))
849
+ .mockImplementationOnce(() => fluentSelect(artifactRows));
850
+ h.dispatchDeps.runStateStore.load = mock(async () => ({
851
+ scopeSnapshot: { vars: { count: 2 } },
852
+ lastActionPath: null,
853
+ lastHeartbeatAt: new Date(),
854
+ }));
855
+
856
+ const res = await call(
857
+ h.router.getRunScopeForReplay,
858
+ { runId: "run-1" },
859
+ { context: h.context },
860
+ );
861
+
862
+ expect(res.context.trigger).toEqual({
863
+ event: "incident.incident.created",
864
+ payload: { id: "INC-7" },
865
+ });
866
+ expect(res.context.artifacts).toEqual({
867
+ "jira.issue": { key: "P-1" },
868
+ j: { key: "P-1" },
869
+ });
870
+ expect(res.context.var).toEqual({ count: 2 });
871
+ expect(res.scopeSnapshotAvailable).toBe(true);
872
+ });
873
+
874
+ it("reports scopeSnapshotAvailable=false when the run state is cleared", async () => {
875
+ const runRow = {
876
+ id: "run-2",
877
+ triggerEventId: "e",
878
+ triggerPayload: {},
879
+ };
880
+ h.db.select = mock(() => fluentSelect([runRow]))
881
+ .mockImplementationOnce(() => fluentSelect([runRow]))
882
+ .mockImplementationOnce(() => fluentSelect([]));
883
+ h.dispatchDeps.runStateStore.load = mock(async () => undefined);
884
+
885
+ const res = await call(
886
+ h.router.getRunScopeForReplay,
887
+ { runId: "run-2" },
888
+ { context: h.context },
889
+ );
890
+ expect(res.scopeSnapshotAvailable).toBe(false);
891
+ expect(res.context.var).toEqual({});
892
+ });
893
+
894
+ it("404s on an unknown run id", async () => {
895
+ h.db.select = mock(() => fluentSelect([]));
896
+ await expect(
897
+ call(
898
+ h.router.getRunScopeForReplay,
899
+ { runId: "missing" },
900
+ { context: h.context },
901
+ ),
902
+ ).rejects.toThrow(/not found/i);
903
+ });
904
+ });
724
905
  });
package/src/router.ts CHANGED
@@ -43,6 +43,12 @@ import type { AutomationStore } from "./automation-store";
43
43
  import { dispatchTrigger } from "./dispatch/engine";
44
44
  import type { DispatchDeps } from "./dispatch/types";
45
45
  import { collectDefinitionIssues } from "./validate-definition";
46
+ import {
47
+ resolveResolutionRootFromStore,
48
+ resolveScriptPackagesDir,
49
+ } from "@checkstack/script-packages-backend";
50
+ import { runScriptTest } from "./script-test";
51
+ import { buildReplayContext } from "./script-test-replay";
46
52
  import * as schema from "./schema";
47
53
 
48
54
  interface RouterDeps {
@@ -140,8 +146,13 @@ export function createAutomationRouter(deps: RouterDeps) {
140
146
  // ─── Automations CRUD ────────────────────────────────────────────────
141
147
 
142
148
  listAutomations: os.listAutomations.handler(async ({ input }) => {
143
- const { limit, offset, status } = input;
144
- const result = await automationStore.list({ limit, offset, status });
149
+ const { limit, offset, status, group } = input;
150
+ const result = await automationStore.list({
151
+ limit,
152
+ offset,
153
+ status,
154
+ group,
155
+ });
145
156
  return {
146
157
  items: result.items,
147
158
  total: result.total,
@@ -150,6 +161,11 @@ export function createAutomationRouter(deps: RouterDeps) {
150
161
  };
151
162
  }),
152
163
 
164
+ listAutomationGroups: os.listAutomationGroups.handler(async () => {
165
+ const groups = await automationStore.listGroups();
166
+ return { groups };
167
+ }),
168
+
153
169
  getAutomation: os.getAutomation.handler(async ({ input }) => {
154
170
  const automation = await automationStore.getById(input.id);
155
171
  if (!automation) {
@@ -446,6 +462,7 @@ export function createAutomationRouter(deps: RouterDeps) {
446
462
  ownerPluginId: t.ownerPluginId,
447
463
  payloadSchema: t.payloadJsonSchema,
448
464
  configSchema: t.configJsonSchema,
465
+ contextKeyLabel: t.contextKeyLabel,
449
466
  }));
450
467
  return { items };
451
468
  }),
@@ -514,6 +531,60 @@ export function createAutomationRouter(deps: RouterDeps) {
514
531
  },
515
532
  ),
516
533
 
534
+ // ─── Inline script testing ───────────────────────────────────────────
535
+
536
+ testScript: os.testScript.handler(async ({ input }) => {
537
+ // Resolve the managed npm-package root from the local store so a test
538
+ // resolves the same allowlisted packages the real `run_script` action
539
+ // would (plan §4.1). Filesystem-only: ready when a tree is
540
+ // materialized, else unset. Execution safety is the runner's
541
+ // (auto-install disabled).
542
+ const status = await resolveResolutionRootFromStore(
543
+ resolveScriptPackagesDir(),
544
+ );
545
+ const resolutionRoot =
546
+ status.mode === "ready" ? status.root : undefined;
547
+ return runScriptTest({ input, deps: { resolutionRoot } });
548
+ }),
549
+
550
+ getRunScopeForReplay: os.getRunScopeForReplay.handler(async ({ input }) => {
551
+ const runRow = await db
552
+ .select()
553
+ .from(schema.automationRuns)
554
+ .where(eq(schema.automationRuns.id, input.runId))
555
+ .limit(1);
556
+ const run = runRow[0];
557
+ if (!run) {
558
+ throw new ORPCError("NOT_FOUND", {
559
+ message: `Run ${input.runId} not found`,
560
+ });
561
+ }
562
+
563
+ const [artifactRows, runState] = await Promise.all([
564
+ db
565
+ .select()
566
+ .from(schema.automationArtifacts)
567
+ .where(eq(schema.automationArtifacts.runId, input.runId))
568
+ .orderBy(asc(schema.automationArtifacts.createdAt)),
569
+ dispatchDeps.runStateStore.load(input.runId),
570
+ ]);
571
+
572
+ const context = buildReplayContext({
573
+ run: {
574
+ triggerEventId: run.triggerEventId,
575
+ triggerPayload: run.triggerPayload,
576
+ },
577
+ artifacts: artifactRows.map((row) => ({
578
+ artifactType: row.artifactType,
579
+ actionId: row.actionId,
580
+ data: row.data,
581
+ })),
582
+ scopeSnapshot: runState?.scopeSnapshot,
583
+ });
584
+
585
+ return { context, scopeSnapshotAvailable: runState !== undefined };
586
+ }),
587
+
517
588
  // ─── Template playground ─────────────────────────────────────────────
518
589
 
519
590
  renderTemplate: os.renderTemplate.handler(async ({ input }) => {