@ixo/oracle-runtime-workers 0.9.0 → 0.11.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.
Files changed (59) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +38 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +249 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +1019 -169
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/owner-store/ixo-vfs-store.ts +12 -5
  39. package/src/owner-store/matrix-media-store.ts +12 -7
  40. package/src/owner-store/measure.ts +39 -0
  41. package/src/owner-store/migrating-store.ts +9 -3
  42. package/src/owner-store/types.ts +14 -4
  43. package/src/plugin-api/tool-helper.ts +9 -1
  44. package/src/plugin-api/types.ts +86 -0
  45. package/src/plugins/index.ts +3 -0
  46. package/src/plugins/matrix-group-chats/README.md +70 -0
  47. package/src/plugins/matrix-group-chats/index.ts +132 -0
  48. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  49. package/src/plugins/tasks/tasks-tools.ts +4 -0
  50. package/src/shell/app.ts +78 -0
  51. package/src/sqlite/cache-config.ts +27 -6
  52. package/src/sqlite/chunk-layout.ts +14 -0
  53. package/src/sqlite/database.ts +188 -19
  54. package/src/sqlite/do-vfs.ts +963 -99
  55. package/src/sqlite/index.ts +13 -0
  56. package/src/sqlite/page-tier.ts +668 -0
  57. package/src/sqlite/sqlite-saver.ts +81 -0
  58. package/src/tasks/scheduler.ts +134 -3
  59. package/src/tasks/store.ts +22 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Whole tool results that were too large for the model's context.
3
+ *
4
+ * `createResultCapMiddleware` (core/middlewares/result-cap.ts) hands the model
5
+ * a head + tail of any result above the turn's cap and stores the whole
6
+ * result here, under a content hash, so the model can page through it with
7
+ * `read_result` when the visible part is not enough. Two tiers:
8
+ *
9
+ * - **SQLite** (this object's database, `tool_results`) for results under
10
+ * `r2MinBytes` (default 1 MB): a Durable Object SQL row holds 2 MB at
11
+ * most, and the hot store is shared with checkpoints, runs and tasks;
12
+ * - **R2** (the tier bucket, `<object id>/results/<id>`) for anything
13
+ * larger — which is what an MCP dump or a big fetch produces — so the
14
+ * 10 GB object limit is never in play.
15
+ *
16
+ * Every row carries `expires_at` (default 24 h): the model reads a result
17
+ * back in the turn that produced it, rarely later. Expired rows and their
18
+ * R2 objects are removed by `sweep()` at boot; a session's results go with
19
+ * the session. Operators may add an R2 lifecycle rule on the `results/`
20
+ * prefix as a belt on top of the sweep. Identical results de-duplicate by
21
+ * hash (the row's expiry is refreshed instead of a second copy).
22
+ */
23
+ import type { DoSqliteDatabase } from '../sqlite/database';
24
+ import type { Logger } from '../plugin-api/types';
25
+ import { NOOP_LOGGER } from '../core/utils';
26
+
27
+ export type ResultTier = 'sqlite' | 'r2';
28
+
29
+ export interface StoredResultRef {
30
+ id: string;
31
+ /** Size in bytes (UTF-8). */
32
+ size: number;
33
+ tier: ResultTier;
34
+ }
35
+
36
+ export type ReadResultOutcome =
37
+ | {
38
+ status: 'ok';
39
+ id: string;
40
+ tier: ResultTier;
41
+ size: number;
42
+ offset: number;
43
+ length: number;
44
+ text: string;
45
+ /** Byte offset of the next chunk, or `null` at the end. */
46
+ next: number | null;
47
+ }
48
+ | { status: 'expired' | 'not-found' };
49
+
50
+ export interface ResultStoreOptions {
51
+ bucket?: R2Bucket;
52
+ /** Key prefix in the bucket (the object's id). */
53
+ prefix: string;
54
+ ttlMs?: number;
55
+ /** Results this size or larger go to R2 (needs `bucket`). */
56
+ r2MinBytes?: number;
57
+ now?: () => number;
58
+ logger?: Logger;
59
+ }
60
+
61
+ export const DEFAULT_RESULT_TTL_MS = 24 * 60 * 60 * 1000;
62
+ export const DEFAULT_RESULT_R2_MIN_BYTES = 1_000_000;
63
+ /** A DO SQL row holds 2 MB; leave room for the row's other columns. */
64
+ export const SQLITE_RESULT_MAX_BYTES = 1_500_000;
65
+ /** Longest chunk `read` returns (bytes). */
66
+ export const MAX_READ_BYTES = 64 * 1024;
67
+
68
+ type ResultRow = {
69
+ id: string;
70
+ session_id: string;
71
+ tool_name: string;
72
+ size: number;
73
+ tier: string;
74
+ content: string | null;
75
+ created_at: string;
76
+ expires_at: number;
77
+ } & Record<string, string | number | null>;
78
+
79
+ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
80
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
81
+ return Array.from(new Uint8Array(digest))
82
+ .map((b) => b.toString(16).padStart(2, '0'))
83
+ .join('');
84
+ }
85
+
86
+ export class ResultStore {
87
+ private setupPromise: Promise<void> | undefined;
88
+
89
+ private readonly now: () => number;
90
+
91
+ private readonly ttlMs: number;
92
+
93
+ private readonly r2MinBytes: number;
94
+
95
+ private readonly logger: Logger;
96
+
97
+ constructor(
98
+ readonly db: DoSqliteDatabase,
99
+ private readonly options: ResultStoreOptions,
100
+ ) {
101
+ this.now = options.now ?? (() => Date.now());
102
+ this.ttlMs = options.ttlMs ?? DEFAULT_RESULT_TTL_MS;
103
+ this.r2MinBytes = options.r2MinBytes ?? DEFAULT_RESULT_R2_MIN_BYTES;
104
+ this.logger = options.logger ?? NOOP_LOGGER;
105
+ }
106
+
107
+ setup(): Promise<void> {
108
+ this.setupPromise ??= this.doSetup().catch((error: unknown) => {
109
+ this.setupPromise = undefined;
110
+ throw error;
111
+ });
112
+ return this.setupPromise;
113
+ }
114
+
115
+ private async doSetup(): Promise<void> {
116
+ await this.db.run(`
117
+ CREATE TABLE IF NOT EXISTS tool_results (
118
+ id TEXT PRIMARY KEY,
119
+ session_id TEXT NOT NULL,
120
+ tool_name TEXT NOT NULL,
121
+ size INTEGER NOT NULL,
122
+ tier TEXT NOT NULL,
123
+ content TEXT,
124
+ created_at TEXT NOT NULL,
125
+ expires_at INTEGER NOT NULL
126
+ )`);
127
+ await this.db.run(
128
+ `CREATE INDEX IF NOT EXISTS idx_tool_results_session ON tool_results(session_id)`,
129
+ );
130
+ await this.db.run(
131
+ `CREATE INDEX IF NOT EXISTS idx_tool_results_expires ON tool_results(expires_at)`,
132
+ );
133
+ await this.sweep();
134
+ }
135
+
136
+ private key(id: string): string {
137
+ return `${this.options.prefix}/results/${id}`;
138
+ }
139
+
140
+ /**
141
+ * Store a result whole. Returns `undefined` when it cannot be kept (too
142
+ * large for a SQLite row and no bucket is bound) — the caller then shows
143
+ * the truncated text without a handle.
144
+ */
145
+ async put(input: {
146
+ sessionId: string;
147
+ toolName: string;
148
+ content: string;
149
+ }): Promise<StoredResultRef | undefined> {
150
+ await this.setup();
151
+ const bytes = new TextEncoder().encode(input.content);
152
+ const id = await sha256Hex(bytes);
153
+ const expiresAt = this.now() + this.ttlMs;
154
+ const existing = await this.db.get<ResultRow>(
155
+ `SELECT id, tier, size FROM tool_results WHERE id = ?`,
156
+ [id],
157
+ );
158
+ if (existing) {
159
+ await this.db.run(`UPDATE tool_results SET expires_at = ? WHERE id = ?`, [
160
+ expiresAt,
161
+ id,
162
+ ]);
163
+ return {
164
+ id,
165
+ size: Number(existing.size),
166
+ tier: existing.tier === 'r2' ? 'r2' : 'sqlite',
167
+ };
168
+ }
169
+ const useR2 =
170
+ this.options.bucket !== undefined && bytes.byteLength >= this.r2MinBytes;
171
+ if (!useR2 && bytes.byteLength > SQLITE_RESULT_MAX_BYTES) {
172
+ this.logger.warn(
173
+ `[results] ${input.toolName}: ${bytes.byteLength} bytes exceed the SQLite tier and no bucket is bound; not saved`,
174
+ );
175
+ return undefined;
176
+ }
177
+ if (useR2) {
178
+ await this.options.bucket!.put(this.key(id), bytes, {
179
+ httpMetadata: { contentType: 'text/plain; charset=utf-8' },
180
+ customMetadata: {
181
+ sessionId: input.sessionId,
182
+ toolName: input.toolName,
183
+ },
184
+ });
185
+ }
186
+ await this.db.run(
187
+ `INSERT OR IGNORE INTO tool_results (id, session_id, tool_name, size, tier, content, created_at, expires_at)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
189
+ [
190
+ id,
191
+ input.sessionId,
192
+ input.toolName,
193
+ bytes.byteLength,
194
+ useR2 ? 'r2' : 'sqlite',
195
+ useR2 ? null : input.content,
196
+ new Date(this.now()).toISOString(),
197
+ expiresAt,
198
+ ],
199
+ );
200
+ return { id, size: bytes.byteLength, tier: useR2 ? 'r2' : 'sqlite' };
201
+ }
202
+
203
+ /** A chunk of a stored result, by byte range. */
204
+ async read(
205
+ id: string,
206
+ offset = 0,
207
+ length = 4_000,
208
+ ): Promise<ReadResultOutcome> {
209
+ await this.setup();
210
+ const row = await this.db.get<ResultRow>(
211
+ `SELECT id, tier, size, content, expires_at FROM tool_results WHERE id = ?`,
212
+ [id],
213
+ );
214
+ if (!row) return { status: 'not-found' };
215
+ if (Number(row.expires_at) <= this.now()) {
216
+ await this.remove([row]);
217
+ return { status: 'expired' };
218
+ }
219
+ const size = Number(row.size);
220
+ const start = Math.max(0, Math.min(Math.floor(offset), size));
221
+ const span = Math.max(
222
+ 1,
223
+ Math.min(Math.floor(length), MAX_READ_BYTES, size - start),
224
+ );
225
+ const tier: ResultTier = row.tier === 'r2' ? 'r2' : 'sqlite';
226
+ let slice: Uint8Array;
227
+ if (tier === 'r2') {
228
+ const object = await this.options.bucket?.get(this.key(id), {
229
+ range: { offset: start, length: span },
230
+ });
231
+ if (!object) return { status: 'not-found' };
232
+ slice = new Uint8Array(await object.arrayBuffer());
233
+ } else {
234
+ slice = new TextEncoder()
235
+ .encode(row.content ?? '')
236
+ .subarray(start, start + span);
237
+ }
238
+ const text = new TextDecoder().decode(slice);
239
+ const next = start + span < size ? start + span : null;
240
+ return {
241
+ status: 'ok',
242
+ id,
243
+ tier,
244
+ size,
245
+ offset: start,
246
+ length: span,
247
+ text,
248
+ next,
249
+ };
250
+ }
251
+
252
+ async deleteForSession(sessionId: string): Promise<number> {
253
+ await this.setup();
254
+ const rows = await this.db.exec<ResultRow>(
255
+ `SELECT id, tier FROM tool_results WHERE session_id = ?`,
256
+ [sessionId],
257
+ );
258
+ await this.remove(rows);
259
+ return rows.length;
260
+ }
261
+
262
+ /** Drop expired rows (and their R2 objects). Returns how many. */
263
+ async sweep(): Promise<number> {
264
+ const rows = await this.db.exec<ResultRow>(
265
+ `SELECT id, tier FROM tool_results WHERE expires_at <= ?`,
266
+ [this.now()],
267
+ );
268
+ await this.remove(rows);
269
+ return rows.length;
270
+ }
271
+
272
+ private async remove(
273
+ rows: Array<Pick<ResultRow, 'id' | 'tier'>>,
274
+ ): Promise<void> {
275
+ if (rows.length === 0) return;
276
+ const r2Keys = rows
277
+ .filter((r) => r.tier === 'r2')
278
+ .map((r) => this.key(r.id));
279
+ if (r2Keys.length > 0 && this.options.bucket) {
280
+ try {
281
+ await this.options.bucket.delete(r2Keys);
282
+ } catch (error) {
283
+ this.logger.warn(
284
+ `[results] could not delete ${r2Keys.length} R2 object(s): ${error instanceof Error ? error.message : String(error)}`,
285
+ );
286
+ }
287
+ }
288
+ for (const row of rows)
289
+ await this.db.run(`DELETE FROM tool_results WHERE id = ?`, [row.id]);
290
+ }
291
+
292
+ async stats(): Promise<{
293
+ rows: number;
294
+ sqliteBytes: number;
295
+ r2Rows: number;
296
+ r2Bytes: number;
297
+ }> {
298
+ await this.setup();
299
+ const row = await this.db.get<{
300
+ rows: number;
301
+ sqlite_bytes: number | null;
302
+ r2_rows: number | null;
303
+ r2_bytes: number | null;
304
+ }>(
305
+ `SELECT COUNT(*) AS rows,
306
+ SUM(CASE WHEN tier = 'sqlite' THEN size ELSE 0 END) AS sqlite_bytes,
307
+ SUM(CASE WHEN tier = 'r2' THEN 1 ELSE 0 END) AS r2_rows,
308
+ SUM(CASE WHEN tier = 'r2' THEN size ELSE 0 END) AS r2_bytes
309
+ FROM tool_results`,
310
+ );
311
+ return {
312
+ rows: Number(row?.rows ?? 0),
313
+ sqliteBytes: Number(row?.sqlite_bytes ?? 0),
314
+ r2Rows: Number(row?.r2_rows ?? 0),
315
+ r2Bytes: Number(row?.r2_bytes ?? 0),
316
+ };
317
+ }
318
+ }
319
+
320
+ /** Parse the `TOOL_RESULT_*` knobs (defaults: 24 h, 1 MB). */
321
+ export function resultStoreKnobs(env: Record<string, unknown>): {
322
+ ttlMs: number;
323
+ r2MinBytes: number;
324
+ } {
325
+ const hours = Number(env.TOOL_RESULT_TTL_HOURS);
326
+ const r2Min = Number(env.TOOL_RESULT_R2_MIN_BYTES);
327
+ return {
328
+ ttlMs:
329
+ Number.isFinite(hours) && hours >= 1
330
+ ? Math.floor(hours * 60 * 60 * 1000)
331
+ : DEFAULT_RESULT_TTL_MS,
332
+ r2MinBytes:
333
+ Number.isInteger(r2Min) &&
334
+ r2Min >= 64 * 1024 &&
335
+ r2Min <= SQLITE_RESULT_MAX_BYTES
336
+ ? r2Min
337
+ : DEFAULT_RESULT_R2_MIN_BYTES,
338
+ };
339
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The output of one run, in order, with a sequence number per frame.
3
+ *
4
+ * Every SSE frame a turn produces (`message`, `reasoning`, `tool_call`,
5
+ * `action_call`, `router.update`, `error`, `done`, plugin-emitted events) is
6
+ * pushed here first. Two things read it:
7
+ *
8
+ * - **subscribers** — the HTTP response of the turn and every client that
9
+ * re-joined (`GET /runs/:id?after=<seq>`); each gets the frames after its
10
+ * cursor and then the live tail;
11
+ * - **the segment packer** — the unflushed tail is packed into one row of
12
+ * `turn_run_segments` every `flushMs` or `flushBytes`, and at once for
13
+ * the frames `immediate` selects (a settled tool result), so a re-join
14
+ * after the object was reset — or a recovery — can restore the exact
15
+ * output. Everything else rides the timer: one row per `flushMs` of
16
+ * output is the write budget, whatever the frame count.
17
+ *
18
+ * Only the unflushed tail lives in memory: a re-join reads older frames from
19
+ * the store (`readSegments`) before it subscribes, so the buffer never grows
20
+ * with the reply. The packer's timer is armed only while there is something
21
+ * to flush and is cleared when the run closes (a pending timer keeps a
22
+ * Durable Object resident — see docs/architecture.md, rules of the road).
23
+ */
24
+
25
+ export interface RunFrame {
26
+ seq: number;
27
+ event: string;
28
+ data: unknown;
29
+ }
30
+
31
+ export interface PackedSegment {
32
+ seqFrom: number;
33
+ seqTo: number;
34
+ /** JSON array of `{ seq, event, data }`. */
35
+ payload: string;
36
+ }
37
+
38
+ export interface RunBufferOptions {
39
+ /** Max age of the unflushed tail before it is packed (ms). */
40
+ flushMs: number;
41
+ /** Max size of the unflushed tail before it is packed (bytes of JSON). */
42
+ flushBytes: number;
43
+ /** Frames packed at once instead of on the timer (a settled tool result must survive a reset). */
44
+ immediate?: (event: string, data: unknown) => boolean;
45
+ /** Persist one packed segment. Errors are reported through `onPackError`. */
46
+ onPack: (segment: PackedSegment) => Promise<void> | void;
47
+ onPackError?: (error: unknown) => void;
48
+ now?: () => number;
49
+ /** Timer hooks (tests use fakes). */
50
+ setTimer?: (fn: () => void, ms: number) => unknown;
51
+ clearTimer?: (handle: unknown) => void;
52
+ /**
53
+ * Sequence number the first frame continues from (a resumed run keeps
54
+ * numbering after the frames its earlier attempt packed, so a client's
55
+ * cursor stays valid across the reset).
56
+ */
57
+ startSeq?: number;
58
+ }
59
+
60
+ export type RunSubscriber = (frame: RunFrame) => void;
61
+
62
+ /** Assistant text accumulated over `message` frames — the reply so far. */
63
+ export function partialTextOf(frames: Iterable<RunFrame>): string {
64
+ let text = '';
65
+ for (const frame of frames) {
66
+ if (frame.event !== 'message') continue;
67
+ const content = (frame.data as { content?: unknown } | undefined)?.content;
68
+ if (typeof content === 'string') text += content;
69
+ }
70
+ return text;
71
+ }
72
+
73
+ /** Decode the frames of packed segments, in order, keeping only `seq > after`. */
74
+ export function framesOfSegments(
75
+ segments: Iterable<Pick<PackedSegment, 'payload'>>,
76
+ after = 0,
77
+ ): RunFrame[] {
78
+ const out: RunFrame[] = [];
79
+ for (const segment of segments) {
80
+ const frames = JSON.parse(segment.payload) as RunFrame[];
81
+ for (const frame of frames) if (frame.seq > after) out.push(frame);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ export class RunBuffer {
87
+ private seq = 0;
88
+
89
+ /** Frames not yet packed into a segment. */
90
+ private readonly tail: RunFrame[] = [];
91
+
92
+ private tailBytes = 0;
93
+
94
+ private readonly subscribers = new Set<RunSubscriber>();
95
+
96
+ private timer: unknown = null;
97
+
98
+ private closed = false;
99
+
100
+ private packing: Promise<void> = Promise.resolve();
101
+
102
+ /** Highest sequence number packed so far (0 = nothing packed). */
103
+ packedSeq = 0;
104
+
105
+ constructor(private readonly options: RunBufferOptions) {
106
+ this.seq = options.startSeq ?? 0;
107
+ this.packedSeq = options.startSeq ?? 0;
108
+ }
109
+
110
+ get lastSeq(): number {
111
+ return this.seq;
112
+ }
113
+
114
+ get isClosed(): boolean {
115
+ return this.closed;
116
+ }
117
+
118
+ /** Append a frame, fan it out, and schedule or force a pack. */
119
+ push(event: string, data: unknown): RunFrame {
120
+ if (this.closed) throw new Error('run buffer is closed');
121
+ const frame: RunFrame = { seq: ++this.seq, event, data };
122
+ this.tail.push(frame);
123
+ this.tailBytes += JSON.stringify(frame).length;
124
+ for (const subscriber of this.subscribers) {
125
+ try {
126
+ subscriber(frame);
127
+ } catch {
128
+ /* a broken subscriber never breaks the run */
129
+ }
130
+ }
131
+ if (
132
+ this.options.immediate?.(event, data) ||
133
+ this.tailBytes >= this.options.flushBytes
134
+ ) {
135
+ void this.flush();
136
+ } else if (this.timer === null) {
137
+ const set = this.options.setTimer ?? setTimeout;
138
+ this.timer = set(() => {
139
+ this.timer = null;
140
+ void this.flush();
141
+ }, this.options.flushMs);
142
+ }
143
+ return frame;
144
+ }
145
+
146
+ /**
147
+ * The unflushed frames after `after` — what a re-join needs on top of the
148
+ * segments it read from the store.
149
+ */
150
+ tailAfter(after: number): RunFrame[] {
151
+ return this.tail.filter((frame) => frame.seq > after);
152
+ }
153
+
154
+ /**
155
+ * Attach a live subscriber. The caller replays the store's segments and
156
+ * `tailAfter(cursor)` first; from then on every new frame is delivered.
157
+ */
158
+ subscribe(subscriber: RunSubscriber): () => void {
159
+ this.subscribers.add(subscriber);
160
+ return () => {
161
+ this.subscribers.delete(subscriber);
162
+ };
163
+ }
164
+
165
+ get subscriberCount(): number {
166
+ return this.subscribers.size;
167
+ }
168
+
169
+ /** Pack the unflushed tail into one segment. Serialised; safe to call often. */
170
+ flush(): Promise<void> {
171
+ if (this.timer !== null) {
172
+ (this.options.clearTimer ?? clearTimeout)(this.timer as never);
173
+ this.timer = null;
174
+ }
175
+ if (this.tail.length === 0) return this.packing;
176
+ const frames = this.tail.splice(0, this.tail.length);
177
+ this.tailBytes = 0;
178
+ const segment: PackedSegment = {
179
+ seqFrom: frames[0]!.seq,
180
+ seqTo: frames[frames.length - 1]!.seq,
181
+ payload: JSON.stringify(frames),
182
+ };
183
+ this.packing = this.packing
184
+ .then(() => this.options.onPack(segment))
185
+ .then(() => {
186
+ this.packedSeq = Math.max(this.packedSeq, segment.seqTo);
187
+ })
188
+ .catch((error: unknown) => {
189
+ // The frames stay delivered to live subscribers; only the durable
190
+ // copy is missing. Report and carry on — the next pack retries
191
+ // nothing (the frames are gone) but the run itself is unaffected.
192
+ this.options.onPackError?.(error);
193
+ });
194
+ return this.packing;
195
+ }
196
+
197
+ /** Final pack; no more frames accepted afterwards. */
198
+ async close(): Promise<void> {
199
+ if (this.closed) return this.packing;
200
+ this.closed = true;
201
+ await this.flush();
202
+ this.subscribers.clear();
203
+ }
204
+ }