From 0e5f2104bd4be6f219f297cde6752f2344fbc350 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 12:52:27 +0000 Subject: [PATCH 1/3] refactor: extract event log codec --- CONTEXT.md | 6 + dogfood/issue-59-event-log-codec/commands.sh | 19 ++ dogfood/issue-59-event-log-codec/create.json | 12 + dogfood/issue-59-event-log-codec/destroy.json | 9 + .../issue-59-event-log-codec/enter-exit.json | 10 + .../issue-59-event-log-codec/enter-print.json | 10 + .../event-log-check.json | 5 + dogfood/issue-59-event-log-codec/events.jsonl | 10 + dogfood/issue-59-event-log-codec/home.txt | 1 + dogfood/issue-59-event-log-codec/notes.md | 11 + .../record-export-asciicast.json | 23 ++ .../record-export-webm.json | 24 ++ .../record-export-webm.stderr | 0 .../issue-59-event-log-codec/recording.cast | 6 + .../issue-59-event-log-codec/recording.webm | Bin 0 -> 14356 bytes .../screenshot-9-reference-dark.png | Bin 0 -> 5709 bytes .../issue-59-event-log-codec/screenshot.json | 20 ++ .../screenshot.stderr | 0 dogfood/issue-59-event-log-codec/session.json | 19 ++ .../issue-59-event-log-codec/snapshot.json | 113 +++++++++ .../issue-59-event-log-codec/snapshot.stderr | 0 .../issue-59-event-log-codec/type-exit.json | 6 + .../issue-59-event-log-codec/type-print.json | 6 + .../issue-59-event-log-codec/wait-exit.json | 9 + .../issue-59-event-log-codec/wait-text.json | 13 + src/cli/commands/record-export.ts | 2 +- src/host/eventLog.ts | 62 +---- src/host/replay.ts | 72 +----- src/replay/offlineReplay.ts | 3 +- src/storage/eventLogCodec.ts | 93 +++++++ test/unit/commands/record-export.test.ts | 2 +- test/unit/host/eventLog.test.ts | 2 +- test/unit/host/replay.test.ts | 117 +-------- test/unit/storage/eventLogCodec.test.ts | 228 ++++++++++++++++++ 34 files changed, 668 insertions(+), 245 deletions(-) create mode 100755 dogfood/issue-59-event-log-codec/commands.sh create mode 100644 dogfood/issue-59-event-log-codec/create.json create mode 100644 dogfood/issue-59-event-log-codec/destroy.json create mode 100644 dogfood/issue-59-event-log-codec/enter-exit.json create mode 100644 dogfood/issue-59-event-log-codec/enter-print.json create mode 100644 dogfood/issue-59-event-log-codec/event-log-check.json create mode 100644 dogfood/issue-59-event-log-codec/events.jsonl create mode 100644 dogfood/issue-59-event-log-codec/home.txt create mode 100644 dogfood/issue-59-event-log-codec/notes.md create mode 100644 dogfood/issue-59-event-log-codec/record-export-asciicast.json create mode 100644 dogfood/issue-59-event-log-codec/record-export-webm.json create mode 100644 dogfood/issue-59-event-log-codec/record-export-webm.stderr create mode 100644 dogfood/issue-59-event-log-codec/recording.cast create mode 100644 dogfood/issue-59-event-log-codec/recording.webm create mode 100644 dogfood/issue-59-event-log-codec/screenshot-9-reference-dark.png create mode 100644 dogfood/issue-59-event-log-codec/screenshot.json create mode 100644 dogfood/issue-59-event-log-codec/screenshot.stderr create mode 100644 dogfood/issue-59-event-log-codec/session.json create mode 100644 dogfood/issue-59-event-log-codec/snapshot.json create mode 100644 dogfood/issue-59-event-log-codec/snapshot.stderr create mode 100644 dogfood/issue-59-event-log-codec/type-exit.json create mode 100644 dogfood/issue-59-event-log-codec/type-print.json create mode 100644 dogfood/issue-59-event-log-codec/wait-exit.json create mode 100644 dogfood/issue-59-event-log-codec/wait-text.json create mode 100644 src/storage/eventLogCodec.ts create mode 100644 test/unit/storage/eventLogCodec.test.ts 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 0000000000000000000000000000000000000000..6f52de4f47c46a4194515962a072871e71df5129 GIT binary patch literal 14356 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 JQ*s0W{ST^;u;>5) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61c849ee4393119947e8967dc6fd63a4a2323732 GIT binary patch literal 5709 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 literal 0 HcmV?d00001 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..05fdad2 --- /dev/null +++ b/src/storage/eventLogCodec.ts @@ -0,0 +1,93 @@ +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', + ); + } +} + +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; +} + +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..eb1e630 --- /dev/null +++ b/test/unit/storage/eventLogCodec.test.ts @@ -0,0 +1,228 @@ +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 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 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', + }); + }); +}); From 6675648c4ee474fcd97839e12277cbe7b8ec66fd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 13:19:41 +0000 Subject: [PATCH 2/3] docs: update event log codec guidance --- AGENTS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From d308a76c648297e45bcf87fd39c014c296ad0422 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 13:58:12 +0000 Subject: [PATCH 3/3] test: cover event log codec guards --- src/storage/eventLogCodec.ts | 2 ++ test/unit/storage/eventLogCodec.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/storage/eventLogCodec.ts b/src/storage/eventLogCodec.ts index 05fdad2..176d79a 100644 --- a/src/storage/eventLogCodec.ts +++ b/src/storage/eventLogCodec.ts @@ -51,6 +51,7 @@ function assertContiguousSequence(records: readonly EventRecord[]): void { } } +/** Parses JSONL content. Errors reference 1-based non-empty-line ordinals. */ export function parseEventLogContent(content: string): EventRecord[] { const lines = content .split('\n') @@ -64,6 +65,7 @@ export function parseEventLogContent(content: string): EventRecord[] { return records; } +/** Validates already-loaded records. Errors reference 0-based array indexes. */ export function validateEventRecords( events: readonly unknown[], ): EventRecord[] { diff --git a/test/unit/storage/eventLogCodec.test.ts b/test/unit/storage/eventLogCodec.test.ts index eb1e630..14de273 100644 --- a/test/unit/storage/eventLogCodec.test.ts +++ b/test/unit/storage/eventLogCodec.test.ts @@ -66,6 +66,15 @@ describe('event log codec', () => { ); }); + 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); @@ -206,6 +215,10 @@ describe('event log codec', () => { ); }); + it('validates an empty array of loaded event records', () => { + expect(validateEventRecords([])).toEqual([]); + }); + it('validates already-loaded event records', () => { const events = createEvents();