@checkstack/automation-frontend 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 (44) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/package.json +13 -9
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/editor/ActionEditor.tsx +180 -90
  5. package/src/editor/ActionListEditor.tsx +27 -1
  6. package/src/editor/AddActionDialog.tsx +15 -45
  7. package/src/editor/AddConditionDialog.tsx +86 -0
  8. package/src/editor/AddTriggerDialog.tsx +97 -0
  9. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  10. package/src/editor/ConditionEditor.tsx +359 -70
  11. package/src/editor/ConditionsEditor.tsx +113 -44
  12. package/src/editor/ItemSheet.tsx +51 -0
  13. package/src/editor/RunReplayPicker.tsx +97 -0
  14. package/src/editor/ScriptServicesBooter.tsx +53 -0
  15. package/src/editor/ScriptTestRenderer.tsx +150 -0
  16. package/src/editor/SystemEntityPicker.test.ts +37 -0
  17. package/src/editor/SystemEntityPicker.tsx +109 -0
  18. package/src/editor/TriggersEditor.tsx +345 -137
  19. package/src/editor/action-helpers.test.ts +107 -0
  20. package/src/editor/action-helpers.ts +72 -0
  21. package/src/editor/action-leaf-cards.tsx +98 -1
  22. package/src/editor/condition-kind.test.ts +126 -0
  23. package/src/editor/condition-kind.ts +130 -0
  24. package/src/editor/item-summary.test.ts +171 -0
  25. package/src/editor/item-summary.ts +210 -0
  26. package/src/editor/picker-dialog.tsx +156 -0
  27. package/src/editor/registry-context.tsx +9 -2
  28. package/src/editor/script-actions.test.ts +184 -0
  29. package/src/editor/script-actions.ts +146 -0
  30. package/src/editor/system-entity-picker.logic.ts +23 -0
  31. package/src/editor/template-completion.test.ts +22 -3
  32. package/src/editor/template-completion.ts +16 -8
  33. package/src/editor/template-helpers.ts +4 -0
  34. package/src/editor/trigger-helpers.test.ts +28 -0
  35. package/src/editor/trigger-helpers.ts +17 -0
  36. package/src/editor/useScriptDiagnostics.ts +108 -0
  37. package/src/index.tsx +2 -0
  38. package/src/pages/AutomationEditPage.tsx +95 -47
  39. package/src/pages/AutomationListPage.tsx +172 -123
  40. package/src/pages/automation-grouping.test.ts +86 -0
  41. package/src/pages/automation-grouping.ts +65 -0
  42. package/src/script-context.test.ts +142 -1
  43. package/src/script-context.ts +115 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,146 @@
1
+ import type {
2
+ ActionInfo,
3
+ ActionInput,
4
+ ActionPath,
5
+ ActionPathStep,
6
+ AutomationDefinition,
7
+ } from "@checkstack/automation-common";
8
+ import type { EditorType } from "@checkstack/common";
9
+ import { actionPathToDefPath, defPathKey } from "./editor-validation";
10
+ import { secretEnvEnvNames } from "../script-context";
11
+
12
+ /**
13
+ * A single inline-script action found in a definition, with everything the
14
+ * headless validator + issue mapping need - independent of whether the action's
15
+ * editor card is mounted/expanded. `collectScriptActions` walks the whole
16
+ * action tree (including collapsed and nested actions) so every script is
17
+ * validated, not just the one on screen.
18
+ */
19
+ export interface ScriptActionRef {
20
+ /** Stable key (the issue-path joined) used to correlate validator results. */
21
+ id: string;
22
+ /** Editor path to this action - drives `generateAutomationContextTypes`. */
23
+ path: ActionPath;
24
+ /** Definition path of the script field, e.g. `["actions",0,"config","script"]`. */
25
+ issuePath: Array<string | number>;
26
+ /** The script source. */
27
+ source: string;
28
+ language: "typescript" | "javascript";
29
+ /** Declared `secretEnv` var names, for the `process.env.*` augmentation. */
30
+ secretEnvNames: string[];
31
+ }
32
+
33
+ /** Narrow an `unknown` to a plain (non-array) object record. */
34
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
35
+ if (value && typeof value === "object" && !Array.isArray(value)) {
36
+ // The lone cast: `typeof === "object"` cannot itself produce the index
37
+ // signature, and re-validating an arbitrary JSON-schema/config record with
38
+ // zod here would be wasteful. Mirrors the same helper in `script-context`.
39
+ return value as Record<string, unknown>;
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Find the script field in an action's config JSON schema by the
46
+ * `x-editor-types` annotation (NOT a hard-coded field name) and report the
47
+ * field key + the TS/JS language it should validate as. Returns undefined for
48
+ * actions with no typescript/javascript editor field (e.g. a shell action, or
49
+ * a non-script provider action).
50
+ */
51
+ function scriptFieldOf(
52
+ configSchema: Record<string, unknown>,
53
+ ): { fieldKey: string; language: "typescript" | "javascript" } | undefined {
54
+ const properties = asRecord(configSchema.properties);
55
+ if (!properties) return undefined;
56
+ for (const [key, prop] of Object.entries(properties)) {
57
+ const editorTypes = asRecord(prop)?.["x-editor-types"];
58
+ if (!Array.isArray(editorTypes)) continue;
59
+ const types = new Set(
60
+ editorTypes.filter((t): t is EditorType => typeof t === "string"),
61
+ );
62
+ if (types.has("typescript")) {
63
+ return { fieldKey: key, language: "typescript" };
64
+ }
65
+ if (types.has("javascript")) {
66
+ return { fieldKey: key, language: "javascript" };
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ /**
73
+ * Walk an automation definition's whole action tree and return every inline
74
+ * TS/JS script action with a non-empty source. The `actions` registry supplies
75
+ * each action's config schema (so the script field + secretEnv mapping are
76
+ * located by annotation, never by a hard-coded key).
77
+ *
78
+ * The walk mirrors the editor's `ActionPath` slot grammar exactly, so the
79
+ * derived definition paths line up with both the backend validator's issue
80
+ * paths and `useActionIssues` card lookups.
81
+ */
82
+ export function collectScriptActions({
83
+ definition,
84
+ actions,
85
+ }: {
86
+ definition: AutomationDefinition;
87
+ actions: ActionInfo[];
88
+ }): ScriptActionRef[] {
89
+ const infoById = new Map(actions.map((action) => [action.qualifiedId, action]));
90
+ const refs: ScriptActionRef[] = [];
91
+
92
+ const visit = (
93
+ list: ActionInput[],
94
+ parentPath: ActionPath,
95
+ slot: ActionPathStep["slot"],
96
+ whenIndex?: number,
97
+ ): void => {
98
+ for (const [index, action] of list.entries()) {
99
+ const step: ActionPathStep =
100
+ whenIndex === undefined ? { slot, index } : { slot, whenIndex, index };
101
+ const path: ActionPath = [...parentPath, step];
102
+
103
+ if ("action" in action) {
104
+ const info = infoById.get(action.action);
105
+ const field = info ? scriptFieldOf(info.configSchema) : undefined;
106
+ if (info && field) {
107
+ const config = asRecord(action.config) ?? {};
108
+ const raw = config[field.fieldKey];
109
+ const source = typeof raw === "string" ? raw : "";
110
+ if (source.trim() !== "") {
111
+ const issuePath = [
112
+ ...actionPathToDefPath(path),
113
+ "config",
114
+ field.fieldKey,
115
+ ];
116
+ refs.push({
117
+ id: defPathKey(issuePath),
118
+ path,
119
+ issuePath,
120
+ source,
121
+ language: field.language,
122
+ secretEnvNames: secretEnvEnvNames({
123
+ configSchema: info.configSchema,
124
+ config: action.config,
125
+ }),
126
+ });
127
+ }
128
+ }
129
+ } else if ("choose" in action) {
130
+ for (const [whenIdx, branch] of action.choose.entries()) {
131
+ visit(branch.sequence, path, "choose-when", whenIdx);
132
+ }
133
+ if (action.else) visit(action.else, path, "choose-else");
134
+ } else if ("parallel" in action) {
135
+ visit(action.parallel, path, "parallel");
136
+ } else if ("repeat" in action) {
137
+ visit(action.repeat.sequence, path, "repeat");
138
+ } else if ("sequence" in action) {
139
+ visit(action.sequence, path, "sequence");
140
+ }
141
+ }
142
+ };
143
+
144
+ visit(definition.actions, [], "root");
145
+ return refs;
146
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pure logic for {@link SystemEntityPicker}, extracted so the manual-entry
3
+ * fallback decision can be unit-tested without rendering the catalog-backed
4
+ * picker (which pulls in `@checkstack/ui` / Monaco).
5
+ */
6
+
7
+ /**
8
+ * Decides whether the picker should open in manual-entry mode for a given
9
+ * `entity` value and the set of known catalog system ids. Templates and ids
10
+ * that aren't in the catalog drop to manual entry so they still round-trip;
11
+ * a blank value or a known system id uses the live picker.
12
+ */
13
+ export function shouldStartManual({
14
+ value,
15
+ knownSystemIds,
16
+ }: {
17
+ value: string;
18
+ knownSystemIds: ReadonlySet<string>;
19
+ }): boolean {
20
+ if (value.length === 0) return false;
21
+ if (value.includes("{{")) return true;
22
+ return !knownSystemIds.has(value);
23
+ }
@@ -216,10 +216,29 @@ describe("createTemplateCompletionProvider — expression mode", () => {
216
216
  it("treats the whole value as an expression (no {{ needed)", () => {
217
217
  const result = provider({ value: "trigger.payload.sev", cursor: 19 });
218
218
  expect(result!.heading).toBe("Fields");
219
- // Field insert in expression mode never appends braces, but does
220
- // append a trailing space to advance to the operator stage.
219
+ // Field insert in expression mode inserts the BARE path with NO trailing
220
+ // space and no braces — a path is a complete expression on its own. The
221
+ // operator stage only fires after a MANUAL space, so picking a field
222
+ // doesn't pop comparators/filters and imply a comparison is required.
221
223
  const severity = result!.items.find((i) => i.label.includes("severity"));
222
- expect(severity?.insertText).toBe("trigger.payload.severity ");
224
+ expect(severity?.insertText).toBe("trigger.payload.severity");
225
+ });
226
+
227
+ it("does NOT auto-advance to operators after a field is inserted", () => {
228
+ // No trailing space (as the field insert now leaves it) → still the field
229
+ // stage, not the operator stage. Operators require a manual space.
230
+ const noSpace = provider({
231
+ value: "trigger.payload.severity",
232
+ cursor: "trigger.payload.severity".length,
233
+ });
234
+ expect(noSpace!.heading).toBe("Fields");
235
+
236
+ // A manual trailing space advances to the operator stage.
237
+ const withSpace = provider({
238
+ value: "trigger.payload.severity ",
239
+ cursor: "trigger.payload.severity ".length,
240
+ });
241
+ expect(withSpace!.heading).toBe("Operators");
223
242
  });
224
243
 
225
244
  it("suggests enum values after a comparator", () => {
@@ -494,6 +494,7 @@ export function createTemplateCompletionProvider(
494
494
  replaceStart,
495
495
  replaceEnd,
496
496
  appendClose: mode === "template" && !window.closed,
497
+ mode,
497
498
  });
498
499
  }
499
500
  case "operator": {
@@ -515,8 +516,9 @@ function fieldResult(args: {
515
516
  replaceStart: number;
516
517
  replaceEnd: number;
517
518
  appendClose: boolean;
519
+ mode: "template" | "expression";
518
520
  }): TemplateCompletionResult | null {
519
- const { stage, fields, replaceStart, replaceEnd, appendClose } = args;
521
+ const { stage, fields, replaceStart, replaceEnd, appendClose, mode } = args;
520
522
  const q = stage.query.toLowerCase();
521
523
  // Match + insert in templateRef space (what gets written into `{{ }}`).
522
524
  // Also match against the canonical `path` so typing the dotted form
@@ -533,13 +535,11 @@ function fieldResult(args: {
533
535
  replaceStart,
534
536
  replaceEnd,
535
537
  items: matches.map((f) => {
536
- // Always append a trailing space so the caret lands in
537
- // whitespace after the field — that re-opens completion in the
538
- // operator stage (comparators / filters) automatically, the same
539
- // way picking a comparator advances to the value stage.
540
538
  if (appendClose) {
541
- // Unclosed `{{` — also append the closing braces, and land the
542
- // caret after the space but before `}}` (offset -2 into ` }}`).
539
+ // Unclosed `{{` (template mode) — append a space + the closing
540
+ // braces and land the caret after the space but before `}}`
541
+ // (offset -2 into ` }}`); the space re-opens the operator stage,
542
+ // the natural next step inside an interpolation.
543
543
  return {
544
544
  label: f.templateRef,
545
545
  detail: f.type,
@@ -548,11 +548,19 @@ function fieldResult(args: {
548
548
  caretOffset: -2,
549
549
  } satisfies TemplateCompletionItem;
550
550
  }
551
+ // Expression mode (bare condition / filter / partitionBy): a path is a
552
+ // complete expression on its own, so DON'T auto-append a trailing space.
553
+ // The operator stage only requires whitespace, so auto-spacing would pop
554
+ // comparators/filters at the user the instant they pick a field and imply
555
+ // a comparison is mandatory. Inserting the bare path closes completion;
556
+ // the user types a space MANUALLY only when they actually want an operator
557
+ // (then the operator stage fires). Template mode keeps the auto-space
558
+ // field → operator flow.
551
559
  return {
552
560
  label: f.templateRef,
553
561
  detail: f.type,
554
562
  description: f.description,
555
- insertText: `${f.templateRef} `,
563
+ insertText: mode === "expression" ? f.templateRef : `${f.templateRef} `,
556
564
  caretOffset: 0,
557
565
  } satisfies TemplateCompletionItem;
558
566
  }),
@@ -25,6 +25,10 @@ export const TEMPLATE_FILTERS: readonly CompletionFilter[] = [
25
25
  { name: "join", signature: "separator", description: 'Join an array (default ", ").', hasArgs: true },
26
26
  { name: "replace", signature: "search, replacement", description: "Replace every occurrence.", hasArgs: true },
27
27
  { name: "not", description: "Negate truthiness." },
28
+ { name: "minutes", description: "A number of minutes, as milliseconds (e.g. 30 | minutes)." },
29
+ { name: "hours", description: "A number of hours, as milliseconds (e.g. 2 | hours)." },
30
+ { name: "duration_since", description: "Milliseconds elapsed since an ISO timestamp." },
31
+ { name: "older_than", signature: "thresholdMs", description: "True when an ISO timestamp is at least N ms in the past (e.g. ts | older_than(30 | minutes)).", hasArgs: true },
28
32
  ];
29
33
 
30
34
  /**
@@ -4,6 +4,7 @@ import {
4
4
  assignDefaultTriggerIds,
5
5
  collectTriggerIds,
6
6
  defaultTriggerId,
7
+ makeTrigger,
7
8
  } from "./trigger-helpers";
8
9
 
9
10
  describe("trigger-helpers", () => {
@@ -55,4 +56,31 @@ describe("trigger-helpers", () => {
55
56
  expect(out[0]!.id).toBe("incident_created");
56
57
  expect(out[1]!.id).toBe("incident_created_2");
57
58
  });
59
+
60
+ // The trigger type picker builds a trigger via `makeTrigger`; it must carry
61
+ // the picked event and a default id that is unique against existing siblings.
62
+ it("makeTrigger sets the picked event and a derived default id", () => {
63
+ expect(makeTrigger({ event: "incident.created", taken: new Set() })).toEqual(
64
+ { event: "incident.created", id: "incident_created" },
65
+ );
66
+ });
67
+
68
+ it("makeTrigger dedupes its default id against existing trigger ids", () => {
69
+ const out = makeTrigger({
70
+ event: "incident.created",
71
+ taken: new Set(["incident_created"]),
72
+ });
73
+ expect(out.event).toBe("incident.created");
74
+ expect(out.id).toBe("incident_created_2");
75
+ });
76
+
77
+ // Regression for the editor-load fix: a stored/seeded definition (e.g. the
78
+ // "Auto-incident: ... flapping" seed) whose trigger carries no `id` must be
79
+ // materialized to its derived id on load, so the Id field shows it without
80
+ // the operator focusing + blurring the input first.
81
+ it("materializes the derived id for an id-less seeded trigger on load", () => {
82
+ const stored: Trigger[] = [{ event: "healthcheck.flapping_detected" }];
83
+ const out = assignDefaultTriggerIds(stored);
84
+ expect(out[0]!.id).toBe("healthcheck_flapping_detected");
85
+ });
58
86
  });
@@ -48,6 +48,23 @@ export function defaultTriggerId(
48
48
  return uniqueTriggerId(suggestTriggerIdBase(trigger), taken);
49
49
  }
50
50
 
51
+ /**
52
+ * Build a fresh trigger subscribed to `event` with a unique, log-friendly
53
+ * default `id` (deduped against `taken`). Used by the trigger type picker so a
54
+ * newly-added trigger is immediately referenceable as `trigger.id` rather than
55
+ * appearing blank.
56
+ */
57
+ export function makeTrigger({
58
+ event,
59
+ taken,
60
+ }: {
61
+ event: string;
62
+ taken: Set<string>;
63
+ }): Trigger {
64
+ const base: Trigger = { event };
65
+ return { ...base, id: defaultTriggerId(base, taken) };
66
+ }
67
+
51
68
  /**
52
69
  * Return a copy of `triggers` with a stable, unique id assigned to every
53
70
  * trigger that does not already have one. Existing ids are preserved and seed
@@ -0,0 +1,108 @@
1
+ import React from "react";
2
+ import type { AutomationDefinition } from "@checkstack/automation-common";
3
+ import { onVscodeServicesReady, validateTypeScriptSources } from "@checkstack/ui";
4
+ import {
5
+ generateAutomationContextTypes,
6
+ generateSecretEnvTypes,
7
+ } from "../script-context";
8
+ import { useAutomationRegistry } from "./registry-context";
9
+ import { collectScriptActions } from "./script-actions";
10
+ import type { DefinitionIssue } from "./editor-validation";
11
+
12
+ /**
13
+ * Continuously type-check every inline TS/JS script action in `definition`
14
+ * against its generated `context` types - even the ones whose editor cards are
15
+ * collapsed or never opened - and surface the results as `DefinitionIssue`s so
16
+ * they flow through the same `ValidationProvider` / per-card badge path as
17
+ * structural validation.
18
+ *
19
+ * The check runs in the browser via the headless `validateTypeScriptSources`
20
+ * (the same standalone TS worker the editor uses), so it needs no backend
21
+ * round-trip. It is debounced and generation-guarded so a burst of edits
22
+ * collapses to one pass and a stale async result never overwrites a newer one.
23
+ *
24
+ * NOTE: this only covers the automation currently open in the editor. Scripts
25
+ * in OTHER automations, or definitions authored via YAML/API, are not seen here
26
+ * - that platform-wide coverage is the (deferred) backend typecheck's job.
27
+ */
28
+ export function useScriptDiagnostics({
29
+ definition,
30
+ }: {
31
+ definition: AutomationDefinition;
32
+ }): DefinitionIssue[] {
33
+ const { triggers, actions, artifactTypes } = useAutomationRegistry();
34
+ const [issues, setIssues] = React.useState<DefinitionIssue[]>([]);
35
+ const generationRef = React.useRef(0);
36
+
37
+ // The validator no-ops until an editor initializes the monaco-vscode services
38
+ // (it must never init them itself). Bumping this when services become ready
39
+ // re-runs the effect below, so opening any one script editor immediately
40
+ // validates ALL scripts - including the still-collapsed ones.
41
+ const [servicesReadyTick, setServicesReadyTick] = React.useState(0);
42
+ React.useEffect(
43
+ () => onVscodeServicesReady(() => setServicesReadyTick((tick) => tick + 1)),
44
+ [],
45
+ );
46
+
47
+ React.useEffect(() => {
48
+ const generation = ++generationRef.current;
49
+ const handle = setTimeout(() => {
50
+ const refs = collectScriptActions({ definition, actions });
51
+ if (refs.length === 0) {
52
+ if (generation === generationRef.current) setIssues([]);
53
+ return;
54
+ }
55
+
56
+ // Each script validates against the SAME merged type defs the editor
57
+ // shows it: the scope-derived `context` union for the action's position,
58
+ // plus the `process.env.*` augmentation for its declared secrets.
59
+ const sources = refs.map((ref) => {
60
+ const { typeDefinitions } = generateAutomationContextTypes({
61
+ definition,
62
+ triggers,
63
+ actions: actions.map((action) => ({
64
+ qualifiedId: action.qualifiedId,
65
+ produces: action.produces,
66
+ })),
67
+ artifactTypes,
68
+ path: ref.path,
69
+ });
70
+ const secretEnvLib = generateSecretEnvTypes({
71
+ envNames: ref.secretEnvNames,
72
+ });
73
+ return {
74
+ id: ref.id,
75
+ source: ref.source,
76
+ typeDefinitions: [typeDefinitions, secretEnvLib]
77
+ .filter(Boolean)
78
+ .join("\n\n"),
79
+ language: ref.language,
80
+ };
81
+ });
82
+
83
+ void validateTypeScriptSources({ sources })
84
+ .then((byId) => {
85
+ if (generation !== generationRef.current) return;
86
+ const next: DefinitionIssue[] = [];
87
+ for (const ref of refs) {
88
+ for (const diagnostic of byId.get(ref.id) ?? []) {
89
+ next.push({
90
+ path: ref.issuePath,
91
+ message: `Line ${diagnostic.line}: ${diagnostic.message}`,
92
+ });
93
+ }
94
+ }
95
+ setIssues(next);
96
+ })
97
+ .catch(() => {
98
+ // Validator failure (e.g. worker unavailable) must never break the
99
+ // editor; just leave script issues empty for this pass.
100
+ if (generation === generationRef.current) setIssues([]);
101
+ });
102
+ }, 400);
103
+
104
+ return () => clearTimeout(handle);
105
+ }, [definition, triggers, actions, artifactTypes, servicesReadyTick]);
106
+
107
+ return issues;
108
+ }
package/src/index.tsx CHANGED
@@ -17,6 +17,8 @@ import { AutomationMenuItems } from "./components/AutomationMenuItems";
17
17
 
18
18
  export {
19
19
  generateAutomationContextTypes,
20
+ generateSecretEnvTypes,
21
+ secretEnvEnvNames,
20
22
  type GenerateAutomationContextTypesInput,
21
23
  type GenerateAutomationContextTypesResult,
22
24
  } from "./script-context";