@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,348 @@
1
+ /**
2
+ * Wake-index reference extraction (reactive automation engine §8.3).
3
+ *
4
+ * A reactive `wait_until` no longer polls — at suspend time we statically
5
+ * extract the `state.*` entity refs its condition reads and insert one
6
+ * wake-index row per ref (§8.1). A relevant `ENTITY_CHANGED` then wakes the
7
+ * wait and re-evaluates the full condition synchronously.
8
+ *
9
+ * This module is the static-analysis half: given a {@link Condition}, return
10
+ * the set of dependency refs the condition depends on. Each ref is one of:
11
+ *
12
+ * - A concrete entity ref `{ kind, id }` → wake-index ref `${kind}:${id}`.
13
+ * - A kind-level WILDCARD `{ kind, id: "*" }` → wake-index ref `${kind}:*`,
14
+ * emitted when the walker can see the kind but not a concrete id (a
15
+ * dynamic/computed id). The wait then wakes on ANY change of that kind
16
+ * and re-evaluates — a few extra wakes, never a silent stall (§12).
17
+ *
18
+ * When extraction is wholly indeterminate (not even a kind), the result is
19
+ * `{ refs: [], indeterminate: true }` so the caller can fall back to the
20
+ * durable timeout timer only and log at `warn` (§8.3) — never silent.
21
+ *
22
+ * Grammar coverage:
23
+ * - Structured `state` conditions → `health:<entity>` (the current health
24
+ * entity's kind is `health`; `evaluateStateCondition` reads
25
+ * `health.systems[entity]`).
26
+ * - Structured `numeric_state` conditions whose `value` is a path/template
27
+ * into `state.<kind>.<id>.<field>` (or the rich `health.*` snapshot).
28
+ * - Template-string conditions: every member-expression rooted at
29
+ * `state.<kind>.<id>` or `health.systems[<id>]` / `health.system`.
30
+ * - `and` / `or` / `not` combinators recurse into their operands.
31
+ */
32
+ import {
33
+ parseCondition,
34
+ type Expr,
35
+ } from "@checkstack/template-engine";
36
+ import type { Condition } from "@checkstack/automation-common";
37
+
38
+ /** The entity kind the rich `health.*` scope snapshot maps to. */
39
+ export const HEALTH_ENTITY_KIND = "health";
40
+
41
+ /** A single extracted dependency. `id === "*"` is the kind-level wildcard. */
42
+ export interface WakeRef {
43
+ kind: string;
44
+ /** Concrete entity id, or `"*"` for the kind-level wildcard. */
45
+ id: string;
46
+ }
47
+
48
+ export interface ExtractedRefs {
49
+ /** De-duplicated refs the condition depends on. */
50
+ refs: ReadonlyArray<WakeRef>;
51
+ /**
52
+ * True when the walker could not derive ANY ref (not even a kind) from a
53
+ * condition that nonetheless references live state — the caller falls back
54
+ * to the timeout timer only and logs a warning.
55
+ */
56
+ indeterminate: boolean;
57
+ }
58
+
59
+ /** Serialize a {@link WakeRef} to its wake-index `ref` column value. */
60
+ export function refToString(ref: WakeRef): string {
61
+ return `${ref.kind}:${ref.id}`;
62
+ }
63
+
64
+ /**
65
+ * Read the operand array of an `and` / `or` combinator, or undefined when
66
+ * `condition` is not that combinator. Avoids a type-predicate over the
67
+ * `Condition` union (which includes `string`).
68
+ */
69
+ function combinatorOperands(
70
+ condition: Exclude<Condition, string>,
71
+ key: "and" | "or",
72
+ ): ReadonlyArray<Condition> | undefined {
73
+ const value = (condition as Record<string, unknown>)[key];
74
+ return Array.isArray(value) ? (value as ReadonlyArray<Condition>) : undefined;
75
+ }
76
+
77
+ /**
78
+ * Extract the wake-index dependency refs for a wait condition.
79
+ *
80
+ * The result is de-duplicated. `indeterminate` is set only when the
81
+ * condition clearly reads state (a template / structured variant) but no
82
+ * ref — concrete or wildcard — could be derived.
83
+ */
84
+ export function extractWakeRefs(condition: Condition): ExtractedRefs {
85
+ const collector = new RefCollector();
86
+ collector.visitCondition(condition);
87
+ return collector.result();
88
+ }
89
+
90
+ class RefCollector {
91
+ private readonly seen = new Set<string>();
92
+ private readonly refs: WakeRef[] = [];
93
+ /** Set when at least one state-reading shape was encountered. */
94
+ private touchedState = false;
95
+
96
+ result(): ExtractedRefs {
97
+ return {
98
+ refs: this.refs,
99
+ // Only surface indeterminate when state was read but nothing resolved
100
+ // to a concrete-or-wildcard ref — the caller then relies on the
101
+ // timeout timer alone and logs a warning.
102
+ indeterminate: this.touchedState && this.refs.length === 0,
103
+ };
104
+ }
105
+
106
+ private add(ref: WakeRef): void {
107
+ this.touchedState = true;
108
+ const key = refToString(ref);
109
+ if (this.seen.has(key)) return;
110
+ this.seen.add(key);
111
+ this.refs.push(ref);
112
+ }
113
+
114
+ /**
115
+ * Note that we saw a state-reading shape we couldn't resolve to a ref.
116
+ * `result()` reports `indeterminate` only if NO ref was recorded overall.
117
+ */
118
+ private markIndeterminate(): void {
119
+ this.touchedState = true;
120
+ }
121
+
122
+ visitCondition(condition: Condition): void {
123
+ if (typeof condition === "string") {
124
+ this.visitTemplate(condition);
125
+ return;
126
+ }
127
+ const andOps = combinatorOperands(condition, "and");
128
+ if (andOps) {
129
+ for (const c of andOps) this.visitCondition(c);
130
+ return;
131
+ }
132
+ const orOps = combinatorOperands(condition, "or");
133
+ if (orOps) {
134
+ for (const c of orOps) this.visitCondition(c);
135
+ return;
136
+ }
137
+ if ("not" in condition) {
138
+ this.visitCondition(condition.not);
139
+ return;
140
+ }
141
+ if ("state" in condition) {
142
+ this.touchedState = true;
143
+ // The structured `state` condition reads `health.systems[entity]`.
144
+ const entity = condition.state.entity;
145
+ if (typeof entity === "string" && entity.length > 0) {
146
+ this.add({ kind: HEALTH_ENTITY_KIND, id: entity });
147
+ } else {
148
+ this.markIndeterminate();
149
+ }
150
+ return;
151
+ }
152
+ if ("numeric_state" in condition) {
153
+ this.touchedState = true;
154
+ const value = condition.numeric_state.value;
155
+ if (typeof value === "string") {
156
+ // A path/template string into state.<kind>.<id>.<field> or health.*.
157
+ this.visitTemplate(value);
158
+ }
159
+ // A literal number references no entity — nothing to extract.
160
+ return;
161
+ }
162
+ if ("time" in condition) {
163
+ // `time` reads no entity state.
164
+ return;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Parse a template/path string and walk its member-expression chains for
170
+ * `state.<kind>.<id>` and rich `health.*` snapshot roots.
171
+ */
172
+ private visitTemplate(source: string): void {
173
+ let root: Expr;
174
+ try {
175
+ root = parseCondition(source).root;
176
+ } catch {
177
+ // Unparseable — treat as a state read we can't resolve (so the caller
178
+ // wildcards / falls back rather than silently never waking).
179
+ this.touchedState = true;
180
+ this.markIndeterminate();
181
+ return;
182
+ }
183
+ this.visitExpr(root);
184
+ }
185
+
186
+ private visitExpr(expr: Expr): void {
187
+ switch (expr.kind) {
188
+ case "member":
189
+ case "index": {
190
+ // Resolve the access chain rooted at this node; if it begins at a
191
+ // state/health root, record the ref and stop descending into the
192
+ // path segments (they're field accesses, not new roots).
193
+ const matched = this.tryRecordChain(expr);
194
+ if (matched) return;
195
+ // Not a state chain — descend into the object (and the index, which
196
+ // may itself contain a state ref) so nested expressions are covered.
197
+ if (expr.kind === "member") {
198
+ this.visitExpr(expr.object);
199
+ } else {
200
+ this.visitExpr(expr.object);
201
+ this.visitExpr(expr.index);
202
+ }
203
+ return;
204
+ }
205
+ case "binary": {
206
+ this.visitExpr(expr.left);
207
+ this.visitExpr(expr.right);
208
+ return;
209
+ }
210
+ case "unary": {
211
+ this.visitExpr(expr.operand);
212
+ return;
213
+ }
214
+ case "ternary": {
215
+ this.visitExpr(expr.condition);
216
+ this.visitExpr(expr.consequent);
217
+ this.visitExpr(expr.alternate);
218
+ return;
219
+ }
220
+ case "pipe": {
221
+ this.visitExpr(expr.value);
222
+ for (const a of expr.args) this.visitExpr(a);
223
+ return;
224
+ }
225
+ case "identifier":
226
+ case "literal": {
227
+ return;
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * If `expr` is an access chain rooted at a recognised state/health root,
234
+ * record the corresponding ref and return true. Otherwise return false.
235
+ *
236
+ * Recognised roots:
237
+ * - `state.<kind>.<id>[…]` → { kind, id }
238
+ * - `state.<kind>[<dynamic>][…]` → { kind, "*" } (wildcard)
239
+ * - `health.systems.<id>[…]` → { health, id }
240
+ * - `health.systems[<dynamic>][…]` → { health, "*" }
241
+ * - `health.system[…]` → { health, "*" } (the implicit
242
+ * context system — unknown id)
243
+ */
244
+ private tryRecordChain(expr: Expr): boolean {
245
+ const chain = flattenChain(expr);
246
+ if (!chain) return false;
247
+ const head = chain.root;
248
+ const rest = chain.segments;
249
+
250
+ if (head === "state") {
251
+ this.touchedState = true;
252
+ const kindSeg = rest[0];
253
+ if (kindSeg === undefined || kindSeg.dynamic) {
254
+ // `state[<dynamic>]` — kind itself unknown.
255
+ this.markIndeterminate();
256
+ return true;
257
+ }
258
+ const kind = kindSeg.name;
259
+ const idSeg = rest[1];
260
+ if (idSeg === undefined) {
261
+ // `state.<kind>` with no id — wildcard the whole kind.
262
+ this.add({ kind, id: "*" });
263
+ return true;
264
+ }
265
+ this.add({ kind, id: idSeg.dynamic ? "*" : idSeg.name });
266
+ return true;
267
+ }
268
+
269
+ if (head === "health") {
270
+ this.touchedState = true;
271
+ const next = rest[0];
272
+ if (next === undefined) {
273
+ this.markIndeterminate();
274
+ return true;
275
+ }
276
+ if (!next.dynamic && next.name === "systems") {
277
+ const idSeg = rest[1];
278
+ if (idSeg === undefined) {
279
+ // `health.systems` with no id — wildcard.
280
+ this.add({ kind: HEALTH_ENTITY_KIND, id: "*" });
281
+ return true;
282
+ }
283
+ this.add({
284
+ kind: HEALTH_ENTITY_KIND,
285
+ id: idSeg.dynamic ? "*" : idSeg.name,
286
+ });
287
+ return true;
288
+ }
289
+ if (!next.dynamic && next.name === "system") {
290
+ // The implicit context system — its concrete id isn't in the path.
291
+ this.add({ kind: HEALTH_ENTITY_KIND, id: "*" });
292
+ return true;
293
+ }
294
+ // `health.<other>` — unknown shape; wildcard health to stay safe.
295
+ this.add({ kind: HEALTH_ENTITY_KIND, id: "*" });
296
+ return true;
297
+ }
298
+
299
+ return false;
300
+ }
301
+ }
302
+
303
+ /** A single segment of a flattened access chain. */
304
+ interface ChainSegment {
305
+ /** The property/index name, when statically known. */
306
+ name: string;
307
+ /** True when the segment is a non-literal index (a computed key). */
308
+ dynamic: boolean;
309
+ }
310
+
311
+ /** A member/index chain flattened to a root identifier + ordered segments. */
312
+ interface FlattenedChain {
313
+ /** Root identifier name (e.g. `state`, `health`). */
314
+ root: string;
315
+ /** Member/index segments after the root, in source order. */
316
+ segments: ChainSegment[];
317
+ }
318
+
319
+ /**
320
+ * Flatten a member/index access chain into `{ root, segments }`.
321
+ * Returns undefined when the chain isn't rooted at a plain identifier
322
+ * (e.g. `(a ? b : c).x`) — such expressions are visited structurally
323
+ * instead.
324
+ */
325
+ function flattenChain(expr: Expr): FlattenedChain | undefined {
326
+ const segments: ChainSegment[] = [];
327
+ let node: Expr = expr;
328
+ // Walk from the outermost access inward, prepending as we go.
329
+ while (node.kind === "member" || node.kind === "index") {
330
+ if (node.kind === "member") {
331
+ segments.unshift({ name: node.property, dynamic: false });
332
+ node = node.object;
333
+ } else {
334
+ const idx = node.index;
335
+ if (idx.kind === "literal" && typeof idx.value === "string") {
336
+ segments.unshift({ name: idx.value, dynamic: false });
337
+ } else if (idx.kind === "literal" && typeof idx.value === "number") {
338
+ segments.unshift({ name: String(idx.value), dynamic: false });
339
+ } else {
340
+ // A computed key — id (or kind) is dynamic.
341
+ segments.unshift({ name: "", dynamic: true });
342
+ }
343
+ node = node.object;
344
+ }
345
+ }
346
+ if (node.kind !== "identifier") return undefined;
347
+ return { root: node.name, segments };
348
+ }