@dudousxd/nestjs-agent-store-drizzle 0.8.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.
package/README.md CHANGED
@@ -36,9 +36,58 @@ export class AppModule {}
36
36
  ```
37
37
 
38
38
  The package ships the `agentSchema` (Drizzle tables), `ensureAgentSchema` (a non-destructive
39
- `CREATE TABLE IF NOT EXISTS` helper for a quick start), `DrizzleAgentStore`, and
40
- `DrizzleGovernanceQueries`. For production, prefer your normal drizzle-kit migrations over the
41
- `ensureAgentSchema` helper.
39
+ DDL helper for a quick start), `DrizzleAgentStore`, and `DrizzleGovernanceQueries`. For production,
40
+ prefer your normal drizzle-kit migrations over the `ensureAgentSchema` helper.
41
+
42
+ ## The read a turn makes
43
+
44
+ The agent loop does not call `getThread` to build a prompt. This store implements the core SPI's
45
+ `ThreadTurnReader`, so the loop asks it for `loadThreadForTurn({ threadId, messageLimit })` instead:
46
+ the thread's newest `messageLimit` messages oldest-first, bounded by the database rather than in
47
+ memory, projected to the columns a model turn reads (`usage`, `follow_ups` and `run_id` stay in the
48
+ table), plus the title, the default agent, and whether the thread has EVER been answered.
49
+
50
+ `messageLimit` is the configured `HistoryPolicy.maxMessages`. A policy that summarizes, or whose
51
+ ceiling is only a token budget, names no row bound and the whole thread is read — see the core
52
+ README. Nothing to wire: the loop probes for the method, and `getThread` remains the right read for
53
+ a client rendering a transcript.
54
+
55
+ ## Upgrading a database that already exists
56
+
57
+ `ensureAgentSchema` is additive only — `CREATE TABLE IF NOT EXISTS` plus `CREATE INDEX IF NOT
58
+ EXISTS`, and an explicit add-column pass for columns this package introduced after a table shipped,
59
+ since the `CREATE TABLE` guard is inert against a table that already exists. Calling it on boot is
60
+ enough; it never drops a column or changes a type.
61
+
62
+ On your own drizzle-kit migrations, those additive statements are yours to write:
63
+
64
+ ```sql
65
+ ALTER TABLE agent_thread ADD COLUMN default_agent TEXT;
66
+ ALTER TABLE agent_message ADD COLUMN run_id TEXT;
67
+ ALTER TABLE agent_message ADD COLUMN attachments TEXT;
68
+ ALTER TABLE agent_run ADD COLUMN parent_run_id TEXT;
69
+ CREATE INDEX agent_tool_call_message_idx ON agent_tool_call (message_id);
70
+ ```
71
+
72
+ The index is not optional on Postgres: `agent_tool_call.message_id` carries a foreign key, and
73
+ Postgres — unlike MySQL — does not index one for you, while both message-scoped reads (the thread
74
+ reader's `IN (…)` and `truncateFrom`'s delete) filter on it.
75
+
76
+ ## Attachment housekeeping
77
+
78
+ `referencedMediaIds(actorRef, mediaIds)` answers which of a set of media ids a message that still
79
+ exists carries, for one actor — the inverse of the host's own staged-media inventory, and the half a
80
+ sweep cannot work out for itself. It needs **no schema change**: it reads the `attachments` JSON
81
+ column messages have carried since attachments shipped, so an existing database answers correctly
82
+ the moment you upgrade, with nothing to backfill.
83
+
84
+ The match happens in memory rather than in SQL. The column holds an array of objects, every dialect
85
+ spells that query differently, and none of them can use an index for it; the scan is bounded to one
86
+ actor's attachment-bearing messages, and a message with no attachments never leaves the database.
87
+
88
+ A thread that was soft-deleted still counts as holding its references: the message rows survive, so
89
+ the bytes are still reachable from stored state. Delete the thread for real and the cascade makes
90
+ them collectable on the next sweep.
42
91
 
43
92
  ## Cost accounting
44
93
 
package/dist/index.cjs CHANGED
@@ -47,6 +47,8 @@ var agentThread = (0, import_sqlite_core.sqliteTable)("agent_thread", {
47
47
  mode: "boolean"
48
48
  }).notNull().default(false),
49
49
  activeStreamId: (0, import_sqlite_core.text)("active_stream_id"),
50
+ /** The agent a new turn on this thread defaults to when the caller names none. */
51
+ defaultAgent: (0, import_sqlite_core.text)("default_agent"),
50
52
  createdAt: (0, import_sqlite_core.integer)("created_at", {
51
53
  mode: "timestamp_ms"
52
54
  }).notNull(),
@@ -72,6 +74,9 @@ var agentMessage = (0, import_sqlite_core.sqliteTable)("agent_message", {
72
74
  toolResults: (0, import_sqlite_core.text)("tool_results", {
73
75
  mode: "json"
74
76
  }).$type(),
77
+ attachments: (0, import_sqlite_core.text)("attachments", {
78
+ mode: "json"
79
+ }).$type(),
75
80
  followUps: (0, import_sqlite_core.text)("follow_ups", {
76
81
  mode: "json"
77
82
  }).$type(),
@@ -79,6 +84,7 @@ var agentMessage = (0, import_sqlite_core.sqliteTable)("agent_message", {
79
84
  mode: "json"
80
85
  }).$type(),
81
86
  agentName: (0, import_sqlite_core.text)("agent_name"),
87
+ runId: (0, import_sqlite_core.text)("run_id"),
82
88
  createdAt: (0, import_sqlite_core.integer)("created_at", {
83
89
  mode: "timestamp_ms"
84
90
  }).notNull()
@@ -110,7 +116,9 @@ var agentToolCall = (0, import_sqlite_core.sqliteTable)("agent_tool_call", {
110
116
  }),
111
117
  /** The run (turn) this call belongs to, for a trace deep-link; null for a pre-rollout row. */
112
118
  runId: (0, import_sqlite_core.text)("run_id")
113
- });
119
+ }, (table) => [
120
+ (0, import_sqlite_core.index)("agent_tool_call_message_idx").on(table.messageId)
121
+ ]);
114
122
  var agentTokenUsage = (0, import_sqlite_core.sqliteTable)("agent_token_usage", {
115
123
  id: (0, import_sqlite_core.text)("id").primaryKey(),
116
124
  threadId: (0, import_sqlite_core.text)("thread_id").notNull().references(() => agentThread.id, {
@@ -169,7 +177,15 @@ var agentRun = (0, import_sqlite_core.sqliteTable)("agent_run", {
169
177
  mode: "timestamp_ms"
170
178
  }),
171
179
  /** sha256 hex of the run's resolved (pre-RAG) system prompt; null for a pre-existing run. */
172
- promptHash: (0, import_sqlite_core.text)("prompt_hash")
180
+ promptHash: (0, import_sqlite_core.text)("prompt_hash"),
181
+ /**
182
+ * The run that delegated this one; null for a turn nobody delegated. The durable runtime's
183
+ * journal holds the same edge, but only there — this column is what lets a reader of run ROWS
184
+ * build the delegation tree and roll a child's cost up to the turn that asked for it, including
185
+ * for a detached child, which outlives its parent's turn and so is paired by nothing in the
186
+ * transcript.
187
+ */
188
+ parentRunId: (0, import_sqlite_core.text)("parent_run_id")
173
189
  }, (table) => [
174
190
  (0, import_sqlite_core.index)("agent_run_started_idx").on(table.startedAt)
175
191
  ]);
@@ -192,6 +208,7 @@ var statements = [
192
208
  title TEXT NOT NULL,
193
209
  transient INTEGER NOT NULL DEFAULT 0,
194
210
  active_stream_id TEXT,
211
+ default_agent TEXT,
195
212
  created_at INTEGER NOT NULL,
196
213
  updated_at INTEGER NOT NULL,
197
214
  deleted_at INTEGER
@@ -205,9 +222,11 @@ var statements = [
205
222
  content TEXT NOT NULL,
206
223
  tool_calls TEXT,
207
224
  tool_results TEXT,
225
+ attachments TEXT,
208
226
  follow_ups TEXT,
209
227
  usage TEXT,
210
228
  agent_name TEXT,
229
+ run_id TEXT,
211
230
  created_at INTEGER NOT NULL
212
231
  )`,
213
232
  `CREATE INDEX IF NOT EXISTS agent_message_thread_created_idx
@@ -227,6 +246,8 @@ var statements = [
227
246
  executed_at INTEGER,
228
247
  run_id TEXT
229
248
  )`,
249
+ `CREATE INDEX IF NOT EXISTS agent_tool_call_message_idx
250
+ ON agent_tool_call (message_id)`,
230
251
  `CREATE TABLE IF NOT EXISTS agent_token_usage (
231
252
  id TEXT PRIMARY KEY NOT NULL,
232
253
  thread_id TEXT NOT NULL REFERENCES agent_thread(id) ON DELETE CASCADE,
@@ -265,20 +286,59 @@ var statements = [
265
286
  retries INTEGER NOT NULL DEFAULT 0,
266
287
  started_at INTEGER NOT NULL,
267
288
  settled_at INTEGER,
268
- prompt_hash TEXT
289
+ prompt_hash TEXT,
290
+ parent_run_id TEXT
269
291
  )`,
270
292
  `CREATE INDEX IF NOT EXISTS agent_run_started_idx
271
293
  ON agent_run (started_at)`
272
294
  ];
295
+ var additiveColumns = [
296
+ {
297
+ table: "agent_message",
298
+ column: "run_id",
299
+ ddl: "ALTER TABLE agent_message ADD COLUMN run_id TEXT"
300
+ },
301
+ {
302
+ table: "agent_message",
303
+ column: "attachments",
304
+ ddl: "ALTER TABLE agent_message ADD COLUMN attachments TEXT"
305
+ },
306
+ {
307
+ table: "agent_thread",
308
+ column: "default_agent",
309
+ ddl: "ALTER TABLE agent_thread ADD COLUMN default_agent TEXT"
310
+ },
311
+ {
312
+ table: "agent_run",
313
+ column: "parent_run_id",
314
+ ddl: "ALTER TABLE agent_run ADD COLUMN parent_run_id TEXT"
315
+ }
316
+ ];
273
317
  async function ensureAgentSchema(db) {
274
318
  for (const statement of statements) {
275
319
  await db.run(import_drizzle_orm.sql.raw(statement));
276
320
  }
321
+ for (const { table, column, ddl } of additiveColumns) {
322
+ const existing = await db.all(import_drizzle_orm.sql.raw(`PRAGMA table_info(${table})`));
323
+ if (!existing.some((row) => row.name === column)) {
324
+ await db.run(import_drizzle_orm.sql.raw(ddl));
325
+ }
326
+ }
277
327
  }
278
328
  __name(ensureAgentSchema, "ensureAgentSchema");
279
329
 
280
330
  // src/drizzle-agent-store.ts
281
331
  var import_drizzle_orm2 = require("drizzle-orm");
332
+ var TURN_MESSAGE_COLUMNS = {
333
+ id: agentMessage.id,
334
+ role: agentMessage.role,
335
+ content: agentMessage.content,
336
+ agentName: agentMessage.agentName,
337
+ toolCalls: agentMessage.toolCalls,
338
+ toolResults: agentMessage.toolResults,
339
+ attachments: agentMessage.attachments,
340
+ createdAt: agentMessage.createdAt
341
+ };
282
342
  var DrizzleAgentStore = class {
283
343
  static {
284
344
  __name(this, "DrizzleAgentStore");
@@ -296,6 +356,7 @@ var DrizzleAgentStore = class {
296
356
  title: input.title ?? "New chat",
297
357
  transient: input.transient ?? false,
298
358
  activeStreamId: null,
359
+ defaultAgent: null,
299
360
  createdAt: now,
300
361
  updatedAt: now,
301
362
  deletedAt: null
@@ -318,6 +379,50 @@ var DrizzleAgentStore = class {
318
379
  } : {}
319
380
  };
320
381
  }
382
+ /**
383
+ * The window a turn reads off a thread: its newest `messageLimit` messages (oldest first), the
384
+ * title, the default agent, and whether the thread has EVER been answered.
385
+ *
386
+ * {@link getThread} is the wrong read for a turn. It materializes the transcript — every message,
387
+ * every attachment, every tool output the thread ever recorded — and the run then journals what it
388
+ * loaded, so a long thread pays for its whole history on every turn and again on every replay.
389
+ *
390
+ * `hasAssistantMessage` is answered over the WHOLE thread, not the returned page: it answers "has
391
+ * this conversation been answered before?", and a thread whose window happens to hold only the
392
+ * user's last questions has still been answered. `null` when the thread is unknown or
393
+ * soft-deleted, same as {@link getThread}.
394
+ */
395
+ async loadThreadForTurn(query) {
396
+ const [thread] = await this.db.select({
397
+ title: agentThread.title,
398
+ defaultAgent: agentThread.defaultAgent
399
+ }).from(agentThread).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(agentThread.id, query.threadId), (0, import_drizzle_orm2.isNull)(agentThread.deletedAt)));
400
+ if (thread === void 0) {
401
+ return null;
402
+ }
403
+ const messages = await this.loadTurnWindow(query.threadId, query.messageLimit);
404
+ const [answered] = await this.db.select({
405
+ id: agentMessage.id
406
+ }).from(agentMessage).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(agentMessage.threadId, query.threadId), (0, import_drizzle_orm2.eq)(agentMessage.role, "assistant"))).limit(1);
407
+ return {
408
+ title: thread.title,
409
+ defaultAgent: thread.defaultAgent,
410
+ hasAssistantMessage: answered !== void 0,
411
+ messages
412
+ };
413
+ }
414
+ /**
415
+ * The newest `limit` messages of a thread, oldest first, projected to the columns a model turn
416
+ * reads. Read newest-first so the LIMIT is the database's job, then reversed for the prompt.
417
+ */
418
+ async loadTurnWindow(threadId, limit) {
419
+ if (limit !== void 0 && limit <= 0) {
420
+ return [];
421
+ }
422
+ const window = this.db.select(TURN_MESSAGE_COLUMNS).from(agentMessage).where((0, import_drizzle_orm2.eq)(agentMessage.threadId, threadId)).orderBy((0, import_drizzle_orm2.desc)(agentMessage.createdAt), (0, import_drizzle_orm2.desc)(agentMessage.id));
423
+ const rows = limit === void 0 ? await window : await window.limit(limit);
424
+ return rows.reverse().map((row) => this.toStoredMessage(row));
425
+ }
321
426
  async listThreads(actorRef, limit = 50) {
322
427
  const threads = await this.db.select().from(agentThread).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(agentThread.actorRef, actorRef), (0, import_drizzle_orm2.eq)(agentThread.transient, false), (0, import_drizzle_orm2.isNull)(agentThread.deletedAt))).orderBy((0, import_drizzle_orm2.desc)(agentThread.updatedAt)).limit(limit);
323
428
  const summaries = [];
@@ -348,6 +453,7 @@ var DrizzleAgentStore = class {
348
453
  title: source.title,
349
454
  transient: false,
350
455
  activeStreamId: null,
456
+ defaultAgent: source.defaultAgent,
351
457
  createdAt: now,
352
458
  updatedAt: now,
353
459
  deletedAt: null
@@ -361,9 +467,14 @@ var DrizzleAgentStore = class {
361
467
  content: message.content,
362
468
  toolCalls: message.toolCalls,
363
469
  toolResults: message.toolResults,
470
+ attachments: message.attachments,
364
471
  followUps: message.followUps,
365
472
  usage: message.usage,
366
473
  agentName: message.agentName,
474
+ // The copy is the same message, so it keeps the run that wrote it. A reader asking which
475
+ // turn produced this text gets the truthful answer; the run's own thread is still the
476
+ // original, so a run-scoped read never picks the fork's rows up.
477
+ runId: message.runId,
367
478
  createdAt: message.createdAt
368
479
  })));
369
480
  }
@@ -383,9 +494,10 @@ var DrizzleAgentStore = class {
383
494
  }
384
495
  async runForToolCall(toolCallId) {
385
496
  const [row] = await this.db.select({
497
+ runId: agentToolCall.runId,
386
498
  activeStreamId: agentThread.activeStreamId
387
499
  }).from(agentToolCall).innerJoin(agentMessage, (0, import_drizzle_orm2.eq)(agentToolCall.messageId, agentMessage.id)).innerJoin(agentThread, (0, import_drizzle_orm2.eq)(agentMessage.threadId, agentThread.id)).where((0, import_drizzle_orm2.eq)(agentToolCall.id, toolCallId));
388
- return row?.activeStreamId ?? null;
500
+ return row?.runId ?? row?.activeStreamId ?? null;
389
501
  }
390
502
  async ownerOfActiveStream(runId) {
391
503
  const [thread] = await this.db.select({
@@ -410,7 +522,43 @@ var DrizzleAgentStore = class {
410
522
  activeStreamId: runId
411
523
  }).where((0, import_drizzle_orm2.eq)(agentThread.id, threadId));
412
524
  }
413
- /** Persist the start of a run (turn). Replay-safe: called under a durable localStep. */
525
+ /**
526
+ * Patch a thread's `title` and/or `defaultAgent`. Each field is only touched when PRESENT in
527
+ * `patch` — `defaultAgent: null` clears it (falls back to the app default), while an omitted
528
+ * `defaultAgent` leaves whatever is stored untouched. A no-op (including `updatedAt`) when the
529
+ * patch is empty or the thread doesn't exist.
530
+ */
531
+ async updateThread(threadId, patch) {
532
+ const updates = {};
533
+ if (patch.title !== void 0) {
534
+ updates.title = patch.title;
535
+ }
536
+ if (patch.defaultAgent !== void 0) {
537
+ updates.defaultAgent = patch.defaultAgent;
538
+ }
539
+ if (Object.keys(updates).length === 0) {
540
+ return;
541
+ }
542
+ updates.updatedAt = /* @__PURE__ */ new Date();
543
+ await this.db.update(agentThread).set(updates).where((0, import_drizzle_orm2.eq)(agentThread.id, threadId));
544
+ }
545
+ /**
546
+ * The thread's default agent, projected — the caller wants one nullable scalar to decide which
547
+ * agent answers the next turn, and {@link getThread} would materialize the whole transcript to
548
+ * hand it over. `null` when the thread is unknown, soft-deleted, or has no default set.
549
+ */
550
+ async defaultAgentForThread(threadId) {
551
+ const [thread] = await this.db.select({
552
+ defaultAgent: agentThread.defaultAgent
553
+ }).from(agentThread).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(agentThread.id, threadId), (0, import_drizzle_orm2.isNull)(agentThread.deletedAt)));
554
+ return thread?.defaultAgent ?? null;
555
+ }
556
+ /**
557
+ * Persist the start of a run (turn). Replay-safe: called under a durable localStep.
558
+ *
559
+ * Takes the SPI's own {@link RecordRunStartInput} rather than a hand-copied shape: a field added
560
+ * to the input is otherwise accepted and dropped, silently, by every adapter that re-declares it.
561
+ */
414
562
  async recordRunStart(run) {
415
563
  const runRow = {
416
564
  id: run.runId,
@@ -424,7 +572,8 @@ var DrizzleAgentStore = class {
424
572
  retries: 0,
425
573
  startedAt: /* @__PURE__ */ new Date(),
426
574
  settledAt: null,
427
- promptHash: run.promptHash ?? null
575
+ promptHash: run.promptHash ?? null,
576
+ parentRunId: run.parentRunId ?? null
428
577
  };
429
578
  await this.db.insert(agentRun).values(runRow);
430
579
  }
@@ -464,9 +613,11 @@ var DrizzleAgentStore = class {
464
613
  content: input.content,
465
614
  toolCalls: input.toolCalls ?? null,
466
615
  toolResults: input.toolResults ?? null,
616
+ attachments: input.attachments ?? null,
467
617
  followUps: input.followUps ?? null,
468
618
  usage: input.usage ?? null,
469
619
  agentName: input.agentName ?? null,
620
+ runId: input.runId ?? null,
470
621
  createdAt: now
471
622
  };
472
623
  await this.db.insert(agentMessage).values(message);
@@ -475,6 +626,11 @@ var DrizzleAgentStore = class {
475
626
  }).where((0, import_drizzle_orm2.eq)(agentThread.id, input.threadId));
476
627
  return this.toStoredMessage(message);
477
628
  }
629
+ async setMessageToolResults(messageId, results) {
630
+ await this.db.update(agentMessage).set({
631
+ toolResults: results
632
+ }).where((0, import_drizzle_orm2.eq)(agentMessage.id, messageId));
633
+ }
478
634
  async truncateFrom(threadId, messageId) {
479
635
  const messages = await this.db.select().from(agentMessage).where((0, import_drizzle_orm2.eq)(agentMessage.threadId, threadId)).orderBy((0, import_drizzle_orm2.asc)(agentMessage.createdAt), (0, import_drizzle_orm2.asc)(agentMessage.id));
480
636
  const cutoff = messages.findIndex((message) => message.id === messageId);
@@ -523,6 +679,39 @@ var DrizzleAgentStore = class {
523
679
  }
524
680
  await this.db.update(agentToolCall).set(updates).where((0, import_drizzle_orm2.eq)(agentToolCall.id, input.toolCallId));
525
681
  }
682
+ /**
683
+ * Of `mediaIds`, the ones a surviving message in one of this actor's threads still carries.
684
+ *
685
+ * Reads the `attachments` JSON back out and matches in memory rather than pushing the match into
686
+ * SQL: the column holds an array of objects, and every dialect this adapter's sibling targets
687
+ * spells that query differently (`jsonb` containment, `json_table`, `json_each`), while none of
688
+ * them can use an index for it anyway. The scan is bounded by ONE actor's attachment-bearing
689
+ * messages, which is a small set — attachments are rare, and a message without one is excluded
690
+ * by the `is not null` before any JSON is parsed.
691
+ *
692
+ * Soft-deleted threads are deliberately included: their message rows survive, so the bytes they
693
+ * point at are still reachable from stored state and are not garbage.
694
+ */
695
+ async referencedMediaIds(actorRef, mediaIds) {
696
+ if (mediaIds.length === 0) {
697
+ return [];
698
+ }
699
+ const rows = await this.db.select({
700
+ attachments: agentMessage.attachments
701
+ }).from(agentMessage).innerJoin(agentThread, (0, import_drizzle_orm2.eq)(agentMessage.threadId, agentThread.id)).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(agentThread.actorRef, actorRef), (0, import_drizzle_orm2.isNotNull)(agentMessage.attachments)));
702
+ const wanted = new Set(mediaIds);
703
+ const found = /* @__PURE__ */ new Set();
704
+ for (const row of rows) {
705
+ for (const attachment of row.attachments ?? []) {
706
+ if (wanted.has(attachment.mediaId)) {
707
+ found.add(attachment.mediaId);
708
+ }
709
+ }
710
+ }
711
+ return [
712
+ ...wanted
713
+ ].filter((mediaId) => found.has(mediaId));
714
+ }
526
715
  async recordUsage(input) {
527
716
  await this.db.insert(agentTokenUsage).values({
528
717
  id: crypto.randomUUID(),
@@ -557,6 +746,7 @@ var DrizzleAgentStore = class {
557
746
  transient: thread.transient,
558
747
  createdAt: thread.createdAt.toISOString(),
559
748
  updatedAt: thread.updatedAt.toISOString(),
749
+ defaultAgent: thread.defaultAgent,
560
750
  ...lastContent !== void 0 ? {
561
751
  lastMessagePreview: lastContent.slice(0, 120)
562
752
  } : {}
@@ -574,6 +764,9 @@ var DrizzleAgentStore = class {
574
764
  ...message.toolResults != null ? {
575
765
  toolResults: message.toolResults
576
766
  } : {},
767
+ ...message.attachments != null ? {
768
+ attachments: message.attachments
769
+ } : {},
577
770
  ...message.followUps != null ? {
578
771
  followUps: message.followUps
579
772
  } : {},
@@ -582,6 +775,9 @@ var DrizzleAgentStore = class {
582
775
  } : {},
583
776
  ...message.agentName != null ? {
584
777
  agentName: message.agentName
778
+ } : {},
779
+ ...message.runId != null ? {
780
+ runId: message.runId
585
781
  } : {}
586
782
  };
587
783
  }
@@ -615,7 +811,8 @@ __name(isToolKind, "isToolKind");
615
811
  var RUN_STATUSES = [
616
812
  "running",
617
813
  "completed",
618
- "failed"
814
+ "failed",
815
+ "cancelled"
619
816
  ];
620
817
  function isRunStatus(value) {
621
818
  return RUN_STATUSES.includes(value);
@@ -656,7 +853,8 @@ function toRecentRunRow(run) {
656
853
  errorMessage: run.errorMessage ?? null,
657
854
  retries: run.retries,
658
855
  startedAt: run.startedAt.toISOString(),
659
- promptHash: run.promptHash ?? null
856
+ promptHash: run.promptHash ?? null,
857
+ parentRunId: run.parentRunId ?? null
660
858
  };
661
859
  }
662
860
  __name(toRecentRunRow, "toRecentRunRow");