@checkstack/automation-common 0.2.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.
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * Variable-scope resolver.
3
+ *
4
+ * Given an automation definition + the registry info for its triggers and
5
+ * actions + the path to a specific leaf action inside the definition, returns
6
+ * the set of template variables that are in scope at that point.
7
+ *
8
+ * The scope rules — kept deliberately conservative so the editor never
9
+ * over-promises:
10
+ *
11
+ * 1. `trigger.event` (string-literal union of subscribed trigger ids).
12
+ * 2. `trigger.payload.*` — union of every field across the subscribed
13
+ * triggers' payload JSON Schemas, with each leaf annotated by the
14
+ * set of triggers that contribute it. The `generateTypeDeclarations`
15
+ * utility consumes this to emit a discriminated union typed by
16
+ * `trigger.event`, so Monaco narrows correctly inside a branch that
17
+ * gates on a specific event id. The picker shows everything;
18
+ * conditional fields surface a `Only when …` hint.
19
+ * 3. `var.<name>` — accumulated from `variables:` actions that linearly
20
+ * precede the target action _in the same sequence slot_. We do NOT
21
+ * bubble variables out of a `choose` / `parallel` / `repeat` branch
22
+ * into the parent sequence, even though the runtime would keep them
23
+ * live, because the editor can't statically prove which branch ran.
24
+ * 4. `artifact.<actionId>.<localArtifactName>` — accumulated from
25
+ * provider actions with a declared `produces` (and an `id`) in the
26
+ * same sequence slot, with the same branch-isolation rule as
27
+ * variables. The runtime exposes the data at
28
+ * `artifacts.<actionId>.<localArtifactName>.<field>`.
29
+ * 5. `repeat.index` and (when the parent is `for_each`) `repeat.item` —
30
+ * whenever the path descends through a `repeat` container.
31
+ *
32
+ * The resolver is pure — no I/O, no side effects. Used by both the editor
33
+ * (for IntelliSense and the variable picker) and the validator (to flag
34
+ * references to undeclared variables).
35
+ */
36
+ import type { Expr } from "@checkstack/template-engine";
37
+ import { parseCondition } from "@checkstack/template-engine";
38
+ import type {
39
+ ActionInfo,
40
+ ActionInput,
41
+ ArtifactTypeInfo,
42
+ AutomationDefinition,
43
+ ChooseInput,
44
+ ConditionInput,
45
+ ParallelInput,
46
+ ProviderAction,
47
+ RepeatInput,
48
+ SequenceInput,
49
+ Trigger,
50
+ TriggerInfo,
51
+ VariablesInput,
52
+ } from "./schemas";
53
+
54
+ /**
55
+ * Hierarchical entry in the resolved scope. Used by the variable picker
56
+ * for the tree view; the flat `path` is what gets inserted as `{{path}}`.
57
+ */
58
+ export interface VariableEntry {
59
+ /** Dot-separated path used at the template site, e.g. `trigger.payload.systemId`. */
60
+ path: string;
61
+ /**
62
+ * Runtime-parseable `{{ }}` insertion form for this entry. Differs from
63
+ * `path` in two ways: the top-level namespace is the plural runtime
64
+ * context key (`var` → `variables`, `artifact` → `artifacts`; `trigger`,
65
+ * `repeat`, `now` already match the runtime context and stay as-is), and
66
+ * any segment that is not a valid template identifier (artifact ids with
67
+ * dots/hyphens, oddly-named variables, hyphenated payload keys) is
68
+ * emitted in bracket notation (`artifacts["integration-jira.issue"]`) so
69
+ * the template engine's tokenizer can read it. Consumers that need the
70
+ * text to actually insert into `{{ }}` should prefer this field and fall
71
+ * back to `path` only when it is absent.
72
+ */
73
+ templateRef?: string;
74
+ /** Human-readable type label. */
75
+ type: string;
76
+ description?: string;
77
+ /**
78
+ * Underlying JSON Schema fragment for this node, when known. The type
79
+ * declaration generator consumes this to emit a strongly-typed
80
+ * `declare const context` block for Monaco.
81
+ */
82
+ jsonSchema?: Record<string, unknown>;
83
+ /** Nested entries for object types — populated for the picker's tree. */
84
+ children?: VariableEntry[];
85
+ /**
86
+ * Emit as an insertable field even when it has children — used for
87
+ * arrays, where both the whole array (`{{ ...tags }}`) and its elements
88
+ * (`{{ ...tags[0] }}`) are referenceable. Leaf entries (no children) are
89
+ * always insertable regardless of this flag.
90
+ */
91
+ referenceable?: boolean;
92
+ /**
93
+ * When set, the entry only exists when `trigger.event` is one of these
94
+ * qualified ids. Populated on `trigger.payload.*` entries that come from
95
+ * a subset of the automation's subscribed triggers, so the picker can
96
+ * render an "Only when …" hint and the type generator can emit a
97
+ * discriminated-union shape.
98
+ */
99
+ conditionalOnTriggers?: string[];
100
+ }
101
+
102
+ export interface VariableScope {
103
+ /** Tree of in-scope entries, grouped under top-level namespaces. */
104
+ entries: VariableEntry[];
105
+ }
106
+
107
+ // ─── Template-ref helpers ────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Whether `segment` is a bare identifier the template engine can read in dot
111
+ * notation. MIRRORS the template-engine tokenizer's identifier rule: a start
112
+ * character in `[A-Za-z_$]` followed by zero or more `[A-Za-z0-9_$]`. Anything
113
+ * else (dots, hyphens, leading digits, empty) must be bracketed instead.
114
+ */
115
+ export function isTemplateIdentifier(segment: string): boolean {
116
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment);
117
+ }
118
+
119
+ /**
120
+ * Append `segment` to `base`, producing a runtime-parseable `{{ }}` member
121
+ * access. Identifier segments use dot notation (`base.segment`); everything
122
+ * else uses bracket notation with a JSON-quoted string literal
123
+ * (`base["seg-ment"]`). `JSON.stringify` yields a valid double-quoted string
124
+ * literal — which the engine tokenizer supports — and safely escapes any
125
+ * dots, hyphens, or quotes inside the segment.
126
+ */
127
+ export function appendTemplateSegment({
128
+ base,
129
+ segment,
130
+ }: {
131
+ base: string;
132
+ segment: string;
133
+ }): string {
134
+ return isTemplateIdentifier(segment)
135
+ ? `${base}.${segment}`
136
+ : `${base}[${JSON.stringify(segment)}]`;
137
+ }
138
+
139
+ /**
140
+ * Append a numeric array-element index to `base`, producing a
141
+ * runtime-parseable `{{ }}` index access (`base[0]`). The index is a bare
142
+ * number with NO quotes — the template engine's tokenizer reads a NUMBER
143
+ * inside the brackets and the renderer resolves it as an array index. This
144
+ * MUST match the tokenizer's bracket-number reconstruction byte-for-byte.
145
+ */
146
+ export function appendArrayIndex({
147
+ base,
148
+ index,
149
+ }: {
150
+ base: string;
151
+ index: number;
152
+ }): string {
153
+ return `${base}[${index}]`;
154
+ }
155
+
156
+ /**
157
+ * One segment of a navigation path from the root of `definition.actions` to
158
+ * a particular leaf action.
159
+ *
160
+ * - `slot: "root"` is reserved for the first segment of the path; `index`
161
+ * is the index into `definition.actions`.
162
+ * - All other slots correspond to a child list inside a composite action.
163
+ * - When `slot === "choose-when"`, `whenIndex` identifies the when-branch.
164
+ */
165
+ export interface ActionPathStep {
166
+ slot:
167
+ | "root"
168
+ | "choose-when"
169
+ | "choose-else"
170
+ | "parallel"
171
+ | "repeat"
172
+ | "sequence";
173
+ /** When `slot === "choose-when"`, which when-branch (index into `choose:`). */
174
+ whenIndex?: number;
175
+ /** Index of the step within the slot's child list. */
176
+ index: number;
177
+ }
178
+
179
+ export type ActionPath = ActionPathStep[];
180
+
181
+ export interface ResolveVariableScopeInput {
182
+ definition: AutomationDefinition;
183
+ triggers: TriggerInfo[];
184
+ actions: ActionInfo[];
185
+ artifactTypes: ArtifactTypeInfo[];
186
+ /** Position of the target action inside `definition.actions`. */
187
+ path: ActionPath;
188
+ }
189
+
190
+ // ─── Helpers ───────────────────────────────────────────────────────────────
191
+
192
+ function getChildList(
193
+ action: ActionInput,
194
+ slot: ActionPathStep["slot"],
195
+ whenIndex: number | undefined,
196
+ ): ActionInput[] {
197
+ if (slot === "root") return [];
198
+ if (slot === "choose-when") {
199
+ const choose = action as ChooseInput;
200
+ if (whenIndex === undefined) return [];
201
+ return choose.choose[whenIndex]?.sequence ?? [];
202
+ }
203
+ if (slot === "choose-else") {
204
+ const choose = action as ChooseInput;
205
+ return choose.else ?? [];
206
+ }
207
+ if (slot === "parallel") {
208
+ return (action as ParallelInput).parallel;
209
+ }
210
+ if (slot === "repeat") {
211
+ return (action as RepeatInput).repeat.sequence;
212
+ }
213
+ // sequence
214
+ return (action as SequenceInput).sequence;
215
+ }
216
+
217
+ function isProviderAction(a: ActionInput): a is ProviderAction {
218
+ return typeof a === "object" && a !== null && "action" in a;
219
+ }
220
+
221
+ function isVariablesAction(a: ActionInput): a is VariablesInput {
222
+ return typeof a === "object" && a !== null && "variables" in a;
223
+ }
224
+
225
+ function isRepeatAction(a: ActionInput): a is RepeatInput {
226
+ return typeof a === "object" && a !== null && "repeat" in a;
227
+ }
228
+
229
+ function repeatMode(action: RepeatInput): "count" | "for_each" | "while" | "until" {
230
+ const r = action.repeat as Record<string, unknown>;
231
+ if ("for_each" in r) return "for_each";
232
+ if ("while" in r) return "while";
233
+ if ("until" in r) return "until";
234
+ return "count";
235
+ }
236
+
237
+ // ─── Condition-aware trigger narrowing ─────────────────────────────────────
238
+
239
+ /**
240
+ * When the path descends through a `choose-when`, the branch's `when:`
241
+ * condition may statically pin `trigger.event` to a specific id (or a
242
+ * specific subset). We use that to narrow `trigger.payload` from the
243
+ * full discriminated union down to the variants the operator is
244
+ * actually inside — so `{{ trigger.payload.title }}` works without the
245
+ * "Only when …" hint when the branch already gates on
246
+ * `trigger.event == "incident.created"`.
247
+ *
248
+ * Returns `undefined` when the condition doesn't tell us anything about
249
+ * `trigger.event` — in that case we keep all subscribed triggers in scope.
250
+ * Returns a `Set<string>` of event ids the condition allows otherwise.
251
+ *
252
+ * Pattern coverage (conservative — anything outside this list falls back
253
+ * to "no narrowing"):
254
+ *
255
+ * - `trigger.event == "X"` (either operand order) → {X}
256
+ * - `trigger.event != "X"` → all-minus-{X}, but only when we know the
257
+ * full universe (the caller passes `allTriggers`). When the universe
258
+ * is unknown we bail.
259
+ * - `A || B`, `A && B` where A and B are themselves narrowable.
260
+ *
261
+ * Complex predicates (`not`, function calls, references to other fields,
262
+ * dynamic comparisons) deliberately bail to `undefined`. We'd rather
263
+ * leave the picker showing every field than guess wrong.
264
+ */
265
+ function narrowTriggersFromCondition(
266
+ condition: ConditionInput,
267
+ universe: string[],
268
+ ): Set<string> | undefined {
269
+ if (typeof condition === "string") {
270
+ let parsed;
271
+ try {
272
+ parsed = parseCondition(condition);
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ return narrowFromExpr(parsed.root, universe);
277
+ }
278
+ if ("and" in condition) {
279
+ let result: Set<string> | undefined;
280
+ for (const child of condition.and) {
281
+ const childNarrow = narrowTriggersFromCondition(child, universe);
282
+ if (childNarrow === undefined) continue;
283
+ result =
284
+ result === undefined
285
+ ? new Set(childNarrow)
286
+ : new Set([...result].filter((id) => childNarrow.has(id)));
287
+ }
288
+ return result;
289
+ }
290
+ if ("or" in condition) {
291
+ let result: Set<string> | undefined;
292
+ for (const child of condition.or) {
293
+ const childNarrow = narrowTriggersFromCondition(child, universe);
294
+ if (childNarrow === undefined) return undefined;
295
+ result =
296
+ result === undefined
297
+ ? new Set(childNarrow)
298
+ : new Set([...result, ...childNarrow]);
299
+ }
300
+ return result;
301
+ }
302
+ // `not` — could in principle return all-minus-{X} but combining with
303
+ // outer AND/OR gets surprising fast. Bail.
304
+ return undefined;
305
+ }
306
+
307
+ function narrowFromExpr(expr: Expr, universe: string[]): Set<string> | undefined {
308
+ if (expr.kind !== "binary") return undefined;
309
+ if (expr.op === "==") {
310
+ const event = extractTriggerEventLiteral(expr.left, expr.right);
311
+ return event === undefined ? undefined : new Set([event]);
312
+ }
313
+ if (expr.op === "!=") {
314
+ const event = extractTriggerEventLiteral(expr.left, expr.right);
315
+ if (event === undefined || universe.length === 0) return undefined;
316
+ return new Set(universe.filter((id) => id !== event));
317
+ }
318
+ if (expr.op === "||") {
319
+ const left = narrowFromExpr(expr.left, universe);
320
+ const right = narrowFromExpr(expr.right, universe);
321
+ if (left === undefined || right === undefined) return undefined;
322
+ return new Set([...left, ...right]);
323
+ }
324
+ if (expr.op === "&&") {
325
+ const left = narrowFromExpr(expr.left, universe);
326
+ const right = narrowFromExpr(expr.right, universe);
327
+ if (left === undefined && right === undefined) return undefined;
328
+ if (left === undefined) return right;
329
+ if (right === undefined) return left;
330
+ return new Set([...left].filter((id) => right.has(id)));
331
+ }
332
+ return undefined;
333
+ }
334
+
335
+ function extractTriggerEventLiteral(
336
+ a: Expr,
337
+ b: Expr,
338
+ ): string | undefined {
339
+ const direction = (member: Expr, literal: Expr): string | undefined => {
340
+ if (member.kind !== "member" || member.property !== "event") return undefined;
341
+ if (member.object.kind !== "identifier" || member.object.name !== "trigger") {
342
+ return undefined;
343
+ }
344
+ if (literal.kind !== "literal" || typeof literal.value !== "string") {
345
+ return undefined;
346
+ }
347
+ return literal.value;
348
+ };
349
+ return direction(a, b) ?? direction(b, a);
350
+ }
351
+
352
+ // ─── JSON Schema → VariableEntry tree ──────────────────────────────────────
353
+
354
+ interface JsonSchemaLike {
355
+ type?: string | string[];
356
+ description?: string;
357
+ properties?: Record<string, JsonSchemaLike>;
358
+ required?: string[];
359
+ items?: JsonSchemaLike;
360
+ enum?: unknown[];
361
+ additionalProperties?: boolean | JsonSchemaLike;
362
+ }
363
+
364
+ function schemaTypeLabel(schema: JsonSchemaLike | undefined): string {
365
+ if (!schema) return "unknown";
366
+ if (schema.enum) {
367
+ return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
368
+ }
369
+ if (Array.isArray(schema.type)) {
370
+ return schema.type.join(" | ");
371
+ }
372
+ if (schema.type === "array" && schema.items) {
373
+ return `${schemaTypeLabel(schema.items)}[]`;
374
+ }
375
+ if (schema.type === "object") return "object";
376
+ if (typeof schema.type === "string") return schema.type;
377
+ return "unknown";
378
+ }
379
+
380
+ /**
381
+ * Build a tree of `VariableEntry` nodes from a JSON Schema, rooted at
382
+ * `parentPath`. Each entry's `path` is the dot-joined navigation key the
383
+ * user would type after the picker inserts it.
384
+ */
385
+ function entriesFromSchema(
386
+ schema: JsonSchemaLike | undefined,
387
+ parentPath: string,
388
+ parentTemplateRef: string,
389
+ ): VariableEntry[] {
390
+ if (!schema || schema.type !== "object" || !schema.properties) return [];
391
+ return Object.entries(schema.properties).map(([key, child]) => {
392
+ const childPath = `${parentPath}.${key}`;
393
+ const childTemplateRef = appendTemplateSegment({
394
+ base: parentTemplateRef,
395
+ segment: key,
396
+ });
397
+ return entryFromSchemaNode(child, childPath, childTemplateRef);
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Build a single `VariableEntry` for a property's schema, recursing into
403
+ * objects and arrays.
404
+ *
405
+ * - `object` with `properties` → container node with `children`.
406
+ * - `array` with `items` → a referenceable whole-array node carrying a
407
+ * single representative-element child at index `0`. The element child
408
+ * recurses for nested objects (`tags[0].field`) / arrays (`matrix[0][0]`)
409
+ * or is a leaf for scalar items (`tags[0]`).
410
+ * - anything else → leaf.
411
+ */
412
+ function entryFromSchemaNode(
413
+ schema: JsonSchemaLike,
414
+ path: string,
415
+ templateRef: string,
416
+ ): VariableEntry {
417
+ if (schema.type === "array" && schema.items) {
418
+ const elemPath = `${path}[0]`;
419
+ const elemTemplateRef = appendArrayIndex({ base: templateRef, index: 0 });
420
+ const elementEntry = entryFromSchemaNode(
421
+ schema.items,
422
+ elemPath,
423
+ elemTemplateRef,
424
+ );
425
+ return {
426
+ path,
427
+ templateRef,
428
+ type: schemaTypeLabel(schema),
429
+ description: schema.description,
430
+ jsonSchema: schema as unknown as Record<string, unknown>,
431
+ // Both the whole array and its elements are referenceable.
432
+ referenceable: true,
433
+ children: [elementEntry],
434
+ } satisfies VariableEntry;
435
+ }
436
+
437
+ const subEntries =
438
+ schema.type === "object" && schema.properties
439
+ ? entriesFromSchema(schema, path, templateRef)
440
+ : undefined;
441
+ return {
442
+ path,
443
+ templateRef,
444
+ type: schemaTypeLabel(schema),
445
+ description: schema.description,
446
+ jsonSchema: schema as unknown as Record<string, unknown>,
447
+ children: subEntries,
448
+ } satisfies VariableEntry;
449
+ }
450
+
451
+ // ─── Trigger scope ─────────────────────────────────────────────────────────
452
+
453
+ /**
454
+ * Build the `trigger.*` namespace as a **discriminated union over
455
+ * `trigger.event`**.
456
+ *
457
+ * Every payload field from every subscribed trigger is offered, and each
458
+ * leaf entry is annotated with the trigger ids that contribute it. Fields
459
+ * shared by all subscribed triggers carry no `conditionalOnTriggers` flag.
460
+ * The TS-declaration generator turns this into a real
461
+ * `trigger: { event: "A"; payload: PA } | { event: "B"; payload: PB }`
462
+ * shape so Monaco narrows correctly inside `{{#if trigger.event === "A"}}`
463
+ * branches.
464
+ */
465
+ /**
466
+ * Static `trigger.actor` subtree. Every trigger carries an actor (who/what
467
+ * caused the event), injected by the platform as event metadata, so this is
468
+ * offered unconditionally on every automation - independent of which triggers
469
+ * are subscribed - to drive filters like
470
+ * `{{ trigger.actor.type == "system" }}`.
471
+ */
472
+ function buildActorEntry(): VariableEntry {
473
+ return {
474
+ path: "trigger.actor",
475
+ templateRef: "trigger.actor",
476
+ type: "object",
477
+ description:
478
+ "Who or what caused the event (system, user, application, or service).",
479
+ jsonSchema: {
480
+ type: "object",
481
+ properties: {
482
+ type: {
483
+ type: "string",
484
+ enum: ["system", "user", "application", "service"],
485
+ },
486
+ id: { type: "string" },
487
+ name: { type: "string" },
488
+ },
489
+ required: ["type", "id"],
490
+ },
491
+ children: [
492
+ {
493
+ path: "trigger.actor.type",
494
+ templateRef: "trigger.actor.type",
495
+ type: '"system" | "user" | "application" | "service"',
496
+ description:
497
+ "Actor kind. Filter e.g. system-created vs user-created events.",
498
+ jsonSchema: {
499
+ type: "string",
500
+ enum: ["system", "user", "application", "service"],
501
+ },
502
+ },
503
+ {
504
+ path: "trigger.actor.id",
505
+ templateRef: "trigger.actor.id",
506
+ type: "string",
507
+ description:
508
+ 'Stable id: user id, application id, plugin id (service), or "system".',
509
+ jsonSchema: { type: "string" },
510
+ },
511
+ {
512
+ path: "trigger.actor.name",
513
+ templateRef: "trigger.actor.name",
514
+ type: "string",
515
+ description: "Human-readable actor name when known.",
516
+ jsonSchema: { type: "string" },
517
+ },
518
+ ],
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Derive a stable, identifier-safe id for a trigger from its event id, used
524
+ * when the operator hasn't assigned an explicit `id`. Mirrors the backend
525
+ * dispatcher's derivation so editor autocomplete, the generated script types,
526
+ * and the runtime `trigger.id` all agree.
527
+ */
528
+ export function deriveTriggerId(event: string): string {
529
+ return event.replaceAll(/[^a-z0-9]+/gi, "_").toLowerCase();
530
+ }
531
+
532
+ /**
533
+ * Build the `trigger.id` entry — the id of the specific trigger declaration
534
+ * that fired. Typed as the literal union of the automation's trigger ids
535
+ * (explicit `id` or derived from the event), so it discriminates triggers
536
+ * even when two subscribe to the same `event`.
537
+ */
538
+ function buildTriggerIdEntry(triggers: Trigger[]): VariableEntry {
539
+ const effectiveIds = [
540
+ ...new Set(triggers.map((t) => t.id ?? deriveTriggerId(t.event))),
541
+ ];
542
+ const idType =
543
+ effectiveIds.length > 0
544
+ ? effectiveIds.map((id) => JSON.stringify(id)).join(" | ")
545
+ : "string";
546
+ return {
547
+ path: "trigger.id",
548
+ templateRef: "trigger.id",
549
+ type: idType,
550
+ description:
551
+ effectiveIds.length <= 1
552
+ ? "Id of the trigger declaration that fired."
553
+ : "Id of the trigger that fired — distinguishes triggers, including two on the same event.",
554
+ jsonSchema:
555
+ effectiveIds.length > 0
556
+ ? { type: "string", enum: effectiveIds }
557
+ : { type: "string" },
558
+ };
559
+ }
560
+
561
+ function buildTriggerEntries(args: {
562
+ triggers: Trigger[];
563
+ registeredTriggers: TriggerInfo[];
564
+ }): VariableEntry[] {
565
+ const { triggers, registeredTriggers } = args;
566
+ const matched = triggers
567
+ .map((t) => registeredTriggers.find((r) => r.qualifiedId === t.event))
568
+ .filter((r): r is TriggerInfo => r !== undefined);
569
+
570
+ const allEventIds = matched.map((m) => m.qualifiedId);
571
+ const eventType =
572
+ allEventIds.length > 0
573
+ ? allEventIds.map((id) => JSON.stringify(id)).join(" | ")
574
+ : "string";
575
+
576
+ const idEntry = buildTriggerIdEntry(triggers);
577
+ const eventEntry: VariableEntry = {
578
+ path: "trigger.event",
579
+ templateRef: "trigger.event",
580
+ type: eventType,
581
+ description:
582
+ allEventIds.length <= 1
583
+ ? "Fully-qualified event id of the trigger that fired."
584
+ : "Discriminator — narrows trigger.payload to the matching variant.",
585
+ jsonSchema:
586
+ allEventIds.length > 0
587
+ ? { type: "string", enum: allEventIds }
588
+ : { type: "string" },
589
+ };
590
+
591
+ if (matched.length === 0) {
592
+ return [
593
+ {
594
+ path: "trigger",
595
+ templateRef: "trigger",
596
+ type: "object",
597
+ description: "Trigger that fired this run.",
598
+ children: [
599
+ idEntry,
600
+ eventEntry,
601
+ buildActorEntry(),
602
+ {
603
+ path: "trigger.payload",
604
+ templateRef: "trigger.payload",
605
+ type: "unknown",
606
+ description: "Trigger payload (no registered schema).",
607
+ jsonSchema: {},
608
+ },
609
+ ],
610
+ },
611
+ ];
612
+ }
613
+
614
+ const payloadEntry = buildPayloadUnion(matched);
615
+
616
+ return [
617
+ {
618
+ path: "trigger",
619
+ templateRef: "trigger",
620
+ type: "object",
621
+ description: "Trigger that fired this run.",
622
+ children: [idEntry, eventEntry, buildActorEntry(), payloadEntry],
623
+ },
624
+ ];
625
+ }
626
+
627
+ /**
628
+ * Merge several triggers' payload schemas into a single
629
+ * `trigger.payload` entry. Strategy:
630
+ *
631
+ * - Walk every property of every payload.
632
+ * - For each property name, collect the set of triggers that include it.
633
+ * - When every trigger includes it with the same JSON shape, emit a
634
+ * plain entry.
635
+ * - Otherwise emit it with `conditionalOnTriggers` listing the contributors
636
+ * — the picker shows it (with a hint), the type generator funnels it
637
+ * into the right discriminated-union variant.
638
+ */
639
+ function buildPayloadUnion(triggers: TriggerInfo[]): VariableEntry {
640
+ if (triggers.length === 1) {
641
+ const only = triggers[0]!;
642
+ return {
643
+ path: "trigger.payload",
644
+ templateRef: "trigger.payload",
645
+ type: schemaTypeLabel(only.payloadSchema as JsonSchemaLike),
646
+ description: "Trigger payload.",
647
+ jsonSchema: only.payloadSchema,
648
+ children: entriesFromSchema(
649
+ only.payloadSchema as JsonSchemaLike,
650
+ "trigger.payload",
651
+ "trigger.payload",
652
+ ),
653
+ };
654
+ }
655
+
656
+ const allEventIds = triggers.map((t) => t.qualifiedId);
657
+ const propertyContributors = new Map<
658
+ string,
659
+ Array<{ triggerId: string; schema: JsonSchemaLike }>
660
+ >();
661
+
662
+ for (const trig of triggers) {
663
+ const schema = trig.payloadSchema as JsonSchemaLike;
664
+ if (schema.type !== "object" || !schema.properties) continue;
665
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
666
+ const list = propertyContributors.get(key) ?? [];
667
+ list.push({ triggerId: trig.qualifiedId, schema: propSchema });
668
+ propertyContributors.set(key, list);
669
+ }
670
+ }
671
+
672
+ const children: VariableEntry[] = [];
673
+ for (const [key, contributors] of propertyContributors) {
674
+ const contributorIds = contributors.map((c) => c.triggerId);
675
+ const universal = contributorIds.length === allEventIds.length;
676
+ const sameShape = contributors.every(
677
+ (c) => schemaTypeLabel(c.schema) === schemaTypeLabel(contributors[0]!.schema),
678
+ );
679
+
680
+ const childPath = `trigger.payload.${key}`;
681
+ const childTemplateRef = appendTemplateSegment({
682
+ base: "trigger.payload",
683
+ segment: key,
684
+ });
685
+ const baseSchema = contributors[0]!.schema;
686
+
687
+ // When all contributors agree on the shape, descend into objects/arrays
688
+ // via the shared builder so payload arrays expose element children too.
689
+ // Otherwise emit a flat union entry with no children (we can't pick one
690
+ // canonical shape to recurse into).
691
+ const node: VariableEntry = sameShape
692
+ ? entryFromSchemaNode(baseSchema, childPath, childTemplateRef)
693
+ : {
694
+ path: childPath,
695
+ templateRef: childTemplateRef,
696
+ type: contributors
697
+ .map((c) => schemaTypeLabel(c.schema))
698
+ .filter((t, i, arr) => arr.indexOf(t) === i)
699
+ .join(" | "),
700
+ description: baseSchema.description,
701
+ jsonSchema: undefined,
702
+ };
703
+
704
+ children.push({
705
+ ...node,
706
+ conditionalOnTriggers: universal ? undefined : contributorIds,
707
+ });
708
+ }
709
+
710
+ return {
711
+ path: "trigger.payload",
712
+ templateRef: "trigger.payload",
713
+ type: "object",
714
+ description:
715
+ "Trigger payload — discriminated union over trigger.event. Fields contributed by only some triggers carry an `Only when …` hint.",
716
+ jsonSchema: undefined,
717
+ children,
718
+ };
719
+ }
720
+
721
+ // ─── Walk & accumulate ─────────────────────────────────────────────────────
722
+
723
+ interface AccumulatedScope {
724
+ vars: VariableEntry[];
725
+ artifacts: VariableEntry[];
726
+ repeats: VariableEntry[];
727
+ /**
728
+ * When defined, the trigger universe is narrowed to this set — populated
729
+ * by walking `choose-when` branches whose `when:` condition statically
730
+ * pins `trigger.event` (see `narrowTriggersFromCondition`). When
731
+ * `undefined`, no condition along the path narrowed anything and all
732
+ * subscribed triggers remain in scope.
733
+ */
734
+ narrowedTriggers: Set<string> | undefined;
735
+ }
736
+
737
+ /**
738
+ * Walk the path step by step, descending into the action tree and
739
+ * accumulating in-scope vars / artifacts / repeat contexts.
740
+ *
741
+ * At each step we consume only the actions _before_ the target index in
742
+ * the current slot's child list — that's the linear upstream.
743
+ */
744
+ function accumulateAlongPath(args: {
745
+ definition: AutomationDefinition;
746
+ actions: ActionInfo[];
747
+ artifactTypes: ArtifactTypeInfo[];
748
+ path: ActionPath;
749
+ subscribedTriggerIds: string[];
750
+ }): AccumulatedScope {
751
+ const { definition, actions, artifactTypes, path, subscribedTriggerIds } = args;
752
+
753
+ const scope: AccumulatedScope = {
754
+ vars: [],
755
+ artifacts: [],
756
+ repeats: [],
757
+ narrowedTriggers: undefined,
758
+ };
759
+ let currentList: ActionInput[] = definition.actions;
760
+ let parentAction: ActionInput | null = null;
761
+
762
+ for (let segment = 0; segment < path.length; segment++) {
763
+ const step = path[segment]!;
764
+
765
+ // Accumulate everything in `currentList` up to (but not including) the
766
+ // target index.
767
+ accumulatePrefix({
768
+ slice: currentList.slice(0, step.index),
769
+ actions,
770
+ artifactTypes,
771
+ scope,
772
+ });
773
+
774
+ // Last step — we don't descend further.
775
+ if (segment === path.length - 1) break;
776
+
777
+ // Descend: target action of this step becomes parent of next step.
778
+ parentAction = currentList[step.index] ?? null;
779
+ if (!parentAction) break;
780
+
781
+ const nextStep = path[segment + 1]!;
782
+
783
+ // Narrow triggers when stepping into a `choose-when`: read the parent
784
+ // Choose's matching when-branch and intersect its narrowing with what
785
+ // we already had.
786
+ if (
787
+ nextStep.slot === "choose-when" &&
788
+ nextStep.whenIndex !== undefined &&
789
+ typeof parentAction === "object" &&
790
+ parentAction !== null &&
791
+ "choose" in parentAction
792
+ ) {
793
+ const choose = parentAction as ChooseInput;
794
+ const branch = choose.choose[nextStep.whenIndex];
795
+ if (branch !== undefined) {
796
+ const universe = scope.narrowedTriggers
797
+ ? [...scope.narrowedTriggers]
798
+ : subscribedTriggerIds;
799
+ const branchNarrow = narrowTriggersFromCondition(
800
+ branch.when,
801
+ universe,
802
+ );
803
+ if (branchNarrow !== undefined) {
804
+ scope.narrowedTriggers =
805
+ scope.narrowedTriggers === undefined
806
+ ? branchNarrow
807
+ : new Set(
808
+ [...scope.narrowedTriggers].filter((id) =>
809
+ branchNarrow.has(id),
810
+ ),
811
+ );
812
+ }
813
+ }
814
+ }
815
+
816
+ if (nextStep.slot === "repeat" && isRepeatAction(parentAction)) {
817
+ scope.repeats.push(
818
+ ...buildRepeatEntries({ action: parentAction, depth: scope.repeats.length / 2 }),
819
+ );
820
+ }
821
+
822
+ currentList = getChildList(parentAction, nextStep.slot, nextStep.whenIndex);
823
+ }
824
+
825
+ return scope;
826
+ }
827
+
828
+ function buildRepeatEntries(args: {
829
+ action: RepeatInput;
830
+ depth: number;
831
+ }): VariableEntry[] {
832
+ const { action } = args;
833
+ const mode = repeatMode(action);
834
+ const out: VariableEntry[] = [
835
+ {
836
+ path: "repeat.index",
837
+ templateRef: "repeat.index",
838
+ type: "number",
839
+ description: "Current iteration index (zero-based).",
840
+ jsonSchema: { type: "integer" },
841
+ },
842
+ ];
843
+ if (mode === "for_each") {
844
+ out.push({
845
+ path: "repeat.item",
846
+ templateRef: "repeat.item",
847
+ type: "unknown",
848
+ description: "Current item in the iterated collection.",
849
+ jsonSchema: {},
850
+ });
851
+ }
852
+ return out;
853
+ }
854
+
855
+ function accumulatePrefix(args: {
856
+ slice: ActionInput[];
857
+ actions: ActionInfo[];
858
+ artifactTypes: ArtifactTypeInfo[];
859
+ scope: AccumulatedScope;
860
+ }): void {
861
+ const { slice, actions, artifactTypes, scope } = args;
862
+
863
+ for (const action of slice) {
864
+ if (isVariablesAction(action)) {
865
+ for (const [name, value] of Object.entries(action.variables)) {
866
+ if (scope.vars.some((v) => v.path === `var.${name}`)) continue;
867
+ scope.vars.push({
868
+ path: `var.${name}`,
869
+ templateRef: appendTemplateSegment({
870
+ base: "variables",
871
+ segment: name,
872
+ }),
873
+ type: typeof value === "string" ? "string | template" : typeof value,
874
+ description: "Operator-defined variable.",
875
+ });
876
+ }
877
+ }
878
+ if (isProviderAction(action)) {
879
+ const registered = actions.find((a) => a.qualifiedId === action.action);
880
+ const produces = registered?.produces;
881
+ // The runtime exposes a produced artifact as
882
+ // `artifacts.<actionId>.<localName>.<field>`. Only producing actions
883
+ // that carry an `id` are referenceable, so skip the rest.
884
+ if (produces && action.id) {
885
+ const artifactInfo = artifactTypes.find(
886
+ (t) => t.qualifiedId === produces,
887
+ );
888
+ // localName = produces with the owning plugin prefix stripped
889
+ // (e.g. `integration-jira.issue` → `issue`).
890
+ const prefix = registered?.ownerPluginId
891
+ ? `${registered.ownerPluginId}.`
892
+ : "";
893
+ const localName =
894
+ prefix && produces.startsWith(prefix)
895
+ ? produces.slice(prefix.length)
896
+ : produces;
897
+
898
+ // Top-level node: `artifact.<id>` → templateRef `artifacts.<id>`.
899
+ const path = `artifact.${action.id}`;
900
+ if (scope.artifacts.some((a) => a.path === path)) continue;
901
+ const templateRef = appendTemplateSegment({
902
+ base: "artifacts",
903
+ segment: action.id,
904
+ });
905
+
906
+ // Intermediate localName node: `artifact.<id>.<localName>` →
907
+ // templateRef `artifacts.<id>.<localName>`, whose children are the
908
+ // artifact schema fields.
909
+ const localPath = `${path}.${localName}`;
910
+ const localTemplateRef = appendTemplateSegment({
911
+ base: templateRef,
912
+ segment: localName,
913
+ });
914
+ const fieldEntries = entriesFromSchema(
915
+ artifactInfo?.schema as JsonSchemaLike | undefined,
916
+ localPath,
917
+ localTemplateRef,
918
+ );
919
+ const localNode: VariableEntry = {
920
+ path: localPath,
921
+ templateRef: localTemplateRef,
922
+ type: artifactInfo?.displayName ?? produces,
923
+ description: artifactInfo?.description,
924
+ jsonSchema: artifactInfo?.schema,
925
+ children: fieldEntries.length > 0 ? fieldEntries : undefined,
926
+ };
927
+
928
+ scope.artifacts.push({
929
+ path,
930
+ templateRef,
931
+ type: artifactInfo?.displayName ?? produces,
932
+ description: artifactInfo?.description,
933
+ jsonSchema: artifactInfo?.schema,
934
+ children: [localNode],
935
+ });
936
+ }
937
+ }
938
+ }
939
+ }
940
+
941
+ // ─── Entry point ───────────────────────────────────────────────────────────
942
+
943
+ export function resolveVariableScope(
944
+ input: ResolveVariableScopeInput,
945
+ ): VariableScope {
946
+ const { definition, triggers, actions, artifactTypes, path } = input;
947
+
948
+ if (path.length === 0 || path[0]!.slot !== "root") {
949
+ return { entries: [] };
950
+ }
951
+
952
+ const subscribedTriggerIds = definition.triggers
953
+ .map((t) => t.event)
954
+ .filter((id) => triggers.some((r) => r.qualifiedId === id));
955
+
956
+ const accumulated = accumulateAlongPath({
957
+ definition,
958
+ actions,
959
+ artifactTypes,
960
+ path,
961
+ subscribedTriggerIds,
962
+ });
963
+
964
+ // When the path's `choose-when` conditions narrowed `trigger.event` to a
965
+ // specific subset, hand the narrowed Trigger list to buildTriggerEntries
966
+ // so the resulting union has only those variants. Picker rows that were
967
+ // previously conditionalOnTriggers become unconditional inside the branch.
968
+ const effectiveTriggers = accumulated.narrowedTriggers
969
+ ? definition.triggers.filter((t) =>
970
+ accumulated.narrowedTriggers!.has(t.event),
971
+ )
972
+ : definition.triggers;
973
+
974
+ const triggerEntries = buildTriggerEntries({
975
+ triggers: effectiveTriggers,
976
+ registeredTriggers: triggers,
977
+ });
978
+
979
+ const entries: VariableEntry[] = [...triggerEntries];
980
+
981
+ if (accumulated.vars.length > 0) {
982
+ entries.push({
983
+ path: "var",
984
+ templateRef: "variables",
985
+ type: "object",
986
+ description: "Variables declared upstream in this run.",
987
+ children: accumulated.vars,
988
+ });
989
+ }
990
+
991
+ if (accumulated.artifacts.length > 0) {
992
+ entries.push({
993
+ path: "artifact",
994
+ templateRef: "artifacts",
995
+ type: "object",
996
+ description: "Artifacts produced by upstream actions in this run.",
997
+ children: accumulated.artifacts,
998
+ });
999
+ }
1000
+
1001
+ if (accumulated.repeats.length > 0) {
1002
+ entries.push({
1003
+ path: "repeat",
1004
+ templateRef: "repeat",
1005
+ type: "object",
1006
+ description: "Current repeat-iteration context.",
1007
+ children: accumulated.repeats,
1008
+ });
1009
+ }
1010
+
1011
+ return { entries };
1012
+ }
1013
+
1014
+ /**
1015
+ * Flatten a `VariableScope` tree to the leaf paths, in the order the
1016
+ * picker should display them. Object-typed parents are kept _before_ their
1017
+ * children so the picker can render headers.
1018
+ */
1019
+ export function flattenScope(scope: VariableScope): VariableEntry[] {
1020
+ const out: VariableEntry[] = [];
1021
+ const visit = (entries: VariableEntry[]): void => {
1022
+ for (const e of entries) {
1023
+ out.push(e);
1024
+ if (e.children) visit(e.children);
1025
+ }
1026
+ };
1027
+ visit(scope.entries);
1028
+ return out;
1029
+ }