@hachej/boring-automation 0.1.71 → 0.1.80

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/front/index.tsx
4
4
  import { definePlugin } from "@hachej/boring-workspace/plugin";
5
- import { CalendarClock } from "lucide-react";
5
+ import { CalendarClock as CalendarClock2 } from "lucide-react";
6
6
 
7
7
  // src/shared/constants.ts
8
8
  var BORING_AUTOMATION_PLUGIN_ID = "boring-automation";
@@ -12,12 +12,110 @@ var BORING_AUTOMATION_ROUTE_PREFIX = "/api/v1/boring-automation";
12
12
  // src/shared/error-codes.ts
13
13
  var BORING_AUTOMATION_ERROR_CODES = {
14
14
  INVALID_BODY: "BORING_AUTOMATION_INVALID_BODY",
15
+ INVALID_CRON: "BORING_AUTOMATION_INVALID_CRON",
16
+ INVALID_TIMEZONE: "BORING_AUTOMATION_INVALID_TIMEZONE",
17
+ INVALID_MODEL: "BORING_AUTOMATION_INVALID_MODEL",
15
18
  AUTOMATION_NOT_FOUND: "BORING_AUTOMATION_NOT_FOUND",
16
- RUN_NOT_FOUND: "BORING_AUTOMATION_RUN_NOT_FOUND"
19
+ RUN_NOT_FOUND: "BORING_AUTOMATION_RUN_NOT_FOUND",
20
+ RUN_ALREADY_ACTIVE: "BORING_AUTOMATION_RUN_ALREADY_ACTIVE",
21
+ RUN_ALREADY_RECORDED: "BORING_AUTOMATION_RUN_ALREADY_RECORDED",
22
+ RUN_EXECUTOR_UNAVAILABLE: "BORING_AUTOMATION_RUN_EXECUTOR_UNAVAILABLE",
23
+ TRIGGER_FORBIDDEN: "BORING_AUTOMATION_TRIGGER_FORBIDDEN",
24
+ RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED"
17
25
  };
18
26
 
19
27
  // src/shared/schema.ts
20
28
  import { z } from "zod";
29
+
30
+ // src/shared/schedule.ts
31
+ import { Cron } from "croner";
32
+ var AUTOMATION_SCHEDULE_ERRORS = {
33
+ INVALID_CRON: "Invalid cron schedule. Use exactly five fields, for example 0 9 * * *.",
34
+ INVALID_TIMEZONE: "Invalid timezone. Use a valid IANA timezone, for example UTC or America/New_York."
35
+ };
36
+ function validateAutomationSchedule(cron, timezone) {
37
+ const errors = {};
38
+ if (!isValidFiveFieldCron(cron)) errors.cron = AUTOMATION_SCHEDULE_ERRORS.INVALID_CRON;
39
+ if (!isValidIanaTimeZone(timezone)) errors.timezone = AUTOMATION_SCHEDULE_ERRORS.INVALID_TIMEZONE;
40
+ return { ok: Object.keys(errors).length === 0, errors };
41
+ }
42
+ function isValidFiveFieldCron(cron) {
43
+ const normalized = normalizeSpace(cron);
44
+ if (!normalized || normalized.split(" ").length !== 5) return false;
45
+ try {
46
+ new Cron(normalized);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+ function isValidIanaTimeZone(timezone) {
53
+ const normalized = timezone.trim();
54
+ if (!normalized) return false;
55
+ try {
56
+ const resolved = new Intl.DateTimeFormat("en-US", { timeZone: normalized }).resolvedOptions().timeZone;
57
+ return resolved === "UTC" || resolved.includes("/");
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+ function evaluateAutomationSchedule(input) {
63
+ const now = new Date(input.now);
64
+ const scheduledFor = floorToMinute(now).toISOString();
65
+ const runs = [...input.runs];
66
+ const decisions = input.automations.map((automation) => {
67
+ const validation = validateAutomationSchedule(automation.cron, automation.timezone);
68
+ if (validation.errors.cron) return skip(automation, null, "invalid-cron", validation.errors.cron);
69
+ if (validation.errors.timezone) return skip(automation, null, "invalid-timezone", validation.errors.timezone);
70
+ if (!automation.enabled) return skip(automation, null, "disabled", "Automation is disabled.");
71
+ const cron = new Cron(normalizeSpace(automation.cron), { timezone: automation.timezone.trim() });
72
+ if (!cron.match(new Date(scheduledFor))) {
73
+ return skip(automation, null, "not-current-minute", "Automation is not scheduled for the current minute.");
74
+ }
75
+ if (hasDuplicateScheduledRun(runs, automation.id, scheduledFor)) {
76
+ return skip(automation, scheduledFor, "duplicate-scheduled-run", "A run already exists for this scheduled minute.");
77
+ }
78
+ if (hasActiveRun(runs, automation.id)) {
79
+ return skip(automation, scheduledFor, "active-run", "Automation already has a queued or running run.");
80
+ }
81
+ return {
82
+ kind: "due",
83
+ automation,
84
+ automationId: automation.id,
85
+ scheduledFor,
86
+ reason: "current-minute-matched"
87
+ };
88
+ }).sort(compareScheduleDecisions);
89
+ return { decisions, due: decisions.filter((decision) => decision.kind === "due") };
90
+ }
91
+ function skip(automation, scheduledFor, reason, message) {
92
+ return { kind: "skip", automation, automationId: automation.id, scheduledFor, reason, message };
93
+ }
94
+ function hasDuplicateScheduledRun(runs, automationId, scheduledFor) {
95
+ return runs.some((run) => run.automationId === automationId && run.trigger === "scheduled" && run.scheduledFor === scheduledFor);
96
+ }
97
+ function hasActiveRun(runs, automationId) {
98
+ return runs.some((run) => run.automationId === automationId && (run.status === "queued" || run.status === "running"));
99
+ }
100
+ function floorToMinute(date) {
101
+ const next = new Date(date);
102
+ next.setUTCSeconds(0, 0);
103
+ return next;
104
+ }
105
+ function normalizeSpace(value) {
106
+ return value.trim().replace(/\s+/g, " ");
107
+ }
108
+ function compareScheduleDecisions(a, b) {
109
+ return compareNullableString(a.scheduledFor, b.scheduledFor) || a.automationId.localeCompare(b.automationId);
110
+ }
111
+ function compareNullableString(a, b) {
112
+ if (a === b) return 0;
113
+ if (a === null) return 1;
114
+ if (b === null) return -1;
115
+ return a.localeCompare(b);
116
+ }
117
+
118
+ // src/shared/schema.ts
21
119
  var nonEmptyString = z.string().trim().min(1);
22
120
  var isoString = z.string().datetime({ offset: true });
23
121
  var nonNegativeInteger = z.number().int().nonnegative();
@@ -30,37 +128,32 @@ var AutomationCreateSchema = z.object({
30
128
  timezone: nonEmptyString,
31
129
  model: nonEmptyString,
32
130
  prompt: z.string().optional()
33
- }).strict();
131
+ }).strict().superRefine((value, ctx) => {
132
+ addScheduleIssues(ctx, value);
133
+ });
34
134
  var AutomationPatchSchema = z.object({
35
135
  title: nonEmptyString.optional(),
36
136
  enabled: z.boolean().optional(),
37
137
  cron: nonEmptyString.optional(),
38
138
  timezone: nonEmptyString.optional(),
39
139
  model: nonEmptyString.optional()
40
- }).strict().refine((value) => Object.keys(value).length > 0, "at least one field must be provided");
140
+ }).strict().refine((value) => Object.keys(value).length > 0, "at least one field must be provided").superRefine((value, ctx) => {
141
+ addScheduleIssues(ctx, value);
142
+ });
41
143
  var PromptUpdateSchema = z.object({
42
144
  prompt: z.string()
43
145
  }).strict();
44
- var AutomationRunCreateSchema = z.object({
146
+ var AutomationRunBeginSchema = z.object({
45
147
  automationId: nonEmptyString,
46
- sessionId: nonEmptyString.nullable().optional(),
47
- status: AutomationRunStatusSchema.optional(),
48
148
  trigger: AutomationRunTriggerSchema,
49
149
  scheduledFor: isoString.nullable().optional(),
50
- startedAt: isoString.nullable().optional(),
51
- completedAt: isoString.nullable().optional(),
52
- durationMs: nonNegativeInteger.nullable().optional(),
53
- inputTokens: nonNegativeInteger.nullable().optional(),
54
- outputTokens: nonNegativeInteger.nullable().optional(),
55
- totalTokens: nonNegativeInteger.nullable().optional(),
56
150
  promptSnapshot: z.string(),
57
151
  modelSnapshot: nonEmptyString,
58
- error: z.string().nullable().optional()
152
+ createdAt: isoString.optional()
59
153
  }).strict();
60
- var AutomationRunPatchSchema = z.object({
154
+ var AutomationRunLifecyclePatchSchema = z.object({
61
155
  sessionId: nonEmptyString.nullable().optional(),
62
156
  status: AutomationRunStatusSchema.optional(),
63
- scheduledFor: isoString.nullable().optional(),
64
157
  startedAt: isoString.nullable().optional(),
65
158
  completedAt: isoString.nullable().optional(),
66
159
  durationMs: nonNegativeInteger.nullable().optional(),
@@ -70,26 +163,854 @@ var AutomationRunPatchSchema = z.object({
70
163
  error: z.string().nullable().optional()
71
164
  }).strict().refine((value) => Object.keys(value).length > 0, "at least one field must be provided");
72
165
  var IdParamsSchema = z.object({ id: nonEmptyString });
166
+ function addScheduleIssues(ctx, value) {
167
+ if (value.cron !== void 0 && !isValidFiveFieldCron(value.cron)) {
168
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["cron"], message: AUTOMATION_SCHEDULE_ERRORS.INVALID_CRON });
169
+ }
170
+ if (value.timezone !== void 0 && !isValidIanaTimeZone(value.timezone)) {
171
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["timezone"], message: AUTOMATION_SCHEDULE_ERRORS.INVALID_TIMEZONE });
172
+ }
173
+ }
73
174
 
74
- // src/front/index.tsx
175
+ // src/front/AutomationPanel.tsx
176
+ import { useCallback, useEffect as useEffect2, useMemo as useMemo3, useRef, useState as useState2 } from "react";
177
+ import { CalendarClock, Plus, RefreshCw } from "lucide-react";
178
+ import { Button as Button4, EmptyState, Notice, Spinner } from "@hachej/boring-ui-kit";
179
+ import { useWorkspaceShellCapabilities } from "@hachej/boring-workspace/plugin";
180
+
181
+ // src/front/AutomationCard.tsx
182
+ import { ChevronDown, Play, Trash2 } from "lucide-react";
183
+ import { Button as Button2, cn as cn2 } from "@hachej/boring-ui-kit";
184
+
185
+ // src/front/format.ts
186
+ function formatDateTime(value) {
187
+ if (!value) return "\u2014";
188
+ const date = new Date(value);
189
+ if (Number.isNaN(date.getTime())) return value;
190
+ return new Intl.DateTimeFormat(void 0, {
191
+ month: "short",
192
+ day: "numeric",
193
+ hour: "numeric",
194
+ minute: "2-digit"
195
+ }).format(date);
196
+ }
197
+ function formatDuration(run) {
198
+ const value = run.durationMs ?? inferDuration(run);
199
+ if (value == null) return "\u2014";
200
+ if (value < 1e3) return `${value}ms`;
201
+ const seconds = Math.round(value / 1e3);
202
+ if (seconds < 60) return `${seconds}s`;
203
+ const minutes = Math.floor(seconds / 60);
204
+ const remainder = seconds % 60;
205
+ return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
206
+ }
207
+ function tokenTotal(run) {
208
+ if (run.totalTokens != null) return run.totalTokens;
209
+ if (run.inputTokens != null || run.outputTokens != null) return (run.inputTokens ?? 0) + (run.outputTokens ?? 0);
210
+ return null;
211
+ }
212
+ function statusLabel(status) {
213
+ switch (status) {
214
+ case "queued":
215
+ return "Queued";
216
+ case "running":
217
+ return "Running";
218
+ case "succeeded":
219
+ return "Succeeded";
220
+ case "failed":
221
+ return "Failed";
222
+ case "cancelled":
223
+ return "Cancelled";
224
+ }
225
+ }
226
+ function statusTone(status) {
227
+ switch (status) {
228
+ case "succeeded":
229
+ return "bg-[color:var(--success-soft)] text-success";
230
+ case "failed":
231
+ return "bg-destructive/10 text-destructive";
232
+ case "running":
233
+ return "bg-foreground/[0.07] text-foreground";
234
+ case "cancelled":
235
+ return "bg-foreground/[0.07] text-muted-foreground";
236
+ case "queued":
237
+ return "bg-foreground/[0.07] text-muted-foreground";
238
+ }
239
+ }
240
+ function inferDuration(run) {
241
+ if (!run?.startedAt || !run.completedAt) return null;
242
+ const started = new Date(run.startedAt).getTime();
243
+ const completed = new Date(run.completedAt).getTime();
244
+ if (Number.isNaN(started) || Number.isNaN(completed) || completed < started) return null;
245
+ return completed - started;
246
+ }
247
+
248
+ // src/front/RunHistory.tsx
249
+ import { MessageSquare } from "lucide-react";
250
+ import { Button, cn } from "@hachej/boring-ui-kit";
75
251
  import { jsx, jsxs } from "react/jsx-runtime";
252
+ function RunHistory({
253
+ runs,
254
+ loading,
255
+ onOpenRun
256
+ }) {
257
+ if (loading) {
258
+ return /* @__PURE__ */ jsx("div", { className: "px-4 py-5 text-sm text-muted-foreground", children: "Loading run history\u2026" });
259
+ }
260
+ if (runs.length === 0) {
261
+ return /* @__PURE__ */ jsx("div", { className: "px-4 py-5 text-sm text-muted-foreground", children: "No runs yet. Future execution slices will write sessions here." });
262
+ }
263
+ return /* @__PURE__ */ jsx("ul", { role: "list", className: "divide-y divide-border/60 bg-card/70", children: runs.map((run) => {
264
+ const tokens = tokenTotal(run);
265
+ const startedOrScheduled = run.startedAt ?? run.scheduledFor ?? run.createdAt;
266
+ const title = run.sessionId ? `Open session ${run.sessionId}` : "Run has no session";
267
+ return /* @__PURE__ */ jsxs("li", { className: "group flex min-h-12 items-center gap-2 px-4 py-2 text-left text-[12px] focus-within:bg-muted/40 hover:bg-muted/40 motion-reduce:transition-none", children: [
268
+ /* @__PURE__ */ jsx("span", { className: cn("shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium", statusTone(run.status)), children: statusLabel(run.status) }),
269
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
270
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-foreground", children: [
271
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: run.trigger === "scheduled" ? "Scheduled" : "Manual" }),
272
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
273
+ "Start ",
274
+ formatDateTime(startedOrScheduled)
275
+ ] }),
276
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
277
+ "Duration ",
278
+ formatDuration(run)
279
+ ] }),
280
+ tokens != null ? /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
281
+ "Tokens ",
282
+ tokens.toLocaleString()
283
+ ] }) : null
284
+ ] }),
285
+ run.error ? /* @__PURE__ */ jsx("div", { className: "mt-1 line-clamp-2 text-destructive", children: run.error }) : null
286
+ ] }),
287
+ /* @__PURE__ */ jsxs(
288
+ Button,
289
+ {
290
+ type: "button",
291
+ variant: "ghost",
292
+ size: "sm",
293
+ disabled: !run.sessionId,
294
+ "aria-label": title,
295
+ title,
296
+ className: "h-7 shrink-0 px-2 text-xs",
297
+ onClick: () => onOpenRun(run),
298
+ children: [
299
+ /* @__PURE__ */ jsx(MessageSquare, { className: "mr-1 size-3.5", "aria-hidden": "true" }),
300
+ "Session"
301
+ ]
302
+ }
303
+ )
304
+ ] }, run.id);
305
+ }) });
306
+ }
307
+
308
+ // src/front/AutomationCard.tsx
309
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
310
+ function AutomationCard({
311
+ automation,
312
+ expanded,
313
+ deleting,
314
+ runs,
315
+ runsLoading,
316
+ runningNow,
317
+ onToggle,
318
+ onEdit,
319
+ onRunNow,
320
+ onDeleteRequest,
321
+ onDeleteCancel,
322
+ onDeleteConfirm,
323
+ onOpenRun
324
+ }) {
325
+ const historyId = `automation-runs-${automation.id}`;
326
+ const deleteTitleId = `automation-delete-title-${automation.id}`;
327
+ return /* @__PURE__ */ jsxs2("article", { className: "border-b border-border/60 bg-card/80 last:border-b-0", children: [
328
+ /* @__PURE__ */ jsxs2("div", { className: "group flex min-h-14 w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-muted/50 focus-within:bg-muted/50 motion-reduce:transition-none", children: [
329
+ /* @__PURE__ */ jsxs2(
330
+ "button",
331
+ {
332
+ type: "button",
333
+ className: "flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
334
+ "aria-expanded": expanded,
335
+ "aria-controls": historyId,
336
+ onClick: onToggle,
337
+ children: [
338
+ /* @__PURE__ */ jsx2(ChevronDown, { className: cn2("size-4 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none", expanded && "rotate-180"), "aria-hidden": "true" }),
339
+ /* @__PURE__ */ jsx2("span", { className: cn2("size-2 shrink-0 rounded-full", automation.enabled ? "bg-[color:var(--success)]" : "bg-muted-foreground/40"), "aria-hidden": "true" }),
340
+ /* @__PURE__ */ jsxs2("span", { className: "min-w-0 flex-1", children: [
341
+ /* @__PURE__ */ jsx2("span", { className: "block truncate font-medium text-foreground", children: automation.title }),
342
+ /* @__PURE__ */ jsxs2("span", { className: "block truncate text-xs text-muted-foreground", children: [
343
+ automation.cron,
344
+ " \xB7 ",
345
+ automation.timezone,
346
+ " \xB7 ",
347
+ automation.model
348
+ ] })
349
+ ] }),
350
+ /* @__PURE__ */ jsxs2("span", { className: "hidden shrink-0 text-xs text-muted-foreground sm:block", children: [
351
+ "Updated ",
352
+ formatDateTime(automation.updatedAt)
353
+ ] })
354
+ ]
355
+ }
356
+ ),
357
+ /* @__PURE__ */ jsxs2(Button2, { type: "button", variant: "ghost", size: "sm", onClick: onRunNow, disabled: runningNow, "aria-label": `Run ${automation.title} now`, children: [
358
+ /* @__PURE__ */ jsx2(Play, { className: "size-3.5", "aria-hidden": "true" }),
359
+ runningNow ? "Running\u2026" : "Run now"
360
+ ] }),
361
+ /* @__PURE__ */ jsx2(Button2, { type: "button", variant: "ghost", size: "sm", onClick: onEdit, children: "Edit" }),
362
+ /* @__PURE__ */ jsx2(Button2, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Delete ${automation.title}`, title: "Delete", onClick: onDeleteRequest, children: /* @__PURE__ */ jsx2(Trash2, { className: "size-4", "aria-hidden": "true" }) })
363
+ ] }),
364
+ deleting ? /* @__PURE__ */ jsxs2("div", { className: "border-t border-border/60 bg-background px-4 py-3 text-sm", role: "region", "aria-labelledby": deleteTitleId, children: [
365
+ /* @__PURE__ */ jsx2("div", { id: deleteTitleId, className: "font-medium text-foreground", children: "Delete this automation?" }),
366
+ /* @__PURE__ */ jsx2("p", { className: "mt-1 text-muted-foreground", children: "Only automation metadata is removed. Prompt Markdown, run records, and Pi chat sessions remain." }),
367
+ /* @__PURE__ */ jsxs2("div", { className: "mt-3 flex gap-2", children: [
368
+ /* @__PURE__ */ jsx2(Button2, { type: "button", variant: "destructive", size: "sm", onClick: onDeleteConfirm, children: "Delete" }),
369
+ /* @__PURE__ */ jsx2(Button2, { type: "button", variant: "ghost", size: "sm", onClick: onDeleteCancel, children: "Cancel" })
370
+ ] })
371
+ ] }) : null,
372
+ expanded ? /* @__PURE__ */ jsxs2("div", { id: historyId, className: "border-t border-border/60", children: [
373
+ /* @__PURE__ */ jsx2("div", { className: "px-4 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70", children: "Run history" }),
374
+ /* @__PURE__ */ jsx2(RunHistory, { runs, loading: runsLoading, onOpenRun })
375
+ ] }) : null
376
+ ] });
377
+ }
378
+
379
+ // src/front/AutomationForm.tsx
380
+ import { useEffect, useMemo, useState } from "react";
381
+ import { Button as Button3, Checkbox, Field, FieldDescription, FieldError, FieldLabel, Input, Textarea } from "@hachej/boring-ui-kit";
382
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
383
+ var DEFAULT_DRAFT = {
384
+ title: "",
385
+ enabled: true,
386
+ cron: "0 9 * * *",
387
+ timezone: "UTC",
388
+ model: "",
389
+ prompt: ""
390
+ };
391
+ function draftFromAutomation(automation, prompt) {
392
+ return {
393
+ title: automation.title,
394
+ enabled: automation.enabled,
395
+ cron: automation.cron,
396
+ timezone: automation.timezone,
397
+ model: automation.model,
398
+ prompt
399
+ };
400
+ }
401
+ function emptyAutomationDraft() {
402
+ return { ...DEFAULT_DRAFT };
403
+ }
404
+ function validateAutomationDraft(draft) {
405
+ const errors = {};
406
+ if (!draft.title.trim()) errors.title = "Title is required.";
407
+ const schedule = validateAutomationSchedule(draft.cron, draft.timezone);
408
+ if (schedule.errors.cron) errors.cron = schedule.errors.cron;
409
+ if (schedule.errors.timezone) errors.timezone = schedule.errors.timezone;
410
+ const model = draft.model.trim();
411
+ if (!model) errors.model = "Model is required.";
412
+ else {
413
+ const separator = model.indexOf(":");
414
+ if (separator <= 0 || !model.slice(0, separator).trim() || !model.slice(separator + 1).trim()) {
415
+ errors.model = "Use provider:model-id syntax.";
416
+ }
417
+ }
418
+ return errors;
419
+ }
420
+ function toAutomationCreate(draft) {
421
+ return {
422
+ title: draft.title.trim(),
423
+ enabled: draft.enabled,
424
+ cron: draft.cron.trim(),
425
+ timezone: draft.timezone.trim(),
426
+ model: draft.model.trim(),
427
+ prompt: draft.prompt
428
+ };
429
+ }
430
+ function toAutomationPatch(draft) {
431
+ return {
432
+ title: draft.title.trim(),
433
+ enabled: draft.enabled,
434
+ cron: draft.cron.trim(),
435
+ timezone: draft.timezone.trim(),
436
+ model: draft.model.trim()
437
+ };
438
+ }
439
+ function AutomationForm({
440
+ automation,
441
+ prompt,
442
+ mode,
443
+ saving,
444
+ onCancel,
445
+ onSubmit
446
+ }) {
447
+ const [draft, setDraft] = useState(() => automation ? draftFromAutomation(automation, prompt) : emptyAutomationDraft());
448
+ const [submitted, setSubmitted] = useState(false);
449
+ useEffect(() => {
450
+ setDraft(automation ? draftFromAutomation(automation, prompt) : emptyAutomationDraft());
451
+ setSubmitted(false);
452
+ }, [automation, prompt]);
453
+ const errors = useMemo(() => validateAutomationDraft(draft), [draft]);
454
+ const hasErrors = Object.keys(errors).length > 0;
455
+ const cronDescriptionIds = submitted && errors.cron ? "automation-cron-description automation-cron-error" : "automation-cron-description";
456
+ const timezoneDescriptionIds = submitted && errors.timezone ? "automation-timezone-description automation-timezone-error" : "automation-timezone-description";
457
+ const modelDescriptionIds = submitted && errors.model ? "automation-model-description automation-model-error" : "automation-model-description";
458
+ function submit(event) {
459
+ event.preventDefault();
460
+ setSubmitted(true);
461
+ if (hasErrors) return;
462
+ onSubmit(draft);
463
+ }
464
+ return /* @__PURE__ */ jsxs3("form", { className: "space-y-4", onSubmit: submit, noValidate: true, "aria-label": `${mode === "create" ? "Create" : "Edit"} automation form`, children: [
465
+ /* @__PURE__ */ jsxs3("div", { className: "grid gap-3 md:grid-cols-2", children: [
466
+ /* @__PURE__ */ jsxs3(Field, { children: [
467
+ /* @__PURE__ */ jsx3(FieldLabel, { htmlFor: "automation-title", children: "Title" }),
468
+ /* @__PURE__ */ jsx3(
469
+ Input,
470
+ {
471
+ id: "automation-title",
472
+ value: draft.title,
473
+ onChange: (event) => setDraft((current) => ({ ...current, title: event.target.value })),
474
+ "aria-invalid": submitted && !!errors.title,
475
+ "aria-describedby": submitted && errors.title ? "automation-title-error" : void 0
476
+ }
477
+ ),
478
+ submitted && errors.title ? /* @__PURE__ */ jsx3(FieldError, { id: "automation-title-error", children: errors.title }) : null
479
+ ] }),
480
+ /* @__PURE__ */ jsxs3(Field, { children: [
481
+ /* @__PURE__ */ jsx3(FieldLabel, { htmlFor: "automation-model", children: "Model" }),
482
+ /* @__PURE__ */ jsx3(
483
+ Input,
484
+ {
485
+ id: "automation-model",
486
+ value: draft.model,
487
+ placeholder: "provider:model-id",
488
+ onChange: (event) => setDraft((current) => ({ ...current, model: event.target.value })),
489
+ "aria-invalid": submitted && !!errors.model,
490
+ "aria-describedby": modelDescriptionIds
491
+ }
492
+ ),
493
+ /* @__PURE__ */ jsx3(FieldDescription, { id: "automation-model-description", children: "Explicit provider and model ID, for example anthropic:claude-sonnet-4-5." }),
494
+ submitted && errors.model ? /* @__PURE__ */ jsx3(FieldError, { id: "automation-model-error", children: errors.model }) : null
495
+ ] }),
496
+ /* @__PURE__ */ jsxs3(Field, { children: [
497
+ /* @__PURE__ */ jsx3(FieldLabel, { htmlFor: "automation-cron", children: "Cron" }),
498
+ /* @__PURE__ */ jsx3(
499
+ Input,
500
+ {
501
+ id: "automation-cron",
502
+ value: draft.cron,
503
+ onChange: (event) => setDraft((current) => ({ ...current, cron: event.target.value })),
504
+ "aria-invalid": submitted && !!errors.cron,
505
+ "aria-describedby": cronDescriptionIds
506
+ }
507
+ ),
508
+ /* @__PURE__ */ jsx3(FieldDescription, { id: "automation-cron-description", children: "Five-field cron, for example 0 9 * * *" }),
509
+ submitted && errors.cron ? /* @__PURE__ */ jsx3(FieldError, { id: "automation-cron-error", children: errors.cron }) : null
510
+ ] }),
511
+ /* @__PURE__ */ jsxs3(Field, { children: [
512
+ /* @__PURE__ */ jsx3(FieldLabel, { htmlFor: "automation-timezone", children: "Timezone" }),
513
+ /* @__PURE__ */ jsx3(
514
+ Input,
515
+ {
516
+ id: "automation-timezone",
517
+ value: draft.timezone,
518
+ onChange: (event) => setDraft((current) => ({ ...current, timezone: event.target.value })),
519
+ "aria-invalid": submitted && !!errors.timezone,
520
+ "aria-describedby": timezoneDescriptionIds
521
+ }
522
+ ),
523
+ /* @__PURE__ */ jsx3(FieldDescription, { id: "automation-timezone-description", children: "IANA timezone, for example UTC or America/New_York." }),
524
+ submitted && errors.timezone ? /* @__PURE__ */ jsx3(FieldError, { id: "automation-timezone-error", children: errors.timezone }) : null
525
+ ] })
526
+ ] }),
527
+ /* @__PURE__ */ jsxs3("label", { className: "flex w-fit items-center gap-2 rounded-md text-sm text-foreground focus-within:ring-2 focus-within:ring-ring/40", children: [
528
+ /* @__PURE__ */ jsx3(
529
+ Checkbox,
530
+ {
531
+ checked: draft.enabled,
532
+ onCheckedChange: (checked) => setDraft((current) => ({ ...current, enabled: checked === true })),
533
+ "aria-label": "Automation enabled"
534
+ }
535
+ ),
536
+ "Enabled"
537
+ ] }),
538
+ /* @__PURE__ */ jsxs3(Field, { children: [
539
+ /* @__PURE__ */ jsx3(FieldLabel, { htmlFor: "automation-prompt", children: "Markdown prompt" }),
540
+ /* @__PURE__ */ jsx3(
541
+ Textarea,
542
+ {
543
+ id: "automation-prompt",
544
+ value: draft.prompt,
545
+ onChange: (event) => setDraft((current) => ({ ...current, prompt: event.target.value })),
546
+ rows: 12,
547
+ spellCheck: false,
548
+ className: "min-h-64 resize-y font-mono text-[13px] leading-5",
549
+ "aria-describedby": "automation-prompt-description"
550
+ }
551
+ ),
552
+ /* @__PURE__ */ jsx3(FieldDescription, { id: "automation-prompt-description", children: "Saved through the canonical prompt route; local CLI mode writes the Markdown prompt file." })
553
+ ] }),
554
+ /* @__PURE__ */ jsxs3("div", { className: "flex flex-wrap justify-end gap-2", children: [
555
+ /* @__PURE__ */ jsx3(Button3, { type: "button", variant: "ghost", onClick: onCancel, disabled: saving, children: "Cancel" }),
556
+ /* @__PURE__ */ jsx3(Button3, { type: "submit", disabled: saving, children: saving ? "Saving\u2026" : mode === "create" ? "Create automation" : "Save automation" })
557
+ ] })
558
+ ] });
559
+ }
560
+
561
+ // src/front/client.ts
562
+ var AutomationClientError = class extends Error {
563
+ constructor(code, message, statusCode = 0) {
564
+ super(message);
565
+ this.code = code;
566
+ this.statusCode = statusCode;
567
+ this.name = "AutomationClientError";
568
+ }
569
+ code;
570
+ statusCode;
571
+ };
572
+ function joinUrl(apiBaseUrl, path) {
573
+ const base = apiBaseUrl?.replace(/\/$/, "") ?? "";
574
+ return `${base}${path}`;
575
+ }
576
+ function timeoutReason(timeoutMs) {
577
+ if (typeof DOMException !== "undefined") return new DOMException(`Automation request timed out after ${timeoutMs}ms`, "TimeoutError");
578
+ return new Error(`Automation request timed out after ${timeoutMs}ms`);
579
+ }
580
+ function composeRequestSignal(source, timeoutMs) {
581
+ const shouldTimeout = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0;
582
+ if (!source && !shouldTimeout) return { signal: void 0, cleanup: () => {
583
+ }, timedOut: () => false };
584
+ const controller = new AbortController();
585
+ let didTimeout = false;
586
+ let timeoutId;
587
+ const abortFromSource = () => controller.abort(source?.reason);
588
+ if (source?.aborted) {
589
+ controller.abort(source.reason);
590
+ } else {
591
+ source?.addEventListener("abort", abortFromSource, { once: true });
592
+ }
593
+ if (shouldTimeout) {
594
+ timeoutId = setTimeout(() => {
595
+ didTimeout = true;
596
+ controller.abort(timeoutReason(timeoutMs));
597
+ }, timeoutMs);
598
+ }
599
+ return {
600
+ signal: controller.signal,
601
+ cleanup: () => {
602
+ if (timeoutId !== void 0) clearTimeout(timeoutId);
603
+ source?.removeEventListener("abort", abortFromSource);
604
+ },
605
+ timedOut: () => didTimeout
606
+ };
607
+ }
608
+ function createAutomationClient(options = {}) {
609
+ async function request(path, init = {}) {
610
+ const headers = {
611
+ ...init.body ? { "Content-Type": "application/json" } : {},
612
+ ...options.headers ?? {},
613
+ ...init.headers ?? {}
614
+ };
615
+ const requestSignal = composeRequestSignal(init.signal ?? void 0, options.apiTimeout);
616
+ try {
617
+ const response = await fetch(joinUrl(options.apiBaseUrl, `${BORING_AUTOMATION_ROUTE_PREFIX}${path}`), {
618
+ ...init,
619
+ headers,
620
+ signal: requestSignal.signal
621
+ });
622
+ if (response.status === 204) return void 0;
623
+ const payload = await response.json().catch(() => ({}));
624
+ if (!response.ok || payload.ok === false) {
625
+ if (response.status === 401 || response.status === 403) options.onAuthError?.(response.status);
626
+ throw new AutomationClientError(
627
+ "code" in payload && typeof payload.code === "string" ? payload.code : "BORING_AUTOMATION_ROUTE_ERROR",
628
+ "error" in payload && typeof payload.error === "string" ? payload.error : "Automation request failed",
629
+ response.status
630
+ );
631
+ }
632
+ return payload;
633
+ } catch (error) {
634
+ if (requestSignal.timedOut()) {
635
+ throw new AutomationClientError(
636
+ "BORING_AUTOMATION_TIMEOUT",
637
+ `Automation request timed out after ${options.apiTimeout}ms`
638
+ );
639
+ }
640
+ throw error;
641
+ } finally {
642
+ requestSignal.cleanup();
643
+ }
644
+ }
645
+ return {
646
+ async listAutomations(requestOptions = {}) {
647
+ const payload = await request("/automations", { signal: requestOptions.signal });
648
+ return payload.automations;
649
+ },
650
+ async createAutomation(input) {
651
+ const payload = await request("/automations", {
652
+ method: "POST",
653
+ body: JSON.stringify(input)
654
+ });
655
+ return payload.automation;
656
+ },
657
+ async getAutomation(id, requestOptions = {}) {
658
+ const payload = await request(`/automations/${encodeURIComponent(id)}`, { signal: requestOptions.signal });
659
+ return payload.automation;
660
+ },
661
+ async updateAutomation(id, patch) {
662
+ const payload = await request(`/automations/${encodeURIComponent(id)}`, {
663
+ method: "PATCH",
664
+ body: JSON.stringify(patch)
665
+ });
666
+ return payload.automation;
667
+ },
668
+ async deleteAutomation(id) {
669
+ await request(`/automations/${encodeURIComponent(id)}`, { method: "DELETE" });
670
+ },
671
+ async getPrompt(id, requestOptions = {}) {
672
+ const payload = await request(`/automations/${encodeURIComponent(id)}/prompt`, { signal: requestOptions.signal });
673
+ return payload.prompt;
674
+ },
675
+ async updatePrompt(id, prompt) {
676
+ await request(`/automations/${encodeURIComponent(id)}/prompt`, {
677
+ method: "PUT",
678
+ body: JSON.stringify({ prompt })
679
+ });
680
+ },
681
+ async runNow(id) {
682
+ const payload = await request(`/automations/${encodeURIComponent(id)}/run`, { method: "POST" });
683
+ return payload.run;
684
+ },
685
+ async listRuns(id, requestOptions = {}) {
686
+ const payload = await request(`/automations/${encodeURIComponent(id)}/runs`, { signal: requestOptions.signal });
687
+ return payload.runs;
688
+ }
689
+ };
690
+ }
691
+
692
+ // src/front/AutomationRuntimeContext.tsx
693
+ import { createContext, useContext, useMemo as useMemo2 } from "react";
694
+ import { jsx as jsx4 } from "react/jsx-runtime";
695
+ var AutomationClientContext = createContext(null);
696
+ function AutomationRuntimeProvider({ apiBaseUrl, authHeaders, onAuthError, apiTimeout, children }) {
697
+ const client = useMemo2(
698
+ () => createAutomationClient({ apiBaseUrl, headers: authHeaders, onAuthError, apiTimeout }),
699
+ [apiBaseUrl, authHeaders, onAuthError, apiTimeout]
700
+ );
701
+ return /* @__PURE__ */ jsx4(AutomationClientContext.Provider, { value: client, children });
702
+ }
703
+ function useAutomationClient() {
704
+ const client = useContext(AutomationClientContext);
705
+ if (!client) throw new Error("useAutomationClient must be used within AutomationRuntimeProvider");
706
+ return client;
707
+ }
708
+
709
+ // src/front/AutomationPanel.tsx
710
+ import { Fragment, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
711
+ function errorMessage(error) {
712
+ if (error instanceof AutomationClientError) return error.message;
713
+ if (error instanceof Error) return error.message;
714
+ return "Automation request failed";
715
+ }
716
+ function detailWithPatch(previous, patch) {
717
+ return {
718
+ prompt: previous?.prompt ?? "",
719
+ promptLoading: previous?.promptLoading ?? false,
720
+ runs: previous?.runs ?? [],
721
+ runsLoading: previous?.runsLoading ?? false,
722
+ ...patch
723
+ };
724
+ }
725
+ function patchDetail(current, automationId, patch) {
726
+ return { ...current, [automationId]: detailWithPatch(current[automationId], patch) };
727
+ }
728
+ function bumpGeneration(generations, automationId) {
729
+ const generation = (generations.current[automationId] ?? 0) + 1;
730
+ generations.current[automationId] = generation;
731
+ return generation;
732
+ }
733
+ function isCurrentGeneration(generations, automationId, generation, signal) {
734
+ return !signal?.aborted && generations.current[automationId] === generation;
735
+ }
76
736
  function AutomationPanel() {
77
- return /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 flex-col bg-background p-4 text-sm text-foreground", children: [
78
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 font-medium", children: [
79
- /* @__PURE__ */ jsx(CalendarClock, { className: "h-4 w-4 text-muted-foreground" }),
80
- BORING_AUTOMATION_PLUGIN_LABEL
737
+ const client = useAutomationClient();
738
+ const shell = useWorkspaceShellCapabilities();
739
+ const [automations, setAutomations] = useState2([]);
740
+ const [details, setDetails] = useState2({});
741
+ const [expandedId, setExpandedId] = useState2(null);
742
+ const [editor, setEditor] = useState2({ mode: "closed" });
743
+ const [deleteId, setDeleteId] = useState2(null);
744
+ const [loading, setLoading] = useState2(true);
745
+ const [saving, setSaving] = useState2(false);
746
+ const [runningNowIds, setRunningNowIds] = useState2(() => /* @__PURE__ */ new Set());
747
+ const [routeError, setRouteError] = useState2(null);
748
+ const [shellError, setShellError] = useState2(null);
749
+ const [saveNotice, setSaveNotice] = useState2(null);
750
+ const promptRequestGeneration = useRef({});
751
+ const runRequestGeneration = useRef({});
752
+ const selectedAutomation = useMemo3(
753
+ () => editor.mode === "edit" ? automations.find((automation) => automation.id === editor.automationId) ?? null : null,
754
+ [automations, editor]
755
+ );
756
+ const loadAutomations = useCallback(async (signal) => {
757
+ setLoading(true);
758
+ setRouteError(null);
759
+ try {
760
+ const next = await client.listAutomations({ signal });
761
+ setAutomations(next);
762
+ } catch (error) {
763
+ if (signal?.aborted) return;
764
+ setRouteError(errorMessage(error));
765
+ } finally {
766
+ if (!signal?.aborted) setLoading(false);
767
+ }
768
+ }, [client]);
769
+ const loadPrompt = useCallback(async (automationId, signal) => {
770
+ const generation = bumpGeneration(promptRequestGeneration, automationId);
771
+ setDetails((current) => patchDetail(current, automationId, { promptLoading: true }));
772
+ try {
773
+ const prompt = await client.getPrompt(automationId, { signal });
774
+ if (!isCurrentGeneration(promptRequestGeneration, automationId, generation, signal)) return;
775
+ setDetails((current) => patchDetail(current, automationId, { prompt, promptLoading: false }));
776
+ } catch (error) {
777
+ if (!isCurrentGeneration(promptRequestGeneration, automationId, generation, signal)) return;
778
+ setRouteError(errorMessage(error));
779
+ setDetails((current) => patchDetail(current, automationId, { promptLoading: false }));
780
+ }
781
+ }, [client]);
782
+ const loadRuns = useCallback(async (automationId, signal) => {
783
+ const generation = bumpGeneration(runRequestGeneration, automationId);
784
+ setDetails((current) => patchDetail(current, automationId, { runsLoading: true }));
785
+ try {
786
+ const runs = await client.listRuns(automationId, { signal });
787
+ if (!isCurrentGeneration(runRequestGeneration, automationId, generation, signal)) return;
788
+ setDetails((current) => patchDetail(current, automationId, { runs, runsLoading: false }));
789
+ } catch (error) {
790
+ if (!isCurrentGeneration(runRequestGeneration, automationId, generation, signal)) return;
791
+ setRouteError(errorMessage(error));
792
+ setDetails((current) => patchDetail(current, automationId, { runsLoading: false }));
793
+ }
794
+ }, [client]);
795
+ useEffect2(() => {
796
+ const controller = new AbortController();
797
+ void loadAutomations(controller.signal);
798
+ return () => controller.abort();
799
+ }, [loadAutomations]);
800
+ const openCreate = useCallback(() => {
801
+ setEditor({ mode: "create" });
802
+ setSaveNotice(null);
803
+ setShellError(null);
804
+ }, []);
805
+ const openEdit = useCallback((automation) => {
806
+ setEditor({ mode: "edit", automationId: automation.id });
807
+ setSaveNotice(null);
808
+ setShellError(null);
809
+ void loadPrompt(automation.id);
810
+ }, [loadPrompt]);
811
+ const toggleExpanded = useCallback((automation) => {
812
+ const willExpand = expandedId !== automation.id;
813
+ setExpandedId(willExpand ? automation.id : null);
814
+ setShellError(null);
815
+ if (willExpand) void loadRuns(automation.id);
816
+ }, [expandedId, loadRuns]);
817
+ async function refreshAutomationAndPrompt(automationId) {
818
+ const generation = bumpGeneration(promptRequestGeneration, automationId);
819
+ setDetails((current) => patchDetail(current, automationId, { promptLoading: true }));
820
+ const [automation, prompt] = await Promise.all([
821
+ client.getAutomation(automationId),
822
+ client.getPrompt(automationId)
823
+ ]);
824
+ if (!isCurrentGeneration(promptRequestGeneration, automationId, generation)) return;
825
+ setAutomations((current) => current.map((item) => item.id === automation.id ? automation : item));
826
+ setDetails((current) => patchDetail(current, automation.id, { prompt, promptLoading: false }));
827
+ }
828
+ async function saveDraft(draft) {
829
+ setSaving(true);
830
+ setRouteError(null);
831
+ setSaveNotice(null);
832
+ try {
833
+ if (editor.mode === "create") {
834
+ const created = await client.createAutomation(toAutomationCreate(draft));
835
+ bumpGeneration(promptRequestGeneration, created.id);
836
+ setAutomations((current) => [created, ...current]);
837
+ setDetails((current) => patchDetail(current, created.id, { prompt: draft.prompt, promptLoading: false, runs: [], runsLoading: false }));
838
+ setExpandedId(created.id);
839
+ setEditor({ mode: "edit", automationId: created.id });
840
+ setSaveNotice({ tone: "success", message: "Automation created." });
841
+ return;
842
+ }
843
+ if (editor.mode === "edit") {
844
+ const automationId = editor.automationId;
845
+ bumpGeneration(promptRequestGeneration, automationId);
846
+ await client.updatePrompt(automationId, draft.prompt);
847
+ setDetails((current) => patchDetail(current, automationId, { prompt: draft.prompt, promptLoading: false }));
848
+ try {
849
+ const updated = await client.updateAutomation(automationId, toAutomationPatch(draft));
850
+ setAutomations((current) => current.map((automation) => automation.id === updated.id ? updated : automation));
851
+ setSaveNotice({ tone: "success", message: "Automation saved." });
852
+ } catch (metadataError) {
853
+ try {
854
+ await refreshAutomationAndPrompt(automationId);
855
+ setSaveNotice({ tone: "warning", message: `Prompt saved, but automation metadata was not saved: ${errorMessage(metadataError)}. Refreshed latest server state.` });
856
+ } catch (refreshError) {
857
+ setSaveNotice({ tone: "warning", message: `Prompt saved, but automation metadata was not saved: ${errorMessage(metadataError)}. Refresh failed: ${errorMessage(refreshError)}` });
858
+ }
859
+ }
860
+ }
861
+ } catch (error) {
862
+ setRouteError(errorMessage(error));
863
+ } finally {
864
+ setSaving(false);
865
+ }
866
+ }
867
+ async function deleteAutomation(id) {
868
+ setRouteError(null);
869
+ try {
870
+ await client.deleteAutomation(id);
871
+ setAutomations((current) => current.filter((automation) => automation.id !== id));
872
+ setDetails((current) => {
873
+ const next = { ...current };
874
+ delete next[id];
875
+ return next;
876
+ });
877
+ if (expandedId === id) setExpandedId(null);
878
+ if (editor.mode === "edit" && editor.automationId === id) setEditor({ mode: "closed" });
879
+ setDeleteId(null);
880
+ setSaveNotice({ tone: "success", message: "Automation deleted." });
881
+ } catch (error) {
882
+ setRouteError(errorMessage(error));
883
+ }
884
+ }
885
+ async function runNow(automation) {
886
+ if (runningNowIds.has(automation.id)) return;
887
+ setRunningNowIds((current) => new Set(current).add(automation.id));
888
+ setRouteError(null);
889
+ setSaveNotice(null);
890
+ setExpandedId(automation.id);
891
+ try {
892
+ const run = await client.runNow(automation.id);
893
+ setDetails((current) => patchDetail(current, automation.id, {
894
+ runs: [run, ...(current[automation.id]?.runs ?? []).filter((item) => item.id !== run.id)],
895
+ runsLoading: false
896
+ }));
897
+ setSaveNotice({ tone: "success", message: run.sessionId ? "Automation finished. Open its session from run history." : "Automation finished." });
898
+ } catch (error) {
899
+ setRouteError(errorMessage(error));
900
+ await loadRuns(automation.id);
901
+ } finally {
902
+ setRunningNowIds((current) => {
903
+ const next = new Set(current);
904
+ next.delete(automation.id);
905
+ return next;
906
+ });
907
+ }
908
+ }
909
+ function openRun(run) {
910
+ if (!run.sessionId) return;
911
+ const result = shell.openDetachedChat(run.sessionId, { title: run.modelSnapshot || "Automation run" });
912
+ setShellError(result.success ? null : result.message);
913
+ }
914
+ const editorPrompt = selectedAutomation ? details[selectedAutomation.id]?.prompt ?? "" : emptyAutomationDraft().prompt;
915
+ const editorLoading = editor.mode === "edit" && selectedAutomation ? details[selectedAutomation.id]?.promptLoading === true : false;
916
+ return /* @__PURE__ */ jsxs4("div", { className: "flex h-full min-h-0 flex-col bg-background text-sm text-foreground", children: [
917
+ /* @__PURE__ */ jsxs4("header", { className: "flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-border/60 px-4 py-3", children: [
918
+ /* @__PURE__ */ jsxs4("div", { className: "min-w-0", children: [
919
+ /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-2", children: [
920
+ /* @__PURE__ */ jsx5("span", { className: "grid size-7 place-items-center rounded-lg bg-[color:oklch(from_var(--accent)_l_c_h/0.14)] text-[color:var(--accent)]", children: /* @__PURE__ */ jsx5(CalendarClock, { className: "size-4", "aria-hidden": "true" }) }),
921
+ /* @__PURE__ */ jsx5("h2", { className: "truncate text-sm font-semibold tracking-tight", children: BORING_AUTOMATION_PLUGIN_LABEL })
922
+ ] }),
923
+ /* @__PURE__ */ jsx5("p", { className: "mt-1 text-xs text-muted-foreground", children: "Local scheduled prompts with Markdown prompt files and Pi session history." })
924
+ ] }),
925
+ /* @__PURE__ */ jsxs4("div", { className: "flex shrink-0 items-center gap-2", children: [
926
+ /* @__PURE__ */ jsxs4(Button4, { type: "button", variant: "ghost", size: "sm", onClick: () => void loadAutomations(), disabled: loading || editor.mode !== "closed", children: [
927
+ /* @__PURE__ */ jsx5(RefreshCw, { className: "size-4", "aria-hidden": "true" }),
928
+ "Refresh"
929
+ ] }),
930
+ /* @__PURE__ */ jsxs4(Button4, { type: "button", size: "sm", onClick: openCreate, children: [
931
+ /* @__PURE__ */ jsx5(Plus, { className: "size-4", "aria-hidden": "true" }),
932
+ "New"
933
+ ] })
934
+ ] })
81
935
  ] }),
82
- /* @__PURE__ */ jsx("p", { className: "mt-2 max-w-prose text-sm text-muted-foreground", children: "Automation UI is implemented in the next slice. Slice 1 provides the trusted plugin shell, file-backed store, and CRUD routes." })
936
+ /* @__PURE__ */ jsx5("div", { className: "min-h-0 flex-1 overflow-y-auto bg-[color:oklch(from_var(--background)_calc(l-0.012)_c_h)]", children: /* @__PURE__ */ jsxs4("div", { className: "mx-auto grid w-full max-w-6xl gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)]", children: [
937
+ /* @__PURE__ */ jsx5("section", { className: "min-w-0 overflow-hidden rounded-xl border border-border/70 bg-card/60", "aria-label": "Automation list", children: loading ? /* @__PURE__ */ jsxs4("div", { className: "flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground", children: [
938
+ /* @__PURE__ */ jsx5(Spinner, { className: "size-4" }),
939
+ " Loading automations\u2026"
940
+ ] }) : automations.length === 0 ? /* @__PURE__ */ jsx5(
941
+ EmptyState,
942
+ {
943
+ title: "No automations yet",
944
+ description: "Create a focused scheduled prompt with cron, timezone, model, and canonical Markdown.",
945
+ icon: /* @__PURE__ */ jsx5(CalendarClock, { className: "size-8", "aria-hidden": "true" }),
946
+ actions: /* @__PURE__ */ jsx5(Button4, { type: "button", onClick: openCreate, children: "Create automation" }),
947
+ className: "min-h-64"
948
+ }
949
+ ) : /* @__PURE__ */ jsx5("div", { children: automations.map((automation) => {
950
+ const detail = details[automation.id];
951
+ return /* @__PURE__ */ jsx5(
952
+ AutomationCard,
953
+ {
954
+ automation,
955
+ expanded: expandedId === automation.id,
956
+ deleting: deleteId === automation.id,
957
+ runs: detail?.runs ?? [],
958
+ runsLoading: detail?.runsLoading === true,
959
+ runningNow: runningNowIds.has(automation.id),
960
+ onToggle: () => toggleExpanded(automation),
961
+ onEdit: () => openEdit(automation),
962
+ onRunNow: () => void runNow(automation),
963
+ onDeleteRequest: () => setDeleteId(automation.id),
964
+ onDeleteCancel: () => setDeleteId(null),
965
+ onDeleteConfirm: () => void deleteAutomation(automation.id),
966
+ onOpenRun: openRun
967
+ },
968
+ automation.id
969
+ );
970
+ }) }) }),
971
+ /* @__PURE__ */ jsxs4("aside", { className: "min-w-0 rounded-xl border border-border/70 bg-card/80 p-4", "aria-label": "Automation editor", children: [
972
+ routeError ? /* @__PURE__ */ jsx5(Notice, { tone: "destructive", className: "mb-3", role: "alert", children: routeError }) : null,
973
+ shellError ? /* @__PURE__ */ jsx5(Notice, { tone: "destructive", className: "mb-3", role: "alert", children: shellError }) : null,
974
+ saveNotice ? /* @__PURE__ */ jsx5(Notice, { tone: saveNotice.tone, className: "mb-3", role: "status", children: saveNotice.message }) : null,
975
+ editor.mode === "closed" ? /* @__PURE__ */ jsx5("div", { className: "flex min-h-80 items-center justify-center px-4 text-center text-sm text-muted-foreground", children: /* @__PURE__ */ jsxs4("div", { children: [
976
+ /* @__PURE__ */ jsx5("div", { className: "font-medium text-foreground", children: "Select an automation to edit" }),
977
+ /* @__PURE__ */ jsx5("p", { className: "mt-1 max-w-xs", children: "Cards expand for read-only run history. The editor saves metadata and Markdown through separate public routes." })
978
+ ] }) }) : editor.mode === "create" ? /* @__PURE__ */ jsxs4(Fragment, { children: [
979
+ /* @__PURE__ */ jsxs4("div", { className: "mb-4", children: [
980
+ /* @__PURE__ */ jsx5("h3", { className: "font-semibold text-foreground", children: "New automation" }),
981
+ /* @__PURE__ */ jsx5("p", { className: "mt-1 text-xs text-muted-foreground", children: "Define the schedule and canonical Markdown prompt." })
982
+ ] }),
983
+ /* @__PURE__ */ jsx5(AutomationForm, { mode: "create", prompt: "", saving, onCancel: () => setEditor({ mode: "closed" }), onSubmit: (draft) => void saveDraft(draft) })
984
+ ] }) : selectedAutomation ? editorLoading ? /* @__PURE__ */ jsxs4("div", { className: "flex min-h-80 items-center justify-center gap-2 text-muted-foreground", children: [
985
+ /* @__PURE__ */ jsx5(Spinner, { className: "size-4" }),
986
+ " Loading prompt\u2026"
987
+ ] }) : /* @__PURE__ */ jsxs4(Fragment, { children: [
988
+ /* @__PURE__ */ jsxs4("div", { className: "mb-4", children: [
989
+ /* @__PURE__ */ jsx5("h3", { className: "font-semibold text-foreground", children: "Edit automation" }),
990
+ /* @__PURE__ */ jsx5("p", { className: "mt-1 text-xs text-muted-foreground", children: selectedAutomation.id })
991
+ ] }),
992
+ /* @__PURE__ */ jsx5(AutomationForm, { automation: selectedAutomation, mode: "edit", prompt: editorPrompt, saving, onCancel: () => setEditor({ mode: "closed" }), onSubmit: (draft) => void saveDraft(draft) })
993
+ ] }) : /* @__PURE__ */ jsx5(Notice, { tone: "destructive", children: "Automation not found." })
994
+ ] })
995
+ ] }) })
83
996
  ] });
84
997
  }
998
+
999
+ // src/front/index.tsx
85
1000
  var boringAutomationPlugin = definePlugin({
86
1001
  id: BORING_AUTOMATION_PLUGIN_ID,
87
1002
  label: BORING_AUTOMATION_PLUGIN_LABEL,
1003
+ providers: [
1004
+ {
1005
+ id: `${BORING_AUTOMATION_PLUGIN_ID}.runtime`,
1006
+ component: AutomationRuntimeProvider
1007
+ }
1008
+ ],
88
1009
  panels: [
89
1010
  {
90
1011
  id: `${BORING_AUTOMATION_PLUGIN_ID}.panel`,
91
1012
  label: BORING_AUTOMATION_PLUGIN_LABEL,
92
- icon: CalendarClock,
1013
+ icon: CalendarClock2,
93
1014
  component: AutomationPanel,
94
1015
  placement: "center",
95
1016
  source: "builtin"
@@ -105,10 +1026,12 @@ var boringAutomationPlugin = definePlugin({
105
1026
  });
106
1027
  var front_default = boringAutomationPlugin;
107
1028
  export {
1029
+ AUTOMATION_SCHEDULE_ERRORS,
1030
+ AutomationClientError,
108
1031
  AutomationCreateSchema,
109
1032
  AutomationPatchSchema,
110
- AutomationRunCreateSchema,
111
- AutomationRunPatchSchema,
1033
+ AutomationRunBeginSchema,
1034
+ AutomationRunLifecyclePatchSchema,
112
1035
  AutomationRunStatusSchema,
113
1036
  AutomationRunTriggerSchema,
114
1037
  BORING_AUTOMATION_ERROR_CODES,
@@ -118,5 +1041,10 @@ export {
118
1041
  IdParamsSchema,
119
1042
  PromptUpdateSchema,
120
1043
  boringAutomationPlugin,
121
- front_default as default
1044
+ createAutomationClient,
1045
+ front_default as default,
1046
+ evaluateAutomationSchedule,
1047
+ isValidFiveFieldCron,
1048
+ isValidIanaTimeZone,
1049
+ validateAutomationSchedule
122
1050
  };