@hachej/boring-automation 0.1.71 → 0.1.79

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