diff --git a/AGENTS.md b/AGENTS.md index b3d52b5..5d2ade8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,10 +39,10 @@ Session state is stored under `~/.agent-tty` by default. In tests and automation - `src/cli/main.ts` — public CLI contract and command registration. - `src/cli/commands/*.ts` — command implementations; most behavior changes start here. - `src/host/hostMain.ts` — per-session host orchestration for PTY, renderer, RPC, waits, and artifacts. -- `src/host/eventLog.ts` — append-only `events.jsonl` writer/reader; sequence numbers must stay contiguous. -- `src/host/replay.ts` — validated replay loader; keep its event-log assumptions aligned with `src/host/eventLog.ts`. +- `src/host/eventLog.ts` — append-only `events.jsonl` writer; append-time sequence numbers must stay contiguous. +- `src/host/replay.ts` — validated replay-input builder for manifest, dimensions, and target sequence semantics. - `src/protocol/schemas.ts` and `src/protocol/messages.ts` — machine-facing schemas and result shapes. -- `src/storage/` — path guards, home/session resolution, manifest I/O, and artifact manifests. +- `src/storage/` — path guards, home/session resolution, manifest I/O, artifact manifests, and the persisted event-log codec. - `src/renderer/ghosttyWeb/backend.ts` — reference renderer and Playwright browser harness. - `src/export/asciicast.ts` and `src/export/webm.ts` — recording export logic. - `src/util/assert.ts` — shared fail-fast assertion helpers. @@ -156,8 +156,8 @@ If validation cannot run, state why and name the next best check. - Treat the event log as canonical execution truth. - New snapshot, screenshot, wait, or export features should flow through replayable event/state data. - Do not add one-off state that only live PTY code can see. -- Keep `src/host/eventLog.ts` and `src/host/replay.ts` assumptions aligned. -- If you change the 50 MB event-log limit, update both `src/host/eventLog.ts` and `src/host/replay.ts`. +- Keep persisted event-log size limits, JSONL parsing, schema validation, and sequence validation centralized in `src/storage/eventLogCodec.ts`. +- If you change the 50 MB event-log limit, update `src/storage/eventLogCodec.ts` as the single source of truth. ## CI And Generated Files diff --git a/CONTEXT.md b/CONTEXT.md index 46d7015..d56b9ed 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,6 +7,9 @@ **Session**: A long-lived PTY-backed terminal instance owned by `agent-tty`. +**Event Log**: +The append-only history of a **Session**'s terminal output, user inputs, control actions, and lifecycle events. It is the canonical source used to reconstruct **Session** state for replay and artifact generation. + **Session Status**: The lifecycle state of a **Session**: `running`, `exiting`, `exited`, `failed`, `destroying`, or `destroyed`. @@ -41,6 +44,9 @@ A convenience policy predicate for the single `destroyed` **Session Status** val - A `destroying` **Session** is **Active** and **Offline Replay Eligible**, but not **Terminal** or **Collectable**. - `exited`, `failed`, and `destroyed` **Sessions** are **Terminal**, **Offline Replay Eligible**, and **Collectable**. +- A **Session** has one **Event Log**. +- An **Offline Replay Eligible Session** is reconstructed from its persisted **Event Log** and manifest. + ## Example dialogue > **Dev:** "Can garbage collection remove a **destroying** **Session**?" diff --git a/dogfood/issue-59-event-log-codec/commands.sh b/dogfood/issue-59-event-log-codec/commands.sh new file mode 100755 index 0000000..5ad0e61 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/commands.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOME_DIR=/tmp/tmp.z9jvcxqfO5 +SESSION_ID=01KQCM5YXV7201SPAN58AGD96S +BUNDLE_DIR=dogfood/issue-59-event-log-codec + +npx tsx src/cli/main.ts --home "$HOME_DIR" create --json -- /bin/sh > "$BUNDLE_DIR/create.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" type "$SESSION_ID" "printf 'hello from codec dogfood\\n'" --json > "$BUNDLE_DIR/type-print.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" send-keys "$SESSION_ID" ENTER --json > "$BUNDLE_DIR/enter-print.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" wait "$SESSION_ID" --text "hello from codec dogfood" --timeout 10000 --json > "$BUNDLE_DIR/wait-text.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" type "$SESSION_ID" exit --json > "$BUNDLE_DIR/type-exit.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" send-keys "$SESSION_ID" ENTER --json > "$BUNDLE_DIR/enter-exit.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" wait "$SESSION_ID" --exit --timeout 10000 --json > "$BUNDLE_DIR/wait-exit.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" snapshot "$SESSION_ID" --json > "$BUNDLE_DIR/snapshot.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" record export "$SESSION_ID" --format asciicast --out "$PWD/$BUNDLE_DIR/recording.cast" --json > "$BUNDLE_DIR/record-export-asciicast.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" screenshot "$SESSION_ID" --json > "$BUNDLE_DIR/screenshot.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" record export "$SESSION_ID" --format webm --timing max-speed --out "$PWD/$BUNDLE_DIR/recording.webm" --json > "$BUNDLE_DIR/record-export-webm.json" +npx tsx src/cli/main.ts --home "$HOME_DIR" destroy "$SESSION_ID" --json > "$BUNDLE_DIR/destroy.json" diff --git a/dogfood/issue-59-event-log-codec/create.json b/dogfood/issue-59-event-log-codec/create.json new file mode 100644 index 0000000..5937a5d --- /dev/null +++ b/dogfood/issue-59-event-log-codec/create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-29T12:42:19.764Z", + "result": { + "sessionId": "01KQCM5YXV7201SPAN58AGD96S", + "createdAt": "2026-04-29T12:42:18.942Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/issue-59-event-log-codec/destroy.json b/dogfood/issue-59-event-log-codec/destroy.json new file mode 100644 index 0000000..e9059c5 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-29T12:42:40.804Z", + "result": { + "sessionId": "01KQCM5YXV7201SPAN58AGD96S", + "destroyed": true + } +} diff --git a/dogfood/issue-59-event-log-codec/enter-exit.json b/dogfood/issue-59-event-log-codec/enter-exit.json new file mode 100644 index 0000000..16ce6cb --- /dev/null +++ b/dogfood/issue-59-event-log-codec/enter-exit.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-04-29T12:42:25.660Z", + "result": { + "accepted": ["ENTER"], + "bytesWritten": 1, + "seq": 7 + } +} diff --git a/dogfood/issue-59-event-log-codec/enter-print.json b/dogfood/issue-59-event-log-codec/enter-print.json new file mode 100644 index 0000000..42d5bc3 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/enter-print.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-04-29T12:42:21.994Z", + "result": { + "accepted": ["ENTER"], + "bytesWritten": 1, + "seq": 3 + } +} diff --git a/dogfood/issue-59-event-log-codec/event-log-check.json b/dogfood/issue-59-event-log-codec/event-log-check.json new file mode 100644 index 0000000..3589e2f --- /dev/null +++ b/dogfood/issue-59-event-log-codec/event-log-check.json @@ -0,0 +1,5 @@ +{ + "eventCount": 10, + "firstSeq": 0, + "lastSeq": 9 +} diff --git a/dogfood/issue-59-event-log-codec/events.jsonl b/dogfood/issue-59-event-log-codec/events.jsonl new file mode 100644 index 0000000..ed9170a --- /dev/null +++ b/dogfood/issue-59-event-log-codec/events.jsonl @@ -0,0 +1,10 @@ +{"seq":0,"ts":"2026-04-29T12:42:19.675Z","type":"output","payload":{"data":"$ "}} +{"seq":1,"ts":"2026-04-29T12:42:20.888Z","type":"input_text","payload":{"data":"printf 'hello from codec dogfood\\n'"}} +{"seq":2,"ts":"2026-04-29T12:42:20.888Z","type":"output","payload":{"data":"printf 'hello from codec dogfood\\n'"}} +{"seq":3,"ts":"2026-04-29T12:42:21.992Z","type":"input_keys","payload":{"keys":["ENTER"]}} +{"seq":4,"ts":"2026-04-29T12:42:21.992Z","type":"output","payload":{"data":"\r\nhello from codec dogfood\r\n$ "}} +{"seq":5,"ts":"2026-04-29T12:42:24.663Z","type":"input_text","payload":{"data":"exit"}} +{"seq":6,"ts":"2026-04-29T12:42:24.663Z","type":"output","payload":{"data":"exit"}} +{"seq":7,"ts":"2026-04-29T12:42:25.657Z","type":"input_keys","payload":{"keys":["ENTER"]}} +{"seq":8,"ts":"2026-04-29T12:42:25.658Z","type":"output","payload":{"data":"\r\n"}} +{"seq":9,"ts":"2026-04-29T12:42:25.659Z","type":"exit","payload":{"exitCode":0,"exitSignal":null}} diff --git a/dogfood/issue-59-event-log-codec/home.txt b/dogfood/issue-59-event-log-codec/home.txt new file mode 100644 index 0000000..591f33e --- /dev/null +++ b/dogfood/issue-59-event-log-codec/home.txt @@ -0,0 +1 @@ +/tmp/tmp.z9jvcxqfO5 diff --git a/dogfood/issue-59-event-log-codec/notes.md b/dogfood/issue-59-event-log-codec/notes.md new file mode 100644 index 0000000..bbdf5e0 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/notes.md @@ -0,0 +1,11 @@ +# Issue #59 event-log codec dogfood + +- Date: 2026-04-29T12:42:18Z +- Isolated AGENT_TTY_HOME: /tmp/tmp.z9jvcxqfO5 + +snapshot: ok +screenshot: ok +screenshot artifact copied: screenshot-9-reference-dark.png +record-export-webm: ok +webm artifact copied: recording.webm +Dogfood home retained at: /tmp/tmp.z9jvcxqfO5 diff --git a/dogfood/issue-59-event-log-codec/record-export-asciicast.json b/dogfood/issue-59-event-log-codec/record-export-asciicast.json new file mode 100644 index 0000000..734e540 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/record-export-asciicast.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T12:42:29.443Z", + "result": { + "sessionId": "01KQCM5YXV7201SPAN58AGD96S", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/grill-docs-cyte/dogfood/issue-59-event-log-codec/recording.cast", + "bytes": 350, + "sha256": "6743c1b391eb593bb538eec634cf1a33aa14a693759ad574edc69892e5a26272", + "capturedAtSeq": 9, + "durationMs": 5984, + "metadata": { + "width": 80, + "height": 24, + "title": "01KQCM5YXV7201SPAN58AGD96S", + "timestamp": 1777466539, + "outputEventCount": 5, + "resizeEventCount": 0, + "markerCount": 0 + } + } +} diff --git a/dogfood/issue-59-event-log-codec/record-export-webm.json b/dogfood/issue-59-event-log-codec/record-export-webm.json new file mode 100644 index 0000000..379e62c --- /dev/null +++ b/dogfood/issue-59-event-log-codec/record-export-webm.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T12:42:39.419Z", + "result": { + "sessionId": "01KQCM5YXV7201SPAN58AGD96S", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/grill-docs-cyte/dogfood/issue-59-event-log-codec/recording.webm", + "bytes": 14356, + "sha256": "bb845c575030ded3c0aeacda922ec2ceb76454abcaf7f68c39867456bdf8c1ae", + "capturedAtSeq": 9, + "durationMs": 5984, + "metadata": { + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "max-speed", + "rendererBackend": "ghostty-web", + "outputEventCount": 5, + "resizeEventCount": 0 + } + } +} diff --git a/dogfood/issue-59-event-log-codec/record-export-webm.stderr b/dogfood/issue-59-event-log-codec/record-export-webm.stderr new file mode 100644 index 0000000..e69de29 diff --git a/dogfood/issue-59-event-log-codec/recording.cast b/dogfood/issue-59-event-log-codec/recording.cast new file mode 100644 index 0000000..17eacab --- /dev/null +++ b/dogfood/issue-59-event-log-codec/recording.cast @@ -0,0 +1,6 @@ +{"version":2,"width":80,"height":24,"timestamp":1777466539,"title":"01KQCM5YXV7201SPAN58AGD96S","sessionId":"01KQCM5YXV7201SPAN58AGD96S","env":{"TERM":"xterm-256color"},"toolVersion":"0.1.1-beta.4"} +[0,"o","$ "] +[1.213,"o","printf 'hello from codec dogfood\\n'"] +[2.317,"o","\r\nhello from codec dogfood\r\n$ "] +[4.988,"o","exit"] +[5.983,"o","\r\n"] diff --git a/dogfood/issue-59-event-log-codec/recording.webm b/dogfood/issue-59-event-log-codec/recording.webm new file mode 100644 index 0000000..6f52de4 Binary files /dev/null and b/dogfood/issue-59-event-log-codec/recording.webm differ diff --git a/dogfood/issue-59-event-log-codec/screenshot-9-reference-dark.png b/dogfood/issue-59-event-log-codec/screenshot-9-reference-dark.png new file mode 100644 index 0000000..61c849e Binary files /dev/null and b/dogfood/issue-59-event-log-codec/screenshot-9-reference-dark.png differ diff --git a/dogfood/issue-59-event-log-codec/screenshot.json b/dogfood/issue-59-event-log-codec/screenshot.json new file mode 100644 index 0000000..20da66a --- /dev/null +++ b/dogfood/issue-59-event-log-codec/screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-04-29T12:42:32.263Z", + "result": { + "sessionId": "01KQCM5YXV7201SPAN58AGD96S", + "capturedAtSeq": 9, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/tmp.z9jvcxqfO5/sessions/01KQCM5YXV7201SPAN58AGD96S/artifacts/screenshot-9-reference-dark.png", + "pngSizeBytes": 5709, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "sha256": "17afb55252eb2ac6bdc2d2e6073691b1c6877733b37001e2eadb717c555c4dbb", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/issue-59-event-log-codec/screenshot.stderr b/dogfood/issue-59-event-log-codec/screenshot.stderr new file mode 100644 index 0000000..e69de29 diff --git a/dogfood/issue-59-event-log-codec/session.json b/dogfood/issue-59-event-log-codec/session.json new file mode 100644 index 0000000..e4961de --- /dev/null +++ b/dogfood/issue-59-event-log-codec/session.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "sessionId": "01KQCM5YXV7201SPAN58AGD96S", + "createdAt": "2026-04-29T12:42:18.942Z", + "updatedAt": "2026-04-29T12:42:25.659Z", + "status": "exited", + "command": ["/bin/sh"], + "cwd": "/home/coder/.mux/src/agent-terminal/grill-docs-cyte", + "shell": "/bin/bash", + "term": "xterm-256color", + "cols": 80, + "rows": 24, + "creationCols": 80, + "creationRows": 24, + "hostPid": 1834170, + "childPid": 1834218, + "exitCode": 0, + "exitSignal": null +} diff --git a/dogfood/issue-59-event-log-codec/snapshot.json b/dogfood/issue-59-event-log-codec/snapshot.json new file mode 100644 index 0000000..198bd2d --- /dev/null +++ b/dogfood/issue-59-event-log-codec/snapshot.json @@ -0,0 +1,113 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-04-29T12:42:28.225Z", + "result": { + "format": "structured", + "sessionId": "01KQCM5YXV7201SPAN58AGD96S", + "capturedAtSeq": 9, + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 0, + "isAltScreen": false, + "visibleLines": [ + { + "row": 0, + "text": "$ printf 'hello from codec dogfood\\n'" + }, + { + "row": 1, + "text": "hello from codec dogfood" + }, + { + "row": 2, + "text": "$ exit" + }, + { + "row": 3, + "text": "" + }, + { + "row": 4, + "text": "" + }, + { + "row": 5, + "text": "" + }, + { + "row": 6, + "text": "" + }, + { + "row": 7, + "text": "" + }, + { + "row": 8, + "text": "" + }, + { + "row": 9, + "text": "" + }, + { + "row": 10, + "text": "" + }, + { + "row": 11, + "text": "" + }, + { + "row": 12, + "text": "" + }, + { + "row": 13, + "text": "" + }, + { + "row": 14, + "text": "" + }, + { + "row": 15, + "text": "" + }, + { + "row": 16, + "text": "" + }, + { + "row": 17, + "text": "" + }, + { + "row": 18, + "text": "" + }, + { + "row": 19, + "text": "" + }, + { + "row": 20, + "text": "" + }, + { + "row": 21, + "text": "" + }, + { + "row": 22, + "text": "" + }, + { + "row": 23, + "text": "" + } + ] + } +} diff --git a/dogfood/issue-59-event-log-codec/snapshot.stderr b/dogfood/issue-59-event-log-codec/snapshot.stderr new file mode 100644 index 0000000..e69de29 diff --git a/dogfood/issue-59-event-log-codec/type-exit.json b/dogfood/issue-59-event-log-codec/type-exit.json new file mode 100644 index 0000000..a13e785 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/type-exit.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-04-29T12:42:24.665Z", + "result": {} +} diff --git a/dogfood/issue-59-event-log-codec/type-print.json b/dogfood/issue-59-event-log-codec/type-print.json new file mode 100644 index 0000000..99eb178 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/type-print.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-04-29T12:42:20.889Z", + "result": {} +} diff --git a/dogfood/issue-59-event-log-codec/wait-exit.json b/dogfood/issue-59-event-log-codec/wait-exit.json new file mode 100644 index 0000000..6a8952f --- /dev/null +++ b/dogfood/issue-59-event-log-codec/wait-exit.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T12:42:26.783Z", + "result": { + "timedOut": false, + "exitCode": 0 + } +} diff --git a/dogfood/issue-59-event-log-codec/wait-text.json b/dogfood/issue-59-event-log-codec/wait-text.json new file mode 100644 index 0000000..566c0e4 --- /dev/null +++ b/dogfood/issue-59-event-log-codec/wait-text.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T12:42:23.643Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "hello from codec dogfood", + "cursorRow": 2, + "cursorCol": 2, + "capturedAtSeq": 4 + } +} diff --git a/src/cli/commands/record-export.ts b/src/cli/commands/record-export.ts index 418cd83..56ab389 100644 --- a/src/cli/commands/record-export.ts +++ b/src/cli/commands/record-export.ts @@ -14,7 +14,7 @@ import { generateWebmExport, type WebmExportResult, } from '../../export/webm.js'; -import { readEventLogRecords } from '../../host/replay.js'; +import { readEventLogRecords } from '../../storage/eventLogCodec.js'; import { hashProfile, resolveProfile } from '../../renderer/profiles.js'; import { CliError } from '../errors.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index e6bf13f..c041fbe 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -15,6 +15,10 @@ import { type MarkerEventPayload, type RunCompleteEventPayload, } from '../protocol/schemas.js'; +import { + assertEventLogSize, + parseEventLogContent, +} from '../storage/eventLogCodec.js'; import { invariant } from '../util/assert.js'; const OutputEventPayloadSchema = z @@ -98,9 +102,6 @@ type EventLogPayload = | ExitEventPayload | MarkerEventPayload; -// Keep this in sync with the replay loader's event-log size limit. -const MAX_EVENT_LOG_SIZE = 50 * 1024 * 1024; - /** * Maximum number of events retained in the in-memory buffer. * At ~200 bytes per event object, 250k events ≈ 50MB — consistent with the file size limit. @@ -169,56 +170,6 @@ function validatePayload( } } -function parseEventLogLine(line: string, lineNumber: number): EventRecord { - let parsedLine: unknown; - try { - parsedLine = JSON.parse(line) as unknown; - } catch { - invariant(false, `event log line ${String(lineNumber)} must be valid JSON`); - } - - const parsedRecord = EventRecordSchema.safeParse(parsedLine); - invariant( - parsedRecord.success, - `event log line ${String(lineNumber)} must match EventRecordSchema`, - ); - - return parsedRecord.data; -} - -function assertContiguousSequence(records: EventRecord[]): void { - if (records.length === 0) { - return; - } - - invariant(records[0]?.seq === 0, 'first event log seq must be 0'); - - for (let index = 1; index < records.length; index += 1) { - const previous = records[index - 1]; - const current = records[index]; - - invariant(previous !== undefined, 'previous event record must exist'); - invariant(current !== undefined, 'current event record must exist'); - invariant( - current.seq === previous.seq + 1, - 'event log seq values must increase by 1 without gaps', - ); - } -} - -function parseEventLogContent(content: string): EventRecord[] { - const lines = content - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - const records = lines.map((line, index) => - parseEventLogLine(line, index + 1), - ); - assertContiguousSequence(records); - return records; -} - function deriveNextSeq(records: readonly EventRecord[]): number { if (records.length === 0) { return 0; @@ -291,10 +242,7 @@ export class EventLog { const fileHandle = await open(filePath, 'a'); try { const fileStats = await fileHandle.stat(); - invariant( - fileStats.size <= MAX_EVENT_LOG_SIZE, - `event log file exceeds size limit (${String(fileStats.size)} bytes, max ${String(MAX_EVENT_LOG_SIZE)})`, - ); + assertEventLogSize(fileStats.size); let eventBuffer: EventRecord[] = []; let nextSeq = 0; diff --git a/src/host/replay.ts b/src/host/replay.ts index 66c386f..1a324fa 100644 --- a/src/host/replay.ts +++ b/src/host/replay.ts @@ -1,81 +1,16 @@ -import { readFile, stat } from 'node:fs/promises'; - import type { ReplayInput } from '../renderer/types.js'; import { - EventRecordSchema, SessionRecordSchema, type EventRecord, type SessionRecord, } from '../protocol/schemas.js'; +import { validateEventRecords } from '../storage/eventLogCodec.js'; import { invariant } from '../util/assert.js'; -export const MAX_EVENT_LOG_SIZE = 50 * 1024 * 1024; - function assertNonEmptyString(value: string, message: string): void { invariant(value.length > 0, message); } -function parseEventRecord(event: unknown, index: number): EventRecord { - const parsedEvent = EventRecordSchema.safeParse(event); - invariant( - parsedEvent.success, - `replay event ${String(index)} must match EventRecordSchema`, - ); - return parsedEvent.data; -} - -function assertContiguousEventSequence(events: EventRecord[]): void { - if (events.length === 0) { - return; - } - - invariant(events[0]?.seq === 0, 'first replay event seq must be 0'); - - for (let index = 1; index < events.length; index += 1) { - const previous = events[index - 1]; - const current = events[index]; - - invariant(previous !== undefined, 'previous replay event must exist'); - invariant(current !== undefined, 'current replay event must exist'); - invariant( - current.seq === previous.seq + 1, - 'replay events must have contiguous seq values', - ); - } -} - -function parseEventLogLine(line: string, lineNumber: number): EventRecord { - let parsedLine: unknown; - try { - parsedLine = JSON.parse(line) as unknown; - } catch { - invariant(false, `event log line ${String(lineNumber)} must be valid JSON`); - } - - return parseEventRecord(parsedLine, lineNumber); -} - -export async function readEventLogRecords( - filePath: string, -): Promise { - assertNonEmptyString(filePath, 'filePath must be a non-empty string'); - - const fileStats = await stat(filePath); - invariant( - fileStats.size <= MAX_EVENT_LOG_SIZE, - `event log file exceeds 50 MB size limit (${String(fileStats.size)} bytes)`, - ); - - const content = await readFile(filePath, 'utf8'); - const lines = content - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - const events = lines.map((line, index) => parseEventLogLine(line, index + 1)); - assertContiguousEventSequence(events); - return events; -} - export function buildReplayInput( sessionId: string, manifest: SessionRecord, @@ -103,10 +38,7 @@ export function buildReplayInput( invariant(initialCols > 0, 'initial cols must be positive'); invariant(initialRows > 0, 'initial rows must be positive'); - const validatedEvents = events.map((event, index) => - parseEventRecord(event, index), - ); - assertContiguousEventSequence(validatedEvents); + const validatedEvents = validateEventRecords(events); let lastSeq = -1; if (validatedEvents.length > 0) { diff --git a/src/replay/offlineReplay.ts b/src/replay/offlineReplay.ts index 226dbc8..9b05189 100644 --- a/src/replay/offlineReplay.ts +++ b/src/replay/offlineReplay.ts @@ -1,6 +1,7 @@ import { basename, isAbsolute, resolve } from 'node:path'; -import { buildReplayInput, readEventLogRecords } from '../host/replay.js'; +import { buildReplayInput } from '../host/replay.js'; +import { readEventLogRecords } from '../storage/eventLogCodec.js'; import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import type { EventRecord, SessionRecord } from '../protocol/schemas.js'; import type { RendererBackend } from '../renderer/backend.js'; diff --git a/src/storage/eventLogCodec.ts b/src/storage/eventLogCodec.ts new file mode 100644 index 0000000..176d79a --- /dev/null +++ b/src/storage/eventLogCodec.ts @@ -0,0 +1,95 @@ +import { readFile, stat } from 'node:fs/promises'; + +import { EventRecordSchema, type EventRecord } from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; + +export const MAX_EVENT_LOG_SIZE = 50 * 1024 * 1024; + +export function assertEventLogSize(size: number): void { + invariant(Number.isInteger(size), 'event log size must be an integer'); + invariant(size >= 0, 'event log size must be non-negative'); + invariant( + size <= MAX_EVENT_LOG_SIZE, + `event log file exceeds size limit (${String(size)} bytes, max ${String(MAX_EVENT_LOG_SIZE)})`, + ); +} + +function parseEventLogLine(line: string, lineNumber: number): EventRecord { + let parsedLine: unknown; + try { + parsedLine = JSON.parse(line) as unknown; + } catch { + invariant(false, `event log line ${String(lineNumber)} must be valid JSON`); + } + + const parsedRecord = EventRecordSchema.safeParse(parsedLine); + invariant( + parsedRecord.success, + `event log line ${String(lineNumber)} must match EventRecordSchema`, + ); + + return parsedRecord.data; +} + +function assertContiguousSequence(records: readonly EventRecord[]): void { + if (records.length === 0) { + return; + } + + invariant(records[0]?.seq === 0, 'first event log seq must be 0'); + + for (let index = 1; index < records.length; index += 1) { + const previous = records[index - 1]; + const current = records[index]; + + invariant(previous !== undefined, 'previous event record must exist'); + invariant(current !== undefined, 'current event record must exist'); + invariant( + current.seq === previous.seq + 1, + 'event log seq values must increase by 1 without gaps', + ); + } +} + +/** Parses JSONL content. Errors reference 1-based non-empty-line ordinals. */ +export function parseEventLogContent(content: string): EventRecord[] { + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const records = lines.map((line, index) => + parseEventLogLine(line, index + 1), + ); + assertContiguousSequence(records); + return records; +} + +/** Validates already-loaded records. Errors reference 0-based array indexes. */ +export function validateEventRecords( + events: readonly unknown[], +): EventRecord[] { + const records = events.map((event, index) => { + const parsedEvent = EventRecordSchema.safeParse(event); + invariant( + parsedEvent.success, + `event log record ${String(index)} must match EventRecordSchema`, + ); + return parsedEvent.data; + }); + + assertContiguousSequence(records); + return records; +} + +export async function readEventLogRecords( + filePath: string, +): Promise { + invariant(filePath.length > 0, 'filePath must be a non-empty string'); + + const fileStats = await stat(filePath); + assertEventLogSize(fileStats.size); + + const content = await readFile(filePath, 'utf8'); + return parseEventLogContent(content); +} diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index cb2c454..03daf3f 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -34,7 +34,7 @@ vi.mock('../../../src/cli/output.js', () => ({ emitSuccess: mocks.emitSuccess, })); -vi.mock('../../../src/host/replay.js', () => ({ +vi.mock('../../../src/storage/eventLogCodec.js', () => ({ readEventLogRecords: mocks.readEventLogRecords, })); diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index 3c127e8..59071d9 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -16,7 +16,7 @@ import { EventLog, MAX_EVENT_BUFFER_ENTRIES, } from '../../../src/host/eventLog.js'; -import { MAX_EVENT_LOG_SIZE } from '../../../src/host/replay.js'; +import { MAX_EVENT_LOG_SIZE } from '../../../src/storage/eventLogCodec.js'; let tempDir = ''; let eventLogPath = ''; diff --git a/test/unit/host/replay.test.ts b/test/unit/host/replay.test.ts index 65d4aa4..9fed60b 100644 --- a/test/unit/host/replay.test.ts +++ b/test/unit/host/replay.test.ts @@ -1,14 +1,6 @@ -import { mkdtemp, open, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - MAX_EVENT_LOG_SIZE, - buildReplayInput, - readEventLogRecords, -} from '../../../src/host/replay.js'; +import { buildReplayInput } from '../../../src/host/replay.js'; import type { EventRecord, SessionRecord, @@ -73,22 +65,7 @@ function createEventsWithMarker(): EventRecord[] { ]; } -let tempDir = ''; -let eventLogPath = ''; - describe('replay helpers', () => { - beforeEach(async () => { - // prettier-ignore - tempDir = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-replay-'))); - eventLogPath = join(tempDir, 'events.jsonl'); - }); - - afterEach(async () => { - if (tempDir.length > 0) { - await rm(tempDir, { recursive: true, force: true }); - } - }); - it('buildReplayInput constructs a replay input from manifest and events', () => { const replayInput = buildReplayInput( 'session-01', @@ -179,7 +156,7 @@ describe('replay helpers', () => { payload: { data: 'world' }, }, ]), - ).toThrow('replay events must have contiguous seq values'); + ).toThrow('event log seq values must increase by 1 without gaps'); }); it('buildReplayInput rejects invalid session identifiers and dimensions', () => { @@ -194,92 +171,4 @@ describe('replay helpers', () => { ), ).toThrow('manifest must match SessionRecordSchema'); }); - - it('readEventLogRecords rejects event logs larger than 50 MB', async () => { - const fileHandle = await open(eventLogPath, 'w'); - - try { - await fileHandle.truncate(MAX_EVENT_LOG_SIZE + 1); - } finally { - await fileHandle.close(); - } - - await expect(readEventLogRecords(eventLogPath)).rejects.toThrow( - `event log file exceeds 50 MB size limit (${String(MAX_EVENT_LOG_SIZE + 1)} bytes)`, - ); - }); - - it('readEventLogRecords rejects malformed JSONL lines', async () => { - await writeFile( - eventLogPath, - `${JSON.stringify(createEvents()[0])}\n{"seq":1`, - 'utf8', - ); - - await expect(readEventLogRecords(eventLogPath)).rejects.toThrow( - 'event log line 2 must be valid JSON', - ); - }); - - it('readEventLogRecords parses legacy JSONL logs without run_complete events', async () => { - const legacyEvents: EventRecord[] = [ - { - seq: 0, - ts: '2026-03-19T12:00:02.000Z', - type: 'output', - payload: { data: 'legacy output' }, - }, - { - seq: 1, - ts: '2026-03-19T12:00:03.000Z', - type: 'input_run', - payload: { - command: 'echo done', - marker: '__AT_MARKER_legacy__', - noWait: false, - }, - }, - { - seq: 2, - ts: '2026-03-19T12:00:04.000Z', - type: 'exit', - payload: { exitCode: 0, exitSignal: null }, - }, - ]; - await writeFile( - eventLogPath, - legacyEvents - .map((event) => JSON.stringify(event)) - .concat('') - .join('\n'), - 'utf8', - ); - - await expect(readEventLogRecords(eventLogPath)).resolves.toEqual( - legacyEvents, - ); - expect( - buildReplayInput('session-01', createManifest(), legacyEvents), - ).toEqual({ - sessionId: 'session-01', - initialCols: 80, - initialRows: 24, - events: legacyEvents, - targetSeq: 2, - }); - }); - - it('readEventLogRecords parses and validates JSONL event logs', async () => { - await writeFile( - eventLogPath, - createEvents() - .map((event) => JSON.stringify(event)) - .concat('') - .join('\n'), - 'utf8', - ); - - const events = await readEventLogRecords(eventLogPath); - expect(events).toEqual(createEvents()); - }); }); diff --git a/test/unit/storage/eventLogCodec.test.ts b/test/unit/storage/eventLogCodec.test.ts new file mode 100644 index 0000000..14de273 --- /dev/null +++ b/test/unit/storage/eventLogCodec.test.ts @@ -0,0 +1,241 @@ +import { mkdtemp, realpath, rm, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { EventRecord } from '../../../src/protocol/schemas.js'; +import { + assertEventLogSize, + MAX_EVENT_LOG_SIZE, + parseEventLogContent, + readEventLogRecords, + validateEventRecords, +} from '../../../src/storage/eventLogCodec.js'; + +type OutputEventRecord = Extract; + +function createEvent( + overrides: Partial = {}, +): OutputEventRecord { + return { + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data: 'hello' }, + ...overrides, + }; +} + +function createEvents(): EventRecord[] { + return [ + createEvent(), + { + seq: 1, + ts: '2026-03-19T12:00:01.000Z', + type: 'resize', + payload: { cols: 100, rows: 30 }, + }, + ]; +} + +function createEventLogContent(events: readonly EventRecord[]): string { + return events.map((event) => JSON.stringify(event)).join('\n'); +} + +let tempDir = ''; +let eventLogPath = ''; + +describe('event log codec', () => { + beforeEach(async () => { + // prettier-ignore + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-event-log-codec-'))); + eventLogPath = join(tempDir, 'events.jsonl'); + }); + + afterEach(async () => { + if (tempDir.length > 0) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('accepts event logs at the maximum size and rejects larger logs', () => { + expect(() => assertEventLogSize(MAX_EVENT_LOG_SIZE)).not.toThrow(); + expect(() => assertEventLogSize(MAX_EVENT_LOG_SIZE + 1)).toThrow( + `event log file exceeds size limit (${String(MAX_EVENT_LOG_SIZE + 1)} bytes, max ${String(MAX_EVENT_LOG_SIZE)})`, + ); + }); + + it('rejects negative and fractional event log sizes', () => { + expect(() => assertEventLogSize(-1)).toThrow( + 'event log size must be non-negative', + ); + expect(() => assertEventLogSize(3.5)).toThrow( + 'event log size must be an integer', + ); + }); + + it('rejects oversized event log files before reading content', async () => { + await writeFile(eventLogPath, '', 'utf8'); + await truncate(eventLogPath, MAX_EVENT_LOG_SIZE + 1); + + await expect(readEventLogRecords(eventLogPath)).rejects.toThrow( + `event log file exceeds size limit (${String(MAX_EVENT_LOG_SIZE + 1)} bytes, max ${String(MAX_EVENT_LOG_SIZE)})`, + ); + }); + + it('parses empty event log content as no records', () => { + expect(parseEventLogContent('')).toEqual([]); + }); + + it('parses valid JSONL event log content', () => { + const events = createEvents(); + + expect(parseEventLogContent(createEventLogContent(events))).toEqual(events); + }); + + it('reads valid JSONL event log files', async () => { + const events = createEvents(); + await writeFile(eventLogPath, createEventLogContent(events), 'utf8'); + + await expect(readEventLogRecords(eventLogPath)).resolves.toEqual(events); + }); + + it('reads legacy JSONL logs without run_complete events', async () => { + const events: EventRecord[] = [ + createEvent({ + payload: { data: 'legacy output' }, + }), + { + seq: 1, + ts: '2026-03-19T12:00:01.000Z', + type: 'input_run', + payload: { + command: 'echo done', + marker: '__AT_MARKER_legacy__', + noWait: false, + }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:02.000Z', + type: 'exit', + payload: { exitCode: 0, exitSignal: null }, + }, + ]; + await writeFile(eventLogPath, createEventLogContent(events), 'utf8'); + + await expect(readEventLogRecords(eventLogPath)).resolves.toEqual(events); + }); + + it('ignores blank and whitespace-only JSONL lines', () => { + const events = createEvents(); + const content = [ + '', + ' ', + JSON.stringify(events[0]), + '', + '\t', + JSON.stringify(events[1]), + '', + ].join('\n'); + + expect(parseEventLogContent(content)).toEqual(events); + }); + + it('reports malformed JSON using non-empty line ordinals', () => { + expect(() => parseEventLogContent('\n \n{"seq":0')).toThrow( + 'event log line 1 must be valid JSON', + ); + }); + + it('rejects malformed JSONL lines', () => { + expect(() => + parseEventLogContent(`${JSON.stringify(createEvent())}\n{"seq":1`), + ).toThrow('event log line 2 must be valid JSON'); + }); + + it('rejects invalid JSONL event record shapes with line numbers', () => { + expect(() => + parseEventLogContent( + JSON.stringify({ + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: {}, + }), + ), + ).toThrow('event log line 1 must match EventRecordSchema'); + }); + + it('rejects invalid loaded event records with zero-based record indexes', () => { + expect(() => + validateEventRecords([ + { + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: {}, + }, + ]), + ).toThrow('event log record 0 must match EventRecordSchema'); + }); + + it('rejects event logs whose first sequence is not zero', () => { + expect(() => + parseEventLogContent(JSON.stringify(createEvent({ seq: 1 }))), + ).toThrow('first event log seq must be 0'); + }); + + it('rejects event log sequence gaps', () => { + const events = [createEvent(), createEvent({ seq: 2 })]; + + expect(() => parseEventLogContent(createEventLogContent(events))).toThrow( + 'event log seq values must increase by 1 without gaps', + ); + }); + + it('rejects duplicate event log sequence numbers', () => { + const events = [createEvent(), createEvent({ seq: 0 })]; + + expect(() => parseEventLogContent(createEventLogContent(events))).toThrow( + 'event log seq values must increase by 1 without gaps', + ); + }); + + it('rejects decreasing event log sequence numbers', () => { + const events = [ + createEvent(), + createEvent({ seq: 1 }), + createEvent({ seq: 0 }), + ]; + + expect(() => validateEventRecords(events)).toThrow( + 'event log seq values must increase by 1 without gaps', + ); + }); + + it('validates an empty array of loaded event records', () => { + expect(validateEventRecords([])).toEqual([]); + }); + + it('validates already-loaded event records', () => { + const events = createEvents(); + + expect(validateEventRecords(events)).toEqual(events); + }); + + it('rejects non-contiguous already-loaded event records', () => { + const events = [createEvent(), createEvent({ seq: 2 })]; + + expect(() => validateEventRecords(events)).toThrow( + 'event log seq values must increase by 1 without gaps', + ); + }); + + it('propagates ENOENT for missing event log files', async () => { + await expect(readEventLogRecords(eventLogPath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); +});