@workflow/world-local 5.0.0-beta.10 → 5.0.0-beta.2

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 (74) hide show
  1. package/dist/config.d.ts +17 -5
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +18 -13
  4. package/dist/config.js.map +1 -1
  5. package/dist/fs.d.ts +58 -2
  6. package/dist/fs.d.ts.map +1 -1
  7. package/dist/fs.js +203 -24
  8. package/dist/fs.js.map +1 -1
  9. package/dist/index.d.ts +15 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +82 -5
  12. package/dist/index.js.map +1 -1
  13. package/dist/init.d.ts +91 -0
  14. package/dist/init.d.ts.map +1 -0
  15. package/dist/init.js +268 -0
  16. package/dist/init.js.map +1 -0
  17. package/dist/instrumentObject.d.ts +8 -0
  18. package/dist/instrumentObject.d.ts.map +1 -0
  19. package/dist/instrumentObject.js +66 -0
  20. package/dist/instrumentObject.js.map +1 -0
  21. package/dist/queue.d.ts +8 -1
  22. package/dist/queue.d.ts.map +1 -1
  23. package/dist/queue.js +164 -53
  24. package/dist/queue.js.map +1 -1
  25. package/dist/storage/events-storage.d.ts +7 -0
  26. package/dist/storage/events-storage.d.ts.map +1 -0
  27. package/dist/storage/events-storage.js +744 -0
  28. package/dist/storage/events-storage.js.map +1 -0
  29. package/dist/storage/filters.d.ts +22 -0
  30. package/dist/storage/filters.d.ts.map +1 -0
  31. package/dist/storage/filters.js +33 -0
  32. package/dist/storage/filters.js.map +1 -0
  33. package/dist/storage/helpers.d.ts +18 -0
  34. package/dist/storage/helpers.d.ts.map +1 -0
  35. package/dist/storage/helpers.js +44 -0
  36. package/dist/storage/helpers.js.map +1 -0
  37. package/dist/storage/hooks-storage.d.ts +12 -0
  38. package/dist/storage/hooks-storage.d.ts.map +1 -0
  39. package/dist/storage/hooks-storage.js +92 -0
  40. package/dist/storage/hooks-storage.js.map +1 -0
  41. package/dist/storage/index.d.ts +12 -0
  42. package/dist/storage/index.d.ts.map +1 -0
  43. package/dist/storage/index.js +32 -0
  44. package/dist/storage/index.js.map +1 -0
  45. package/dist/storage/legacy.d.ts +13 -0
  46. package/dist/storage/legacy.d.ts.map +1 -0
  47. package/dist/storage/legacy.js +73 -0
  48. package/dist/storage/legacy.js.map +1 -0
  49. package/dist/storage/runs-storage.d.ts +7 -0
  50. package/dist/storage/runs-storage.d.ts.map +1 -0
  51. package/dist/storage/runs-storage.js +58 -0
  52. package/dist/storage/runs-storage.js.map +1 -0
  53. package/dist/storage/steps-storage.d.ts +7 -0
  54. package/dist/storage/steps-storage.d.ts.map +1 -0
  55. package/dist/storage/steps-storage.js +49 -0
  56. package/dist/storage/steps-storage.js.map +1 -0
  57. package/dist/storage.d.ts +9 -2
  58. package/dist/storage.d.ts.map +1 -1
  59. package/dist/storage.js +8 -455
  60. package/dist/storage.js.map +1 -1
  61. package/dist/streamer.d.ts +4 -2
  62. package/dist/streamer.d.ts.map +1 -1
  63. package/dist/streamer.js +451 -104
  64. package/dist/streamer.js.map +1 -1
  65. package/dist/telemetry.d.ts +28 -0
  66. package/dist/telemetry.d.ts.map +1 -0
  67. package/dist/telemetry.js +71 -0
  68. package/dist/telemetry.js.map +1 -0
  69. package/dist/test-helpers.d.ts +54 -0
  70. package/dist/test-helpers.d.ts.map +1 -0
  71. package/dist/test-helpers.js +118 -0
  72. package/dist/test-helpers.js.map +1 -0
  73. package/dist/util.js.map +1 -1
  74. package/package.json +12 -11
@@ -11,9 +11,11 @@ export interface Chunk {
11
11
  chunk: Buffer;
12
12
  }
13
13
  export declare function serializeChunk(chunk: Chunk): Buffer<ArrayBuffer>;
14
+ /** Check only the EOF flag byte without copying chunk payload. */
15
+ export declare function isEofChunk(serialized: Buffer): boolean;
14
16
  export declare function deserializeChunk(serialized: Buffer): {
15
17
  eof: boolean;
16
- chunk: Buffer<ArrayBufferLike>;
18
+ chunk: Buffer<ArrayBuffer>;
17
19
  };
18
- export declare function createStreamer(basedir: string): Streamer;
20
+ export declare function createStreamer(basedir: string, tag?: string): Streamer;
19
21
  //# sourceMappingURL=streamer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"streamer.d.ts","sourceRoot":"","sources":["../src/streamer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAQhD;;;;;;GAMG;AACH,MAAM,WAAW,KAAK;IACpB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,KAAK,uBAG1C;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM;;;EAIlD;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,CAmLxD"}
1
+ {"version":3,"file":"streamer.d.ts","sourceRoot":"","sources":["../src/streamer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAGV,QAAQ,EAET,MAAM,iBAAiB,CAAC;AAqBzB;;;;;;GAMG;AACH,MAAM,WAAW,KAAK;IACpB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,KAAK,uBAG1C;AAED,kEAAkE;AAClE,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM;;;EAKlD;AAuCD,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,QAAQ,CAmjBtE"}
package/dist/streamer.js CHANGED
@@ -1,130 +1,477 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import path from 'node:path';
3
3
  import { monotonicFactory } from 'ulid';
4
- import { listJSONFiles, readBuffer, write } from './fs.js';
4
+ import { z } from 'zod';
5
+ import { listFilesByExtension, readBuffer, readJSONWithFallback, taggedPath, write, writeJSON, } from './fs.js';
5
6
  // Create a monotonic ULID factory that ensures ULIDs are always increasing
6
7
  // even when generated within the same millisecond
7
8
  const monotonicUlid = monotonicFactory(() => Math.random());
9
+ // Schema for the run-to-streams mapping file
10
+ const RunStreamsSchema = z.object({
11
+ streams: z.array(z.string()),
12
+ });
8
13
  export function serializeChunk(chunk) {
9
14
  const eofByte = Buffer.from([chunk.eof ? 1 : 0]);
10
15
  return Buffer.concat([eofByte, chunk.chunk]);
11
16
  }
17
+ /** Check only the EOF flag byte without copying chunk payload. */
18
+ export function isEofChunk(serialized) {
19
+ return serialized[0] === 1;
20
+ }
12
21
  export function deserializeChunk(serialized) {
13
22
  const eof = serialized[0] === 1;
14
- const chunk = serialized.subarray(1);
23
+ // Create a copy instead of a view to prevent ArrayBuffer detachment
24
+ const chunk = Buffer.from(serialized.subarray(1));
15
25
  return { eof, chunk };
16
26
  }
17
- export function createStreamer(basedir) {
27
+ /**
28
+ * List chunk files for a stream, sorted chronologically (ULID order).
29
+ * Returns both the sorted file names and a map of file → extension for
30
+ * resolving the full path. Handles tagged and legacy (.json) formats.
31
+ */
32
+ async function listChunkFilesForStream(chunksDir, name, tag) {
33
+ const listPromises = [
34
+ listFilesByExtension(chunksDir, '.bin'),
35
+ listFilesByExtension(chunksDir, '.json'),
36
+ ];
37
+ if (tag) {
38
+ listPromises.push(listFilesByExtension(chunksDir, `.${tag}.bin`));
39
+ }
40
+ const [binFiles, jsonFiles, ...taggedResults] = await Promise.all(listPromises);
41
+ const taggedBinFiles = taggedResults[0] ?? [];
42
+ const extMap = new Map();
43
+ for (const f of jsonFiles)
44
+ extMap.set(f, '.json');
45
+ const tagSfx = tag ? `.${tag}` : '';
46
+ for (const f of binFiles) {
47
+ if (tag && f.endsWith(tagSfx))
48
+ continue;
49
+ extMap.set(f, '.bin');
50
+ }
51
+ for (const f of taggedBinFiles)
52
+ extMap.set(f, `.${tag}.bin`);
53
+ const files = [...extMap.keys()]
54
+ .filter((file) => file.startsWith(`${name}-`))
55
+ .sort();
56
+ return { files, extMap };
57
+ }
58
+ export function createStreamer(basedir, tag) {
59
+ const tagSuffix = tag ? `.${tag}` : '';
18
60
  const streamEmitter = new EventEmitter();
61
+ // Track which streams have already been registered for a run (in-memory cache)
62
+ const registeredStreams = new Set();
63
+ // Helper to record the runId <> streamId association
64
+ async function registerStreamForRun(runId, streamName) {
65
+ const cacheKey = `${runId}:${streamName}`;
66
+ if (registeredStreams.has(cacheKey)) {
67
+ return; // Already registered in this session
68
+ }
69
+ const runStreamsPath = taggedPath(basedir, 'streams/runs', runId, tag);
70
+ // Read existing streams for this run (try tagged first, fall back to untagged)
71
+ const existing = await readJSONWithFallback(basedir, 'streams/runs', runId, RunStreamsSchema, tag);
72
+ const streams = existing?.streams ?? [];
73
+ // Add stream if not already present
74
+ if (!streams.includes(streamName)) {
75
+ streams.push(streamName);
76
+ await writeJSON(runStreamsPath, { streams }, { overwrite: true });
77
+ }
78
+ registeredStreams.add(cacheKey);
79
+ }
80
+ // Helper to convert a chunk to a Buffer
81
+ function toBuffer(chunk) {
82
+ if (typeof chunk === 'string') {
83
+ return Buffer.from(new TextEncoder().encode(chunk));
84
+ }
85
+ else if (chunk instanceof Buffer) {
86
+ return chunk;
87
+ }
88
+ else {
89
+ return Buffer.from(chunk);
90
+ }
91
+ }
19
92
  return {
20
- async writeToStream(name, _runId, chunk) {
21
- // Await runId if it's a promise to ensure proper flushing
22
- await _runId;
23
- const chunkId = `strm_${monotonicUlid()}`;
24
- if (typeof chunk === 'string') {
25
- chunk = new TextEncoder().encode(chunk);
26
- }
27
- const serialized = serializeChunk({
28
- chunk: Buffer.from(chunk),
29
- eof: false,
30
- });
31
- const chunkPath = path.join(basedir, 'streams', 'chunks', `${name}-${chunkId}.json`);
32
- await write(chunkPath, serialized);
33
- // Emit real-time event
34
- const chunkData = typeof chunk === 'string'
35
- ? new TextEncoder().encode(chunk)
36
- : chunk instanceof Buffer
37
- ? new Uint8Array(chunk)
38
- : chunk;
39
- streamEmitter.emit(`chunk:${name}`, {
40
- streamName: name,
41
- chunkData,
42
- chunkId,
43
- });
44
- },
45
- async closeStream(name, _runId) {
46
- // Await runId if it's a promise to ensure proper flushing
47
- await _runId;
48
- const chunkId = `strm_${monotonicUlid()}`;
49
- const chunkPath = path.join(basedir, 'streams', 'chunks', `${name}-${chunkId}.json`);
50
- await write(chunkPath, serializeChunk({ chunk: Buffer.from([]), eof: true }));
51
- streamEmitter.emit(`close:${name}`, { streamName: name });
52
- },
53
- async readFromStream(name, startIndex = 0) {
54
- const chunksDir = path.join(basedir, 'streams', 'chunks');
55
- let removeListeners = () => { };
56
- return new ReadableStream({
57
- async start(controller) {
58
- // Track chunks delivered via events to prevent duplicates and maintain order.
59
- const deliveredChunkIds = new Set();
60
- // Buffer for chunks that arrive via events during disk reading
61
- const bufferedEventChunks = [];
62
- let isReadingFromDisk = true;
63
- const chunkListener = (event) => {
64
- deliveredChunkIds.add(event.chunkId);
65
- if (isReadingFromDisk) {
66
- // Buffer chunks that arrive during disk reading to maintain order
67
- bufferedEventChunks.push({
68
- chunkId: event.chunkId,
69
- chunkData: event.chunkData,
70
- });
71
- }
72
- else {
73
- // After disk reading is complete, deliver chunks immediately
74
- controller.enqueue(event.chunkData);
75
- }
76
- };
77
- const closeListener = () => {
78
- // Remove listeners before closing
79
- streamEmitter.off(`chunk:${name}`, chunkListener);
80
- streamEmitter.off(`close:${name}`, closeListener);
81
- controller.close();
93
+ streams: {
94
+ async write(_runId, name, chunk) {
95
+ // Generate ULID synchronously BEFORE any await to preserve call order.
96
+ // This ensures that chunks written in sequence maintain their order even
97
+ // when runId is a promise that multiple writes are waiting on.
98
+ const chunkId = `chnk_${monotonicUlid()}`;
99
+ // Await runId if it's a promise to ensure proper flushing
100
+ const runId = await _runId;
101
+ // Register this stream for the run
102
+ await registerStreamForRun(runId, name);
103
+ // Convert chunk to buffer for serialization
104
+ const chunkBuffer = toBuffer(chunk);
105
+ const serialized = serializeChunk({
106
+ chunk: chunkBuffer,
107
+ eof: false,
108
+ });
109
+ const chunkPath = path.join(basedir, 'streams', 'chunks', `${name}-${chunkId}${tagSuffix}.bin`);
110
+ await write(chunkPath, serialized);
111
+ // Emit real-time event with Uint8Array (create copy to prevent ArrayBuffer detachment)
112
+ const chunkData = Uint8Array.from(chunkBuffer);
113
+ streamEmitter.emit(`chunk:${name}`, {
114
+ streamName: name,
115
+ chunkData,
116
+ chunkId,
117
+ });
118
+ },
119
+ async writeMulti(_runId, name, chunks) {
120
+ if (chunks.length === 0)
121
+ return;
122
+ // Generate all ULIDs synchronously BEFORE any await to preserve call order.
123
+ // This ensures that chunks maintain their order even when runId is a promise.
124
+ const chunkIds = chunks.map(() => `chnk_${monotonicUlid()}`);
125
+ // Await runId if it's a promise
126
+ const runId = await _runId;
127
+ // Register this stream for the run
128
+ await registerStreamForRun(runId, name);
129
+ // Prepare chunk data for parallel writes
130
+ const chunkBuffers = chunks.map((chunk) => toBuffer(chunk));
131
+ // Write all chunks in parallel for efficiency, but track individual completion
132
+ const writePromises = chunkBuffers.map(async (chunkBuffer, i) => {
133
+ const chunkId = chunkIds[i];
134
+ const serialized = serializeChunk({
135
+ chunk: chunkBuffer,
136
+ eof: false,
137
+ });
138
+ const chunkPath = path.join(basedir, 'streams', 'chunks', `${name}-${chunkId}${tagSuffix}.bin`);
139
+ await write(chunkPath, serialized);
140
+ // Return data needed for event emission
141
+ return {
142
+ chunkId,
143
+ chunkData: Uint8Array.from(chunkBuffer),
82
144
  };
83
- removeListeners = closeListener;
84
- // Set up listeners FIRST to avoid missing events
85
- streamEmitter.on(`chunk:${name}`, chunkListener);
86
- streamEmitter.on(`close:${name}`, closeListener);
87
- // Now load existing chunks from disk
88
- const files = await listJSONFiles(chunksDir);
89
- const chunkFiles = files
90
- .filter((file) => file.startsWith(`${name}-`))
91
- .sort(); // ULID lexicographic sort = chronological order
92
- // Process existing chunks, skipping any already delivered via events
93
- let isComplete = false;
94
- for (let i = startIndex; i < chunkFiles.length; i++) {
95
- const file = chunkFiles[i];
96
- // Extract chunk ID from filename: "streamName-chunkId"
97
- const chunkId = file.substring(name.length + 1);
98
- // Skip if already delivered via event
99
- if (deliveredChunkIds.has(chunkId)) {
100
- continue;
101
- }
102
- const chunk = deserializeChunk(await readBuffer(path.join(chunksDir, `${file}.json`)));
103
- if (chunk?.eof === true) {
104
- isComplete = true;
145
+ });
146
+ // Emit events in order, waiting for each chunk's write to complete
147
+ // This ensures events are emitted in order while writes happen in parallel
148
+ for (const writePromise of writePromises) {
149
+ const { chunkId, chunkData } = await writePromise;
150
+ streamEmitter.emit(`chunk:${name}`, {
151
+ streamName: name,
152
+ chunkData,
153
+ chunkId,
154
+ });
155
+ }
156
+ },
157
+ async close(_runId, name) {
158
+ // Generate ULID synchronously BEFORE any await to preserve call order.
159
+ const chunkId = `chnk_${monotonicUlid()}`;
160
+ // Await runId if it's a promise to ensure proper flushing
161
+ const runId = await _runId;
162
+ // Register this stream for the run (in case write wasn't called)
163
+ await registerStreamForRun(runId, name);
164
+ const chunkPath = path.join(basedir, 'streams', 'chunks', `${name}-${chunkId}${tagSuffix}.bin`);
165
+ await write(chunkPath, serializeChunk({ chunk: Buffer.from([]), eof: true }));
166
+ streamEmitter.emit(`close:${name}`, { streamName: name });
167
+ },
168
+ async list(runId) {
169
+ const data = await readJSONWithFallback(basedir, 'streams/runs', runId, RunStreamsSchema, tag);
170
+ return data?.streams ?? [];
171
+ },
172
+ async getChunks(_runId, name, options) {
173
+ const limit = options?.limit ?? 100;
174
+ const chunksDir = path.join(basedir, 'streams', 'chunks');
175
+ const { files: chunkFiles, extMap: fileExtMap } = await listChunkFilesForStream(chunksDir, name, tag);
176
+ // Decode cursor
177
+ let startIndex = 0;
178
+ if (options?.cursor) {
179
+ try {
180
+ const decoded = JSON.parse(Buffer.from(options.cursor, 'base64').toString('utf-8'));
181
+ startIndex = decoded.i;
182
+ }
183
+ catch {
184
+ startIndex = 0;
185
+ }
186
+ }
187
+ // Walk from startIndex, reading only the files we need.
188
+ // Files before the cursor are skipped entirely.
189
+ let streamDone = false;
190
+ const resultChunks = [];
191
+ let dataIndex = 0; // running count of data (non-EOF) files seen
192
+ for (const file of chunkFiles) {
193
+ const ext = fileExtMap.get(file) ?? '.bin';
194
+ const filePath = path.join(chunksDir, `${file}${ext}`);
195
+ // Before the cursor: only need to check EOF (1 byte), skip content
196
+ if (dataIndex < startIndex) {
197
+ if (isEofChunk(await readBuffer(filePath))) {
198
+ streamDone = true;
105
199
  break;
106
200
  }
107
- if (chunk.chunk.byteLength) {
108
- controller.enqueue(chunk.chunk);
201
+ dataIndex++;
202
+ continue;
203
+ }
204
+ // Collected enough data chunks — peek at the next file for EOF/hasMore
205
+ if (resultChunks.length >= limit) {
206
+ if (isEofChunk(await readBuffer(filePath))) {
207
+ streamDone = true;
109
208
  }
209
+ else {
210
+ // More data files exist beyond this page
211
+ dataIndex++;
212
+ }
213
+ break;
110
214
  }
111
- // Finished reading from disk - now deliver buffered event chunks in chronological order
112
- isReadingFromDisk = false;
113
- // Sort buffered chunks by ULID (chronological order)
114
- bufferedEventChunks.sort((a, b) => a.chunkId.localeCompare(b.chunkId));
115
- for (const buffered of bufferedEventChunks) {
116
- controller.enqueue(buffered.chunkData);
215
+ // In the page window: deserialize fully
216
+ const chunk = deserializeChunk(await readBuffer(filePath));
217
+ if (chunk.eof) {
218
+ streamDone = true;
219
+ break;
117
220
  }
118
- if (isComplete) {
119
- removeListeners();
120
- controller.close();
121
- return;
221
+ resultChunks.push({
222
+ index: dataIndex,
223
+ data: Uint8Array.from(chunk.chunk),
224
+ });
225
+ dataIndex++;
226
+ }
227
+ // hasMore = we know there are data files beyond this page
228
+ const hasMore = !streamDone && dataIndex > startIndex + resultChunks.length;
229
+ const nextIndex = startIndex + resultChunks.length;
230
+ const nextCursor = hasMore
231
+ ? Buffer.from(JSON.stringify({ i: nextIndex })).toString('base64')
232
+ : null;
233
+ return {
234
+ data: resultChunks,
235
+ cursor: nextCursor,
236
+ hasMore,
237
+ done: streamDone,
238
+ };
239
+ },
240
+ async getInfo(_runId, name) {
241
+ const chunksDir = path.join(basedir, 'streams', 'chunks');
242
+ const { files: chunkFiles, extMap: fileExtMap } = await listChunkFilesForStream(chunksDir, name, tag);
243
+ // Only read the first byte of each file to check EOF — no full
244
+ // deserialization needed since we just need a count.
245
+ let streamDone = false;
246
+ let dataCount = 0;
247
+ for (const file of chunkFiles) {
248
+ const ext = fileExtMap.get(file) ?? '.bin';
249
+ if (isEofChunk(await readBuffer(path.join(chunksDir, `${file}${ext}`)))) {
250
+ streamDone = true;
251
+ break;
122
252
  }
123
- },
124
- cancel() {
125
- removeListeners();
126
- },
127
- });
253
+ dataCount++;
254
+ }
255
+ return { tailIndex: dataCount - 1, done: streamDone };
256
+ },
257
+ async get(_runId, name, startIndex = 0) {
258
+ const chunksDir = path.join(basedir, 'streams', 'chunks');
259
+ let removeListeners = () => { };
260
+ let pollInterval = null;
261
+ return new ReadableStream({
262
+ async start(controller) {
263
+ // Track chunks delivered via events to prevent duplicates and maintain order.
264
+ const deliveredChunkIds = new Set();
265
+ // Buffer for chunks that arrive via events during disk reading
266
+ const bufferedEventChunks = [];
267
+ let isReadingFromDisk = true;
268
+ // Buffer close event if it arrives during disk reading
269
+ let pendingClose = false;
270
+ // Set when the controller is closed; guards against enqueue-after-close
271
+ // in the polling callback when closeListener fires mid-iteration.
272
+ let streamClosed = false;
273
+ const chunkListener = (event) => {
274
+ // Skip empty chunks to maintain consistency with disk reading behavior
275
+ if (event.chunkData.byteLength === 0) {
276
+ deliveredChunkIds.add(event.chunkId);
277
+ return;
278
+ }
279
+ if (isReadingFromDisk) {
280
+ deliveredChunkIds.add(event.chunkId);
281
+ // Buffer chunks that arrive during disk reading to maintain order
282
+ // Create a copy to prevent ArrayBuffer detachment when enqueued later
283
+ bufferedEventChunks.push({
284
+ chunkId: event.chunkId,
285
+ chunkData: Uint8Array.from(event.chunkData),
286
+ });
287
+ }
288
+ else if (!deliveredChunkIds.has(event.chunkId)) {
289
+ // Guard against duplicates: polling may have already claimed this
290
+ // chunk between its has() check and readBuffer() yield.
291
+ deliveredChunkIds.add(event.chunkId);
292
+ // After disk reading is complete, deliver chunks immediately
293
+ // Create a copy to prevent ArrayBuffer detachment
294
+ controller.enqueue(Uint8Array.from(event.chunkData));
295
+ }
296
+ };
297
+ const closeListener = () => {
298
+ // Buffer close event if disk reading is still in progress
299
+ if (isReadingFromDisk) {
300
+ pendingClose = true;
301
+ return;
302
+ }
303
+ // Remove listeners before closing
304
+ streamClosed = true;
305
+ streamEmitter.off(`chunk:${name}`, chunkListener);
306
+ streamEmitter.off(`close:${name}`, closeListener);
307
+ if (pollInterval) {
308
+ clearInterval(pollInterval);
309
+ pollInterval = null;
310
+ }
311
+ try {
312
+ controller.close();
313
+ }
314
+ catch {
315
+ // Ignore if controller is already closed (e.g., from cancel() or EOF)
316
+ }
317
+ };
318
+ removeListeners = closeListener;
319
+ // Set up listeners FIRST to avoid missing events
320
+ streamEmitter.on(`chunk:${name}`, chunkListener);
321
+ streamEmitter.on(`close:${name}`, closeListener);
322
+ // Now load existing chunks from disk.
323
+ const { files: chunkFiles, extMap: fileExtMap } = await listChunkFilesForStream(chunksDir, name, tag);
324
+ // Resolve negative startIndex relative to the number of data chunks
325
+ // (excluding the trailing EOF marker chunk, if present).
326
+ let dataChunkCount = chunkFiles.length;
327
+ if (typeof startIndex === 'number' &&
328
+ startIndex < 0 &&
329
+ chunkFiles.length > 0) {
330
+ const lastFile = chunkFiles[chunkFiles.length - 1];
331
+ const lastExt = fileExtMap.get(lastFile) ?? '.bin';
332
+ // Note: this incurs an extra disk read to check the EOF marker.
333
+ // Acceptable since negative startIndex is not a hot path.
334
+ const lastChunk = deserializeChunk(await readBuffer(path.join(chunksDir, `${lastFile}${lastExt}`)));
335
+ if (lastChunk?.eof === true) {
336
+ dataChunkCount--;
337
+ }
338
+ }
339
+ const resolvedStartIndex = typeof startIndex === 'number' && startIndex < 0
340
+ ? Math.max(0, dataChunkCount + startIndex)
341
+ : startIndex;
342
+ // Process existing chunks, skipping any already delivered via events
343
+ let isComplete = false;
344
+ for (let i = resolvedStartIndex; i < chunkFiles.length; i++) {
345
+ const file = chunkFiles[i];
346
+ // Extract chunk ID from filename: "streamName-chunkId" or "streamName-chunkId.tag"
347
+ const rawChunkId = file.substring(name.length + 1);
348
+ // Strip tag suffix (e.g., "chnk_ULID.vitest-0" → "chnk_ULID")
349
+ const chunkId = tag
350
+ ? rawChunkId.replace(`.${tag}`, '')
351
+ : rawChunkId;
352
+ // Skip if already delivered via event
353
+ if (deliveredChunkIds.has(chunkId)) {
354
+ continue;
355
+ }
356
+ const ext = fileExtMap.get(file) ?? '.bin';
357
+ const chunk = deserializeChunk(await readBuffer(path.join(chunksDir, `${file}${ext}`)));
358
+ if (chunk?.eof === true) {
359
+ isComplete = true;
360
+ break;
361
+ }
362
+ // Track as handled so polling doesn't re-deliver
363
+ deliveredChunkIds.add(chunkId);
364
+ if (chunk.chunk.byteLength) {
365
+ // Create a copy to prevent ArrayBuffer detachment
366
+ controller.enqueue(Uint8Array.from(chunk.chunk));
367
+ }
368
+ }
369
+ // Finished reading from disk - now deliver buffered event chunks in chronological order
370
+ isReadingFromDisk = false;
371
+ // Sort buffered chunks by ULID (chronological order)
372
+ bufferedEventChunks.sort((a, b) => a.chunkId.localeCompare(b.chunkId));
373
+ for (const buffered of bufferedEventChunks) {
374
+ // Create a copy for defense in depth (already copied at storage, but be extra safe)
375
+ controller.enqueue(Uint8Array.from(buffered.chunkData));
376
+ }
377
+ if (isComplete) {
378
+ removeListeners();
379
+ try {
380
+ controller.close();
381
+ }
382
+ catch {
383
+ // Ignore if controller is already closed (e.g., from closeListener event)
384
+ }
385
+ return;
386
+ }
387
+ // Process any pending close event that arrived during disk reading
388
+ if (pendingClose) {
389
+ streamEmitter.off(`chunk:${name}`, chunkListener);
390
+ streamEmitter.off(`close:${name}`, closeListener);
391
+ try {
392
+ controller.close();
393
+ }
394
+ catch {
395
+ // Ignore if controller is already closed
396
+ }
397
+ return;
398
+ }
399
+ // Track pre-startIndex chunks so polling doesn't re-deliver them
400
+ for (let i = 0; i < resolvedStartIndex && i < chunkFiles.length; i++) {
401
+ const file = chunkFiles[i];
402
+ const rawChunkId = file.substring(name.length + 1);
403
+ const chunkId = tag
404
+ ? rawChunkId.replace(`.${tag}`, '')
405
+ : rawChunkId;
406
+ deliveredChunkIds.add(chunkId);
407
+ }
408
+ // Start filesystem polling for cross-process streaming support.
409
+ // The EventEmitter only works in-process; when the writer is in a
410
+ // separate process (e.g. e2e test runner ↔ workbench app), polling
411
+ // the shared filesystem is the fallback delivery mechanism.
412
+ let isPolling = false;
413
+ pollInterval = setInterval(async () => {
414
+ if (isPolling)
415
+ return;
416
+ isPolling = true;
417
+ try {
418
+ const { files: currentFiles, extMap: currentExtMap } = await listChunkFilesForStream(chunksDir, name, tag);
419
+ for (const file of currentFiles) {
420
+ const rawChunkId = file.substring(name.length + 1);
421
+ const chunkId = tag
422
+ ? rawChunkId.replace(`.${tag}`, '')
423
+ : rawChunkId;
424
+ if (deliveredChunkIds.has(chunkId))
425
+ continue;
426
+ deliveredChunkIds.add(chunkId);
427
+ const ext = currentExtMap.get(file) ?? '.bin';
428
+ const chunk = deserializeChunk(await readBuffer(path.join(chunksDir, `${file}${ext}`)));
429
+ if (chunk?.eof === true) {
430
+ streamClosed = true;
431
+ if (pollInterval) {
432
+ clearInterval(pollInterval);
433
+ pollInterval = null;
434
+ }
435
+ streamEmitter.off(`chunk:${name}`, chunkListener);
436
+ streamEmitter.off(`close:${name}`, closeListener);
437
+ try {
438
+ controller.close();
439
+ }
440
+ catch {
441
+ // Ignore if controller is already closed
442
+ }
443
+ return;
444
+ }
445
+ // Guard against enqueue-after-close: closeListener may have
446
+ // fired between our readBuffer() yield and this point.
447
+ if (streamClosed)
448
+ return;
449
+ if (chunk.chunk.byteLength) {
450
+ controller.enqueue(Uint8Array.from(chunk.chunk));
451
+ }
452
+ }
453
+ }
454
+ catch (err) {
455
+ // Silently ignore transient filesystem errors (ENOENT, EACCES, etc.)
456
+ // Surface unexpected errors so bugs aren't hidden
457
+ if (!(err instanceof Error && 'code' in err)) {
458
+ console.error('[world-local] Unexpected polling error:', err);
459
+ }
460
+ }
461
+ finally {
462
+ isPolling = false;
463
+ }
464
+ }, 100);
465
+ },
466
+ cancel() {
467
+ removeListeners();
468
+ if (pollInterval) {
469
+ clearInterval(pollInterval);
470
+ pollInterval = null;
471
+ }
472
+ },
473
+ });
474
+ },
128
475
  },
129
476
  };
130
477
  }