@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.
- package/CHANGELOG.md +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
+
}
|