@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,232 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify, SignJWT, type JWTVerifyGetKey } from "jose";
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
4
|
+
const LOCAL_STATE_TYP = "local_google_oauth_state";
|
|
5
|
+
export const LOCAL_DEV_IDENTITY_TYP = "robodev_local_dev_identity";
|
|
6
|
+
export const LOCAL_DEV_IDENTITY_AUD = "robodev-local-dev";
|
|
7
|
+
const JTI_TTL_MS = 3 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
const usedJti = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
function invalidRedirect(message: string): Error {
|
|
12
|
+
return Object.assign(new Error(message), {
|
|
13
|
+
statusCode: 400,
|
|
14
|
+
code: "invalid_redirect_uri",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function invalidAssertion(message = "invalid_assertion"): Error {
|
|
19
|
+
return Object.assign(new Error(message), {
|
|
20
|
+
statusCode: 400,
|
|
21
|
+
code: "invalid_assertion",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseLoopbackUrl(raw: string): URL {
|
|
26
|
+
let url: URL;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
throw invalidRedirect("redirect_uri is not a valid URL");
|
|
31
|
+
}
|
|
32
|
+
if (url.username || url.password) {
|
|
33
|
+
throw invalidRedirect("redirect_uri must not include credentials");
|
|
34
|
+
}
|
|
35
|
+
if (!LOOPBACK_HOSTS.has(url.hostname)) {
|
|
36
|
+
throw invalidRedirect("redirect_uri must be a loopback host");
|
|
37
|
+
}
|
|
38
|
+
url.hash = "";
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** http-only loopback URL. Starbase bounce target. */
|
|
43
|
+
export function assertLocalBrokerRedirectUri(raw: string): string {
|
|
44
|
+
const url = parseLoopbackUrl(raw);
|
|
45
|
+
if (url.protocol !== "http:") {
|
|
46
|
+
throw invalidRedirect("callback_uri must be http on a loopback host");
|
|
47
|
+
}
|
|
48
|
+
return url.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Loopback http or https. Local app `redirect_uri` (Vite may be https). */
|
|
52
|
+
export function assertLocalAppRedirectUri(raw: string): string {
|
|
53
|
+
const url = parseLoopbackUrl(raw);
|
|
54
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
55
|
+
throw invalidRedirect("redirect_uri must be http or https on a loopback host");
|
|
56
|
+
}
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function localGoogleCallbackUri(listenOrigin: string): string {
|
|
61
|
+
return `${listenOrigin.replace(/\/$/, "")}/api/user/auth/google/callback`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function projectGoogleOverrideMode(
|
|
65
|
+
clientId?: string,
|
|
66
|
+
clientSecret?: string,
|
|
67
|
+
): "both" | "one" | "none" {
|
|
68
|
+
const id = clientId?.trim() ?? "";
|
|
69
|
+
const secret = clientSecret?.trim() ?? "";
|
|
70
|
+
if (id && secret) return "both";
|
|
71
|
+
if (id || secret) return "one";
|
|
72
|
+
return "none";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function brokerDevStartUrl(
|
|
76
|
+
brokerBaseUrl: string,
|
|
77
|
+
input: { callbackUri: string; redirectUri: string },
|
|
78
|
+
): string {
|
|
79
|
+
const url = new URL(`${brokerBaseUrl.replace(/\/$/, "")}/internal/auth/google/dev`);
|
|
80
|
+
url.searchParams.set("callback_uri", input.callbackUri);
|
|
81
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
82
|
+
return url.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function redirectWithQuery(redirectUri: string, params: Record<string, string>): string {
|
|
86
|
+
const url = new URL(redirectUri);
|
|
87
|
+
for (const [key, value] of Object.entries(params)) {
|
|
88
|
+
url.searchParams.set(key, value);
|
|
89
|
+
}
|
|
90
|
+
return url.toString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Google authorize URL with a caller-supplied redirect (local override). */
|
|
94
|
+
export function localGoogleAuthorizeUrl(
|
|
95
|
+
state: string,
|
|
96
|
+
creds: { clientId: string; redirectUri: string },
|
|
97
|
+
): string {
|
|
98
|
+
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
99
|
+
url.searchParams.set("client_id", creds.clientId);
|
|
100
|
+
url.searchParams.set("redirect_uri", creds.redirectUri);
|
|
101
|
+
url.searchParams.set("response_type", "code");
|
|
102
|
+
url.searchParams.set("scope", "openid email profile");
|
|
103
|
+
url.searchParams.set("state", state);
|
|
104
|
+
url.searchParams.set("prompt", "select_account");
|
|
105
|
+
return url.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function exchangeLocalGoogleCode(
|
|
109
|
+
code: string,
|
|
110
|
+
creds: { clientId: string; clientSecret: string; redirectUri: string },
|
|
111
|
+
): Promise<{ email: string; name: string | null; subject: string }> {
|
|
112
|
+
const body = new URLSearchParams({
|
|
113
|
+
code,
|
|
114
|
+
client_id: creds.clientId,
|
|
115
|
+
client_secret: creds.clientSecret,
|
|
116
|
+
redirect_uri: creds.redirectUri,
|
|
117
|
+
grant_type: "authorization_code",
|
|
118
|
+
});
|
|
119
|
+
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
122
|
+
body,
|
|
123
|
+
});
|
|
124
|
+
if (!tokenRes.ok) {
|
|
125
|
+
throw new Error("google token exchange failed");
|
|
126
|
+
}
|
|
127
|
+
const tokens = (await tokenRes.json()) as { access_token?: string };
|
|
128
|
+
if (!tokens.access_token) {
|
|
129
|
+
throw new Error("google token exchange failed");
|
|
130
|
+
}
|
|
131
|
+
const profileRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
|
132
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
133
|
+
});
|
|
134
|
+
if (!profileRes.ok) {
|
|
135
|
+
throw new Error("google profile failed");
|
|
136
|
+
}
|
|
137
|
+
const profile = (await profileRes.json()) as {
|
|
138
|
+
id?: string;
|
|
139
|
+
email?: string;
|
|
140
|
+
name?: string;
|
|
141
|
+
};
|
|
142
|
+
if (!profile.email || !profile.id) {
|
|
143
|
+
throw new Error("google profile missing email");
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
email: profile.email,
|
|
147
|
+
name: profile.name?.trim() || null,
|
|
148
|
+
subject: profile.id,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function signLocalGoogleState(
|
|
153
|
+
jwtSecret: string,
|
|
154
|
+
redirectUri: string,
|
|
155
|
+
): Promise<string> {
|
|
156
|
+
return new SignJWT({ typ: LOCAL_STATE_TYP, redirectUri })
|
|
157
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
158
|
+
.setIssuedAt()
|
|
159
|
+
.setExpirationTime("10m")
|
|
160
|
+
.sign(new TextEncoder().encode(jwtSecret));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function verifyLocalGoogleState(
|
|
164
|
+
jwtSecret: string,
|
|
165
|
+
token: string,
|
|
166
|
+
): Promise<{ redirectUri: string }> {
|
|
167
|
+
const { payload } = await jwtVerify(token, new TextEncoder().encode(jwtSecret));
|
|
168
|
+
if (payload.typ !== LOCAL_STATE_TYP || typeof payload.redirectUri !== "string") {
|
|
169
|
+
throw new Error("invalid local google oauth state");
|
|
170
|
+
}
|
|
171
|
+
return { redirectUri: payload.redirectUri };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pruneUsedJti(now: number): void {
|
|
175
|
+
for (const [jti, expires] of usedJti) {
|
|
176
|
+
if (expires <= now) usedJti.delete(jti);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resetLocalDevAssertionReplayForTests(): void {
|
|
181
|
+
usedJti.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function verifyLocalDevIdentityAssertion(
|
|
185
|
+
assertion: string,
|
|
186
|
+
options: {
|
|
187
|
+
brokerBaseUrl: string;
|
|
188
|
+
callbackUri: string;
|
|
189
|
+
jwks?: JWTVerifyGetKey;
|
|
190
|
+
},
|
|
191
|
+
): Promise<{
|
|
192
|
+
email: string;
|
|
193
|
+
name: string | null;
|
|
194
|
+
googleSubject: string;
|
|
195
|
+
redirectUri: string;
|
|
196
|
+
}> {
|
|
197
|
+
const issuer = options.brokerBaseUrl.replace(/\/$/, "");
|
|
198
|
+
const jwks = options.jwks ?? createRemoteJWKSet(new URL(`${issuer}/internal/auth/jwks.json`));
|
|
199
|
+
const { payload } = await jwtVerify(assertion, jwks, {
|
|
200
|
+
issuer,
|
|
201
|
+
audience: LOCAL_DEV_IDENTITY_AUD,
|
|
202
|
+
});
|
|
203
|
+
if (payload.typ !== LOCAL_DEV_IDENTITY_TYP) {
|
|
204
|
+
throw invalidAssertion();
|
|
205
|
+
}
|
|
206
|
+
if (payload.callback_uri !== options.callbackUri) {
|
|
207
|
+
throw invalidAssertion();
|
|
208
|
+
}
|
|
209
|
+
if (typeof payload.email !== "string" || typeof payload.google_subject !== "string") {
|
|
210
|
+
throw invalidAssertion();
|
|
211
|
+
}
|
|
212
|
+
if (typeof payload.redirect_uri !== "string") {
|
|
213
|
+
throw invalidAssertion();
|
|
214
|
+
}
|
|
215
|
+
const redirectUri = assertLocalAppRedirectUri(payload.redirect_uri);
|
|
216
|
+
const jti = payload.jti;
|
|
217
|
+
if (typeof jti !== "string" || !jti) {
|
|
218
|
+
throw invalidAssertion();
|
|
219
|
+
}
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
pruneUsedJti(now);
|
|
222
|
+
if (usedJti.has(jti)) {
|
|
223
|
+
throw invalidAssertion();
|
|
224
|
+
}
|
|
225
|
+
usedJti.set(jti, now + JTI_TTL_MS);
|
|
226
|
+
return {
|
|
227
|
+
email: payload.email.toLowerCase(),
|
|
228
|
+
name: typeof payload.name === "string" ? payload.name : null,
|
|
229
|
+
googleSubject: payload.google_subject,
|
|
230
|
+
redirectUri,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import type { JobDefinition, JobHandlerContext, RobodevDb } from "@robodev-ai/sdk";
|
|
4
|
+
import { JOB_KIND } from "@robodev-ai/sdk";
|
|
5
|
+
import { nextRunAfter } from "./cron.js";
|
|
6
|
+
import type { RouteClients } from "./invoke.js";
|
|
7
|
+
import { JOB_MAX_ATTEMPTS, JOB_PAYLOAD_MAX_BYTES } from "./job-queue.js";
|
|
8
|
+
import {
|
|
9
|
+
createLocalJobsEngine,
|
|
10
|
+
ensureJobsSchema,
|
|
11
|
+
type JobsQueryable,
|
|
12
|
+
type LocalCronRow,
|
|
13
|
+
type LocalJobRow,
|
|
14
|
+
type LocalJobsGeneration,
|
|
15
|
+
} from "./local-jobs.js";
|
|
16
|
+
|
|
17
|
+
function jobDef(
|
|
18
|
+
handler: (ctx: JobHandlerContext) => Promise<unknown> | unknown,
|
|
19
|
+
extras: { timeoutMs?: number; cron?: string } = {},
|
|
20
|
+
): JobDefinition {
|
|
21
|
+
return { _kind: JOB_KIND, handler, ...extras };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function unavailable(feature: string): never {
|
|
25
|
+
throw new Error(`ctx.${feature} needs hosted Starbase. Run robodev deploy to use it.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mockClients(): RouteClients {
|
|
29
|
+
return {
|
|
30
|
+
email: {
|
|
31
|
+
async send() {
|
|
32
|
+
return { id: "local_mail", status: "sent" };
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
llm: { complete: () => unavailable("llm"), stream: () => unavailable("llm") },
|
|
36
|
+
agent: {
|
|
37
|
+
createSession: () => unavailable("agent"),
|
|
38
|
+
start: () => unavailable("agent"),
|
|
39
|
+
events: () => unavailable("agent"),
|
|
40
|
+
subscribe: () => unavailable("agent"),
|
|
41
|
+
destroy: () => unavailable("agent"),
|
|
42
|
+
},
|
|
43
|
+
storage: {
|
|
44
|
+
upload: () => unavailable("storage"),
|
|
45
|
+
get: () => unavailable("storage"),
|
|
46
|
+
getUrl: () => unavailable("storage"),
|
|
47
|
+
delete: () => unavailable("storage"),
|
|
48
|
+
list: () => unavailable("storage"),
|
|
49
|
+
},
|
|
50
|
+
push: { send: () => unavailable("push") },
|
|
51
|
+
jobs: { enqueue: async () => ({ id: "nested" }) },
|
|
52
|
+
sockets: {
|
|
53
|
+
send: () => unavailable("sockets"),
|
|
54
|
+
broadcast: () => unavailable("sockets"),
|
|
55
|
+
},
|
|
56
|
+
env: {},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function memoryJobs() {
|
|
61
|
+
const jobs: LocalJobRow[] = [];
|
|
62
|
+
const crons: LocalCronRow[] = [];
|
|
63
|
+
const now = () => new Date();
|
|
64
|
+
|
|
65
|
+
const query: JobsQueryable["query"] = async (text, values = []) => {
|
|
66
|
+
const sql = text.replace(/\s+/g, " ").trim();
|
|
67
|
+
if (/^(CREATE SCHEMA|CREATE TABLE|CREATE INDEX)/.test(sql)) return { rows: [] };
|
|
68
|
+
|
|
69
|
+
if (sql.includes("INSERT INTO robodev_jobs.jobs")) {
|
|
70
|
+
const created = now();
|
|
71
|
+
jobs.push({
|
|
72
|
+
id: String(values[0]),
|
|
73
|
+
name: String(values[1]),
|
|
74
|
+
payload_json: String(values[2]),
|
|
75
|
+
status: "queued",
|
|
76
|
+
attempts: 0,
|
|
77
|
+
max_attempts: Number(values[3]),
|
|
78
|
+
run_at: values[4] as Date,
|
|
79
|
+
last_error: null,
|
|
80
|
+
created_at: created,
|
|
81
|
+
updated_at: created,
|
|
82
|
+
});
|
|
83
|
+
return { rows: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (sql.includes("FOR UPDATE SKIP LOCKED")) {
|
|
87
|
+
const due = jobs
|
|
88
|
+
.filter((row) => row.status === "queued" && row.run_at.getTime() <= Date.now())
|
|
89
|
+
.sort((a, b) => a.run_at.getTime() - b.run_at.getTime())[0];
|
|
90
|
+
if (!due) return { rows: [] };
|
|
91
|
+
due.status = "running";
|
|
92
|
+
due.updated_at = now();
|
|
93
|
+
return { rows: [{ ...due }] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (sql.includes("SET status = 'succeeded'")) {
|
|
97
|
+
const row = jobs.find((entry) => entry.id === values[0]);
|
|
98
|
+
if (row) {
|
|
99
|
+
row.status = "succeeded";
|
|
100
|
+
row.last_error = null;
|
|
101
|
+
row.updated_at = now();
|
|
102
|
+
}
|
|
103
|
+
return { rows: [] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (sql.includes("run_at = COALESCE")) {
|
|
107
|
+
const row = jobs.find((entry) => entry.id === values[0]);
|
|
108
|
+
if (row) {
|
|
109
|
+
row.status = values[1] as LocalJobRow["status"];
|
|
110
|
+
row.attempts = Number(values[2]);
|
|
111
|
+
row.last_error = (values[3] as string | null) ?? null;
|
|
112
|
+
if (values[4]) row.run_at = values[4] as Date;
|
|
113
|
+
row.updated_at = now();
|
|
114
|
+
}
|
|
115
|
+
return { rows: [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (sql.includes("WHERE status = 'running'") && sql.includes("SET status = 'queued'")) {
|
|
119
|
+
for (const row of jobs) {
|
|
120
|
+
if (row.status === "running") {
|
|
121
|
+
row.status = "queued";
|
|
122
|
+
row.updated_at = now();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { rows: [] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (sql.includes("INSERT INTO robodev_jobs.crons")) {
|
|
129
|
+
crons.push({
|
|
130
|
+
job_name: String(values[0]),
|
|
131
|
+
cron: String(values[1]),
|
|
132
|
+
next_run_at: values[2] as Date,
|
|
133
|
+
});
|
|
134
|
+
return { rows: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (sql.includes("FROM robodev_jobs.crons") && sql.includes("WHERE next_run_at <=")) {
|
|
138
|
+
const at = values[0] as Date;
|
|
139
|
+
return {
|
|
140
|
+
rows: crons
|
|
141
|
+
.filter((row) => row.next_run_at.getTime() <= at.getTime())
|
|
142
|
+
.sort((a, b) => a.next_run_at.getTime() - b.next_run_at.getTime())
|
|
143
|
+
.slice(0, 50),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (sql.includes("FROM robodev_jobs.crons") && sql.includes("SELECT job_name")) {
|
|
148
|
+
return { rows: [...crons] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (sql.includes("UPDATE robodev_jobs.crons") && sql.includes("SET cron")) {
|
|
152
|
+
const row = crons.find((entry) => entry.job_name === values[0]);
|
|
153
|
+
if (row) {
|
|
154
|
+
row.cron = String(values[1]);
|
|
155
|
+
row.next_run_at = values[2] as Date;
|
|
156
|
+
}
|
|
157
|
+
return { rows: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (sql.includes("UPDATE robodev_jobs.crons") && sql.includes("SET next_run_at")) {
|
|
161
|
+
const row = crons.find((entry) => entry.job_name === values[0]);
|
|
162
|
+
if (row) row.next_run_at = values[1] as Date;
|
|
163
|
+
return { rows: [] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (sql.includes("DELETE FROM robodev_jobs.crons")) {
|
|
167
|
+
const keep = new Set(values[0] as string[]);
|
|
168
|
+
for (let i = crons.length - 1; i >= 0; i--) {
|
|
169
|
+
if (!keep.has(crons[i]!.job_name)) crons.splice(i, 1);
|
|
170
|
+
}
|
|
171
|
+
return { rows: [] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return { jobs, crons, query };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function waitUntil(pred: () => boolean, ms = 1000): Promise<void> {
|
|
181
|
+
const start = Date.now();
|
|
182
|
+
while (!pred()) {
|
|
183
|
+
if (Date.now() - start > ms) throw new Error("timed out waiting");
|
|
184
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function engineFor(
|
|
189
|
+
store: ReturnType<typeof memoryJobs>,
|
|
190
|
+
jobs: LocalJobsGeneration["jobs"],
|
|
191
|
+
logs: string[] = [],
|
|
192
|
+
) {
|
|
193
|
+
const generation: LocalJobsGeneration = { db: {} as RobodevDb, jobs };
|
|
194
|
+
return createLocalJobsEngine({
|
|
195
|
+
query: store.query,
|
|
196
|
+
getGeneration: () => generation,
|
|
197
|
+
log: (line) => logs.push(line),
|
|
198
|
+
clients: mockClients,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test("ensureJobsSchema is idempotent SQL", async () => {
|
|
203
|
+
const store = memoryJobs();
|
|
204
|
+
await ensureJobsSchema({ query: store.query });
|
|
205
|
+
await ensureJobsSchema({ query: store.query });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("enqueue known job; unknown and oversize throw without insert", async () => {
|
|
209
|
+
const store = memoryJobs();
|
|
210
|
+
const engine = engineFor(store, [{ name: "digest", def: jobDef(() => undefined) }]);
|
|
211
|
+
const queued = await engine.enqueue({ name: "digest", payload: { ok: true } });
|
|
212
|
+
assert.match(queued.id, /^job_/);
|
|
213
|
+
assert.equal(store.jobs.length, 1);
|
|
214
|
+
assert.equal(JSON.parse(store.jobs[0]!.payload_json).ok, true);
|
|
215
|
+
|
|
216
|
+
await assert.rejects(() => engine.enqueue({ name: "missing" }), /Unknown job name: missing/);
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
() => engine.enqueue({ name: "digest", payload: "x".repeat(JOB_PAYLOAD_MAX_BYTES) }),
|
|
219
|
+
/job payload must be at most 256KB/,
|
|
220
|
+
);
|
|
221
|
+
assert.equal(store.jobs.length, 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("successful handler marks succeeded and logs", async () => {
|
|
225
|
+
const store = memoryJobs();
|
|
226
|
+
const logs: string[] = [];
|
|
227
|
+
const seen: unknown[] = [];
|
|
228
|
+
const engine = engineFor(
|
|
229
|
+
store,
|
|
230
|
+
[{ name: "digest", def: jobDef((ctx) => seen.push(ctx.payload)) }],
|
|
231
|
+
logs,
|
|
232
|
+
);
|
|
233
|
+
await engine.enqueue({ name: "digest", payload: { n: 1 } });
|
|
234
|
+
await engine.tickJobs();
|
|
235
|
+
await waitUntil(() => store.jobs[0]?.status === "succeeded" && logs.length > 0);
|
|
236
|
+
assert.deepEqual(seen, [{ n: 1 }]);
|
|
237
|
+
assert.equal(logs[0], " digest succeeded");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("throw backs off then dead at 5", async () => {
|
|
241
|
+
const store = memoryJobs();
|
|
242
|
+
const logs: string[] = [];
|
|
243
|
+
const engine = engineFor(
|
|
244
|
+
store,
|
|
245
|
+
[
|
|
246
|
+
{
|
|
247
|
+
name: "digest",
|
|
248
|
+
def: jobDef(() => {
|
|
249
|
+
throw new Error("boom");
|
|
250
|
+
}),
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
logs,
|
|
254
|
+
);
|
|
255
|
+
await engine.enqueue({ name: "digest" });
|
|
256
|
+
for (let attempt = 1; attempt <= JOB_MAX_ATTEMPTS; attempt++) {
|
|
257
|
+
store.jobs[0]!.run_at = new Date(0);
|
|
258
|
+
store.jobs[0]!.status = "queued";
|
|
259
|
+
await engine.tickJobs();
|
|
260
|
+
await waitUntil(() => store.jobs[0]!.status !== "running");
|
|
261
|
+
}
|
|
262
|
+
assert.equal(store.jobs[0]?.status, "dead");
|
|
263
|
+
assert.equal(store.jobs[0]?.attempts, 5);
|
|
264
|
+
assert.equal(store.jobs[0]?.last_error, "boom");
|
|
265
|
+
assert.match(logs[0] ?? "", /failed attempt 1\/5: boom/);
|
|
266
|
+
assert.match(logs.at(-1) ?? "", /digest dead: boom/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("timeout is handler_timeout and retries", async () => {
|
|
270
|
+
const store = memoryJobs();
|
|
271
|
+
const logs: string[] = [];
|
|
272
|
+
const engine = engineFor(
|
|
273
|
+
store,
|
|
274
|
+
[
|
|
275
|
+
{
|
|
276
|
+
name: "slow",
|
|
277
|
+
def: jobDef(() => new Promise((resolve) => setTimeout(resolve, 50)), { timeoutMs: 10 }),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
logs,
|
|
281
|
+
);
|
|
282
|
+
await engine.enqueue({ name: "slow" });
|
|
283
|
+
await engine.tickJobs();
|
|
284
|
+
await waitUntil(() => store.jobs[0]?.last_error === "handler_timeout");
|
|
285
|
+
assert.equal(store.jobs[0]?.status, "queued");
|
|
286
|
+
assert.equal(store.jobs[0]?.last_error, "handler_timeout");
|
|
287
|
+
assert.match(logs[0] ?? "", /failed attempt 1\/5: handler_timeout/);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("reconcile keeps unchanged next_run, reschedules, and deletes", async () => {
|
|
291
|
+
const now = new Date("2026-01-01T00:00:00.000Z");
|
|
292
|
+
const keptNext = new Date("2026-01-01T01:00:00.000Z");
|
|
293
|
+
const store = memoryJobs();
|
|
294
|
+
store.crons.push(
|
|
295
|
+
{ job_name: "hourly", cron: "0 * * * *", next_run_at: keptNext },
|
|
296
|
+
{ job_name: "stale", cron: "0 0 * * *", next_run_at: new Date("2026-01-02T00:00:00.000Z") },
|
|
297
|
+
{ job_name: "daily", cron: "0 0 * * *", next_run_at: new Date("2026-01-02T00:00:00.000Z") },
|
|
298
|
+
);
|
|
299
|
+
const engine = engineFor(store, []);
|
|
300
|
+
await engine.reconcile(
|
|
301
|
+
[
|
|
302
|
+
{ name: "hourly", def: { cron: "0 * * * *" } },
|
|
303
|
+
{ name: "daily", def: { cron: "0 12 * * *" } },
|
|
304
|
+
{ name: "fresh", def: { cron: "*/5 * * * *" } },
|
|
305
|
+
],
|
|
306
|
+
now,
|
|
307
|
+
);
|
|
308
|
+
const byName = Object.fromEntries(store.crons.map((row) => [row.job_name, row]));
|
|
309
|
+
assert.equal(store.crons.length, 3);
|
|
310
|
+
assert.equal(byName.hourly?.next_run_at, keptNext);
|
|
311
|
+
assert.equal(byName.daily?.cron, "0 12 * * *");
|
|
312
|
+
assert.deepEqual(byName.daily?.next_run_at, nextRunAfter("0 12 * * *", now));
|
|
313
|
+
assert.deepEqual(byName.fresh?.next_run_at, nextRunAfter("*/5 * * * *", now));
|
|
314
|
+
assert.equal(byName.stale, undefined);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("removing cron deletes the row", async () => {
|
|
318
|
+
const store = memoryJobs();
|
|
319
|
+
store.crons.push({
|
|
320
|
+
job_name: "hourly",
|
|
321
|
+
cron: "0 * * * *",
|
|
322
|
+
next_run_at: new Date("2026-01-01T01:00:00.000Z"),
|
|
323
|
+
});
|
|
324
|
+
const engine = engineFor(store, []);
|
|
325
|
+
await engine.reconcile([{ name: "hourly", def: {} }]);
|
|
326
|
+
assert.equal(store.crons.length, 0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("tick enqueues null payload, advances first, and does not roll back", async () => {
|
|
330
|
+
const now = new Date("2026-01-01T00:00:00.000Z");
|
|
331
|
+
const store = memoryJobs();
|
|
332
|
+
store.crons.push({ job_name: "hourly", cron: "0 * * * *", next_run_at: now });
|
|
333
|
+
const events: string[] = [];
|
|
334
|
+
const engine = createLocalJobsEngine({
|
|
335
|
+
query: async (text, values) => {
|
|
336
|
+
if (text.includes("UPDATE robodev_jobs.crons") && text.includes("SET next_run_at")) {
|
|
337
|
+
events.push("advance");
|
|
338
|
+
}
|
|
339
|
+
if (text.includes("INSERT INTO robodev_jobs.jobs")) events.push("enqueue");
|
|
340
|
+
return store.query(text, values);
|
|
341
|
+
},
|
|
342
|
+
getGeneration: () => ({
|
|
343
|
+
db: {} as RobodevDb,
|
|
344
|
+
jobs: [{ name: "hourly", def: jobDef(() => undefined) }],
|
|
345
|
+
}),
|
|
346
|
+
clients: mockClients,
|
|
347
|
+
});
|
|
348
|
+
await engine.tickDue(now);
|
|
349
|
+
assert.deepEqual(events, ["advance", "enqueue"]);
|
|
350
|
+
assert.equal(JSON.parse(store.jobs[0]!.payload_json), null);
|
|
351
|
+
assert.deepEqual(store.crons[0]?.next_run_at, nextRunAfter("0 * * * *", now));
|
|
352
|
+
|
|
353
|
+
const failStore = memoryJobs();
|
|
354
|
+
failStore.crons.push({ job_name: "hourly", cron: "0 * * * *", next_run_at: now });
|
|
355
|
+
const failing = createLocalJobsEngine({
|
|
356
|
+
query: failStore.query,
|
|
357
|
+
getGeneration: () => ({ db: {} as RobodevDb, jobs: [] }),
|
|
358
|
+
clients: mockClients,
|
|
359
|
+
});
|
|
360
|
+
await failing.tickDue(now);
|
|
361
|
+
assert.equal(failStore.jobs.length, 0);
|
|
362
|
+
assert.deepEqual(failStore.crons[0]?.next_run_at, nextRunAfter("0 * * * *", now));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("overlap piles up queued rows", async () => {
|
|
366
|
+
const now = new Date("2026-01-01T00:00:00.000Z");
|
|
367
|
+
const store = memoryJobs();
|
|
368
|
+
store.crons.push({ job_name: "hourly", cron: "0 * * * *", next_run_at: now });
|
|
369
|
+
const engine = engineFor(store, [{ name: "hourly", def: jobDef(() => undefined) }]);
|
|
370
|
+
await engine.tickDue(now);
|
|
371
|
+
store.crons[0]!.next_run_at = now;
|
|
372
|
+
await engine.tickDue(now);
|
|
373
|
+
assert.equal(store.jobs.length, 2);
|
|
374
|
+
assert.equal(
|
|
375
|
+
store.jobs.every((row) => row.status === "queued"),
|
|
376
|
+
true,
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("missing handler is route_not_found", async () => {
|
|
381
|
+
const store = memoryJobs();
|
|
382
|
+
const logs: string[] = [];
|
|
383
|
+
const engine = engineFor(store, [], logs);
|
|
384
|
+
store.jobs.push({
|
|
385
|
+
id: "job_ghost",
|
|
386
|
+
name: "gone",
|
|
387
|
+
payload_json: "null",
|
|
388
|
+
status: "queued",
|
|
389
|
+
attempts: 0,
|
|
390
|
+
max_attempts: 5,
|
|
391
|
+
run_at: new Date(0),
|
|
392
|
+
last_error: null,
|
|
393
|
+
created_at: new Date(),
|
|
394
|
+
updated_at: new Date(),
|
|
395
|
+
});
|
|
396
|
+
await engine.tickJobs();
|
|
397
|
+
await waitUntil(() => store.jobs[0]?.last_error === "route_not_found");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("hosted-only clients still throw in job handlers; email is real", async () => {
|
|
401
|
+
const store = memoryJobs();
|
|
402
|
+
const seen: string[] = [];
|
|
403
|
+
const engine = engineFor(store, [
|
|
404
|
+
{
|
|
405
|
+
name: "probe",
|
|
406
|
+
def: jobDef(async (ctx) => {
|
|
407
|
+
await ctx.email.send({ to: "a@b.c", subject: "hi" });
|
|
408
|
+
seen.push("email");
|
|
409
|
+
try {
|
|
410
|
+
await ctx.llm.complete({ messages: [{ role: "user", content: "x" }] });
|
|
411
|
+
} catch (error) {
|
|
412
|
+
seen.push((error as Error).message);
|
|
413
|
+
}
|
|
414
|
+
}),
|
|
415
|
+
},
|
|
416
|
+
]);
|
|
417
|
+
await engine.enqueue({ name: "probe" });
|
|
418
|
+
await engine.tickJobs();
|
|
419
|
+
await waitUntil(() => store.jobs[0]?.status === "succeeded");
|
|
420
|
+
assert.equal(seen[0], "email");
|
|
421
|
+
assert.match(seen[1] ?? "", /ctx\.llm needs hosted Starbase/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("start resets running rows; stop clears timers", async () => {
|
|
425
|
+
const store = memoryJobs();
|
|
426
|
+
store.jobs.push({
|
|
427
|
+
id: "job_stuck",
|
|
428
|
+
name: "digest",
|
|
429
|
+
payload_json: "null",
|
|
430
|
+
status: "running",
|
|
431
|
+
attempts: 1,
|
|
432
|
+
max_attempts: 5,
|
|
433
|
+
run_at: new Date(),
|
|
434
|
+
last_error: null,
|
|
435
|
+
created_at: new Date(),
|
|
436
|
+
updated_at: new Date(),
|
|
437
|
+
});
|
|
438
|
+
const engine = engineFor(store, [{ name: "digest", def: jobDef(() => undefined) }]);
|
|
439
|
+
assert.equal(engine.running, false);
|
|
440
|
+
await engine.start();
|
|
441
|
+
assert.equal(store.jobs[0]?.status, "queued");
|
|
442
|
+
assert.equal(engine.running, true);
|
|
443
|
+
await engine.start();
|
|
444
|
+
assert.equal(engine.running, true);
|
|
445
|
+
await engine.stop();
|
|
446
|
+
assert.equal(engine.running, false);
|
|
447
|
+
await engine.stop();
|
|
448
|
+
});
|