@checkstack/automation-frontend 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/package.json +38 -0
  3. package/src/components/AutomationMenuItems.tsx +37 -0
  4. package/src/editor/ActionEditor.tsx +367 -0
  5. package/src/editor/ActionListEditor.tsx +203 -0
  6. package/src/editor/AddActionDialog.tsx +225 -0
  7. package/src/editor/AutomationDefinitionContext.tsx +37 -0
  8. package/src/editor/AutomationDefinitionEditor.tsx +99 -0
  9. package/src/editor/ConditionEditor.tsx +218 -0
  10. package/src/editor/ConditionsEditor.tsx +89 -0
  11. package/src/editor/ItemPicker.tsx +147 -0
  12. package/src/editor/TriggersEditor.tsx +269 -0
  13. package/src/editor/action-composite-cards.tsx +390 -0
  14. package/src/editor/action-helpers.ts +365 -0
  15. package/src/editor/action-leaf-cards.tsx +426 -0
  16. package/src/editor/editor-validation.test.ts +95 -0
  17. package/src/editor/editor-validation.tsx +200 -0
  18. package/src/editor/registry-context.tsx +192 -0
  19. package/src/editor/template-completion.test.ts +412 -0
  20. package/src/editor/template-completion.ts +664 -0
  21. package/src/editor/template-helpers.test.ts +145 -0
  22. package/src/editor/template-helpers.ts +95 -0
  23. package/src/editor/trigger-helpers.test.ts +58 -0
  24. package/src/editor/trigger-helpers.ts +67 -0
  25. package/src/editor/useConnectionOptionResolvers.ts +80 -0
  26. package/src/editor/yaml-markers.ts +116 -0
  27. package/src/index.tsx +95 -0
  28. package/src/pages/AutomationEditPage.tsx +567 -0
  29. package/src/pages/AutomationListPage.tsx +304 -0
  30. package/src/pages/RunDetailPage.tsx +333 -0
  31. package/src/pages/RunsPage.tsx +233 -0
  32. package/src/pages/TemplatePlaygroundPage.tsx +224 -0
  33. package/src/script-context.test.ts +247 -0
  34. package/src/script-context.ts +218 -0
  35. package/tsconfig.json +29 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tests for the scope → completion-field flattening + shell-env derivation.
3
+ *
4
+ * The key invariant: completion fields carry both the canonical `path`
5
+ * (used ONLY for shell-env names, which must match the backend's
6
+ * path-based injection) and the runtime-parseable `templateRef` (what
7
+ * gets inserted into `{{ }}`).
8
+ */
9
+ import { describe, expect, it } from "bun:test";
10
+ import type { VariableScope } from "@checkstack/automation-common";
11
+ import { fieldsToShellEnvVars, flattenScopeToFields } from "./template-helpers";
12
+
13
+ const scope: VariableScope = {
14
+ entries: [
15
+ {
16
+ path: "trigger",
17
+ templateRef: "trigger",
18
+ type: "object",
19
+ children: [
20
+ {
21
+ path: "trigger.payload",
22
+ templateRef: "trigger.payload",
23
+ type: "object",
24
+ children: [
25
+ {
26
+ path: "trigger.payload.severity",
27
+ templateRef: "trigger.payload.severity",
28
+ type: "string",
29
+ jsonSchema: { type: "string", enum: ["low", "high"] },
30
+ },
31
+ ],
32
+ },
33
+ ],
34
+ },
35
+ {
36
+ path: "artifact",
37
+ templateRef: "artifacts",
38
+ type: "object",
39
+ children: [
40
+ {
41
+ path: "artifact.integration-jira.issue",
42
+ templateRef: 'artifacts["integration-jira.issue"]',
43
+ type: "object",
44
+ children: [
45
+ {
46
+ path: "artifact.integration-jira.issue.issueKey",
47
+ templateRef: 'artifacts["integration-jira.issue"].issueKey',
48
+ type: "string",
49
+ },
50
+ ],
51
+ },
52
+ ],
53
+ },
54
+ ],
55
+ };
56
+
57
+ const arrayScope: VariableScope = {
58
+ entries: [
59
+ {
60
+ path: "artifact.integration-jira.issue",
61
+ templateRef: 'artifacts["integration-jira.issue"]',
62
+ type: "object",
63
+ children: [
64
+ {
65
+ path: "artifact.integration-jira.issue.tags",
66
+ templateRef: 'artifacts["integration-jira.issue"].tags',
67
+ type: "string[]",
68
+ referenceable: true,
69
+ children: [
70
+ {
71
+ path: "artifact.integration-jira.issue.tags[0]",
72
+ templateRef: 'artifacts["integration-jira.issue"].tags[0]',
73
+ type: "string",
74
+ },
75
+ ],
76
+ },
77
+ ],
78
+ },
79
+ ],
80
+ };
81
+
82
+ describe("flattenScopeToFields", () => {
83
+ it("emits both the whole-array field and its element field", () => {
84
+ const fields = flattenScopeToFields(arrayScope);
85
+ const refs = fields.map((f) => f.templateRef);
86
+ // Whole array (referenceable) AND the element are both offered.
87
+ expect(refs).toContain('artifacts["integration-jira.issue"].tags');
88
+ expect(refs).toContain('artifacts["integration-jira.issue"].tags[0]');
89
+ });
90
+
91
+ it("derives shell env names from the canonical path for array fields", () => {
92
+ const fields = flattenScopeToFields(arrayScope);
93
+ const vars = fieldsToShellEnvVars(fields);
94
+ for (const v of vars) {
95
+ expect(v.name).toMatch(/^CHECKSTACK_/);
96
+ expect(v.name).not.toContain("[");
97
+ expect(v.name).not.toContain("]");
98
+ expect(v.name).not.toContain('"');
99
+ }
100
+ });
101
+
102
+ it("carries both path and templateRef on each leaf", () => {
103
+ const fields = flattenScopeToFields(scope);
104
+ const severity = fields.find(
105
+ (f) => f.path === "trigger.payload.severity",
106
+ );
107
+ expect(severity?.templateRef).toBe("trigger.payload.severity");
108
+ expect(severity?.enumValues).toEqual(["low", "high"]);
109
+
110
+ const issueKey = fields.find(
111
+ (f) => f.path === "artifact.integration-jira.issue.issueKey",
112
+ );
113
+ expect(issueKey?.templateRef).toBe(
114
+ 'artifacts["integration-jira.issue"].issueKey',
115
+ );
116
+ });
117
+
118
+ it("falls back to path when an entry has no templateRef", () => {
119
+ const legacyScope: VariableScope = {
120
+ entries: [{ path: "var.foo", type: "string" }],
121
+ };
122
+ const [field] = flattenScopeToFields(legacyScope);
123
+ expect(field?.templateRef).toBe("var.foo");
124
+ });
125
+ });
126
+
127
+ describe("fieldsToShellEnvVars", () => {
128
+ it("derives env names from the canonical dotted path, NOT the bracket templateRef", () => {
129
+ const fields = flattenScopeToFields(scope);
130
+ const vars = fieldsToShellEnvVars(fields);
131
+ const names = vars.map((v) => v.name);
132
+
133
+ // Artifact env var must come from the dotted path, producing a clean
134
+ // CHECKSTACK_ARTIFACT_* name — never from the bracket/quote form.
135
+ const artifactVar = names.find((n) => n.includes("ARTIFACT"));
136
+ expect(artifactVar).toBeDefined();
137
+ expect(artifactVar).toMatch(/^CHECKSTACK_/);
138
+ // No bracket / quote leakage from the templateRef into the env name.
139
+ for (const name of names) {
140
+ expect(name).not.toContain("[");
141
+ expect(name).not.toContain('"');
142
+ expect(name).not.toContain("artifacts");
143
+ }
144
+ });
145
+ });
@@ -0,0 +1,95 @@
1
+ import type { VariableEntry, VariableScope } from "@checkstack/automation-common";
2
+ import { toShellEnvKey } from "@checkstack/automation-common";
3
+ import type { ShellEnvVar } from "@checkstack/ui";
4
+ import type { CompletionField, CompletionFilter } from "./template-completion";
5
+
6
+ /**
7
+ * Built-in template filters shipped by the engine. Mirrors the registry
8
+ * built in `@checkstack/template-engine` → `createDefaultFilterRegistry`.
9
+ * Drives the "filter" stage of staged completion (after a `|`).
10
+ *
11
+ * Hand-written rather than auto-derived so we can supply human-readable
12
+ * signatures + short descriptions (the engine stores only the function).
13
+ */
14
+ export const TEMPLATE_FILTERS: readonly CompletionFilter[] = [
15
+ { name: "default", signature: "fallback", description: "Substitute when null / undefined / empty.", hasArgs: true },
16
+ { name: "upper", description: "Uppercase a string." },
17
+ { name: "lower", description: "Lowercase a string." },
18
+ { name: "capitalize", description: "Capitalise the first letter." },
19
+ { name: "trim", description: "Strip leading + trailing whitespace." },
20
+ { name: "truncate", signature: "length", description: "Truncate to N chars, append …", hasArgs: true },
21
+ { name: "length", description: "Length of a string / array / object." },
22
+ { name: "json", description: "Stringify a value as JSON." },
23
+ { name: "iso", description: "Format a date as ISO-8601." },
24
+ { name: "date", signature: "format", description: 'Format: "ISO" | "date" | "time" | "unix" | "rfc2822".', hasArgs: true },
25
+ { name: "join", signature: "separator", description: 'Join an array (default ", ").', hasArgs: true },
26
+ { name: "replace", signature: "search, replacement", description: "Replace every occurrence.", hasArgs: true },
27
+ { name: "not", description: "Negate truthiness." },
28
+ ];
29
+
30
+ /**
31
+ * Map the in-scope completion fields to the `$CHECKSTACK_*` env vars a
32
+ * shell script action receives. Uses the shared {@link toShellEnvKey}
33
+ * rule so the `$` names suggested here are exactly the ones the backend
34
+ * injects at run time.
35
+ */
36
+ export function fieldsToShellEnvVars(
37
+ fields: readonly CompletionField[],
38
+ ): ShellEnvVar[] {
39
+ // Derive from the canonical dotted `path`, NOT `templateRef`. The
40
+ // backend's `toShellEnvKey` injection is path-based, so deriving from
41
+ // `path` keeps the `$CHECKSTACK_*` names suggested here byte-identical
42
+ // to what the runtime injects (bracket-notation templateRefs would
43
+ // produce different — and wrong — env var names).
44
+ return fields.map((field) => ({
45
+ name: toShellEnvKey(field.path),
46
+ description: field.type ? `${field.path} (${field.type})` : field.path,
47
+ }));
48
+ }
49
+
50
+ /**
51
+ * Flatten a resolved `VariableScope` to the fields the completion
52
+ * provider offers in its "field" stage. Object-typed parents are
53
+ * skipped — operators interpolate leaf values, not whole objects — but
54
+ * `referenceable` nodes (arrays) are emitted alongside their element
55
+ * children so both `{{ ...tags }}` and `{{ ...tags[0] }}` are offered.
56
+ * Each field carries its `enumValues` (when the schema declared an
57
+ * `enum`) so the provider's "value" stage can suggest concrete
58
+ * comparisons.
59
+ */
60
+ export function flattenScopeToFields(scope: VariableScope): CompletionField[] {
61
+ const out: CompletionField[] = [];
62
+ const visit = (entries: readonly VariableEntry[]): void => {
63
+ for (const entry of entries) {
64
+ // Emit a field for every leaf, plus any node explicitly flagged
65
+ // `referenceable` (arrays — both the whole array and its elements are
66
+ // insertable). Always recurse into children regardless.
67
+ if (!entry.children?.length || entry.referenceable) {
68
+ out.push({
69
+ path: entry.path,
70
+ templateRef: entry.templateRef ?? entry.path,
71
+ type: entry.type,
72
+ description: entry.description,
73
+ enumValues: extractEnum(entry.jsonSchema),
74
+ });
75
+ }
76
+ if (entry.children?.length) visit(entry.children);
77
+ }
78
+ };
79
+ visit(scope.entries);
80
+ return out;
81
+ }
82
+
83
+ function extractEnum(
84
+ jsonSchema: Record<string, unknown> | undefined,
85
+ ): Array<string | number | boolean> | undefined {
86
+ const candidate = jsonSchema?.enum;
87
+ if (!Array.isArray(candidate)) return undefined;
88
+ const usable = candidate.filter(
89
+ (v): v is string | number | boolean =>
90
+ typeof v === "string" ||
91
+ typeof v === "number" ||
92
+ typeof v === "boolean",
93
+ );
94
+ return usable.length > 0 ? usable : undefined;
95
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { Trigger } from "@checkstack/automation-common";
3
+ import {
4
+ assignDefaultTriggerIds,
5
+ collectTriggerIds,
6
+ defaultTriggerId,
7
+ } from "./trigger-helpers";
8
+
9
+ describe("trigger-helpers", () => {
10
+ it("derives a default id from the event when none is set", () => {
11
+ expect(defaultTriggerId({ event: "incident.created" }, new Set())).toBe(
12
+ "incident_created",
13
+ );
14
+ });
15
+
16
+ it("dedupes against taken ids with numeric suffixes", () => {
17
+ const trigger: Trigger = { event: "incident.created" };
18
+ expect(defaultTriggerId(trigger, new Set(["incident_created"]))).toBe(
19
+ "incident_created_2",
20
+ );
21
+ expect(
22
+ defaultTriggerId(
23
+ trigger,
24
+ new Set(["incident_created", "incident_created_2"]),
25
+ ),
26
+ ).toBe("incident_created_3");
27
+ });
28
+
29
+ it("collects only non-empty ids", () => {
30
+ const triggers: Trigger[] = [
31
+ { event: "a", id: "x" },
32
+ { event: "b" },
33
+ { event: "c", id: "" },
34
+ ];
35
+ expect([...collectTriggerIds(triggers)]).toEqual(["x"]);
36
+ });
37
+
38
+ it("assigns unique ids to triggers missing them, preserving existing", () => {
39
+ const out = assignDefaultTriggerIds([
40
+ { event: "incident.created", id: "primary" },
41
+ { event: "incident.created" },
42
+ { event: "maintenance.created" },
43
+ ]);
44
+ expect(out[0]!.id).toBe("primary");
45
+ expect(out[1]!.id).toBe("incident_created");
46
+ expect(out[2]!.id).toBe("maintenance_created");
47
+ expect(new Set(out.map((t) => t.id)).size).toBe(3);
48
+ });
49
+
50
+ it("makes two triggers on the same event distinguishable", () => {
51
+ const out = assignDefaultTriggerIds([
52
+ { event: "incident.created" },
53
+ { event: "incident.created" },
54
+ ]);
55
+ expect(out[0]!.id).toBe("incident_created");
56
+ expect(out[1]!.id).toBe("incident_created_2");
57
+ });
58
+ });
@@ -0,0 +1,67 @@
1
+ import type { Trigger } from "@checkstack/automation-common";
2
+ import { deriveTriggerId } from "@checkstack/automation-common";
3
+
4
+ /**
5
+ * Auto-id helpers for triggers, mirroring `action-helpers` for actions.
6
+ *
7
+ * A trigger's `id` is what `trigger.id` resolves to at runtime and the
8
+ * discriminator that distinguishes triggers in templates / scripts - including
9
+ * two triggers subscribed to the same `event`. The editor assigns a unique,
10
+ * log-friendly default so the operator never has to, but can rename it.
11
+ */
12
+
13
+ /** Base id for a trigger: derived from its event id, falling back to `trigger`. */
14
+ function suggestTriggerIdBase(trigger: Trigger): string {
15
+ return deriveTriggerId(trigger.event) || "trigger";
16
+ }
17
+
18
+ /** Collect every assigned (non-empty) trigger id into a set. */
19
+ export function collectTriggerIds(
20
+ triggers: Trigger[],
21
+ into: Set<string> = new Set(),
22
+ ): Set<string> {
23
+ for (const trigger of triggers) {
24
+ if (typeof trigger.id === "string" && trigger.id.length > 0) {
25
+ into.add(trigger.id);
26
+ }
27
+ }
28
+ return into;
29
+ }
30
+
31
+ /** Make `base` unique against `taken`, appending `_2`, `_3`, ... as needed. */
32
+ function uniqueTriggerId(base: string, taken: Set<string>): string {
33
+ if (!taken.has(base)) return base;
34
+ let n = 2;
35
+ while (taken.has(`${base}_${n}`)) n += 1;
36
+ return `${base}_${n}`;
37
+ }
38
+
39
+ /**
40
+ * A single identifier-safe, unique default id for `trigger`, deduped against
41
+ * `taken`. Used to seed a new trigger and to re-fill the Id field when the
42
+ * operator clears it.
43
+ */
44
+ export function defaultTriggerId(
45
+ trigger: Trigger,
46
+ taken: Set<string>,
47
+ ): string {
48
+ return uniqueTriggerId(suggestTriggerIdBase(trigger), taken);
49
+ }
50
+
51
+ /**
52
+ * Return a copy of `triggers` with a stable, unique id assigned to every
53
+ * trigger that does not already have one. Existing ids are preserved and seed
54
+ * the uniqueness set. Used when seeding the starter automation so the id is
55
+ * shown immediately rather than appearing blank.
56
+ */
57
+ export function assignDefaultTriggerIds(
58
+ triggers: Trigger[],
59
+ taken: Set<string> = collectTriggerIds(triggers),
60
+ ): Trigger[] {
61
+ return triggers.map((trigger) => {
62
+ if (typeof trigger.id === "string" && trigger.id.length > 0) return trigger;
63
+ const id = uniqueTriggerId(suggestTriggerIdBase(trigger), taken);
64
+ taken.add(id);
65
+ return { ...trigger, id };
66
+ });
67
+ }
@@ -0,0 +1,80 @@
1
+ import React from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import { IntegrationApi } from "@checkstack/integration-common";
4
+ import type { OptionsResolver } from "@checkstack/ui";
5
+
6
+ /**
7
+ * Resolver name for the connection picker itself. Mirrors the
8
+ * `CONNECTION_OPTIONS` constant every integration provider defines
9
+ * (`provider.ts` → `*_RESOLVERS.CONNECTION_OPTIONS`). The bridge resolves
10
+ * this one via `listConnections` (no connection is selected yet) and every
11
+ * other resolver name via `getConnectionOptions` (cascading dropdowns that
12
+ * depend on the chosen `connectionId`).
13
+ */
14
+ const CONNECTION_RESOLVER_NAME = "connectionOptions";
15
+
16
+ /**
17
+ * Bridges a connection-backed action's `x-options-resolver` schema fields to
18
+ * the integration RPC contract so the automation editor's `DynamicForm` can
19
+ * render a working connection picker plus cascading provider dropdowns.
20
+ *
21
+ * Returns a stable, name-agnostic resolver map: the `connectionOptions`
22
+ * resolver lists the provider's connections, and any other resolver name is
23
+ * forwarded to `getConnectionOptions` for the currently selected connection,
24
+ * passing the live form values as `context` for dependent fields.
25
+ *
26
+ * When `connectionProviderId` is undefined (a non-connection action such as
27
+ * Log or Run Script), an empty map is returned so nothing tries to resolve.
28
+ */
29
+ export function useConnectionOptionResolvers(
30
+ connectionProviderId?: string,
31
+ ): Record<string, OptionsResolver> {
32
+ const client = usePluginClient(IntegrationApi);
33
+
34
+ return React.useMemo(() => {
35
+ if (!connectionProviderId) return {};
36
+ const providerId = connectionProviderId;
37
+
38
+ // A Proxy lets us serve a resolver for any resolver name a provider's
39
+ // schema references without enumerating them here; the field component
40
+ // only ever reads `optionsResolvers[resolverName]`.
41
+ const handler: ProxyHandler<Record<string, OptionsResolver>> = {
42
+ get: (_target, prop) => {
43
+ if (typeof prop !== "string") return;
44
+ const resolverName = prop;
45
+
46
+ const resolver: OptionsResolver = async (formValues) => {
47
+ if (resolverName === CONNECTION_RESOLVER_NAME) {
48
+ const connections = await client.listConnections.call({
49
+ providerId,
50
+ });
51
+ return connections.map((connection) => ({
52
+ value: connection.id,
53
+ label: connection.name,
54
+ }));
55
+ }
56
+
57
+ const connectionId = formValues.connectionId;
58
+ if (typeof connectionId !== "string" || connectionId.length === 0) {
59
+ return [];
60
+ }
61
+
62
+ const options = await client.getConnectionOptions.call({
63
+ providerId,
64
+ connectionId,
65
+ resolverName,
66
+ context: formValues,
67
+ });
68
+ return options.map((option) => ({
69
+ value: option.value,
70
+ label: option.label,
71
+ }));
72
+ };
73
+
74
+ return resolver;
75
+ },
76
+ };
77
+
78
+ return new Proxy<Record<string, OptionsResolver>>({}, handler);
79
+ }, [client, connectionProviderId]);
80
+ }
@@ -0,0 +1,116 @@
1
+ import { parseDocument } from "yaml";
2
+ import type { EditorMarker } from "@checkstack/ui";
3
+
4
+ export interface DefinitionIssue {
5
+ path: Array<string | number>;
6
+ message: string;
7
+ }
8
+
9
+ /**
10
+ * Map definition validation issues (and YAML syntax errors) onto inline
11
+ * Monaco markers so the editor squiggles the exact offending node
12
+ * instead of listing errors in a side panel.
13
+ *
14
+ * For each semantic issue we navigate the YAML document to the node at
15
+ * the issue's `path` and use its source range. When the node doesn't
16
+ * exist (e.g. a missing required field), we walk up the path to the
17
+ * nearest existing ancestor and mark that — so the operator is pointed
18
+ * at the right block even when the key itself is absent.
19
+ *
20
+ * Syntax errors come straight from the parser's own `errors` (which
21
+ * carry source offsets), so malformed YAML is squiggled too.
22
+ */
23
+ export function computeYamlMarkers(
24
+ yamlText: string,
25
+ issues: DefinitionIssue[],
26
+ ): EditorMarker[] {
27
+ const doc = parseDocument(yamlText, { keepSourceTokens: true });
28
+ const markers: EditorMarker[] = [];
29
+
30
+ // 1. Syntax errors from the parser.
31
+ for (const error of doc.errors) {
32
+ const [start, end] = error.pos;
33
+ markers.push(rangeToMarker(yamlText, start, end, error.message));
34
+ }
35
+
36
+ // 2. Semantic issues mapped to their node's range.
37
+ for (const issue of issues) {
38
+ const range = resolveRange(doc, issue.path);
39
+ if (range) {
40
+ markers.push(
41
+ rangeToMarker(yamlText, range[0], range[1], issue.message),
42
+ );
43
+ } else {
44
+ // No node anywhere along the path — fall back to the document start.
45
+ markers.push({
46
+ startLineNumber: 1,
47
+ startColumn: 1,
48
+ endLineNumber: 1,
49
+ endColumn: 2,
50
+ message: `${issue.path.join(".") || "(root)"}: ${issue.message}`,
51
+ severity: "error",
52
+ });
53
+ }
54
+ }
55
+
56
+ return markers;
57
+ }
58
+
59
+ type ParsedDocument = ReturnType<typeof parseDocument>;
60
+
61
+ /**
62
+ * Find a source range for `path`, walking up to the nearest existing
63
+ * ancestor when the exact node is missing.
64
+ */
65
+ function resolveRange(
66
+ doc: ParsedDocument,
67
+ path: Array<string | number>,
68
+ ): [number, number] | null {
69
+ for (let length = path.length; length >= 0; length--) {
70
+ const node =
71
+ length === 0
72
+ ? doc.contents
73
+ : (doc.getIn(path.slice(0, length), true) as
74
+ | { range?: [number, number, number] | null }
75
+ | undefined);
76
+ const range = node?.range;
77
+ if (range) return [range[0], range[1]];
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function rangeToMarker(
83
+ text: string,
84
+ start: number,
85
+ end: number,
86
+ message: string,
87
+ ): EditorMarker {
88
+ const from = offsetToLineCol(text, start);
89
+ // Guarantee a non-empty highlight even for zero-width ranges.
90
+ const to = offsetToLineCol(text, Math.max(end, start + 1));
91
+ return {
92
+ startLineNumber: from.line,
93
+ startColumn: from.column,
94
+ endLineNumber: to.line,
95
+ endColumn: to.column,
96
+ message,
97
+ severity: "error",
98
+ };
99
+ }
100
+
101
+ /** Convert a 0-based character offset to a 1-based Monaco line/column. */
102
+ function offsetToLineCol(
103
+ text: string,
104
+ offset: number,
105
+ ): { line: number; column: number } {
106
+ const clamped = Math.max(0, Math.min(offset, text.length));
107
+ let line = 1;
108
+ let lastLineStart = 0;
109
+ for (let i = 0; i < clamped; i++) {
110
+ if (text[i] === "\n") {
111
+ line += 1;
112
+ lastLineStart = i + 1;
113
+ }
114
+ }
115
+ return { line, column: clamped - lastLineStart + 1 };
116
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,95 @@
1
+ import {
2
+ createFrontendPlugin,
3
+ createSlotExtension,
4
+ UserMenuItemsSlot,
5
+ } from "@checkstack/frontend-api";
6
+ import {
7
+ automationRoutes,
8
+ pluginMetadata,
9
+ automationAccess,
10
+ } from "@checkstack/automation-common";
11
+ import { AutomationListPage } from "./pages/AutomationListPage";
12
+ import { AutomationEditPage } from "./pages/AutomationEditPage";
13
+ import { RunsPage } from "./pages/RunsPage";
14
+ import { RunDetailPage } from "./pages/RunDetailPage";
15
+ import { TemplatePlaygroundPage } from "./pages/TemplatePlaygroundPage";
16
+ import { AutomationMenuItems } from "./components/AutomationMenuItems";
17
+
18
+ export {
19
+ generateAutomationContextTypes,
20
+ type GenerateAutomationContextTypesInput,
21
+ type GenerateAutomationContextTypesResult,
22
+ } from "./script-context";
23
+
24
+ /**
25
+ * Frontend plugin for the automation platform.
26
+ *
27
+ * Routes:
28
+ *
29
+ * - `/automation/` → list view
30
+ * - `/automation/new` → blank edit page (create)
31
+ * - `/automation/:automationId` → edit page
32
+ * - `/automation/:automationId/runs` → run history
33
+ * - `/automation/:automationId/runs/:runId` → single run drill-down
34
+ * - `/automation/playground` → template playground
35
+ *
36
+ * Run history pages and the playground are gated on `automation.read`;
37
+ * everything that mutates state (create/edit/delete/toggle, manual run,
38
+ * cancel run) further requires `automation.manage`. The edit page
39
+ * downgrades gracefully — viewers can read the YAML but the Save / Run
40
+ * Now / Delete controls disappear.
41
+ *
42
+ * No `foreignSignals` declared: every signal the automation domain
43
+ * emits (`AUTOMATION_DEFINITION_CHANGED`, `AUTOMATION_RUN_*`) is owned
44
+ * by this plugin, so the auto-invalidator wires it up for free. If
45
+ * cross-domain signals matter later (e.g. invalidating the run list
46
+ * when a remote incident closes), declare them here.
47
+ */
48
+ export default createFrontendPlugin({
49
+ metadata: pluginMetadata,
50
+ routes: [
51
+ {
52
+ route: automationRoutes.routes.list,
53
+ element: <AutomationListPage />,
54
+ title: "Automations",
55
+ accessRule: automationAccess.read,
56
+ },
57
+ {
58
+ route: automationRoutes.routes.create,
59
+ element: <AutomationEditPage />,
60
+ title: "New automation",
61
+ accessRule: automationAccess.manage,
62
+ },
63
+ {
64
+ route: automationRoutes.routes.edit,
65
+ element: <AutomationEditPage />,
66
+ title: "Edit automation",
67
+ accessRule: automationAccess.read,
68
+ },
69
+ {
70
+ route: automationRoutes.routes.runs,
71
+ element: <RunsPage />,
72
+ title: "Run history",
73
+ accessRule: automationAccess.read,
74
+ },
75
+ {
76
+ route: automationRoutes.routes.runDetail,
77
+ element: <RunDetailPage />,
78
+ title: "Run details",
79
+ accessRule: automationAccess.read,
80
+ },
81
+ {
82
+ route: automationRoutes.routes.playground,
83
+ element: <TemplatePlaygroundPage />,
84
+ title: "Template playground",
85
+ accessRule: automationAccess.read,
86
+ },
87
+ ],
88
+ extensions: [
89
+ createSlotExtension(UserMenuItemsSlot, {
90
+ id: "automation.user-menu.items",
91
+ component: AutomationMenuItems,
92
+ metadata: { group: "Automation" },
93
+ }),
94
+ ],
95
+ });