diff --git a/.agents/skills/verify-session-lifecycle/coverage.json b/.agents/skills/verify-session-lifecycle/coverage.json index 5aa40153..91f0e7ef 100644 --- a/.agents/skills/verify-session-lifecycle/coverage.json +++ b/.agents/skills/verify-session-lifecycle/coverage.json @@ -133,7 +133,7 @@ "src/host/sessionState.ts#SessionState.requestDestroy": "f66074e815527ff1", "src/host/sessionState.ts#SessionState.recordDestroyed": "495b3a20c56b6330", "src/host/sessionState.ts#SessionState.recordExit": "a4f3f6706ae953c3", - "src/host/eventLog.ts#EventLog.open": "2bc70b7c6b669f20", + "src/host/eventLog.ts#EventLog.open": "4d4a1c04ce17c37d", "src/host/rpcServer.ts#RpcServer.listen": "aa62e1cd89eceaa4", "src/host/rpcServer.ts#RpcServer.close": "2d8092cceb4b8e13", "src/storage/manifests.ts#readValidatedJsonFile": "6287eb627b09bf80", diff --git a/README.md b/README.md index f54981b0..e145d453 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The `agent-tty` CLI (`src/cli/main.ts`) starts one detached host process per ses - The CLI JSON envelope is the stable automation surface. `src/protocol/envelope.ts` defines it, and `src/cli/exitCodes.ts` maps known error codes to fixed exit codes. Unmapped codes, such as `SKILL_NOT_FOUND`, exit 1. [src/protocol/envelope.ts:CommandEnvelope] [src/cli/exitCodes.ts:exitCodeForError] - Every `.object(...)` schema in `src/protocol/schemas.ts` and `src/protocol/messages.ts` ends in `.strict()`, so unknown keys fail validation. [src/protocol/schemas.ts:SessionRecordSchema] [src/protocol/messages.ts:RpcRequestSchema] -- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB). `EventLog.append` checks no byte size. It caps only the buffer at `MAX_EVENT_BUFFER_ENTRIES` (250,000 entries), so a live session can grow the file past 50 MB and then fail to reopen. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] +- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB) with `EventLogTooLargeError`. `EventLog.append` never writes the file past that size and never holds more than `MAX_EVENT_BUFFER_ENTRIES` (250,000) entries. Ordinary records stop `EVENT_LOG_TAIL_RESERVE_BYTES` (4,096 bytes) short of the size and `EVENT_LOG_TAIL_RESERVE_ENTRIES` (2) short of the entry cap. The first record that does not fit makes the log append one `marker` record whose label starts with `EVENT_LOG_LIMIT_LABEL_PREFIX` and reads `agent-tty: event log limit reached ( bytes); later events are not recorded` (` events` in place of ` bytes` when the entry cap stopped the log), and from then on the log stores only the `exit` record. The program keeps running, and the host acknowledges every other event with the `seq` of the last stored record, so later output, input, resizes, signals and markers appear in no snapshot, screenshot or export. Live features that replay the log see the program as it was at the stop: `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request encodes against the mouse modes in the stored log. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [src/host/eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:EventLogTooLargeError] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] - Session, socket and artifact paths come from guarded builders that reject path separators and `.` or `..` session IDs. [src/storage/sessionPaths.ts:sessionDir] [src/storage/artifactPaths.ts:artifactPath] - `session.json`, the artifact manifest and the Home registry are written through `writeTextFileAtomic`: a temp file with mode `0o600`, then a rename. [src/storage/manifests.ts:writeTextFileAtomic] - `gc` deletes only sessions whose status is collectable (`exited`, `failed` or `destroyed`) after `reconcileSession`, and it re-reads the manifest before each delete. [src/cli/commands/gc.ts:gcSessions] [src/protocol/sessionStatusPolicy.ts:getSessionStatusPolicy] diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 304c6533..319825e9 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -96,3 +96,18 @@ If it still appears: - The program running inside the session set the marker itself. To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh's styled default) when creating the session. + +## A Recording Ends Early, or an Export Names the Event Log Limit + +Each session records its terminal output in `events.jsonl`. The log stops at the first of two limits: 52,428,800 bytes (50 MB), or 250,000 events. A program that prints a lot reaches the byte limit. A program that prints in many tiny chunks reaches the event limit long before 50 MB. When the log stops, the host writes one marker and after it records only the program's exit. The marker reads `agent-tty: event log limit reached (52428800 bytes); later events are not recorded` when the byte limit stopped the log, and `agent-tty: event log limit reached (250000 events); later events are not recorded` when the event limit did. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker. An asciicast export ends with the marker. `mark` refuses a label that starts with `agent-tty: event log limit reached` with `INVALID_INPUT`, because only the host writes that marker. + +Commands on a live session read the stored log too, so after the marker they see the session as it was at the marker: + +- `wait --text` and `wait --regex` for text that appears later time out. +- `wait --screen-stable-ms` can report a stable screen while the program keeps printing, because the stored screen no longer changes. +- `mouse` encodes against the mouse modes in the stored log. A mode that the program turned on after the marker is not known, so the event succeeds with `reported: false` and writes nothing. A mode that the program turned off after the marker is still on in the stored log, so the program receives mouse bytes that it no longer expects. +- `wait --idle-ms` and `wait --exit` watch the live program and are not affected. + +To keep a long session recordable, send its verbose output to a file instead of the terminal, or print in larger chunks. + +A command that fails with a message such as `its event log is 81336613 bytes, over the 52428800-byte limit that commands can read` names a log that an older version of agent-tty wrote past the limit. No command can read that log. `inspect` and `destroy` still work on the session. diff --git a/src/cli/commands/README.md b/src/cli/commands/README.md index c594d6d2..92026ab8 100644 --- a/src/cli/commands/README.md +++ b/src/cli/commands/README.md @@ -34,7 +34,7 @@ This directory holds one handler module per agent-tty CLI command. `src/cli/main - `wait` rejects a mix of legacy flags (`--exit`, `--idle-ms`) and render flags. `--text` and `--regex` are mutually exclusive. The default timeout is `DEFAULT_WAIT_TIMEOUT_MS` (30,000 ms), which the `main.ts` help text names. An `--idle-ms` at or above the effective timeout (explicit or default), or a `--screen-stable-ms` whose `screenStableMinimumMs` is not shorter than it, throws `INVALID_DURATION`. `--timeout 0` skips the check. [wait.ts:runWaitCommand] [wait.ts:assertWindowFitsTimeout] [main.ts:main] - A timed-out `wait` throws `WAIT_TIMEOUT` with the wait result attached, so its error envelope keeps `result`. Without `--json`, and only when the wait lasted its whole timeout (`timeoutMs > 0`, measured by the CLI), the message names the timeout and, for the default, how to change it. With `--json`, the message stays `Wait timed out before its condition was met.` [wait.ts:throwIfTimedOut] [wait.ts:timeoutMessage] - `snapshot` and `screenshot` try the live RPC only when the manifest status is `running`. They fall back to offline replay only on `HOST_UNREACHABLE` and rethrow other errors. [snapshot.ts:runSnapshotCommand] [screenshot.ts:runScreenshotCommand] -- `record export` accepts only the formats in `RecordExportFormatSchema` (`asciicast`, `webm`, `svg`). `--animate` needs `svg`, and `svg` refuses `--timing`. [record-export.ts:runRecordExportCommand] +- `record export` accepts only the formats in `RecordExportFormatSchema` (`asciicast`, `webm`, `svg`). `--animate` needs `svg`, and `svg` refuses `--timing`. An event log over the size limit fails with `EXPORT_ERROR` in every format, a message that names the log size and the limit, and `eventLogBytes` and `maxEventLogBytes` in `details`. [record-export.ts:runRecordExportCommand] - `batch` parses the whole plan with `parseBatchPlan` before it resolves the session target. [batch.ts:runBatchCommand] - `create` polls readiness up to `READINESS_MAX_ATTEMPTS` (50) times, `READINESS_POLL_INTERVAL_MS` (100 ms) apart. [create.ts:waitForReadyHost] - `doctor` reports the `node-runtime` check as failed when the Node version is outside the `engines.node` range of `package.json`. It keeps running the other checks, reports `ok: false` and sets `process.exitCode = 1`. [doctor.ts:runNodeRuntimeCheck] [doctor.ts:runDoctorCommand] diff --git a/src/cli/commands/record-export.ts b/src/cli/commands/record-export.ts index ed002c93..a8a205ab 100644 --- a/src/cli/commands/record-export.ts +++ b/src/cli/commands/record-export.ts @@ -16,7 +16,10 @@ import { type WebmExportResult, } from '../../export/webm.js'; import { captureGridFrames } from '../../replay/gridFrames.js'; -import { readEventLogRecords } from '../../storage/eventLogCodec.js'; +import { + EventLogTooLargeError, + readEventLogRecords, +} from '../../storage/eventLogCodec.js'; import { getBuiltinProfile, hashProfile, @@ -579,6 +582,20 @@ export async function runRecordExportCommand( throw error; } + if (error instanceof EventLogTooLargeError) { + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { + message: `Failed to export session "${options.sessionId}" as ${format}: its event log is ${String(error.size)} bytes, over the ${String(error.maxSize)}-byte limit that commands can read.`, + details: { + sessionId: options.sessionId, + format, + out: options.out, + eventLogBytes: error.size, + maxEventLogBytes: error.maxSize, + }, + cause: error, + }); + } + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { message: `Failed to export session "${options.sessionId}" as ${format}.`, details: { diff --git a/src/export/README.md b/src/export/README.md index 894a8800..876ffdfa 100644 --- a/src/export/README.md +++ b/src/export/README.md @@ -6,7 +6,7 @@ This directory converts a session's event log into recording exports. `generateA ## Interface -- `AsciicastHeader`: `version: 2`, `width`, `height`, `timestamp`, `title`, optional `sessionId` and `toolVersion`, and `env.TERM` [asciicast.ts:AsciicastHeader]. +- `AsciicastHeader`: `version: 2`, `width`, `height`, `timestamp`, `title`, optional `sessionId` and `toolVersion`, and `env.TERM`. `width` and `height` are the creation size of the session (`manifest.creationCols` and `manifest.creationRows`, or `manifest.cols` and `manifest.rows` when they are absent), the size that replay starts from. A later `resize` event becomes an `r` line, so a resized session keeps its first size in the header [asciicast.ts:AsciicastHeader]. - `AsciicastExport`: `contents`, `header`, `capturedAtSeq`, `durationMs`, `outputEventCount`, `resizeEventCount` and `markerCount` [asciicast.ts:AsciicastExport]. - `generateAsciicast(sessionId, manifest, events, toolVersion?)` returns an `AsciicastExport`. It runs synchronously [asciicast.ts:generateAsciicast]. - `WebmExportOptions`: `sessionId`, `sessionDir`, `manifest`, `events`, `outputPath`, and optional `targetSeq`, `profileName`, `timingMode` and `rendererName` [webm.ts:WebmExportOptions]. @@ -17,7 +17,7 @@ This directory converts a session's event log into recording exports. `generateA ## Invariants -- `generateAsciicast` asserts a non-empty `sessionId` equal to `manifest.sessionId`, positive `manifest.cols` and `manifest.rows`, and a non-empty `toolVersion` when given [asciicast.ts:generateAsciicast]. `manifest.createdAt` must parse as a valid timestamp even when there are events. The header uses it when `events` is empty [asciicast.ts:parseTimestamp]. +- `generateAsciicast` asserts a non-empty `sessionId` equal to `manifest.sessionId`, a positive initial width and height (`manifest.creationCols ?? manifest.cols` and `manifest.creationRows ?? manifest.rows`, as in `buildReplayInput`), and a non-empty `toolVersion` when given [asciicast.ts:generateAsciicast]. `manifest.createdAt` must parse as a valid timestamp even when there are events. The header uses it when `events` is empty [asciicast.ts:parseTimestamp]. - Event timestamps must parse to finite numbers [asciicast.ts:parseTimestamp]. They must not decrease from one event to the next [asciicast.ts:generateAsciicast]. - Event mapping: `output` becomes `o`, `resize` becomes `r` with `COLSxROWS`, and `marker` becomes `m`. Input, run, signal and exit events are skipped. An unknown type reaches `unreachable()` [asciicast.ts:generateAsciicast]. - `generateWebmExport` asserts a non-empty `sessionId`, absolute `sessionDir` and `outputPath`, and positive initial dimensions. An empty event list gives a blank video with `capturedAtSeq` -1 and `durationMs` 0 [webm.ts:generateWebmExport]. diff --git a/src/export/asciicast.ts b/src/export/asciicast.ts index 46feda5c..c4dfa99d 100644 --- a/src/export/asciicast.ts +++ b/src/export/asciicast.ts @@ -57,8 +57,12 @@ export function generateAsciicast( manifest.sessionId === sessionId, 'manifest sessionId must match the export sessionId', ); - invariant(manifest.cols > 0, 'manifest cols must be positive'); - invariant(manifest.rows > 0, 'manifest rows must be positive'); + // The header names the size that the first output was written at: the + // creation size, as in `buildReplayInput`. Later sizes arrive as `r` lines. + const initialCols = manifest.creationCols ?? manifest.cols; + const initialRows = manifest.creationRows ?? manifest.rows; + invariant(initialCols > 0, 'initial cols must be positive'); + invariant(initialRows > 0, 'initial rows must be positive'); const fallbackTimestampMs = parseTimestamp( manifest.createdAt, @@ -82,8 +86,8 @@ export function generateAsciicast( const header: AsciicastHeader = { version: 2, - width: manifest.cols, - height: manifest.rows, + width: initialCols, + height: initialRows, timestamp: Math.floor(firstTimestampMs / 1000), title: sessionId, sessionId, diff --git a/src/host/README.md b/src/host/README.md index b8181e4a..31d4e292 100644 --- a/src/host/README.md +++ b/src/host/README.md @@ -30,8 +30,8 @@ - `captureHost(pid)` returns a `HostRecord` (`version`, `pid`, `bootId`, `startTicks`, `pidNamespace`, `timeNamespace`, `machineId`) on Linux when `/proc` shows the process and the boot id, else `null`. `machineId` is the first of `/etc/machine-id` and `/var/lib/dbus/machine-id` that holds 32 hex characters, else `null`. A record without `timeNamespace` and `machineId`, which a build between #260 and #267 wrote, still reads, and only the checks of those two fields are skipped. `writeHostRecord(sessionDirectory, record)` writes it as `host.json`, and `readHostRecord(sessionDirectory)` returns it, or `null` when it is missing or unreadable. `hostRecordError(sessionDirectory)` returns the error when the file exists but its read or its check fails, else `null`, so that the `HOST_TIMEOUT` of `destroySession` for an unshared group names `host.json` and that error instead of another pid namespace, and says to make the file readable only for EACCES or EPERM. `judgeHost(pid, record)` returns `dead` only when no process has the pid, a zombie has it, or the record names another boot id or start time, `alive` when both match, and `unknown` otherwise: also off Linux for a pid that exists, for a CLI in another pid namespace or on another machine than the record names, and, instead of a start time mismatch, for a CLI in another time namespace. [hostRecord.ts:captureHost] [hostRecord.ts:writeHostRecord] [hostRecord.ts:readHostRecord] [hostRecord.ts:hostRecordError] [hostRecord.ts:judgeHost] - `SessionState` holds the host's in-memory `SessionRecord` (type from `src/protocol/schemas.ts`). Methods: `snapshot`, `setChildPid`, `setDimensions`, `setFailureOrigin`, `requestDestroy`, `recordDestroyed` and `recordExit`. [sessionState.ts:SessionState] - `createSerialManifestWriter(path, snapshot, write?)` returns a function that writes `snapshot()` to `path`. The default `write` is `writeManifest` (`src/storage/manifests.ts`). `runHost` sends every manifest write through one such function. [manifestWriter.ts:createSerialManifestWriter] [hostMain.ts:runHost] -- `EventLog` is the append-only JSONL event log with an in-memory buffer. Methods: `static open(filePath)`, `append(type, payload)` (returns the new `seq`), `startAppend('input_run', payload)` (returns `{ seq, written }` without an `await`), `getEvents`, `getEventsSince(afterSeq)`, `readAll` and `close`. [eventLog.ts:EventLog] -- `MAX_EVENT_BUFFER_ENTRIES` is 250,000. `statEventLogBytes(filePath)` returns the file size or `undefined` when the file does not exist. `countEventLogEntries(filePath)` counts non-blank lines. [eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [eventLog.ts:statEventLogBytes] [eventLog.ts:countEventLogEntries] +- `EventLog` is the append-only JSONL event log with an in-memory buffer. Methods: `static open(filePath, options?)`, `append(type, payload)` (returns the `seq` of the stored record), `startAppend('input_run', payload)` (returns `{ seq, written }` without an `await`), `getEvents`, `getEventsSince(afterSeq)`, `readAll` and `close`. [eventLog.ts:EventLog] +- `MAX_EVENT_BUFFER_ENTRIES` is 250,000. `EVENT_LOG_TAIL_RESERVE_BYTES` is 4,096. `EVENT_LOG_TAIL_RESERVE_ENTRIES` is 2. `EVENT_LOG_LIMIT_LABEL_PREFIX` starts the label of the stop marker. `statEventLogBytes(filePath)` returns the file size or `undefined` when the file does not exist. `countEventLogEntries(filePath)` counts non-blank lines. [eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [eventLog.ts:statEventLogBytes] [eventLog.ts:countEventLogEntries] - `createMouseRpcHandler(deps)` returns the `mouse` handler `(request, rendererName)`, which resolves a `MouseResult` (`reported`, `bytesWritten`, `seq`). `MouseRpcDependencies` injects the renderer manager (in `runHost` a second `HostRendererManager` that only mouse input uses, so a render on another renderer or profile never replaces the encoder's backend and its same-cell motion state), the profile, the libghostty-vt probe, the replay loader, the commandable check, the PTY writer and the `input_mouse` append. The handler keeps the held buttons between requests. [mouseRpc.ts:createMouseRpcHandler] [mouseRpc.ts:MouseRpcDependencies] - `prepareHeldMouseButtons(currentHeld, input)` returns the next held set, `anyButtonPressed` and the `dragButton` that a button-free `move` reports, without changing `currentHeld`. [mouseInput.ts:prepareHeldMouseButtons] - `buildReplayInput(sessionId, manifest, events, ...)` builds a validated `ReplayInput` (type from `src/renderer/types.ts`) with the initial size and target sequence number. [replay.ts:buildReplayInput] @@ -44,8 +44,9 @@ ## Invariants -- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. [eventLog.ts:EventLog] -- `EventLog.append` validates each payload with a strict Zod schema for its event type. It throws when the buffer holds `MAX_EVENT_BUFFER_ENTRIES` records, after `close()`, and after an earlier write failed (the write queue stays rejected). A failed write removes the failed records from the buffer. [eventLog.ts:EventLog] +- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `options.maxBytes` sets the size limit of the file. It defaults to `MAX_EVENT_LOG_SIZE`, must exceed `EVENT_LOG_TAIL_RESERVE_BYTES` and cannot exceed `MAX_EVENT_LOG_SIZE`, so the writer never passes what readers accept. `options.maxEntries` sets the limit of the number of events. It defaults to `MAX_EVENT_BUFFER_ENTRIES`, must exceed `EVENT_LOG_TAIL_RESERVE_ENTRIES` and cannot exceed `MAX_EVENT_BUFFER_ENTRIES`. Tests inject small limits. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. A log that already holds its stop `marker` (a label that starts with `EVENT_LOG_LIMIT_LABEL_PREFIX`) starts stopped, so a reopened log stores only the `exit` record and writes no second stop record. A file of blank lines holds no record. When its size leaves less than `EVENT_LOG_TAIL_RESERVE_BYTES` free under `maxBytes`, `open` throws, because a stop record might not fit and a dropped append would have no earlier `seq` to report. [eventLog.ts:EventLog] [eventLog.ts:EventLogOptions] [eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] +- At either limit, the file size or the number of events, `EventLog.append` stops the log. Ordinary records may fill the file up to `maxBytes` minus `EVENT_LOG_TAIL_RESERVE_BYTES`, and the buffer up to `maxEntries` minus `EVENT_LOG_TAIL_RESERVE_ENTRIES` (one entry for the stop record and one for the `exit` record). The first record that does not fit makes the log append one `marker` record with the label `agent-tty: event log limit reached (); later events are not recorded`, inside the reserves. `` is ` bytes` when the file size stopped the log and ` events` when the event count stopped it, so a reader can tell the two apart. A record that passes both limits is reported as the byte limit. From then on the log stores only the `exit` record, which also fits in the reserves, so the file never passes the byte limit and the buffer never passes the entry limit, and the host does not die from either. A record that the log does not store takes no `seq`. `append` resolves with the `seq` of the last stored record, and `startAppend` returns it as `seq`. Handlers, waited runs and `resize` therefore keep working, and the program keeps running. Its later output, input, resizes, signals and markers are in no snapshot, screenshot or export, because those replay the log. Every live feature that replays the log sees the program as it was at the stop: a live `wait` for text that appears after the stop times out, `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request after the stop encodes against the mouse modes that the stored log shows, not against modes that the program enabled or disabled after the stop (`reported` is false when the stored modes have mouse tracking off). `wait --idle-ms` and `wait --exit` follow the live program and are not affected. Once a write has failed, a dropped append rejects like any other. [eventLog.ts:EventLog] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [mouseRpc.ts:createMouseRpcHandler] +- `EventLog.append` validates each payload with a strict Zod schema for its event type. It throws after `close()` and after an earlier write failed (the write queue stays rejected). It does not throw at a size limit or at `MAX_EVENT_BUFFER_ENTRIES`: the log stops instead. A failed write removes the failed records from the buffer. [eventLog.ts:EventLog] - `SessionState` enforces its transitions: `setChildPid` and `setDimensions` need status `running`, and the child PID is set once. `SessionState` has no method that sets `hostPid`. `requestDestroy` moves `running`, `exited` or `failed` to `destroying`. `recordDestroyed` needs `destroying`. `recordExit` needs `running` or `exiting` and no earlier exit. [sessionState.ts:SessionState] - `runHost` asserts that the manifest `sessionId` matches its argument and that the manifest `hostPid` equals its own `process.pid`. `waitForManifestFromCreate` asserts that `creatorPid` is a positive integer. [hostMain.ts:runHost] [hostMain.ts:waitForManifestFromCreate] - The function from `createSerialManifestWriter` starts each write after the previous write settles. Each write takes the snapshot when it starts, not when it was requested. A failed write rejects only its own caller, and the next write still runs. [manifestWriter.ts:createSerialManifestWriter] @@ -83,7 +84,7 @@ - `runHost` checks for the `create-failed` marker with `existsSync` before it spawns the program, and returns when it finds it. It checks again right after its start-up manifest write, before it listens, and then shuts down. From there on, `watchCreateFailed` checks for the marker every `CREATE_FAILED_POLL_INTERVAL_MS` (100 ms) while the creator process lives and once after it exited (off Windows, a `process.ppid` other than the creator's pid means that the creator exited; on Windows, a liveness probe that throws counts as exited; the watch stops after `CREATE_FAILED_WATCH_MAX_MS`, 60 s, in any case), and starts the shutdown when it finds the marker. [hostMain.ts:runHost] - `runHost` rethrows a PTY-ingestion, event-log append, exit-handling or shutdown error in `process.nextTick`. The uncaught exception crashes the host process. [hostMain.ts:rethrowAsync] - `resolveHostRendererName` reads `process.env.AGENT_TTY_HOST_RENDERER` and `process.env.AGENT_TTY_RENDERER`. [hostMain.ts:resolveHostRendererName] -- `EventLog.open` opens the file in append mode and runs `chmod 0o600`. `append` writes with `fileHandle.appendFile`, one call at a time. `close` calls `fileHandle.sync()` and then closes the file. [eventLog.ts:EventLog] +- `EventLog.open` opens the file in append mode and runs `chmod 0o600`. `append` writes with `fileHandle.appendFile`, one call at a time, and never takes the file past its size limit. `close` calls `fileHandle.sync()` and then closes the file. [eventLog.ts:EventLog] - `RpcServer.listen` uses `net.createServer`, listens on the socket path and runs `chmod 0o600` on the socket. `close` closes the server and unlinks the socket. [rpcServer.ts:RpcServer] - `sendRpc` connects with `net.connect` and writes one JSON line. [rpcClient.ts:sendRpc] - `HostRendererManager` boots, replays and disposes backends from the injected factory. In `runHost` that factory is `createRendererBackend` (`src/renderer/registry.ts`), with the session's creation size as the libghostty-vt initial size. `screenshotPath` creates `/screenshots` with `mkdirSync`. [renderer.ts:HostRendererManager] diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 96b8c351..96482fdc 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -19,8 +19,10 @@ import { } from '../protocol/schemas.js'; import { assertEventLogSize, + MAX_EVENT_LOG_SIZE, parseEventLogContent, } from '../storage/eventLogCodec.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import { invariant } from '../util/assert.js'; import { hasErrorCode } from '../util/hasErrorCode.js'; @@ -115,9 +117,53 @@ export interface StartedAppend { /** * 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. + * The log stops at this count the same way it stops at its byte limit. */ export const MAX_EVENT_BUFFER_ENTRIES = 250_000; +/** + * Room that ordinary records leave free at the end of the file. The stop + * record and the `exit` record are written inside it, so the file never + * passes its limit. Both records are far smaller than this. + */ +export const EVENT_LOG_TAIL_RESERVE_BYTES = 4096; + +/** + * Entries that ordinary records leave free in the buffer: one for the stop + * record and one for the `exit` record. + */ +export const EVENT_LOG_TAIL_RESERVE_ENTRIES = 2; + +/** + * Start of the label of the marker that records why the log stopped. The rest + * of the label names the limit that was reached: `( bytes)` for the file + * size, `( events)` for the entry count. + */ +export const EVENT_LOG_LIMIT_LABEL_PREFIX = + 'agent-tty: event log limit reached'; + +export interface EventLogOptions { + /** + * Size limit of the file in bytes. Defaults to `MAX_EVENT_LOG_SIZE`, which + * readers enforce, and cannot exceed it. Tests inject a small limit. + */ + readonly maxBytes?: number; + /** + * Limit of the number of events. Defaults to `MAX_EVENT_BUFFER_ENTRIES` and + * cannot exceed it. Tests inject a small limit. + */ + readonly maxEntries?: number; +} + +/** The limit that stopped the log. */ +type StopReason = 'bytes' | 'entries'; + +interface PreparedRecord { + readonly record: EventRecord; + readonly line: string; + readonly bytes: number; +} + function assertFilePath(filePath: string): void { invariant(filePath.length > 0, 'filePath must be a non-empty string'); } @@ -197,6 +243,14 @@ function deriveNextSeq(records: readonly EventRecord[]): number { return lastRecord.seq + 1; } +/** True for the marker that `recordStop` writes when the log stops. */ +function isStopMarker(record: EventRecord): boolean { + return ( + record.type === 'marker' && + record.payload.label.startsWith(EVENT_LOG_LIMIT_LABEL_PREFIX) + ); +} + /** * Returns the event log file size in bytes, or `undefined` when the file * does not exist (ENOENT). Non-ENOENT errors propagate to the caller. @@ -255,16 +309,44 @@ export class EventLog { private eventBuffer: EventRecord[] = []; + // Bytes of the file once every queued write has landed. A failed write does + // not give its bytes back: the poisoned writeQueue rejects every later + // append anyway. + private fileBytes: number; + private constructor( filePath: string, private readonly fileHandle: FileHandle, private nextSeq: number, + fileBytes: number, + private readonly maxBytes: number, + private readonly maxEntries: number, eventBuffer: EventRecord[] = [], + // Set once the log has stopped taking records. See `queueAppend`. A log + // that holds its stop marker starts stopped. + private stopped = false, private isClosed = false, ) { invariant(filePath.length > 0, 'filePath must be a non-empty string'); invariant(Number.isInteger(nextSeq), 'nextSeq must be an integer'); invariant(nextSeq >= 0, 'nextSeq must be non-negative'); + invariant( + Number.isInteger(fileBytes) && fileBytes >= 0, + 'fileBytes must be a non-negative integer', + ); + invariant( + Number.isInteger(maxBytes) && + maxBytes > EVENT_LOG_TAIL_RESERVE_BYTES && + maxBytes <= MAX_EVENT_LOG_SIZE, + 'maxBytes must leave room beyond the tail reserve and must not exceed MAX_EVENT_LOG_SIZE', + ); + invariant( + Number.isInteger(maxEntries) && + maxEntries > EVENT_LOG_TAIL_RESERVE_ENTRIES && + maxEntries <= MAX_EVENT_BUFFER_ENTRIES, + 'maxEntries must leave room beyond the tail reserve and must not exceed MAX_EVENT_BUFFER_ENTRIES', + ); + this.fileBytes = fileBytes; invariant( nextSeq === eventBuffer.length, 'nextSeq must match buffered event count', @@ -272,8 +354,13 @@ export class EventLog { this.eventBuffer = eventBuffer; } - static async open(filePath: string): Promise { + static async open( + filePath: string, + options: EventLogOptions = {}, + ): Promise { assertFilePath(filePath); + const maxBytes = options.maxBytes ?? MAX_EVENT_LOG_SIZE; + const maxEntries = options.maxEntries ?? MAX_EVENT_BUFFER_ENTRIES; const fileHandle = await open(filePath, 'a'); try { @@ -294,7 +381,30 @@ export class EventLog { invariant(nextSeq >= 0, 'derived next seq must be non-negative'); } - return new EventLog(filePath, fileHandle, nextSeq, eventBuffer); + // A file of blank lines holds no record. When its size is already inside + // the tail reserve, the stop marker may not fit, and a dropped append + // would have no earlier record whose `seq` it could report. This host + // never writes such a file, so refuse it instead of starting a log that + // could not stop. + if ( + eventBuffer.length === 0 && + fileStats.size > maxBytes - EVENT_LOG_TAIL_RESERVE_BYTES + ) { + throw new Error( + `event log ${filePath} holds no record, and its ${String(fileStats.size)} bytes leave less than ${String(EVENT_LOG_TAIL_RESERVE_BYTES)} bytes free under the ${String(maxBytes)}-byte limit`, + ); + } + + return new EventLog( + filePath, + fileHandle, + nextSeq, + fileStats.size, + maxBytes, + maxEntries, + eventBuffer, + eventBuffer.some(isStopMarker), + ); } catch (error) { await fileHandle.close(); throw error; @@ -334,6 +444,18 @@ export class EventLog { type: EventLogEventType, payload: EventLogPayload, ): Promise { + // Only `recordStop` writes the stop marker, so a reopened log can trust a + // marker with this prefix (see `isStopMarker`). + if ( + type === 'marker' && + (payload as MarkerEventPayload).label.startsWith( + EVENT_LOG_LIMIT_LABEL_PREFIX, + ) + ) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: `A marker label must not start with "${EVENT_LOG_LIMIT_LABEL_PREFIX}": agent-tty reserves it for the record that stops the event log.`, + }); + } return await this.queueAppend(type, payload).written; } @@ -341,6 +463,25 @@ export class EventLog { return this.queueAppend(type, payload); } + /** + * Appends a record while the log has room, and otherwise stops the log: + * + * - Ordinary records may fill the file up to `maxBytes` minus + * `EVENT_LOG_TAIL_RESERVE_BYTES`, and the buffer up to `maxEntries` minus + * `EVENT_LOG_TAIL_RESERVE_ENTRIES`. + * - The first record that does not fit stops the log. The log appends one + * stop marker inside the reserve (see `recordStop`), and from then on it + * records nothing but the `exit` record, which also fits in the reserve. + * - A stopped log raises no error. It acknowledges a record that it does + * not store with the `seq` of the last stored record, so input handlers, + * run completion and resize keep working, and the program keeps running. + * Its later output, input, resizes, signals and markers are absent from + * the log, and so from every snapshot, screenshot and export, which + * replay the log. + * + * `seq` values stay contiguous because a record that is not stored never + * takes one. + */ private queueAppend( type: EventLogEventType, payload: EventLogPayload, @@ -348,45 +489,132 @@ export class EventLog { invariant(!this.isClosed, 'cannot append to a closed event log'); const validatedPayload = validatePayload(type, payload); + if (this.stopped && type !== 'exit') { + // A runaway program keeps printing: do not build records to drop. + return this.acknowledgeUnstored(); + } + + const prepared = this.prepareRecord(type, validatedPayload); + const stopReason = this.findStopReason(type, prepared.bytes); + if (stopReason === undefined) { + return this.enqueue(prepared); + } + + if (!this.stopped) { + this.recordStop(stopReason); + } + return this.acknowledgeUnstored(); + } + + private prepareRecord( + type: EventLogEventType, + validatedPayload: EventLogPayload, + ): PreparedRecord { const seq = this.nextSeq; - invariant( - seq === this.nextSeq, - 'event seq must match the expected next seq', - ); invariant(seq >= 0, 'event seq must be non-negative'); - this.nextSeq += 1; - const record = { + const parsedRecord = EventRecordSchema.safeParse({ seq, ts: new Date().toISOString(), type, payload: validatedPayload, + }); + invariant( + parsedRecord.success, + 'event record must match EventRecordSchema', + ); + + const line = `${JSON.stringify(parsedRecord.data)}\n`; + return { + record: parsedRecord.data, + line, + bytes: Buffer.byteLength(line, 'utf8'), }; + } + + /** + * Returns the limit that a record of `bytes` bytes would pass, or + * `undefined` when it fits. The last record of a session, `exit`, may use + * the reserves. When both limits are passed, the byte limit is the reason. + */ + private findStopReason( + type: EventLogEventType, + bytes: number, + ): StopReason | undefined { + const reserveBytes = type === 'exit' ? 0 : EVENT_LOG_TAIL_RESERVE_BYTES; + const reserveEntries = type === 'exit' ? 0 : EVENT_LOG_TAIL_RESERVE_ENTRIES; + + if (bytes > this.maxBytes - this.fileBytes - reserveBytes) { + return 'bytes'; + } + if (this.eventBuffer.length + 1 > this.maxEntries - reserveEntries) { + return 'entries'; + } + return undefined; + } + /** + * Stops the log and records why, once. The marker is the only existing + * event type that fits: it carries a label, replayers ignore it, and the + * asciicast export shows it as a marker. Its label names the limit that was + * reached: bytes or events. It goes into the reserves, so it fits unless a + * log that this host did not write already filled them before it opened. + */ + private recordStop(reason: StopReason): void { + this.stopped = true; + + const limit = + reason === 'bytes' + ? `${String(this.maxBytes)} bytes` + : `${String(this.maxEntries)} events`; + const stop = this.prepareRecord('marker', { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${limit}); later events are not recorded`, + }); + if ( + stop.bytes > this.maxBytes - this.fileBytes || + this.eventBuffer.length + 1 > this.maxEntries + ) { + return; + } + + // A failed write reaches the caller through the poisoned writeQueue. + this.enqueue(stop).written.catch(() => undefined); + } + + private acknowledgeUnstored(): StartedAppend { + const seq = this.nextSeq - 1; invariant( - record.seq === seq, - 'event record seq must match the reserved seq', + seq >= 0, + 'a log that does not store a record must hold an earlier record', ); - const parsedRecord = EventRecordSchema.safeParse(record); + // Chained on the writeQueue so that the caller sees an earlier failed + // write, and so that the seq is durable once this settles. + return { seq, written: this.writeQueue.then(() => seq) }; + } + + private enqueue({ record, line, bytes }: PreparedRecord): StartedAppend { + const seq = record.seq; invariant( - parsedRecord.success, - 'event record must match EventRecordSchema', + seq === this.nextSeq, + 'event seq must match the expected next seq', ); - if (this.eventBuffer.length >= MAX_EVENT_BUFFER_ENTRIES) { - this.nextSeq = seq; - } invariant( - this.eventBuffer.length < MAX_EVENT_BUFFER_ENTRIES, - `event buffer exceeds ${String(MAX_EVENT_BUFFER_ENTRIES)} entries; session event log is too large`, + this.eventBuffer.length < this.maxEntries, + `event buffer exceeds ${String(this.maxEntries)} entries; session event log is too large`, ); invariant( - parsedRecord.data.seq === this.eventBuffer.length, + seq === this.eventBuffer.length, 'event record seq must match the buffered event count', ); - this.eventBuffer.push(parsedRecord.data); + invariant( + this.fileBytes + bytes <= this.maxBytes, + 'event log append must stay inside the size limit', + ); + this.nextSeq += 1; + this.eventBuffer.push(record); + this.fileBytes += bytes; - const line = `${JSON.stringify(parsedRecord.data)}\n`; const writePromise = this.writeQueue.then(async () => { try { await this.fileHandle.appendFile(line, 'utf8'); diff --git a/src/replay/README.md b/src/replay/README.md index 642a0ee2..8700e7fc 100644 --- a/src/replay/README.md +++ b/src/replay/README.md @@ -15,7 +15,7 @@ - `run` must be a function. `sessionDir` must be a non-empty absolute path. The session ID is `basename(sessionDir)` and must be non-empty. [offlineReplay.ts:withOfflineReplayRenderer] [offlineReplay.ts:getSessionId] - The manifest must exist. A missing manifest fails an `invariant`. [offlineReplay.ts:withOfflineReplayRenderer] - The default renderer is `DEFAULT_RENDERER_NAME`, checked with `resolveRendererName` (both from `src/renderer/names.ts`). The default profile is `'reference-dark'`, resolved with `resolveProfile` (from `src/renderer/profiles.ts`). The default backend factory is `createRendererBackend` (from `src/renderer/registry.ts`). [offlineReplay.ts:withOfflineReplayRenderer] [offlineReplay.ts:createBackend] -- A missing event log counts as zero events. Other event-log read errors, replay-input errors, profile errors and boot or replay errors become a `CliError` with code `REPLAY_ERROR` (from `src/protocol/errors.ts`). [offlineReplay.ts:readOfflineReplayInput] [offlineReplay.ts:withOfflineReplayRenderer] +- A missing event log counts as zero events. Other event-log read errors, replay-input errors, profile errors and boot or replay errors become a `CliError` with code `REPLAY_ERROR` (from `src/protocol/errors.ts`). A log over the size limit gives a message that names its size and the limit, with `eventLogPath`, `eventLogBytes` and `maxEventLogBytes` in `details`. [offlineReplay.ts:readOfflineReplayInput] [offlineReplay.ts:withOfflineReplayRenderer] - `backend.replayTo` always runs, also for an empty Event Log (`targetSeq` -1). [offlineReplay.ts:withOfflineReplayRenderer] - After the backend is created, `backend.dispose()` runs in a `finally` block, on success and on failure. [offlineReplay.ts:withOfflineReplayRenderer] - `captureGridFrames` gives one blank frame with `capturedAtSeq` -1 for an empty event log and boots no backend. It fails with `EXPORT_ERROR` when the `libghostty-vt` backend cannot boot, or when the retained cells pass `defaultMaxGridCells` of the V8 heap limit (at most `MAX_ANIMATED_GRID_CELLS`). [gridFrames.ts:captureGridFrames] diff --git a/src/replay/offlineReplay.ts b/src/replay/offlineReplay.ts index a19538ff..90548051 100644 --- a/src/replay/offlineReplay.ts +++ b/src/replay/offlineReplay.ts @@ -1,7 +1,10 @@ import { basename, isAbsolute, resolve } from 'node:path'; import { buildReplayInput } from '../host/replay.js'; -import { readEventLogRecords } from '../storage/eventLogCodec.js'; +import { + EventLogTooLargeError, + 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'; @@ -99,6 +102,16 @@ async function readOfflineReplayInput( } catch (error) { if (isEnoentError(error)) { events = []; + } else if (error instanceof EventLogTooLargeError) { + throw makeCliError(ERROR_CODES.REPLAY_ERROR, { + message: `Failed to read event log: ${eventLogFile} is ${String(error.size)} bytes, over the ${String(error.maxSize)}-byte limit that commands can read.`, + details: { + eventLogPath: eventLogFile, + eventLogBytes: error.size, + maxEventLogBytes: error.maxSize, + }, + cause: error, + }); } else { throw makeCliError(ERROR_CODES.REPLAY_ERROR, { message: `Failed to read event log: ${eventLogFile}`, diff --git a/src/storage/README.md b/src/storage/README.md index 8ad0b27e..8e462fed 100644 --- a/src/storage/README.md +++ b/src/storage/README.md @@ -17,7 +17,7 @@ - `resolveExplicitOutputPath(outputPath, sessionsRoot)` resolves an `--out` path for `record export` and `screenshot`. A relative path resolves against `process.cwd()`. Missing parent folders are created one at a time below the nearest existing ancestor. Before the first `mkdir`, the real path of the folders to create is checked against `sessionsRoot`, and each created folder must then resolve to its own path. The parent folder must be a directory, and be writable and searchable (`W_OK | X_OK`). The result's `path` uses the parent's real path, and `parentDev` and `parentIno` hold the parent folder's device and inode. A result inside the real path of `sessionsRoot` (when it does not exist, the real path of its nearest existing ancestor, joined with the missing rest) throws `EXPORT_ERROR`. `assertOutputParentUnchanged(output)` throws `EXPORT_ERROR` when the parent's real path, device or inode no longer match, or when the parent can no longer be read (for example, it was removed). `copyFileReplacing(sourcePath, output)` copies to `.tmp-`, runs that check, and renames the temp file over `path`. `record export --out` runs the same check before its rename. Node has no `openat` or `renameat`, so a swap of the parent or an ancestor between the check and the rename is not caught. A swap after the temp write leaves the temp file in the original folder, which now has another path, and a swap that is undone before the check leaves it in the other folder. Neither can replace a file there. For the same reason, a swap of an ancestor between the real-path check and a `mkdir` can create one empty folder in the other place before the next check stops the command. [outputPath.ts:resolveExplicitOutputPath] [outputPath.ts:assertOutputParentUnchanged] [outputPath.ts:copyFileReplacing] - `readValidatedJsonFile`, `writeValidatedJsonFile`, `writeValidatedJsonFileSync` and `writeTextFileAtomic`, with their option interfaces, are the generic validated JSON and text file helpers. [manifests.ts:readValidatedJsonFile] [manifests.ts:writeValidatedJsonFile] [manifests.ts:writeValidatedJsonFileSync] [manifests.ts:writeTextFileAtomic] - `readManifest`, `readManifestIfExists` and `writeManifest` read and write a `SessionRecord` (schema from `src/protocol/schemas.ts`). `readManifestIfExists` returns `null` when the file does not exist. [manifests.ts:readManifest] [manifests.ts:readManifestIfExists] [manifests.ts:writeManifest] -- `MAX_EVENT_LOG_SIZE` is 52,428,800 bytes (50 MB). `assertEventLogSize(size)` checks a size against it. [eventLogCodec.ts:MAX_EVENT_LOG_SIZE] [eventLogCodec.ts:assertEventLogSize] +- `MAX_EVENT_LOG_SIZE` is 52,428,800 bytes (50 MB). `assertEventLogSize(size)` checks a size against it and throws `EventLogTooLargeError` (with `size` and `maxSize`) above it. The host writer stops at this size (see `src/host/README.md`), so only a log from an older host can exceed it. [eventLogCodec.ts:MAX_EVENT_LOG_SIZE] [eventLogCodec.ts:assertEventLogSize] [eventLogCodec.ts:EventLogTooLargeError] - `parseEventLogContent(text)` parses JSONL text into `EventRecord[]`. `validateEventRecords(values)` validates records that are already loaded. `readEventLogRecords(filePath)` checks the file size, reads the file and parses it. `EventRecordSchema` comes from `src/protocol/schemas.ts`. [eventLogCodec.ts:parseEventLogContent] [eventLogCodec.ts:validateEventRecords] [eventLogCodec.ts:readEventLogRecords] - `ArtifactEntrySchema` and `ArtifactManifestSchema`, with types `ArtifactEntry` and `ArtifactManifest`, describe `/artifacts/manifest.json`. [artifactManifest.ts:ArtifactEntrySchema] [artifactManifest.ts:ArtifactManifestSchema] - `readArtifactManifest(sessionDir)` returns the manifest, or an empty version 1 manifest when the file does not exist. `writeArtifactManifest` validates and writes it. [artifactManifest.ts:readArtifactManifest] [artifactManifest.ts:writeArtifactManifest] @@ -35,7 +35,7 @@ - `sessionDir` requires an absolute `home` and a session ID that is not empty, `.` or `..` and has no `/` or `\`. The result must be a direct child of `/sessions`, and `session.json` and `events.jsonl` must be direct children of the session directory. [sessionPaths.ts:sessionDir] [sessionPaths.ts:manifestPath] - `socketPath` asserts that the socket directory is a direct child of the socket root, that the two IDs have 8 and 12 characters, and that the session directory's parent is `/sessions`. [sessionPaths.ts:socketPath] - `artifactPath` rejects a file name that is empty or has `/` or `\`, and asserts that the result is directly inside `/artifacts`. File-name builders replace unsafe characters in the profile name with `-`. `screenshotFilename`, `snapshotFilename` and `recordingFilename` require an integer `seq` of at least -1, and `videoFilename` requires a non-negative integer `seq`. [artifactPaths.ts:artifactPath] [artifactPaths.ts:screenshotFilename] [artifactPaths.ts:recordingFilename] -- `assertEventLogSize` requires an integer from 0 to `MAX_EVENT_LOG_SIZE`. `readEventLogRecords` checks the size with `stat` before it reads the file. [eventLogCodec.ts:assertEventLogSize] [eventLogCodec.ts:readEventLogRecords] +- `assertEventLogSize` requires an integer from 0 to `MAX_EVENT_LOG_SIZE`, and an integer above it throws `EventLogTooLargeError`, not an `AssertionError`. `readEventLogRecords` checks the size with `stat` before it reads the file. [eventLogCodec.ts:assertEventLogSize] [eventLogCodec.ts:readEventLogRecords] - `parseEventLogContent` and `validateEventRecords` require each record to match `EventRecordSchema`, the first `seq` to be 0, and each next `seq` to be the previous one plus 1. Blank lines are skipped. [eventLogCodec.ts:parseEventLogContent] [eventLogCodec.ts:validateEventRecords] - Read and write paths in `manifests.ts` must be non-empty and absolute. Invalid JSON and schema failures throw `MANIFEST_VALIDATION_ERROR`. Other read errors throw `STORAGE_READ_ERROR`. Write failures throw `STORAGE_WRITE_ERROR`, except that `writeManifest`, `writeDestroyMarker` and `writeCreateFailedMarker` throw `SESSION_NOT_FOUND` when the session directory is gone. `writeDestroyMarker` and `writeCreateFailedMarker` share `writeEmptyMarker`, which writes an empty file through a temp file and a rename, like `writeManifest`, so it replaces a symlink at that path instead of writing through it, and it creates no folder. [manifests.ts:readValidatedJsonFile] [manifests.ts:writeTextFileAtomic] [manifests.ts:writeManifest] [manifests.ts:writeDestroyMarker] [manifests.ts:writeCreateFailedMarker] - `ArtifactEntrySchema` and `ArtifactManifestSchema` are `.strict()`. `kind` is `screenshot`, `snapshot`, `recording` or `video`. `capturedAtSeq` is an integer of at least -1 (-1 for a capture before the first event). The manifest `version` is the literal `1`. The manifest and entry `sessionId` must equal the session directory name. [artifactManifest.ts:ArtifactEntrySchema] [artifactManifest.ts:ArtifactManifestSchema] diff --git a/src/storage/eventLogCodec.ts b/src/storage/eventLogCodec.ts index 176d79a8..174bd1a8 100644 --- a/src/storage/eventLogCodec.ts +++ b/src/storage/eventLogCodec.ts @@ -5,13 +5,31 @@ import { invariant } from '../util/assert.js'; export const MAX_EVENT_LOG_SIZE = 50 * 1024 * 1024; +/** + * A log file larger than the limit. This is a state of the file, not a bug in + * the caller, so commands can catch it and name the cause. The host writer + * never produces such a file: it stops appending at the limit. + */ +export class EventLogTooLargeError extends Error { + public readonly size: number; + public readonly maxSize: number; + + public constructor(size: number, maxSize: number) { + super( + `event log file exceeds size limit (${String(size)} bytes, max ${String(maxSize)})`, + ); + this.name = 'EventLogTooLargeError'; + this.size = size; + this.maxSize = maxSize; + } +} + 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)})`, - ); + if (size > MAX_EVENT_LOG_SIZE) { + throw new EventLogTooLargeError(size, MAX_EVENT_LOG_SIZE); + } } function parseEventLogLine(line: string, lineNumber: number): EventRecord { diff --git a/test/integration/record-export.test.ts b/test/integration/record-export.test.ts index ccdca7b0..5098d515 100644 --- a/test/integration/record-export.test.ts +++ b/test/integration/record-export.test.ts @@ -1,5 +1,12 @@ import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, realpath } from 'node:fs/promises'; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + truncate, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import process from 'node:process'; @@ -7,7 +14,8 @@ import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { readArtifactManifest } from '../../src/storage/artifactManifest.js'; -import { sessionDir } from '../../src/storage/sessionPaths.js'; +import { MAX_EVENT_LOG_SIZE } from '../../src/storage/eventLogCodec.js'; +import { eventLogPath, sessionDir } from '../../src/storage/sessionPaths.js'; import { cleanupHome, createSession, @@ -887,6 +895,50 @@ describe('record export integration', { timeout: 120_000 }, () => { expect(await readFile(out, 'utf8')).toContain('done'); }); + it('names the size limit when the event log is too large to read', async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'done\\n'; exit 0", + ]); + waitForExit(testHome, sessionId); + // A log that a host of an older version grew past the limit. A sparse + // file keeps the test fast: the size check runs before the read. + await truncate( + eventLogPath(sessionDir(testHome, sessionId)), + MAX_EVENT_LOG_SIZE + 1, + ); + + const exportResult = runCli( + ['record', 'export', sessionId, '--format', 'asciicast', '--json'], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + expect(exportResult.status).toBe(7); + const exportEnvelope = JSON.parse(exportResult.stdout) as ErrorEnvelope; + expect(exportEnvelope.error.code).toBe('EXPORT_ERROR'); + expect(exportEnvelope.error.message).toMatch( + new RegExp( + `^Failed to export session "${sessionId}" as asciicast: its event log is \\d+ bytes, over the ${String(MAX_EVENT_LOG_SIZE)}-byte limit that commands can read\\.$`, + ), + ); + expect(exportEnvelope.error.details?.maxEventLogBytes).toBe( + MAX_EVENT_LOG_SIZE, + ); + + const snapshotResult = runCli( + ['snapshot', sessionId, '--json'], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + expect(snapshotResult.status).toBe(10); + const snapshotEnvelope = JSON.parse(snapshotResult.stdout) as ErrorEnvelope; + expect(snapshotEnvelope.error.code).toBe('REPLAY_ERROR'); + expect(snapshotEnvelope.error.message).toContain( + `-byte limit that commands can read.`, + ); + }); + it('rejects invalid export formats', () => { const result = runCli( ['record', 'export', 'session-01', '--format', 'bogus', '--json'], diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index e51fba3f..779703e9 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -14,11 +14,13 @@ import process from 'node:process'; import type * as FsPromises from 'node:fs/promises'; +import type * as EventLogCodecModule from '../../../src/storage/eventLogCodec.js'; import type * as HomeModule from '../../../src/storage/home.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ERROR_CODES, makeCliError } from '../../../src/protocol/errors.js'; +import { EventLogTooLargeError } from '../../../src/storage/eventLogCodec.js'; const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), @@ -50,7 +52,8 @@ vi.mock('../../../src/cli/output.js', () => ({ emitSuccess: mocks.emitSuccess, })); -vi.mock('../../../src/storage/eventLogCodec.js', () => ({ +vi.mock('../../../src/storage/eventLogCodec.js', async (importOriginal) => ({ + ...(await importOriginal()), readEventLogRecords: mocks.readEventLogRecords, })); @@ -1628,6 +1631,50 @@ describe('record export command', () => { expect(mocks.emitSuccess).not.toHaveBeenCalled(); }); + it.each(['asciicast', 'webm', 'svg'] as const)( + 'names the size limit when the event log is too large to export as %s', + async (format) => { + mocks.readEventLogRecords.mockRejectedValue( + new EventLogTooLargeError(78_623_361, 52_428_800), + ); + + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.EXPORT_ERROR, + message: `Failed to export session "session-01" as ${format}: its event log is 78623361 bytes, over the 52428800-byte limit that commands can read.`, + details: { + sessionId: 'session-01', + format, + eventLogBytes: 78_623_361, + maxEventLogBytes: 52_428_800, + }, + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }, + ); + + it('keeps the generic message for other event log read failures', async () => { + mocks.readEventLogRecords.mockRejectedValue(new Error('EIO')); + + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'asciicast', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.EXPORT_ERROR, + message: 'Failed to export session "session-01" as asciicast.', + }); + }); + it('writes explicit relative output paths within the current working directory', async () => { const workspaceDirectory = await createTemporaryDirectory( 'agent-tty-record-export-workspace-', diff --git a/test/unit/export/asciicast.test.ts b/test/unit/export/asciicast.test.ts index ff7d21ac..d6319468 100644 --- a/test/unit/export/asciicast.test.ts +++ b/test/unit/export/asciicast.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { generateAsciicast } from '../../../src/export/asciicast.js'; +import { buildReplayInput } from '../../../src/host/replay.js'; import type { EventRecord, SessionRecord, @@ -207,6 +208,86 @@ describe('generateAsciicast', () => { expect(result.durationMs).toBe(1000); }); + it('names the creation size in the header and the later size in an r line', () => { + // The session started at 80x24 and was resized to 100x30, so the manifest + // holds the final size. The first output was written at 80x24. + const manifest = createManifest({ + cols: 100, + rows: 30, + creationCols: 80, + creationRows: 24, + }); + const events: EventRecord[] = [ + { + seq: 0, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'before' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:02.000Z', + type: 'resize', + payload: { cols: 100, rows: 30 }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:03.000Z', + type: 'output', + payload: { data: 'after' }, + }, + ]; + + const result = generateAsciicast('session-01', manifest, events); + + expect(result.header).toMatchObject({ width: 80, height: 24 }); + expect(parseAsciicastLines(result.contents)).toEqual([ + result.header, + [0, 'o', 'before'], + [1, 'r', '100x30'], + [2, 'o', 'after'], + ]); + + // Replay starts from the same size. + const replay = buildReplayInput('session-01', manifest, events); + expect([result.header.width, result.header.height]).toEqual([ + replay.initialCols, + replay.initialRows, + ]); + }); + + it('names the creation size when a resize after a stopped log was dropped', () => { + // The log stopped before the resize, so it holds no `r` line. The header + // still names the size that the stored output was written at. + const manifest = createManifest({ + cols: 100, + rows: 30, + creationCols: 80, + creationRows: 24, + }); + + const result = generateAsciicast('session-01', manifest, [ + { + seq: 0, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'stored' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:02.000Z', + type: 'marker', + payload: { + label: + 'agent-tty: event log limit reached (52428800 bytes); later events are not recorded', + }, + }, + ]); + + expect(result.header).toMatchObject({ width: 80, height: 24 }); + expect(result.resizeEventCount).toBe(0); + }); + it('produces a header-only cast for empty event logs', () => { const manifest = createManifest({ createdAt: '2026-03-19T12:34:56.000Z', diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index ece8a232..bd5ca288 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -3,6 +3,7 @@ import { readFile, realpath, rm, + stat, truncate, writeFile, } from 'node:fs/promises'; @@ -11,13 +12,21 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateAsciicast } from '../../../src/export/asciicast.js'; import { countEventLogEntries, + EVENT_LOG_LIMIT_LABEL_PREFIX, + EVENT_LOG_TAIL_RESERVE_BYTES, + EVENT_LOG_TAIL_RESERVE_ENTRIES, EventLog, MAX_EVENT_BUFFER_ENTRIES, statEventLogBytes, } from '../../../src/host/eventLog.js'; -import { MAX_EVENT_LOG_SIZE } from '../../../src/storage/eventLogCodec.js'; +import type { SessionRecord } from '../../../src/protocol/schemas.js'; +import { + MAX_EVENT_LOG_SIZE, + readEventLogRecords, +} from '../../../src/storage/eventLogCodec.js'; let tempDir = ''; let eventLogPath = ''; @@ -434,7 +443,7 @@ describe('EventLog', () => { ); }); - it('rejects appends when the in-memory buffer reaches the runtime cap', async () => { + it('stops at the in-memory entry cap instead of throwing', async () => { const eventLog = await EventLog.open(eventLogPath); const eventLogInternals = eventLog as unknown as { eventBuffer: unknown[]; @@ -442,21 +451,48 @@ describe('EventLog', () => { }; try { + // Stand in for a log that holds all but the two reserved entries. eventLogInternals.eventBuffer = new Array( - MAX_EVENT_BUFFER_ENTRIES, + MAX_EVENT_BUFFER_ENTRIES - 2, ) as unknown[]; - eventLogInternals.nextSeq = MAX_EVENT_BUFFER_ENTRIES; + eventLogInternals.nextSeq = MAX_EVENT_BUFFER_ENTRIES - 2; await expect( eventLog.append('output', { data: 'overflow' }), - ).rejects.toThrow( - `event buffer exceeds ${String(MAX_EVENT_BUFFER_ENTRIES)} entries; session event log is too large`, - ); + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 2); + await expect( + eventLog.append('output', { data: 'dropped' }), + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 2); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 1); + await expect( + eventLog.append('output', { data: 'after the exit' }), + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 1); expect(eventLogInternals.eventBuffer).toHaveLength( MAX_EVENT_BUFFER_ENTRIES, ); expect(eventLogInternals.nextSeq).toBe(MAX_EVENT_BUFFER_ENTRIES); + + const written = (await readFile(eventLogPath, 'utf8')) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + seq: number; + type: string; + payload: { label?: string }; + }, + ); + expect(written.map((record) => [record.seq, record.type])).toEqual([ + [MAX_EVENT_BUFFER_ENTRIES - 2, 'marker'], + [MAX_EVENT_BUFFER_ENTRIES - 1, 'exit'], + ]); + expect(written[0]?.payload.label).toBe( + `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(MAX_EVENT_BUFFER_ENTRIES)} events); later events are not recorded`, + ); } finally { await eventLog.close(); } @@ -525,3 +561,781 @@ describe('EventLog', () => { ); }); }); + +describe('EventLog size limit', () => { + // Small enough to reach with a few dozen appends, large enough to leave + // room beyond the tail reserve. + const LIMIT = EVENT_LOG_TAIL_RESERVE_BYTES * 2; + + beforeEach(async () => { + // oxfmt-ignore + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-event-log-limit-'))); + eventLogPath = join(tempDir, 'events.jsonl'); + await writeFile(eventLogPath, '', 'utf8'); + }); + + afterEach(async () => { + if (tempDir.length > 0) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + async function fileBytes(): Promise { + return (await stat(eventLogPath)).size; + } + + async function fillUntilStopped(eventLog: EventLog): Promise { + for (let index = 0; index < 1000; index += 1) { + await eventLog.append('output', { data: `line ${String(index)}\r\n` }); + if (eventLog.getEvents().some((event) => event.type === 'marker')) { + return; + } + } + expect.unreachable('the log never stopped'); + } + + function stopRecords(eventLog: EventLog): readonly unknown[] { + return eventLog + .getEvents() + .filter( + (event) => + event.type === 'marker' && + event.payload.label.startsWith(EVENT_LOG_LIMIT_LABEL_PREFIX), + ); + } + + function outputLine(seq: number, data: string): string { + return `${JSON.stringify({ + seq, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data }, + })}\n`; + } + + /** Writes `count` records, as a log that an earlier host left behind. */ + async function prefillEntries(count: number): Promise { + let contents = ''; + for (let seq = 0; seq < count; seq += 1) { + contents += outputLine(seq, 'earlier'); + } + await writeFile(eventLogPath, contents, 'utf8'); + } + + /** Writes records that make a file of exactly `totalBytes` bytes. */ + async function prefillBytes(totalBytes: number): Promise { + let contents = ''; + let seq = 0; + while ( + totalBytes - Buffer.byteLength(contents) >= + 2 * (Buffer.byteLength(outputLine(seq, '')) + 100) + ) { + contents += outputLine(seq, 'a'.repeat(100)); + seq += 1; + } + const lastData = + totalBytes - + Buffer.byteLength(contents) - + Buffer.byteLength(outputLine(seq, '')); + contents += outputLine(seq, 'z'.repeat(lastData)); + seq += 1; + expect(Buffer.byteLength(contents)).toBe(totalBytes); + await writeFile(eventLogPath, contents, 'utf8'); + return seq; + } + + it('stops at the limit: one stop record, later events dropped, file within the limit', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await fillUntilStopped(eventLog); + const seqAtStop = eventLog.getEvents().at(-1)?.seq; + expect(seqAtStop).toBeGreaterThan(0); + + // Every later append resolves, and none of them takes a seq. + await expect( + eventLog.append('output', { data: 'x'.repeat(100) }), + ).resolves.toBe(seqAtStop); + await expect( + eventLog.append('input_text', { data: 'typed' }), + ).resolves.toBe(seqAtStop); + await expect( + eventLog.append('resize', { cols: 100, rows: 30 }), + ).resolves.toBe(seqAtStop); + await expect( + eventLog.append('signal', { signal: 'SIGINT' }), + ).resolves.toBe(seqAtStop); + await expect(eventLog.append('marker', { label: 'late' })).resolves.toBe( + seqAtStop, + ); + expect( + eventLog.startAppend('input_run', { + command: 'ls', + noWait: true, + }).seq, + ).toBe(seqAtStop); + + // The exit record still fits in the reserve. + const exitSeq = await eventLog.append('exit', { + exitCode: 0, + exitSignal: null, + }); + expect(exitSeq).toBe((seqAtStop ?? 0) + 1); + await expect(eventLog.append('output', { data: 'after' })).resolves.toBe( + exitSeq, + ); + await eventLog.readAll(); + + const events = eventLog.getEvents(); + expect(stopRecords(eventLog)).toHaveLength(1); + expect(events.map((event) => event.seq)).toEqual( + events.map((_, index) => index), + ); + expect(events.at(-2)).toMatchObject({ + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(LIMIT)} bytes); later events are not recorded`, + }, + }); + expect(events.at(-1)?.type).toBe('exit'); + expect(events.map((event) => event.type)).not.toContain('input_text'); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('keeps ordinary records out of the tail reserve', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await fillUntilStopped(eventLog); + await eventLog.readAll(); + + // Everything but the stop record fits below the reserve. + const stopSeq = eventLog.getEvents().at(-1)?.seq ?? 0; + const withoutStop = eventLog + .getEvents() + .slice(0, stopSeq) + .reduce( + (total, event) => + total + Buffer.byteLength(`${JSON.stringify(event)}\n`), + 0, + ); + expect(withoutStop).toBeLessThanOrEqual( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES, + ); + } finally { + await eventLog.close(); + } + }); + + it('stops on a single record that is larger than the room', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await eventLog.append('output', { data: 'prompt$ ' }); + await expect( + eventLog.append('output', { data: 'x'.repeat(LIMIT) }), + ).resolves.toBe(1); + await eventLog.readAll(); + + expect(eventLog.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + ]); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('stores nothing after the stop, not even a record that would fit', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await eventLog.append('output', { data: 'prompt$ ' }); + // About 6000 bytes do not fit in the room below the tail reserve. + await expect( + eventLog.append('output', { data: 'x'.repeat(6000) }), + ).resolves.toBe(1); + + // These are small enough to fit, but the log has stopped. + await expect(eventLog.append('output', { data: 'ok' })).resolves.toBe(1); + await expect( + eventLog.append('input_text', { data: 'typed' }), + ).resolves.toBe(1); + + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(2); + await eventLog.readAll(); + + expect(eventLog.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'exit', + ]); + } finally { + await eventLog.close(); + } + }); + + it('counts bytes, not characters, toward the limit', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + // 100 characters of 3 bytes each: a record is about 300 bytes longer than + // its character count says. + const block = '█'.repeat(100); + + try { + for ( + let index = 0; + index < 100 && stopRecords(eventLog).length === 0; + index += 1 + ) { + await eventLog.append('output', { data: block }); + } + expect(stopRecords(eventLog)).toHaveLength(1); + await eventLog.append('exit', { exitCode: 0, exitSignal: null }); + await eventLog.readAll(); + } finally { + await eventLog.close(); + } + + const lines = (await readFile(eventLogPath, 'utf8')).trimEnd().split('\n'); + const byteLength = (text: string): number => + Buffer.byteLength(`${text}\n`, 'utf8'); + const stopIndex = lines.findIndex((line) => + line.includes(EVENT_LOG_LIMIT_LABEL_PREFIX), + ); + expect(stopIndex).toBeGreaterThan(0); + // Ordinary records stay out of the reserve, measured in bytes. + expect( + lines + .slice(0, stopIndex) + .reduce((sum, line) => sum + byteLength(line), 0), + ).toBeLessThanOrEqual(LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + }); + + it('stays stopped when it opens a log that holds its stop record', async () => { + const first = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await first.append('output', { data: 'prompt$ ' }); + // About 6000 bytes do not fit in the room below the tail reserve. The + // log stops far from the reserve boundary, so a small record would fit. + await first.append('output', { data: 'x'.repeat(6000) }); + await first.readAll(); + expect(stopRecords(first)).toHaveLength(1); + } finally { + await first.close(); + } + + const reopened = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + // A small record still gets no seq, and the log writes no second stop. + await expect(reopened.append('output', { data: 'ok' })).resolves.toBe(1); + await expect( + reopened.append('input_text', { data: 'typed' }), + ).resolves.toBe(1); + // The exit record stays admissible. + await expect( + reopened.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(2); + await reopened.readAll(); + + expect(reopened.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'exit', + ]); + expect(stopRecords(reopened)).toHaveLength(1); + } finally { + await reopened.close(); + } + + // A log whose last record is the exit record after the stop also stays + // stopped. + const again = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await expect(again.append('output', { data: 'ok' })).resolves.toBe(2); + await again.readAll(); + expect(stopRecords(again)).toHaveLength(1); + } finally { + await again.close(); + } + + const stored = await readEventLogRecords(eventLogPath); + expect(stored.map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'exit', + ]); + }); + + it('refuses a marker label that starts with the stop record prefix', async () => { + const log = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await log.append('output', { data: 'prompt$ ' }); + await expect( + log.append('marker', { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (8192 bytes); later events are not recorded`, + }), + ).rejects.toMatchObject({ + code: 'INVALID_INPUT', + message: `A marker label must not start with "${EVENT_LOG_LIMIT_LABEL_PREFIX}": agent-tty reserves it for the record that stops the event log.`, + }); + await expect(log.append('marker', { label: 'ok' })).resolves.toBe(1); + } finally { + await log.close(); + } + + const reopened = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await expect(reopened.append('output', { data: 'more' })).resolves.toBe( + 2, + ); + } finally { + await reopened.close(); + } + }); + + it('does not treat other markers as the stop record when it opens a log', async () => { + const first = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await first.append('output', { data: 'prompt$ ' }); + await first.append('marker', { label: 'checkpoint' }); + // Names the limit, but without the stop record's prefix. + await first.append('marker', { + label: 'event log limit reached (8192 bytes)', + }); + } finally { + await first.close(); + } + + const reopened = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await expect(reopened.append('output', { data: 'ok' })).resolves.toBe(3); + await reopened.readAll(); + + expect(reopened.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'marker', + 'output', + ]); + } finally { + await reopened.close(); + } + }); + + it('refuses a log of blank lines that leaves no room for a stop record', async () => { + // No record, so a dropped append would have no seq to report. + await writeFile(eventLogPath, '\n'.repeat(LIMIT), 'utf8'); + await expect( + EventLog.open(eventLogPath, { maxBytes: LIMIT }), + ).rejects.toThrow( + `event log ${eventLogPath} holds no record, and its ${String(LIMIT)} bytes leave less than ${String(EVENT_LOG_TAIL_RESERVE_BYTES)} bytes free under the ${String(LIMIT)}-byte limit`, + ); + + await writeFile( + eventLogPath, + '\n'.repeat(LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES + 1), + 'utf8', + ); + await expect( + EventLog.open(eventLogPath, { maxBytes: LIMIT }), + ).rejects.toThrow('holds no record'); + }); + + it('stops with the stop record as the first record of a log of blank lines up to the reserve', async () => { + await writeFile( + eventLogPath, + '\n'.repeat(LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES), + 'utf8', + ); + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await expect( + eventLog.append('output', { data: 'x'.repeat(LIMIT) }), + ).resolves.toBe(0); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(1); + await eventLog.readAll(); + + expect(eventLog.getEvents().map((event) => event.type)).toEqual([ + 'marker', + 'exit', + ]); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('words the stop record as the docs quote it, for both limits', async () => { + expect(EVENT_LOG_LIMIT_LABEL_PREFIX).toBe( + 'agent-tty: event log limit reached', + ); + + const bytesLog = await EventLog.open(eventLogPath, { maxBytes: 8192 }); + try { + await fillUntilStopped(bytesLog); + expect(stopRecords(bytesLog)).toMatchObject([ + { + payload: { + label: + 'agent-tty: event log limit reached (8192 bytes); later events are not recorded', + }, + }, + ]); + } finally { + await bytesLog.close(); + } + + await writeFile(eventLogPath, '', 'utf8'); + const entriesLog = await EventLog.open(eventLogPath, { maxEntries: 20 }); + try { + for (let index = 0; index < 20; index += 1) { + await entriesLog.append('output', { data: 'y' }); + } + expect(stopRecords(entriesLog)).toMatchObject([ + { + payload: { + label: + 'agent-tty: event log limit reached (20 events); later events are not recorded', + }, + }, + ]); + } finally { + await entriesLog.close(); + } + }); + + it('counts the size of an existing file toward the limit when it opens', async () => { + const records = await prefillBytes( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES - 10, + ); + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + // About 80 bytes: it fits an empty file, but not the 10 bytes of room + // that the existing records leave below the reserve. + await expect(eventLog.append('output', { data: 'late' })).resolves.toBe( + records, + ); + await eventLog.readAll(); + + const events = eventLog.getEvents(); + expect(events).toHaveLength(records + 1); + expect(events.at(-1)).toMatchObject({ + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(LIMIT)} bytes); later events are not recorded`, + }, + }); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('writes no stop record that would pass the byte limit', async () => { + // A log that another host filled up to the limit, tail reserve included. + const records = await prefillBytes(LIMIT - 30); + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await expect(eventLog.append('output', { data: 'late' })).resolves.toBe( + records - 1, + ); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(records - 1); + await eventLog.readAll(); + + expect(eventLog.getEvents()).toHaveLength(records); + expect(await fileBytes()).toBe(LIMIT - 30); + } finally { + await eventLog.close(); + } + }); + + it('keeps a tail reserve that fits the stop record and the largest exit record', () => { + const lineBytes = (record: object): number => + Buffer.byteLength(`${JSON.stringify(record)}\n`); + // `seq` stays below the entry limit. The host writes an integer exit + // code and `String(signal)` of a non-negative integer. + const stop = lineBytes({ + seq: MAX_EVENT_BUFFER_ENTRIES - 2, + ts: '2026-03-19T12:00:00.000Z', + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(MAX_EVENT_LOG_SIZE)} bytes); later events are not recorded`, + }, + }); + const exit = lineBytes({ + seq: MAX_EVENT_BUFFER_ENTRIES - 1, + ts: '2026-03-19T12:00:00.000Z', + type: 'exit', + payload: { + exitCode: -Number.MAX_SAFE_INTEGER, + exitSignal: String(Number.MAX_SAFE_INTEGER), + }, + }); + + expect(stop + exit).toBeLessThanOrEqual(EVENT_LOG_TAIL_RESERVE_BYTES); + }); + + it('stops at the entry limit: one stop record that names the events', async () => { + const maxEntries = 20; + const eventLog = await EventLog.open(eventLogPath, { maxEntries }); + + try { + // Ordinary records fill every entry but the two reserved ones. + for (let index = 0; index < maxEntries; index += 1) { + await eventLog.append('output', { data: `line ${String(index)}\r\n` }); + } + const stopSeq = maxEntries - EVENT_LOG_TAIL_RESERVE_ENTRIES; + expect(eventLog.getEvents().at(-1)?.seq).toBe(stopSeq); + + await expect( + eventLog.append('input_text', { data: 'typed' }), + ).resolves.toBe(stopSeq); + await expect( + eventLog.append('resize', { cols: 100, rows: 30 }), + ).resolves.toBe(stopSeq); + await expect(eventLog.append('marker', { label: 'late' })).resolves.toBe( + stopSeq, + ); + + // The exit record still has its entry. + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(stopSeq + 1); + await expect(eventLog.append('output', { data: 'after' })).resolves.toBe( + stopSeq + 1, + ); + await eventLog.readAll(); + + const events = eventLog.getEvents(); + expect(events).toHaveLength(maxEntries); + expect(events.map((event) => event.type)).toEqual([ + ...Array.from({ length: stopSeq }, () => 'output'), + 'marker', + 'exit', + ]); + expect(events.at(-2)).toMatchObject({ + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(maxEntries)} events); later events are not recorded`, + }, + }); + } finally { + await eventLog.close(); + } + + const stored = await readEventLogRecords(eventLogPath); + expect(stored.map((event) => event.seq)).toEqual( + stored.map((_, index) => index), + ); + expect(stored).toHaveLength(maxEntries); + }); + + it('writes the exit record into the entry reserve when the log has not stopped', async () => { + const maxEntries = 20; + const eventLog = await EventLog.open(eventLogPath, { maxEntries }); + + try { + for ( + let index = 0; + index < maxEntries - EVENT_LOG_TAIL_RESERVE_ENTRIES; + index += 1 + ) { + await eventLog.append('output', { data: 'y' }); + } + expect(stopRecords(eventLog)).toHaveLength(0); + + await eventLog.append('exit', { exitCode: 0, exitSignal: null }); + await eventLog.readAll(); + + expect(eventLog.getEvents().at(-1)?.type).toBe('exit'); + expect(stopRecords(eventLog)).toHaveLength(0); + } finally { + await eventLog.close(); + } + }); + + it('writes no stop record that would pass the entry limit', async () => { + // A log that another host filled up to the limit, reserve included. + const maxEntries = 10; + await prefillEntries(maxEntries); + const eventLog = await EventLog.open(eventLogPath, { maxEntries }); + + try { + await expect(eventLog.append('output', { data: 'late' })).resolves.toBe( + maxEntries - 1, + ); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(maxEntries - 1); + await eventLog.readAll(); + + expect(eventLog.getEvents()).toHaveLength(maxEntries); + } finally { + await eventLog.close(); + } + }); + + it('names the byte limit when a record passes both limits', async () => { + const maxEntries = 10; + await prefillEntries(maxEntries - EVENT_LOG_TAIL_RESERVE_ENTRIES); + const eventLog = await EventLog.open(eventLogPath, { + maxBytes: LIMIT, + maxEntries, + }); + + try { + await eventLog.append('output', { data: 'x'.repeat(LIMIT) }); + await eventLog.readAll(); + + expect(eventLog.getEvents().at(-1)).toMatchObject({ + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(LIMIT)} bytes); later events are not recorded`, + }, + }); + } finally { + await eventLog.close(); + } + }); + + it('writes the exit record into the reserve when the log has not stopped', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + const lineBytes = ( + seq: number, + type: 'output' | 'exit', + payload: object, + ): number => + Buffer.byteLength( + `${JSON.stringify({ seq, ts: new Date().toISOString(), type, payload })}\n`, + ); + const output = { data: 'y'.repeat(20) }; + const exit = { exitCode: 0, exitSignal: null }; + + try { + // Fill the file up to the reserve line, without stopping the log. + let seq = 0; + while ( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES - (await fileBytes()) >= + lineBytes(seq, 'output', output) + ) { + await eventLog.append('output', output); + seq += 1; + } + expect( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES - (await fileBytes()), + ).toBeLessThan(lineBytes(seq, 'exit', exit)); + expect(stopRecords(eventLog)).toHaveLength(0); + + await eventLog.append('exit', exit); + await eventLog.readAll(); + + expect(eventLog.getEvents().at(-1)?.type).toBe('exit'); + expect(stopRecords(eventLog)).toHaveLength(0); + expect(await fileBytes()).toBeGreaterThan( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES, + ); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('leaves a log that readers accept and the asciicast export shows the stop', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await fillUntilStopped(eventLog); + await eventLog.append('exit', { exitCode: 0, exitSignal: null }); + } finally { + await eventLog.close(); + } + + const events = await readEventLogRecords(eventLogPath); + expect(events.map((event) => event.seq)).toEqual( + events.map((_, index) => index), + ); + + const manifest: SessionRecord = { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:00.000Z', + status: 'exited', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: null, + childPid: null, + exitCode: 0, + exitSignal: null, + }; + const cast = generateAsciicast('session-01', manifest, events); + expect(cast.markerCount).toBe(1); + const markerLine = cast.contents + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown[]) + .find((line) => line[1] === 'm'); + expect(markerLine?.[2]).toContain(EVENT_LOG_LIMIT_LABEL_PREFIX); + }); + + it('reports a failed write to the caller of a dropped append', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + const eventLogInternals = eventLog as unknown as { + fileHandle: { + appendFile: (data: string, encoding: BufferEncoding) => Promise; + }; + writeQueue: Promise; + }; + + try { + await fillUntilStopped(eventLog); + vi.spyOn(eventLogInternals.fileHandle, 'appendFile').mockRejectedValue( + new Error('EIO'), + ); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).rejects.toThrow('EIO'); + await expect( + eventLog.append('output', { data: 'dropped' }), + ).rejects.toThrow('EIO'); + vi.restoreAllMocks(); + eventLogInternals.writeQueue = Promise.resolve(); + } finally { + await eventLog.close(); + } + }); + + it('refuses a limit that readers would not accept or that has no room', async () => { + await expect( + EventLog.open(eventLogPath, { maxBytes: MAX_EVENT_LOG_SIZE + 1 }), + ).rejects.toThrow('maxBytes must leave room'); + await expect( + EventLog.open(eventLogPath, { maxBytes: EVENT_LOG_TAIL_RESERVE_BYTES }), + ).rejects.toThrow('maxBytes must leave room'); + await expect( + EventLog.open(eventLogPath, { + maxEntries: MAX_EVENT_BUFFER_ENTRIES + 1, + }), + ).rejects.toThrow('maxEntries must leave room'); + await expect( + EventLog.open(eventLogPath, { + maxEntries: EVENT_LOG_TAIL_RESERVE_ENTRIES, + }), + ).rejects.toThrow('maxEntries must leave room'); + }); +}); diff --git a/test/unit/replay/offlineReplay.test.ts b/test/unit/replay/offlineReplay.test.ts index a39203ce..f4cd976f 100644 --- a/test/unit/replay/offlineReplay.test.ts +++ b/test/unit/replay/offlineReplay.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, realpath, rm, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; @@ -13,6 +13,7 @@ import type { import { withOfflineReplayRenderer } from '../../../src/replay/offlineReplay.js'; import type { RendererBackend } from '../../../src/renderer/backend.js'; import type { ReplayInput, ReplayState } from '../../../src/renderer/types.js'; +import { MAX_EVENT_LOG_SIZE } from '../../../src/storage/eventLogCodec.js'; import { writeManifest } from '../../../src/storage/manifests.js'; import { eventLogPath, @@ -559,6 +560,53 @@ describe('withOfflineReplayRenderer', () => { expect(backend.disposed).toBe(true); }); + it('names the size limit when the event log is too large', async () => { + const { sessionDir } = await createSessionFixture(); + const eventsFile = eventLogPath(sessionDir); + await truncate(eventsFile, MAX_EVENT_LOG_SIZE + 1); + const backend = createMockBackend(); + + const replayPromise = withOfflineReplayRenderer( + { sessionDir }, + () => Promise.resolve('unreachable'), + { backendFactory: () => backend }, + ); + + await expect(replayPromise).rejects.toMatchObject({ + name: 'CliError', + code: ERROR_CODES.REPLAY_ERROR, + message: `Failed to read event log: ${eventsFile} is ${String(MAX_EVENT_LOG_SIZE + 1)} bytes, over the ${String(MAX_EVENT_LOG_SIZE)}-byte limit that commands can read.`, + details: { + eventLogPath: eventsFile, + eventLogBytes: MAX_EVENT_LOG_SIZE + 1, + maxEventLogBytes: MAX_EVENT_LOG_SIZE, + }, + }); + expect(backend.booted).toBe(false); + expect(backend.disposed).toBe(true); + }); + + it('keeps the generic message for other event log read failures', async () => { + const { sessionDir } = await createSessionFixture({ + eventsContents: '{"seq":0', + }); + const eventsFile = eventLogPath(sessionDir); + const backend = createMockBackend(); + + const replayPromise = withOfflineReplayRenderer( + { sessionDir }, + () => Promise.resolve('unreachable'), + { backendFactory: () => backend }, + ); + + await expect(replayPromise).rejects.toMatchObject({ + name: 'CliError', + code: ERROR_CODES.REPLAY_ERROR, + message: `Failed to read event log: ${eventsFile}`, + details: {}, + }); + }); + it('wraps non-contiguous event log sequences as REPLAY_ERROR and still disposes the backend', async () => { const { sessionDir } = await createSessionFixture({ events: [ diff --git a/test/unit/storage/eventLogCodec.test.ts b/test/unit/storage/eventLogCodec.test.ts index 16c8492d..e6e5d893 100644 --- a/test/unit/storage/eventLogCodec.test.ts +++ b/test/unit/storage/eventLogCodec.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { EventRecord } from '../../../src/protocol/schemas.js'; import { assertEventLogSize, + EventLogTooLargeError, MAX_EVENT_LOG_SIZE, parseEventLogContent, readEventLogRecords, @@ -84,6 +85,21 @@ describe('event log codec', () => { ); }); + it('throws a typed error that carries the size and the limit', async () => { + expect(() => assertEventLogSize(MAX_EVENT_LOG_SIZE + 1)).toThrow( + EventLogTooLargeError, + ); + + await writeFile(eventLogPath, '', 'utf8'); + await truncate(eventLogPath, MAX_EVENT_LOG_SIZE + 1); + + await expect(readEventLogRecords(eventLogPath)).rejects.toMatchObject({ + name: 'EventLogTooLargeError', + size: MAX_EVENT_LOG_SIZE + 1, + maxSize: MAX_EVENT_LOG_SIZE, + }); + }); + it('parses empty event log content as no records', () => { expect(parseEventLogContent('')).toEqual([]); });