@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,386 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ EsmScriptRunner,
4
+ ShellScriptRunner,
5
+ } from "@checkstack/backend-api";
6
+ import {
7
+ buildScriptContext,
8
+ buildTestSecretEnv,
9
+ runScriptTest,
10
+ type ScriptTestContext,
11
+ } from "./script-test";
12
+
13
+ function fakeEsmRunner(
14
+ impl: EsmScriptRunner["run"],
15
+ ): { runner: EsmScriptRunner; calls: Parameters<EsmScriptRunner["run"]>[0][] } {
16
+ const calls: Parameters<EsmScriptRunner["run"]>[0][] = [];
17
+ return {
18
+ calls,
19
+ runner: {
20
+ run: (options) => {
21
+ calls.push(options);
22
+ return impl(options);
23
+ },
24
+ },
25
+ };
26
+ }
27
+
28
+ function fakeShellRunner(
29
+ impl: ShellScriptRunner["run"],
30
+ ): {
31
+ runner: ShellScriptRunner;
32
+ calls: Parameters<ShellScriptRunner["run"]>[0][];
33
+ } {
34
+ const calls: Parameters<ShellScriptRunner["run"]>[0][] = [];
35
+ return {
36
+ calls,
37
+ runner: {
38
+ run: (options) => {
39
+ calls.push(options);
40
+ return impl(options);
41
+ },
42
+ },
43
+ };
44
+ }
45
+
46
+ describe("buildScriptContext", () => {
47
+ test("fills defaults for an empty sample", () => {
48
+ const ctx = buildScriptContext(undefined);
49
+ expect(ctx).toEqual({
50
+ trigger: { event: "", payload: {} },
51
+ artifacts: {},
52
+ var: {},
53
+ automation: {
54
+ runId: "test-run",
55
+ automationId: "test-automation",
56
+ contextKey: null,
57
+ },
58
+ });
59
+ });
60
+
61
+ test("passes through provided trigger / artifacts / var and includes repeat only when present", () => {
62
+ const sample: ScriptTestContext = {
63
+ trigger: { event: "incident.created", payload: { id: "INC-1" } },
64
+ artifacts: { jira_issue: { key: "PROJ-1" } },
65
+ var: { count: 3 },
66
+ repeat: { index: 2, item: { name: "a" } },
67
+ };
68
+ const ctx = buildScriptContext(sample);
69
+ expect(ctx.trigger).toEqual({
70
+ event: "incident.created",
71
+ payload: { id: "INC-1" },
72
+ });
73
+ expect(ctx.artifacts).toEqual({ jira_issue: { key: "PROJ-1" } });
74
+ expect(ctx.var).toEqual({ count: 3 });
75
+ expect(ctx.repeat).toEqual({ index: 2, item: { name: "a" } });
76
+ });
77
+
78
+ test("omits repeat when not in the sample", () => {
79
+ const ctx = buildScriptContext({ trigger: { event: "x" } });
80
+ expect("repeat" in ctx).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe("runScriptTest — typescript", () => {
85
+ test("invokes the ESM runner with the built context + helper module", async () => {
86
+ const { runner, calls } = fakeEsmRunner(async () => ({
87
+ result: { id: "ok" },
88
+ stdout: "log line",
89
+ stderr: "",
90
+ timedOut: false,
91
+ }));
92
+
93
+ const out = await runScriptTest({
94
+ input: {
95
+ kind: "typescript",
96
+ script: "export default { id: 'ok' }",
97
+ context: { trigger: { event: "e", payload: { a: 1 } } },
98
+ timeoutMs: 5000,
99
+ },
100
+ deps: { esmRunner: runner },
101
+ });
102
+
103
+ expect(calls).toHaveLength(1);
104
+ expect(calls[0]?.helperModuleName).toBe("@checkstack/integration");
105
+ expect(calls[0]?.helperFunctionName).toBe("defineIntegration");
106
+ expect(calls[0]?.timeoutMs).toBe(5000);
107
+ expect(out.result).toEqual({ id: "ok" });
108
+ expect(out.stdout).toBe("log line");
109
+ expect(out.error).toBeUndefined();
110
+ expect(out.timedOut).toBe(false);
111
+ expect(out.durationMs).toBeGreaterThanOrEqual(0);
112
+ });
113
+
114
+ test("surfaces a thrown-script error", async () => {
115
+ const { runner } = fakeEsmRunner(async () => ({
116
+ error: "boom",
117
+ stack: "Error: boom",
118
+ stdout: "",
119
+ stderr: "",
120
+ timedOut: false,
121
+ }));
122
+
123
+ const out = await runScriptTest({
124
+ input: { kind: "typescript", script: "throw new Error('boom')", timeoutMs: 1000 },
125
+ deps: { esmRunner: runner },
126
+ });
127
+ expect(out.error).toBe("boom");
128
+ });
129
+
130
+ test("reports timeout as an error", async () => {
131
+ const { runner } = fakeEsmRunner(async () => ({
132
+ stdout: "",
133
+ stderr: "",
134
+ timedOut: true,
135
+ error: "Script execution timed out",
136
+ }));
137
+
138
+ const out = await runScriptTest({
139
+ input: { kind: "typescript", script: "while(true){}", timeoutMs: 100 },
140
+ deps: { esmRunner: runner },
141
+ });
142
+ expect(out.timedOut).toBe(true);
143
+ expect(out.error).toBe("Script execution timed out");
144
+ });
145
+
146
+ test("catches an unexpected runner rejection instead of throwing", async () => {
147
+ const { runner } = fakeEsmRunner(async () => {
148
+ throw new Error("spawn failed");
149
+ });
150
+ const out = await runScriptTest({
151
+ input: { kind: "typescript", script: "x", timeoutMs: 1000 },
152
+ deps: { esmRunner: runner },
153
+ });
154
+ expect(out.error).toBe("spawn failed");
155
+ });
156
+ });
157
+
158
+ describe("runScriptTest — shell", () => {
159
+ test("flattens the sample context into CHECKSTACK_* env and merges explicit env", async () => {
160
+ const { runner, calls } = fakeShellRunner(async () => ({
161
+ exitCode: 0,
162
+ stdout: "hello",
163
+ stderr: "",
164
+ timedOut: false,
165
+ }));
166
+
167
+ const out = await runScriptTest({
168
+ input: {
169
+ kind: "shell",
170
+ script: "echo $CHECKSTACK_TRIGGER_PAYLOAD_ID",
171
+ context: { trigger: { event: "e", payload: { id: "INC-9" } } },
172
+ env: { EXTRA: "1" },
173
+ timeoutMs: 3000,
174
+ },
175
+ deps: { shellRunner: runner },
176
+ });
177
+
178
+ expect(calls).toHaveLength(1);
179
+ const env = calls[0]?.env ?? {};
180
+ expect(env.CHECKSTACK_TRIGGER_EVENT).toBe("e");
181
+ expect(env.CHECKSTACK_TRIGGER_PAYLOAD_ID).toBe("INC-9");
182
+ expect(env.EXTRA).toBe("1");
183
+ expect(out.exitCode).toBe(0);
184
+ expect(out.stdout).toBe("hello");
185
+ expect(out.error).toBeUndefined();
186
+ });
187
+
188
+ test("explicit env wins on key collision", async () => {
189
+ const { runner, calls } = fakeShellRunner(async () => ({
190
+ exitCode: 0,
191
+ stdout: "",
192
+ stderr: "",
193
+ timedOut: false,
194
+ }));
195
+ await runScriptTest({
196
+ input: {
197
+ kind: "shell",
198
+ script: "true",
199
+ context: { trigger: { event: "from-context" } },
200
+ env: { CHECKSTACK_TRIGGER_EVENT: "from-env" },
201
+ timeoutMs: 1000,
202
+ },
203
+ deps: { shellRunner: runner },
204
+ });
205
+ expect(calls[0]?.env?.CHECKSTACK_TRIGGER_EVENT).toBe("from-env");
206
+ });
207
+
208
+ test("reports a non-zero exit code as an error", async () => {
209
+ const { runner } = fakeShellRunner(async () => ({
210
+ exitCode: 2,
211
+ stdout: "",
212
+ stderr: "nope",
213
+ timedOut: false,
214
+ }));
215
+ const out = await runScriptTest({
216
+ input: { kind: "shell", script: "exit 2", timeoutMs: 1000 },
217
+ deps: { shellRunner: runner },
218
+ });
219
+ expect(out.exitCode).toBe(2);
220
+ expect(out.error).toContain("exited with code 2");
221
+ });
222
+
223
+ test("reports a shell timeout", async () => {
224
+ const { runner } = fakeShellRunner(async () => ({
225
+ exitCode: -1,
226
+ stdout: "",
227
+ stderr: "Script execution timed out",
228
+ timedOut: true,
229
+ }));
230
+ const out = await runScriptTest({
231
+ input: { kind: "shell", script: "sleep 100", timeoutMs: 50 },
232
+ deps: { shellRunner: runner },
233
+ });
234
+ expect(out.timedOut).toBe(true);
235
+ expect(out.error).toBe("Script execution timed out");
236
+ });
237
+ });
238
+
239
+ describe("runScriptTest secret masking (leak guard)", () => {
240
+ test("redacts run-scoped secret values from a TS test result/stdout/stderr", async () => {
241
+ const { runner } = fakeEsmRunner(async () => ({
242
+ result: { token: "gh_injectedSecret999" },
243
+ stdout: "echoing gh_injectedSecret999 to stdout",
244
+ stderr: "warn: gh_injectedSecret999 seen",
245
+ timedOut: false,
246
+ }));
247
+ const out = await runScriptTest({
248
+ input: { kind: "typescript", script: "export default () => {}", timeoutMs: 1000 },
249
+ deps: { esmRunner: runner, maskValues: ["gh_injectedSecret999"] },
250
+ });
251
+ expect(out.stdout).toBe("echoing **** to stdout");
252
+ expect(out.stderr).toBe("warn: **** seen");
253
+ expect(out.result).toEqual({ token: "****" });
254
+ expect(JSON.stringify(out)).not.toContain("gh_injectedSecret999");
255
+ });
256
+
257
+ test("redacts secret values from shell test stdout/stderr", async () => {
258
+ const { runner } = fakeShellRunner(async () => ({
259
+ exitCode: 0,
260
+ stdout: "$ echo db-password-456",
261
+ stderr: "",
262
+ timedOut: false,
263
+ }));
264
+ const out = await runScriptTest({
265
+ input: { kind: "shell", script: "echo $X", timeoutMs: 1000 },
266
+ deps: { shellRunner: runner, maskValues: ["db-password-456"] },
267
+ });
268
+ expect(out.stdout).toBe("$ echo ****");
269
+ });
270
+
271
+ test("is a no-op when no mask values are provided (Phase 1 default)", async () => {
272
+ const { runner } = fakeEsmRunner(async () => ({
273
+ result: undefined,
274
+ stdout: "no secrets here",
275
+ stderr: "",
276
+ timedOut: false,
277
+ }));
278
+ const out = await runScriptTest({
279
+ input: { kind: "typescript", script: "export default () => {}", timeoutMs: 1000 },
280
+ deps: { esmRunner: runner },
281
+ });
282
+ expect(out.stdout).toBe("no secrets here");
283
+ });
284
+ });
285
+
286
+ describe("runScriptTest secret placeholders + overrides (decision 4)", () => {
287
+ test("injects __SECRET_<NAME>__ placeholders by default (no real resolution)", async () => {
288
+ const { runner, calls } = fakeEsmRunner(async (opts) => ({
289
+ result: opts.env ?? null,
290
+ stdout: "",
291
+ stderr: "",
292
+ timedOut: false,
293
+ }));
294
+ const out = await runScriptTest({
295
+ input: {
296
+ kind: "typescript",
297
+ script: "export default () => {}",
298
+ timeoutMs: 1000,
299
+ secretEnv: { API_TOKEN: "${{ secrets.jira_token }}" },
300
+ },
301
+ deps: { esmRunner: runner },
302
+ });
303
+ // The injected env is the placeholder, never a real value.
304
+ expect(calls[0].env).toEqual({ API_TOKEN: "__SECRET_jira_token__" });
305
+ expect(out.result).toEqual({ API_TOKEN: "__SECRET_jira_token__" });
306
+ });
307
+
308
+ test("injects a user override instead of the placeholder", async () => {
309
+ const { runner, calls } = fakeShellRunner(async () => ({
310
+ exitCode: 0,
311
+ stdout: "ran",
312
+ stderr: "",
313
+ timedOut: false,
314
+ }));
315
+ await runScriptTest({
316
+ input: {
317
+ kind: "shell",
318
+ script: "echo $DB",
319
+ timeoutMs: 1000,
320
+ secretEnv: { DB: "${{ secrets.db_pass }}" },
321
+ secretOverrides: { db_pass: "my-test-override" },
322
+ },
323
+ deps: { shellRunner: runner },
324
+ });
325
+ expect(calls[0].env?.DB).toBe("my-test-override");
326
+ });
327
+
328
+ test("masks the user-override value out of the result", async () => {
329
+ const { runner } = fakeEsmRunner(async () => ({
330
+ result: { leaked: "my-test-override" },
331
+ stdout: "echo my-test-override",
332
+ stderr: "",
333
+ timedOut: false,
334
+ }));
335
+ const out = await runScriptTest({
336
+ input: {
337
+ kind: "typescript",
338
+ script: "export default () => {}",
339
+ timeoutMs: 1000,
340
+ secretEnv: { TOKEN: "${{ secrets.tok }}" },
341
+ secretOverrides: { tok: "my-test-override" },
342
+ },
343
+ deps: { esmRunner: runner },
344
+ });
345
+ expect(out.stdout).toBe("echo ****");
346
+ expect(out.result).toEqual({ leaked: "****" });
347
+ expect(JSON.stringify(out)).not.toContain("my-test-override");
348
+ });
349
+
350
+ test("no secretEnv -> no secret env injected (least-privilege)", async () => {
351
+ const { runner, calls } = fakeShellRunner(async () => ({
352
+ exitCode: 0,
353
+ stdout: "",
354
+ stderr: "",
355
+ timedOut: false,
356
+ }));
357
+ await runScriptTest({
358
+ input: { kind: "shell", script: "true", timeoutMs: 1000 },
359
+ deps: { shellRunner: runner },
360
+ });
361
+ // Only the $CHECKSTACK_* context env is present; no secret-derived keys.
362
+ const envKeys = Object.keys(calls[0].env ?? {});
363
+ expect(envKeys.some((k) => !k.startsWith("CHECKSTACK_"))).toBe(false);
364
+ });
365
+ });
366
+
367
+ describe("buildTestSecretEnv", () => {
368
+ test("placeholder when no override; override when given; masks overrides", () => {
369
+ const { env, maskValues } = buildTestSecretEnv({
370
+ secretEnv: {
371
+ A: "${{ secrets.alpha }}",
372
+ B: "${{ secrets.beta }}",
373
+ },
374
+ secretOverrides: { beta: "real-override" },
375
+ });
376
+ expect(env).toEqual({
377
+ A: "__SECRET_alpha__",
378
+ B: "real-override",
379
+ });
380
+ expect(maskValues).toEqual(["real-override"]);
381
+ });
382
+
383
+ test("empty when no secretEnv declared", () => {
384
+ expect(buildTestSecretEnv({})).toEqual({ env: {}, maskValues: [] });
385
+ });
386
+ });
@@ -0,0 +1,258 @@
1
+ /**
2
+ * In-UI script testing for automation `run_script` / `run_shell` actions.
3
+ *
4
+ * This is the backend half of Feature 2 (test scripts in the UI). It
5
+ * exercises the **same** sandboxed runners the real actions use
6
+ * (`defaultEsmScriptRunner` for TypeScript/JavaScript, `defaultShellScriptRunner`
7
+ * for shell) against an editable, user-supplied sample context — so an
8
+ * operator can click "Run" in the editor and see the result without
9
+ * triggering a whole automation.
10
+ *
11
+ * Security: the test runs on the central backend with the same subprocess
12
+ * isolation + curated `SAFE_ENV_VARS` env as production. Authoring a
13
+ * script already lets the user execute code, so no new privilege is
14
+ * granted; the endpoint is `manage`-gated and time-bounded.
15
+ *
16
+ * The module is deliberately pure (no DB, no oRPC) and takes injectable
17
+ * runners so it can be unit-tested without spawning a real subprocess.
18
+ */
19
+ import {
20
+ defaultEsmScriptRunner,
21
+ defaultShellScriptRunner,
22
+ type EsmScriptRunner,
23
+ type ShellScriptRunner,
24
+ } from "@checkstack/backend-api";
25
+ import { extractErrorMessage } from "@checkstack/common";
26
+ import {
27
+ maskScriptRunOutput,
28
+ buildTestSecretEnv,
29
+ } from "@checkstack/secrets-common";
30
+ import { flattenScopeToShellEnv } from "./script-test-shell-env";
31
+
32
+ // =============================================================================
33
+ // PUBLIC TYPES
34
+ // =============================================================================
35
+
36
+ export type ScriptTestKind = "typescript" | "shell";
37
+
38
+ /**
39
+ * Sample run context an operator edits in the test panel. Mirrors the
40
+ * shape `run_script` exposes as `globalThis.context` and `run_shell`
41
+ * flattens into `$CHECKSTACK_*` env vars. Every field is optional so a
42
+ * partial sample still runs.
43
+ */
44
+ export interface ScriptTestContext {
45
+ trigger?: {
46
+ event?: string;
47
+ payload?: Record<string, unknown>;
48
+ };
49
+ artifacts?: Record<string, unknown>;
50
+ var?: Record<string, unknown>;
51
+ repeat?: { index: number; item?: unknown };
52
+ }
53
+
54
+ export interface ScriptTestInput {
55
+ kind: ScriptTestKind;
56
+ script: string;
57
+ /** Editable sample context (auto-seeded in the UI). */
58
+ context?: ScriptTestContext;
59
+ /** Extra env vars for shell tests (merged over the flattened context). */
60
+ env?: Record<string, string>;
61
+ /**
62
+ * The script's declared secret -> env mapping
63
+ * (`{ ENV_NAME: "${{ secrets.NAME }}" }`). The test panel NEVER resolves
64
+ * real secret values (decision 4): each declared env var is injected with
65
+ * a named placeholder (`__SECRET_<NAME>__`) by default, or the user's
66
+ * override (see `secretOverrides`) for a realistic run.
67
+ */
68
+ secretEnv?: Record<string, string>;
69
+ /**
70
+ * Optional user-supplied per-secret-NAME override values (keyed by the
71
+ * `${{ secrets.NAME }}` name). Injected instead of the placeholder, and
72
+ * masked out of the result so an override can't round-trip unmasked.
73
+ */
74
+ secretOverrides?: Record<string, string>;
75
+ /** Working directory for shell tests. */
76
+ workingDirectory?: string;
77
+ timeoutMs: number;
78
+ }
79
+
80
+ export interface ScriptTestResult {
81
+ /** Return value of a TypeScript test (undefined for shell). */
82
+ result?: unknown;
83
+ stdout: string;
84
+ stderr: string;
85
+ /** Exit code of a shell test (undefined for TypeScript). */
86
+ exitCode?: number;
87
+ durationMs: number;
88
+ timedOut: boolean;
89
+ /** Populated when the script threw / failed to load / exited non-zero. */
90
+ error?: string;
91
+ }
92
+
93
+ export interface ScriptTestDeps {
94
+ esmRunner?: EsmScriptRunner;
95
+ shellRunner?: ShellScriptRunner;
96
+ /**
97
+ * Managed npm-package resolution root, so a TypeScript test resolves the
98
+ * same allowlisted packages the real `run_script` action would. Omit when
99
+ * no packages are configured (today's behavior). Plan §4.1: "with the
100
+ * managed resolutionRoot once Feature 1 lands."
101
+ */
102
+ resolutionRoot?: string;
103
+ /**
104
+ * Extra secret VALUES to redact (Jenkins-style) from the test result, on
105
+ * top of those derived from the input's `secretOverrides`. Normally
106
+ * unused — the test path masks the user overrides automatically.
107
+ */
108
+ maskValues?: Iterable<string>;
109
+ }
110
+
111
+ // `buildTestSecretEnv` / `secretTestPlaceholder` are the shared, pure
112
+ // secret-test helpers from `@checkstack/secrets-common` (re-exported so the
113
+ // existing import path keeps working).
114
+ export { buildTestSecretEnv, secretTestPlaceholder } from "@checkstack/secrets-common";
115
+
116
+ // =============================================================================
117
+ // CONTEXT NORMALISATION
118
+ // =============================================================================
119
+
120
+ /**
121
+ * Build the `globalThis.context` object a `run_script` action sees, from
122
+ * the editable sample. Keeps the same key names (`trigger`, `artifacts`,
123
+ * `var`, `automation`, optional `repeat`) so testing matches runtime.
124
+ */
125
+ export function buildScriptContext(
126
+ context: ScriptTestContext | undefined,
127
+ ): Record<string, unknown> {
128
+ return {
129
+ trigger: {
130
+ event: context?.trigger?.event ?? "",
131
+ payload: context?.trigger?.payload ?? {},
132
+ },
133
+ artifacts: context?.artifacts ?? {},
134
+ var: context?.var ?? {},
135
+ ...(context?.repeat ? { repeat: context.repeat } : {}),
136
+ // A test run has no real automation identity; surface stable
137
+ // placeholder values so `context.automation.runId` is non-null.
138
+ automation: {
139
+ runId: "test-run",
140
+ automationId: "test-automation",
141
+ contextKey: null,
142
+ },
143
+ };
144
+ }
145
+
146
+ // =============================================================================
147
+ // RUN
148
+ // =============================================================================
149
+
150
+ /**
151
+ * Execute a single test run of a script against a sample context.
152
+ *
153
+ * Never throws for ordinary script failures (syntax error, thrown error,
154
+ * non-zero exit, timeout) — those are returned in the {@link ScriptTestResult}
155
+ * so the UI can render them. Only truly unexpected infrastructure errors
156
+ * propagate, and even those are caught and surfaced as `error`.
157
+ */
158
+ export async function runScriptTest({
159
+ input,
160
+ deps = {},
161
+ }: {
162
+ input: ScriptTestInput;
163
+ deps?: ScriptTestDeps;
164
+ }): Promise<ScriptTestResult> {
165
+ const startedAt = Date.now();
166
+ // Build the test secret env: placeholders by default, user overrides if
167
+ // given. NO real secret value is ever resolved in the test path.
168
+ const secretTest = buildTestSecretEnv({
169
+ secretEnv: input.secretEnv,
170
+ secretOverrides: input.secretOverrides,
171
+ });
172
+ // Universal leak masking at the output boundary: redact any user-override
173
+ // secret value (plus any extra `deps.maskValues`) out of
174
+ // result/stdout/stderr/error before it is returned to the browser, so even
175
+ // an override can't round-trip to the surface unmasked.
176
+ const maskValues = [...secretTest.maskValues, ...(deps.maskValues ?? [])];
177
+ const mask = (
178
+ res: ScriptTestResult,
179
+ ): ScriptTestResult => {
180
+ const masked = maskScriptRunOutput({
181
+ output: {
182
+ result: res.result,
183
+ stdout: res.stdout,
184
+ stderr: res.stderr,
185
+ error: res.error,
186
+ },
187
+ values: maskValues,
188
+ });
189
+ return { ...res, ...masked };
190
+ };
191
+
192
+ try {
193
+ if (input.kind === "shell") {
194
+ const runner = deps.shellRunner ?? defaultShellScriptRunner;
195
+ const flattened = flattenScopeToShellEnv(input.context);
196
+ const res = await runner.run({
197
+ script: input.script,
198
+ // Operator env, then the test secret env (placeholders/overrides)
199
+ // on top so a declared secret env name wins.
200
+ env: { ...flattened, ...input.env, ...secretTest.env },
201
+ cwd: input.workingDirectory,
202
+ timeoutMs: input.timeoutMs,
203
+ });
204
+ const durationMs = Date.now() - startedAt;
205
+ if (res.timedOut) {
206
+ return mask({
207
+ stdout: res.stdout,
208
+ stderr: res.stderr,
209
+ exitCode: res.exitCode,
210
+ durationMs,
211
+ timedOut: true,
212
+ error: "Script execution timed out",
213
+ });
214
+ }
215
+ return mask({
216
+ stdout: res.stdout,
217
+ stderr: res.stderr,
218
+ exitCode: res.exitCode,
219
+ durationMs,
220
+ timedOut: false,
221
+ error:
222
+ res.exitCode === 0
223
+ ? undefined
224
+ : `Shell script exited with code ${res.exitCode}`,
225
+ });
226
+ }
227
+
228
+ const runner = deps.esmRunner ?? defaultEsmScriptRunner;
229
+ const res = await runner.run({
230
+ script: input.script,
231
+ context: buildScriptContext(input.context),
232
+ timeoutMs: input.timeoutMs,
233
+ helperModuleName: "@checkstack/integration",
234
+ helperFunctionName: "defineIntegration",
235
+ ...(Object.keys(secretTest.env).length > 0
236
+ ? { env: secretTest.env }
237
+ : {}),
238
+ ...(deps.resolutionRoot ? { resolutionRoot: deps.resolutionRoot } : {}),
239
+ });
240
+ const durationMs = Date.now() - startedAt;
241
+ return mask({
242
+ result: res.result,
243
+ stdout: res.stdout,
244
+ stderr: res.stderr,
245
+ durationMs,
246
+ timedOut: res.timedOut,
247
+ error: res.timedOut ? "Script execution timed out" : res.error,
248
+ });
249
+ } catch (error) {
250
+ return mask({
251
+ stdout: "",
252
+ stderr: "",
253
+ durationMs: Date.now() - startedAt,
254
+ timedOut: false,
255
+ error: extractErrorMessage(error),
256
+ });
257
+ }
258
+ }
@@ -62,8 +62,10 @@ export function createTriggerRegistry(): TriggerRegistry {
62
62
  payloadSchema: definition.payloadSchema,
63
63
  configSchema: definition.configSchema,
64
64
  contextKey: definition.contextKey,
65
+ contextKeyLabel: definition.contextKeyLabel,
65
66
  hook: definition.hook,
66
67
  setup: definition.setup,
68
+ evaluateConfig: definition.evaluateConfig,
67
69
  qualifiedId,
68
70
  ownerPluginId: pluginMetadata.pluginId,
69
71
  payloadJsonSchema,
@@ -88,6 +88,7 @@ function baseDefinition(
88
88
  },
89
89
  ],
90
90
  mode: "single",
91
+ concurrency_scope: "automation",
91
92
  max_runs: 1,
92
93
  ...overrides,
93
94
  };
package/tsconfig.json CHANGED
@@ -7,6 +7,9 @@
7
7
  {
8
8
  "path": "../automation-common"
9
9
  },
10
+ {
11
+ "path": "../backend"
12
+ },
10
13
  {
11
14
  "path": "../backend-api"
12
15
  },
@@ -19,6 +22,18 @@
19
22
  {
20
23
  "path": "../drizzle-helper"
21
24
  },
25
+ {
26
+ "path": "../gitops-backend"
27
+ },
28
+ {
29
+ "path": "../gitops-common"
30
+ },
31
+ {
32
+ "path": "../healthcheck-common"
33
+ },
34
+ {
35
+ "path": "../integration-backend"
36
+ },
22
37
  {
23
38
  "path": "../integration-common"
24
39
  },
@@ -28,6 +43,15 @@
28
43
  {
29
44
  "path": "../queue-api"
30
45
  },
46
+ {
47
+ "path": "../script-packages-backend"
48
+ },
49
+ {
50
+ "path": "../secrets-backend"
51
+ },
52
+ {
53
+ "path": "../secrets-common"
54
+ },
31
55
  {
32
56
  "path": "../signal-common"
33
57
  },