@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.
- package/LICENSE +21 -0
- package/README.md +14 -1
- package/dist/front/index.d.ts +29 -2
- package/dist/front/index.js +954 -26
- package/dist/server/index.d.ts +122 -13
- package/dist/server/index.js +780 -60
- package/dist/shared/index.d.ts +90 -54
- package/dist/shared/index.js +124 -18
- package/package.json +17 -15
package/dist/front/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
152
|
+
createdAt: isoString.optional()
|
|
59
153
|
}).strict();
|
|
60
|
-
var
|
|
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/
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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__ */
|
|
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:
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
1044
|
+
createAutomationClient,
|
|
1045
|
+
front_default as default,
|
|
1046
|
+
evaluateAutomationSchedule,
|
|
1047
|
+
isValidFiveFieldCron,
|
|
1048
|
+
isValidIanaTimeZone,
|
|
1049
|
+
validateAutomationSchedule
|
|
122
1050
|
};
|