@robodev-ai/runtime 0.4.0 → 0.4.2
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/email-templates.test.ts +105 -0
- package/src/email-templates.ts +169 -0
- package/src/index.ts +30 -1
- package/src/local-jobs.test.ts +74 -0
- package/src/local-jobs.ts +44 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robodev-ai/runtime",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth, the socket engine, and local file storage. 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.11.
|
|
35
|
+
"@robodev-ai/sdk": "0.11.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/busboy": "^1.5.4",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
escapeEmailHtml,
|
|
8
|
+
extractTemplateVars,
|
|
9
|
+
htmlToPlainText,
|
|
10
|
+
isCustomEmailSlug,
|
|
11
|
+
loadLocalEmailTemplate,
|
|
12
|
+
mergeCustomTemplateVariables,
|
|
13
|
+
renderEmailFields,
|
|
14
|
+
renderEmailTemplate,
|
|
15
|
+
resolveEmailSendInput,
|
|
16
|
+
} from "./email-templates.js";
|
|
17
|
+
|
|
18
|
+
test("renderEmailTemplate replaces whitespace-tolerant vars", () => {
|
|
19
|
+
assert.equal(renderEmailTemplate("Hi {{ name }}", { name: "Ada" }), "Hi Ada");
|
|
20
|
+
assert.equal(renderEmailTemplate("Hi {{name}}", { name: "Ada" }), "Hi Ada");
|
|
21
|
+
assert.equal(renderEmailTemplate("Hi {{ missing }}", {}), "Hi ");
|
|
22
|
+
assert.equal(renderEmailTemplate("Hi {{1bad}}", { "1bad": "x" }), "Hi {{1bad}}");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("renderEmailTemplate HTML-escapes only in html mode", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
renderEmailTemplate("<p>{{name}}</p>", { name: `<a>"'` }, { html: true }),
|
|
28
|
+
`<p>${escapeEmailHtml(`<a>"'`)}</p>`,
|
|
29
|
+
);
|
|
30
|
+
assert.equal(renderEmailTemplate("{{name}}", { name: "<x>" }), "<x>");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("extractTemplateVars unions unique names in order", () => {
|
|
34
|
+
assert.deepEqual(extractTemplateVars("{{email}} {{name}}", "{{email}} {{link}}"), [
|
|
35
|
+
"email",
|
|
36
|
+
"name",
|
|
37
|
+
"link",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("htmlToPlainText strips tags", () => {
|
|
42
|
+
assert.equal(htmlToPlainText("<p>Your reset code is <code>123</code>.</p>"), "Your reset code is 123 .");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("resolveEmailSendInput renders catalog then applies overrides", () => {
|
|
46
|
+
const resolved = resolveEmailSendInput(
|
|
47
|
+
{
|
|
48
|
+
to: "a@b.com",
|
|
49
|
+
template: "welcome",
|
|
50
|
+
vars: { name: "Ada" },
|
|
51
|
+
subject: "Override",
|
|
52
|
+
},
|
|
53
|
+
{ subject: "Hi {{name}}", html: "<p>Hello {{name}}</p>", text: "Hello {{name}}" },
|
|
54
|
+
);
|
|
55
|
+
assert.equal(resolved.subject, "Override");
|
|
56
|
+
assert.equal(resolved.html, "<p>Hello Ada</p>");
|
|
57
|
+
assert.equal(resolved.text, "Hello Ada");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("resolveEmailSendInput throws template_not_found without catalog or body", () => {
|
|
61
|
+
assert.throws(
|
|
62
|
+
() => resolveEmailSendInput({ to: "a@b.com", template: "missing" }, null),
|
|
63
|
+
(error: unknown) => error instanceof Error && error.message === "template_not_found",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("loadLocalEmailTemplate reads html plus optional json", async () => {
|
|
68
|
+
const root = await mkdtemp(join(tmpdir(), "robodev-emails-"));
|
|
69
|
+
await mkdir(join(root, "robodev", "emails"), { recursive: true });
|
|
70
|
+
await writeFile(join(root, "robodev", "emails", "welcome.html"), "<p>Hi {{name}}</p>", "utf8");
|
|
71
|
+
await writeFile(
|
|
72
|
+
join(root, "robodev", "emails", "welcome.json"),
|
|
73
|
+
JSON.stringify({ subject: "Hello", text: "Hi {{name}}" }),
|
|
74
|
+
"utf8",
|
|
75
|
+
);
|
|
76
|
+
const loaded = await loadLocalEmailTemplate(root, "welcome");
|
|
77
|
+
assert.deepEqual(loaded, {
|
|
78
|
+
slug: "welcome",
|
|
79
|
+
subject: "Hello",
|
|
80
|
+
html: "<p>Hi {{name}}</p>",
|
|
81
|
+
text: "Hi {{name}}",
|
|
82
|
+
});
|
|
83
|
+
assert.equal(await loadLocalEmailTemplate(root, "missing"), null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("custom slug and variable merge rules", () => {
|
|
87
|
+
assert.equal(isCustomEmailSlug("welcome"), true);
|
|
88
|
+
assert.equal(isCustomEmailSlug("auth.welcome"), false);
|
|
89
|
+
assert.equal(isCustomEmailSlug("Welcome"), false);
|
|
90
|
+
assert.deepEqual(mergeCustomTemplateVariables(["email", "name"], ["extra", "email", "1bad"]), [
|
|
91
|
+
"email",
|
|
92
|
+
"name",
|
|
93
|
+
"extra",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("renderEmailFields derives plaintext when text is empty", () => {
|
|
98
|
+
const rendered = renderEmailFields(
|
|
99
|
+
{ subject: "Hi {{name}}", html: "<p>Hello {{name}}</p>", text: "" },
|
|
100
|
+
{ name: "Ada" },
|
|
101
|
+
);
|
|
102
|
+
assert.equal(rendered.subject, "Hi Ada");
|
|
103
|
+
assert.equal(rendered.html, "<p>Hello Ada</p>");
|
|
104
|
+
assert.equal(rendered.text, "Hello Ada");
|
|
105
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { EmailSendInput } from "@robodev-ai/sdk";
|
|
4
|
+
|
|
5
|
+
export const EMAIL_TEMPLATE_VAR_RE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
6
|
+
export const EMAIL_TEMPLATE_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
export const EMAIL_TEMPLATE_MAX_VARS = 32;
|
|
8
|
+
export const EMAIL_TEMPLATE_MAX_VAR_NAME = 40;
|
|
9
|
+
export const EMAIL_TEMPLATE_MAX_BYTES = 200 * 1024;
|
|
10
|
+
export const EMAIL_TEMPLATE_MAX_SUBJECT = 200;
|
|
11
|
+
export const EMAIL_TEMPLATE_MAX_CUSTOM = 40;
|
|
12
|
+
export const CUSTOM_EMAIL_SLUG_RE = /^[a-z][a-z0-9-]{0,62}$/;
|
|
13
|
+
|
|
14
|
+
export const SYSTEM_EMAIL_SLUGS = ["auth.welcome", "auth.magic-link", "auth.forgot-password"] as const;
|
|
15
|
+
export type SystemEmailSlug = (typeof SYSTEM_EMAIL_SLUGS)[number];
|
|
16
|
+
|
|
17
|
+
export type EmailTemplateKind = "system" | "custom";
|
|
18
|
+
|
|
19
|
+
export type LocalEmailTemplate = {
|
|
20
|
+
slug: string;
|
|
21
|
+
subject: string;
|
|
22
|
+
html: string;
|
|
23
|
+
text: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type EmailTemplateVars = Record<string, string | number | boolean>;
|
|
27
|
+
|
|
28
|
+
export function isSystemEmailSlug(slug: string): slug is SystemEmailSlug {
|
|
29
|
+
return (SYSTEM_EMAIL_SLUGS as readonly string[]).includes(slug);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isCustomEmailSlug(slug: string): boolean {
|
|
33
|
+
return CUSTOM_EMAIL_SLUG_RE.test(slug) && !slug.startsWith("auth.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function escapeEmailHtml(value: string): string {
|
|
37
|
+
return value
|
|
38
|
+
.replaceAll("&", "&")
|
|
39
|
+
.replaceAll("<", "<")
|
|
40
|
+
.replaceAll(">", ">")
|
|
41
|
+
.replaceAll('"', """)
|
|
42
|
+
.replaceAll("'", "'");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function htmlToPlainText(html: string): string {
|
|
46
|
+
return html
|
|
47
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ")
|
|
48
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ")
|
|
49
|
+
.replace(/<[^>]+>/g, " ")
|
|
50
|
+
.replace(/ /gi, " ")
|
|
51
|
+
.replace(/&/g, "&")
|
|
52
|
+
.replace(/</g, "<")
|
|
53
|
+
.replace(/>/g, ">")
|
|
54
|
+
.replace(/"/g, '"')
|
|
55
|
+
.replace(/'/g, "'")
|
|
56
|
+
.replace(/\s+/g, " ")
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function extractTemplateVars(...parts: Array<string | null | undefined>): string[] {
|
|
61
|
+
const names: string[] = [];
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
if (!part) continue;
|
|
65
|
+
const re = new RegExp(EMAIL_TEMPLATE_VAR_RE.source, "g");
|
|
66
|
+
for (const match of part.matchAll(re)) {
|
|
67
|
+
const name = match[1] ?? "";
|
|
68
|
+
if (!name || seen.has(name)) continue;
|
|
69
|
+
seen.add(name);
|
|
70
|
+
names.push(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return names;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderEmailTemplate(
|
|
77
|
+
template: string,
|
|
78
|
+
vars: EmailTemplateVars = {},
|
|
79
|
+
options: { html?: boolean } = {},
|
|
80
|
+
): string {
|
|
81
|
+
return template.replace(EMAIL_TEMPLATE_VAR_RE, (token, name: string) => {
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(vars, name)) return "";
|
|
83
|
+
const raw = vars[name];
|
|
84
|
+
const value = raw == null ? "" : String(raw);
|
|
85
|
+
return options.html ? escapeEmailHtml(value) : value;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderEmailFields(
|
|
90
|
+
input: { subject: string; html: string; text?: string },
|
|
91
|
+
vars: EmailTemplateVars = {},
|
|
92
|
+
): { subject: string; html: string; text: string } {
|
|
93
|
+
const html = renderEmailTemplate(input.html, vars, { html: true });
|
|
94
|
+
const textSource = input.text?.trim()
|
|
95
|
+
? renderEmailTemplate(input.text, vars)
|
|
96
|
+
: htmlToPlainText(html);
|
|
97
|
+
return {
|
|
98
|
+
subject: renderEmailTemplate(input.subject, vars),
|
|
99
|
+
html,
|
|
100
|
+
text: textSource,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function localEmailTemplatePaths(slug: string): { html: string; json: string } {
|
|
105
|
+
return {
|
|
106
|
+
html: `robodev/emails/${slug}.html`,
|
|
107
|
+
json: `robodev/emails/${slug}.json`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function loadLocalEmailTemplate(
|
|
112
|
+
projectRoot: string,
|
|
113
|
+
slug: string,
|
|
114
|
+
): Promise<LocalEmailTemplate | null> {
|
|
115
|
+
const paths = localEmailTemplatePaths(slug);
|
|
116
|
+
let html: string;
|
|
117
|
+
try {
|
|
118
|
+
html = await readFile(join(projectRoot, paths.html), "utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
let subject = "";
|
|
123
|
+
let text = "";
|
|
124
|
+
try {
|
|
125
|
+
const raw = await readFile(join(projectRoot, paths.json), "utf8");
|
|
126
|
+
const parsed = JSON.parse(raw) as { subject?: unknown; text?: unknown };
|
|
127
|
+
if (typeof parsed.subject === "string") subject = parsed.subject;
|
|
128
|
+
if (typeof parsed.text === "string") text = parsed.text;
|
|
129
|
+
} catch {
|
|
130
|
+
/* html-only catalog row */
|
|
131
|
+
}
|
|
132
|
+
return { slug, subject, html, text };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveEmailSendInput(
|
|
136
|
+
input: EmailSendInput,
|
|
137
|
+
catalog: { subject: string; html: string; text?: string } | null,
|
|
138
|
+
): EmailSendInput {
|
|
139
|
+
if (!input.template) return input;
|
|
140
|
+
if (!catalog) {
|
|
141
|
+
if (input.html || input.text) return input;
|
|
142
|
+
throw Object.assign(new Error("template_not_found"), { code: "template_not_found" });
|
|
143
|
+
}
|
|
144
|
+
const rendered = renderEmailFields(catalog, input.vars ?? {});
|
|
145
|
+
return {
|
|
146
|
+
...input,
|
|
147
|
+
subject: input.subject ?? rendered.subject,
|
|
148
|
+
html: input.html ?? rendered.html,
|
|
149
|
+
text: input.text ?? rendered.text,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function mergeCustomTemplateVariables(
|
|
154
|
+
extracted: readonly string[],
|
|
155
|
+
extras: readonly string[] = [],
|
|
156
|
+
): string[] {
|
|
157
|
+
const names: string[] = [];
|
|
158
|
+
const seen = new Set<string>();
|
|
159
|
+
for (const name of [...extracted, ...extras]) {
|
|
160
|
+
if (!EMAIL_TEMPLATE_VAR_NAME_RE.test(name) || name.length > EMAIL_TEMPLATE_MAX_VAR_NAME) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (seen.has(name)) continue;
|
|
164
|
+
seen.add(name);
|
|
165
|
+
names.push(name);
|
|
166
|
+
if (names.length >= EMAIL_TEMPLATE_MAX_VARS) break;
|
|
167
|
+
}
|
|
168
|
+
return names;
|
|
169
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
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
4
|
* loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth,
|
|
5
|
-
* the socket engine, local file storage,
|
|
5
|
+
* the socket engine, local file storage, the offline `robodev dev` jobs engine,
|
|
6
|
+
* and email template rendering.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export {
|
|
@@ -218,6 +219,7 @@ export {
|
|
|
218
219
|
ensureJobsSchema,
|
|
219
220
|
type JobsQueryable,
|
|
220
221
|
type LocalCronRow,
|
|
222
|
+
type LocalJobFinishedEvent,
|
|
221
223
|
type LocalJobRow,
|
|
222
224
|
type LocalJobStatus,
|
|
223
225
|
type LocalJobsEngine,
|
|
@@ -278,6 +280,33 @@ export {
|
|
|
278
280
|
type StorageQueryable,
|
|
279
281
|
} from "./local-storage.js";
|
|
280
282
|
|
|
283
|
+
export {
|
|
284
|
+
CUSTOM_EMAIL_SLUG_RE,
|
|
285
|
+
EMAIL_TEMPLATE_MAX_BYTES,
|
|
286
|
+
EMAIL_TEMPLATE_MAX_CUSTOM,
|
|
287
|
+
EMAIL_TEMPLATE_MAX_SUBJECT,
|
|
288
|
+
EMAIL_TEMPLATE_MAX_VAR_NAME,
|
|
289
|
+
EMAIL_TEMPLATE_MAX_VARS,
|
|
290
|
+
EMAIL_TEMPLATE_VAR_NAME_RE,
|
|
291
|
+
EMAIL_TEMPLATE_VAR_RE,
|
|
292
|
+
SYSTEM_EMAIL_SLUGS,
|
|
293
|
+
escapeEmailHtml,
|
|
294
|
+
extractTemplateVars,
|
|
295
|
+
htmlToPlainText,
|
|
296
|
+
isCustomEmailSlug,
|
|
297
|
+
isSystemEmailSlug,
|
|
298
|
+
loadLocalEmailTemplate,
|
|
299
|
+
localEmailTemplatePaths,
|
|
300
|
+
mergeCustomTemplateVariables,
|
|
301
|
+
renderEmailFields,
|
|
302
|
+
renderEmailTemplate,
|
|
303
|
+
resolveEmailSendInput,
|
|
304
|
+
type EmailTemplateKind,
|
|
305
|
+
type EmailTemplateVars,
|
|
306
|
+
type LocalEmailTemplate,
|
|
307
|
+
type SystemEmailSlug,
|
|
308
|
+
} from "./email-templates.js";
|
|
309
|
+
|
|
281
310
|
export {
|
|
282
311
|
LOCAL_DEV_IDENTITY_AUD,
|
|
283
312
|
LOCAL_DEV_IDENTITY_TYP,
|
package/src/local-jobs.test.ts
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
ensureJobsSchema,
|
|
11
11
|
type JobsQueryable,
|
|
12
12
|
type LocalCronRow,
|
|
13
|
+
type LocalJobFinishedEvent,
|
|
13
14
|
type LocalJobRow,
|
|
15
|
+
type LocalJobsEngineOptions,
|
|
14
16
|
type LocalJobsGeneration,
|
|
15
17
|
} from "./local-jobs.js";
|
|
16
18
|
|
|
@@ -208,6 +210,7 @@ function engineFor(
|
|
|
208
210
|
store: ReturnType<typeof memoryJobs>,
|
|
209
211
|
jobs: LocalJobsGeneration["jobs"],
|
|
210
212
|
logs: string[] = [],
|
|
213
|
+
onJobFinished?: LocalJobsEngineOptions["onJobFinished"],
|
|
211
214
|
) {
|
|
212
215
|
const generation: LocalJobsGeneration = { db: {} as RobodevDb, jobs };
|
|
213
216
|
return createLocalJobsEngine({
|
|
@@ -215,6 +218,7 @@ function engineFor(
|
|
|
215
218
|
getGeneration: () => generation,
|
|
216
219
|
log: (line) => logs.push(line),
|
|
217
220
|
clients: mockClients,
|
|
221
|
+
onJobFinished,
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
224
|
|
|
@@ -465,3 +469,73 @@ test("start resets running rows; stop clears timers", async () => {
|
|
|
465
469
|
assert.equal(engine.running, false);
|
|
466
470
|
await engine.stop();
|
|
467
471
|
});
|
|
472
|
+
|
|
473
|
+
test("onJobFinished fires once per finishJob for succeeded, failed-retry, and dead", async () => {
|
|
474
|
+
const succeeded: LocalJobFinishedEvent[] = [];
|
|
475
|
+
const okStore = memoryJobs();
|
|
476
|
+
const ok = engineFor(okStore, [{ name: "digest", def: jobDef(() => undefined) }], [], (event) =>
|
|
477
|
+
succeeded.push(event),
|
|
478
|
+
);
|
|
479
|
+
await ok.enqueue({ name: "digest", payload: { n: 1 } });
|
|
480
|
+
await ok.tickJobs();
|
|
481
|
+
await waitUntil(() => succeeded.length === 1);
|
|
482
|
+
assert.equal(succeeded.length, 1);
|
|
483
|
+
assert.equal(succeeded[0]?.outcome, "succeeded");
|
|
484
|
+
assert.equal(succeeded[0]?.engineStatus, "succeeded");
|
|
485
|
+
assert.equal(succeeded[0]?.error, null);
|
|
486
|
+
assert.equal(succeeded[0]?.name, "digest");
|
|
487
|
+
assert.deepEqual(succeeded[0]?.payload, { n: 1 });
|
|
488
|
+
|
|
489
|
+
const retried: LocalJobFinishedEvent[] = [];
|
|
490
|
+
const retryStore = memoryJobs();
|
|
491
|
+
const retry = engineFor(
|
|
492
|
+
retryStore,
|
|
493
|
+
[
|
|
494
|
+
{
|
|
495
|
+
name: "digest",
|
|
496
|
+
def: jobDef(() => {
|
|
497
|
+
throw new Error("boom");
|
|
498
|
+
}),
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
[],
|
|
502
|
+
(event) => retried.push(event),
|
|
503
|
+
);
|
|
504
|
+
await retry.enqueue({ name: "digest", payload: { n: 2 } });
|
|
505
|
+
await retry.tickJobs();
|
|
506
|
+
await waitUntil(() => retried.length === 1);
|
|
507
|
+
assert.equal(retried.length, 1);
|
|
508
|
+
assert.equal(retried[0]?.outcome, "failed");
|
|
509
|
+
assert.equal(retried[0]?.engineStatus, "queued");
|
|
510
|
+
assert.equal(retried[0]?.error, "boom");
|
|
511
|
+
assert.equal(retried[0]?.attempts, 1);
|
|
512
|
+
assert.deepEqual(retried[0]?.payload, { n: 2 });
|
|
513
|
+
|
|
514
|
+
const finished: LocalJobFinishedEvent[] = [];
|
|
515
|
+
const deadStore = memoryJobs();
|
|
516
|
+
const dying = engineFor(
|
|
517
|
+
deadStore,
|
|
518
|
+
[
|
|
519
|
+
{
|
|
520
|
+
name: "digest",
|
|
521
|
+
def: jobDef(() => {
|
|
522
|
+
throw new Error("boom");
|
|
523
|
+
}),
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
[],
|
|
527
|
+
(event) => finished.push(event),
|
|
528
|
+
);
|
|
529
|
+
await dying.enqueue({ name: "digest" });
|
|
530
|
+
for (let attempt = 1; attempt <= JOB_MAX_ATTEMPTS; attempt++) {
|
|
531
|
+
deadStore.jobs[0]!.run_at = new Date(0);
|
|
532
|
+
deadStore.jobs[0]!.status = "queued";
|
|
533
|
+
await dying.tickJobs();
|
|
534
|
+
await waitUntil(() => finished.length === attempt);
|
|
535
|
+
}
|
|
536
|
+
assert.equal(finished.length, JOB_MAX_ATTEMPTS);
|
|
537
|
+
assert.equal(finished.at(-1)?.outcome, "failed");
|
|
538
|
+
assert.equal(finished.at(-1)?.engineStatus, "dead");
|
|
539
|
+
assert.equal(finished.at(-1)?.attempts, JOB_MAX_ATTEMPTS);
|
|
540
|
+
assert.equal(finished.at(-1)?.error, "boom");
|
|
541
|
+
});
|
package/src/local-jobs.ts
CHANGED
|
@@ -49,11 +49,23 @@ export type LocalJobsGeneration = {
|
|
|
49
49
|
jobs: readonly { name: string; file?: string; def: JobDefinition }[];
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
export type LocalJobFinishedEvent = {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
payload: unknown;
|
|
56
|
+
outcome: "succeeded" | "failed";
|
|
57
|
+
engineStatus: "succeeded" | "queued" | "dead";
|
|
58
|
+
attempts: number;
|
|
59
|
+
error: string | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
52
62
|
export type LocalJobsEngineOptions = {
|
|
53
63
|
query: JobsQueryable["query"];
|
|
54
64
|
getGeneration: () => LocalJobsGeneration | null;
|
|
55
65
|
log?: (line: string) => void;
|
|
56
66
|
clients: () => RouteClients;
|
|
67
|
+
/** Invoked after the job row UPDATE. Failed-retry (`queued`) and `dead` both report `failed`. */
|
|
68
|
+
onJobFinished?: (event: LocalJobFinishedEvent) => void;
|
|
57
69
|
};
|
|
58
70
|
|
|
59
71
|
export type LocalJobsEngine = {
|
|
@@ -221,6 +233,8 @@ export function createLocalJobsEngine(options: LocalJobsEngineOptions): LocalJob
|
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
async function finishJob(row: LocalJobRow, error: string | null): Promise<void> {
|
|
236
|
+
let engineStatus: LocalJobFinishedEvent["engineStatus"];
|
|
237
|
+
let attempts = row.attempts;
|
|
224
238
|
if (!error) {
|
|
225
239
|
await query(
|
|
226
240
|
`UPDATE robodev_jobs.jobs
|
|
@@ -229,21 +243,38 @@ export function createLocalJobsEngine(options: LocalJobsEngineOptions): LocalJob
|
|
|
229
243
|
[row.id],
|
|
230
244
|
);
|
|
231
245
|
log(` ${row.name} succeeded`);
|
|
232
|
-
|
|
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)}`);
|
|
246
|
+
engineStatus = "succeeded";
|
|
244
247
|
} else {
|
|
245
|
-
|
|
248
|
+
attempts = row.attempts + 1;
|
|
249
|
+
const next = nextJobFailureState(attempts);
|
|
250
|
+
await query(
|
|
251
|
+
`UPDATE robodev_jobs.jobs
|
|
252
|
+
SET status = $2, attempts = $3, last_error = $4, run_at = COALESCE($5, run_at), updated_at = now()
|
|
253
|
+
WHERE id = $1`,
|
|
254
|
+
[row.id, next.status, attempts, error, next.runAt],
|
|
255
|
+
);
|
|
256
|
+
engineStatus = next.status;
|
|
257
|
+
if (next.status === "dead") {
|
|
258
|
+
log(` ${row.name} dead: ${shortError(error)}`);
|
|
259
|
+
} else {
|
|
260
|
+
log(` ${row.name} failed attempt ${attempts}/${JOB_MAX_ATTEMPTS}: ${shortError(error)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
let payload: unknown = null;
|
|
264
|
+
try {
|
|
265
|
+
payload = JSON.parse(row.payload_json) as unknown;
|
|
266
|
+
} catch {
|
|
267
|
+
payload = null;
|
|
246
268
|
}
|
|
269
|
+
options.onJobFinished?.({
|
|
270
|
+
id: row.id,
|
|
271
|
+
name: row.name,
|
|
272
|
+
payload,
|
|
273
|
+
outcome: error ? "failed" : "succeeded",
|
|
274
|
+
engineStatus,
|
|
275
|
+
attempts,
|
|
276
|
+
error,
|
|
277
|
+
});
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
async function runClaimedJob(
|