@robodev-ai/runtime 0.1.0 → 0.3.0

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.
@@ -0,0 +1,353 @@
1
+ import type { JobDefinition, JobEnqueueInput, RobodevDb } from "@robodev-ai/sdk";
2
+ import { assertFiveFieldCron, nextRunAfter } from "./cron.js";
3
+ import { HandlerTimeoutError, clampTimeoutMs, withHandlerTimeout } from "./handler-timeout.js";
4
+ import { id } from "./ids.js";
5
+ import type { RouteClients } from "./invoke.js";
6
+ import {
7
+ JOB_CONCURRENCY,
8
+ JOB_MAX_ATTEMPTS,
9
+ assertKnownJob,
10
+ nextJobFailureState,
11
+ serializeJobPayload,
12
+ type JobCronDefinition,
13
+ } from "./job-queue.js";
14
+
15
+ /** Hosted uses metadata `now()`; local worker/ticker intervals match hosted. */
16
+ export const LOCAL_JOB_WORKER_MS = 1000;
17
+ export const LOCAL_JOB_CRON_MS = 60_000;
18
+
19
+ export type JobsQueryable = {
20
+ query: <T extends Record<string, unknown> = Record<string, unknown>>(
21
+ sql: string,
22
+ values?: unknown[],
23
+ ) => Promise<{ rows: T[] }>;
24
+ };
25
+
26
+ export type LocalJobStatus = "queued" | "running" | "succeeded" | "dead";
27
+
28
+ export type LocalJobRow = {
29
+ id: string;
30
+ name: string;
31
+ payload_json: string;
32
+ status: LocalJobStatus;
33
+ attempts: number;
34
+ max_attempts: number;
35
+ run_at: Date;
36
+ last_error: string | null;
37
+ created_at: Date;
38
+ updated_at: Date;
39
+ };
40
+
41
+ export type LocalCronRow = {
42
+ job_name: string;
43
+ cron: string;
44
+ next_run_at: Date;
45
+ };
46
+
47
+ export type LocalJobsGeneration = {
48
+ db: RobodevDb;
49
+ jobs: readonly { name: string; file?: string; def: JobDefinition }[];
50
+ };
51
+
52
+ export type LocalJobsEngineOptions = {
53
+ query: JobsQueryable["query"];
54
+ getGeneration: () => LocalJobsGeneration | null;
55
+ log?: (line: string) => void;
56
+ clients: () => RouteClients;
57
+ };
58
+
59
+ export type LocalJobsEngine = {
60
+ enqueue: (input: JobEnqueueInput) => Promise<{ id: string }>;
61
+ reconcile: (jobs: readonly JobCronDefinition[], now?: Date) => Promise<void>;
62
+ tickDue: (now?: Date) => Promise<void>;
63
+ tickJobs: () => Promise<void>;
64
+ start: () => Promise<void>;
65
+ stop: () => Promise<void>;
66
+ readonly generation: LocalJobsGeneration | null;
67
+ readonly running: boolean;
68
+ };
69
+
70
+ function resolveRunAt(input: JobEnqueueInput): Date {
71
+ if (input.runAt) return input.runAt;
72
+ if (typeof input.delayMs === "number" && Number.isFinite(input.delayMs)) {
73
+ return new Date(Date.now() + Math.max(0, input.delayMs));
74
+ }
75
+ return new Date();
76
+ }
77
+
78
+ function desiredCronJobs(jobs: readonly JobCronDefinition[]): { name: string; cron: string }[] {
79
+ return jobs
80
+ .filter((job) => job.def.cron !== undefined)
81
+ .map((job) => ({ name: job.name, cron: assertFiveFieldCron(job.def.cron!) }));
82
+ }
83
+
84
+ function shortError(message: string): string {
85
+ const oneLine = message.split("\n")[0]?.trim() || "handler_failed";
86
+ return oneLine.length > 160 ? `${oneLine.slice(0, 157)}...` : oneLine;
87
+ }
88
+
89
+ /** Creates the reserved `robodev_jobs` schema. Idempotent, not drizzle, not public. */
90
+ export async function ensureJobsSchema(db: JobsQueryable): Promise<void> {
91
+ await db.query(`CREATE SCHEMA IF NOT EXISTS robodev_jobs`);
92
+ await db.query(`
93
+ CREATE TABLE IF NOT EXISTS robodev_jobs.jobs (
94
+ id TEXT PRIMARY KEY,
95
+ name TEXT NOT NULL,
96
+ payload_json TEXT NOT NULL DEFAULT 'null',
97
+ status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'dead')),
98
+ attempts INTEGER NOT NULL DEFAULT 0,
99
+ max_attempts INTEGER NOT NULL DEFAULT 5,
100
+ run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
101
+ last_error TEXT,
102
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
103
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
104
+ )
105
+ `);
106
+ await db.query(`
107
+ CREATE INDEX IF NOT EXISTS robodev_jobs_queued_run_at
108
+ ON robodev_jobs.jobs (status, run_at)
109
+ WHERE status = 'queued'
110
+ `);
111
+ await db.query(`
112
+ CREATE TABLE IF NOT EXISTS robodev_jobs.crons (
113
+ job_name TEXT PRIMARY KEY,
114
+ cron TEXT NOT NULL,
115
+ next_run_at TIMESTAMPTZ NOT NULL,
116
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
117
+ )
118
+ `);
119
+ await db.query(`
120
+ CREATE INDEX IF NOT EXISTS robodev_jobs_crons_next_run_at
121
+ ON robodev_jobs.crons (next_run_at)
122
+ `);
123
+ }
124
+
125
+ export function createLocalJobsEngine(options: LocalJobsEngineOptions): LocalJobsEngine {
126
+ const query = options.query;
127
+ const log = options.log ?? (() => undefined);
128
+ let inFlight = 0;
129
+ const running = new Set<Promise<void>>();
130
+ let workerTimer: ReturnType<typeof setInterval> | undefined;
131
+ let cronTimer: ReturnType<typeof setInterval> | undefined;
132
+
133
+ async function enqueue(input: JobEnqueueInput): Promise<{ id: string }> {
134
+ const generation = options.getGeneration();
135
+ const known = generation?.jobs.map((job) => job.name) ?? [];
136
+ assertKnownJob(input.name, known);
137
+ const payloadJson = serializeJobPayload(input.payload);
138
+ const jobId = id("job");
139
+ await query(
140
+ `INSERT INTO robodev_jobs.jobs
141
+ (id, name, payload_json, status, attempts, max_attempts, run_at)
142
+ VALUES ($1, $2, $3, 'queued', 0, $4, $5)`,
143
+ [jobId, input.name, payloadJson, JOB_MAX_ATTEMPTS, resolveRunAt(input)],
144
+ );
145
+ return { id: jobId };
146
+ }
147
+
148
+ async function reconcile(jobs: readonly JobCronDefinition[], now = new Date()): Promise<void> {
149
+ const desired = desiredCronJobs(jobs);
150
+ const existing = await query<LocalCronRow>(
151
+ `SELECT job_name, cron, next_run_at
152
+ FROM robodev_jobs.crons`,
153
+ );
154
+ const byName = new Map(existing.rows.map((row) => [row.job_name, row]));
155
+
156
+ for (const job of desired) {
157
+ const row = byName.get(job.name);
158
+ if (!row) {
159
+ await query(
160
+ `INSERT INTO robodev_jobs.crons (job_name, cron, next_run_at)
161
+ VALUES ($1, $2, $3)`,
162
+ [job.name, job.cron, nextRunAfter(job.cron, now)],
163
+ );
164
+ continue;
165
+ }
166
+ if (row.cron !== job.cron) {
167
+ await query(
168
+ `UPDATE robodev_jobs.crons
169
+ SET cron = $2, next_run_at = $3, updated_at = now()
170
+ WHERE job_name = $1`,
171
+ [job.name, job.cron, nextRunAfter(job.cron, now)],
172
+ );
173
+ }
174
+ }
175
+
176
+ await query(`DELETE FROM robodev_jobs.crons WHERE NOT (job_name = ANY($1::text[]))`, [
177
+ desired.map((job) => job.name),
178
+ ]);
179
+ }
180
+
181
+ async function tickDue(now = new Date()): Promise<void> {
182
+ const due = await query<LocalCronRow>(
183
+ `SELECT job_name, cron, next_run_at
184
+ FROM robodev_jobs.crons
185
+ WHERE next_run_at <= $1
186
+ ORDER BY next_run_at ASC
187
+ LIMIT 50`,
188
+ [now],
189
+ );
190
+ for (const row of due.rows) {
191
+ const nextRunAt = nextRunAfter(row.cron, now);
192
+ await query(
193
+ `UPDATE robodev_jobs.crons
194
+ SET next_run_at = $2, updated_at = now()
195
+ WHERE job_name = $1`,
196
+ [row.job_name, nextRunAt],
197
+ );
198
+ try {
199
+ await enqueue({ name: row.job_name, payload: null });
200
+ } catch {
201
+ // Tick is skipped after next_run_at advanced; do not roll back.
202
+ }
203
+ }
204
+ }
205
+
206
+ async function claimNextJob(): Promise<LocalJobRow | null> {
207
+ const result = await query<LocalJobRow>(
208
+ `UPDATE robodev_jobs.jobs
209
+ SET status = 'running', updated_at = now()
210
+ WHERE id = (
211
+ SELECT id FROM robodev_jobs.jobs
212
+ WHERE status = 'queued' AND run_at <= now()
213
+ ORDER BY run_at ASC
214
+ FOR UPDATE SKIP LOCKED
215
+ LIMIT 1
216
+ )
217
+ RETURNING id, name, payload_json, status, attempts, max_attempts, run_at,
218
+ last_error, created_at, updated_at`,
219
+ );
220
+ return result.rows[0] ?? null;
221
+ }
222
+
223
+ async function finishJob(row: LocalJobRow, error: string | null): Promise<void> {
224
+ if (!error) {
225
+ await query(
226
+ `UPDATE robodev_jobs.jobs
227
+ SET status = 'succeeded', last_error = NULL, updated_at = now()
228
+ WHERE id = $1`,
229
+ [row.id],
230
+ );
231
+ log(` ${row.name} succeeded`);
232
+ return;
233
+ }
234
+ const attempts = row.attempts + 1;
235
+ const next = nextJobFailureState(attempts);
236
+ await query(
237
+ `UPDATE robodev_jobs.jobs
238
+ SET status = $2, attempts = $3, last_error = $4, run_at = COALESCE($5, run_at), updated_at = now()
239
+ WHERE id = $1`,
240
+ [row.id, next.status, attempts, error, next.runAt],
241
+ );
242
+ if (next.status === "dead") {
243
+ log(` ${row.name} dead: ${shortError(error)}`);
244
+ } else {
245
+ log(` ${row.name} failed attempt ${attempts}/${JOB_MAX_ATTEMPTS}: ${shortError(error)}`);
246
+ }
247
+ }
248
+
249
+ async function runClaimedJob(
250
+ row: LocalJobRow,
251
+ generation: LocalJobsGeneration | null,
252
+ ): Promise<void> {
253
+ const job = generation?.jobs.find((entry) => entry.name === row.name);
254
+ if (!generation || !job) {
255
+ await finishJob(row, "route_not_found");
256
+ return;
257
+ }
258
+ let payload: unknown = null;
259
+ try {
260
+ payload = JSON.parse(row.payload_json) as unknown;
261
+ } catch {
262
+ payload = null;
263
+ }
264
+ const clients = options.clients();
265
+ try {
266
+ await withHandlerTimeout(async () => {
267
+ await (job.def as JobDefinition).handler({
268
+ db: generation.db,
269
+ email: clients.email,
270
+ llm: clients.llm,
271
+ agent: clients.agent,
272
+ env: clients.env,
273
+ push: clients.push,
274
+ jobs: clients.jobs,
275
+ sockets: clients.sockets,
276
+ payload,
277
+ });
278
+ }, clampTimeoutMs(job.def.timeoutMs));
279
+ await finishJob(row, null);
280
+ } catch (error) {
281
+ const message =
282
+ error instanceof HandlerTimeoutError
283
+ ? "handler_timeout"
284
+ : error instanceof Error
285
+ ? error.message
286
+ : "handler_failed";
287
+ await finishJob(row, message);
288
+ }
289
+ }
290
+
291
+ async function tickJobs(): Promise<void> {
292
+ while (inFlight < JOB_CONCURRENCY) {
293
+ const generation = options.getGeneration();
294
+ const row = await claimNextJob().catch(() => null);
295
+ if (!row) return;
296
+ inFlight += 1;
297
+ const run = runClaimedJob(row, generation);
298
+ running.add(run);
299
+ void run.finally(() => {
300
+ inFlight -= 1;
301
+ running.delete(run);
302
+ });
303
+ }
304
+ }
305
+
306
+ async function requeueRunning(): Promise<void> {
307
+ await query(
308
+ `UPDATE robodev_jobs.jobs
309
+ SET status = 'queued', updated_at = now()
310
+ WHERE status = 'running'`,
311
+ );
312
+ }
313
+
314
+ async function start(): Promise<void> {
315
+ if (workerTimer || cronTimer) return;
316
+ await requeueRunning();
317
+ workerTimer = setInterval(() => {
318
+ void tickJobs();
319
+ }, LOCAL_JOB_WORKER_MS);
320
+ workerTimer.unref?.();
321
+ cronTimer = setInterval(() => {
322
+ void tickDue();
323
+ }, LOCAL_JOB_CRON_MS);
324
+ cronTimer.unref?.();
325
+ }
326
+
327
+ async function stop(): Promise<void> {
328
+ if (workerTimer) {
329
+ clearInterval(workerTimer);
330
+ workerTimer = undefined;
331
+ }
332
+ if (cronTimer) {
333
+ clearInterval(cronTimer);
334
+ cronTimer = undefined;
335
+ }
336
+ await Promise.all([...running]);
337
+ }
338
+
339
+ return {
340
+ enqueue,
341
+ reconcile,
342
+ tickDue,
343
+ tickJobs,
344
+ start,
345
+ stop,
346
+ get generation() {
347
+ return options.getGeneration();
348
+ },
349
+ get running() {
350
+ return Boolean(workerTimer || cronTimer);
351
+ },
352
+ };
353
+ }