@ixo/oracle-runtime-workers 0.4.1 → 0.9.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.
@@ -55,6 +55,23 @@ export function pendingApprovalOf(
55
55
  return undefined;
56
56
  }
57
57
 
58
+ /**
59
+ * Where a fired run is in its life. Written before the agent turn starts
60
+ * (`running`), when the result is stored and the schedule advanced
61
+ * (`delivering`), and when the room has acknowledged the message
62
+ * (`delivered`). A row a later alarm finds in `running` or `delivering`
63
+ * without a live owner belongs to an incarnation of the object that died:
64
+ * `delivering` is re-sent from the stored result, `running` is closed as
65
+ * `interrupted` and never re-run (its tools may have executed). Approval
66
+ * bookkeeping rows carry no state.
67
+ */
68
+ export type TaskRunState =
69
+ | 'running'
70
+ | 'delivering'
71
+ | 'delivered'
72
+ | 'failed'
73
+ | 'interrupted';
74
+
58
75
  /** One audit row per fired run / approval decision. */
59
76
  export interface TaskRunEntry {
60
77
  runId: string;
@@ -63,7 +80,29 @@ export interface TaskRunEntry {
63
80
  finishedAt?: string;
64
81
  /** true = delivered, false = failed, undefined = no run happened (approval bookkeeping). */
65
82
  ok?: boolean;
83
+ /** Technical reason / summary — for operators and the run history, never shown to the user as-is. */
66
84
  detail?: string;
85
+ state?: TaskRunState;
86
+ /** Delivery rounds attempted so far (a round is one retried send sequence). */
87
+ attempts?: number;
88
+ /** Earliest time the next delivery round may start (ms epoch). */
89
+ retryAt?: number;
90
+ }
91
+
92
+ /** A run a previous or current incarnation has not finished: what re-delivery or closure needs. */
93
+ export interface OpenTaskRun {
94
+ runId: string;
95
+ taskId: string;
96
+ startedAt: string;
97
+ state: 'running' | 'delivering';
98
+ /** Transaction id every send of this run's result uses (server-side dedupe). */
99
+ txnId: string;
100
+ /** Where the result goes; resolved before the turn, so a re-delivery needs no gateway lookup. */
101
+ roomId?: string;
102
+ /** The finished result, once the run reached `delivering`. */
103
+ resultText?: string;
104
+ attempts: number;
105
+ retryAt?: number;
67
106
  }
68
107
 
69
108
  type TaskRow = {
@@ -90,8 +129,33 @@ type RunRow = {
90
129
  finished_at: string | null;
91
130
  ok: number | null;
92
131
  detail: string | null;
132
+ state: string | null;
133
+ attempts: number | null;
134
+ retry_at: number | bigint | null;
135
+ };
136
+
137
+ type OpenRunRow = {
138
+ run_id: string;
139
+ task_id: string;
140
+ started_at: string;
141
+ state: string;
142
+ txn_id: string | null;
143
+ room_id: string | null;
144
+ result_text: string | null;
145
+ attempts: number | null;
146
+ retry_at: number | bigint | null;
93
147
  };
94
148
 
149
+ /** Columns added after the first release; added to existing files on setup. */
150
+ const RUN_COLUMN_UPGRADES: ReadonlyArray<readonly [string, string]> = [
151
+ ['state', 'TEXT'],
152
+ ['txn_id', 'TEXT'],
153
+ ['room_id', 'TEXT'],
154
+ ['result_text', 'TEXT'],
155
+ ['attempts', 'INTEGER NOT NULL DEFAULT 0'],
156
+ ['retry_at', 'INTEGER'],
157
+ ];
158
+
95
159
  const TASK_COLUMNS = `id, title, spec, schedule_json, status, approval, created_at,
96
160
  updated_at, next_run_at, last_run_at, last_result_json, consecutive_failures, pending_approval_at,
97
161
  delivery_room_id`;
@@ -237,6 +301,23 @@ export class TasksStore {
237
301
  await this.db.run(
238
302
  `CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs(task_id, started_at)`,
239
303
  );
304
+ // Files written before the run ledger existed lack these columns. One
305
+ // metadata read per object boot; the ALTERs run once per file, ever.
306
+ const present = new Set(
307
+ (
308
+ await this.db.exec<{ name: string }>(`PRAGMA table_info(task_runs)`)
309
+ ).map((row) => row.name),
310
+ );
311
+ for (const [name, ddl] of RUN_COLUMN_UPGRADES) {
312
+ if (!present.has(name))
313
+ await this.db.run(`ALTER TABLE task_runs ADD COLUMN ${name} ${ddl}`);
314
+ }
315
+ // The open-run scan on every alarm touches only the (normally empty)
316
+ // set of unfinished rows, never the whole audit trail.
317
+ await this.db.run(
318
+ `CREATE INDEX IF NOT EXISTS idx_task_runs_open ON task_runs(state)
319
+ WHERE state IN ('running', 'delivering')`,
320
+ );
240
321
  }
241
322
 
242
323
  async insert(record: TaskRecord): Promise<void> {
@@ -315,8 +396,8 @@ export class TasksStore {
315
396
  async recordRun(entry: TaskRunEntry): Promise<void> {
316
397
  await this.setup();
317
398
  await this.db.run(
318
- `INSERT INTO task_runs (run_id, task_id, started_at, finished_at, ok, detail)
319
- VALUES (?, ?, ?, ?, ?, ?)`,
399
+ `INSERT INTO task_runs (run_id, task_id, started_at, finished_at, ok, detail, state, attempts, retry_at)
400
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
320
401
  [
321
402
  entry.runId,
322
403
  entry.taskId,
@@ -324,15 +405,126 @@ export class TasksStore {
324
405
  entry.finishedAt ?? null,
325
406
  entry.ok === undefined ? null : entry.ok,
326
407
  entry.detail ?? null,
408
+ entry.state ?? null,
409
+ entry.attempts ?? 0,
410
+ entry.retryAt ?? null,
327
411
  ],
328
412
  );
329
413
  }
330
414
 
415
+ /**
416
+ * Open a run's ledger row before its agent turn starts — one row write.
417
+ * `txnId` is fixed here so every later send of the result, by this or a
418
+ * later incarnation, carries the same id.
419
+ */
420
+ async startRun(run: {
421
+ runId: string;
422
+ taskId: string;
423
+ startedAt: string;
424
+ txnId: string;
425
+ }): Promise<void> {
426
+ await this.setup();
427
+ await this.db.run(
428
+ `INSERT INTO task_runs (run_id, task_id, started_at, state, txn_id, attempts)
429
+ VALUES (?, ?, ?, 'running', ?, 0)`,
430
+ [run.runId, run.taskId, run.startedAt, run.txnId],
431
+ );
432
+ }
433
+
434
+ /** Move a run's row along its life (one row write; only the given fields change). */
435
+ async updateRun(
436
+ runId: string,
437
+ patch: {
438
+ state?: TaskRunState;
439
+ roomId?: string;
440
+ resultText?: string;
441
+ finishedAt?: string;
442
+ ok?: boolean;
443
+ detail?: string;
444
+ attempts?: number;
445
+ retryAt?: number | null;
446
+ },
447
+ ): Promise<void> {
448
+ await this.setup();
449
+ const sets: string[] = [];
450
+ const params: Array<string | number | null> = [];
451
+ if (patch.state !== undefined) {
452
+ sets.push('state = ?');
453
+ params.push(patch.state);
454
+ }
455
+ if (patch.roomId !== undefined) {
456
+ sets.push('room_id = ?');
457
+ params.push(patch.roomId);
458
+ }
459
+ if (patch.resultText !== undefined) {
460
+ sets.push('result_text = ?');
461
+ params.push(patch.resultText);
462
+ }
463
+ if (patch.finishedAt !== undefined) {
464
+ sets.push('finished_at = ?');
465
+ params.push(patch.finishedAt);
466
+ }
467
+ if (patch.ok !== undefined) {
468
+ sets.push('ok = ?');
469
+ params.push(patch.ok ? 1 : 0);
470
+ }
471
+ if (patch.detail !== undefined) {
472
+ sets.push('detail = ?');
473
+ params.push(patch.detail);
474
+ }
475
+ if (patch.attempts !== undefined) {
476
+ sets.push('attempts = ?');
477
+ params.push(patch.attempts);
478
+ }
479
+ if (patch.retryAt !== undefined) {
480
+ sets.push('retry_at = ?');
481
+ params.push(patch.retryAt);
482
+ }
483
+ if (sets.length === 0) return;
484
+ params.push(runId);
485
+ await this.db.run(
486
+ `UPDATE task_runs SET ${sets.join(', ')} WHERE run_id = ?`,
487
+ params,
488
+ );
489
+ }
490
+
491
+ /** Runs no incarnation has finished, oldest first (normally none — an indexed read). */
492
+ async openRuns(): Promise<OpenTaskRun[]> {
493
+ await this.setup();
494
+ const rows = await this.db.exec<OpenRunRow>(
495
+ `SELECT run_id, task_id, started_at, state, txn_id, room_id, result_text, attempts, retry_at
496
+ FROM task_runs WHERE state IN ('running', 'delivering')
497
+ ORDER BY started_at, run_id`,
498
+ );
499
+ return rows.map((row) => ({
500
+ runId: row.run_id,
501
+ taskId: row.task_id,
502
+ startedAt: row.started_at,
503
+ state: row.state === 'delivering' ? 'delivering' : 'running',
504
+ txnId: row.txn_id ?? `task-${row.run_id}`,
505
+ ...(row.room_id ? { roomId: row.room_id } : {}),
506
+ ...(row.result_text !== null ? { resultText: row.result_text } : {}),
507
+ attempts: Number(row.attempts ?? 0),
508
+ ...(row.retry_at !== null ? { retryAt: Number(row.retry_at) } : {}),
509
+ }));
510
+ }
511
+
512
+ /** Earliest pending delivery retry (ms epoch) over open runs, or null. */
513
+ async minRetryAt(): Promise<number | null> {
514
+ await this.setup();
515
+ const row = await this.db.get<{ next: number | bigint | null }>(
516
+ `SELECT MIN(retry_at) AS next FROM task_runs
517
+ WHERE state = 'delivering' AND retry_at IS NOT NULL`,
518
+ );
519
+ if (row === undefined || row.next === null) return null;
520
+ return Number(row.next);
521
+ }
522
+
331
523
  /** Audit trail for one task, newest first. */
332
524
  async listRuns(taskId: string, limit = 20): Promise<TaskRunEntry[]> {
333
525
  await this.setup();
334
526
  const rows = await this.db.exec<RunRow>(
335
- `SELECT run_id, task_id, started_at, finished_at, ok, detail
527
+ `SELECT run_id, task_id, started_at, finished_at, ok, detail, state, attempts, retry_at
336
528
  FROM task_runs WHERE task_id = ? ORDER BY started_at DESC, run_id DESC LIMIT ?`,
337
529
  [taskId, limit],
338
530
  );
@@ -345,6 +537,9 @@ export class TasksStore {
345
537
  if (row.finished_at !== null) entry.finishedAt = row.finished_at;
346
538
  if (row.ok !== null) entry.ok = row.ok !== 0;
347
539
  if (row.detail !== null) entry.detail = row.detail;
540
+ if (row.state !== null) entry.state = row.state as TaskRunState;
541
+ if (row.attempts !== null) entry.attempts = Number(row.attempts);
542
+ if (row.retry_at !== null) entry.retryAt = Number(row.retry_at);
348
543
  return entry;
349
544
  });
350
545
  }