@ixo/oracle-runtime-workers 0.37.2 → 0.38.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.
@@ -14,6 +14,7 @@
14
14
  * messages(thread_id, checkpoint_ns, checkpoint_id, message_id PK,
15
15
  * message_type, message_content, message BLOB, created_at)
16
16
  * schema_migrations(version PK, name, applied_at)
17
+ * attachment_payload_sweeps(thread_id PK, swept_at)
17
18
  *
18
19
  * Behaviour kept from the original:
19
20
  * - `channel_values.messages` is stripped from the stored checkpoint and each
@@ -25,6 +26,18 @@
25
26
  * - `pending_sends` migration for pre-v4 checkpoints.
26
27
  * - Serialization is `JsonPlusSerializer` (the `BaseCheckpointSaver` default).
27
28
  *
29
+ * Attachment bytes are never stored: every write path (message rows, the
30
+ * checkpoint blob's channel values, pending writes) stores a human message
31
+ * that carries inline base64 blocks as its placeholder copy
32
+ * (`inline-payloads.ts`), while the run's in-memory state keeps the blocks.
33
+ * Whatever reads the database back — the next turn, a durable run resumed
34
+ * after a reset, the transcript, the owner copy — sees the placeholder.
35
+ * Human message rows written before that (with their bytes) are rewritten
36
+ * once per thread, on the thread's first load (`sweepInlinePayloads`; the
37
+ * thread is then recorded in `attachment_payload_sweeps`). A write whose
38
+ * serialized form still holds a payload block is logged
39
+ * (`containsInlinePayload`): tool and AI messages are never stripped.
40
+ *
28
41
  * Differences: everything is async (wa-sqlite API), `console` replaces the
29
42
  * `@ixo/logger` Logger, the oracle name stamped on messages comes from
30
43
  * `options.oracleName` instead of `process.env`, and each `put`/`putWrites`/
@@ -54,6 +67,15 @@ import {
54
67
  stringify,
55
68
  type CleanAdditionalKwargs,
56
69
  } from './serialization';
70
+ import {
71
+ containsInlinePayload,
72
+ INLINE_PAYLOAD_NEEDLE,
73
+ withoutInlinePayloads,
74
+ } from './inline-payloads';
75
+ import {
76
+ hasInlinePayload,
77
+ offloadInlinePayloads,
78
+ } from '../attachments/retention';
57
79
 
58
80
  export interface SqliteSaverOptions {
59
81
  /**
@@ -145,6 +167,17 @@ function sameFingerprint(
145
167
  );
146
168
  }
147
169
 
170
+ /**
171
+ * The stored form of one message object: the fingerprint it was encoded
172
+ * from, its blob and, when the stored form is a placeholder copy (inline
173
+ * attachment bytes left out), that copy's `message_content` text.
174
+ */
175
+ interface EncodedMessage {
176
+ fingerprint: MessageFingerprint;
177
+ blob: Uint8Array;
178
+ storedContent?: string;
179
+ }
180
+
148
181
  /** A message's row parameters, and whether its stored row already holds them. */
149
182
  interface EncodedMessageRow {
150
183
  row: SqlParam[];
@@ -270,7 +303,31 @@ const migration002: Migration = {
270
303
  },
271
304
  };
272
305
 
273
- const MIGRATIONS: readonly Migration[] = [migration001, migration002];
306
+ /**
307
+ * Migration 003: `attachment_payload_sweeps`, the threads whose human
308
+ * message rows no longer hold inline attachment bytes (see
309
+ * `sweepInlinePayloads`).
310
+ */
311
+ const migration003: Migration = {
312
+ version: 3,
313
+ name: 'add_attachment_payload_sweeps',
314
+ up: async (db) => {
315
+ await db.run(`
316
+ CREATE TABLE IF NOT EXISTS attachment_payload_sweeps (
317
+ thread_id TEXT NOT NULL PRIMARY KEY,
318
+ swept_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
319
+ )`);
320
+ },
321
+ };
322
+
323
+ const MIGRATIONS: readonly Migration[] = [
324
+ migration001,
325
+ migration002,
326
+ migration003,
327
+ ];
328
+
329
+ /** Rows the one-time attachment sweep inflates and rewrites per transaction. */
330
+ const SWEEP_BATCH_ROWS = 8;
274
331
 
275
332
  // `writes.value` blobs may be gzipped, so they travel as hex() — a
276
333
  // CAST(... AS TEXT) of compressed bytes would mangle them irreversibly.
@@ -334,10 +391,11 @@ export class SqliteSaver extends BaseCheckpointSaver {
334
391
  * third one per step — on a long thread, the peak that reset a 128 MB
335
392
  * isolate at the end of a tool turn.
336
393
  */
337
- private readonly encoded = new WeakMap<
338
- BaseMessage,
339
- { fingerprint: MessageFingerprint; blob: Uint8Array }
340
- >();
394
+ private readonly encoded = new WeakMap<BaseMessage, EncodedMessage>();
395
+ /** Threads known to hold no inline attachment bytes in their rows. */
396
+ private readonly sweptThreads = new Set<string>();
397
+ /** The sweep in progress per thread: concurrent loads share it. */
398
+ private readonly sweeps = new Map<string, Promise<void>>();
341
399
 
342
400
  constructor(
343
401
  db: DoSqliteDatabase,
@@ -470,6 +528,7 @@ export class SqliteSaver extends BaseCheckpointSaver {
470
528
  */
471
529
  async listThreadMessages(threadId: string): Promise<BaseMessage[]> {
472
530
  await this.setup();
531
+ await this.ensureSwept(threadId);
473
532
  const rows = await this.db.exec<MessageRow>(
474
533
  'SELECT message FROM messages WHERE thread_id = ? ORDER BY created_at ASC, rowid ASC',
475
534
  [threadId],
@@ -514,6 +573,7 @@ export class SqliteSaver extends BaseCheckpointSaver {
514
573
  },
515
574
  ): Promise<ThreadMessageRow[]> {
516
575
  await this.setup();
576
+ await this.ensureSwept(threadId);
517
577
  const older = opts.direction === 'older';
518
578
  const cmp = older ? '<' : '>';
519
579
  const rowCmp = opts.inclusive ? `${cmp}=` : cmp;
@@ -548,8 +608,8 @@ export class SqliteSaver extends BaseCheckpointSaver {
548
608
  /**
549
609
  * The thread's messages whose stored plain content (`message_content`)
550
610
  * contains `contentContains`, oldest first — a SQL `LIKE` pre-filter, so
551
- * only the matching rows' blobs are inflated. Attachment payload retention
552
- * uses it to find rows that still hold inline bytes / a placeholder.
611
+ * only the matching rows' blobs are inflated. `view_attachment` uses it to
612
+ * find the messages that quote an attachment reference.
553
613
  */
554
614
  async listThreadMessagesMatching(
555
615
  threadId: string,
@@ -589,25 +649,103 @@ export class SqliteSaver extends BaseCheckpointSaver {
589
649
  }
590
650
 
591
651
  /**
592
- * Rewrite one persisted message in place — same row, checkpoint linkage
593
- * and `created_at` (transcript order) untouched. Returns false when the
594
- * thread has no row for the message's id. Attachment payload retention
595
- * uses it to swap inline bytes for a placeholder after a turn.
652
+ * Run the thread's one-time attachment sweep unless it ran already (in
653
+ * this object, or recorded in `attachment_payload_sweeps`). A failed
654
+ * sweep is logged and left for the next load; the read goes on.
596
655
  */
597
- async replaceThreadMessage(
598
- threadId: string,
599
- message: BaseMessage,
600
- ): Promise<boolean> {
601
- await this.setup();
602
- // Only the type/content/blob columns of the row are taken from the
603
- // encoder; the checkpoint columns it also produces stay as stored.
604
- const [, , , messageId, messageType, messageContent, blob] =
605
- await this.toMessageRow(message, threadId, '', '');
606
- const result = await this.db.run(
607
- 'UPDATE messages SET message_type = ?, message_content = ?, message = ? WHERE thread_id = ? AND message_id = ?',
608
- [messageType!, messageContent!, blob!, threadId, messageId!],
656
+ private ensureSwept(threadId: string): Promise<void> {
657
+ if (this.sweptThreads.has(threadId)) return Promise.resolve();
658
+ const running = this.sweeps.get(threadId);
659
+ if (running) return running;
660
+ const sweep = this.sweepInlinePayloads(threadId)
661
+ .then(
662
+ () => {
663
+ this.sweptThreads.add(threadId);
664
+ },
665
+ (error: unknown) => {
666
+ console.warn(
667
+ `[sqlite-saver] thread ${threadId}: attachment sweep failed, retried on the next load`,
668
+ error,
669
+ );
670
+ },
671
+ )
672
+ .finally(() => {
673
+ this.sweeps.delete(threadId);
674
+ });
675
+ this.sweeps.set(threadId, sweep);
676
+ return sweep;
677
+ }
678
+
679
+ /**
680
+ * Rewrite the thread's human message rows that still hold inline
681
+ * attachment bytes (written before the saver left them out) as their
682
+ * placeholder copy, then record the thread as swept. Rows summarised out
683
+ * of graph state are never written again, so without this their bytes
684
+ * would stay in the transcript and the owner copy for good. The matching
685
+ * rows are found by their `message_content` (no blob inflated for the
686
+ * rest) and rewritten `SWEEP_BATCH_ROWS` at a time, each row in place
687
+ * (same rowid, checkpoint linkage, `created_at` and kwargs). Tool and AI
688
+ * messages are left as they are (see `inline-payloads.ts`).
689
+ */
690
+ private async sweepInlinePayloads(threadId: string): Promise<void> {
691
+ const swept = await this.db.get<{ found: number }>(
692
+ 'SELECT 1 AS found FROM attachment_payload_sweeps WHERE thread_id = ?',
693
+ [threadId],
609
694
  );
610
- return result.changes > 0;
695
+ if (swept !== undefined) return;
696
+ const needle = likeContains(INLINE_PAYLOAD_NEEDLE);
697
+ const matching = await this.db.exec<{ rowid: number }>(
698
+ `SELECT rowid FROM messages WHERE thread_id = ? AND message_type = 'human' AND message_content LIKE ? ESCAPE '\\' ORDER BY rowid`,
699
+ [threadId, needle],
700
+ );
701
+ let rewritten = 0;
702
+ for (let i = 0; i < matching.length; i += SWEEP_BATCH_ROWS) {
703
+ const batch = matching.slice(i, i + SWEEP_BATCH_ROWS).map((r) => r.rowid);
704
+ const rows = await this.db.exec<{
705
+ rowid: number;
706
+ message_id: string;
707
+ message: Uint8Array | string;
708
+ }>(
709
+ 'SELECT rowid, message_id, message FROM messages WHERE rowid IN (SELECT value FROM json_each(?))',
710
+ [JSON.stringify(batch)],
711
+ );
712
+ const updates: SqlParam[][] = [];
713
+ for (const row of rows) {
714
+ const placeholder = offloadInlinePayloads(
715
+ await this.loadMessage(row.message),
716
+ );
717
+ if (!placeholder) continue;
718
+ updates.push([
719
+ contentColumn(placeholder.content),
720
+ await compressForStorage(
721
+ stringify(placeholder, (_key, value) => _default(value)),
722
+ ),
723
+ row.rowid,
724
+ row.message_id,
725
+ needle,
726
+ ]);
727
+ }
728
+ if (updates.length === 0) continue;
729
+ await this.db.transaction(async () => {
730
+ for (const update of updates) {
731
+ // Only a row that is still the one read: same message, bytes
732
+ // still in it (a `put` in between stored its placeholder).
733
+ const result = await this.db.run(
734
+ `UPDATE messages SET message_content = ?, message = ? WHERE rowid = ? AND message_id = ? AND message_content LIKE ? ESCAPE '\\'`,
735
+ update,
736
+ );
737
+ rewritten += result.changes;
738
+ }
739
+ });
740
+ }
741
+ await this.db.run(
742
+ 'INSERT OR IGNORE INTO attachment_payload_sweeps (thread_id) VALUES (?)',
743
+ [threadId],
744
+ );
745
+ if (matching.length > 0)
746
+ console.info(
747
+ `[sqlite-saver] thread ${threadId}: ${rewritten} of ${matching.length} stored human message(s) with inline attachment bytes rewritten as their placeholder`,
748
+ );
611
749
  }
612
750
 
613
751
  private async loadMessage(raw: Uint8Array | string): Promise<BaseMessage> {
@@ -643,6 +781,7 @@ export class SqliteSaver extends BaseCheckpointSaver {
643
781
  const checkpointId: unknown = configurable.checkpoint_id;
644
782
  if (typeof threadId !== 'string' || typeof checkpointNs !== 'string')
645
783
  return undefined;
784
+ await this.ensureSwept(threadId);
646
785
 
647
786
  const row =
648
787
  typeof checkpointId === 'string' && checkpointId.length > 0
@@ -859,7 +998,7 @@ export class SqliteSaver extends BaseCheckpointSaver {
859
998
  if (typeof checkpointNs !== 'string')
860
999
  throw new Error('"checkpoint_ns" must be a string.');
861
1000
 
862
- const { checkpoint, messages } = removeMessagesFromCheckpoint(checkpointIn);
1001
+ const { checkpoint, messages } = toStoredCheckpoint(checkpointIn);
863
1002
  const [[type1, serializedCheckpoint], [type2, serializedMetadata]] =
864
1003
  await Promise.all([
865
1004
  this.serde.dumpsTyped(checkpoint),
@@ -869,6 +1008,8 @@ export class SqliteSaver extends BaseCheckpointSaver {
869
1008
  throw new Error(
870
1009
  'Failed to serialized checkpoint and metadata to the same type.',
871
1010
  );
1011
+ if (containsInlinePayload(serializedCheckpoint))
1012
+ warnStoredPayload(`the checkpoint of thread ${threadId}`);
872
1013
 
873
1014
  const messageRows = await Promise.all(
874
1015
  messages?.map((message) =>
@@ -1034,6 +1175,9 @@ export class SqliteSaver extends BaseCheckpointSaver {
1034
1175
  */
1035
1176
  private rememberLoaded(message: BaseMessage, raw: Uint8Array | string): void {
1036
1177
  if (!(raw instanceof Uint8Array)) return;
1178
+ // A row written before attachment bytes were left out of storage still
1179
+ // holds them: not remembered, so the next `put` stores its placeholder.
1180
+ if (hasInlinePayload(message)) return;
1037
1181
  this.encoded.set(message, {
1038
1182
  fingerprint: fingerprintOf(message, this.normalizeKwargs(message)),
1039
1183
  blob: raw,
@@ -1044,6 +1188,9 @@ export class SqliteSaver extends BaseCheckpointSaver {
1044
1188
  * A message's row parameters. A message whose fingerprint matches the
1045
1189
  * blob last encoded or loaded for it reuses that blob without
1046
1190
  * serializing it (`unchanged`); any other is serialized and compressed.
1191
+ * A human message with inline attachment bytes is stored as its
1192
+ * placeholder copy (`offloadInlinePayloads`): the copy is serialized once
1193
+ * and dropped, and only its blob and content text are remembered.
1047
1194
  */
1048
1195
  private async encodeMessageRow(
1049
1196
  message: BaseMessage,
@@ -1053,20 +1200,26 @@ export class SqliteSaver extends BaseCheckpointSaver {
1053
1200
  ): Promise<EncodedMessageRow> {
1054
1201
  const merged = this.normalizeKwargs(message);
1055
1202
  const fingerprint = fingerprintOf(message, merged);
1056
- const cached = this.encoded.get(message);
1057
- let serialized: Uint8Array;
1203
+ let encoded = this.encoded.get(message);
1058
1204
  let unchanged = false;
1059
1205
  if (
1060
- cached !== undefined &&
1061
- sameFingerprint(cached.fingerprint, fingerprint)
1206
+ encoded !== undefined &&
1207
+ sameFingerprint(encoded.fingerprint, fingerprint)
1062
1208
  ) {
1063
- serialized = cached.blob;
1064
1209
  unchanged = true;
1065
1210
  } else {
1066
- serialized = await compressForStorage(
1067
- stringify(message, (_key, value) => _default(value)),
1068
- );
1069
- this.encoded.set(message, { fingerprint, blob: serialized });
1211
+ const placeholder = offloadInlinePayloads(message);
1212
+ const stored = placeholder ?? message;
1213
+ encoded = {
1214
+ fingerprint,
1215
+ blob: await compressForStorage(
1216
+ stringify(stored, (_key, value) => _default(value)),
1217
+ ),
1218
+ ...(placeholder
1219
+ ? { storedContent: contentColumn(placeholder.content) }
1220
+ : {}),
1221
+ };
1222
+ this.encoded.set(message, encoded);
1070
1223
  }
1071
1224
  const messageId =
1072
1225
  message.id ??
@@ -1081,10 +1234,8 @@ export class SqliteSaver extends BaseCheckpointSaver {
1081
1234
  checkpointId,
1082
1235
  messageId,
1083
1236
  message.getType(),
1084
- typeof message.content === 'string'
1085
- ? message.content
1086
- : JSON.stringify(message.content),
1087
- serialized,
1237
+ encoded.storedContent ?? contentColumn(message.content),
1238
+ encoded.blob,
1088
1239
  merged.timestamp,
1089
1240
  ],
1090
1241
  unchanged,
@@ -1170,9 +1321,13 @@ export class SqliteSaver extends BaseCheckpointSaver {
1170
1321
  const checkpointNs: unknown = configurable.checkpoint_ns;
1171
1322
  const ns = typeof checkpointNs === 'string' ? checkpointNs : '';
1172
1323
 
1324
+ const holding: string[] = [];
1173
1325
  const rows = await Promise.all(
1174
1326
  writes.map(async (write, idx): Promise<SqlParam[]> => {
1175
- const [type, serialized] = await this.serde.dumpsTyped(write[1]);
1327
+ const [type, serialized] = await this.serde.dumpsTyped(
1328
+ withoutInlinePayloads(write[1]),
1329
+ );
1330
+ if (containsInlinePayload(serialized)) holding.push(write[0]);
1176
1331
  return [
1177
1332
  resolvedThreadId,
1178
1333
  ns,
@@ -1185,6 +1340,10 @@ export class SqliteSaver extends BaseCheckpointSaver {
1185
1340
  ];
1186
1341
  }),
1187
1342
  );
1343
+ if (holding.length > 0)
1344
+ warnStoredPayload(
1345
+ `a pending write of thread ${resolvedThreadId} (task ${taskId}, channel ${holding.join(', ')})`,
1346
+ );
1188
1347
  await this.db.transaction(async () => {
1189
1348
  for (const row of rows) {
1190
1349
  await this.db.run(
@@ -1198,7 +1357,12 @@ export class SqliteSaver extends BaseCheckpointSaver {
1198
1357
 
1199
1358
  async deleteThread(threadId: string): Promise<void> {
1200
1359
  await this.setup();
1360
+ this.sweptThreads.delete(threadId);
1201
1361
  await this.db.transaction(async () => {
1362
+ await this.db.run(
1363
+ 'DELETE FROM attachment_payload_sweeps WHERE thread_id = ?',
1364
+ [threadId],
1365
+ );
1202
1366
  await this.db.run('DELETE FROM checkpoints WHERE thread_id = ?', [
1203
1367
  threadId,
1204
1368
  ]);
@@ -1253,18 +1417,53 @@ function asReasoningDetails(
1253
1417
  );
1254
1418
  }
1255
1419
 
1420
+ /**
1421
+ * One line for a write that stores inline base64 after stripping
1422
+ * (`containsInlinePayload`): a tool or AI message carrying base64, or a
1423
+ * human message in a shape `withoutInlinePayloads` does not reach.
1424
+ */
1425
+ function warnStoredPayload(what: string): void {
1426
+ console.warn(
1427
+ `[sqlite-saver] ${what} still holds inline base64 after attachment stripping: a tool/AI message with base64 content, or a message shape inline-payloads.ts does not reach; those bytes are stored`,
1428
+ );
1429
+ }
1430
+
1431
+ /** The `messages.message_content` text of a message's content. */
1432
+ function contentColumn(content: BaseMessage['content']): string {
1433
+ return typeof content === 'string' ? content : JSON.stringify(content);
1434
+ }
1435
+
1256
1436
  function isCheckpointWithMessages(
1257
1437
  checkpoint: Checkpoint,
1258
1438
  ): checkpoint is CheckpointWithMessages {
1259
1439
  return 'messages' in checkpoint.channel_values;
1260
1440
  }
1261
1441
 
1262
- function removeMessagesFromCheckpoint(checkpoint: Checkpoint): {
1442
+ /**
1443
+ * The checkpoint as its blob stores it: `channel_values.messages` taken out
1444
+ * (they become message rows, returned) and inline attachment bytes left out
1445
+ * of every other channel value. The input is never modified; it is returned
1446
+ * as is when there is nothing to take out.
1447
+ */
1448
+ function toStoredCheckpoint(checkpoint: Checkpoint): {
1263
1449
  checkpoint: Checkpoint;
1264
1450
  messages?: BaseMessage[];
1265
1451
  } {
1266
- if (!isCheckpointWithMessages(checkpoint)) return { checkpoint };
1452
+ const messages = isCheckpointWithMessages(checkpoint)
1453
+ ? checkpoint.channel_values.messages
1454
+ : undefined;
1455
+ let channelValues: ChannelValues | undefined;
1456
+ for (const [channel, value] of Object.entries(checkpoint.channel_values)) {
1457
+ if (channel === 'messages') continue;
1458
+ const stored = withoutInlinePayloads(value);
1459
+ if (stored === value) continue;
1460
+ channelValues ??= { ...checkpoint.channel_values };
1461
+ channelValues[channel] = stored;
1462
+ }
1463
+ if (messages === undefined && channelValues === undefined)
1464
+ return { checkpoint };
1267
1465
  const copy = copyCheckpoint(checkpoint);
1466
+ if (channelValues !== undefined) copy.channel_values = channelValues;
1268
1467
  delete copy.channel_values.messages;
1269
- return { checkpoint: copy, messages: checkpoint.channel_values.messages };
1468
+ return { checkpoint: copy, ...(messages ? { messages } : {}) };
1270
1469
  }