opencode-drive 0.1.7 → 0.1.9

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/src/cli/start.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { launchInstance } from "./instance.js"
2
2
  import { mkdir, rm } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
+ import { connectSimulation } from "../client/index.js"
5
+ import { exportRecording } from "../recording/index.js"
4
6
  import { connectMockBackend } from "./mock-backend.js"
5
7
  import { createResponseSettings } from "./response-generator.js"
6
- import { runScript } from "./script.js"
8
+ import { loadScript, runScript } from "./script.js"
9
+ import type { DriveScript } from "./script.js"
7
10
  import { listenControl } from "./control.js"
8
11
  import {
9
12
  controlPath,
@@ -19,14 +22,16 @@ import type { StartOptions } from "./types.js"
19
22
  export async function start(options: StartOptions) {
20
23
  if (!options.visible && !options.script && !options.daemon)
21
24
  return startDetached(options)
25
+ const script = options.script ? await loadScript(options.script) : undefined
22
26
  const responses = createResponseSettings()
23
27
  const instance = await launchInstance({
24
28
  name: options.name,
25
29
  command: options.command,
26
30
  dev: options.dev,
27
- state: options.state,
28
31
  scripted: options.script !== undefined,
29
32
  visible: options.visible,
33
+ record: options.record,
34
+ setup: script?.setup,
30
35
  })
31
36
  await register({
32
37
  version: 1,
@@ -43,13 +48,48 @@ export async function start(options: StartOptions) {
43
48
  await instance.stop()
44
49
  throw error
45
50
  })
46
- const interrupt = () => void instance.stop()
47
51
  let completed = false
48
52
  let current: ReturnType<typeof run> | undefined
49
- let restarting: Promise<void> | undefined
53
+ let restarting: Promise<string | undefined> | undefined
50
54
  let stopping = false
55
+ const screenshots: string[] = []
56
+ let driveReady = false
57
+ let recording: Promise<string | undefined> | undefined
58
+ const finishCurrentRecording = (onProgress?: (percent: number) => void) => {
59
+ if (!options.record || options.visible || !driveReady)
60
+ return Promise.resolve(undefined)
61
+ recording ??= finishRecording(instance, onProgress)
62
+ return recording
63
+ }
64
+ const interrupt = () => {
65
+ stopping = true
66
+ current?.abort.abort(new Error("opencode-drive interrupted"))
67
+ void finishCurrentRecording()
68
+ .catch((error) =>
69
+ process.stderr.write(`opencode-drive: failed to export recording: ${error}\n`),
70
+ )
71
+ .finally(() => instance.stop())
72
+ }
51
73
  process.once("SIGINT", interrupt)
52
74
  process.once("SIGTERM", interrupt)
75
+ const stopInstance = async (onProgress?: (percent: number) => void) => {
76
+ try {
77
+ const output = await finishCurrentRecording(onProgress)
78
+ return {
79
+ ...(output ? { recording: output } : {}),
80
+ screenshots: [...screenshots],
81
+ }
82
+ } finally {
83
+ stopping = true
84
+ current?.abort.abort(new Error("opencode-drive stopped"))
85
+ await instance.stop()
86
+ }
87
+ }
88
+ const completeScript = async () => {
89
+ completed = true
90
+ const result = await stopInstance()
91
+ for (const screenshot of result.screenshots) console.log(screenshot)
92
+ }
53
93
  let closeControl: (() => Promise<void>) | undefined
54
94
  try {
55
95
  closeControl = await listenControl(controlPath(options.name), {
@@ -57,34 +97,49 @@ export async function start(options: StartOptions) {
57
97
  if (restarting) return restarting
58
98
  restarting = (async () => {
59
99
  await markStarting(options.name, process.pid)
100
+ const output = await finishCurrentRecording()
60
101
  const previous = current
61
102
  previous?.abort.abort(new Error("script restarted"))
62
103
  await previous?.promise.catch(() => undefined)
104
+ driveReady = false
63
105
  await instance.restart()
64
- current = run(options, instance, responses)
106
+ recording = undefined
107
+ current = run(options, instance, responses, script?.run, (path) =>
108
+ screenshots.push(path),
109
+ )
65
110
  await current.ready
111
+ driveReady = true
66
112
  await markReady(options.name, process.pid)
113
+ return output
67
114
  })().finally(() => {
68
115
  restarting = undefined
69
116
  })
70
117
  return restarting
71
118
  },
72
- stop: async () => {
73
- stopping = true
74
- current?.abort.abort(new Error("opencode-drive stopped"))
75
- await instance.stop()
76
- },
119
+ stop: stopInstance,
77
120
  responses: async (input) => {
78
121
  if (options.script)
79
122
  throw new Error("responses are unavailable when --script owns the simulation backend")
80
123
  return responses.update(input)
81
124
  },
82
125
  })
83
- current = run(options, instance, responses)
126
+ current = run(options, instance, responses, script?.run, (path) =>
127
+ screenshots.push(path),
128
+ )
84
129
  await current.ready
130
+ driveReady = true
85
131
  await markReady(options.name, process.pid)
86
132
  if (options.visible) {
87
- const status = await instance.wait()
133
+ const result = options.script
134
+ ? await Promise.race([
135
+ current.promise.then(() => ({ script: true as const })),
136
+ instance.wait().then((status) => ({ script: false as const, status })),
137
+ ])
138
+ : { script: false as const, status: await instance.wait() }
139
+ if (result.script) {
140
+ await completeScript()
141
+ }
142
+ const status = result.script ? await instance.wait() : result.status
88
143
  if (status !== 0 && !stopping) process.exitCode = status
89
144
  return
90
145
  }
@@ -97,6 +152,10 @@ export async function start(options: StartOptions) {
97
152
  continue
98
153
  }
99
154
  if (active !== current) continue
155
+ if (options.script) {
156
+ await completeScript()
157
+ break
158
+ }
100
159
  completed = true
101
160
  break
102
161
  }
@@ -104,14 +163,45 @@ export async function start(options: StartOptions) {
104
163
  process.off("SIGINT", interrupt)
105
164
  process.off("SIGTERM", interrupt)
106
165
  current?.abort.abort(new Error("opencode-drive stopped"))
166
+ const recordingPath = await finishCurrentRecording().catch((error) => {
167
+ process.stderr.write(`opencode-drive: failed to export recording: ${error}\n`)
168
+ return undefined
169
+ })
107
170
  await closeControl?.()
108
171
  await instance.stop()
109
172
  await unregister(options.name, process.pid)
110
173
  if (options.script && !options.visible)
111
174
  report(instance, completed ? "completed" : undefined)
175
+ if (options.script && recordingPath)
176
+ console.error(`opencode-drive: recording ${recordingPath}`)
112
177
  }
113
178
  }
114
179
 
180
+ async function finishRecording(
181
+ instance: Awaited<ReturnType<typeof launchInstance>>,
182
+ onProgress?: (percent: number) => void,
183
+ ) {
184
+ const expected = instance.recording
185
+ if (!expected) throw new Error("recording was not enabled for this instance")
186
+ let timeline: string
187
+ if (instance.child.exitCode !== null) {
188
+ timeline = expected.timeline
189
+ } else {
190
+ const ui = await connectSimulation({ url: instance.endpoints.ui, timeout: 60_000 })
191
+ try {
192
+ timeline = await ui.finishRecording()
193
+ } finally {
194
+ ui.close()
195
+ }
196
+ }
197
+ if (timeline !== expected.timeline)
198
+ throw new Error(`OpenCode returned an unexpected recording path: ${timeline}`)
199
+ if (!(await Bun.file(timeline).exists()))
200
+ throw new Error(`OpenCode recording timeline was not created: ${timeline}`)
201
+ await exportRecording(timeline, expected.video, { onProgress })
202
+ return expected.video
203
+ }
204
+
115
205
  async function startDetached(options: StartOptions) {
116
206
  const existing = await resolveInstance(options.name, { ready: false }).catch(() => undefined)
117
207
  if (existing)
@@ -129,7 +219,7 @@ async function startDetached(options: StartOptions) {
129
219
  options.name,
130
220
  ...(options.script ? ["--script", options.script] : []),
131
221
  ...(options.dev ? ["--dev", options.dev] : []),
132
- ...(options.state ? ["--state", options.state] : []),
222
+ ...(options.record ? ["--record"] : []),
133
223
  ...(options.command.length ? ["--", ...options.command] : []),
134
224
  ],
135
225
  {
@@ -176,6 +266,8 @@ function run(
176
266
  options: StartOptions,
177
267
  instance: Awaited<ReturnType<typeof launchInstance>>,
178
268
  responses: ReturnType<typeof createResponseSettings>,
269
+ driveScript: DriveScript | undefined,
270
+ onScreenshot: (path: string) => void,
179
271
  ) {
180
272
  const abort = new AbortController()
181
273
  const child = instance.child
@@ -188,24 +280,27 @@ function run(
188
280
  ready: readiness,
189
281
  promise: (async () => {
190
282
  await instance.waitForDrive("both")
191
- if (options.script) {
283
+ if (driveScript) {
192
284
  const script = runScript(
193
- options.script,
285
+ driveScript,
194
286
  instance.artifacts,
195
287
  instance.endpoints,
196
288
  abort.signal,
289
+ onScreenshot,
197
290
  )
198
291
  ready()
199
- await script
200
292
  if (options.visible) {
201
- await Promise.race([
202
- child.exited,
203
- new Promise<void>((resolve) =>
204
- abort.signal.addEventListener("abort", () => resolve(), {
205
- once: true,
206
- }),
207
- ),
208
- ])
293
+ await script
294
+ return
295
+ }
296
+ const result = await Promise.race([
297
+ script.then(() => ({ script: true as const })),
298
+ child.exited.then((status) => ({ script: false as const, status })),
299
+ ])
300
+ if (!result.script) {
301
+ abort.abort(new Error(`OpenCode exited with status ${result.status}`))
302
+ await script.catch(() => undefined)
303
+ if (result.status !== 0) process.exitCode = result.status
209
304
  }
210
305
  return
211
306
  }
package/src/cli/stop.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { request } from "./control.js"
1
+ import { requestStop } from "./control.js"
2
2
  import { manifestPath, resolveInstance } from "./registry.js"
3
3
 
4
4
  export async function stop(name?: string) {
5
5
  const manifest = await resolveInstance(name)
6
- await request(manifest.control, "stop")
7
- const deadline = Date.now() + 10_000
6
+ const result = await requestStop(manifest.control, (percent) => {
7
+ console.error(`Rendering video: ${percent}%`)
8
+ })
9
+ const deadline = Date.now() + 5 * 60_000
8
10
  while (Date.now() < deadline) {
9
11
  const current: unknown = await Bun.file(manifestPath(manifest.name))
10
12
  .json()
@@ -15,7 +17,12 @@ export async function stop(name?: string) {
15
17
  !("pid" in current) ||
16
18
  current.pid !== manifest.pid
17
19
  ) {
18
- console.log("success")
20
+ for (const screenshot of result.screenshots) console.log(screenshot)
21
+ if (result.recording) {
22
+ console.error(`Video successfully created: ${result.recording}`)
23
+ } else if (result.screenshots.length === 0) {
24
+ console.log("success")
25
+ }
19
26
  return
20
27
  }
21
28
  await Bun.sleep(25)
package/src/cli/types.ts CHANGED
@@ -9,8 +9,8 @@ export interface StartOptions {
9
9
  readonly daemon: boolean
10
10
  readonly script?: string
11
11
  readonly visible: boolean
12
+ readonly record: boolean
12
13
  readonly dev?: string
13
- readonly state?: string
14
14
  readonly command: ReadonlyArray<string>
15
15
  }
16
16
 
@@ -4,20 +4,16 @@ const defaultPort = 40900
4
4
 
5
5
  type Methods = {
6
6
  readonly "ui.screenshot": {
7
- readonly params: undefined
7
+ readonly params: Frontend.ScreenshotParams | undefined
8
8
  readonly result: Frontend.Screenshot
9
9
  }
10
10
  readonly "ui.state": {
11
11
  readonly params: undefined
12
12
  readonly result: Frontend.State
13
13
  }
14
- readonly "ui.start-record": {
15
- readonly params: undefined
16
- readonly result: Frontend.StartRecord
17
- }
18
- readonly "ui.end-record": {
14
+ readonly "ui.recording.finish": {
19
15
  readonly params: undefined
20
- readonly result: Frontend.EndRecord
16
+ readonly result: Frontend.RecordingFinish
21
17
  }
22
18
  readonly "ui.type": {
23
19
  readonly params: Frontend.TypeParams
@@ -69,6 +65,7 @@ export interface SimulationClientOptions {
69
65
  readonly portAttempts?: number
70
66
  /** Per-call timeout in milliseconds. Defaults to 30_000. */
71
67
  readonly timeout?: number
68
+ readonly onScreenshot?: (path: string) => void
72
69
  }
73
70
 
74
71
  export class SimulationError extends Error {
@@ -93,13 +90,20 @@ export class SimulationClient {
93
90
 
94
91
  private readonly socket: WebSocket
95
92
  private readonly timeout: number
93
+ private readonly onScreenshot?: (path: string) => void
96
94
  private nextId = 1
97
95
  private readonly pending = new Map<number, Waiter>()
98
96
 
99
- private constructor(socket: WebSocket, url: string, timeout: number) {
97
+ private constructor(
98
+ socket: WebSocket,
99
+ url: string,
100
+ timeout: number,
101
+ onScreenshot?: (path: string) => void,
102
+ ) {
100
103
  this.socket = socket
101
104
  this.url = url
102
105
  this.timeout = timeout
106
+ this.onScreenshot = onScreenshot
103
107
  socket.addEventListener("message", (event) =>
104
108
  this.onMessage(String(event.data)),
105
109
  )
@@ -116,14 +120,24 @@ export class SimulationClient {
116
120
  ): Promise<SimulationClient> {
117
121
  const timeout = options?.timeout ?? 30_000
118
122
  if (options?.url !== undefined) {
119
- return new SimulationClient(await open(options.url), options.url, timeout)
123
+ return new SimulationClient(
124
+ await open(options.url),
125
+ options.url,
126
+ timeout,
127
+ options.onScreenshot,
128
+ )
120
129
  }
121
130
  const first = options?.port ?? defaultPort
122
131
  const attempts = options?.portAttempts ?? 10
123
132
  for (let offset = 0; offset < attempts; offset++) {
124
133
  const url = `ws://127.0.0.1:${first + offset}`
125
134
  try {
126
- return new SimulationClient(await open(url), url, timeout)
135
+ return new SimulationClient(
136
+ await open(url),
137
+ url,
138
+ timeout,
139
+ options?.onScreenshot,
140
+ )
127
141
  } catch {
128
142
  // occupied by something else or nothing listening; try the next port
129
143
  }
@@ -170,16 +184,17 @@ export class SimulationClient {
170
184
  return this.call("ui.state")
171
185
  }
172
186
 
173
- screenshot(): Promise<Frontend.Screenshot> {
174
- return this.call("ui.screenshot")
175
- }
176
-
177
- startRecord(): Promise<Frontend.StartRecord> {
178
- return this.call("ui.start-record")
187
+ async screenshot(name?: string): Promise<Frontend.Screenshot> {
188
+ const path = await this.call(
189
+ "ui.screenshot",
190
+ name === undefined ? undefined : { name },
191
+ )
192
+ this.onScreenshot?.(path)
193
+ return path
179
194
  }
180
195
 
181
- endRecord(): Promise<Frontend.EndRecord> {
182
- return this.call("ui.end-record")
196
+ finishRecording(): Promise<Frontend.RecordingFinish> {
197
+ return this.call("ui.recording.finish")
183
198
  }
184
199
 
185
200
  /** Executes one user-level action and returns the post-action state. */
@@ -111,11 +111,14 @@ export namespace Frontend {
111
111
  export const Screenshot = Schema.String
112
112
  export type Screenshot = Schema.Schema.Type<typeof Screenshot>
113
113
 
114
- export const StartRecord = Schema.Struct({ recording: Schema.Literal(true) })
115
- export interface StartRecord extends Schema.Schema.Type<typeof StartRecord> {}
114
+ export const RecordingFinish = Schema.String
115
+ export type RecordingFinish = Schema.Schema.Type<typeof RecordingFinish>
116
116
 
117
- export const EndRecord = Schema.String
118
- export type EndRecord = Schema.Schema.Type<typeof EndRecord>
117
+ export const ScreenshotParams = Schema.Struct({
118
+ name: Schema.optional(Schema.String),
119
+ })
120
+ export interface ScreenshotParams
121
+ extends Schema.Schema.Type<typeof ScreenshotParams> {}
119
122
 
120
123
  export const TypeParams = Schema.Struct({ text: Schema.String })
121
124
  export interface TypeParams extends Schema.Schema.Type<typeof TypeParams> {}
@@ -169,13 +172,12 @@ export namespace Frontend {
169
172
  }),
170
173
  Schema.Struct({
171
174
  ...JsonRpc.RequestFields,
172
- method: Schema.Literals([
173
- "ui.enter",
174
- "ui.screenshot",
175
- "ui.state",
176
- "ui.start-record",
177
- "ui.end-record",
178
- ]),
175
+ method: Schema.Literal("ui.screenshot"),
176
+ params: Schema.optional(ScreenshotParams),
177
+ }),
178
+ Schema.Struct({
179
+ ...JsonRpc.RequestFields,
180
+ method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]),
179
181
  }),
180
182
  ])
181
183
  export type Request = Schema.Schema.Type<typeof Request>
@@ -0,0 +1,102 @@
1
+ import { join } from "node:path"
2
+ import { defineScript } from "../index.js"
3
+
4
+ export default defineScript(async ({ artifacts, backend, ui }) => {
5
+ const completed = Array.from({ length: 3 }, () => Promise.withResolvers<void>())
6
+ let turn = 0
7
+
8
+ await backend.attach(async (request) => {
9
+ if (isTitleRequest(request.body)) {
10
+ await backend.chunk(request.id, [{ type: "textDelta", text: "Stale exploring reproduction" }])
11
+ await backend.finish(request.id)
12
+ return
13
+ }
14
+ const current = turn++
15
+ if (current === 0) {
16
+ await backend.chunk(request.id, [
17
+ {
18
+ type: "toolCall",
19
+ index: 0,
20
+ id: "call_read",
21
+ name: "read",
22
+ input: { filePath: join(artifacts, "files", "src", "garden.js") },
23
+ },
24
+ ])
25
+ await backend.finish(request.id, "tool-calls")
26
+ return
27
+ }
28
+ if (current === 1) {
29
+ await backend.finish(request.id, "tool-calls")
30
+ completed[0]?.resolve()
31
+ return
32
+ }
33
+ const response = current === 2 ? "The file exports a small greeting function." : "Confirmed again with no more tools."
34
+ await backend.chunk(request.id, [{ type: "textDelta", text: response }])
35
+ await backend.finish(request.id)
36
+ completed[current - 1]?.resolve()
37
+ })
38
+
39
+ const prompts = [
40
+ "Read src/garden.js, then tell me what it contains.",
41
+ "Now inspect that file one more time.",
42
+ "Finally, verify the same file again.",
43
+ ]
44
+ for (const [index, prompt] of prompts.entries()) {
45
+ if (index === 0) await ui.typeText(prompt)
46
+ else await typeSlowly(ui, prompt)
47
+ await ui.pressEnter()
48
+ await withTimeout(
49
+ completed[index]!.promise,
50
+ 30_000,
51
+ `timed out waiting for empty continuation ${index + 1}`,
52
+ )
53
+ await waitForEditor(ui)
54
+ await Bun.sleep(500)
55
+ await ui.screenshot(`stale-exploring-${index + 1}`)
56
+ }
57
+ })
58
+
59
+ async function withTimeout(promise: Promise<void>, timeout: number, message: string) {
60
+ const expired = Promise.withResolvers<never>()
61
+ const timer = setTimeout(() => expired.reject(new Error(message)), timeout)
62
+ try {
63
+ await Promise.race([promise, expired.promise])
64
+ } finally {
65
+ clearTimeout(timer)
66
+ }
67
+ }
68
+
69
+ async function typeSlowly(
70
+ ui: Parameters<Parameters<typeof defineScript>[0]>[0]["ui"],
71
+ text: string,
72
+ ) {
73
+ for (const char of text) {
74
+ await ui.typeText(char)
75
+ await Bun.sleep(55)
76
+ }
77
+ }
78
+
79
+ async function waitForEditor(
80
+ ui: Parameters<Parameters<typeof defineScript>[0]>[0]["ui"],
81
+ ) {
82
+ const deadline = Date.now() + 30_000
83
+ while (Date.now() < deadline) {
84
+ if ((await ui.state()).focused.editor) return
85
+ await Bun.sleep(50)
86
+ }
87
+ throw new Error("timed out waiting for the session to become idle")
88
+ }
89
+
90
+ function isTitleRequest(body: unknown) {
91
+ if (typeof body !== "object" || body === null || !("messages" in body)) return false
92
+ const messages = body.messages
93
+ if (!Array.isArray(messages)) return false
94
+ return messages.some(
95
+ (message) =>
96
+ typeof message === "object" &&
97
+ message !== null &&
98
+ "content" in message &&
99
+ typeof message.content === "string" &&
100
+ message.content.includes("You are a title generator"),
101
+ )
102
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,8 @@
1
1
  export * from "./client/index.js"
2
2
  export { defineScript } from "./cli/script.js"
3
- export type { DriveScript, ScriptContext } from "./cli/script.js"
3
+ export type {
4
+ DriveScript,
5
+ DriveScriptSetup,
6
+ ScriptContext,
7
+ ScriptSetupContext,
8
+ } from "./cli/script.js"
@@ -0,0 +1,93 @@
1
+ import { createReadStream } from "node:fs"
2
+ import type { TimelineHeader, TimelineOutput, TimelineRecord } from "./types.js"
3
+
4
+ function fail(line: number, message: string): never {
5
+ throw new Error(`Invalid recording timeline at line ${line}: ${message}`)
6
+ }
7
+
8
+ function isObject(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value)
10
+ }
11
+
12
+ function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
13
+ const actual = Object.keys(value).sort()
14
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index])
15
+ }
16
+
17
+ function positiveInteger(value: unknown): value is number {
18
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
19
+ }
20
+
21
+ function nonnegativeInteger(value: unknown): value is number {
22
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
23
+ }
24
+
25
+ function canonicalBase64(value: string) {
26
+ if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
27
+ return false
28
+ }
29
+ return Buffer.from(value, "base64").toString("base64") === value
30
+ }
31
+
32
+ function parseRecord(text: string, line: number, first: boolean, previousAt: number): TimelineRecord {
33
+ let value: unknown
34
+ try {
35
+ value = JSON.parse(text)
36
+ } catch {
37
+ fail(line, "line is not valid JSON")
38
+ }
39
+ if (!isObject(value)) fail(line, "record must be an object")
40
+
41
+ if (first) {
42
+ if (!hasExactKeys(value, ["cols", "encoding", "rows", "type", "version"])) fail(line, "invalid header fields")
43
+ if (value.type !== "header" || value.version !== 1 || value.encoding !== "base64") {
44
+ fail(line, "unsupported or missing header")
45
+ }
46
+ if (!positiveInteger(value.cols) || !positiveInteger(value.rows)) fail(line, "cols and rows must be positive integers")
47
+ return { type: "header", version: 1, cols: value.cols, rows: value.rows, encoding: "base64" } satisfies TimelineHeader
48
+ }
49
+
50
+ if (!hasExactKeys(value, ["at_ms", "data", "type"]) || value.type !== "output") fail(line, "invalid output fields")
51
+ if (!nonnegativeInteger(value.at_ms)) fail(line, "at_ms must be a nonnegative integer")
52
+ if (value.at_ms < previousAt) fail(line, "output timestamps must be nondecreasing")
53
+ if (typeof value.data !== "string" || !canonicalBase64(value.data)) fail(line, "data must be canonical base64")
54
+ return { type: "output", at_ms: value.at_ms, data: value.data } satisfies TimelineOutput
55
+ }
56
+
57
+ /** Decodes and validates a timeline without loading the complete file into memory. */
58
+ export async function* decodeTimeline(path: string): AsyncGenerator<TimelineRecord> {
59
+ const decoder = new TextDecoder("utf-8", { fatal: true })
60
+ let buffered = ""
61
+ let line = 0
62
+ let records = 0
63
+ let previousAt = -1
64
+
65
+ const consume = (raw: string) => {
66
+ line++
67
+ const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw
68
+ if (text.length === 0) fail(line, "empty lines are not allowed")
69
+ const record = parseRecord(text, line, records === 0, previousAt)
70
+ records++
71
+ if (record.type === "output") previousAt = record.at_ms
72
+ return record
73
+ }
74
+
75
+ try {
76
+ for await (const chunk of createReadStream(path)) {
77
+ buffered += decoder.decode(chunk, { stream: true })
78
+ let newline: number
79
+ while ((newline = buffered.indexOf("\n")) !== -1) {
80
+ const raw = buffered.slice(0, newline)
81
+ buffered = buffered.slice(newline + 1)
82
+ yield consume(raw)
83
+ }
84
+ }
85
+ buffered += decoder.decode()
86
+ } catch (error) {
87
+ if (error instanceof TypeError) fail(line + 1, "file is not valid UTF-8")
88
+ throw error
89
+ }
90
+
91
+ if (buffered.length > 0) yield consume(buffered)
92
+ if (records === 0) fail(1, "missing header")
93
+ }