@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robodev-ai/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, and Robodev Auth. Shared by hosted Starbase and `robodev dev`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"pg": "^8.16.3",
|
|
33
33
|
"zod": "^3.25.76",
|
|
34
34
|
"zod-to-json-schema": "^3.24.6",
|
|
35
|
-
"@robodev-ai/sdk": "0.
|
|
35
|
+
"@robodev-ai/sdk": "0.11.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/busboy": "^1.5.4",
|
package/src/auth-core.ts
CHANGED
|
@@ -267,3 +267,52 @@ export async function revokeRefresh(db: AuthQueryable, refreshToken: string): Pr
|
|
|
267
267
|
hashToken(refreshToken),
|
|
268
268
|
]);
|
|
269
269
|
}
|
|
270
|
+
|
|
271
|
+
export type GoogleUpsertResult =
|
|
272
|
+
| { user: AuthRow; created: boolean; updated: boolean; previousName?: string | null }
|
|
273
|
+
| "signup_disabled";
|
|
274
|
+
|
|
275
|
+
export async function upsertGoogleUser(
|
|
276
|
+
pool: AuthQueryable,
|
|
277
|
+
input: { email: string; name: string | null; subject: string },
|
|
278
|
+
options: { allowSignups: boolean } = { allowSignups: true },
|
|
279
|
+
): Promise<GoogleUpsertResult> {
|
|
280
|
+
await ensureAuthSchema(pool);
|
|
281
|
+
const email = input.email.toLowerCase();
|
|
282
|
+
const existing = await pool.query<AuthRow>(
|
|
283
|
+
`SELECT id, email, password_hash, name, google_subject FROM robodev_auth.users WHERE email = $1`,
|
|
284
|
+
[email],
|
|
285
|
+
);
|
|
286
|
+
const row = existing.rows[0];
|
|
287
|
+
if (row) {
|
|
288
|
+
const previousName = row.name;
|
|
289
|
+
const nextName = input.name && input.name !== row.name ? input.name : row.name;
|
|
290
|
+
if (nextName !== row.name || row.google_subject !== input.subject) {
|
|
291
|
+
await pool.query(
|
|
292
|
+
`UPDATE robodev_auth.users SET name = $1, google_subject = $2 WHERE id = $3`,
|
|
293
|
+
[nextName, input.subject, row.id],
|
|
294
|
+
);
|
|
295
|
+
return {
|
|
296
|
+
user: { ...row, name: nextName, google_subject: input.subject },
|
|
297
|
+
created: false,
|
|
298
|
+
updated: true,
|
|
299
|
+
previousName,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return { user: row, created: false, updated: false };
|
|
303
|
+
}
|
|
304
|
+
if (!options.allowSignups) return "signup_disabled";
|
|
305
|
+
const created: AuthRow = {
|
|
306
|
+
id: id("usr"),
|
|
307
|
+
email,
|
|
308
|
+
password_hash: null,
|
|
309
|
+
name: input.name,
|
|
310
|
+
google_subject: input.subject,
|
|
311
|
+
};
|
|
312
|
+
await pool.query(
|
|
313
|
+
`INSERT INTO robodev_auth.users (id, email, password_hash, name, google_subject)
|
|
314
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
315
|
+
[created.id, created.email, created.password_hash, created.name, created.google_subject],
|
|
316
|
+
);
|
|
317
|
+
return { user: created, created: true, updated: false };
|
|
318
|
+
}
|
package/src/cron.test.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { nextRunAfter, parseCron } from "./cron.js";
|
|
4
|
+
|
|
5
|
+
test("* * * * * is the next minute", () => {
|
|
6
|
+
const from = new Date(Date.UTC(2026, 0, 1, 12, 0, 30));
|
|
7
|
+
assert.deepEqual(nextRunAfter("* * * * *", from), new Date(Date.UTC(2026, 0, 1, 12, 1, 0)));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("0 0 * * * is the next midnight", () => {
|
|
11
|
+
const from = new Date(Date.UTC(2026, 0, 1, 12, 30, 0));
|
|
12
|
+
assert.deepEqual(nextRunAfter("0 0 * * *", from), new Date(Date.UTC(2026, 0, 2, 0, 0, 0)));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects 6-field and invalid expressions", () => {
|
|
16
|
+
assert.throws(() => parseCron("* * * * * *"), /exactly 5 fields/);
|
|
17
|
+
assert.throws(() => parseCron("not-cron"), /exactly 5 fields/);
|
|
18
|
+
assert.throws(() => parseCron("60 * * * *"), /invalid cron field/);
|
|
19
|
+
});
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const FIELD_RANGES = [
|
|
2
|
+
{ min: 0, max: 59 },
|
|
3
|
+
{ min: 0, max: 23 },
|
|
4
|
+
{ min: 1, max: 31 },
|
|
5
|
+
{ min: 1, max: 12 },
|
|
6
|
+
{ min: 0, max: 7 },
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
export type CronFields = [Set<number>, Set<number>, Set<number>, Set<number>, Set<number>];
|
|
10
|
+
|
|
11
|
+
function expandField(field: string, min: number, max: number): Set<number> {
|
|
12
|
+
const values = new Set<number>();
|
|
13
|
+
for (const part of field.split(",")) {
|
|
14
|
+
const [rangePart, stepPart] = part.split("/");
|
|
15
|
+
const step = stepPart ? Number(stepPart) : 1;
|
|
16
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
17
|
+
throw new Error("invalid cron field");
|
|
18
|
+
}
|
|
19
|
+
let start = min;
|
|
20
|
+
let end = max;
|
|
21
|
+
if (rangePart && rangePart !== "*") {
|
|
22
|
+
if (rangePart.includes("-")) {
|
|
23
|
+
const [fromRaw, toRaw] = rangePart.split("-");
|
|
24
|
+
start = Number(fromRaw);
|
|
25
|
+
end = Number(toRaw);
|
|
26
|
+
} else {
|
|
27
|
+
start = Number(rangePart);
|
|
28
|
+
end = start;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (
|
|
32
|
+
!Number.isInteger(start) ||
|
|
33
|
+
!Number.isInteger(end) ||
|
|
34
|
+
start < min ||
|
|
35
|
+
end > max ||
|
|
36
|
+
start > end
|
|
37
|
+
) {
|
|
38
|
+
throw new Error("invalid cron field");
|
|
39
|
+
}
|
|
40
|
+
for (let value = start; value <= end; value += step) {
|
|
41
|
+
values.add(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (max === 7 && values.has(7)) values.add(0);
|
|
45
|
+
return values;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseCron(expr: string): CronFields {
|
|
49
|
+
const fields = expr.trim().split(/\s+/);
|
|
50
|
+
if (fields.length !== 5) {
|
|
51
|
+
throw new Error("cron must have exactly 5 fields");
|
|
52
|
+
}
|
|
53
|
+
return FIELD_RANGES.map((range, index) =>
|
|
54
|
+
expandField(fields[index] ?? "", range.min, range.max),
|
|
55
|
+
) as CronFields;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matches(date: Date, fields: CronFields): boolean {
|
|
59
|
+
const [minutes, hours, days, months, weekdays] = fields;
|
|
60
|
+
const dow = date.getUTCDay();
|
|
61
|
+
return (
|
|
62
|
+
minutes.has(date.getUTCMinutes()) &&
|
|
63
|
+
hours.has(date.getUTCHours()) &&
|
|
64
|
+
days.has(date.getUTCDate()) &&
|
|
65
|
+
months.has(date.getUTCMonth() + 1) &&
|
|
66
|
+
(weekdays.has(dow) || (dow === 0 && weekdays.has(7)))
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function nextRunAfter(expr: string, from: Date): Date {
|
|
71
|
+
const fields = parseCron(expr);
|
|
72
|
+
const cursor = new Date(from.getTime());
|
|
73
|
+
cursor.setUTCSeconds(0, 0);
|
|
74
|
+
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
|
|
75
|
+
const limit = cursor.getTime() + 366 * 24 * 60 * 60 * 1000;
|
|
76
|
+
while (cursor.getTime() <= limit) {
|
|
77
|
+
if (matches(cursor, fields)) return new Date(cursor);
|
|
78
|
+
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
|
|
79
|
+
}
|
|
80
|
+
throw new Error("could not find next cron run");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function assertFiveFieldCron(expr: string): string {
|
|
84
|
+
parseCron(expr);
|
|
85
|
+
return expr.trim();
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@robodev-ai/runtime` — the project runtime shared by hosted Starbase deploys and the
|
|
3
3
|
* offline `robodev dev` loop: deploy-file classification, the esbuild compile, module
|
|
4
|
-
* loading, schema push, route matching, OpenAPI, handler invocation,
|
|
4
|
+
* loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth,
|
|
5
|
+
* and the offline `robodev dev` jobs engine.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
export {
|
|
@@ -165,8 +166,10 @@ export {
|
|
|
165
166
|
toAuthUser,
|
|
166
167
|
updateUserPassword,
|
|
167
168
|
updateUserProfile,
|
|
169
|
+
upsertGoogleUser,
|
|
168
170
|
type AuthQueryable,
|
|
169
171
|
type AuthRow,
|
|
172
|
+
type GoogleUpsertResult,
|
|
170
173
|
type NonceType,
|
|
171
174
|
} from "./auth-core.js";
|
|
172
175
|
|
|
@@ -193,3 +196,48 @@ export {
|
|
|
193
196
|
type LocalAuthRequest,
|
|
194
197
|
type LocalAuthResult,
|
|
195
198
|
} from "./local-auth.js";
|
|
199
|
+
|
|
200
|
+
export { assertFiveFieldCron, nextRunAfter, parseCron, type CronFields } from "./cron.js";
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
JOB_CONCURRENCY,
|
|
204
|
+
JOB_MAX_ATTEMPTS,
|
|
205
|
+
JOB_PAYLOAD_MAX_BYTES,
|
|
206
|
+
assertJobCrons,
|
|
207
|
+
assertKnownJob,
|
|
208
|
+
jobBackoffMs,
|
|
209
|
+
nextJobFailureState,
|
|
210
|
+
serializeJobPayload,
|
|
211
|
+
type JobCronDefinition,
|
|
212
|
+
} from "./job-queue.js";
|
|
213
|
+
|
|
214
|
+
export {
|
|
215
|
+
LOCAL_JOB_CRON_MS,
|
|
216
|
+
LOCAL_JOB_WORKER_MS,
|
|
217
|
+
createLocalJobsEngine,
|
|
218
|
+
ensureJobsSchema,
|
|
219
|
+
type JobsQueryable,
|
|
220
|
+
type LocalCronRow,
|
|
221
|
+
type LocalJobRow,
|
|
222
|
+
type LocalJobStatus,
|
|
223
|
+
type LocalJobsEngine,
|
|
224
|
+
type LocalJobsEngineOptions,
|
|
225
|
+
type LocalJobsGeneration,
|
|
226
|
+
} from "./local-jobs.js";
|
|
227
|
+
|
|
228
|
+
export {
|
|
229
|
+
LOCAL_DEV_IDENTITY_AUD,
|
|
230
|
+
LOCAL_DEV_IDENTITY_TYP,
|
|
231
|
+
assertLocalAppRedirectUri,
|
|
232
|
+
assertLocalBrokerRedirectUri,
|
|
233
|
+
brokerDevStartUrl,
|
|
234
|
+
exchangeLocalGoogleCode,
|
|
235
|
+
localGoogleAuthorizeUrl,
|
|
236
|
+
localGoogleCallbackUri,
|
|
237
|
+
projectGoogleOverrideMode,
|
|
238
|
+
redirectWithQuery,
|
|
239
|
+
resetLocalDevAssertionReplayForTests,
|
|
240
|
+
signLocalGoogleState,
|
|
241
|
+
verifyLocalDevIdentityAssertion,
|
|
242
|
+
verifyLocalGoogleState,
|
|
243
|
+
} from "./local-google.js";
|
package/src/invoke.ts
CHANGED
|
@@ -56,9 +56,6 @@ export type RunRouteInput = {
|
|
|
56
56
|
resolveUser: (authorization?: string) => Promise<AuthUser | null>;
|
|
57
57
|
/** Built after auth passes, right before the handler runs. */
|
|
58
58
|
clients: () => Promise<RouteClients> | RouteClients;
|
|
59
|
-
/** True when the request carries a valid schedule signature. */
|
|
60
|
-
verifySchedule?: (headers: Record<string, string | string[] | undefined>) => boolean;
|
|
61
|
-
scheduleId?: string;
|
|
62
59
|
/** Lets the host translate its own error types before the generic 500. */
|
|
63
60
|
mapError?: (error: unknown) => MappedError | null;
|
|
64
61
|
};
|
|
@@ -145,22 +142,13 @@ export async function runRoute(input: RunRouteInput): Promise<RouteOutcome> {
|
|
|
145
142
|
body = parsed.data;
|
|
146
143
|
}
|
|
147
144
|
|
|
148
|
-
const scheduleValid = input.verifySchedule ? input.verifySchedule(input.headers) : false;
|
|
149
|
-
const schedule = scheduleValid
|
|
150
|
-
? { id: input.scheduleId ?? "schedule" }
|
|
151
|
-
: input.scheduleId
|
|
152
|
-
? { id: input.scheduleId }
|
|
153
|
-
: null;
|
|
154
|
-
|
|
155
145
|
let user: AuthUser | null = null;
|
|
156
146
|
const auth = route.def.auth ?? false;
|
|
157
|
-
if (auth === "
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
});
|
|
163
|
-
}
|
|
147
|
+
if (typeof auth === "string" && auth !== "required" && auth !== "optional") {
|
|
148
|
+
return invalid(401, {
|
|
149
|
+
error: "unauthorized",
|
|
150
|
+
message: "legacy schedule auth is no longer supported",
|
|
151
|
+
});
|
|
164
152
|
} else if (typeof auth === "function") {
|
|
165
153
|
try {
|
|
166
154
|
user = await auth({ headers: input.headers });
|
|
@@ -202,7 +190,6 @@ export async function runRoute(input: RunRouteInput): Promise<RouteOutcome> {
|
|
|
202
190
|
push: clients.push,
|
|
203
191
|
jobs: clients.jobs,
|
|
204
192
|
sockets: clients.sockets,
|
|
205
|
-
schedule,
|
|
206
193
|
};
|
|
207
194
|
const result = await withHandlerTimeout(
|
|
208
195
|
() => Promise.resolve(route.def.handler(context)),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
assertJobCrons,
|
|
5
|
+
assertKnownJob,
|
|
6
|
+
JOB_MAX_ATTEMPTS,
|
|
7
|
+
JOB_PAYLOAD_MAX_BYTES,
|
|
8
|
+
jobBackoffMs,
|
|
9
|
+
nextJobFailureState,
|
|
10
|
+
serializeJobPayload,
|
|
11
|
+
} from "./job-queue.js";
|
|
12
|
+
|
|
13
|
+
function job(name: string, cron?: string, file = `jobs/${name}.ts`) {
|
|
14
|
+
return { name, file, def: cron === undefined ? {} : { cron } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("enqueue row is queued with serialized payload", () => {
|
|
18
|
+
assertKnownJob("resize", ["resize", "email"]);
|
|
19
|
+
const payloadJson = serializeJobPayload({ key: "avatar.png" });
|
|
20
|
+
assert.equal(JSON.parse(payloadJson).key, "avatar.png");
|
|
21
|
+
const row = {
|
|
22
|
+
name: "resize",
|
|
23
|
+
payload_json: payloadJson,
|
|
24
|
+
status: "queued" as const,
|
|
25
|
+
attempts: 0,
|
|
26
|
+
};
|
|
27
|
+
assert.equal(row.status, "queued");
|
|
28
|
+
assert.equal(row.attempts, 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("unknown name throws", () => {
|
|
32
|
+
assert.throws(() => assertKnownJob("missing", ["resize"]), /Unknown job name: missing/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("oversize payload throws", () => {
|
|
36
|
+
const oversized = "x".repeat(JOB_PAYLOAD_MAX_BYTES);
|
|
37
|
+
assert.throws(() => serializeJobPayload(oversized), /job payload must be at most 256KB/);
|
|
38
|
+
assert.equal(serializeJobPayload(undefined), "null");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("backoff then dead after 5 attempts", () => {
|
|
42
|
+
assert.equal(jobBackoffMs(1), 1000);
|
|
43
|
+
assert.equal(jobBackoffMs(2), 4000);
|
|
44
|
+
let state = nextJobFailureState(1);
|
|
45
|
+
for (let attempts = 2; attempts < JOB_MAX_ATTEMPTS; attempts++) {
|
|
46
|
+
assert.equal(state.status, "queued");
|
|
47
|
+
assert.ok(state.runAt);
|
|
48
|
+
state = nextJobFailureState(attempts);
|
|
49
|
+
}
|
|
50
|
+
state = nextJobFailureState(JOB_MAX_ATTEMPTS);
|
|
51
|
+
assert.equal(state.status, "dead");
|
|
52
|
+
assert.equal(state.runAt, null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("invalid cron fails with hosted error shape", () => {
|
|
56
|
+
assert.throws(
|
|
57
|
+
() => assertJobCrons([job("digest", "not-a-cron")]),
|
|
58
|
+
(error: unknown) => {
|
|
59
|
+
const err = error as { statusCode?: number; code?: string; path?: string };
|
|
60
|
+
return (
|
|
61
|
+
err.statusCode === 400 && err.code === "invalid_job_cron" && err.path === "jobs/digest.ts"
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
assert.throws(() => assertJobCrons([job("digest", "")]));
|
|
66
|
+
assert.throws(() => assertJobCrons([job("digest", " ")]));
|
|
67
|
+
assert.doesNotThrow(() => assertJobCrons([job("digest"), job("hourly", "0 * * * *")]));
|
|
68
|
+
});
|
package/src/job-queue.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { assertFiveFieldCron } from "./cron.js";
|
|
2
|
+
|
|
3
|
+
export const JOB_MAX_ATTEMPTS = 5;
|
|
4
|
+
export const JOB_PAYLOAD_MAX_BYTES = 256 * 1024;
|
|
5
|
+
export const JOB_CONCURRENCY = 4;
|
|
6
|
+
|
|
7
|
+
export type JobCronDefinition = {
|
|
8
|
+
name: string;
|
|
9
|
+
file?: string;
|
|
10
|
+
def: { cron?: string };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function jobBackoffMs(attempt: number): number {
|
|
14
|
+
return Math.min(1000 * 4 ** Math.max(attempt - 1, 0), 300_000);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function nextJobFailureState(attempts: number): {
|
|
18
|
+
status: "queued" | "dead";
|
|
19
|
+
runAt: Date | null;
|
|
20
|
+
} {
|
|
21
|
+
if (attempts >= JOB_MAX_ATTEMPTS) {
|
|
22
|
+
return { status: "dead", runAt: null };
|
|
23
|
+
}
|
|
24
|
+
return { status: "queued", runAt: new Date(Date.now() + jobBackoffMs(attempts)) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function serializeJobPayload(payload: unknown): string {
|
|
28
|
+
const json = JSON.stringify(payload ?? null);
|
|
29
|
+
if (Buffer.byteLength(json, "utf8") > JOB_PAYLOAD_MAX_BYTES) {
|
|
30
|
+
throw new Error("job payload must be at most 256KB");
|
|
31
|
+
}
|
|
32
|
+
return json;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertKnownJob(name: string, known: Iterable<string>): void {
|
|
36
|
+
const names = new Set(known);
|
|
37
|
+
if (!names.has(name)) {
|
|
38
|
+
throw new Error(`Unknown job name: ${name}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertJobCrons(jobs: readonly JobCronDefinition[]): void {
|
|
43
|
+
for (const job of jobs) {
|
|
44
|
+
if (job.def.cron === undefined) continue;
|
|
45
|
+
try {
|
|
46
|
+
if (typeof job.def.cron !== "string" || job.def.cron.trim() === "") {
|
|
47
|
+
throw new Error("cron must be a non-empty 5-field expression");
|
|
48
|
+
}
|
|
49
|
+
assertFiveFieldCron(job.def.cron);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw Object.assign(new Error(error instanceof Error ? error.message : "invalid cron"), {
|
|
52
|
+
statusCode: 400,
|
|
53
|
+
code: "invalid_job_cron",
|
|
54
|
+
path: job.file ?? `jobs/${job.name}.ts`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|