@wringer/stream 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +75 -2
- package/dist/append-whole-text.d.ts +1 -0
- package/dist/build-file-step.d.ts +16 -0
- package/dist/build-stream-slice.d.ts +12 -0
- package/dist/collect-lines.d.ts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/pick-command-stop-delay.d.ts +6 -0
- package/dist/pick-file-read.d.ts +10 -0
- package/dist/read-file-from.d.ts +11 -0
- package/dist/read-stream-slice.d.ts +8 -0
- package/dist/record-writer-machine.d.ts +65 -0
- package/dist/source-phase-machine.d.ts +21 -0
- package/dist/start-stream-recorder.d.ts +39 -0
- package/dist/stream.d.ts +29 -0
- package/dist/types.d.ts +2 -0
- package/dist/wait-or-abort.d.ts +1 -0
- package/package.json +47 -4
- package/src/append-whole-text.ts +30 -0
- package/src/build-file-step.ts +39 -0
- package/src/build-stream-slice.ts +34 -0
- package/src/collect-lines.ts +13 -0
- package/src/index.ts +5 -0
- package/src/pick-command-stop-delay.ts +23 -0
- package/src/pick-file-read.ts +22 -0
- package/src/read-file-from.ts +54 -0
- package/src/read-stream-slice.ts +84 -0
- package/src/record-writer-machine.ts +185 -0
- package/src/source-phase-machine.ts +132 -0
- package/src/start-stream-recorder.ts +354 -0
- package/src/stream.ts +175 -0
- package/src/types.ts +4 -0
- package/src/wait-or-abort.ts +7 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { WringerError } from '@wringer/core';
|
|
3
|
+
import type { Access, Host } from '@wringer/core';
|
|
4
|
+
import { createActor } from 'xstate';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { buildFileStep } from './build-file-step.ts';
|
|
7
|
+
import type { FileFollowState } from './build-file-step.ts';
|
|
8
|
+
import { collectLines } from './collect-lines.ts';
|
|
9
|
+
import { pickCommandStopDelay } from './pick-command-stop-delay.ts';
|
|
10
|
+
import type { FileChunk, FilePosition } from './read-file-from.ts';
|
|
11
|
+
import { recordWriterMachine } from './record-writer-machine.ts';
|
|
12
|
+
import { sourcePhaseMachine } from './source-phase-machine.ts';
|
|
13
|
+
import type { SourceDetails, SourcePhase } from './source-phase-machine.ts';
|
|
14
|
+
|
|
15
|
+
const commandSourceSchema = z.strictObject({ command: z.array(z.string().min(1)).min(1) });
|
|
16
|
+
const fileSourceSchema = z.strictObject({ file: z.string().min(1) });
|
|
17
|
+
|
|
18
|
+
export const streamSourceSchema = z.union([commandSourceSchema, fileSourceSchema]);
|
|
19
|
+
|
|
20
|
+
export type StreamSource = z.infer<typeof streamSourceSchema>;
|
|
21
|
+
|
|
22
|
+
export interface RecorderRequest {
|
|
23
|
+
readonly sources: Readonly<Record<string, StreamSource>>;
|
|
24
|
+
readonly directory: string;
|
|
25
|
+
readonly host: Host;
|
|
26
|
+
readonly access: Access;
|
|
27
|
+
readonly now: () => Date;
|
|
28
|
+
readonly createDirectory: (path: string) => Promise<void>;
|
|
29
|
+
readonly appendText: (path: string, text: string) => Promise<void>;
|
|
30
|
+
readonly readFileFrom: (
|
|
31
|
+
path: string,
|
|
32
|
+
from: FilePosition,
|
|
33
|
+
limitBytes: number,
|
|
34
|
+
) => Promise<FileChunk>;
|
|
35
|
+
|
|
36
|
+
// A file source polls through this; it settles early, without rejecting, once the signal aborts.
|
|
37
|
+
readonly wait: (delayMs: number, signal: AbortSignal) => Promise<void>;
|
|
38
|
+
|
|
39
|
+
// At most this many records wait behind the batch being written; a source that logs faster loses
|
|
40
|
+
// the excess, and the stream says how many lines it lost.
|
|
41
|
+
readonly pendingLimit: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const sourceStatusSchema = z.strictObject({
|
|
45
|
+
name: z.string(),
|
|
46
|
+
state: z.enum(['running', 'exited', 'failed']),
|
|
47
|
+
detail: z.string().nullable(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
type SourceStatus = z.infer<typeof sourceStatusSchema>;
|
|
51
|
+
|
|
52
|
+
export interface StreamRecorder {
|
|
53
|
+
readonly status: () => readonly SourceStatus[];
|
|
54
|
+
readonly stop: () => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Follows each source for the whole run and appends each line to `<name>.ndjson` as `{ at, line }`.
|
|
58
|
+
// A `command` source runs on the target's host; a `file` source is read where the run lives.
|
|
59
|
+
export async function startStreamRecorder(request: RecorderRequest): Promise<StreamRecorder> {
|
|
60
|
+
await request.createDirectory(request.directory);
|
|
61
|
+
|
|
62
|
+
const lifetime = new AbortController();
|
|
63
|
+
|
|
64
|
+
const followers = Object.entries(request.sources).map(([name, source]) =>
|
|
65
|
+
followSource({ ...request, name, source, signal: lifetime.signal }),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
status: () => followers.map((follower) => follower.status()),
|
|
70
|
+
stop: async () => {
|
|
71
|
+
lifetime.abort();
|
|
72
|
+
|
|
73
|
+
const readFailures = await Promise.all(followers.map((follower) => follower.done));
|
|
74
|
+
const settled = await Promise.allSettled(followers.map((follower) => follower.drain()));
|
|
75
|
+
|
|
76
|
+
const failures = [
|
|
77
|
+
...readFailures.flatMap((failure) => (failure === null ? [] : [failure])),
|
|
78
|
+
...settled.flatMap((result) =>
|
|
79
|
+
result.status === 'rejected' ? [String(result.reason)] : [],
|
|
80
|
+
),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
if (failures.length > 0) {
|
|
84
|
+
throw new WringerError('environment', {
|
|
85
|
+
message: `The recorder lost records: ${failures.join('; ')}`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface FollowSourceRequest extends RecorderRequest {
|
|
93
|
+
readonly name: string;
|
|
94
|
+
readonly source: StreamSource;
|
|
95
|
+
readonly signal: AbortSignal;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Follower {
|
|
99
|
+
readonly status: () => SourceStatus;
|
|
100
|
+
|
|
101
|
+
// Settles once the source ends, with why reading it failed, or null when it ended without a fault.
|
|
102
|
+
readonly done: Promise<string | null>;
|
|
103
|
+
readonly drain: () => Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ReportedPhase {
|
|
107
|
+
readonly state: SourceStatus['state'];
|
|
108
|
+
readonly detail: keyof SourceDetails | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reportedPhases: Readonly<Record<SourcePhase, ReportedPhase>> = {
|
|
112
|
+
running: { state: 'running', detail: null },
|
|
113
|
+
'running-unwritten': { state: 'failed', detail: 'write' },
|
|
114
|
+
exited: { state: 'exited', detail: 'reading' },
|
|
115
|
+
'exited-unwritten': { state: 'failed', detail: 'write' },
|
|
116
|
+
failed: { state: 'failed', detail: 'reading' },
|
|
117
|
+
'failed-unwritten': { state: 'failed', detail: 'write' },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export function buildSourceStatus(
|
|
121
|
+
name: string,
|
|
122
|
+
phase: SourcePhase,
|
|
123
|
+
details: SourceDetails,
|
|
124
|
+
): SourceStatus {
|
|
125
|
+
const reported = reportedPhases[phase];
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name,
|
|
129
|
+
state: reported.state,
|
|
130
|
+
detail: reported.detail === null ? null : details[reported.detail],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function followSource(request: FollowSourceRequest): Follower {
|
|
135
|
+
const path = join(request.directory, `${request.name}.ndjson`);
|
|
136
|
+
const phase = createActor(sourcePhaseMachine);
|
|
137
|
+
|
|
138
|
+
phase.start();
|
|
139
|
+
|
|
140
|
+
const writer = makeRecordWriter({
|
|
141
|
+
path,
|
|
142
|
+
now: request.now,
|
|
143
|
+
appendText: request.appendText,
|
|
144
|
+
pendingLimit: request.pendingLimit,
|
|
145
|
+
onError: (error) => {
|
|
146
|
+
phase.send({
|
|
147
|
+
type: 'write-failed',
|
|
148
|
+
detail: `The recorder could not write: ${String(error)}`,
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
onRecovered: () => {
|
|
152
|
+
phase.send({ type: 'write-recovered', detail: null });
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const onLine = (line: string): void => {
|
|
157
|
+
writer.add(JSON.stringify({ at: request.now().toISOString(), line: capLine(line) }));
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const watch = async (): Promise<string | null> => {
|
|
161
|
+
try {
|
|
162
|
+
const exitCode = await runFollow(request, onLine);
|
|
163
|
+
|
|
164
|
+
phase.send({ type: 'exit', detail: `exit code ${exitCode}` });
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
phase.send({ type: 'fail', detail: String(error) });
|
|
169
|
+
|
|
170
|
+
return `${request.name} could not be read: ${String(error)}`;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
status: () => {
|
|
176
|
+
const snapshot = phase.getSnapshot();
|
|
177
|
+
|
|
178
|
+
return buildSourceStatus(request.name, snapshot.context.phase, snapshot.context.details);
|
|
179
|
+
},
|
|
180
|
+
done: watch(),
|
|
181
|
+
drain: writer.drain,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface RecordWriterOptions {
|
|
186
|
+
readonly path: string;
|
|
187
|
+
readonly now: () => Date;
|
|
188
|
+
readonly appendText: (path: string, text: string) => Promise<void>;
|
|
189
|
+
readonly pendingLimit: number;
|
|
190
|
+
readonly onError: (error: unknown) => void;
|
|
191
|
+
readonly onRecovered: () => void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface RecordWriter {
|
|
195
|
+
readonly add: (record: string) => void;
|
|
196
|
+
readonly drain: () => Promise<void>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function makeRecordWriter(options: RecordWriterOptions): RecordWriter {
|
|
200
|
+
const writer = createActor(recordWriterMachine, {
|
|
201
|
+
input: {
|
|
202
|
+
...options,
|
|
203
|
+
write: (text: string) => options.appendText(options.path, text),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
writer.start();
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
add: (record) => {
|
|
211
|
+
writer.send({ type: 'add', record });
|
|
212
|
+
},
|
|
213
|
+
drain: async () => {
|
|
214
|
+
const settle = Promise.withResolvers<null>();
|
|
215
|
+
|
|
216
|
+
writer.send({ type: 'drain', settle });
|
|
217
|
+
|
|
218
|
+
await settle.promise;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A record holds at most this many characters of its line, so every record fits well inside the
|
|
224
|
+
// smallest slice a reader takes.
|
|
225
|
+
const recordLineCap = 4 * 1024;
|
|
226
|
+
|
|
227
|
+
function capLine(line: string): string {
|
|
228
|
+
return line.length > recordLineCap ? `${line.slice(0, recordLineCap)}...` : line;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function runFollow(request: FollowSourceRequest, onLine: (line: string) => void): Promise<number> {
|
|
232
|
+
if ('command' in request.source) {
|
|
233
|
+
return followCommand(request, request.source.command, onLine);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return followFile(request, request.source.file, onLine);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A follow command, such as `tail -F`, prints each line as it reads it, so the recorder reads on
|
|
240
|
+
// until the command's output goes quiet before it kills the command.
|
|
241
|
+
async function followCommand(
|
|
242
|
+
request: FollowSourceRequest,
|
|
243
|
+
argv: readonly string[],
|
|
244
|
+
onLine: (line: string) => void,
|
|
245
|
+
): Promise<number> {
|
|
246
|
+
const follow = new AbortController();
|
|
247
|
+
|
|
248
|
+
let lastLineMs: number | null = null;
|
|
249
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
250
|
+
|
|
251
|
+
const checkQuiet = (stopMs: number): void => {
|
|
252
|
+
const delayMs = pickCommandStopDelay({ nowMs: performance.now(), stopMs, lastLineMs });
|
|
253
|
+
|
|
254
|
+
if (delayMs === 0) {
|
|
255
|
+
follow.abort();
|
|
256
|
+
} else {
|
|
257
|
+
timer = setTimeout(checkQuiet, delayMs, stopMs);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const onStop = (): void => {
|
|
262
|
+
checkQuiet(performance.now());
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
request.signal.addEventListener('abort', onStop, { once: true });
|
|
266
|
+
|
|
267
|
+
if (request.signal.aborted) {
|
|
268
|
+
follow.abort();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
return await request.host.follow({
|
|
273
|
+
argv,
|
|
274
|
+
access: request.access,
|
|
275
|
+
signal: follow.signal,
|
|
276
|
+
onLine: (line) => {
|
|
277
|
+
lastLineMs = performance.now();
|
|
278
|
+
|
|
279
|
+
onLine(line);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
} finally {
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
|
|
285
|
+
request.signal.removeEventListener('abort', onStop);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const filePollMs = 100;
|
|
290
|
+
|
|
291
|
+
// A file source can hold a large log before the run starts, so each read takes at most this much,
|
|
292
|
+
// and the recorder reads again until it reaches the end.
|
|
293
|
+
const fileReadLimitBytes = 64 * 1024;
|
|
294
|
+
|
|
295
|
+
// A file that a writer keeps growing never reads to its end, so one pass, the final one at a stop
|
|
296
|
+
// included, takes at most this many reads.
|
|
297
|
+
const fileReadsPerPass = 256;
|
|
298
|
+
|
|
299
|
+
// The recorder reads a file source itself, so its stop can read the file to its end once the app
|
|
300
|
+
// has stopped writing; a killed `tail` would drop what it had yet to read. A file source ends with
|
|
301
|
+
// exit code 0 at the recorder's stop.
|
|
302
|
+
async function followFile(
|
|
303
|
+
request: FollowSourceRequest,
|
|
304
|
+
path: string,
|
|
305
|
+
onLine: (line: string) => void,
|
|
306
|
+
): Promise<number> {
|
|
307
|
+
let decoder = new TextDecoder();
|
|
308
|
+
|
|
309
|
+
let state: FileFollowState = { position: { offset: 0, identity: null }, carry: '' };
|
|
310
|
+
|
|
311
|
+
const readNew = async (): Promise<void> => {
|
|
312
|
+
let more = true;
|
|
313
|
+
|
|
314
|
+
for (let reads = 0; more && reads < fileReadsPerPass; reads += 1) {
|
|
315
|
+
const chunk = await request.readFileFrom(path, state.position, fileReadLimitBytes);
|
|
316
|
+
|
|
317
|
+
const step = buildFileStep(state, {
|
|
318
|
+
start: chunk.start,
|
|
319
|
+
identity: chunk.identity,
|
|
320
|
+
byteCount: chunk.bytes.length,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// A fresh decoder drops the old file's unfinished character along with its line.
|
|
324
|
+
if (step.restarted) {
|
|
325
|
+
decoder = new TextDecoder();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const text = decoder.decode(chunk.bytes, { stream: true });
|
|
329
|
+
const split = collectLines(step.state.carry, text, recordLineCap);
|
|
330
|
+
|
|
331
|
+
state = { position: step.state.position, carry: split.carry };
|
|
332
|
+
more = step.more;
|
|
333
|
+
|
|
334
|
+
for (const line of split.lines) {
|
|
335
|
+
onLine(line);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
while (!request.signal.aborted) {
|
|
341
|
+
await readNew();
|
|
342
|
+
|
|
343
|
+
// Bun.sleep takes no signal, and this wait has to end at the stop.
|
|
344
|
+
await request.wait(filePollMs, request.signal);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await readNew();
|
|
348
|
+
|
|
349
|
+
if (state.carry !== '') {
|
|
350
|
+
onLine(state.carry);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { WringerError, createDirectory, defineDriver, findOwnValue } from '@wringer/core';
|
|
5
|
+
import type { DriverSession, Fence, RunHandle } from '@wringer/core';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { appendWholeText } from './append-whole-text.ts';
|
|
8
|
+
import { streamRecordSchema } from './build-stream-slice.ts';
|
|
9
|
+
import type { StreamSlice } from './build-stream-slice.ts';
|
|
10
|
+
import { readFileFrom } from './read-file-from.ts';
|
|
11
|
+
import { readStreamSlice } from './read-stream-slice.ts';
|
|
12
|
+
import type { StreamSliceRequest } from './read-stream-slice.ts';
|
|
13
|
+
import {
|
|
14
|
+
sourceStatusSchema,
|
|
15
|
+
startStreamRecorder,
|
|
16
|
+
streamSourceSchema,
|
|
17
|
+
} from './start-stream-recorder.ts';
|
|
18
|
+
import type { StreamSource } from './start-stream-recorder.ts';
|
|
19
|
+
import { waitOrAbort } from './wait-or-abort.ts';
|
|
20
|
+
|
|
21
|
+
const streamNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/u);
|
|
22
|
+
|
|
23
|
+
// The recorder caps a record's line at 4096 characters, which JSON escaping can grow to about
|
|
24
|
+
// 24 KiB, so a smaller slice could hold no whole record and never advance its cursor.
|
|
25
|
+
const minimumSliceBytes = 32 * 1024;
|
|
26
|
+
|
|
27
|
+
export const streamReadSchema = z.strictObject({
|
|
28
|
+
name: streamNameSchema,
|
|
29
|
+
from: z.number().int().nonnegative().optional(),
|
|
30
|
+
maxBytes: z.number().int().min(minimumSliceBytes).optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const streamSliceSchema = z.strictObject({
|
|
34
|
+
name: z.string(),
|
|
35
|
+
records: z.array(streamRecordSchema),
|
|
36
|
+
cursor: z.number().int().nonnegative(),
|
|
37
|
+
more: z.boolean(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const optionsSchema = z.strictObject({
|
|
41
|
+
maxBytes: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(64 * 1024)
|
|
45
|
+
.default(64 * 1024),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const targetConfigSchema = z.strictObject({
|
|
49
|
+
sources: z.record(streamNameSchema, streamSourceSchema).optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const statusListSchema = z.array(sourceStatusSchema);
|
|
53
|
+
|
|
54
|
+
// Records a target's logs for the whole run, in the run's supervisor, and reads them back from a
|
|
55
|
+
// cursor. A read with no `from` starts at the cursor that `use` last marked, so an agent reads only
|
|
56
|
+
// what its own steps logged.
|
|
57
|
+
export const stream = defineDriver({
|
|
58
|
+
name: 'stream',
|
|
59
|
+
options: optionsSchema,
|
|
60
|
+
targetConfig: targetConfigSchema,
|
|
61
|
+
services: {
|
|
62
|
+
recorder: {
|
|
63
|
+
start: async (ctx) => {
|
|
64
|
+
const recorder = await startStreamRecorder({
|
|
65
|
+
sources: pickSources(ctx.config.sources, ctx.run),
|
|
66
|
+
directory: ctx.run.paths.streams,
|
|
67
|
+
host: ctx.host,
|
|
68
|
+
access: ctx.access,
|
|
69
|
+
now: () => new Date(),
|
|
70
|
+
createDirectory,
|
|
71
|
+
appendText: appendWholeText,
|
|
72
|
+
readFileFrom: (path, from, limitBytes) =>
|
|
73
|
+
readFileFrom(path, from, limitBytes, openForRead),
|
|
74
|
+
wait: waitOrAbort,
|
|
75
|
+
pendingLimit: 10_000,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
methods: { status: () => Promise.resolve(recorder.status()) },
|
|
80
|
+
stop: recorder.stop,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
client: (session, fence) =>
|
|
86
|
+
createStreamClient(session, fence, (request) => readStreamSlice(request, openForRead)),
|
|
87
|
+
commands: (command) => [
|
|
88
|
+
command({
|
|
89
|
+
path: ['stream', 'read'],
|
|
90
|
+
summary: 'Read a stream from the cursor that `use` marked, or from --from',
|
|
91
|
+
args: streamReadSchema,
|
|
92
|
+
positionals: ['name'],
|
|
93
|
+
data: streamSliceSchema,
|
|
94
|
+
effect: 'read',
|
|
95
|
+
run: (args, ctx) => ctx.client.read(args),
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
checks: [
|
|
99
|
+
{
|
|
100
|
+
name: 'recording',
|
|
101
|
+
timeoutMs: 5000,
|
|
102
|
+
run: async (ctx) => {
|
|
103
|
+
const answer = await ctx.session.service('recorder').call('status', null);
|
|
104
|
+
|
|
105
|
+
const statuses = statusListSchema.parse(answer);
|
|
106
|
+
const stopped = statuses.filter((status) => status.state !== 'running');
|
|
107
|
+
|
|
108
|
+
if (stopped.length > 0) {
|
|
109
|
+
throw new WringerError('check-failed', {
|
|
110
|
+
message: `These streams stopped recording: ${stopped.map((status) => `${status.name} (${status.detail ?? status.state})`).join(', ')}.`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// With no sources, the driver records the app the run launched.
|
|
119
|
+
function pickSources(
|
|
120
|
+
sources: Readonly<Record<string, StreamSource>> | undefined,
|
|
121
|
+
run: RunHandle,
|
|
122
|
+
): Readonly<Record<string, StreamSource>> {
|
|
123
|
+
return sources ?? { app: { file: run.childLog('app').path } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function openForRead(path: string): Promise<FileHandle> {
|
|
127
|
+
return open(path, 'r');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type StreamSession = DriverSession<
|
|
131
|
+
z.infer<typeof optionsSchema>,
|
|
132
|
+
z.infer<typeof targetConfigSchema>
|
|
133
|
+
>;
|
|
134
|
+
|
|
135
|
+
type ReadSlice = (request: StreamSliceRequest) => Promise<StreamSlice>;
|
|
136
|
+
|
|
137
|
+
export type StreamRead = z.input<typeof streamReadSchema>;
|
|
138
|
+
|
|
139
|
+
export type StreamSliceData = z.infer<typeof streamSliceSchema>;
|
|
140
|
+
|
|
141
|
+
export interface StreamClient {
|
|
142
|
+
readonly read: (request: StreamRead) => Promise<StreamSliceData>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function createStreamClient(
|
|
146
|
+
session: StreamSession,
|
|
147
|
+
fence: Fence,
|
|
148
|
+
readSlice: ReadSlice,
|
|
149
|
+
): StreamClient {
|
|
150
|
+
return {
|
|
151
|
+
read: fence({
|
|
152
|
+
effect: () => 'read',
|
|
153
|
+
describe: (request: StreamRead) => `stream read ${request.name}`,
|
|
154
|
+
run: async (request) => {
|
|
155
|
+
const parsed = streamReadSchema.parse(request);
|
|
156
|
+
const sources = pickSources(session.config.sources, session.run);
|
|
157
|
+
|
|
158
|
+
if (!Object.hasOwn(sources, parsed.name)) {
|
|
159
|
+
throw new WringerError('usage', {
|
|
160
|
+
message: `Target ${session.target} records no stream named ${parsed.name}.`,
|
|
161
|
+
hint: `Read one of: ${Object.keys(sources).join(', ')}.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const slice = await readSlice({
|
|
166
|
+
path: join(session.run.paths.streams, `${parsed.name}.ndjson`),
|
|
167
|
+
from: parsed.from ?? findOwnValue(session.cursors, parsed.name) ?? 0,
|
|
168
|
+
maxBytes: Math.min(parsed.maxBytes ?? session.options.maxBytes, session.options.maxBytes),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return { name: parsed.name, ...slice, records: [...slice.records] };
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { setTimeout } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
// Settles after the delay, or as soon as the signal aborts, and never rejects: a poll loop checks
|
|
4
|
+
// the signal itself after each wait.
|
|
5
|
+
export async function waitOrAbort(delayMs: number, signal: AbortSignal): Promise<void> {
|
|
6
|
+
await setTimeout(delayMs, undefined, { signal }).catch(() => null);
|
|
7
|
+
}
|