@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/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();
|
|
369
|
+
});
|
|
370
|
+
}
|
|
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);
|
|
179
376
|
});
|
|
180
377
|
}
|
|
181
|
-
async
|
|
182
|
-
const now = nowIso();
|
|
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,365 @@ function clone(value) {
|
|
|
300
515
|
return JSON.parse(JSON.stringify(value));
|
|
301
516
|
}
|
|
302
517
|
|
|
518
|
+
// src/server/postgresStore.ts
|
|
519
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
520
|
+
var PostgresAutomationStore = class {
|
|
521
|
+
constructor(sql, actor, clock = () => /* @__PURE__ */ new Date()) {
|
|
522
|
+
this.sql = sql;
|
|
523
|
+
this.actor = actor;
|
|
524
|
+
this.clock = clock;
|
|
525
|
+
}
|
|
526
|
+
sql;
|
|
527
|
+
actor;
|
|
528
|
+
clock;
|
|
529
|
+
async listAutomations() {
|
|
530
|
+
const rows = await this.sql`
|
|
531
|
+
SELECT id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
532
|
+
FROM boring_automation_automations
|
|
533
|
+
WHERE workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
534
|
+
ORDER BY created_at, id
|
|
535
|
+
`;
|
|
536
|
+
return rows.map(toAutomation);
|
|
537
|
+
}
|
|
538
|
+
async getAutomation(id) {
|
|
539
|
+
const rows = await this.sql`
|
|
540
|
+
SELECT id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
541
|
+
FROM boring_automation_automations
|
|
542
|
+
WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
543
|
+
`;
|
|
544
|
+
return rows[0] ? toAutomation(rows[0]) : null;
|
|
545
|
+
}
|
|
546
|
+
async createAutomation(input) {
|
|
547
|
+
const now = this.clock().toISOString();
|
|
548
|
+
const id = randomUUID2();
|
|
549
|
+
const prompt = input.prompt ?? "";
|
|
550
|
+
const rows = await this.sql`
|
|
551
|
+
INSERT INTO boring_automation_automations (id, workspace_id, owner_user_id, title, enabled, cron, timezone, model, prompt, created_at, updated_at)
|
|
552
|
+
VALUES (${id}, ${this.actor.workspaceId}, ${this.actor.userId}, ${input.title}, ${input.enabled ?? true}, ${input.cron}, ${input.timezone}, ${input.model}, ${prompt}, ${now}, ${now})
|
|
553
|
+
RETURNING id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
554
|
+
`;
|
|
555
|
+
return toAutomation(rows[0]);
|
|
556
|
+
}
|
|
557
|
+
async updateAutomation(id, patch) {
|
|
558
|
+
const current = await this.getAutomation(id);
|
|
559
|
+
if (!current) throw automationNotFound(id);
|
|
560
|
+
const next = { ...current, ...patch, updatedAt: this.clock().toISOString() };
|
|
561
|
+
const rows = await this.sql`
|
|
562
|
+
UPDATE boring_automation_automations
|
|
563
|
+
SET title = ${next.title}, enabled = ${next.enabled}, cron = ${next.cron}, timezone = ${next.timezone}, model = ${next.model}, updated_at = ${next.updatedAt}
|
|
564
|
+
WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
565
|
+
RETURNING id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
|
|
566
|
+
`;
|
|
567
|
+
if (!rows[0]) throw automationNotFound(id);
|
|
568
|
+
return toAutomation(rows[0]);
|
|
569
|
+
}
|
|
570
|
+
async deleteAutomation(id) {
|
|
571
|
+
const result = await this.sql`
|
|
572
|
+
DELETE FROM boring_automation_automations
|
|
573
|
+
WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
574
|
+
`;
|
|
575
|
+
if (result.count === 0) throw automationNotFound(id);
|
|
576
|
+
}
|
|
577
|
+
async getPrompt(automationId) {
|
|
578
|
+
const automation = await this.getAutomation(automationId);
|
|
579
|
+
if (!automation) throw automationNotFound(automationId);
|
|
580
|
+
const rows = await this.sql`
|
|
581
|
+
SELECT prompt FROM boring_automation_automations
|
|
582
|
+
WHERE id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
583
|
+
`;
|
|
584
|
+
return rows[0]?.prompt ?? "";
|
|
585
|
+
}
|
|
586
|
+
async updatePrompt(automationId, body) {
|
|
587
|
+
const result = await this.sql`
|
|
588
|
+
UPDATE boring_automation_automations SET prompt = ${body}, updated_at = ${this.clock().toISOString()}
|
|
589
|
+
WHERE id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
590
|
+
`;
|
|
591
|
+
if (result.count === 0) throw automationNotFound(automationId);
|
|
592
|
+
}
|
|
593
|
+
async reconcileOrphanedRuns(automationId) {
|
|
594
|
+
await this.sql`
|
|
595
|
+
UPDATE boring_automation_runs
|
|
596
|
+
SET status = 'failed', completed_at = ${this.clock().toISOString()}, error = 'Automation host restarted before the run completed', updated_at = ${this.clock().toISOString()}
|
|
597
|
+
WHERE automation_id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId} AND status IN ('queued', 'running')
|
|
598
|
+
`;
|
|
599
|
+
}
|
|
600
|
+
async beginRun(input) {
|
|
601
|
+
const automation = await this.getAutomation(input.automationId);
|
|
602
|
+
if (!automation) throw automationNotFound(input.automationId);
|
|
603
|
+
const active = await this.sql`
|
|
604
|
+
SELECT id FROM boring_automation_runs
|
|
605
|
+
WHERE automation_id = ${input.automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId} AND status IN ('queued', 'running')
|
|
606
|
+
LIMIT 1
|
|
607
|
+
`;
|
|
608
|
+
if (active[0]) throw runAlreadyActive(input.automationId);
|
|
609
|
+
try {
|
|
610
|
+
const now = input.createdAt ?? this.clock().toISOString();
|
|
611
|
+
const id = randomUUID2();
|
|
612
|
+
const rows = await this.sql`
|
|
613
|
+
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)
|
|
614
|
+
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})
|
|
615
|
+
RETURNING *
|
|
616
|
+
`;
|
|
617
|
+
return toRun(rows[0]);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if (isUniqueViolation(error)) {
|
|
620
|
+
if (constraintName(error) === "boring_automation_runs_active_once_idx") throw runAlreadyActive(input.automationId);
|
|
621
|
+
if (input.trigger === "scheduled" && input.scheduledFor) throw runAlreadyRecorded(input.automationId, input.scheduledFor);
|
|
622
|
+
}
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async updateRunLifecycle(runId, patch) {
|
|
627
|
+
const current = await this.findRun(runId);
|
|
628
|
+
if (!current) throw runNotFound(runId);
|
|
629
|
+
const next = { ...current, ...patch, updatedAt: this.clock().toISOString() };
|
|
630
|
+
const rows = await this.sql`
|
|
631
|
+
UPDATE boring_automation_runs
|
|
632
|
+
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}
|
|
633
|
+
WHERE id = ${runId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
634
|
+
RETURNING *
|
|
635
|
+
`;
|
|
636
|
+
if (!rows[0]) throw runNotFound(runId);
|
|
637
|
+
return toRun(rows[0]);
|
|
638
|
+
}
|
|
639
|
+
async listRuns(automationId) {
|
|
640
|
+
const rows = await this.sql`
|
|
641
|
+
SELECT * FROM boring_automation_runs
|
|
642
|
+
WHERE automation_id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
643
|
+
ORDER BY created_at DESC, id DESC
|
|
644
|
+
`;
|
|
645
|
+
return rows.map(toRun);
|
|
646
|
+
}
|
|
647
|
+
async findRun(runId) {
|
|
648
|
+
const rows = await this.sql`
|
|
649
|
+
SELECT * FROM boring_automation_runs
|
|
650
|
+
WHERE id = ${runId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
|
|
651
|
+
`;
|
|
652
|
+
return rows[0] ? toRun(rows[0]) : null;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
function toAutomation(row) {
|
|
656
|
+
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) };
|
|
657
|
+
}
|
|
658
|
+
function toRun(row) {
|
|
659
|
+
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) };
|
|
660
|
+
}
|
|
661
|
+
function iso(value) {
|
|
662
|
+
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
663
|
+
}
|
|
664
|
+
function nullableIso(value) {
|
|
665
|
+
return value === null ? null : iso(value);
|
|
666
|
+
}
|
|
667
|
+
function isUniqueViolation(error) {
|
|
668
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "23505");
|
|
669
|
+
}
|
|
670
|
+
function constraintName(error) {
|
|
671
|
+
return error && typeof error === "object" && "constraint_name" in error ? String(error.constraint_name) : void 0;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/server/manualRunExecutor.ts
|
|
675
|
+
var ManualRunExecutor = class {
|
|
676
|
+
constructor(options) {
|
|
677
|
+
this.options = options;
|
|
678
|
+
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
679
|
+
}
|
|
680
|
+
options;
|
|
681
|
+
clock;
|
|
682
|
+
async run(input) {
|
|
683
|
+
const actor = await this.options.actorResolver(input.request);
|
|
684
|
+
const store = await this.options.storeForRequest?.(input.request, actor) ?? this.options.store;
|
|
685
|
+
const automation = await store.getAutomation(input.automationId);
|
|
686
|
+
if (!automation) {
|
|
687
|
+
throw new AutomationStoreError(BORING_AUTOMATION_ERROR_CODES.AUTOMATION_NOT_FOUND, `automation ${input.automationId} not found`);
|
|
688
|
+
}
|
|
689
|
+
const promptSnapshot = await store.getPrompt(input.automationId);
|
|
690
|
+
const modelSnapshot = automation.model;
|
|
691
|
+
const model = parseAutomationModel(modelSnapshot);
|
|
692
|
+
const createdAt = this.nowIso();
|
|
693
|
+
const trigger = input.trigger ?? "manual";
|
|
694
|
+
const scheduledFor = trigger === "scheduled" ? input.scheduledFor ?? null : null;
|
|
695
|
+
if (trigger === "scheduled" && !scheduledFor) {
|
|
696
|
+
throw new AutomationStoreError(BORING_AUTOMATION_ERROR_CODES.INVALID_BODY, "scheduled runs require scheduledFor");
|
|
697
|
+
}
|
|
698
|
+
const run = await store.beginRun({
|
|
699
|
+
automationId: automation.id,
|
|
700
|
+
trigger,
|
|
701
|
+
scheduledFor,
|
|
702
|
+
promptSnapshot,
|
|
703
|
+
modelSnapshot,
|
|
704
|
+
createdAt
|
|
705
|
+
});
|
|
706
|
+
const usage = { input: null, output: null };
|
|
707
|
+
let current = run;
|
|
708
|
+
let sessionId = null;
|
|
709
|
+
let terminalStatus = null;
|
|
710
|
+
let terminalError = null;
|
|
711
|
+
let startedAt = null;
|
|
712
|
+
try {
|
|
713
|
+
const dispatcher = await this.options.dispatcherResolver.resolve(actor, { request: input.request });
|
|
714
|
+
startedAt = this.nowIso();
|
|
715
|
+
current = await store.updateRunLifecycle(run.id, {
|
|
716
|
+
status: "running",
|
|
717
|
+
startedAt,
|
|
718
|
+
sessionId: null
|
|
719
|
+
});
|
|
720
|
+
for await (const event of dispatcher.send({
|
|
721
|
+
content: promptSnapshot,
|
|
722
|
+
model,
|
|
723
|
+
actor: { id: actor.userId },
|
|
724
|
+
originSurface: "boring-automation"
|
|
725
|
+
})) {
|
|
726
|
+
const eventSessionId = sessionIdFromEvent(event);
|
|
727
|
+
if (!sessionId && eventSessionId) {
|
|
728
|
+
sessionId = eventSessionId;
|
|
729
|
+
current = await store.updateRunLifecycle(run.id, { sessionId });
|
|
730
|
+
}
|
|
731
|
+
aggregateUsage(usage, event);
|
|
732
|
+
const outcome = terminalOutcomeFromEvent(event);
|
|
733
|
+
if (outcome && !terminalStatus) {
|
|
734
|
+
terminalStatus = outcome.status;
|
|
735
|
+
terminalError = outcome.error;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const completedAt = this.nowIso();
|
|
739
|
+
return await this.finalizeRun(store, run.id, {
|
|
740
|
+
current,
|
|
741
|
+
sessionId,
|
|
742
|
+
startedAt,
|
|
743
|
+
completedAt,
|
|
744
|
+
status: terminalStatus ?? "succeeded",
|
|
745
|
+
error: terminalStatus === "failed" ? terminalError ?? "Automation run failed" : null,
|
|
746
|
+
usage
|
|
747
|
+
});
|
|
748
|
+
} catch (error) {
|
|
749
|
+
const completedAt = this.nowIso();
|
|
750
|
+
const cancelled = isCancellationError(error);
|
|
751
|
+
const status = terminalStatus ?? (cancelled ? "cancelled" : "failed");
|
|
752
|
+
return await this.finalizeRun(store, run.id, {
|
|
753
|
+
current,
|
|
754
|
+
sessionId,
|
|
755
|
+
startedAt,
|
|
756
|
+
completedAt,
|
|
757
|
+
status,
|
|
758
|
+
error: status === "failed" ? terminalError ?? safeErrorMessage2(error) : null,
|
|
759
|
+
usage
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async finalizeRun(store, runId, input) {
|
|
764
|
+
return await store.updateRunLifecycle(runId, {
|
|
765
|
+
status: input.status,
|
|
766
|
+
completedAt: input.completedAt,
|
|
767
|
+
durationMs: durationMs(input.startedAt ?? input.current.createdAt, input.completedAt),
|
|
768
|
+
sessionId: input.sessionId,
|
|
769
|
+
...finalizeUsage(input.usage),
|
|
770
|
+
error: input.error
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
nowIso() {
|
|
774
|
+
return this.clock().toISOString();
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
function parseAutomationModel(value) {
|
|
778
|
+
const index = value.indexOf(":");
|
|
779
|
+
if (index <= 0 || index === value.length - 1) {
|
|
780
|
+
throw new AutomationStoreError(
|
|
781
|
+
BORING_AUTOMATION_ERROR_CODES.INVALID_MODEL,
|
|
782
|
+
"automation model must use explicit provider:model-id syntax"
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
const provider = value.slice(0, index).trim();
|
|
786
|
+
const id = value.slice(index + 1).trim();
|
|
787
|
+
if (!provider || !id) {
|
|
788
|
+
throw new AutomationStoreError(
|
|
789
|
+
BORING_AUTOMATION_ERROR_CODES.INVALID_MODEL,
|
|
790
|
+
"automation model must use explicit provider:model-id syntax"
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return { provider, id };
|
|
794
|
+
}
|
|
795
|
+
function sessionIdFromEvent(event) {
|
|
796
|
+
if (!event || typeof event !== "object") return null;
|
|
797
|
+
const sessionId = event.sessionId;
|
|
798
|
+
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
|
|
799
|
+
}
|
|
800
|
+
function chunkFromEvent(event) {
|
|
801
|
+
if (!event || typeof event !== "object") return null;
|
|
802
|
+
const chunk = event.chunk;
|
|
803
|
+
if (!chunk || typeof chunk !== "object") return null;
|
|
804
|
+
return chunk;
|
|
805
|
+
}
|
|
806
|
+
function aggregateUsage(accumulator, event) {
|
|
807
|
+
const chunk = chunkFromEvent(event);
|
|
808
|
+
if (!chunk || chunk.type !== "usage") return;
|
|
809
|
+
const usage = chunk.usage;
|
|
810
|
+
if (!usage || typeof usage !== "object") return;
|
|
811
|
+
const record = usage;
|
|
812
|
+
const input = sumObservedNumbers(record.input, record.inputTokens, record.cacheRead, record.cacheReadTokens, record.cacheWrite, record.cacheWriteTokens);
|
|
813
|
+
const output = sumObservedNumbers(record.output, record.outputTokens);
|
|
814
|
+
if (input !== null) accumulator.input = (accumulator.input ?? 0) + input;
|
|
815
|
+
if (output !== null) accumulator.output = (accumulator.output ?? 0) + output;
|
|
816
|
+
}
|
|
817
|
+
function sumObservedNumbers(...values) {
|
|
818
|
+
let observed = false;
|
|
819
|
+
let total = 0;
|
|
820
|
+
for (const value of values) {
|
|
821
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) continue;
|
|
822
|
+
observed = true;
|
|
823
|
+
total += Math.trunc(value);
|
|
824
|
+
}
|
|
825
|
+
return observed ? total : null;
|
|
826
|
+
}
|
|
827
|
+
function finalizeUsage(usage) {
|
|
828
|
+
if (usage.input === null && usage.output === null) {
|
|
829
|
+
return { inputTokens: null, outputTokens: null, totalTokens: null };
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
inputTokens: usage.input,
|
|
833
|
+
outputTokens: usage.output,
|
|
834
|
+
totalTokens: (usage.input ?? 0) + (usage.output ?? 0)
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function terminalOutcomeFromEvent(event) {
|
|
838
|
+
const chunk = chunkFromEvent(event);
|
|
839
|
+
if (!chunk) return null;
|
|
840
|
+
if (chunk.type === "agent-end" && !chunk.willRetry) {
|
|
841
|
+
if (chunk.status === "ok") return { status: "succeeded", error: null };
|
|
842
|
+
if (chunk.status === "aborted") return { status: "cancelled", error: null };
|
|
843
|
+
return { status: "failed", error: "Automation run failed" };
|
|
844
|
+
}
|
|
845
|
+
if (chunk.type === "error") {
|
|
846
|
+
return { status: "failed", error: safeErrorMessage2(chunk.error) };
|
|
847
|
+
}
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
function isCancellationError(error) {
|
|
851
|
+
if (!error || typeof error !== "object") return false;
|
|
852
|
+
const record = error;
|
|
853
|
+
return record.name === "AbortError" || record.code === "ABORT_ERR";
|
|
854
|
+
}
|
|
855
|
+
function safeErrorMessage2(error) {
|
|
856
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : error && typeof error === "object" && typeof error.message === "string" ? error.message : "Automation run failed";
|
|
857
|
+
const firstLine = raw.split(/\r?\n/u)[0]?.trim() || "Automation run failed";
|
|
858
|
+
return firstLine.length > 300 ? `${firstLine.slice(0, 297)}...` : firstLine;
|
|
859
|
+
}
|
|
860
|
+
function durationMs(startedAt, completedAt) {
|
|
861
|
+
return Math.max(0, new Date(completedAt).getTime() - new Date(startedAt).getTime());
|
|
862
|
+
}
|
|
863
|
+
|
|
303
864
|
// src/server/routes.ts
|
|
304
865
|
import { ZodError } from "zod";
|
|
305
866
|
async function automationRoutes(app, opts) {
|
|
306
867
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations`, async (_request, reply) => {
|
|
307
868
|
try {
|
|
308
|
-
return { ok: true, automations: await opts.
|
|
869
|
+
return { ok: true, automations: await (await resolveStore(opts, _request)).listAutomations() };
|
|
309
870
|
} catch (cause) {
|
|
310
871
|
return sendError(reply, cause);
|
|
311
872
|
}
|
|
312
873
|
});
|
|
313
874
|
app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations`, async (request, reply) => {
|
|
314
875
|
try {
|
|
315
|
-
const automation = await opts.
|
|
876
|
+
const automation = await (await resolveStore(opts, request)).createAutomation(parseBody(AutomationCreateSchema, request.body));
|
|
316
877
|
return reply.status(201).send({ ok: true, automation });
|
|
317
878
|
} catch (cause) {
|
|
318
879
|
return sendError(reply, cause);
|
|
@@ -321,7 +882,7 @@ async function automationRoutes(app, opts) {
|
|
|
321
882
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id`, async (request, reply) => {
|
|
322
883
|
try {
|
|
323
884
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
324
|
-
const automation = await opts.
|
|
885
|
+
const automation = await (await resolveStore(opts, request)).getAutomation(id);
|
|
325
886
|
if (!automation) throw automationNotFound(id);
|
|
326
887
|
return { ok: true, automation };
|
|
327
888
|
} catch (cause) {
|
|
@@ -331,7 +892,7 @@ async function automationRoutes(app, opts) {
|
|
|
331
892
|
app.patch(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id`, async (request, reply) => {
|
|
332
893
|
try {
|
|
333
894
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
334
|
-
const automation = await opts.
|
|
895
|
+
const automation = await (await resolveStore(opts, request)).updateAutomation(id, parseBody(AutomationPatchSchema, request.body));
|
|
335
896
|
return { ok: true, automation };
|
|
336
897
|
} catch (cause) {
|
|
337
898
|
return sendError(reply, cause);
|
|
@@ -340,7 +901,7 @@ async function automationRoutes(app, opts) {
|
|
|
340
901
|
app.delete(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id`, async (request, reply) => {
|
|
341
902
|
try {
|
|
342
903
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
343
|
-
await opts.
|
|
904
|
+
await (await resolveStore(opts, request)).deleteAutomation(id);
|
|
344
905
|
return reply.status(204).send();
|
|
345
906
|
} catch (cause) {
|
|
346
907
|
return sendError(reply, cause);
|
|
@@ -349,7 +910,7 @@ async function automationRoutes(app, opts) {
|
|
|
349
910
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id/prompt`, async (request, reply) => {
|
|
350
911
|
try {
|
|
351
912
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
352
|
-
return { ok: true, prompt: await opts.
|
|
913
|
+
return { ok: true, prompt: await (await resolveStore(opts, request)).getPrompt(id) };
|
|
353
914
|
} catch (cause) {
|
|
354
915
|
return sendError(reply, cause);
|
|
355
916
|
}
|
|
@@ -358,21 +919,58 @@ async function automationRoutes(app, opts) {
|
|
|
358
919
|
try {
|
|
359
920
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
360
921
|
const { prompt } = parseBody(PromptUpdateSchema, request.body);
|
|
361
|
-
await opts.
|
|
922
|
+
await (await resolveStore(opts, request)).updatePrompt(id, prompt);
|
|
362
923
|
return { ok: true };
|
|
363
924
|
} catch (cause) {
|
|
364
925
|
return sendError(reply, cause);
|
|
365
926
|
}
|
|
366
927
|
});
|
|
928
|
+
app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/due`, async (request, reply) => {
|
|
929
|
+
try {
|
|
930
|
+
if (!isLoopbackAddress(request.ip)) {
|
|
931
|
+
throw new AutomationStoreError(
|
|
932
|
+
BORING_AUTOMATION_ERROR_CODES.TRIGGER_FORBIDDEN,
|
|
933
|
+
"automation due trigger is limited to loopback callers"
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
if (!opts.dueRunService) {
|
|
937
|
+
throw new AutomationStoreError(
|
|
938
|
+
BORING_AUTOMATION_ERROR_CODES.RUN_EXECUTOR_UNAVAILABLE,
|
|
939
|
+
"automation due executor is unavailable"
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
return { ok: true, ...await opts.dueRunService.runDue(request) };
|
|
943
|
+
} catch (cause) {
|
|
944
|
+
return sendError(reply, cause);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id/run`, async (request, reply) => {
|
|
948
|
+
try {
|
|
949
|
+
const { id } = parseParams(IdParamsSchema, request.params);
|
|
950
|
+
if (!opts.manualRunExecutor) {
|
|
951
|
+
throw new AutomationStoreError(
|
|
952
|
+
BORING_AUTOMATION_ERROR_CODES.RUN_EXECUTOR_UNAVAILABLE,
|
|
953
|
+
"automation run executor is unavailable"
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
const run = await opts.manualRunExecutor.run({ automationId: id, request });
|
|
957
|
+
return reply.status(201).send({ ok: true, run });
|
|
958
|
+
} catch (cause) {
|
|
959
|
+
return sendError(reply, cause);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
367
962
|
app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations/:id/runs`, async (request, reply) => {
|
|
368
963
|
try {
|
|
369
964
|
const { id } = parseParams(IdParamsSchema, request.params);
|
|
370
|
-
return { ok: true, runs: await opts.
|
|
965
|
+
return { ok: true, runs: await (await resolveStore(opts, request)).listRuns(id) };
|
|
371
966
|
} catch (cause) {
|
|
372
967
|
return sendError(reply, cause);
|
|
373
968
|
}
|
|
374
969
|
});
|
|
375
970
|
}
|
|
971
|
+
async function resolveStore(opts, request) {
|
|
972
|
+
return await opts.storeForRequest?.(request) ?? opts.store;
|
|
973
|
+
}
|
|
376
974
|
function parseBody(schema, body) {
|
|
377
975
|
return parse(schema, body);
|
|
378
976
|
}
|
|
@@ -386,10 +984,11 @@ function parse(schema, value) {
|
|
|
386
984
|
}
|
|
387
985
|
function sendError(reply, cause) {
|
|
388
986
|
if (cause instanceof ZodError) {
|
|
987
|
+
const issue = cause.issues[0];
|
|
389
988
|
return reply.status(400).send({
|
|
390
989
|
ok: false,
|
|
391
|
-
code:
|
|
392
|
-
error:
|
|
990
|
+
code: zodIssueErrorCode(issue),
|
|
991
|
+
error: issue?.message ?? "invalid request"
|
|
393
992
|
});
|
|
394
993
|
}
|
|
395
994
|
if (cause instanceof AutomationStoreError) {
|
|
@@ -397,24 +996,119 @@ function sendError(reply, cause) {
|
|
|
397
996
|
}
|
|
398
997
|
throw cause;
|
|
399
998
|
}
|
|
999
|
+
function zodIssueErrorCode(issue) {
|
|
1000
|
+
const field = issue?.path[0];
|
|
1001
|
+
if (field === "cron") return BORING_AUTOMATION_ERROR_CODES.INVALID_CRON;
|
|
1002
|
+
if (field === "timezone") return BORING_AUTOMATION_ERROR_CODES.INVALID_TIMEZONE;
|
|
1003
|
+
return BORING_AUTOMATION_ERROR_CODES.INVALID_BODY;
|
|
1004
|
+
}
|
|
1005
|
+
function isLoopbackAddress(address) {
|
|
1006
|
+
const normalized = address.trim().toLowerCase();
|
|
1007
|
+
return normalized === "127.0.0.1" || normalized === "::1" || normalized.startsWith("::ffff:127.");
|
|
1008
|
+
}
|
|
400
1009
|
function httpStatusForStoreError(error) {
|
|
401
1010
|
switch (error.code) {
|
|
402
1011
|
case BORING_AUTOMATION_ERROR_CODES.INVALID_BODY:
|
|
1012
|
+
case BORING_AUTOMATION_ERROR_CODES.INVALID_CRON:
|
|
1013
|
+
case BORING_AUTOMATION_ERROR_CODES.INVALID_TIMEZONE:
|
|
1014
|
+
case BORING_AUTOMATION_ERROR_CODES.INVALID_MODEL:
|
|
403
1015
|
return 400;
|
|
404
1016
|
case BORING_AUTOMATION_ERROR_CODES.AUTOMATION_NOT_FOUND:
|
|
405
1017
|
case BORING_AUTOMATION_ERROR_CODES.RUN_NOT_FOUND:
|
|
406
1018
|
return 404;
|
|
1019
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_ACTIVE:
|
|
1020
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_ALREADY_RECORDED:
|
|
1021
|
+
return 409;
|
|
1022
|
+
case BORING_AUTOMATION_ERROR_CODES.TRIGGER_FORBIDDEN:
|
|
1023
|
+
return 403;
|
|
1024
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_EXECUTOR_UNAVAILABLE:
|
|
1025
|
+
return 503;
|
|
1026
|
+
case BORING_AUTOMATION_ERROR_CODES.RUN_FAILED:
|
|
1027
|
+
return 500;
|
|
407
1028
|
}
|
|
408
1029
|
}
|
|
409
1030
|
|
|
1031
|
+
// src/server/migrations.ts
|
|
1032
|
+
async function runBoringAutomationMigrations(sql) {
|
|
1033
|
+
await sql.unsafe(`
|
|
1034
|
+
CREATE TABLE IF NOT EXISTS boring_automation_automations (
|
|
1035
|
+
id uuid PRIMARY KEY,
|
|
1036
|
+
workspace_id text NOT NULL,
|
|
1037
|
+
owner_user_id text NOT NULL,
|
|
1038
|
+
title text NOT NULL,
|
|
1039
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
1040
|
+
cron text NOT NULL,
|
|
1041
|
+
timezone text NOT NULL,
|
|
1042
|
+
model text NOT NULL,
|
|
1043
|
+
prompt text NOT NULL DEFAULT '',
|
|
1044
|
+
created_at timestamptz NOT NULL,
|
|
1045
|
+
updated_at timestamptz NOT NULL
|
|
1046
|
+
)
|
|
1047
|
+
`);
|
|
1048
|
+
await sql.unsafe(`
|
|
1049
|
+
CREATE INDEX IF NOT EXISTS boring_automation_automations_owner_idx
|
|
1050
|
+
ON boring_automation_automations (workspace_id, owner_user_id)
|
|
1051
|
+
`);
|
|
1052
|
+
await sql.unsafe(`
|
|
1053
|
+
CREATE TABLE IF NOT EXISTS boring_automation_runs (
|
|
1054
|
+
id uuid PRIMARY KEY,
|
|
1055
|
+
automation_id uuid NOT NULL REFERENCES boring_automation_automations(id) ON DELETE CASCADE,
|
|
1056
|
+
workspace_id text NOT NULL,
|
|
1057
|
+
owner_user_id text NOT NULL,
|
|
1058
|
+
session_id text,
|
|
1059
|
+
status text NOT NULL,
|
|
1060
|
+
trigger text NOT NULL,
|
|
1061
|
+
scheduled_for timestamptz,
|
|
1062
|
+
started_at timestamptz,
|
|
1063
|
+
completed_at timestamptz,
|
|
1064
|
+
duration_ms integer,
|
|
1065
|
+
input_tokens integer,
|
|
1066
|
+
output_tokens integer,
|
|
1067
|
+
total_tokens integer,
|
|
1068
|
+
prompt_snapshot text NOT NULL,
|
|
1069
|
+
model_snapshot text NOT NULL,
|
|
1070
|
+
error text,
|
|
1071
|
+
created_at timestamptz NOT NULL,
|
|
1072
|
+
updated_at timestamptz NOT NULL,
|
|
1073
|
+
CONSTRAINT boring_automation_runs_status_check CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
|
1074
|
+
CONSTRAINT boring_automation_runs_trigger_check CHECK (trigger IN ('manual', 'scheduled'))
|
|
1075
|
+
)
|
|
1076
|
+
`);
|
|
1077
|
+
await sql.unsafe(`
|
|
1078
|
+
CREATE INDEX IF NOT EXISTS boring_automation_runs_automation_idx
|
|
1079
|
+
ON boring_automation_runs (automation_id, created_at DESC)
|
|
1080
|
+
`);
|
|
1081
|
+
await sql.unsafe(`
|
|
1082
|
+
CREATE UNIQUE INDEX IF NOT EXISTS boring_automation_runs_active_once_idx
|
|
1083
|
+
ON boring_automation_runs (automation_id)
|
|
1084
|
+
WHERE status IN ('queued', 'running')
|
|
1085
|
+
`);
|
|
1086
|
+
await sql.unsafe(`
|
|
1087
|
+
CREATE UNIQUE INDEX IF NOT EXISTS boring_automation_runs_scheduled_once_idx
|
|
1088
|
+
ON boring_automation_runs (automation_id, scheduled_for)
|
|
1089
|
+
WHERE trigger = 'scheduled' AND scheduled_for IS NOT NULL
|
|
1090
|
+
`);
|
|
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({
|
|
1097
|
+
store,
|
|
1098
|
+
storeForRequest: options.storeForRequest,
|
|
1099
|
+
dispatcherResolver: options.dispatcherResolver,
|
|
1100
|
+
actorResolver: options.actorResolver
|
|
1101
|
+
}) : void 0;
|
|
1102
|
+
const dueRunService = manualRunExecutor && !options.storeForRequest ? new DueRunService({ store, executor: manualRunExecutor }) : void 0;
|
|
413
1103
|
return defineServerPlugin({
|
|
414
1104
|
id: BORING_AUTOMATION_PLUGIN_ID,
|
|
415
1105
|
label: BORING_AUTOMATION_PLUGIN_LABEL,
|
|
416
1106
|
routes: async (app) => {
|
|
417
|
-
await automationRoutes(app, { store
|
|
1107
|
+
await automationRoutes(app, { store, storeForRequest: options.storeForRequest ? async (request) => {
|
|
1108
|
+
const actor = options.actorResolver ? await options.actorResolver(request) : void 0;
|
|
1109
|
+
if (!actor) throw new Error("automation actor resolver is unavailable");
|
|
1110
|
+
return await options.storeForRequest(request, actor);
|
|
1111
|
+
} : void 0, manualRunExecutor, dueRunService });
|
|
418
1112
|
}
|
|
419
1113
|
});
|
|
420
1114
|
}
|
|
@@ -423,16 +1117,31 @@ function createDefaultStore(workspaceRoot) {
|
|
|
423
1117
|
return new FileAutomationStore(join2(workspaceRoot, ".pi", "automation"));
|
|
424
1118
|
}
|
|
425
1119
|
function defaultBoringAutomationServerPlugin(options, ctx) {
|
|
1120
|
+
const trusted = ctx?.trusted;
|
|
1121
|
+
if (!options?.store && trusted?.sql && trusted.workspaceAgentDispatcherResolver && trusted.actorResolver) {
|
|
1122
|
+
const sql = trusted.sql;
|
|
1123
|
+
const fallbackStore = new PostgresAutomationStore(sql, { workspaceId: "unbound", userId: "unbound" });
|
|
1124
|
+
return createBoringAutomationServerPlugin({
|
|
1125
|
+
...options,
|
|
1126
|
+
store: fallbackStore,
|
|
1127
|
+
storeForRequest: async (_request, actor) => new PostgresAutomationStore(sql, actor),
|
|
1128
|
+
dispatcherResolver: options?.dispatcherResolver ?? trusted.workspaceAgentDispatcherResolver,
|
|
1129
|
+
actorResolver: options?.actorResolver ?? trusted.actorResolver
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
426
1132
|
return createBoringAutomationServerPlugin({
|
|
427
1133
|
...options,
|
|
428
|
-
workspaceRoot: options?.workspaceRoot ?? ctx?.workspaceRoot
|
|
1134
|
+
workspaceRoot: options?.workspaceRoot ?? ctx?.workspaceRoot,
|
|
1135
|
+
dispatcherResolver: options?.dispatcherResolver ?? trusted?.workspaceAgentDispatcherResolver,
|
|
1136
|
+
actorResolver: options?.actorResolver ?? trusted?.actorResolver
|
|
429
1137
|
});
|
|
430
1138
|
}
|
|
431
1139
|
export {
|
|
1140
|
+
AUTOMATION_SCHEDULE_ERRORS,
|
|
432
1141
|
AutomationCreateSchema,
|
|
433
1142
|
AutomationPatchSchema,
|
|
434
|
-
|
|
435
|
-
|
|
1143
|
+
AutomationRunBeginSchema,
|
|
1144
|
+
AutomationRunLifecyclePatchSchema,
|
|
436
1145
|
AutomationRunStatusSchema,
|
|
437
1146
|
AutomationRunTriggerSchema,
|
|
438
1147
|
AutomationStoreError,
|
|
@@ -440,12 +1149,23 @@ export {
|
|
|
440
1149
|
BORING_AUTOMATION_PLUGIN_ID,
|
|
441
1150
|
BORING_AUTOMATION_PLUGIN_LABEL,
|
|
442
1151
|
BORING_AUTOMATION_ROUTE_PREFIX,
|
|
1152
|
+
DueRunService,
|
|
443
1153
|
FileAutomationStore,
|
|
444
1154
|
IdParamsSchema,
|
|
1155
|
+
ManualRunExecutor,
|
|
1156
|
+
PostgresAutomationStore,
|
|
445
1157
|
PromptUpdateSchema,
|
|
446
1158
|
automationNotFound,
|
|
447
1159
|
automationRoutes,
|
|
448
1160
|
createBoringAutomationServerPlugin,
|
|
449
1161
|
defaultBoringAutomationServerPlugin as default,
|
|
450
|
-
|
|
1162
|
+
evaluateAutomationSchedule,
|
|
1163
|
+
isValidFiveFieldCron,
|
|
1164
|
+
isValidIanaTimeZone,
|
|
1165
|
+
parseAutomationModel,
|
|
1166
|
+
runAlreadyActive,
|
|
1167
|
+
runAlreadyRecorded,
|
|
1168
|
+
runBoringAutomationMigrations,
|
|
1169
|
+
runNotFound,
|
|
1170
|
+
validateAutomationSchedule
|
|
451
1171
|
};
|