@robodev-ai/runtime 0.4.0 → 0.4.1

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.1",
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",
package/src/index.ts CHANGED
@@ -218,6 +218,7 @@ export {
218
218
  ensureJobsSchema,
219
219
  type JobsQueryable,
220
220
  type LocalCronRow,
221
+ type LocalJobFinishedEvent,
221
222
  type LocalJobRow,
222
223
  type LocalJobStatus,
223
224
  type LocalJobsEngine,
@@ -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(