@hachej/boring-automation 0.1.71 → 0.1.79
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 +121 -13
- package/dist/server/index.js +760 -60
- package/dist/shared/index.d.ts +90 -54
- package/dist/shared/index.js +124 -18
- package/package.json +17 -15
package/dist/server/index.js
CHANGED
|
@@ -10,12 +10,110 @@ var BORING_AUTOMATION_ROUTE_PREFIX = "/api/v1/boring-automation";
|
|
|
10
10
|
// src/shared/error-codes.ts
|
|
11
11
|
var BORING_AUTOMATION_ERROR_CODES = {
|
|
12
12
|
INVALID_BODY: "BORING_AUTOMATION_INVALID_BODY",
|
|
13
|
+
INVALID_CRON: "BORING_AUTOMATION_INVALID_CRON",
|
|
14
|
+
INVALID_TIMEZONE: "BORING_AUTOMATION_INVALID_TIMEZONE",
|
|
15
|
+
INVALID_MODEL: "BORING_AUTOMATION_INVALID_MODEL",
|
|
13
16
|
AUTOMATION_NOT_FOUND: "BORING_AUTOMATION_NOT_FOUND",
|
|
14
|
-
RUN_NOT_FOUND: "BORING_AUTOMATION_RUN_NOT_FOUND"
|
|
17
|
+
RUN_NOT_FOUND: "BORING_AUTOMATION_RUN_NOT_FOUND",
|
|
18
|
+
RUN_ALREADY_ACTIVE: "BORING_AUTOMATION_RUN_ALREADY_ACTIVE",
|
|
19
|
+
RUN_ALREADY_RECORDED: "BORING_AUTOMATION_RUN_ALREADY_RECORDED",
|
|
20
|
+
RUN_EXECUTOR_UNAVAILABLE: "BORING_AUTOMATION_RUN_EXECUTOR_UNAVAILABLE",
|
|
21
|
+
TRIGGER_FORBIDDEN: "BORING_AUTOMATION_TRIGGER_FORBIDDEN",
|
|
22
|
+
RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED"
|
|
15
23
|
};
|
|
16
24
|
|
|
17
25
|
// src/shared/schema.ts
|
|
18
26
|
import { z } from "zod";
|
|
27
|
+
|
|
28
|
+
// src/shared/schedule.ts
|
|
29
|
+
import { Cron } from "croner";
|
|
30
|
+
var AUTOMATION_SCHEDULE_ERRORS = {
|
|
31
|
+
INVALID_CRON: "Invalid cron schedule. Use exactly five fields, for example 0 9 * * *.",
|
|
32
|
+
INVALID_TIMEZONE: "Invalid timezone. Use a valid IANA timezone, for example UTC or America/New_York."
|
|
33
|
+
};
|
|
34
|
+
function validateAutomationSchedule(cron, timezone) {
|
|
35
|
+
const errors = {};
|
|
36
|
+
if (!isValidFiveFieldCron(cron)) errors.cron = AUTOMATION_SCHEDULE_ERRORS.INVALID_CRON;
|
|
37
|
+
if (!isValidIanaTimeZone(timezone)) errors.timezone = AUTOMATION_SCHEDULE_ERRORS.INVALID_TIMEZONE;
|
|
38
|
+
return { ok: Object.keys(errors).length === 0, errors };
|
|
39
|
+
}
|
|
40
|
+
function isValidFiveFieldCron(cron) {
|
|
41
|
+
const normalized = normalizeSpace(cron);
|
|
42
|
+
if (!normalized || normalized.split(" ").length !== 5) return false;
|
|
43
|
+
try {
|
|
44
|
+
new Cron(normalized);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function isValidIanaTimeZone(timezone) {
|
|
51
|
+
const normalized = timezone.trim();
|
|
52
|
+
if (!normalized) return false;
|
|
53
|
+
try {
|
|
54
|
+
const resolved = new Intl.DateTimeFormat("en-US", { timeZone: normalized }).resolvedOptions().timeZone;
|
|
55
|
+
return resolved === "UTC" || resolved.includes("/");
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function evaluateAutomationSchedule(input) {
|
|
61
|
+
const now = new Date(input.now);
|
|
62
|
+
const scheduledFor = floorToMinute(now).toISOString();
|
|
63
|
+
const runs = [...input.runs];
|
|
64
|
+
const decisions = input.automations.map((automation) => {
|
|
65
|
+
const validation = validateAutomationSchedule(automation.cron, automation.timezone);
|
|
66
|
+
if (validation.errors.cron) return skip(automation, null, "invalid-cron", validation.errors.cron);
|
|
67
|
+
if (validation.errors.timezone) return skip(automation, null, "invalid-timezone", validation.errors.timezone);
|
|
68
|
+
if (!automation.enabled) return skip(automation, null, "disabled", "Automation is disabled.");
|
|
69
|
+
const cron = new Cron(normalizeSpace(automation.cron), { timezone: automation.timezone.trim() });
|
|
70
|
+
if (!cron.match(new Date(scheduledFor))) {
|
|
71
|
+
return skip(automation, null, "not-current-minute", "Automation is not scheduled for the current minute.");
|
|
72
|
+
}
|
|
73
|
+
if (hasDuplicateScheduledRun(runs, automation.id, scheduledFor)) {
|
|
74
|
+
return skip(automation, scheduledFor, "duplicate-scheduled-run", "A run already exists for this scheduled minute.");
|
|
75
|
+
}
|
|
76
|
+
if (hasActiveRun(runs, automation.id)) {
|
|
77
|
+
return skip(automation, scheduledFor, "active-run", "Automation already has a queued or running run.");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind: "due",
|
|
81
|
+
automation,
|
|
82
|
+
automationId: automation.id,
|
|
83
|
+
scheduledFor,
|
|
84
|
+
reason: "current-minute-matched"
|
|
85
|
+
};
|
|
86
|
+
}).sort(compareScheduleDecisions);
|
|
87
|
+
return { decisions, due: decisions.filter((decision) => decision.kind === "due") };
|
|
88
|
+
}
|
|
89
|
+
function skip(automation, scheduledFor, reason, message) {
|
|
90
|
+
return { kind: "skip", automation, automationId: automation.id, scheduledFor, reason, message };
|
|
91
|
+
}
|
|
92
|
+
function hasDuplicateScheduledRun(runs, automationId, scheduledFor) {
|
|
93
|
+
return runs.some((run) => run.automationId === automationId && run.trigger === "scheduled" && run.scheduledFor === scheduledFor);
|
|
94
|
+
}
|
|
95
|
+
function hasActiveRun(runs, automationId) {
|
|
96
|
+
return runs.some((run) => run.automationId === automationId && (run.status === "queued" || run.status === "running"));
|
|
97
|
+
}
|
|
98
|
+
function floorToMinute(date) {
|
|
99
|
+
const next = new Date(date);
|
|
100
|
+
next.setUTCSeconds(0, 0);
|
|
101
|
+
return next;
|
|
102
|
+
}
|
|
103
|
+
function normalizeSpace(value) {
|
|
104
|
+
return value.trim().replace(/\s+/g, " ");
|
|
105
|
+
}
|
|
106
|
+
function compareScheduleDecisions(a, b) {
|
|
107
|
+
return compareNullableString(a.scheduledFor, b.scheduledFor) || a.automationId.localeCompare(b.automationId);
|
|
108
|
+
}
|
|
109
|
+
function compareNullableString(a, b) {
|
|
110
|
+
if (a === b) return 0;
|
|
111
|
+
if (a === null) return 1;
|
|
112
|
+
if (b === null) return -1;
|
|
113
|
+
return a.localeCompare(b);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/shared/schema.ts
|
|
19
117
|
var nonEmptyString = z.string().trim().min(1);
|
|
20
118
|
var isoString = z.string().datetime({ offset: true });
|
|
21
119
|
var nonNegativeInteger = z.number().int().nonnegative();
|
|
@@ -28,37 +126,32 @@ var AutomationCreateSchema = z.object({
|
|
|
28
126
|
timezone: nonEmptyString,
|
|
29
127
|
model: nonEmptyString,
|
|
30
128
|
prompt: z.string().optional()
|
|
31
|
-
}).strict()
|
|
129
|
+
}).strict().superRefine((value, ctx) => {
|
|
130
|
+
addScheduleIssues(ctx, value);
|
|
131
|
+
});
|
|
32
132
|
var AutomationPatchSchema = z.object({
|
|
33
133
|
title: nonEmptyString.optional(),
|
|
34
134
|
enabled: z.boolean().optional(),
|
|
35
135
|
cron: nonEmptyString.optional(),
|
|
36
136
|
timezone: nonEmptyString.optional(),
|
|
37
137
|
model: nonEmptyString.optional()
|
|
38
|
-
}).strict().refine((value) => Object.keys(value).length > 0, "at least one field must be provided")
|
|
138
|
+
}).strict().refine((value) => Object.keys(value).length > 0, "at least one field must be provided").superRefine((value, ctx) => {
|
|
139
|
+
addScheduleIssues(ctx, value);
|
|
140
|
+
});
|
|
39
141
|
var PromptUpdateSchema = z.object({
|
|
40
142
|
prompt: z.string()
|
|
41
143
|
}).strict();
|
|
42
|
-
var
|
|
144
|
+
var AutomationRunBeginSchema = z.object({
|
|
43
145
|
automationId: nonEmptyString,
|
|
44
|
-
sessionId: nonEmptyString.nullable().optional(),
|
|
45
|
-
status: AutomationRunStatusSchema.optional(),
|
|
46
146
|
trigger: AutomationRunTriggerSchema,
|
|
47
147
|
scheduledFor: isoString.nullable().optional(),
|
|
48
|
-
startedAt: isoString.nullable().optional(),
|
|
49
|
-
completedAt: isoString.nullable().optional(),
|
|
50
|
-
durationMs: nonNegativeInteger.nullable().optional(),
|
|
51
|
-
inputTokens: nonNegativeInteger.nullable().optional(),
|
|
52
|
-
outputTokens: nonNegativeInteger.nullable().optional(),
|
|
53
|
-
totalTokens: nonNegativeInteger.nullable().optional(),
|
|
54
148
|
promptSnapshot: z.string(),
|
|
55
149
|
modelSnapshot: nonEmptyString,
|
|
56
|
-
|
|
150
|
+
createdAt: isoString.optional()
|
|
57
151
|
}).strict();
|
|
58
|
-
var
|
|
152
|
+
var AutomationRunLifecyclePatchSchema = z.object({
|
|
59
153
|
sessionId: nonEmptyString.nullable().optional(),
|
|
60
154
|
status: AutomationRunStatusSchema.optional(),
|
|
61
|
-
scheduledFor: isoString.nullable().optional(),
|
|
62
155
|
startedAt: isoString.nullable().optional(),
|
|
63
156
|
completedAt: isoString.nullable().optional(),
|
|
64
157
|
durationMs: nonNegativeInteger.nullable().optional(),
|
|
@@ -68,11 +161,14 @@ var AutomationRunPatchSchema = z.object({
|
|
|
68
161
|
error: z.string().nullable().optional()
|
|
69
162
|
}).strict().refine((value) => Object.keys(value).length > 0, "at least one field must be provided");
|
|
70
163
|
var IdParamsSchema = z.object({ id: nonEmptyString });
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
164
|
+
function addScheduleIssues(ctx, value) {
|
|
165
|
+
if (value.cron !== void 0 && !isValidFiveFieldCron(value.cron)) {
|
|
166
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["cron"], message: AUTOMATION_SCHEDULE_ERRORS.INVALID_CRON });
|
|
167
|
+
}
|
|
168
|
+
if (value.timezone !== void 0 && !isValidIanaTimeZone(value.timezone)) {
|
|
169
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["timezone"], message: AUTOMATION_SCHEDULE_ERRORS.INVALID_TIMEZONE });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
76
172
|
|
|
77
173
|
// src/server/store.ts
|
|
78
174
|
var AutomationStoreError = class extends Error {
|
|
@@ -88,8 +184,98 @@ function automationNotFound(id) {
|
|
|
88
184
|
function runNotFound(id) {
|
|
89
185
|
return new AutomationStoreError(BORING_AUTOMATION_ERROR_CODES.RUN_NOT_FOUND, `automation run ${id} not found`);
|
|
90
186
|
}
|
|
187
|
+
function runAlreadyActive(automationId) {
|
|
188
|
+
return new AutomationStoreError(BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_ACTIVE, `automation ${automationId} already has an active run`);
|
|
189
|
+
}
|
|
190
|
+
function runAlreadyRecorded(automationId, scheduledFor) {
|
|
191
|
+
return new AutomationStoreError(
|
|
192
|
+
BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_RECORDED,
|
|
193
|
+
`automation ${automationId} already has a run for ${scheduledFor}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/server/dueRunService.ts
|
|
198
|
+
var DueRunService = class {
|
|
199
|
+
constructor(options) {
|
|
200
|
+
this.options = options;
|
|
201
|
+
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
202
|
+
}
|
|
203
|
+
options;
|
|
204
|
+
clock;
|
|
205
|
+
async runDue(request) {
|
|
206
|
+
const now = this.clock();
|
|
207
|
+
const automations = await this.options.store.listAutomations();
|
|
208
|
+
const runs = (await Promise.all(automations.map(async (automation) => {
|
|
209
|
+
await this.options.store.reconcileOrphanedRuns(automation.id);
|
|
210
|
+
return await this.options.store.listRuns(automation.id);
|
|
211
|
+
}))).flat();
|
|
212
|
+
const evaluated = evaluateAutomationSchedule({ automations, runs, now });
|
|
213
|
+
const outcomes = evaluated.decisions.filter((decision) => decision.kind === "skip").map((decision) => ({
|
|
214
|
+
kind: "skipped",
|
|
215
|
+
automationId: decision.automationId,
|
|
216
|
+
scheduledFor: decision.scheduledFor,
|
|
217
|
+
reason: decision.reason,
|
|
218
|
+
message: decision.message
|
|
219
|
+
}));
|
|
220
|
+
for (const decision of evaluated.due) {
|
|
221
|
+
try {
|
|
222
|
+
const run = await this.options.executor.run({
|
|
223
|
+
automationId: decision.automationId,
|
|
224
|
+
request,
|
|
225
|
+
trigger: "scheduled",
|
|
226
|
+
scheduledFor: decision.scheduledFor
|
|
227
|
+
});
|
|
228
|
+
outcomes.push({ kind: "started", automationId: decision.automationId, scheduledFor: decision.scheduledFor, run: toDueRunSummary(run) });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof AutomationStoreError && (error.code === BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_ACTIVE || error.code === BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_RECORDED)) {
|
|
231
|
+
outcomes.push({
|
|
232
|
+
kind: "skipped",
|
|
233
|
+
automationId: decision.automationId,
|
|
234
|
+
scheduledFor: decision.scheduledFor,
|
|
235
|
+
reason: error.code === BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_ACTIVE ? "active-run" : "duplicate-scheduled-run",
|
|
236
|
+
message: error.message
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
outcomes.push({
|
|
241
|
+
kind: "failed",
|
|
242
|
+
automationId: decision.automationId,
|
|
243
|
+
scheduledFor: decision.scheduledFor,
|
|
244
|
+
code: error instanceof AutomationStoreError ? error.code : BORING_AUTOMATION_ERROR_CODES.RUN_FAILED,
|
|
245
|
+
message: safeErrorMessage(error)
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
outcomes.sort((a, b) => a.automationId.localeCompare(b.automationId) || (a.scheduledFor ?? "").localeCompare(b.scheduledFor ?? ""));
|
|
250
|
+
return { now: now.toISOString(), decisions: evaluated.decisions, outcomes };
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function toDueRunSummary(run) {
|
|
254
|
+
return {
|
|
255
|
+
id: run.id,
|
|
256
|
+
automationId: run.automationId,
|
|
257
|
+
sessionId: run.sessionId,
|
|
258
|
+
status: run.status,
|
|
259
|
+
trigger: run.trigger,
|
|
260
|
+
scheduledFor: run.scheduledFor,
|
|
261
|
+
startedAt: run.startedAt,
|
|
262
|
+
completedAt: run.completedAt,
|
|
263
|
+
durationMs: run.durationMs,
|
|
264
|
+
inputTokens: run.inputTokens,
|
|
265
|
+
outputTokens: run.outputTokens,
|
|
266
|
+
totalTokens: run.totalTokens
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function safeErrorMessage(error) {
|
|
270
|
+
const message = error instanceof Error ? error.message : "Automation run failed";
|
|
271
|
+
const firstLine = message.split(/\r?\n/u)[0]?.trim() || "Automation run failed";
|
|
272
|
+
return firstLine.length > 300 ? `${firstLine.slice(0, 297)}...` : firstLine;
|
|
273
|
+
}
|
|
91
274
|
|
|
92
275
|
// src/server/fileStore.ts
|
|
276
|
+
import { randomUUID } from "crypto";
|
|
277
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
278
|
+
import { dirname, join } from "path";
|
|
93
279
|
var EMPTY_STATE = {
|
|
94
280
|
automations: {},
|
|
95
281
|
runs: {}
|
|
@@ -100,12 +286,16 @@ var FileAutomationStore = class {
|
|
|
100
286
|
constructor(rootDir, options = {}) {
|
|
101
287
|
this.rootDir = rootDir;
|
|
102
288
|
this.writer = options.writer ?? writeAtomic;
|
|
289
|
+
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
103
290
|
}
|
|
104
291
|
rootDir;
|
|
105
292
|
state = null;
|
|
106
293
|
loadInFlight = null;
|
|
107
294
|
writeChain = Promise.resolve();
|
|
295
|
+
/** Active runs owned by this store process; persisted active runs are orphaned after restart. */
|
|
296
|
+
activeRunIds = /* @__PURE__ */ new Set();
|
|
108
297
|
writer;
|
|
298
|
+
clock;
|
|
109
299
|
async listAutomations() {
|
|
110
300
|
const state = await this.load();
|
|
111
301
|
return Object.values(state.automations).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).map(clone);
|
|
@@ -116,7 +306,7 @@ var FileAutomationStore = class {
|
|
|
116
306
|
return automation ? clone(automation) : null;
|
|
117
307
|
}
|
|
118
308
|
async createAutomation(input) {
|
|
119
|
-
const now = nowIso();
|
|
309
|
+
const now = this.nowIso();
|
|
120
310
|
const id = randomUUID();
|
|
121
311
|
const automation = {
|
|
122
312
|
id,
|
|
@@ -146,7 +336,7 @@ var FileAutomationStore = class {
|
|
|
146
336
|
id: automation.id,
|
|
147
337
|
promptRef: automation.promptRef,
|
|
148
338
|
createdAt: automation.createdAt,
|
|
149
|
-
updatedAt: nowIso()
|
|
339
|
+
updatedAt: this.nowIso()
|
|
150
340
|
};
|
|
151
341
|
state.automations[id] = updated;
|
|
152
342
|
});
|
|
@@ -175,45 +365,61 @@ var FileAutomationStore = class {
|
|
|
175
365
|
await this.mutate((state) => {
|
|
176
366
|
const current = state.automations[automationId];
|
|
177
367
|
if (!current) throw automationNotFound(automationId);
|
|
178
|
-
current.updatedAt = nowIso();
|
|
368
|
+
current.updatedAt = this.nowIso();
|
|
179
369
|
});
|
|
180
370
|
}
|
|
181
|
-
async
|
|
182
|
-
const now = nowIso();
|
|
371
|
+
async reconcileOrphanedRuns(automationId) {
|
|
372
|
+
const now = this.nowIso();
|
|
373
|
+
await this.mutate((state) => {
|
|
374
|
+
if (!state.automations[automationId]) throw automationNotFound(automationId);
|
|
375
|
+
reconcileOrphanedRuns(state, automationId, this.activeRunIds, now);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async beginRun(input) {
|
|
379
|
+
const now = input.createdAt ?? this.nowIso();
|
|
183
380
|
let run;
|
|
184
381
|
await this.mutate((state) => {
|
|
185
382
|
if (!state.automations[input.automationId]) throw automationNotFound(input.automationId);
|
|
383
|
+
reconcileOrphanedRuns(state, input.automationId, this.activeRunIds, now);
|
|
384
|
+
if (input.trigger === "scheduled" && input.scheduledFor) {
|
|
385
|
+
const duplicate = Object.values(state.runs).some((candidate) => candidate.automationId === input.automationId && candidate.trigger === "scheduled" && candidate.scheduledFor === input.scheduledFor);
|
|
386
|
+
if (duplicate) throw runAlreadyRecorded(input.automationId, input.scheduledFor);
|
|
387
|
+
}
|
|
388
|
+
const active = Object.values(state.runs).find((candidate) => candidate.automationId === input.automationId && (candidate.status === "queued" || candidate.status === "running"));
|
|
389
|
+
if (active) throw runAlreadyActive(input.automationId);
|
|
186
390
|
run = {
|
|
187
391
|
id: randomUUID(),
|
|
188
392
|
automationId: input.automationId,
|
|
189
|
-
sessionId:
|
|
190
|
-
status:
|
|
393
|
+
sessionId: null,
|
|
394
|
+
status: "queued",
|
|
191
395
|
trigger: input.trigger,
|
|
192
396
|
scheduledFor: input.scheduledFor ?? null,
|
|
193
|
-
startedAt:
|
|
194
|
-
completedAt:
|
|
195
|
-
durationMs:
|
|
196
|
-
inputTokens:
|
|
197
|
-
outputTokens:
|
|
198
|
-
totalTokens:
|
|
397
|
+
startedAt: null,
|
|
398
|
+
completedAt: null,
|
|
399
|
+
durationMs: null,
|
|
400
|
+
inputTokens: null,
|
|
401
|
+
outputTokens: null,
|
|
402
|
+
totalTokens: null,
|
|
199
403
|
promptSnapshot: input.promptSnapshot,
|
|
200
404
|
modelSnapshot: input.modelSnapshot,
|
|
201
|
-
error:
|
|
405
|
+
error: null,
|
|
202
406
|
createdAt: now,
|
|
203
407
|
updatedAt: now
|
|
204
408
|
};
|
|
205
409
|
state.runs[run.id] = clone(run);
|
|
410
|
+
this.activeRunIds.add(run.id);
|
|
206
411
|
});
|
|
207
412
|
return clone(requireValue(run));
|
|
208
413
|
}
|
|
209
|
-
async
|
|
414
|
+
async updateRunLifecycle(runId, patch) {
|
|
210
415
|
let updated;
|
|
211
416
|
await this.mutate((state) => {
|
|
212
417
|
const run = state.runs[runId];
|
|
213
418
|
if (!run) throw runNotFound(runId);
|
|
214
|
-
updated = applyRunPatch(run, patch);
|
|
419
|
+
updated = applyRunPatch(run, patch, this.nowIso());
|
|
215
420
|
state.runs[runId] = updated;
|
|
216
421
|
});
|
|
422
|
+
if (updated && isTerminalRunStatus(updated.status)) this.activeRunIds.delete(runId);
|
|
217
423
|
return clone(requireValue(updated));
|
|
218
424
|
}
|
|
219
425
|
async listRuns(automationId) {
|
|
@@ -222,10 +428,6 @@ var FileAutomationStore = class {
|
|
|
222
428
|
const state = await this.load();
|
|
223
429
|
return Object.values(state.runs).filter((run) => run.automationId === automationId).sort((a, b) => runSortTimestamp(b).localeCompare(runSortTimestamp(a))).map(clone);
|
|
224
430
|
}
|
|
225
|
-
async findRunningRun(automationId) {
|
|
226
|
-
const runs = await this.listRuns(automationId);
|
|
227
|
-
return runs.find((run) => run.status === "running") ?? null;
|
|
228
|
-
}
|
|
229
431
|
statePath() {
|
|
230
432
|
return join(this.rootDir, "store.json");
|
|
231
433
|
}
|
|
@@ -247,6 +449,9 @@ var FileAutomationStore = class {
|
|
|
247
449
|
this.writeChain = run.catch(() => void 0);
|
|
248
450
|
return run;
|
|
249
451
|
}
|
|
452
|
+
nowIso() {
|
|
453
|
+
return this.clock().toISOString();
|
|
454
|
+
}
|
|
250
455
|
async load() {
|
|
251
456
|
if (this.state) return this.state;
|
|
252
457
|
if (!this.loadInFlight) {
|
|
@@ -273,8 +478,21 @@ var FileAutomationStore = class {
|
|
|
273
478
|
function promptRefForId(id) {
|
|
274
479
|
return `prompts/${id}.md`;
|
|
275
480
|
}
|
|
276
|
-
function
|
|
277
|
-
const
|
|
481
|
+
function reconcileOrphanedRuns(state, automationId, activeRunIds, completedAt) {
|
|
482
|
+
for (const run of Object.values(state.runs)) {
|
|
483
|
+
if (run.automationId !== automationId || isTerminalRunStatus(run.status) || activeRunIds.has(run.id)) continue;
|
|
484
|
+
run.status = "failed";
|
|
485
|
+
run.completedAt = completedAt;
|
|
486
|
+
run.durationMs = Math.max(0, new Date(completedAt).getTime() - new Date(run.startedAt ?? run.createdAt).getTime());
|
|
487
|
+
run.error = "Automation host restarted before the run completed";
|
|
488
|
+
run.updatedAt = completedAt;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function isTerminalRunStatus(status) {
|
|
492
|
+
return status === "succeeded" || status === "failed" || status === "cancelled";
|
|
493
|
+
}
|
|
494
|
+
function applyRunPatch(run, patch, updatedAt) {
|
|
495
|
+
const next = { ...run, updatedAt };
|
|
278
496
|
for (const [key, value] of Object.entries(patch)) {
|
|
279
497
|
if (value !== void 0) next[key] = value;
|
|
280
498
|
}
|
|
@@ -289,9 +507,6 @@ async function writeAtomic(path, content) {
|
|
|
289
507
|
await writeFile(tmp, content, "utf8");
|
|
290
508
|
await rename(tmp, path);
|
|
291
509
|
}
|
|
292
|
-
function nowIso() {
|
|
293
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
294
|
-
}
|
|
295
510
|
function requireValue(value) {
|
|
296
511
|
if (value === void 0) throw new Error("expected automation store mutation to produce a value");
|
|
297
512
|
return value;
|
|
@@ -300,19 +515,209 @@ function clone(value) {
|
|
|
300
515
|
return JSON.parse(JSON.stringify(value));
|
|
301
516
|
}
|
|
302
517
|
|
|
518
|
+
// src/server/manualRunExecutor.ts
|
|
519
|
+
var ManualRunExecutor = class {
|
|
520
|
+
constructor(options) {
|
|
521
|
+
this.options = options;
|
|
522
|
+
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
523
|
+
}
|
|
524
|
+
options;
|
|
525
|
+
clock;
|
|
526
|
+
async run(input) {
|
|
527
|
+
const actor = await this.options.actorResolver(input.request);
|
|
528
|
+
const store = await this.options.storeForRequest?.(input.request, actor) ?? this.options.store;
|
|
529
|
+
const automation = await store.getAutomation(input.automationId);
|
|
530
|
+
if (!automation) {
|
|
531
|
+
throw new AutomationStoreError(BORING_AUTOMATION_ERROR_CODES.AUTOMATION_NOT_FOUND, `automation ${input.automationId} not found`);
|
|
532
|
+
}
|
|
533
|
+
const promptSnapshot = await store.getPrompt(input.automationId);
|
|
534
|
+
const modelSnapshot = automation.model;
|
|
535
|
+
const model = parseAutomationModel(modelSnapshot);
|
|
536
|
+
const createdAt = this.nowIso();
|
|
537
|
+
const trigger = input.trigger ?? "manual";
|
|
538
|
+
const scheduledFor = trigger === "scheduled" ? input.scheduledFor ?? null : null;
|
|
539
|
+
if (trigger === "scheduled" && !scheduledFor) {
|
|
540
|
+
throw new AutomationStoreError(BORING_AUTOMATION_ERROR_CODES.INVALID_BODY, "scheduled runs require scheduledFor");
|
|
541
|
+
}
|
|
542
|
+
const run = await store.beginRun({
|
|
543
|
+
automationId: automation.id,
|
|
544
|
+
trigger,
|
|
545
|
+
scheduledFor,
|
|
546
|
+
promptSnapshot,
|
|
547
|
+
modelSnapshot,
|
|
548
|
+
createdAt
|
|
549
|
+
});
|
|
550
|
+
const usage = { input: null, output: null };
|
|
551
|
+
let current = run;
|
|
552
|
+
let sessionId = null;
|
|
553
|
+
let terminalStatus = null;
|
|
554
|
+
let terminalError = null;
|
|
555
|
+
let startedAt = null;
|
|
556
|
+
try {
|
|
557
|
+
const dispatcher = await this.options.dispatcherResolver.resolve(actor, { request: input.request });
|
|
558
|
+
startedAt = this.nowIso();
|
|
559
|
+
current = await store.updateRunLifecycle(run.id, {
|
|
560
|
+
status: "running",
|
|
561
|
+
startedAt,
|
|
562
|
+
sessionId: null
|
|
563
|
+
});
|
|
564
|
+
for await (const event of dispatcher.send({
|
|
565
|
+
content: promptSnapshot,
|
|
566
|
+
model,
|
|
567
|
+
actor: { id: actor.userId },
|
|
568
|
+
originSurface: "boring-automation"
|
|
569
|
+
})) {
|
|
570
|
+
const eventSessionId = sessionIdFromEvent(event);
|
|
571
|
+
if (!sessionId && eventSessionId) {
|
|
572
|
+
sessionId = eventSessionId;
|
|
573
|
+
current = await store.updateRunLifecycle(run.id, { sessionId });
|
|
574
|
+
}
|
|
575
|
+
aggregateUsage(usage, event);
|
|
576
|
+
const outcome = terminalOutcomeFromEvent(event);
|
|
577
|
+
if (outcome && !terminalStatus) {
|
|
578
|
+
terminalStatus = outcome.status;
|
|
579
|
+
terminalError = outcome.error;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const completedAt = this.nowIso();
|
|
583
|
+
return await this.finalizeRun(store, run.id, {
|
|
584
|
+
current,
|
|
585
|
+
sessionId,
|
|
586
|
+
startedAt,
|
|
587
|
+
completedAt,
|
|
588
|
+
status: terminalStatus ?? "succeeded",
|
|
589
|
+
error: terminalStatus === "failed" ? terminalError ?? "Automation run failed" : null,
|
|
590
|
+
usage
|
|
591
|
+
});
|
|
592
|
+
} catch (error) {
|
|
593
|
+
const completedAt = this.nowIso();
|
|
594
|
+
const cancelled = isCancellationError(error);
|
|
595
|
+
const status = terminalStatus ?? (cancelled ? "cancelled" : "failed");
|
|
596
|
+
return await this.finalizeRun(store, run.id, {
|
|
597
|
+
current,
|
|
598
|
+
sessionId,
|
|
599
|
+
startedAt,
|
|
600
|
+
completedAt,
|
|
601
|
+
status,
|
|
602
|
+
error: status === "failed" ? terminalError ?? safeErrorMessage2(error) : null,
|
|
603
|
+
usage
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async finalizeRun(store, runId, input) {
|
|
608
|
+
return await store.updateRunLifecycle(runId, {
|
|
609
|
+
status: input.status,
|
|
610
|
+
completedAt: input.completedAt,
|
|
611
|
+
durationMs: durationMs(input.startedAt ?? input.current.createdAt, input.completedAt),
|
|
612
|
+
sessionId: input.sessionId,
|
|
613
|
+
...finalizeUsage(input.usage),
|
|
614
|
+
error: input.error
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
nowIso() {
|
|
618
|
+
return this.clock().toISOString();
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
function parseAutomationModel(value) {
|
|
622
|
+
const index = value.indexOf(":");
|
|
623
|
+
if (index <= 0 || index === value.length - 1) {
|
|
624
|
+
throw new AutomationStoreError(
|
|
625
|
+
BORING_AUTOMATION_ERROR_CODES.INVALID_MODEL,
|
|
626
|
+
"automation model must use explicit provider:model-id syntax"
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const provider = value.slice(0, index).trim();
|
|
630
|
+
const id = value.slice(index + 1).trim();
|
|
631
|
+
if (!provider || !id) {
|
|
632
|
+
throw new AutomationStoreError(
|
|
633
|
+
BORING_AUTOMATION_ERROR_CODES.INVALID_MODEL,
|
|
634
|
+
"automation model must use explicit provider:model-id syntax"
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return { provider, id };
|
|
638
|
+
}
|
|
639
|
+
function sessionIdFromEvent(event) {
|
|
640
|
+
if (!event || typeof event !== "object") return null;
|
|
641
|
+
const sessionId = event.sessionId;
|
|
642
|
+
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
|
|
643
|
+
}
|
|
644
|
+
function chunkFromEvent(event) {
|
|
645
|
+
if (!event || typeof event !== "object") return null;
|
|
646
|
+
const chunk = event.chunk;
|
|
647
|
+
if (!chunk || typeof chunk !== "object") return null;
|
|
648
|
+
return chunk;
|
|
649
|
+
}
|
|
650
|
+
function aggregateUsage(accumulator, event) {
|
|
651
|
+
const chunk = chunkFromEvent(event);
|
|
652
|
+
if (!chunk || chunk.type !== "usage") return;
|
|
653
|
+
const usage = chunk.usage;
|
|
654
|
+
if (!usage || typeof usage !== "object") return;
|
|
655
|
+
const record = usage;
|
|
656
|
+
const input = sumObservedNumbers(record.input, record.inputTokens, record.cacheRead, record.cacheReadTokens, record.cacheWrite, record.cacheWriteTokens);
|
|
657
|
+
const output = sumObservedNumbers(record.output, record.outputTokens);
|
|
658
|
+
if (input !== null) accumulator.input = (accumulator.input ?? 0) + input;
|
|
659
|
+
if (output !== null) accumulator.output = (accumulator.output ?? 0) + output;
|
|
660
|
+
}
|
|
661
|
+
function sumObservedNumbers(...values) {
|
|
662
|
+
let observed = false;
|
|
663
|
+
let total = 0;
|
|
664
|
+
for (const value of values) {
|
|
665
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) continue;
|
|
666
|
+
observed = true;
|
|
667
|
+
total += Math.trunc(value);
|
|
668
|
+
}
|
|
669
|
+
return observed ? total : null;
|
|
670
|
+
}
|
|
671
|
+
function finalizeUsage(usage) {
|
|
672
|
+
if (usage.input === null && usage.output === null) {
|
|
673
|
+
return { inputTokens: null, outputTokens: null, totalTokens: null };
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
inputTokens: usage.input,
|
|
677
|
+
outputTokens: usage.output,
|
|
678
|
+
totalTokens: (usage.input ?? 0) + (usage.output ?? 0)
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function terminalOutcomeFromEvent(event) {
|
|
682
|
+
const chunk = chunkFromEvent(event);
|
|
683
|
+
if (!chunk) return null;
|
|
684
|
+
if (chunk.type === "agent-end" && !chunk.willRetry) {
|
|
685
|
+
if (chunk.status === "ok") return { status: "succeeded", error: null };
|
|
686
|
+
if (chunk.status === "aborted") return { status: "cancelled", error: null };
|
|
687
|
+
return { status: "failed", error: "Automation run failed" };
|
|
688
|
+
}
|
|
689
|
+
if (chunk.type === "error") {
|
|
690
|
+
return { status: "failed", error: safeErrorMessage2(chunk.error) };
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
function isCancellationError(error) {
|
|
695
|
+
if (!error || typeof error !== "object") return false;
|
|
696
|
+
const record = error;
|
|
697
|
+
return record.name === "AbortError" || record.code === "ABORT_ERR";
|
|
698
|
+
}
|
|
699
|
+
function safeErrorMessage2(error) {
|
|
700
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : error && typeof error === "object" && typeof error.message === "string" ? error.message : "Automation run failed";
|
|
701
|
+
const firstLine = raw.split(/\r?\n/u)[0]?.trim() || "Automation run failed";
|
|
702
|
+
return firstLine.length > 300 ? `${firstLine.slice(0, 297)}...` : firstLine;
|
|
703
|
+
}
|
|
704
|
+
function durationMs(startedAt, completedAt) {
|
|
705
|
+
return Math.max(0, new Date(completedAt).getTime() - new Date(startedAt).getTime());
|
|
706
|
+
}
|
|
707
|
+
|
|
303
708
|
// src/server/routes.ts
|
|
304
709
|
import { ZodError } from "zod";
|
|
305
710
|
async function automationRoutes(app, opts) {
|
|
306
711
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations`, async (_request, reply) => {
|
|
307
712
|
try {
|
|
308
|
-
return { ok: true, automations: await opts.
|
|
713
|
+
return { ok: true, automations: await (await resolveStore(opts, _request)).listAutomations() };
|
|
309
714
|
} catch (cause) {
|
|
310
715
|
return sendError(reply, cause);
|
|
311
716
|
}
|
|
312
717
|
});
|
|
313
718
|
app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations`, async (request, reply) => {
|
|
314
719
|
try {
|
|
315
|
-
const automation = await opts.
|
|
720
|
+
const automation = await (await resolveStore(opts, request)).createAutomation(parseBody(AutomationCreateSchema, request.body));
|
|
316
721
|
return reply.status(201).send({ ok: true, automation });
|
|
317
722
|
} catch (cause) {
|
|
318
723
|
return sendError(reply, cause);
|
|
@@ -321,7 +726,7 @@ async function automationRoutes(app, opts) {
|
|
|
321
726
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id`, async (request, reply) => {
|
|
322
727
|
try {
|
|
323
728
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
324
|
-
const automation = await opts.
|
|
729
|
+
const automation = await (await resolveStore(opts, request)).getAutomation(id);
|
|
325
730
|
if (!automation) throw automationNotFound(id);
|
|
326
731
|
return { ok: true, automation };
|
|
327
732
|
} catch (cause) {
|
|
@@ -331,7 +736,7 @@ async function automationRoutes(app, opts) {
|
|
|
331
736
|
app.patch(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id`, async (request, reply) => {
|
|
332
737
|
try {
|
|
333
738
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
334
|
-
const automation = await opts.
|
|
739
|
+
const automation = await (await resolveStore(opts, request)).updateAutomation(id, parseBody(AutomationPatchSchema, request.body));
|
|
335
740
|
return { ok: true, automation };
|
|
336
741
|
} catch (cause) {
|
|
337
742
|
return sendError(reply, cause);
|
|
@@ -340,7 +745,7 @@ async function automationRoutes(app, opts) {
|
|
|
340
745
|
app.delete(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id`, async (request, reply) => {
|
|
341
746
|
try {
|
|
342
747
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
343
|
-
await opts.
|
|
748
|
+
await (await resolveStore(opts, request)).deleteAutomation(id);
|
|
344
749
|
return reply.status(204).send();
|
|
345
750
|
} catch (cause) {
|
|
346
751
|
return sendError(reply, cause);
|
|
@@ -349,7 +754,7 @@ async function automationRoutes(app, opts) {
|
|
|
349
754
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id/prompt`, async (request, reply) => {
|
|
350
755
|
try {
|
|
351
756
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
352
|
-
return { ok: true, prompt: await opts.
|
|
757
|
+
return { ok: true, prompt: await (await resolveStore(opts, request)).getPrompt(id) };
|
|
353
758
|
} catch (cause) {
|
|
354
759
|
return sendError(reply, cause);
|
|
355
760
|
}
|
|
@@ -358,21 +763,58 @@ async function automationRoutes(app, opts) {
|
|
|
358
763
|
try {
|
|
359
764
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
360
765
|
const { prompt } = parseBody(PromptUpdateSchema, request.body);
|
|
361
|
-
await opts.
|
|
766
|
+
await (await resolveStore(opts, request)).updatePrompt(id, prompt);
|
|
362
767
|
return { ok: true };
|
|
363
768
|
} catch (cause) {
|
|
364
769
|
return sendError(reply, cause);
|
|
365
770
|
}
|
|
366
771
|
});
|
|
772
|
+
app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/due`, async (request, reply) => {
|
|
773
|
+
try {
|
|
774
|
+
if (!isLoopbackAddress(request.ip)) {
|
|
775
|
+
throw new AutomationStoreError(
|
|
776
|
+
BORING_AUTOMATION_ERROR_CODES.TRIGGER_FORBIDDEN,
|
|
777
|
+
"automation due trigger is limited to loopback callers"
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
if (!opts.dueRunService) {
|
|
781
|
+
throw new AutomationStoreError(
|
|
782
|
+
BORING_AUTOMATION_ERROR_CODES.RUN_EXECUTOR_UNAVAILABLE,
|
|
783
|
+
"automation due executor is unavailable"
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
return { ok: true, ...await opts.dueRunService.runDue(request) };
|
|
787
|
+
} catch (cause) {
|
|
788
|
+
return sendError(reply, cause);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id/run`, async (request, reply) => {
|
|
792
|
+
try {
|
|
793
|
+
const { id } = parseParams(IdParamsSchema, request.params);
|
|
794
|
+
if (!opts.manualRunExecutor) {
|
|
795
|
+
throw new AutomationStoreError(
|
|
796
|
+
BORING_AUTOMATION_ERROR_CODES.RUN_EXECUTOR_UNAVAILABLE,
|
|
797
|
+
"automation run executor is unavailable"
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const run = await opts.manualRunExecutor.run({ automationId: id, request });
|
|
801
|
+
return reply.status(201).send({ ok: true, run });
|
|
802
|
+
} catch (cause) {
|
|
803
|
+
return sendError(reply, cause);
|
|
804
|
+
}
|
|
805
|
+
});
|
|
367
806
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id/runs`, async (request, reply) => {
|
|
368
807
|
try {
|
|
369
808
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
370
|
-
return { ok: true, runs: await opts.
|
|
809
|
+
return { ok: true, runs: await (await resolveStore(opts, request)).listRuns(id) };
|
|
371
810
|
} catch (cause) {
|
|
372
811
|
return sendError(reply, cause);
|
|
373
812
|
}
|
|
374
813
|
});
|
|
375
814
|
}
|
|
815
|
+
async function resolveStore(opts, request) {
|
|
816
|
+
return await opts.storeForRequest?.(request) ?? opts.store;
|
|
817
|
+
}
|
|
376
818
|
function parseBody(schema, body) {
|
|
377
819
|
return parse(schema, body);
|
|
378
820
|
}
|
|
@@ -386,10 +828,11 @@ function parse(schema, value) {
|
|
|
386
828
|
}
|
|
387
829
|
function sendError(reply, cause) {
|
|
388
830
|
if (cause instanceof ZodError) {
|
|
831
|
+
const issue = cause.issues[0];
|
|
389
832
|
return reply.status(400).send({
|
|
390
833
|
ok: false,
|
|
391
|
-
code:
|
|
392
|
-
error:
|
|
834
|
+
code: zodIssueErrorCode(issue),
|
|
835
|
+
error: issue?.message ?? "invalid request"
|
|
393
836
|
});
|
|
394
837
|
}
|
|
395
838
|
if (cause instanceof AutomationStoreError) {
|
|
@@ -397,24 +840,266 @@ function sendError(reply, cause) {
|
|
|
397
840
|
}
|
|
398
841
|
throw cause;
|
|
399
842
|
}
|
|
843
|
+
function zodIssueErrorCode(issue) {
|
|
844
|
+
const field = issue?.path[0];
|
|
845
|
+
if (field === "cron") return BORING_AUTOMATION_ERROR_CODES.INVALID_CRON;
|
|
846
|
+
if (field === "timezone") return BORING_AUTOMATION_ERROR_CODES.INVALID_TIMEZONE;
|
|
847
|
+
return BORING_AUTOMATION_ERROR_CODES.INVALID_BODY;
|
|
848
|
+
}
|
|
849
|
+
function isLoopbackAddress(address) {
|
|
850
|
+
const normalized = address.trim().toLowerCase();
|
|
851
|
+
return normalized === "127.0.0.1" || normalized === "::1" || normalized.startsWith("::ffff:127.");
|
|
852
|
+
}
|
|
400
853
|
function httpStatusForStoreError(error) {
|
|
401
854
|
switch (error.code) {
|
|
402
855
|
case BORING_AUTOMATION_ERROR_CODES.INVALID_BODY:
|
|
856
|
+
case BORING_AUTOMATION_ERROR_CODES.INVALID_CRON:
|
|
857
|
+
case BORING_AUTOMATION_ERROR_CODES.INVALID_TIMEZONE:
|
|
858
|
+
case BORING_AUTOMATION_ERROR_CODES.INVALID_MODEL:
|
|
403
859
|
return 400;
|
|
404
860
|
case BORING_AUTOMATION_ERROR_CODES.AUTOMATION_NOT_FOUND:
|
|
405
861
|
case BORING_AUTOMATION_ERROR_CODES.RUN_NOT_FOUND:
|
|
406
862
|
return 404;
|
|
863
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_ACTIVE:
|
|
864
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_RECORDED:
|
|
865
|
+
return 409;
|
|
866
|
+
case BORING_AUTOMATION_ERROR_CODES.TRIGGER_FORBIDDEN:
|
|
867
|
+
return 403;
|
|
868
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_EXECUTOR_UNAVAILABLE:
|
|
869
|
+
return 503;
|
|
870
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_FAILED:
|
|
871
|
+
return 500;
|
|
407
872
|
}
|
|
408
873
|
}
|
|
409
874
|
|
|
875
|
+
// src/server/migrations.ts
|
|
876
|
+
async function runBoringAutomationMigrations(sql) {
|
|
877
|
+
await sql.unsafe(`
|
|
878
|
+
CREATE TABLE IF NOT EXISTS boring_automation_automations (
|
|
879
|
+
id uuid PRIMARY KEY,
|
|
880
|
+
workspace_id text NOT NULL,
|
|
881
|
+
owner_user_id text NOT NULL,
|
|
882
|
+
title text NOT NULL,
|
|
883
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
884
|
+
cron text NOT NULL,
|
|
885
|
+
timezone text NOT NULL,
|
|
886
|
+
model text NOT NULL,
|
|
887
|
+
prompt text NOT NULL DEFAULT '',
|
|
888
|
+
created_at timestamptz NOT NULL,
|
|
889
|
+
updated_at timestamptz NOT NULL
|
|
890
|
+
)
|
|
891
|
+
`);
|
|
892
|
+
await sql.unsafe(`
|
|
893
|
+
CREATE INDEX IF NOT EXISTS boring_automation_automations_owner_idx
|
|
894
|
+
ON boring_automation_automations (workspace_id, owner_user_id)
|
|
895
|
+
`);
|
|
896
|
+
await sql.unsafe(`
|
|
897
|
+
CREATE TABLE IF NOT EXISTS boring_automation_runs (
|
|
898
|
+
id uuid PRIMARY KEY,
|
|
899
|
+
automation_id uuid NOT NULL REFERENCES boring_automation_automations(id) ON DELETE CASCADE,
|
|
900
|
+
workspace_id text NOT NULL,
|
|
901
|
+
owner_user_id text NOT NULL,
|
|
902
|
+
session_id text,
|
|
903
|
+
status text NOT NULL,
|
|
904
|
+
trigger text NOT NULL,
|
|
905
|
+
scheduled_for timestamptz,
|
|
906
|
+
started_at timestamptz,
|
|
907
|
+
completed_at timestamptz,
|
|
908
|
+
duration_ms integer,
|
|
909
|
+
input_tokens integer,
|
|
910
|
+
output_tokens integer,
|
|
911
|
+
total_tokens integer,
|
|
912
|
+
prompt_snapshot text NOT NULL,
|
|
913
|
+
model_snapshot text NOT NULL,
|
|
914
|
+
error text,
|
|
915
|
+
created_at timestamptz NOT NULL,
|
|
916
|
+
updated_at timestamptz NOT NULL,
|
|
917
|
+
CONSTRAINT boring_automation_runs_status_check CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
|
918
|
+
CONSTRAINT boring_automation_runs_trigger_check CHECK (trigger IN ('manual', 'scheduled'))
|
|
919
|
+
)
|
|
920
|
+
`);
|
|
921
|
+
await sql.unsafe(`
|
|
922
|
+
CREATE INDEX IF NOT EXISTS boring_automation_runs_automation_idx
|
|
923
|
+
ON boring_automation_runs (automation_id, created_at DESC)
|
|
924
|
+
`);
|
|
925
|
+
await sql.unsafe(`
|
|
926
|
+
CREATE UNIQUE INDEX IF NOT EXISTS boring_automation_runs_active_once_idx
|
|
927
|
+
ON boring_automation_runs (automation_id)
|
|
928
|
+
WHERE status IN ('queued', 'running')
|
|
929
|
+
`);
|
|
930
|
+
await sql.unsafe(`
|
|
931
|
+
CREATE UNIQUE INDEX IF NOT EXISTS boring_automation_runs_scheduled_once_idx
|
|
932
|
+
ON boring_automation_runs (automation_id, scheduled_for)
|
|
933
|
+
WHERE trigger = 'scheduled' AND scheduled_for IS NOT NULL
|
|
934
|
+
`);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/server/postgresStore.ts
|
|
938
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
939
|
+
var PostgresAutomationStore = class {
|
|
940
|
+
constructor(sql, actor, clock = () => /* @__PURE__ */ new Date()) {
|
|
941
|
+
this.sql = sql;
|
|
942
|
+
this.actor = actor;
|
|
943
|
+
this.clock = clock;
|
|
944
|
+
}
|
|
945
|
+
sql;
|
|
946
|
+
actor;
|
|
947
|
+
clock;
|
|
948
|
+
async listAutomations() {
|
|
949
|
+
const rows = await this.sql`
|
|
950
|
+
SELECT id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
951
|
+
FROM boring_automation_automations
|
|
952
|
+
WHERE workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
953
|
+
ORDER BY created_at, id
|
|
954
|
+
`;
|
|
955
|
+
return rows.map(toAutomation);
|
|
956
|
+
}
|
|
957
|
+
async getAutomation(id) {
|
|
958
|
+
const rows = await this.sql`
|
|
959
|
+
SELECT id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
960
|
+
FROM boring_automation_automations
|
|
961
|
+
WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
962
|
+
`;
|
|
963
|
+
return rows[0] ? toAutomation(rows[0]) : null;
|
|
964
|
+
}
|
|
965
|
+
async createAutomation(input) {
|
|
966
|
+
const now = this.clock().toISOString();
|
|
967
|
+
const id = randomUUID2();
|
|
968
|
+
const prompt = input.prompt ?? "";
|
|
969
|
+
const rows = await this.sql`
|
|
970
|
+
INSERT INTO boring_automation_automations (id, workspace_id, owner_user_id, title, enabled, cron, timezone, model, prompt, created_at, updated_at)
|
|
971
|
+
VALUES (${id}, ${this.actor.workspaceId}, ${this.actor.userId}, ${input.title}, ${input.enabled ?? true}, ${input.cron}, ${input.timezone}, ${input.model}, ${prompt}, ${now}, ${now})
|
|
972
|
+
RETURNING id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
973
|
+
`;
|
|
974
|
+
return toAutomation(rows[0]);
|
|
975
|
+
}
|
|
976
|
+
async updateAutomation(id, patch) {
|
|
977
|
+
const current = await this.getAutomation(id);
|
|
978
|
+
if (!current) throw automationNotFound(id);
|
|
979
|
+
const next = { ...current, ...patch, updatedAt: this.clock().toISOString() };
|
|
980
|
+
const rows = await this.sql`
|
|
981
|
+
UPDATE boring_automation_automations
|
|
982
|
+
SET title = ${next.title}, enabled = ${next.enabled}, cron = ${next.cron}, timezone = ${next.timezone}, model = ${next.model}, updated_at = ${next.updatedAt}
|
|
983
|
+
WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
984
|
+
RETURNING id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
985
|
+
`;
|
|
986
|
+
if (!rows[0]) throw automationNotFound(id);
|
|
987
|
+
return toAutomation(rows[0]);
|
|
988
|
+
}
|
|
989
|
+
async deleteAutomation(id) {
|
|
990
|
+
const result = await this.sql`
|
|
991
|
+
DELETE FROM boring_automation_automations
|
|
992
|
+
WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
993
|
+
`;
|
|
994
|
+
if (result.count === 0) throw automationNotFound(id);
|
|
995
|
+
}
|
|
996
|
+
async getPrompt(automationId) {
|
|
997
|
+
const automation = await this.getAutomation(automationId);
|
|
998
|
+
if (!automation) throw automationNotFound(automationId);
|
|
999
|
+
const rows = await this.sql`
|
|
1000
|
+
SELECT prompt FROM boring_automation_automations
|
|
1001
|
+
WHERE id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
1002
|
+
`;
|
|
1003
|
+
return rows[0]?.prompt ?? "";
|
|
1004
|
+
}
|
|
1005
|
+
async updatePrompt(automationId, body) {
|
|
1006
|
+
const result = await this.sql`
|
|
1007
|
+
UPDATE boring_automation_automations SET prompt = ${body}, updated_at = ${this.clock().toISOString()}
|
|
1008
|
+
WHERE id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
1009
|
+
`;
|
|
1010
|
+
if (result.count === 0) throw automationNotFound(automationId);
|
|
1011
|
+
}
|
|
1012
|
+
async reconcileOrphanedRuns(automationId) {
|
|
1013
|
+
await this.sql`
|
|
1014
|
+
UPDATE boring_automation_runs
|
|
1015
|
+
SET status = 'failed', completed_at = ${this.clock().toISOString()}, error = 'Automation host restarted before the run completed', updated_at = ${this.clock().toISOString()}
|
|
1016
|
+
WHERE automation_id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId} AND status IN ('queued', 'running')
|
|
1017
|
+
`;
|
|
1018
|
+
}
|
|
1019
|
+
async beginRun(input) {
|
|
1020
|
+
const automation = await this.getAutomation(input.automationId);
|
|
1021
|
+
if (!automation) throw automationNotFound(input.automationId);
|
|
1022
|
+
const active = await this.sql`
|
|
1023
|
+
SELECT id FROM boring_automation_runs
|
|
1024
|
+
WHERE automation_id = ${input.automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId} AND status IN ('queued', 'running')
|
|
1025
|
+
LIMIT 1
|
|
1026
|
+
`;
|
|
1027
|
+
if (active[0]) throw runAlreadyActive(input.automationId);
|
|
1028
|
+
try {
|
|
1029
|
+
const now = input.createdAt ?? this.clock().toISOString();
|
|
1030
|
+
const id = randomUUID2();
|
|
1031
|
+
const rows = await this.sql`
|
|
1032
|
+
INSERT INTO boring_automation_runs (id, automation_id, workspace_id, owner_user_id, session_id, status, trigger, scheduled_for, started_at, completed_at, duration_ms, input_tokens, output_tokens, total_tokens, prompt_snapshot, model_snapshot, error, created_at, updated_at)
|
|
1033
|
+
VALUES (${id}, ${input.automationId}, ${this.actor.workspaceId}, ${this.actor.userId}, NULL, 'queued', ${input.trigger}, ${input.scheduledFor ?? null}, NULL, NULL, NULL, NULL, NULL, NULL, ${input.promptSnapshot}, ${input.modelSnapshot}, NULL, ${now}, ${now})
|
|
1034
|
+
RETURNING *
|
|
1035
|
+
`;
|
|
1036
|
+
return toRun(rows[0]);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
if (isUniqueViolation(error)) {
|
|
1039
|
+
if (constraintName(error) === "boring_automation_runs_active_once_idx") throw runAlreadyActive(input.automationId);
|
|
1040
|
+
if (input.trigger === "scheduled" && input.scheduledFor) throw runAlreadyRecorded(input.automationId, input.scheduledFor);
|
|
1041
|
+
}
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
async updateRunLifecycle(runId, patch) {
|
|
1046
|
+
const current = await this.findRun(runId);
|
|
1047
|
+
if (!current) throw runNotFound(runId);
|
|
1048
|
+
const next = { ...current, ...patch, updatedAt: this.clock().toISOString() };
|
|
1049
|
+
const rows = await this.sql`
|
|
1050
|
+
UPDATE boring_automation_runs
|
|
1051
|
+
SET session_id = ${next.sessionId}, status = ${next.status}, started_at = ${next.startedAt}, completed_at = ${next.completedAt}, duration_ms = ${next.durationMs}, input_tokens = ${next.inputTokens}, output_tokens = ${next.outputTokens}, total_tokens = ${next.totalTokens}, error = ${next.error}, updated_at = ${next.updatedAt}
|
|
1052
|
+
WHERE id = ${runId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
1053
|
+
RETURNING *
|
|
1054
|
+
`;
|
|
1055
|
+
if (!rows[0]) throw runNotFound(runId);
|
|
1056
|
+
return toRun(rows[0]);
|
|
1057
|
+
}
|
|
1058
|
+
async listRuns(automationId) {
|
|
1059
|
+
const rows = await this.sql`
|
|
1060
|
+
SELECT * FROM boring_automation_runs
|
|
1061
|
+
WHERE automation_id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
1062
|
+
ORDER BY created_at DESC, id DESC
|
|
1063
|
+
`;
|
|
1064
|
+
return rows.map(toRun);
|
|
1065
|
+
}
|
|
1066
|
+
async findRun(runId) {
|
|
1067
|
+
const rows = await this.sql`
|
|
1068
|
+
SELECT * FROM boring_automation_runs
|
|
1069
|
+
WHERE id = ${runId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
1070
|
+
`;
|
|
1071
|
+
return rows[0] ? toRun(rows[0]) : null;
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
function toAutomation(row) {
|
|
1075
|
+
return { id: row.id, title: row.title, enabled: row.enabled, cron: row.cron, timezone: row.timezone, model: row.model, promptRef: `hosted:${row.id}`, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) };
|
|
1076
|
+
}
|
|
1077
|
+
function toRun(row) {
|
|
1078
|
+
return { id: row.id, automationId: row.automation_id, sessionId: row.session_id, status: row.status, trigger: row.trigger, scheduledFor: nullableIso(row.scheduled_for), startedAt: nullableIso(row.started_at), completedAt: nullableIso(row.completed_at), durationMs: row.duration_ms, inputTokens: row.input_tokens, outputTokens: row.output_tokens, totalTokens: row.total_tokens, promptSnapshot: row.prompt_snapshot, modelSnapshot: row.model_snapshot, error: row.error, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) };
|
|
1079
|
+
}
|
|
1080
|
+
function iso(value) {
|
|
1081
|
+
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
1082
|
+
}
|
|
1083
|
+
function nullableIso(value) {
|
|
1084
|
+
return value === null ? null : iso(value);
|
|
1085
|
+
}
|
|
1086
|
+
function isUniqueViolation(error) {
|
|
1087
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "23505");
|
|
1088
|
+
}
|
|
1089
|
+
function constraintName(error) {
|
|
1090
|
+
return error && typeof error === "object" && "constraint_name" in error ? String(error.constraint_name) : void 0;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
410
1093
|
// src/server/index.ts
|
|
411
1094
|
function createBoringAutomationServerPlugin(options = {}) {
|
|
412
1095
|
const store = options.store ?? createDefaultStore(options.workspaceRoot);
|
|
1096
|
+
const manualRunExecutor = options.dispatcherResolver && options.actorResolver ? new ManualRunExecutor({ store, dispatcherResolver: options.dispatcherResolver, actorResolver: options.actorResolver }) : void 0;
|
|
1097
|
+
const dueRunService = manualRunExecutor ? new DueRunService({ store, executor: manualRunExecutor }) : void 0;
|
|
413
1098
|
return defineServerPlugin({
|
|
414
1099
|
id: BORING_AUTOMATION_PLUGIN_ID,
|
|
415
1100
|
label: BORING_AUTOMATION_PLUGIN_LABEL,
|
|
416
1101
|
routes: async (app) => {
|
|
417
|
-
await automationRoutes(app, { store });
|
|
1102
|
+
await automationRoutes(app, { store, manualRunExecutor, dueRunService });
|
|
418
1103
|
}
|
|
419
1104
|
});
|
|
420
1105
|
}
|
|
@@ -423,16 +1108,20 @@ function createDefaultStore(workspaceRoot) {
|
|
|
423
1108
|
return new FileAutomationStore(join2(workspaceRoot, ".pi", "automation"));
|
|
424
1109
|
}
|
|
425
1110
|
function defaultBoringAutomationServerPlugin(options, ctx) {
|
|
1111
|
+
const trusted = ctx?.trusted;
|
|
426
1112
|
return createBoringAutomationServerPlugin({
|
|
427
1113
|
...options,
|
|
428
|
-
workspaceRoot: options?.workspaceRoot ?? ctx?.workspaceRoot
|
|
1114
|
+
workspaceRoot: options?.workspaceRoot ?? ctx?.workspaceRoot,
|
|
1115
|
+
dispatcherResolver: options?.dispatcherResolver ?? trusted?.workspaceAgentDispatcherResolver,
|
|
1116
|
+
actorResolver: options?.actorResolver ?? trusted?.actorResolver
|
|
429
1117
|
});
|
|
430
1118
|
}
|
|
431
1119
|
export {
|
|
1120
|
+
AUTOMATION_SCHEDULE_ERRORS,
|
|
432
1121
|
AutomationCreateSchema,
|
|
433
1122
|
AutomationPatchSchema,
|
|
434
|
-
|
|
435
|
-
|
|
1123
|
+
AutomationRunBeginSchema,
|
|
1124
|
+
AutomationRunLifecyclePatchSchema,
|
|
436
1125
|
AutomationRunStatusSchema,
|
|
437
1126
|
AutomationRunTriggerSchema,
|
|
438
1127
|
AutomationStoreError,
|
|
@@ -440,12 +1129,23 @@ export {
|
|
|
440
1129
|
BORING_AUTOMATION_PLUGIN_ID,
|
|
441
1130
|
BORING_AUTOMATION_PLUGIN_LABEL,
|
|
442
1131
|
BORING_AUTOMATION_ROUTE_PREFIX,
|
|
1132
|
+
DueRunService,
|
|
443
1133
|
FileAutomationStore,
|
|
444
1134
|
IdParamsSchema,
|
|
1135
|
+
ManualRunExecutor,
|
|
1136
|
+
PostgresAutomationStore,
|
|
445
1137
|
PromptUpdateSchema,
|
|
446
1138
|
automationNotFound,
|
|
447
1139
|
automationRoutes,
|
|
448
1140
|
createBoringAutomationServerPlugin,
|
|
449
1141
|
defaultBoringAutomationServerPlugin as default,
|
|
450
|
-
|
|
1142
|
+
evaluateAutomationSchedule,
|
|
1143
|
+
isValidFiveFieldCron,
|
|
1144
|
+
isValidIanaTimeZone,
|
|
1145
|
+
parseAutomationModel,
|
|
1146
|
+
runAlreadyActive,
|
|
1147
|
+
runAlreadyRecorded,
|
|
1148
|
+
runBoringAutomationMigrations,
|
|
1149
|
+
runNotFound,
|
|
1150
|
+
validateAutomationSchedule
|
|
451
1151
|
};
|