@hachej/boring-automation 0.1.80 → 0.1.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,4 +15,4 @@ Executable model values use explicit `provider:model-id` syntax. Legacy unqualif
15
15
 
16
16
  Execution is currently composed for trusted local CLI folder mode. Scheduling has no background timer: user-owned cron/systemd may invoke `POST /api/v1/boring-automation/due` once per minute while the CLI server is running. Missed minutes are not backfilled.
17
17
 
18
- Hosted persistence infrastructure is now available as an explicit deployment migration callback (`runBoringAutomationMigrations`) and an actor-bound `PostgresAutomationStore`. Full-app route/executor composition and authenticated hosted triggers remain separate follow-up work; hosted execution is not activated by this infrastructure alone.
18
+ Hosted persistence and creator-scoped execution are available in full-app. The deployment migration callback is `runBoringAutomationMigrations`; configure `BORING_AUTOMATION_TRIGGER_TOKEN` and have the platform scheduler invoke `POST /api/v1/boring-automation/due/hosted` with `Authorization: Bearer <token>`. The endpoint re-checks each creator and fails closed when authorization is lost. It has no hidden timer.
@@ -21,7 +21,9 @@ var BORING_AUTOMATION_ERROR_CODES = {
21
21
  RUN_ALREADY_RECORDED: "BORING_AUTOMATION_RUN_ALREADY_RECORDED",
22
22
  RUN_EXECUTOR_UNAVAILABLE: "BORING_AUTOMATION_RUN_EXECUTOR_UNAVAILABLE",
23
23
  TRIGGER_FORBIDDEN: "BORING_AUTOMATION_TRIGGER_FORBIDDEN",
24
- RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED"
24
+ RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED",
25
+ OWNER_UNAUTHORIZED: "BORING_AUTOMATION_OWNER_UNAUTHORIZED",
26
+ TRIGGER_UNAUTHORIZED: "BORING_AUTOMATION_TRIGGER_UNAUTHORIZED"
25
27
  };
26
28
 
27
29
  // src/shared/schema.ts
@@ -2,9 +2,9 @@ import { WorkspaceAgentDispatcherResolver } from '@hachej/boring-agent/server';
2
2
  import { FastifyRequest, FastifyInstance } from 'fastify';
3
3
  import { WorkspaceAgentServerPluginContext } from '@hachej/boring-workspace/app/server';
4
4
  import { WorkspaceServerPlugin } from '@hachej/boring-workspace/server';
5
+ import postgres from 'postgres';
5
6
  import { Automation, AutomationCreate, AutomationPatch, AutomationRunBegin, AutomationRun, AutomationRunLifecyclePatch, BoringAutomationErrorCode, AutomationScheduleDecision } from '../shared/index.js';
6
7
  export { AUTOMATION_SCHEDULE_ERRORS, AutomationCreateInput, AutomationCreateSchema, AutomationPatchInput, AutomationPatchSchema, AutomationRunBeginInput, AutomationRunBeginSchema, AutomationRunLifecyclePatchInput, AutomationRunLifecyclePatchSchema, AutomationRunStatus, AutomationRunStatusSchema, AutomationRunTrigger, AutomationRunTriggerSchema, AutomationScheduleDueDecision, AutomationScheduleDueReason, AutomationScheduleSkipDecision, AutomationScheduleSkipReason, AutomationScheduleValidationResult, BORING_AUTOMATION_ERROR_CODES, BORING_AUTOMATION_PLUGIN_ID, BORING_AUTOMATION_PLUGIN_LABEL, BORING_AUTOMATION_ROUTE_PREFIX, EvaluateAutomationScheduleInput, EvaluateAutomationScheduleResult, IdParamsSchema, PromptUpdateSchema, evaluateAutomationSchedule, isValidFiveFieldCron, isValidIanaTimeZone, validateAutomationSchedule } from '../shared/index.js';
7
- import postgres from 'postgres';
8
8
  import 'zod';
9
9
 
10
10
  /** Plugin-local dependency injection seam for the single-workspace automation store. */
@@ -46,6 +46,7 @@ interface ManualRunInput {
46
46
  request: FastifyRequest;
47
47
  trigger?: "manual" | "scheduled";
48
48
  scheduledFor?: string | null;
49
+ actor?: VerifiedAutomationActor;
49
50
  }
50
51
  declare class ManualRunExecutor {
51
52
  private readonly options;
@@ -97,6 +98,55 @@ declare class DueRunService {
97
98
  runDue(request: FastifyRequest): Promise<DueRunResult>;
98
99
  }
99
100
 
101
+ interface HostedAutomationActor {
102
+ workspaceId: string;
103
+ userId: string;
104
+ }
105
+ interface HostedAutomationCandidate {
106
+ automation: Automation;
107
+ actor: HostedAutomationActor;
108
+ runs: AutomationRun[];
109
+ }
110
+ type Sql = postgres.Sql;
111
+ /** Hosted store bound to a verified workspace/user pair; every query is scoped by both. */
112
+ declare class PostgresAutomationStore implements AutomationStore {
113
+ private readonly sql;
114
+ private readonly actor;
115
+ private readonly clock;
116
+ constructor(sql: Sql, actor: HostedAutomationActor, clock?: () => Date);
117
+ listAutomations(): Promise<Automation[]>;
118
+ getAutomation(id: string): Promise<Automation | null>;
119
+ createAutomation(input: AutomationCreate): Promise<Automation>;
120
+ updateAutomation(id: string, patch: AutomationPatch): Promise<Automation>;
121
+ deleteAutomation(id: string): Promise<void>;
122
+ getPrompt(automationId: string): Promise<string>;
123
+ updatePrompt(automationId: string, body: string): Promise<void>;
124
+ reconcileOrphanedRuns(automationId: string): Promise<void>;
125
+ beginRun(input: AutomationRunBegin): Promise<AutomationRun>;
126
+ updateRunLifecycle(runId: string, patch: AutomationRunLifecyclePatch): Promise<AutomationRun>;
127
+ listRuns(automationId: string): Promise<AutomationRun[]>;
128
+ private findRun;
129
+ }
130
+ declare function listHostedAutomationCandidates(sql: Sql): Promise<HostedAutomationCandidate[]>;
131
+
132
+ interface HostedDueRunServiceOptions {
133
+ sql: postgres.Sql;
134
+ dispatcherResolver: WorkspaceAgentDispatcherResolver;
135
+ verifyActor: (actor: HostedAutomationActor) => Promise<boolean> | boolean;
136
+ clock?: () => Date;
137
+ }
138
+ interface HostedDueRunResult {
139
+ now: string;
140
+ outcomes: DueRunOutcome[];
141
+ }
142
+ /** Runs due work for every creator while preserving creator-scoped execution. */
143
+ declare class HostedDueRunService {
144
+ private readonly options;
145
+ private readonly clock;
146
+ constructor(options: HostedDueRunServiceOptions);
147
+ runDue(request: FastifyRequest): Promise<HostedDueRunResult>;
148
+ }
149
+
100
150
  type AtomicWriter = (path: string, content: string) => Promise<void>;
101
151
  interface FileAutomationStoreOptions {
102
152
  writer?: AtomicWriter;
@@ -134,36 +184,13 @@ declare class FileAutomationStore implements AutomationStore {
134
184
  /** Deployment-owned hosted schema registration for the automation plugin. */
135
185
  declare function runBoringAutomationMigrations(sql: postgres.Sql): Promise<void>;
136
186
 
137
- interface HostedAutomationActor {
138
- workspaceId: string;
139
- userId: string;
140
- }
141
- type Sql = postgres.Sql;
142
- /** Hosted store bound to a verified workspace/user pair; every query is scoped by both. */
143
- declare class PostgresAutomationStore implements AutomationStore {
144
- private readonly sql;
145
- private readonly actor;
146
- private readonly clock;
147
- constructor(sql: Sql, actor: HostedAutomationActor, clock?: () => Date);
148
- listAutomations(): Promise<Automation[]>;
149
- getAutomation(id: string): Promise<Automation | null>;
150
- createAutomation(input: AutomationCreate): Promise<Automation>;
151
- updateAutomation(id: string, patch: AutomationPatch): Promise<Automation>;
152
- deleteAutomation(id: string): Promise<void>;
153
- getPrompt(automationId: string): Promise<string>;
154
- updatePrompt(automationId: string, body: string): Promise<void>;
155
- reconcileOrphanedRuns(automationId: string): Promise<void>;
156
- beginRun(input: AutomationRunBegin): Promise<AutomationRun>;
157
- updateRunLifecycle(runId: string, patch: AutomationRunLifecyclePatch): Promise<AutomationRun>;
158
- listRuns(automationId: string): Promise<AutomationRun[]>;
159
- private findRun;
160
- }
161
-
162
187
  interface AutomationRoutesOptions {
163
188
  store: AutomationStore;
164
189
  storeForRequest?: (request: FastifyRequest) => Promise<AutomationStore> | AutomationStore;
165
190
  manualRunExecutor?: Pick<ManualRunExecutor, "run">;
166
191
  dueRunService?: Pick<DueRunService, "runDue">;
192
+ hostedDueRunService?: Pick<HostedDueRunService, "runDue">;
193
+ hostedTriggerToken?: string;
167
194
  }
168
195
  declare function automationRoutes(app: FastifyInstance, opts: AutomationRoutesOptions): Promise<void>;
169
196
 
@@ -173,8 +200,11 @@ interface BoringAutomationServerPluginOptions {
173
200
  dispatcherResolver?: WorkspaceAgentDispatcherResolver;
174
201
  actorResolver?: (request: FastifyRequest) => Promise<VerifiedAutomationActor> | VerifiedAutomationActor;
175
202
  storeForRequest?: (request: FastifyRequest, actor: VerifiedAutomationActor) => Promise<AutomationStore> | AutomationStore;
203
+ actorVerifier?: (actor: VerifiedAutomationActor) => Promise<boolean> | boolean;
204
+ hostedTriggerToken?: string;
205
+ hostedDueRunService?: Pick<HostedDueRunService, "runDue">;
176
206
  }
177
207
  declare function createBoringAutomationServerPlugin(options?: BoringAutomationServerPluginOptions): WorkspaceServerPlugin;
178
208
  declare function defaultBoringAutomationServerPlugin(options?: BoringAutomationServerPluginOptions, ctx?: Pick<WorkspaceAgentServerPluginContext, "workspaceRoot"> & Partial<Pick<WorkspaceAgentServerPluginContext, "trusted">>): WorkspaceServerPlugin;
179
209
 
180
- export { Automation, AutomationCreate, AutomationPatch, type AutomationRoutesOptions, AutomationRun, AutomationRunBegin, AutomationRunLifecyclePatch, AutomationScheduleDecision, type AutomationStore, AutomationStoreError, BoringAutomationErrorCode, type BoringAutomationServerPluginOptions, type DueRunOutcome, type DueRunResult, DueRunService, type DueRunServiceOptions, type DueRunSummary, FileAutomationStore, type FileAutomationStoreOptions, type HostedAutomationActor, ManualRunExecutor, type ManualRunExecutorOptions, type ManualRunInput, PostgresAutomationStore, type VerifiedAutomationActor, automationNotFound, automationRoutes, createBoringAutomationServerPlugin, defaultBoringAutomationServerPlugin as default, parseAutomationModel, runAlreadyActive, runAlreadyRecorded, runBoringAutomationMigrations, runNotFound };
210
+ export { Automation, AutomationCreate, AutomationPatch, type AutomationRoutesOptions, AutomationRun, AutomationRunBegin, AutomationRunLifecyclePatch, AutomationScheduleDecision, type AutomationStore, AutomationStoreError, BoringAutomationErrorCode, type BoringAutomationServerPluginOptions, type DueRunOutcome, type DueRunResult, DueRunService, type DueRunServiceOptions, type DueRunSummary, FileAutomationStore, type FileAutomationStoreOptions, type HostedAutomationActor, type HostedAutomationCandidate, type HostedDueRunResult, HostedDueRunService, type HostedDueRunServiceOptions, ManualRunExecutor, type ManualRunExecutorOptions, type ManualRunInput, PostgresAutomationStore, type VerifiedAutomationActor, automationNotFound, automationRoutes, createBoringAutomationServerPlugin, defaultBoringAutomationServerPlugin as default, listHostedAutomationCandidates, parseAutomationModel, runAlreadyActive, runAlreadyRecorded, runBoringAutomationMigrations, runNotFound };
@@ -19,7 +19,9 @@ var BORING_AUTOMATION_ERROR_CODES = {
19
19
  RUN_ALREADY_RECORDED: "BORING_AUTOMATION_RUN_ALREADY_RECORDED",
20
20
  RUN_EXECUTOR_UNAVAILABLE: "BORING_AUTOMATION_RUN_EXECUTOR_UNAVAILABLE",
21
21
  TRIGGER_FORBIDDEN: "BORING_AUTOMATION_TRIGGER_FORBIDDEN",
22
- RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED"
22
+ RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED",
23
+ OWNER_UNAUTHORIZED: "BORING_AUTOMATION_OWNER_UNAUTHORIZED",
24
+ TRIGGER_UNAUTHORIZED: "BORING_AUTOMATION_TRIGGER_UNAUTHORIZED"
23
25
  };
24
26
 
25
27
  // src/shared/schema.ts
@@ -515,162 +517,6 @@ function clone(value) {
515
517
  return JSON.parse(JSON.stringify(value));
516
518
  }
517
519
 
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
520
  // src/server/manualRunExecutor.ts
675
521
  var ManualRunExecutor = class {
676
522
  constructor(options) {
@@ -680,7 +526,7 @@ var ManualRunExecutor = class {
680
526
  options;
681
527
  clock;
682
528
  async run(input) {
683
- const actor = await this.options.actorResolver(input.request);
529
+ const actor = input.actor ?? await this.options.actorResolver(input.request);
684
530
  const store = await this.options.storeForRequest?.(input.request, actor) ?? this.options.store;
685
531
  const automation = await store.getAutomation(input.automationId);
686
532
  if (!automation) {
@@ -861,8 +707,277 @@ function durationMs(startedAt, completedAt) {
861
707
  return Math.max(0, new Date(completedAt).getTime() - new Date(startedAt).getTime());
862
708
  }
863
709
 
710
+ // src/server/postgresStore.ts
711
+ import { randomUUID as randomUUID2 } from "crypto";
712
+ var PostgresAutomationStore = class {
713
+ constructor(sql, actor, clock = () => /* @__PURE__ */ new Date()) {
714
+ this.sql = sql;
715
+ this.actor = actor;
716
+ this.clock = clock;
717
+ }
718
+ sql;
719
+ actor;
720
+ clock;
721
+ async listAutomations() {
722
+ const rows = await this.sql`
723
+ SELECT id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
724
+ FROM boring_automation_automations
725
+ WHERE workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
726
+ ORDER BY created_at, id
727
+ `;
728
+ return rows.map(toAutomation);
729
+ }
730
+ async getAutomation(id) {
731
+ const rows = await this.sql`
732
+ SELECT id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
733
+ FROM boring_automation_automations
734
+ WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
735
+ `;
736
+ return rows[0] ? toAutomation(rows[0]) : null;
737
+ }
738
+ async createAutomation(input) {
739
+ const now = this.clock().toISOString();
740
+ const id = randomUUID2();
741
+ const prompt = input.prompt ?? "";
742
+ const rows = await this.sql`
743
+ INSERT INTO boring_automation_automations (id, workspace_id, owner_user_id, title, enabled, cron, timezone, model, prompt, created_at, updated_at)
744
+ VALUES (${id}, ${this.actor.workspaceId}, ${this.actor.userId}, ${input.title}, ${input.enabled ?? true}, ${input.cron}, ${input.timezone}, ${input.model}, ${prompt}, ${now}, ${now})
745
+ RETURNING id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
746
+ `;
747
+ return toAutomation(rows[0]);
748
+ }
749
+ async updateAutomation(id, patch) {
750
+ const current = await this.getAutomation(id);
751
+ if (!current) throw automationNotFound(id);
752
+ const next = { ...current, ...patch, updatedAt: this.clock().toISOString() };
753
+ const rows = await this.sql`
754
+ UPDATE boring_automation_automations
755
+ SET title = ${next.title}, enabled = ${next.enabled}, cron = ${next.cron}, timezone = ${next.timezone}, model = ${next.model}, updated_at = ${next.updatedAt}
756
+ WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
757
+ RETURNING id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
758
+ `;
759
+ if (!rows[0]) throw automationNotFound(id);
760
+ return toAutomation(rows[0]);
761
+ }
762
+ async deleteAutomation(id) {
763
+ const result = await this.sql`
764
+ DELETE FROM boring_automation_automations
765
+ WHERE id = ${id} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
766
+ `;
767
+ if (result.count === 0) throw automationNotFound(id);
768
+ }
769
+ async getPrompt(automationId) {
770
+ const automation = await this.getAutomation(automationId);
771
+ if (!automation) throw automationNotFound(automationId);
772
+ const rows = await this.sql`
773
+ SELECT prompt FROM boring_automation_automations
774
+ WHERE id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
775
+ `;
776
+ return rows[0]?.prompt ?? "";
777
+ }
778
+ async updatePrompt(automationId, body) {
779
+ const result = await this.sql`
780
+ UPDATE boring_automation_automations SET prompt = ${body}, updated_at = ${this.clock().toISOString()}
781
+ WHERE id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
782
+ `;
783
+ if (result.count === 0) throw automationNotFound(automationId);
784
+ }
785
+ async reconcileOrphanedRuns(automationId) {
786
+ await this.sql`
787
+ UPDATE boring_automation_runs
788
+ SET status = 'failed', completed_at = ${this.clock().toISOString()}, error = 'Automation host restarted before the run completed', updated_at = ${this.clock().toISOString()}
789
+ WHERE automation_id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId} AND status IN ('queued', 'running')
790
+ `;
791
+ }
792
+ async beginRun(input) {
793
+ const automation = await this.getAutomation(input.automationId);
794
+ if (!automation) throw automationNotFound(input.automationId);
795
+ const active = await this.sql`
796
+ SELECT id FROM boring_automation_runs
797
+ WHERE automation_id = ${input.automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId} AND status IN ('queued', 'running')
798
+ LIMIT 1
799
+ `;
800
+ if (active[0]) throw runAlreadyActive(input.automationId);
801
+ try {
802
+ const now = input.createdAt ?? this.clock().toISOString();
803
+ const id = randomUUID2();
804
+ const rows = await this.sql`
805
+ 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)
806
+ 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})
807
+ RETURNING *
808
+ `;
809
+ return toRun(rows[0]);
810
+ } catch (error) {
811
+ if (isUniqueViolation(error)) {
812
+ if (constraintName(error) === "boring_automation_runs_active_once_idx") throw runAlreadyActive(input.automationId);
813
+ if (input.trigger === "scheduled" && input.scheduledFor) throw runAlreadyRecorded(input.automationId, input.scheduledFor);
814
+ }
815
+ throw error;
816
+ }
817
+ }
818
+ async updateRunLifecycle(runId, patch) {
819
+ const current = await this.findRun(runId);
820
+ if (!current) throw runNotFound(runId);
821
+ const next = { ...current, ...patch, updatedAt: this.clock().toISOString() };
822
+ const rows = await this.sql`
823
+ UPDATE boring_automation_runs
824
+ 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}
825
+ WHERE id = ${runId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
826
+ RETURNING *
827
+ `;
828
+ if (!rows[0]) throw runNotFound(runId);
829
+ return toRun(rows[0]);
830
+ }
831
+ async listRuns(automationId) {
832
+ const rows = await this.sql`
833
+ SELECT * FROM boring_automation_runs
834
+ WHERE automation_id = ${automationId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
835
+ ORDER BY created_at DESC, id DESC
836
+ `;
837
+ return rows.map(toRun);
838
+ }
839
+ async findRun(runId) {
840
+ const rows = await this.sql`
841
+ SELECT * FROM boring_automation_runs
842
+ WHERE id = ${runId} AND workspace_id = ${this.actor.workspaceId} AND owner_user_id = ${this.actor.userId}
843
+ `;
844
+ return rows[0] ? toRun(rows[0]) : null;
845
+ }
846
+ };
847
+ async function listHostedAutomationCandidates(sql) {
848
+ const automationRows = await sql`
849
+ SELECT id, workspace_id, owner_user_id, title, enabled, cron, timezone, model, prompt, created_at, updated_at
850
+ FROM boring_automation_automations
851
+ ORDER BY id
852
+ `;
853
+ const runRows = await sql`
854
+ SELECT * FROM boring_automation_runs ORDER BY automation_id, created_at DESC, id DESC
855
+ `;
856
+ const runsByAutomation = /* @__PURE__ */ new Map();
857
+ for (const row of runRows) {
858
+ const list = runsByAutomation.get(row.automation_id) ?? [];
859
+ list.push(toRun(row));
860
+ runsByAutomation.set(row.automation_id, list);
861
+ }
862
+ return automationRows.map((row) => ({
863
+ automation: toAutomation(row),
864
+ actor: { workspaceId: row.workspace_id, userId: row.owner_user_id },
865
+ runs: runsByAutomation.get(row.id) ?? []
866
+ }));
867
+ }
868
+ function toAutomation(row) {
869
+ 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) };
870
+ }
871
+ function toRun(row) {
872
+ 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) };
873
+ }
874
+ function iso(value) {
875
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
876
+ }
877
+ function nullableIso(value) {
878
+ return value === null ? null : iso(value);
879
+ }
880
+ function isUniqueViolation(error) {
881
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "23505");
882
+ }
883
+ function constraintName(error) {
884
+ return error && typeof error === "object" && "constraint_name" in error ? String(error.constraint_name) : void 0;
885
+ }
886
+
887
+ // src/server/hostedDueRunService.ts
888
+ var HostedDueRunService = class {
889
+ constructor(options) {
890
+ this.options = options;
891
+ this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
892
+ }
893
+ options;
894
+ clock;
895
+ async runDue(request) {
896
+ const now = this.clock();
897
+ const candidates = await listHostedAutomationCandidates(this.options.sql);
898
+ const outcomes = [];
899
+ for (const candidate of candidates) {
900
+ if (!await this.options.verifyActor(candidate.actor)) {
901
+ outcomes.push({
902
+ kind: "failed",
903
+ automationId: candidate.automation.id,
904
+ scheduledFor: now.toISOString(),
905
+ code: BORING_AUTOMATION_ERROR_CODES.OWNER_UNAUTHORIZED,
906
+ message: "automation creator is no longer authorized"
907
+ });
908
+ continue;
909
+ }
910
+ const evaluated = evaluateAutomationSchedule({
911
+ automations: [candidate.automation],
912
+ runs: candidate.runs,
913
+ now
914
+ });
915
+ const decision = evaluated.due[0];
916
+ if (!decision) {
917
+ const skipped = evaluated.decisions[0];
918
+ if (skipped?.kind === "skip") outcomes.push({
919
+ kind: "skipped",
920
+ automationId: candidate.automation.id,
921
+ scheduledFor: skipped.scheduledFor,
922
+ reason: skipped.reason,
923
+ message: skipped.message
924
+ });
925
+ continue;
926
+ }
927
+ const store = new PostgresAutomationStore(this.options.sql, candidate.actor, this.clock);
928
+ const executor = new ManualRunExecutor({
929
+ store,
930
+ dispatcherResolver: this.options.dispatcherResolver,
931
+ actorResolver: () => candidate.actor
932
+ });
933
+ try {
934
+ const run = await executor.run({
935
+ automationId: candidate.automation.id,
936
+ request,
937
+ trigger: "scheduled",
938
+ scheduledFor: decision.scheduledFor,
939
+ actor: candidate.actor
940
+ });
941
+ outcomes.push({
942
+ kind: "started",
943
+ automationId: candidate.automation.id,
944
+ scheduledFor: decision.scheduledFor,
945
+ run: toSummary(run)
946
+ });
947
+ } catch (error) {
948
+ outcomes.push({
949
+ kind: "failed",
950
+ automationId: candidate.automation.id,
951
+ scheduledFor: decision.scheduledFor,
952
+ code: error instanceof Error && "code" in error ? String(error.code) : BORING_AUTOMATION_ERROR_CODES.RUN_FAILED,
953
+ message: error instanceof Error ? error.message : "Automation run failed"
954
+ });
955
+ }
956
+ }
957
+ outcomes.sort((a, b) => a.automationId.localeCompare(b.automationId));
958
+ return { now: now.toISOString(), outcomes };
959
+ }
960
+ };
961
+ function toSummary(run) {
962
+ return {
963
+ id: run.id,
964
+ automationId: run.automationId,
965
+ sessionId: run.sessionId,
966
+ status: run.status,
967
+ trigger: run.trigger,
968
+ scheduledFor: run.scheduledFor,
969
+ startedAt: run.startedAt,
970
+ completedAt: run.completedAt,
971
+ durationMs: run.durationMs,
972
+ inputTokens: run.inputTokens,
973
+ outputTokens: run.outputTokens,
974
+ totalTokens: run.totalTokens
975
+ };
976
+ }
977
+
864
978
  // src/server/routes.ts
865
979
  import { ZodError } from "zod";
980
+ import { timingSafeEqual } from "crypto";
866
981
  async function automationRoutes(app, opts) {
867
982
  app.get(`${BORING_AUTOMATION_ROUTE_PREFIX}/automations`, async (_request, reply) => {
868
983
  try {
@@ -925,6 +1040,16 @@ async function automationRoutes(app, opts) {
925
1040
  return sendError(reply, cause);
926
1041
  }
927
1042
  });
1043
+ app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/due/hosted`, async (request, reply) => {
1044
+ try {
1045
+ if (!opts.hostedDueRunService || !opts.hostedTriggerToken || !hasBearerToken(request.headers.authorization, opts.hostedTriggerToken)) {
1046
+ throw new AutomationStoreError(BORING_AUTOMATION_ERROR_CODES.TRIGGER_UNAUTHORIZED, "hosted automation trigger is unauthorized");
1047
+ }
1048
+ return { ok: true, ...await opts.hostedDueRunService.runDue(request) };
1049
+ } catch (cause) {
1050
+ return sendError(reply, cause);
1051
+ }
1052
+ });
928
1053
  app.post(`${BORING_AUTOMATION_ROUTE_PREFIX}/due`, async (request, reply) => {
929
1054
  try {
930
1055
  if (!isLoopbackAddress(request.ip)) {
@@ -1002,6 +1127,12 @@ function zodIssueErrorCode(issue) {
1002
1127
  if (field === "timezone") return BORING_AUTOMATION_ERROR_CODES.INVALID_TIMEZONE;
1003
1128
  return BORING_AUTOMATION_ERROR_CODES.INVALID_BODY;
1004
1129
  }
1130
+ function hasBearerToken(header, expected) {
1131
+ if (!header?.startsWith("Bearer ")) return false;
1132
+ const actual = Buffer.from(header.slice(7), "utf8");
1133
+ const target = Buffer.from(expected, "utf8");
1134
+ return actual.length === target.length && timingSafeEqual(actual, target);
1135
+ }
1005
1136
  function isLoopbackAddress(address) {
1006
1137
  const normalized = address.trim().toLowerCase();
1007
1138
  return normalized === "127.0.0.1" || normalized === "::1" || normalized.startsWith("::ffff:127.");
@@ -1021,6 +1152,10 @@ function httpStatusForStoreError(error) {
1021
1152
  return 409;
1022
1153
  case BORING_AUTOMATION_ERROR_CODES.TRIGGER_FORBIDDEN:
1023
1154
  return 403;
1155
+ case BORING_AUTOMATION_ERROR_CODES.TRIGGER_UNAUTHORIZED:
1156
+ return 401;
1157
+ case BORING_AUTOMATION_ERROR_CODES.OWNER_UNAUTHORIZED:
1158
+ return 403;
1024
1159
  case BORING_AUTOMATION_ERROR_CODES.RUN_EXECUTOR_UNAVAILABLE:
1025
1160
  return 503;
1026
1161
  case BORING_AUTOMATION_ERROR_CODES.RUN_FAILED:
@@ -1104,11 +1239,18 @@ function createBoringAutomationServerPlugin(options = {}) {
1104
1239
  id: BORING_AUTOMATION_PLUGIN_ID,
1105
1240
  label: BORING_AUTOMATION_PLUGIN_LABEL,
1106
1241
  routes: async (app) => {
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 });
1242
+ await automationRoutes(app, {
1243
+ store,
1244
+ storeForRequest: options.storeForRequest ? async (request) => {
1245
+ const actor = options.actorResolver ? await options.actorResolver(request) : void 0;
1246
+ if (!actor) throw new Error("automation actor resolver is unavailable");
1247
+ return await options.storeForRequest(request, actor);
1248
+ } : void 0,
1249
+ manualRunExecutor,
1250
+ dueRunService,
1251
+ hostedDueRunService: options.hostedDueRunService,
1252
+ hostedTriggerToken: options.hostedTriggerToken
1253
+ });
1112
1254
  }
1113
1255
  });
1114
1256
  }
@@ -1126,7 +1268,14 @@ function defaultBoringAutomationServerPlugin(options, ctx) {
1126
1268
  store: fallbackStore,
1127
1269
  storeForRequest: async (_request, actor) => new PostgresAutomationStore(sql, actor),
1128
1270
  dispatcherResolver: options?.dispatcherResolver ?? trusted.workspaceAgentDispatcherResolver,
1129
- actorResolver: options?.actorResolver ?? trusted.actorResolver
1271
+ actorResolver: options?.actorResolver ?? trusted.actorResolver,
1272
+ actorVerifier: options?.actorVerifier ?? trusted.actorVerifier,
1273
+ hostedTriggerToken: options?.hostedTriggerToken ?? trusted.hostedAutomationTriggerToken,
1274
+ hostedDueRunService: options?.hostedDueRunService ?? new HostedDueRunService({
1275
+ sql,
1276
+ dispatcherResolver: options?.dispatcherResolver ?? trusted.workspaceAgentDispatcherResolver,
1277
+ verifyActor: options?.actorVerifier ?? trusted.actorVerifier
1278
+ })
1130
1279
  });
1131
1280
  }
1132
1281
  return createBoringAutomationServerPlugin({
@@ -1151,6 +1300,7 @@ export {
1151
1300
  BORING_AUTOMATION_ROUTE_PREFIX,
1152
1301
  DueRunService,
1153
1302
  FileAutomationStore,
1303
+ HostedDueRunService,
1154
1304
  IdParamsSchema,
1155
1305
  ManualRunExecutor,
1156
1306
  PostgresAutomationStore,
@@ -1162,6 +1312,7 @@ export {
1162
1312
  evaluateAutomationSchedule,
1163
1313
  isValidFiveFieldCron,
1164
1314
  isValidIanaTimeZone,
1315
+ listHostedAutomationCandidates,
1165
1316
  parseAutomationModel,
1166
1317
  runAlreadyActive,
1167
1318
  runAlreadyRecorded,
@@ -16,6 +16,8 @@ declare const BORING_AUTOMATION_ERROR_CODES: {
16
16
  readonly RUN_EXECUTOR_UNAVAILABLE: "BORING_AUTOMATION_RUN_EXECUTOR_UNAVAILABLE";
17
17
  readonly TRIGGER_FORBIDDEN: "BORING_AUTOMATION_TRIGGER_FORBIDDEN";
18
18
  readonly RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED";
19
+ readonly OWNER_UNAUTHORIZED: "BORING_AUTOMATION_OWNER_UNAUTHORIZED";
20
+ readonly TRIGGER_UNAUTHORIZED: "BORING_AUTOMATION_TRIGGER_UNAUTHORIZED";
19
21
  };
20
22
  type BoringAutomationErrorCode = typeof BORING_AUTOMATION_ERROR_CODES[keyof typeof BORING_AUTOMATION_ERROR_CODES];
21
23
 
@@ -15,7 +15,9 @@ var BORING_AUTOMATION_ERROR_CODES = {
15
15
  RUN_ALREADY_RECORDED: "BORING_AUTOMATION_RUN_ALREADY_RECORDED",
16
16
  RUN_EXECUTOR_UNAVAILABLE: "BORING_AUTOMATION_RUN_EXECUTOR_UNAVAILABLE",
17
17
  TRIGGER_FORBIDDEN: "BORING_AUTOMATION_TRIGGER_FORBIDDEN",
18
- RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED"
18
+ RUN_FAILED: "BORING_AUTOMATION_RUN_FAILED",
19
+ OWNER_UNAUTHORIZED: "BORING_AUTOMATION_OWNER_UNAUTHORIZED",
20
+ TRIGGER_UNAUTHORIZED: "BORING_AUTOMATION_TRIGGER_UNAUTHORIZED"
19
21
  };
20
22
 
21
23
  // src/shared/schema.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-automation",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -43,14 +43,14 @@
43
43
  "fastify": "^5.9.0",
44
44
  "react": "^18.0.0 || ^19.0.0",
45
45
  "react-dom": "^18.0.0 || ^19.0.0",
46
- "@hachej/boring-workspace": "0.1.80"
46
+ "@hachej/boring-workspace": "0.1.81"
47
47
  },
48
48
  "dependencies": {
49
49
  "croner": "^10.0.1",
50
50
  "lucide-react": "^1.21.0",
51
51
  "postgres": "^3.4.7",
52
52
  "zod": "^3.23.0",
53
- "@hachej/boring-ui-kit": "0.1.80"
53
+ "@hachej/boring-ui-kit": "0.1.81"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@testing-library/jest-dom": "^6.9.1",
@@ -66,7 +66,7 @@
66
66
  "tsup": "^8.4.0",
67
67
  "typescript": "~6.0.3",
68
68
  "vitest": "^4.1.9",
69
- "@hachej/boring-workspace": "0.1.80"
69
+ "@hachej/boring-workspace": "0.1.81"
70
70
  },
71
71
  "peerDependenciesMeta": {
72
72
  "fastify": {