@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robodev-ai/runtime",
3
- "version": "0.4.0",
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.0"
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("&", "&amp;")
39
+ .replaceAll("<", "&lt;")
40
+ .replaceAll(">", "&gt;")
41
+ .replaceAll('"', "&quot;")
42
+ .replaceAll("'", "&#39;");
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(/&nbsp;/gi, " ")
51
+ .replace(/&amp;/g, "&")
52
+ .replace(/&lt;/g, "<")
53
+ .replace(/&gt;/g, ">")
54
+ .replace(/&quot;/g, '"')
55
+ .replace(/&#39;/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, and the offline `robodev dev` jobs engine.
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,
@@ -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
- 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)}`);
246
+ engineStatus = "succeeded";
244
247
  } else {
245
- log(` ${row.name} failed attempt ${attempts}/${JOB_MAX_ATTEMPTS}: ${shortError(error)}`);
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(