@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,233 @@
1
+ import React from "react";
2
+ import { Link, useParams } from "react-router-dom";
3
+ import { History, ChevronLeft } from "lucide-react";
4
+ import {
5
+ usePluginClient,
6
+ accessApiRef,
7
+ useApi,
8
+ wrapInSuspense,
9
+ } from "@checkstack/frontend-api";
10
+ import {
11
+ AutomationApi,
12
+ automationAccess,
13
+ automationRoutes,
14
+ } from "@checkstack/automation-common";
15
+ import type { RunStatus } from "@checkstack/automation-common";
16
+ import {
17
+ PageLayout,
18
+ Card,
19
+ CardContent,
20
+ CardHeader,
21
+ CardTitle,
22
+ Badge,
23
+ Button,
24
+ Table,
25
+ TableHeader,
26
+ TableRow,
27
+ TableHead,
28
+ TableBody,
29
+ TableCell,
30
+ LoadingSpinner,
31
+ QueryErrorState,
32
+ EmptyState,
33
+ } from "@checkstack/ui";
34
+ import { resolveRoute } from "@checkstack/common";
35
+ import { formatDistanceToNow } from "date-fns";
36
+
37
+ const RUN_STATUS_VARIANT: Record<
38
+ RunStatus,
39
+ "default" | "secondary" | "outline" | "destructive" | "success" | "warning"
40
+ > = {
41
+ pending: "outline",
42
+ running: "secondary",
43
+ waiting: "warning",
44
+ success: "success",
45
+ failed: "destructive",
46
+ cancelled: "outline",
47
+ skipped: "outline",
48
+ };
49
+
50
+ /**
51
+ * Run history for a single automation. Status filter pinned to the top;
52
+ * rows link to the run detail page. We also surface a `← Back to
53
+ * automation` link in the header — the most common navigation from this
54
+ * page is back to the parent edit page, not back to the list.
55
+ */
56
+ const RunsPageContent: React.FC = () => {
57
+ const { automationId } = useParams<{ automationId: string }>();
58
+ const client = usePluginClient(AutomationApi);
59
+ const accessApi = useApi(accessApiRef);
60
+ const { allowed, loading: accessLoading } = accessApi.useAccess(
61
+ automationAccess.read,
62
+ );
63
+ const [statusFilter, setStatusFilter] = React.useState<RunStatus | "all">(
64
+ "all",
65
+ );
66
+
67
+ const automationQuery = client.getAutomation.useQuery(
68
+ { id: automationId ?? "" },
69
+ // Drop the cache entry as soon as this page unmounts: the editor seeds its
70
+ // form from this same `getAutomation` cache key once, and a lingering entry
71
+ // here would let it seed pre-edit (stale) data. See AutomationEditPage.
72
+ { enabled: Boolean(automationId), gcTime: 0 },
73
+ );
74
+
75
+ const runsQuery = client.listRuns.useQuery(
76
+ {
77
+ automationId: automationId ?? "",
78
+ limit: 50,
79
+ ...(statusFilter === "all" ? {} : { status: statusFilter }),
80
+ },
81
+ { enabled: Boolean(automationId) },
82
+ );
83
+
84
+ const runs = runsQuery.data?.items ?? [];
85
+
86
+ return (
87
+ <PageLayout
88
+ title={
89
+ automationQuery.data
90
+ ? `${automationQuery.data.name} — runs`
91
+ : "Run history"
92
+ }
93
+ subtitle="Past executions of this automation"
94
+ icon={History}
95
+ loading={accessLoading}
96
+ allowed={allowed}
97
+ actions={
98
+ automationId && (
99
+ <Link
100
+ to={resolveRoute(automationRoutes.routes.edit, { automationId })}
101
+ >
102
+ <Button variant="outline" size="sm">
103
+ <ChevronLeft className="mr-1 h-4 w-4" />
104
+ Back to automation
105
+ </Button>
106
+ </Link>
107
+ )
108
+ }
109
+ >
110
+ <Card>
111
+ <CardHeader className="border-b">
112
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
113
+ <CardTitle className="text-base">Runs</CardTitle>
114
+ <div className="flex flex-wrap items-center gap-1">
115
+ {(
116
+ [
117
+ "all",
118
+ "running",
119
+ "success",
120
+ "failed",
121
+ "cancelled",
122
+ "waiting",
123
+ ] as const
124
+ ).map((option) => (
125
+ <Button
126
+ key={option}
127
+ size="sm"
128
+ variant={statusFilter === option ? "primary" : "outline"}
129
+ onClick={() => setStatusFilter(option)}
130
+ className="capitalize"
131
+ >
132
+ {option}
133
+ </Button>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ </CardHeader>
138
+ <CardContent className="p-0">
139
+ {runsQuery.isLoading ? (
140
+ <div className="p-6">
141
+ <LoadingSpinner />
142
+ </div>
143
+ ) : runsQuery.isError ? (
144
+ <QueryErrorState
145
+ error={runsQuery.error}
146
+ onRetry={() => runsQuery.refetch()}
147
+ />
148
+ ) : runs.length === 0 ? (
149
+ <EmptyState
150
+ icon={<History className="h-8 w-8 text-muted-foreground" />}
151
+ title="No runs match this filter"
152
+ description="Manually trigger the automation from the edit page to generate a run."
153
+ />
154
+ ) : (
155
+ <Table>
156
+ <TableHeader>
157
+ <TableRow>
158
+ <TableHead>Status</TableHead>
159
+ <TableHead>Trigger</TableHead>
160
+ <TableHead>Started</TableHead>
161
+ <TableHead>Duration</TableHead>
162
+ <TableHead className="w-24 text-right" />
163
+ </TableRow>
164
+ </TableHeader>
165
+ <TableBody>
166
+ {runs.map((run) => (
167
+ <TableRow key={run.id}>
168
+ <TableCell>
169
+ <Badge
170
+ variant={RUN_STATUS_VARIANT[run.status] ?? "outline"}
171
+ className="capitalize"
172
+ >
173
+ {run.status}
174
+ </Badge>
175
+ </TableCell>
176
+ <TableCell>
177
+ <code className="font-mono text-xs">
178
+ {run.triggerEventId || "manual"}
179
+ </code>
180
+ </TableCell>
181
+ <TableCell>
182
+ <span className="text-xs text-muted-foreground">
183
+ {formatDistanceToNow(new Date(run.startedAt), {
184
+ addSuffix: true,
185
+ })}
186
+ </span>
187
+ </TableCell>
188
+ <TableCell>
189
+ <span className="text-xs text-muted-foreground">
190
+ {formatDuration(run.startedAt, run.finishedAt)}
191
+ </span>
192
+ </TableCell>
193
+ <TableCell className="text-right">
194
+ {automationId && (
195
+ <Link
196
+ to={resolveRoute(
197
+ automationRoutes.routes.runDetail,
198
+ {
199
+ automationId,
200
+ runId: run.id,
201
+ },
202
+ )}
203
+ >
204
+ <Button variant="ghost" size="sm">
205
+ Open
206
+ </Button>
207
+ </Link>
208
+ )}
209
+ </TableCell>
210
+ </TableRow>
211
+ ))}
212
+ </TableBody>
213
+ </Table>
214
+ )}
215
+ </CardContent>
216
+ </Card>
217
+ </PageLayout>
218
+ );
219
+ };
220
+
221
+ function formatDuration(
222
+ startedAt: Date | string,
223
+ finishedAt: Date | string | null | undefined,
224
+ ): string {
225
+ if (!finishedAt) return "—";
226
+ const ms =
227
+ new Date(finishedAt).getTime() - new Date(startedAt).getTime();
228
+ if (ms < 1000) return `${ms}ms`;
229
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
230
+ return `${Math.round(ms / 60_000)}m`;
231
+ }
232
+
233
+ export const RunsPage = wrapInSuspense(RunsPageContent);
@@ -0,0 +1,224 @@
1
+ import React from "react";
2
+ import { FlaskConical } from "lucide-react";
3
+ import {
4
+ usePluginClient,
5
+ accessApiRef,
6
+ useApi,
7
+ wrapInSuspense,
8
+ } from "@checkstack/frontend-api";
9
+ import {
10
+ AutomationApi,
11
+ automationAccess,
12
+ } from "@checkstack/automation-common";
13
+ import {
14
+ Card,
15
+ CardHeader,
16
+ CardTitle,
17
+ CardContent,
18
+ Button,
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ CodeEditor,
25
+ PageLayout,
26
+ Alert,
27
+ AlertTitle,
28
+ AlertDescription,
29
+ Label,
30
+ } from "@checkstack/ui";
31
+ import { extractErrorMessage } from "@checkstack/common";
32
+
33
+ const DEFAULT_TEMPLATE = `Hello {{ trigger.payload.title }}!
34
+
35
+ The incident severity is {{ trigger.payload.severity | upper }}.`;
36
+
37
+ const DEFAULT_CONTEXT = `{
38
+ "trigger": {
39
+ "event": "incident.created",
40
+ "payload": {
41
+ "incidentId": "INC-42",
42
+ "title": "API latency spike",
43
+ "severity": "high"
44
+ }
45
+ }
46
+ }`;
47
+
48
+ /**
49
+ * Live template-engine playground. Two editors on top — template body
50
+ * (with `{{ }}` syntax) and sample JSON context — and a button that
51
+ * sends both to the `renderTemplate` RPC; the result drops into the
52
+ * output card at the bottom.
53
+ *
54
+ * Switching the mode between `template` and `condition` swaps which
55
+ * field on the response is rendered (`output` for templates,
56
+ * `booleanResult` for conditions). Parse errors come back with a
57
+ * `line` / `column` so the alert can point the operator at the right
58
+ * spot — Monaco doesn't get inline markers yet (that's a Phase 12.x
59
+ * polish), but the message text is precise.
60
+ */
61
+ const TemplatePlaygroundContent: React.FC = () => {
62
+ const client = usePluginClient(AutomationApi);
63
+ const accessApi = useApi(accessApiRef);
64
+ const { allowed, loading: accessLoading } = accessApi.useAccess(
65
+ automationAccess.read,
66
+ );
67
+
68
+ const [template, setTemplate] = React.useState(DEFAULT_TEMPLATE);
69
+ const [contextText, setContextText] = React.useState(DEFAULT_CONTEXT);
70
+ const [mode, setMode] = React.useState<"template" | "condition">("template");
71
+ const [result, setResult] = React.useState<
72
+ | { kind: "ok"; output?: string; booleanResult?: boolean }
73
+ | { kind: "err"; message: string; line?: number; column?: number }
74
+ | null
75
+ >(null);
76
+
77
+ const renderMutation = client.renderTemplate.useMutation({
78
+ onSuccess: (data) => {
79
+ if (data.success) {
80
+ setResult({
81
+ kind: "ok",
82
+ output: data.output,
83
+ booleanResult: data.booleanResult,
84
+ });
85
+ } else if (data.error) {
86
+ setResult({
87
+ kind: "err",
88
+ message: data.error.message,
89
+ line: data.error.line,
90
+ column: data.error.column,
91
+ });
92
+ } else {
93
+ setResult({ kind: "err", message: "Unknown rendering error" });
94
+ }
95
+ },
96
+ onError: (error) => {
97
+ setResult({ kind: "err", message: extractErrorMessage(error) });
98
+ },
99
+ });
100
+
101
+ const handleRender = () => {
102
+ let parsedContext: Record<string, unknown> = {};
103
+ try {
104
+ parsedContext = JSON.parse(contextText) as Record<string, unknown>;
105
+ } catch (error) {
106
+ setResult({
107
+ kind: "err",
108
+ message: `Sample context is not valid JSON: ${extractErrorMessage(error)}`,
109
+ });
110
+ return;
111
+ }
112
+ renderMutation.mutate({
113
+ template,
114
+ context: parsedContext,
115
+ mode,
116
+ });
117
+ };
118
+
119
+ return (
120
+ <PageLayout
121
+ title="Template Playground"
122
+ subtitle="Test templates and conditions against a sample trigger payload"
123
+ icon={FlaskConical}
124
+ loading={accessLoading}
125
+ allowed={allowed}
126
+ >
127
+ <div className="grid gap-4 lg:grid-cols-2">
128
+ <Card>
129
+ <CardHeader className="border-b">
130
+ <CardTitle className="text-base flex items-center gap-2">
131
+ Template
132
+ <Select
133
+ value={mode}
134
+ onValueChange={(value) =>
135
+ setMode(value as "template" | "condition")
136
+ }
137
+ >
138
+ <SelectTrigger className="ml-auto h-7 w-36 text-xs">
139
+ <SelectValue />
140
+ </SelectTrigger>
141
+ <SelectContent>
142
+ <SelectItem value="template">Template</SelectItem>
143
+ <SelectItem value="condition">Condition</SelectItem>
144
+ </SelectContent>
145
+ </Select>
146
+ </CardTitle>
147
+ </CardHeader>
148
+ <CardContent className="p-0">
149
+ <CodeEditor
150
+ value={template}
151
+ onChange={setTemplate}
152
+ language="markdown"
153
+ minHeight="320px"
154
+ />
155
+ </CardContent>
156
+ </Card>
157
+ <Card>
158
+ <CardHeader className="border-b">
159
+ <CardTitle className="text-base">Sample context (JSON)</CardTitle>
160
+ </CardHeader>
161
+ <CardContent className="p-0">
162
+ <CodeEditor
163
+ value={contextText}
164
+ onChange={setContextText}
165
+ language="json"
166
+ minHeight="320px"
167
+ />
168
+ </CardContent>
169
+ </Card>
170
+ </div>
171
+
172
+ <div className="mt-4 flex items-center gap-2">
173
+ <Button
174
+ type="button"
175
+ onClick={handleRender}
176
+ disabled={renderMutation.isPending}
177
+ >
178
+ {renderMutation.isPending ? "Rendering…" : "Render"}
179
+ </Button>
180
+ </div>
181
+
182
+ <Card className="mt-4">
183
+ <CardHeader className="border-b">
184
+ <CardTitle className="text-base">Result</CardTitle>
185
+ </CardHeader>
186
+ <CardContent>
187
+ {!result && (
188
+ <p className="text-sm text-muted-foreground italic">
189
+ Click Render to evaluate.
190
+ </p>
191
+ )}
192
+ {result?.kind === "ok" && mode === "template" && (
193
+ <pre className="whitespace-pre-wrap font-mono text-sm">
194
+ {result.output ?? ""}
195
+ </pre>
196
+ )}
197
+ {result?.kind === "ok" && mode === "condition" && (
198
+ <Label className="text-sm">
199
+ Result:{" "}
200
+ <span className="font-mono">
201
+ {result.booleanResult === true ? "true" : "false"}
202
+ </span>
203
+ </Label>
204
+ )}
205
+ {result?.kind === "err" && (
206
+ <Alert variant="error">
207
+ <AlertTitle>Render failed</AlertTitle>
208
+ <AlertDescription>
209
+ {result.message}
210
+ {result.line !== undefined && (
211
+ <span className="ml-2 text-xs font-mono opacity-70">
212
+ (line {result.line}, column {result.column})
213
+ </span>
214
+ )}
215
+ </AlertDescription>
216
+ </Alert>
217
+ )}
218
+ </CardContent>
219
+ </Card>
220
+ </PageLayout>
221
+ );
222
+ };
223
+
224
+ export const TemplatePlaygroundPage = wrapInSuspense(TemplatePlaygroundContent);
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Tests for the automation-context type-declaration generator.
3
+ *
4
+ * Each scenario builds a tiny definition + registry fixture, runs
5
+ * `generateAutomationContextTypes`, and asserts on key fragments of the
6
+ * emitted TS source. Full source-equivalence is brittle (whitespace +
7
+ * field ordering shift with stylistic changes), so the assertions
8
+ * target the load-bearing semantic invariants.
9
+ */
10
+ import { describe, expect, it } from "bun:test";
11
+ import type {
12
+ ArtifactTypeInfo,
13
+ AutomationDefinition,
14
+ TriggerInfo,
15
+ } from "@checkstack/automation-common";
16
+ import { generateAutomationContextTypes } from "./script-context";
17
+
18
+ const incidentCreated: TriggerInfo = {
19
+ qualifiedId: "incident.created",
20
+ displayName: "Incident Created",
21
+ category: "Incidents",
22
+ ownerPluginId: "incident",
23
+ payloadSchema: {
24
+ type: "object",
25
+ properties: {
26
+ incidentId: { type: "string" },
27
+ title: { type: "string" },
28
+ },
29
+ required: ["incidentId", "title"],
30
+ },
31
+ };
32
+
33
+ const incidentResolved: TriggerInfo = {
34
+ qualifiedId: "incident.resolved",
35
+ displayName: "Incident Resolved",
36
+ category: "Incidents",
37
+ ownerPluginId: "incident",
38
+ payloadSchema: {
39
+ type: "object",
40
+ properties: {
41
+ incidentId: { type: "string" },
42
+ resolvedAt: { type: "string" },
43
+ },
44
+ required: ["incidentId"],
45
+ },
46
+ };
47
+
48
+ const jiraIssue: ArtifactTypeInfo = {
49
+ // Qualified id as the registry emits it: `${ownerPluginId}.${localId}`.
50
+ qualifiedId: "integration-jira.issue",
51
+ displayName: "Jira Issue",
52
+ ownerPluginId: "integration-jira",
53
+ schema: {
54
+ type: "object",
55
+ properties: {
56
+ key: { type: "string" },
57
+ url: { type: "string" },
58
+ },
59
+ required: ["key"],
60
+ },
61
+ };
62
+
63
+ function baseDef(
64
+ overrides: Partial<AutomationDefinition> = {},
65
+ ): AutomationDefinition {
66
+ return {
67
+ name: "Test",
68
+ triggers: [{ event: "incident.created" }],
69
+ conditions: [],
70
+ actions: [],
71
+ mode: "single",
72
+ max_runs: 1,
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ describe("generateAutomationContextTypes", () => {
78
+ it("emits a single-variant trigger union for one subscribed trigger", () => {
79
+ const { typeDefinitions } = generateAutomationContextTypes({
80
+ definition: baseDef(),
81
+ triggers: [incidentCreated, incidentResolved],
82
+ artifactTypes: [],
83
+ path: [{ slot: "root", index: 0 }],
84
+ });
85
+ expect(typeDefinitions).toContain('readonly event: "incident.created"');
86
+ // The other registered trigger must NOT appear in the union — it
87
+ // isn't subscribed by this automation.
88
+ expect(typeDefinitions).not.toContain('readonly event: "incident.resolved"');
89
+ });
90
+
91
+ it("carries trigger.id (derived) and a shared actor on the trigger union", () => {
92
+ const { typeDefinitions } = generateAutomationContextTypes({
93
+ definition: baseDef(),
94
+ triggers: [incidentCreated, incidentResolved],
95
+ artifactTypes: [],
96
+ path: [{ slot: "root", index: 0 }],
97
+ });
98
+ // id is a literal, derived from the event when no explicit id is set.
99
+ expect(typeDefinitions).toContain('readonly id: "incident_created"');
100
+ // actor is shared across every variant (intersected onto the union).
101
+ expect(typeDefinitions).toContain("type AutomationActor =");
102
+ expect(typeDefinitions).toContain("readonly actor: AutomationActor");
103
+ });
104
+
105
+ it("uses explicit trigger ids to discriminate two triggers on the same event", () => {
106
+ const def = baseDef({
107
+ triggers: [
108
+ { event: "incident.created", id: "majors" },
109
+ { event: "incident.created", id: "minors" },
110
+ ],
111
+ });
112
+ const { typeDefinitions } = generateAutomationContextTypes({
113
+ definition: def,
114
+ triggers: [incidentCreated],
115
+ artifactTypes: [],
116
+ path: [{ slot: "root", index: 0 }],
117
+ });
118
+ expect(typeDefinitions).toContain('readonly id: "majors"');
119
+ expect(typeDefinitions).toContain('readonly id: "minors"');
120
+ });
121
+
122
+ it("emits a multi-variant discriminated union when multiple triggers are subscribed", () => {
123
+ const def = baseDef({
124
+ triggers: [{ event: "incident.created" }, { event: "incident.resolved" }],
125
+ });
126
+ const { typeDefinitions } = generateAutomationContextTypes({
127
+ definition: def,
128
+ triggers: [incidentCreated, incidentResolved],
129
+ artifactTypes: [],
130
+ path: [{ slot: "root", index: 0 }],
131
+ });
132
+ expect(typeDefinitions).toContain('readonly event: "incident.created"');
133
+ expect(typeDefinitions).toContain('readonly event: "incident.resolved"');
134
+ // Each variant carries its own payload — `title` only appears in the
135
+ // incident.created arm; `resolvedAt` only in incident.resolved.
136
+ expect(typeDefinitions).toContain("title");
137
+ expect(typeDefinitions).toContain("resolvedAt");
138
+ });
139
+
140
+ it("emits artifacts keyed by producing action id, nested by local name", () => {
141
+ const def = baseDef({
142
+ actions: [
143
+ {
144
+ id: "open_jira",
145
+ action: "integration-jira.create_issue",
146
+ config: {},
147
+ enabled: true,
148
+ continue_on_error: false,
149
+ },
150
+ {
151
+ action: "automation.notify_user",
152
+ config: {},
153
+ enabled: true,
154
+ continue_on_error: false,
155
+ },
156
+ ],
157
+ });
158
+ const { typeDefinitions } = generateAutomationContextTypes({
159
+ definition: def,
160
+ triggers: [incidentCreated],
161
+ artifactTypes: [jiraIssue],
162
+ actions: [
163
+ // `produces` from listActions is the QUALIFIED artifact type id
164
+ // (the registry qualifies it on registration), matching `jiraIssue`.
165
+ {
166
+ qualifiedId: "integration-jira.create_issue",
167
+ produces: "integration-jira.issue",
168
+ },
169
+ { qualifiedId: "automation.notify_user" },
170
+ ],
171
+ path: [{ slot: "root", index: 1 }],
172
+ });
173
+ // Keyed by the producing action's id, nested by local artifact name -
174
+ // mirrors the runtime `artifacts.open_jira.issue.key`.
175
+ expect(typeDefinitions).toContain('readonly "open_jira"');
176
+ expect(typeDefinitions).toContain('readonly "issue"');
177
+ expect(typeDefinitions).toContain("key");
178
+ expect(typeDefinitions).toContain("url");
179
+ });
180
+
181
+ it("falls back to Record<string, unknown> for artifacts when no producing actions are upstream", () => {
182
+ const { typeDefinitions } = generateAutomationContextTypes({
183
+ definition: baseDef(),
184
+ triggers: [incidentCreated],
185
+ artifactTypes: [jiraIssue],
186
+ actions: [],
187
+ path: [{ slot: "root", index: 0 }],
188
+ });
189
+ expect(typeDefinitions).toContain(
190
+ "readonly artifacts: Record<string, unknown>",
191
+ );
192
+ });
193
+
194
+ it("emits a repeat context only when the path descends through a repeat", () => {
195
+ const def = baseDef({
196
+ actions: [
197
+ {
198
+ repeat: {
199
+ for_each: "{{ trigger.payload.items }}",
200
+ sequence: [
201
+ {
202
+ action: "automation.notify_user",
203
+ config: {},
204
+ enabled: true,
205
+ continue_on_error: false,
206
+ },
207
+ ],
208
+ },
209
+ enabled: true,
210
+ continue_on_error: false,
211
+ },
212
+ ],
213
+ });
214
+ const inside = generateAutomationContextTypes({
215
+ definition: def,
216
+ triggers: [incidentCreated],
217
+ artifactTypes: [],
218
+ actions: [{ qualifiedId: "automation.notify_user" }],
219
+ path: [
220
+ { slot: "root", index: 0 },
221
+ { slot: "repeat", index: 0 },
222
+ ],
223
+ });
224
+ expect(inside.typeDefinitions).toContain("readonly repeat:");
225
+ expect(inside.typeDefinitions).toContain("readonly index: number");
226
+ expect(inside.typeDefinitions).toContain("readonly item: unknown");
227
+
228
+ const outside = generateAutomationContextTypes({
229
+ definition: def,
230
+ triggers: [incidentCreated],
231
+ artifactTypes: [],
232
+ actions: [{ qualifiedId: "automation.notify_user" }],
233
+ path: [{ slot: "root", index: 0 }],
234
+ });
235
+ expect(outside.typeDefinitions).not.toContain("readonly repeat:");
236
+ });
237
+
238
+ it("returns the resolved scope alongside the type definitions for picker reuse", () => {
239
+ const { scope } = generateAutomationContextTypes({
240
+ definition: baseDef(),
241
+ triggers: [incidentCreated],
242
+ artifactTypes: [],
243
+ path: [{ slot: "root", index: 0 }],
244
+ });
245
+ expect(scope.entries.some((e) => e.path === "trigger")).toBe(true);
246
+ });
247
+ });