@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.
@@ -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 AutomationRunCreateSchema = z.object({
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
- error: z.string().nullable().optional()
150
+ createdAt: isoString.optional()
57
151
  }).strict();
58
- var AutomationRunPatchSchema = z.object({
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
- // src/server/fileStore.ts
73
- import { randomUUID } from "crypto";
74
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
75
- import { dirname, join } from "path";
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 createRun(input) {
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: input.sessionId ?? null,
190
- status: input.status ?? "queued",
393
+ sessionId: null,
394
+ status: "queued",
191
395
  trigger: input.trigger,
192
396
  scheduledFor: input.scheduledFor ?? null,
193
- startedAt: input.startedAt ?? null,
194
- completedAt: input.completedAt ?? null,
195
- durationMs: input.durationMs ?? null,
196
- inputTokens: input.inputTokens ?? null,
197
- outputTokens: input.outputTokens ?? null,
198
- totalTokens: input.totalTokens ?? null,
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: input.error ?? null,
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 updateRun(runId, patch) {
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 applyRunPatch(run, patch) {
277
- const next = { ...run, updatedAt: nowIso() };
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.store.listAutomations() };
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.store.createAutomation(parseBody(AutomationCreateSchema, request.body));
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.store.getAutomation(id);
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.store.updateAutomation(id, parseBody(AutomationPatchSchema, request.body));
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.store.deleteAutomation(id);
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.store.getPrompt(id) };
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.store.updatePrompt(id, prompt);
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.store.listRuns(id) };
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: BORING_AUTOMATION_ERROR_CODES.INVALID_BODY,
392
- error: cause.issues[0]?.message ?? "invalid request"
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
- AutomationRunCreateSchema,
435
- AutomationRunPatchSchema,
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
- runNotFound
1162
+ evaluateAutomationSchedule,
1163
+ isValidFiveFieldCron,
1164
+ isValidIanaTimeZone,
1165
+ parseAutomationModel,
1166
+ runAlreadyActive,
1167
+ runAlreadyRecorded,
1168
+ runBoringAutomationMigrations,
1169
+ runNotFound,
1170
+ validateAutomationSchedule
451
1171
  };