@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.
- package/package.json +2 -2
- package/src/auth-core.ts +49 -0
- package/src/cron.test.ts +19 -0
- package/src/cron.ts +86 -0
- package/src/index.ts +49 -1
- package/src/invoke.ts +5 -18
- package/src/job-queue.test.ts +68 -0
- package/src/job-queue.ts +58 -0
- package/src/local-auth.test.ts +262 -6
- package/src/local-auth.ts +180 -5
- package/src/local-google.test.ts +93 -0
- package/src/local-google.ts +232 -0
- package/src/local-jobs.test.ts +448 -0
- package/src/local-jobs.ts +353 -0
|
@@ -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
|
+
}
|