diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c25b29..74de9122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.2.54.0] - 2026-10-28 (development candidate) + +Cycle 0059 pt1 ([Issue #147](https://github.com/laststance/codebook/issues/147)), the first of three parts of a feature cycle whose outcome is a server run saved as a learning replay. This part lays the storage a server replay will be written to and read from. Nothing writes one yet. A server run is still refused at **Save learning replay**, and nothing about server runs changes for you in this version. N59, the cycle's native trial, runs on the last part. + +**The saved study limit is counted the same way everywhere.** + +- **A new study's save, the check at a run's start and a recording's start count your test replays too**, as a test's start already counted your saved studies. While saved studies and test replays fill **Codebook: Saved Study Limit**, a new run is refused before the application receives any input, with the study library's full-library sentence, instead of running and then being refused at its save. A test replay folder Codebook cannot read takes no place, as before. + ## [0.2.53.0] - 2026-10-28 (development candidate) Cycle 0058 ([Issue #143](https://github.com/laststance/codebook/issues/143)), a bug-fix cycle: it fixes the 13 defects Cycle 0057 carried, so a stopped or retried server run does only what it reports, and what the chat and you read about a server run is true. In the native trial N58, on the pinned clone of `laststance/switch-time` with its database stopped (development evidence, no acceptance credit), on macOS 26 (a virtual machine) in VS Code 1.142.0 (Copilot Chat, GPT-6.1 Terra) and this cycle's candidate `6473c5c3`, a natural question about how sign-in finds a user started the server; after a `500` the chat read the run's output, named the refused database connection as observed output, and every stop freed the port within a second. The released source adds review fixes made after `6473c5c3` (the late pause during a stop, the retried stop's check before SIGKILL, the message of a body that cannot be shown, the LLM Wiki view's kept message); N58 did not run again on them. The trial's identities and the hashes of its stills (kept private) are in [Issue #143's comments](https://github.com/laststance/codebook/issues/143). diff --git a/README.md b/README.md index aee9a028..ab4bc65a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Codebook contributes `debug.focusEditorOnBreak: false` as the native debugger de ## Implementation status -This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.53.0** once the pull request of [Cycle 0058](https://github.com/laststance/codebook/issues/143) is merged; until then 0.2.52.0 ([PR #142](https://github.com/laststance/codebook/pull/142), the third pull request of Cycle 0057) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0058 is a bug-fix cycle that closes the 13 defects Cycle 0057 carried: a stopped or retried server run signals and reports only what it did, what the chat and the learner read about a server run is true (skill 1.0.52), and the LLM Wiki view's message no longer flickers empty. Cycle 0057 was a feature cycle that explains a repository's own Node HTTP server live, in three pull requests: the first masks credentials in what every run captures and lays the server profile's launch parts; the second offers the `node-server` profile through MCP, which starts the server under the debugger, sends one declared request (confirmed by you when it changes data) and stops in the route handler's source; the third routes the bundled skill to it (skill 1.0.51), tightens stopping and what the run shows, and carries the native trial in VS Code and Cursor. Cycle 0056 was a bug-fix cycle that closed the defects Cycle 0055 carried, in three pull requests: the first makes an LLM Wiki start say what happens when it joins a start under way, is stopped or passes its bound, and release or write back what it claimed; the second the LLM Wiki view and the reader; the third a saved test replay that Cursor refused to open, with the native trial. [Cycle 0055](https://github.com/laststance/codebook/issues/132) is a feature cycle: **Codebook: Generate LLM Wiki** writes a project's first LLM Wiki through the editor's agent from Codebook's own entry (its first pull request), and an **LLM Wiki** view in the Activity Bar shows, opens and updates it (its second). Cycle 0053 was a feature cycle: a test inside a workspace package runs with that package's Vitest and config, as the package's own `vitest` would, while a test the repository root's config already selects keeps running from the root. Cycle 0054 is a bug-fix cycle that closes the three defects Cycle 0053 carried: a test that ends before a later declared stop says so, a package test's launch asks the repository root again whether its config now selects the test, and the native-trial bootstrap keeps a listing it could not release. Cycle 0051 opened a test replay in the learning-replay composition a browser replay has: the Learning summary in the chat's place, one transport for both replay families, and a Learning summary button that shows a hidden summary again. Cycle 0052 was a bug-fix cycle that closed every defect Cycle 0051 carried, in three pull requests: the first makes replays leave, open and reload without stale frames and makes their entries and exits behave as the learner chose; the second fixes a test replay's moves and its summary page; the third the live runtime's unreadable facts, a browser run's refusals and the tests' hygiene. The MCP API is 1.8. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. +This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.54.0** once the first pull request of [Cycle 0059](https://github.com/laststance/codebook/issues/147) is merged; until then 0.2.53.0 ([PR #144](https://github.com/laststance/codebook/pull/144), Cycle 0058) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0059 is a feature cycle that saves a server run as a learning replay, in three pull requests: the first lays the storage a server replay is written to and read from (nothing writes one yet, so a server run is still refused at **Save learning replay**) and makes the saved study limit count test replays at a study's save, a run's start and a recording's start too; the second reads and replays a saved server history; the third records it during the run and carries the native trial. Cycle 0058 was a bug-fix cycle that closes the 13 defects Cycle 0057 carried: a stopped or retried server run signals and reports only what it did, what the chat and the learner read about a server run is true (skill 1.0.52), and the LLM Wiki view's message no longer flickers empty. Cycle 0057 was a feature cycle that explains a repository's own Node HTTP server live, in three pull requests: the first masks credentials in what every run captures and lays the server profile's launch parts; the second offers the `node-server` profile through MCP, which starts the server under the debugger, sends one declared request (confirmed by you when it changes data) and stops in the route handler's source; the third routes the bundled skill to it (skill 1.0.51), tightens stopping and what the run shows, and carries the native trial in VS Code and Cursor. Cycle 0056 was a bug-fix cycle that closed the defects Cycle 0055 carried, in three pull requests: the first makes an LLM Wiki start say what happens when it joins a start under way, is stopped or passes its bound, and release or write back what it claimed; the second the LLM Wiki view and the reader; the third a saved test replay that Cursor refused to open, with the native trial. [Cycle 0055](https://github.com/laststance/codebook/issues/132) is a feature cycle: **Codebook: Generate LLM Wiki** writes a project's first LLM Wiki through the editor's agent from Codebook's own entry (its first pull request), and an **LLM Wiki** view in the Activity Bar shows, opens and updates it (its second). Cycle 0053 was a feature cycle: a test inside a workspace package runs with that package's Vitest and config, as the package's own `vitest` would, while a test the repository root's config already selects keeps running from the root. Cycle 0054 is a bug-fix cycle that closes the three defects Cycle 0053 carried: a test that ends before a later declared stop says so, a package test's launch asks the repository root again whether its config now selects the test, and the native-trial bootstrap keeps a listing it could not release. Cycle 0051 opened a test replay in the learning-replay composition a browser replay has: the Learning summary in the chat's place, one transport for both replay families, and a Learning summary button that shows a hidden summary again. Cycle 0052 was a bug-fix cycle that closed every defect Cycle 0051 carried, in three pull requests: the first makes replays leave, open and reload without stale frames and makes their entries and exits behave as the learner chose; the second fixes a test replay's moves and its summary page; the third the live runtime's unreadable facts, a browser run's refusals and the tests' hygiene. The MCP API is 1.8. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. The specification is [goal version 10](PRODUCT_GOAL.md), and the implementation targets external repositories. Historical fixture or coupled-Wiki success is not proof of that destination. diff --git a/VERSION b/VERSION index 13ed2b5f..1bc9bc9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.53.0 +0.2.54.0 diff --git a/extension/README.md b/extension/README.md index 77526c99..9ae08a3a 100644 --- a/extension/README.md +++ b/extension/README.md @@ -627,7 +627,7 @@ From 0.2.22 the refusal names what you can do at your limit: - below 256: `Study library is full. Increase Codebook: Saved Study Limit (codebook.savedStudyLimit) in Settings, or delete a saved study from Saved studies in the Study workspace, before starting another run. Only the learner can do either; an editor agent cannot. Existing learning was kept.` - at 256: `Study library is full at its maximum of 256 saved studies. Delete a saved study from Saved studies in the Study workspace before starting another run. Only the learner can delete a saved study; an editor agent cannot. Existing learning was kept.` -The messages for a study that cannot be saved and for a recording that cannot start follow the same pattern (`…then retry storage.`, `Recording library is full…delete a saved study that has a recording…`). A refused run is not saved later either: it leaves no study behind, the Study pane shows `The last run did not start: ` without offering **Retry storage / refresh** for it, and its run resource reports its history as unavailable. A limit setting that is not an integer from 1 to 256 also ends a run with `PREREQUISITE_MISSING` and `Set Codebook: Saved Study Limit (codebook.savedStudyLimit) to an integer from 1 to 256. Existing learning was kept.` The check at the start of a run reserves nothing: when two editor windows of the same workspace each start a run for the last free place, both starts are accepted, and the study saved second is refused at its save with the `…then retry storage.` message; raise the limit or delete a saved study, then use **Retry storage / refresh**. The start of a Node test is different: it takes the storage lock (`writer.lock` in the learning storage) and reserves its history under it, so of two windows that start a test for the last free place one is refused with `PREREQUISITE_MISSING` and the sentence of the study library above. While the saved studies and the test histories fill the limit, `codebook_status` reads the `node-test` profile as not ready and its `missing` sentence names the count of taken places and the limit, so an editor agent does not prepare a start that would be refused; only the learner can raise the setting in the Settings of the running editor profile or delete a saved study, and a test replay cannot be deleted from Saved studies yet. A start that meets the lock while another window holds it, or a lock that stays, is refused with the storage lock's sentence, which [Delete a saved study](#delete-a-saved-study) quotes and explains; nothing was written then, and the test was not started. +The messages for a study that cannot be saved and for a recording that cannot start follow the same pattern (`…then retry storage.`, `Recording library is full…delete a saved study that has a recording…`). A refused run is not saved later either: it leaves no study behind, the Study pane shows `The last run did not start: ` without offering **Retry storage / refresh** for it, and its run resource reports its history as unavailable. A limit setting that is not an integer from 1 to 256 also ends a run with `PREREQUISITE_MISSING` and `Set Codebook: Saved Study Limit (codebook.savedStudyLimit) to an integer from 1 to 256. Existing learning was kept.` The check at the start of a run reserves nothing: when two editor windows of the same workspace each start a run for the last free place, both starts are accepted, and the study saved second is refused at its save with the `…then retry storage.` message; raise the limit or delete a saved study, then use **Retry storage / refresh**. The start of a Node test is different: it takes the storage lock (`writer.lock` in the learning storage) and reserves its history under it, so of two windows that start a test for the last free place one is refused with `PREREQUISITE_MISSING` and the sentence of the study library above. While the saved studies and the test histories fill the limit, `codebook_status` reads the `node-test` profile as not ready and its `missing` sentence names the count of taken places and the limit, so an editor agent does not prepare a start that would be refused; only the learner can raise the setting in the Settings of the running editor profile or delete a saved study, and a test replay cannot be deleted from Saved studies yet. The limit is shared in both directions: the save of a new study, the check at a run's start and a recording's start count the test histories too, so while the saved studies and the test histories fill the limit a new run or study is refused with the study library's sentence above, as a test start is. A start that meets the lock while another window holds it, or a lock that stays, is refused with the storage lock's sentence, which [Delete a saved study](#delete-a-saved-study) quotes and explains; nothing was written then, and the test was not started. #### Delete a saved study diff --git a/extension/fallow-dead-code-baseline.json b/extension/fallow-dead-code-baseline.json index 711c0db5..fb8bc4cb 100644 --- a/extension/fallow-dead-code-baseline.json +++ b/extension/fallow-dead-code-baseline.json @@ -71,11 +71,8 @@ "unused-class-member:src/client.ts:StudyClient:discover", "unused-class-member:src/client.ts:StudyClient:json", "unused-class-member:src/client.ts:StudyClient:tool", - "unused-class-member:src/nodeTestHistoryStore.ts:NodeTestHistoryWriter:abandon", - "unused-class-member:src/nodeTestHistoryStore.ts:NodeTestHistoryWriter:appendOutput", - "unused-class-member:src/nodeTestHistoryStore.ts:NodeTestHistoryWriter:appendResult", - "unused-class-member:src/nodeTestHistoryStore.ts:NodeTestHistoryWriter:appendStop", - "unused-class-member:src/nodeTestHistoryStore.ts:NodeTestHistoryWriter:finish" + "unused-class-member:src/nodeTestHistoryWriter.ts:NodeTestHistoryWriter:appendResult", + "unused-class-member:src/nodeTestHistoryWriter.ts:NodeTestHistoryWriter:appendStop" ], "unused_store_members": [], "unprovided_injects": [], diff --git a/extension/package.json b/extension/package.json index ce958fcf..f5fa535b 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "codebook", "description": "Connect an external agent to source-grounded codebase studies.", "publisher": "laststance", - "version": "0.2.53", + "version": "0.2.54", "private": true, "engines": { "vscode": "^1.120.0", diff --git a/extension/scripts/tests/package.test.mjs b/extension/scripts/tests/package.test.mjs index f77ed6df..8ad7dde5 100644 --- a/extension/scripts/tests/package.test.mjs +++ b/extension/scripts/tests/package.test.mjs @@ -95,7 +95,7 @@ test('a real VSIX loads outside the checkout and excludes bundled applications a // Assert assert.equal(receipt.isolatedModuleLoad, true) assert.equal(receipt.isolatedWikiPageWorker, true) - assert.equal(checked.extension.version, '0.2.53') + assert.equal(checked.extension.version, '0.2.54') assert.equal(checked.extension.apiVersion, '1.8') assert.equal(checked.entries, 33) assert.equal(receipt.dependencies.length, 16) diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 4fe3ee33..8169c3fe 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -8,7 +8,7 @@ import { CAPTURE_TIMEOUT_MS } from './runtimeSupport/constants' * 1.8 changed node-test outcomes: a passing test whose history sealed complete reads `demonstrated` in `finalOutcome`, `studyOutcome` and `recordedStudyOutcome` (1.7 stopped at `partial`), its sealed history is a test replay (`recordingAvailable` and `replayAvailable`), and `nodeTest` adds `testResult`, `learningCoverage` and `learningMissing`. A node-test start's deterministic refusals arrive as the run's `error` before its first stop, a failed history seal makes `codebook_stop` answer `HISTORY_SEAL_FAILED`, and `codebook_playback` refuses a test replay with `PLAYBACK_PROFILE_UNSUPPORTED`. */ export const API_VERSION = '1.8' -export const EXTENSION_VERSION = '0.2.53' +export const EXTENSION_VERSION = '0.2.54' export const CONFIGURED_MINIMUM_EXTENSION_VERSION = '0.2.0' export const NODE_TEST_MINIMUM_EXTENSION_VERSION = '0.2.12' export const LOOPBACK_ADDRESS = '127.0.0.1' @@ -1365,6 +1365,38 @@ export const NODE_TEST_HISTORY_WRITER_LIMIT_BYTES = 4_096 /** The largest seal file of a test history Codebook writes or reads ({@link NodeTestHistoryWriter.finish}, {@link isSealedEmptyHistory}). */ export const NODE_TEST_HISTORY_SEAL_LIMIT_BYTES = 16_384 export const NODE_TEST_HISTORY_MAX_ENTRIES = 10_000 +/** The folder of node-server histories beside `node-tests.v1`, so test histories need no migration and a revert leaves them untouched. */ +export const NODE_SERVER_HISTORY_DIRECTORY = 'node-servers.v1' +/** The `formatVersion` of a node-server history header; a later cycle relates several histories of one explanation without a format break. */ +export const NODE_SERVER_HISTORY_FORMAT_VERSION = 1 +/** The most entries one node-server history holds, `source` and `end` included. */ +export const NODE_SERVER_HISTORY_MAX_ENTRIES = 10_000 +/** The largest journal of a node-server history. */ +export const NODE_SERVER_HISTORY_LIMIT_BYTES = 8_388_608 +/** The largest seal of a node-server history; it holds every partial reason uncut. */ +export const NODE_SERVER_HISTORY_SEAL_LIMIT_BYTES = 262_144 +/** The most `stop` entries one node-server history holds: the run's capture limit. */ +export const NODE_SERVER_HISTORY_MAX_STOPS = NODE_SERVER_REQUEST_CAPTURE_LIMIT +/** Output entries one node-server history keeps before one `output-truncated` entry replaces the rest. */ +export const NODE_SERVER_OUTPUT_BUDGET_ENTRIES = 2_000 +/** Output bytes one node-server history keeps before one `output-truncated` entry replaces the rest. */ +export const NODE_SERVER_OUTPUT_BUDGET_BYTES = 2_097_152 +/** Journal bytes kept free of output for the request, sources, stops, terminal entry and `end`. */ +export const NODE_SERVER_STRUCTURAL_HEADROOM_BYTES = 1_048_576 +/** Journal entries kept free of output for the request, sources, stops, terminal entry and `end`. */ +export const NODE_SERVER_STRUCTURAL_HEADROOM_ENTRIES = 100 +/** Entries a node-server writer holds pending before it drops output; structural entries wait instead. */ +export const NODE_SERVER_WRITER_QUEUE_LIMIT_ENTRIES = 1_000 +/** Bytes a node-server writer holds pending before it drops output; structural entries wait instead. */ +export const NODE_SERVER_WRITER_QUEUE_LIMIT_BYTES = 4_194_304 +/** The largest source text a `source` entry stores; a larger file is stored as its hash with `sourceUnavailable: 'too large'`. */ +export const NODE_SERVER_SOURCE_TEXT_LIMIT_BYTES = 524_288 +/** The longest text of one `output` entry of a node-server history. */ +export const NODE_SERVER_OUTPUT_TEXT_LIMIT_CHARS = 4_096 +/** The longest response body one `response` entry stores (the shown body: `shapeResponseBody` cuts at 8,192 bytes). */ +export const NODE_SERVER_RESPONSE_BODY_LIMIT_CHARS = 16_384 +/** How many entries a missing-evidence list names in an answer; the rest is a count. */ +export const NODE_SERVER_MISSING_LIST_LIMIT = 20 /** File system error codes that a retry can clear (a descriptor or lock shortage, a busy or antivirus-held file, a disk error): a read that fails with one is not remembered as the history's state ({@link isTransientFileError}). */ export const TRANSIENT_FILE_ERROR_CODES: readonly string[] = [ 'EMFILE', @@ -1489,6 +1521,8 @@ export const REPOSITORY_CONDITION_LIMIT = 8 export const REPOSITORY_ATTRIBUTE_LIMIT_CHARS = 64 export const REPOSITORY_LITERAL_LIMIT_CHARS = 1_024 export const REPOSITORY_INSPECTION_LIMIT = 10 +/** The most unreadable values one node-server `stop` entry lists: one per inspection its declaration allows, so the seal's reasons (each kept uncut) stay inside {@link NODE_SERVER_HISTORY_SEAL_LIMIT_BYTES}. */ +export const NODE_SERVER_STOP_UNAVAILABLE_LIMIT = REPOSITORY_INSPECTION_LIMIT export const REPOSITORY_INSPECTION_TIMEOUT_MS = 1_000 export const REPOSITORY_INSPECTION_PATH_LIMIT_CHARS = 128 export const REPOSITORY_INSPECTION_SEGMENT_LIMIT = 4 @@ -1984,3 +2018,18 @@ export const PREVIEW_VALUE_PREFIX_LIMIT_CHARS = 128 /** How much of an oversized private-key block an output redactor drops before it ends the block at the next line break, so a header without an END marker cannot silence the channel ({@link createOutputRedactor}); four times a 4096-bit RSA key's PEM. */ export const OUTPUT_REDACTION_BLOCK_DROP_LIMIT_CHARS = 16_384 +/** The stable codes of the reasons a node-server history is partial; each is keyed in the README's troubleshooting table and listed in `captureMissing`. */ +export const NODE_SERVER_HISTORY_REASON_CODES = { + noResponseRequestFailed: 'SERVER_HISTORY_NO_RESPONSE_REQUEST_FAILED', + noResponseStopped: 'SERVER_HISTORY_NO_RESPONSE_STOPPED', + noResponseExited: 'SERVER_HISTORY_NO_RESPONSE_EXITED', + noResponseRunFailed: 'SERVER_HISTORY_NO_RESPONSE_RUN_FAILED', + stopNotReached: 'SERVER_HISTORY_STOP_NOT_REACHED', + snapshotMissing: 'SERVER_HISTORY_SNAPSHOT_MISSING', + sourceUnavailable: 'SERVER_HISTORY_SOURCE_UNAVAILABLE', + valueUnavailable: 'SERVER_HISTORY_VALUE_UNAVAILABLE', + appendFailed: 'SERVER_HISTORY_APPEND_FAILED', + sizeLimit: 'SERVER_HISTORY_SIZE_LIMIT', + captureBudgetExhausted: 'SERVER_HISTORY_CAPTURE_BUDGET_EXHAUSTED', + outputTruncated: 'SERVER_HISTORY_OUTPUT_TRUNCATED', +} as const diff --git a/extension/src/historyStorageDescriptor.ts b/extension/src/historyStorageDescriptor.ts new file mode 100644 index 00000000..ee483262 --- /dev/null +++ b/extension/src/historyStorageDescriptor.ts @@ -0,0 +1,162 @@ +import type { NodeLearningNeeds } from './utils/listMissingNodeExplanations' +import type { CitableEvent } from './utils/recordingJournalScan' + +/** The fields every history header has, whatever its profile. */ +export type HistoryHeaderBase = { + profile: string + workspaceKey: string + studyId: string + instanceId: string + runId: string + createdAt: string +} + +/** The fields every journal entry has, whatever its profile. */ +export type HistoryEntryBase = { + type: string + sequence: number + elapsedMs: number +} + +/** The fields every seal has, whatever its profile. */ +export type HistorySealBase = { + studyId: string + entryCount: number + journalBytes: number + journalHash: string + status: 'complete' | 'partial' + endedAt: string + reason?: string +} + +/** The fields every summary has, whatever its profile. */ +export type HistorySummaryBase = { + studyId: string + createdAt: string + profile: string + status: 'recording' | 'complete' | 'partial' | 'interrupted' + reason?: string + verification: 'verified' | 'unavailable' +} + +/** The five types one profile fixes: what a store with its descriptor reads and writes. */ +export type HistoryTypes = { + Header: HistoryHeaderBase + Entry: HistoryEntryBase + Seal: HistorySealBase + Progress: unknown + Summary: HistorySummaryBase + /** The writer class a store of this profile hands to its caller. */ + Writer: object +} + +/** A verified history as a store returns it. */ +export type HistoryIndexOf = { + header: Types['Header'] + entries: Types['Entry'][] + seal?: Types['Seal'] + status: 'recording' | 'complete' | 'partial' | 'interrupted' + reason?: string +} + +/** What the writer asks of a profile's rules, one instance per writer, holding that writer's state. */ +export type HistoryWriterRules = { + /** The largest journal this profile writes. */ + readonly journalLimitBytes: number + readonly maxEntries: number + /** Parses a numbered entry the writer is about to write. */ + parseEntry(value: unknown): Types['Entry'] + /** Refuses an entry that breaks the profile's order rules; changes nothing. @throws The refusal. */ + check(entry: Types['Entry']): void + /** Records an entry whose line is durable. */ + commit(entry: Types['Entry']): void + /** Whether the journal holds nothing to replay, so the folder is removed at the seal. */ + holdsNothingToReplay(entryCount: number): boolean + /** Refuses a seal the profile cannot honestly make, before the journal is read. @throws The refusal. */ + assertSealable(status: 'complete' | 'partial', failed: boolean): void + /** Builds and validates the seal from the journal as written. */ + buildSeal(input: { + studyId: string + status: 'complete' | 'partial' + reason?: string + journal: Buffer + entryCount: number + endedAt: string + /** The first failed append, when one ended the timeline. */ + appendFailure?: Error + /** Whether an entry or byte bound refused an entry. */ + limitReached: boolean + }): Types['Seal'] + readonly sealLimitBytes: number +} + +/** What the incremental scan of a history another window records asks of a profile. */ +export type HistoryScanRules = { + readonly journalLimitBytes: number + parseEntry(value: unknown): Types['Entry'] + start(): Types['Progress'] + accept( + progress: Types['Progress'], + entry: Types['Entry'], + header: Types['Header'], + ): void + citableOf(entry: Types['Entry']): CitableEvent | undefined + /** Whether a scan keeps the whole entry (a recording keeps only what a summary counts). */ + keeps(entry: Types['Entry']): boolean +} + +/** + * One storage descriptor per history profile: its folder, codec and validation, order rules, journal-scan rules, seal evaluator and summary. The store is generic over it, so a later execution form adds a descriptor instead of branches. It never imports UI: the UI adapter of a profile (text catalog, learner events, accessible words) is separate. + * Passed to {@link NodeTestHistoryStore}; the default is {@link nodeTestStorageDescriptor}. + */ +export type HistoryStorageDescriptor = { + readonly profile: Types['Header']['profile'] + /** The folder under the learning directory. */ + readonly directoryName: string + readonly journalLimitBytes: number + readonly sealLimitBytes: number + /** What the store words when it meets a journal that does not verify. */ + readonly words: { interruptedPrefix: string; noSeal: string; subject: string } + /** Adds the profile's version field to a header the caller built. */ + stampHeader( + input: Omit, + ): unknown + parseHeader(value: unknown): Types['Header'] + parseSeal(value: unknown): Types['Seal'] + parseIndex(value: HistoryIndexOf): HistoryIndexOf + scanRules: HistoryScanRules + /** Makes the writer of a reserved history; `describeCause` words a failure without private paths. */ + createWriter( + directory: string, + header: Types['Header'], + onClosed: () => void, + describeCause: (error: unknown) => string, + ): Types['Writer'] + /** Refuses a sealed journal whose seal contradicts what the entries establish. @throws The refusal. */ + verifySeal(input: { + seal: Types['Seal'] + header: Types['Header'] + entries: Types['Entry'][] + progress: Types['Progress'] + hasIncompleteLine: boolean + }): void + /** Whether this profile keeps learning material in pt1's store (test histories do; server histories gain it with the MCP release). */ + readonly supportsLearningMaterial: boolean + emptySummary(header: Types['Header']): Types['Summary'] + /** Counts a verified history into a summary. */ + summarize(input: { + history: Pick< + HistoryIndexOf, + 'header' | 'entries' | 'status' | 'reason' | 'seal' + > + materialContent?: unknown + materialRevision?: number + }): { summary: Types['Summary']; needs?: NodeLearningNeeds } + /** Re-counts a summary against a changed learning material without reading the journal; only a profile that keeps learning material has it. */ + applyMaterial?(input: { + summary: Types['Summary'] + needs: NodeLearningNeeds + materialContent?: unknown + materialRevision: number + }): Types['Summary'] +} diff --git a/extension/src/learningLibrary.ts b/extension/src/learningLibrary.ts index d9686514..334b1dc5 100644 --- a/extension/src/learningLibrary.ts +++ b/extension/src/learningLibrary.ts @@ -374,6 +374,7 @@ export class LearningLibrary { * @param workspaceKey - The hash of the workspace the learning belongs to. * @param capacity - Reads the Saved Study Limit setting at each admission. * @param removalAvailability - Reads at each refused save whether this window can remove a saved study, so the refusal names a removal only while one can work here. + * @param savedHistoryCount - Counts the node histories that take places of the same limit, up to the given number; the default counts none. * @example new LearningLibrary(directory, workspaceKey, () => 64, () => service.studyRemovalAvailability) */ constructor( @@ -382,6 +383,10 @@ export class LearningLibrary { private readonly capacity: () => unknown = () => undefined, private readonly removalAvailability: () => StudyRemovalAvailability = () => 'available', + /** Counts the histories (test and server) that share the saved-study limit, up to the given number; read at each admission so a browser save counts every kind. */ + private readonly savedHistoryCount: ( + places: number, + ) => Promise = async () => 0, ) {} /** Resolves the current admission limit when another workspace-bound learner artifact is reserved. @example library.storageLimit() */ @@ -1143,11 +1148,17 @@ export class LearningLibrary { } /** Holds the one admission rule for {@link admitsStudy} and {@link saveStudy}: a study already kept is always admitted, a new one only below the limit. @example this.admits(existing, study.id) */ - private admits(existing: { id: string }[], studyId: string): boolean { - return ( - existing.some((value) => value.id === studyId) || - existing.length < savedStudyLimit(this.capacity()) - ) + private async admits( + existing: { id: string }[], + studyId: string, + ): Promise { + // Updating a study already kept is never a new reservation. + if (existing.some((value) => value.id === studyId)) return true + const limit = savedStudyLimit(this.capacity()) + const places = limit - existing.length + if (places <= 0) return false + // The histories of every kind share the limit: a place is free only while they leave one. + return (await this.savedHistoryCount(places)) < places } /** @@ -1180,7 +1191,7 @@ export class LearningLibrary { (await this.optionalStat(checkpoint.recordingDirectory)) === undefined ) throw new StudyRemovedWithItsRecordingError() - if (!this.admits(existing, study.id)) + if (!(await this.admits(existing, study.id))) throw new Error( fullLibraryMessage( 'save', diff --git a/extension/src/nodeHistoryJournalWriter.ts b/extension/src/nodeHistoryJournalWriter.ts new file mode 100644 index 00000000..69362a73 --- /dev/null +++ b/extension/src/nodeHistoryJournalWriter.ts @@ -0,0 +1,232 @@ +import { readFile, rm, open } from 'node:fs/promises' +import { join } from 'node:path' + +import { NODE_TEST_HISTORY_WRITER_FILE, PRIVATE_FILE_MODE } from './constants' +import type { + HistoryTypes, + HistoryWriterRules, +} from './historyStorageDescriptor' +import { sha256 } from './runtimeSupport/utils/sha256' +import { atomicPrivateWrite } from './utils/atomicPrivateWrite' +import { cutHistoryReason } from './utils/cutHistoryReason' +import { holdsLearnerFiles } from './utils/holdsLearnerFiles' + +/** The file names of a history's folder that the store and its writer share. */ +export const HISTORY_HEADER_FILE = 'header.json' +export const HISTORY_JOURNAL_FILE = 'events.jsonl' +export const HISTORY_SEAL_FILE = 'sealed.json' + +/** + * Serializes fsynced journal appends so every kind of evidence keeps one verified timeline, for any history profile: the profile's rules decide what an entry may follow, the writer decides durability. The first failed entry ends that timeline; the verified prefix can still be sealed as partial. + * Built by a profile's storage descriptor ({@link HistoryStorageDescriptor.createWriter}) and returned by {@link NodeTestHistoryStore.start}; its subclasses add the entry methods of their profile. + * It separates closed to new appends from draining accepted appends: an entry accepted before the seal began is written, then the seal follows; one offered after is refused at the call. + * @example await writer.finish('partial', 'Cancelled by the learner.') + */ +export class HistoryJournalWriter { + private sequence = 0 + private entries = 0 + /** Journal bytes written by confirmed entries; anything beyond them is a torn write. */ + private confirmedBytes = 0 + /** The first refused or failed entry; later evidence would follow a gap. */ + private failure?: Error + /** Whether an entry or byte bound refused an entry. */ + private limitReached = false + private pending: Promise = Promise.resolve() + private closed = false + /** Set when the seal began: new appends are refused, the accepted ones still drain. */ + private closing = false + /** Set by {@link abandon}: a seal still in flight checks it before it publishes `sealed.json`. */ + private abandoned = false + private finishOperation?: Promise + /** The SHA-256 of the journal this writer sealed; `undefined` before the seal and after an abandoned one. */ + private sealedHash?: string + + constructor( + private readonly directory: string, + readonly header: Types['Header'], + private readonly onClosed: () => void, + protected readonly rules: HistoryWriterRules, + ) {} + + /** + * Counts the entries this writer confirmed, the journal's entry count once sealed; read by {@link StudyService.sealNodeHistory}, which keeps it for the run's outcome: a replay needs at least one entry. + * @example writer.entryCount // => 4 + */ + get entryCount(): number { + return this.entries + } + + /** + * Fsyncs and seals the journal; after a failed entry only the verified prefix can be sealed, as partial; called by {@link StudyService.stopRecord}. + * The profile's rules decide whether `complete` is honest; a repeated or concurrent call joins the first one, so Save, Stop and a self-exit at once give one seal. + * @param status - `complete` for evidence the caller verified as whole, else `partial`. + * @param reason - Why a partial history ended. + * @returns The SHA-256 of the sealed journal, the run's `recording.contextHash`; `undefined` when the writer was abandoned before the call (nothing is sealed, nothing throws). + * @throws When the profile refuses `complete`, the journal holds bytes no entry confirmed, the seal cannot be written, or the writer is abandoned while this seal is in flight (a `sealed.json` it already published is removed again). + * @example await writer.finish('partial', 'Cancelled by the learner.') // => 'a3f…' + */ + finish( + status: 'complete' | 'partial', + reason?: string, + ): Promise { + if (this.closed) return Promise.resolve(this.sealedHash) + if (this.finishOperation) return this.finishOperation + const operation = (async () => { + this.closing = true + // Every entry accepted before this call is ahead of the seal in the queue. + await this.pending + this.rules.assertSealable(status, Boolean(this.failure)) + const path = join(this.directory, HISTORY_JOURNAL_FILE), + journal = await readFile(path) + // A write torn by an I/O failure leaves bytes no entry confirmed; sealing them would certify a journal readers must refuse. + if (journal.byteLength !== this.confirmedBytes) { + this.failure ??= new Error( + 'Node history journal has bytes its writer never confirmed.', + ) + throw new Error( + 'Node history journal has bytes its writer never confirmed; it remains an interrupted prefix.', + ) + } + // A history that holds nothing to replay: its folder is removed when it seals, so it is listed nowhere and takes no place of the saved study limit (D6, F85). A folder that cannot be removed is sealed as any other. + if ( + this.rules.holdsNothingToReplay(this.entries) && + // Learning material or understanding marks saved before the first entry keep the folder, sealed as any other (F274). + !(await holdsLearnerFiles(this.directory)) && + (await rm(this.directory, { recursive: true, force: true }).then( + () => true, + () => false, + )) + ) { + this.sealedHash = sha256(journal) + this.closed = true + this.onClosed() + return this.sealedHash + } + const seal = this.rules.buildSeal({ + studyId: this.header.studyId, + status, + reason: reason === undefined ? undefined : cutHistoryReason(reason), + journal, + entryCount: this.entries, + endedAt: new Date().toISOString(), + ...(this.failure ? { appendFailure: this.failure } : {}), + limitReached: this.limitReached, + }) + await atomicPrivateWrite( + join(this.directory, HISTORY_SEAL_FILE), + seal, + this.rules.sealLimitBytes, + // A seal that finishes after the writer was abandoned (a timeout) never publishes: the folder was reported interrupted. + () => { + if (this.abandoned) + throw new Error( + 'Node history seal was abandoned before it was published.', + ) + }, + ) + // An abandon that landed during the publish itself: the folder was reported interrupted, so the file just published goes again. + if (this.abandoned) { + await rm(join(this.directory, HISTORY_SEAL_FILE), { force: true }) + throw new Error( + 'Node history seal was abandoned before it was published.', + ) + } + this.sealedHash = seal.journalHash + this.closed = true + // The seal is published first: another window never finds neither the seal nor the record. + await this.removeWriterRecord() + this.onClosed() + return seal.journalHash + })().catch((error: unknown) => { + // An abandoned writer stays closed; any other failed seal may be tried again. + if (!this.abandoned) { + this.closing = false + this.finishOperation = undefined + } + throw error + }) + this.finishOperation = operation + return operation + } + + /** Marks a failed or timed-out finalization as an interrupted prefix so library readers do not show it as live; a seal still in flight publishes nothing afterwards. @example writer.abandon() */ + abandon(): void { + this.abandoned = true + this.closed = true + this.closing = false + // Other windows read the history as interrupted once the record is gone (F260); the removal is awaited by nobody. + void this.removeWriterRecord() + this.onClosed() + } + + /** Removes the record of the editor process that writes this history ({@link writeNodeTestWriterRecord}); a record that stays is ignored once its process is gone, and by the seal. @example await this.removeWriterRecord() */ + private removeWriterRecord(): Promise { + return rm(join(this.directory, NODE_TEST_HISTORY_WRITER_FILE), { + force: true, + }).catch(() => undefined) + } + + /** + * Queues one entry behind the accepted ones; the entry is numbered, checked against the profile's rules and made durable when its turn comes, so callers in one synchronous step get consecutive numbers in call order. + * @param input - The entry without its sequence. + * @returns Settles when the entry is durable; rejects when it is refused, and the writer then accepts nothing more. + * @throws Rejects at the call when the seal began or the writer is closed; later, with the profile's refusal or the I/O failure. + * @example await this.append({ type: 'output', elapsedMs: 1, channel: 'stdout', text: 'ready' }) + */ + protected append(input: Omit): Promise { + // Acceptance is decided here, in the caller's turn: an entry offered after the seal began is refused, one offered before it is written. + if (this.closed || this.closing) + return Promise.reject( + this.failure + ? this.afterFailure(this.failure) + : new Error('Node history is already sealing or sealed.'), + ) + const operation = this.pending.then(async () => { + // Evidence after a failed entry would follow a gap, so the verified prefix ends at the first failure. + if (this.failure) throw this.afterFailure(this.failure) + if (this.closed) + throw new Error('Node history is already sealing or sealed.') + if (this.entries >= this.rules.maxEntries) { + this.limitReached = true + throw new Error('Node history reached its event limit.') + } + const entry = this.rules.parseEntry({ + ...input, + sequence: this.sequence + 1, + }) + this.rules.check(entry) + const line = Buffer.from(`${JSON.stringify(entry)}\n`) + const path = join(this.directory, HISTORY_JOURNAL_FILE), + handle = await open(path, 'a', PRIVATE_FILE_MODE) + try { + const stat = await handle.stat() + if (stat.size + line.byteLength > this.rules.journalLimitBytes) { + this.limitReached = true + throw new Error( + 'Node history reached its storage limit; existing evidence remains readable.', + ) + } + await handle.writeFile(line) + await handle.sync() + } finally { + await handle.close() + } + this.sequence = entry.sequence + this.entries++ + this.confirmedBytes += line.byteLength + this.rules.commit(entry) + }) + // Later entries and the seal wait for this one without inheriting its rejection. + this.pending = operation.catch((error: unknown) => { + this.failure ??= error instanceof Error ? error : new Error(String(error)) + }) + return operation + } + + /** Words the refusal of an entry that follows a failed one. */ + private afterFailure(failure: Error): Error { + return new Error( + `Node history stopped accepting evidence after an earlier entry failed: ${failure.message}`, + ) + } +} diff --git a/extension/src/nodeHistoryRepository.ts b/extension/src/nodeHistoryRepository.ts new file mode 100644 index 00000000..a66f78e9 --- /dev/null +++ b/extension/src/nodeHistoryRepository.ts @@ -0,0 +1,118 @@ +import type { NodeServerHistoryTypes } from './nodeServerHistoryTypes' +import { nodeServerStorageDescriptor } from './nodeServerStorageDescriptor' +import { + NodeHistoryStore, + NodeTestHistoryStore, + type NodeHistoryWriterJudge, +} from './nodeTestHistoryStore' +import { isNodeHistoryIdentity } from './utils/isNodeHistoryIdentity' + +/** The store a study id resolved to, with the profile that tells which. */ +export type ResolvedNodeHistory = + | { profile: 'node-test'; store: NodeTestHistoryStore } + | { profile: 'node-server'; store: NodeHistoryStore } + +/** + * Holds the stores of every history profile under one learning directory and resolves a study id to the store whose folder holds it, so the workspace, resources, MCP and deletion ask one place. It also wires the shared admission: each store counts the other profiles' histories and the saved studies together under the one storage lock, so a test, a server and a browser save race for the same last place and one wins. + * Built where the workspace builds its history store; nothing writes a server history yet. + * @example const repository = new NodeHistoryRepository(directory, workspaceKey, () => 64, () => library.savedStudyCount()) + */ +export class NodeHistoryRepository { + readonly testHistories: NodeTestHistoryStore + readonly serverHistories: NodeHistoryStore + + /** + * @param learningDirectory - The learning directory both profiles' folders live in. + * @param workspaceKey - The workspace the histories belong to. + * @param capacity - Reads the saved study limit at each admission. + * @param savedArtifactCount - Counts the saved studies as the library's own admission counts them. + * @param writerJudge - The clock and the judge of another window's writer; a test names its own. + */ + constructor( + learningDirectory: string, + workspaceKey: string, + capacity: () => number, + savedArtifactCount: () => Promise, + writerJudge?: NodeHistoryWriterJudge, + ) { + this.testHistories = new NodeTestHistoryStore( + learningDirectory, + workspaceKey, + capacity, + savedArtifactCount, + writerJudge, + undefined, + (places) => this.serverHistories.countListedHistories(places), + ) + this.serverHistories = new NodeHistoryStore( + nodeServerStorageDescriptor, + learningDirectory, + workspaceKey, + capacity, + savedArtifactCount, + writerJudge, + (places) => this.testHistories.countListedHistories(places), + ) + } + + /** + * Resolves a study id to the store whose folder holds it, without reading any file. + * @param studyId - A study id an agent sent or a listing produced. + * @returns The store and its profile; `undefined` for an id that is no history identity or has no folder in either profile. + * @example repository.resolve(studyId)?.profile // => 'node-server' + */ + resolve(studyId: string): ResolvedNodeHistory | undefined { + if (!isNodeHistoryIdentity(studyId)) return undefined + if (this.testHistories.holdsHistoryFolder(studyId)) + return { profile: 'node-test', store: this.testHistories } + if (this.serverHistories.holdsHistoryFolder(studyId)) + return { profile: 'node-server', store: this.serverHistories } + return undefined + } + + /** + * Counts every kind of history, up to `places`, for the library's admission: a browser save counts test and server histories as the histories count saved studies. + * A history directory that cannot be read counts no history, as only a history the list shows counts: an unreadable Node history store never blocks a browser save. The histories' own admission ({@link NodeHistoryStore.start}) keeps refusing on it. + * @param places - The places the histories may take; the count is not carried beyond it. + * @returns The histories of both profiles, or `places` once that many are found. + * @example await repository.countHistories(64) // => 5 + */ + async countHistories(places: number): Promise { + const tests = await this.testHistories + .countListedHistories(places) + .catch(() => 0) + return ( + tests + + (await this.serverHistories + .countListedHistories(Math.max(places - tests, 0)) + .catch(() => 0)) + ) + } + + /** + * Reads the entry a study id and sequence name, validating the id with the history identity rule and the sequence as a positive integer the verified journal holds. + * @param studyId - The history's study id. + * @param sequence - The entry's journal sequence. + * @returns The profile and the entry as stored. + * @throws When the id names no history, the sequence is not a positive integer, no entry has it, or the journal does not verify. + * @example const { entry } = await repository.entryAt(studyId, 3) + */ + // Consumed by the run's history sink (Cycle 0059 pt3); pt1 has no producer yet. + // fallow-ignore-next-line unused-class-member + async entryAt( + studyId: string, + sequence: number, + ): Promise<{ + profile: 'node-test' | 'node-server' + entry: { type: string } + }> { + const resolved = this.resolve(studyId) + if (!resolved) throw new Error('No Node history has this study id.') + if (!Number.isInteger(sequence) || sequence < 1) + throw new Error('A journal sequence is a positive integer.') + const history = await resolved.store.read(studyId) + const entry = history.entries.find((value) => value.sequence === sequence) + if (!entry) throw new Error('The history has no entry with this sequence.') + return { profile: resolved.profile, entry } + } +} diff --git a/extension/src/nodeServerHistorySchemas.ts b/extension/src/nodeServerHistorySchemas.ts new file mode 100644 index 00000000..7dcf487c --- /dev/null +++ b/extension/src/nodeServerHistorySchemas.ts @@ -0,0 +1,233 @@ +import { z } from 'zod' + +import { + MAX_EXPLANATION_CHARS, + NODE_SERVER_HISTORY_FORMAT_VERSION, + NODE_SERVER_HISTORY_MAX_ENTRIES, + NODE_SERVER_HISTORY_MAX_STOPS, + NODE_SERVER_OUTPUT_TEXT_LIMIT_CHARS, + NODE_SERVER_PROFILE, + NODE_SERVER_RESPONSE_BODY_LIMIT_CHARS, + NODE_SERVER_SOURCE_TEXT_LIMIT_BYTES, + NODE_SERVER_STOP_UNAVAILABLE_LIMIT, + REPOSITORY_PATH_LIMIT_CHARS, +} from './constants' +import { studyIdSchema } from './legacyStudySchemas' +import { nodeServerConfigSchema } from './nodeServerConfigSchemas' +import { replayDebugSnapshotSchema } from './replayDebugSchemas' +import { sha256 } from './runtimeSupport/utils/sha256' +import { repositoryHashSchema } from './sourceSchemas' + +const base = { + sequence: z.number().int().positive().max(NODE_SERVER_HISTORY_MAX_ENTRIES), + elapsedMs: z.number().finite().nonnegative(), +} +const attempt = z.number().int().positive() +const repositoryPath = z.string().min(1).max(REPOSITORY_PATH_LIMIT_CHARS) + +/** + * The header of a node-server history: the run's identity, its question, the declaration it ran (secret-named values masked; {@link maskNodeServerConfiguration}), the declared request and stops, the repository revision and the hashes of the declared sources. + * `configurationHash` is the SHA-256 of the original declaration: identity only, never reversible. + * @example nodeServerHistoryHeaderSchema.parse({ formatVersion: 1, profile: 'node-server', … }) + */ +export const nodeServerHistoryHeaderSchema = z + .strictObject({ + formatVersion: z.literal(NODE_SERVER_HISTORY_FORMAT_VERSION), + profile: z.literal(NODE_SERVER_PROFILE), + workspaceKey: repositoryHashSchema, + studyId: studyIdSchema, + instanceId: z.uuid(), + runId: z.uuid(), + createdAt: z.iso.datetime(), + question: z.string().min(1).max(MAX_EXPLANATION_CHARS), + configurationHash: repositoryHashSchema, + configuration: nodeServerConfigSchema, + declaredRequest: z.strictObject({ + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + path: z.string().min(1).max(2_048), + }), + declaredStops: z + .array( + z.strictObject({ + path: repositoryPath, + line: z.number().int().positive(), + }), + ) + .min(1) + .max(NODE_SERVER_HISTORY_MAX_STOPS), + repositoryRevision: z.strictObject({ + revision: z.string().min(1).max(1_024), + dirty: z.boolean(), + }), + sourceHashes: z + .array( + z.strictObject({ path: repositoryPath, hash: repositoryHashSchema }), + ) + .max(NODE_SERVER_HISTORY_MAX_STOPS), + }) + .refine( + (value) => value.studyId === `${value.instanceId}_${value.runId}`, + 'Node server history identity differs from its run.', + ) + +const stopSource = z.strictObject({ + path: repositoryPath, + hash: repositoryHashSchema, + line: z.number().int().positive(), + column: z.number().int().positive(), +}) + +/** One entry of a node-server journal; each carries its journal `sequence` and `elapsedMs` since launch. */ +export const nodeServerHistoryEntrySchema = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('output'), + ...base, + channel: z.enum(['stdout', 'stderr']), + text: z.string().min(1).max(NODE_SERVER_OUTPUT_TEXT_LIMIT_CHARS), + }), + z.strictObject({ + type: z.literal('request'), + ...base, + attempt, + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + path: z.string().min(1).max(2_048), + bodyBytes: z.number().int().nonnegative(), + }), + z.strictObject({ + type: z.literal('request-withdrawn'), + ...base, + attempt, + code: z.string().min(1).max(128), + }), + z.strictObject({ type: z.literal('request-sent'), ...base, attempt }), + z + .strictObject({ + type: z.literal('source'), + ...base, + path: repositoryPath, + hash: repositoryHashSchema, + text: z.string().max(NODE_SERVER_SOURCE_TEXT_LIMIT_BYTES).optional(), + sourceUnavailable: z.enum(['too large', 'not captured']).optional(), + }) + .refine( + (entry) => + entry.text === undefined + ? entry.sourceUnavailable !== undefined + : entry.sourceUnavailable === undefined && + sha256(entry.text) === entry.hash, + 'A source entry holds hash-verified text, or its hash with the reason its text is unavailable.', + ), + z.strictObject({ + type: z.literal('stop'), + ...base, + declaredStopIndex: z + .number() + .int() + .nonnegative() + .max(NODE_SERVER_HISTORY_MAX_STOPS - 1), + hit: z.number().int().positive(), + generation: z.number().int().nonnegative(), + source: stopSource, + frame: z.strictObject({ name: z.string().max(2_048) }), + facts: z.record(z.string(), z.json()), + // One entry per inspection the declaration allows; every one becomes an uncut seal reason. + unavailable: z + .array( + z.strictObject({ + path: z.string().max(512), + reason: z.string().max(1_024), + }), + ) + .max(NODE_SERVER_STOP_UNAVAILABLE_LIMIT), + debuggerSnapshot: replayDebugSnapshotSchema, + }), + z.strictObject({ + type: z.literal('response'), + ...base, + status: z.number().int().min(100).max(599), + contentType: z.string().max(256).nullable(), + body: z.string().max(NODE_SERVER_RESPONSE_BODY_LIMIT_CHARS), + bodyBytes: z.number().int().nonnegative(), + readCut: z.boolean(), + location: z.string().max(2_048).nullable(), + requestDurationMs: z.number().finite().nonnegative(), + }), + z.strictObject({ + type: z.literal('request-failed'), + ...base, + code: z.string().min(1).max(128), + message: z.string().max(2_048), + }), + z.strictObject({ + type: z.literal('output-truncated'), + ...base, + limit: z.enum(['entries', 'bytes', 'queue']), + }), + z.strictObject({ + type: z.literal('end'), + ...base, + reason: z.enum(['stopped', 'closed', 'failed']), + code: z.string().max(128).optional(), + exitCode: z.number().int().nullable().optional(), + signal: z.string().max(32).nullable().optional(), + }), +]) + +/** One reason a server history is partial: a stable `code`, the `params` that make `text`, and the exact `text` the learner reads. */ +const nodeServerSealReasonSchema = z.strictObject({ + code: z.string().min(1).max(128), + params: z.record(z.string(), z.union([z.string().max(1_024), z.number()])), + text: z.string().min(1).max(2_048), +}) + +/** The seal of a node-server history: its status, every reason uncut, the capture outcome and the journal's hash. */ +export const nodeServerHistorySealSchema = z + .strictObject({ + version: z.literal(NODE_SERVER_HISTORY_FORMAT_VERSION), + studyId: studyIdSchema, + entryCount: z + .number() + .int() + .nonnegative() + .max(NODE_SERVER_HISTORY_MAX_ENTRIES), + journalBytes: z.number().int().nonnegative(), + journalHash: repositoryHashSchema, + status: z.enum(['complete', 'partial']), + endedAt: z.iso.datetime(), + reasons: z.array(nodeServerSealReasonSchema), + capture: z.enum(['complete', 'incomplete']), + /** The first reason's text cut to the index's bound, so a summary reads `reason` as for a test history. */ + reason: z.string().max(1_024).optional(), + }) + .refine( + (seal) => + (seal.status === 'complete') === (seal.reasons.length === 0) && + (seal.capture === 'complete') === (seal.reasons.length === 0), + 'A seal is complete exactly when it names no reason.', + ) + +export type NodeServerHistoryHeader = z.infer< + typeof nodeServerHistoryHeaderSchema +> +export type NodeServerHistoryEntry = z.infer< + typeof nodeServerHistoryEntrySchema +> +export type NodeServerHistorySeal = z.infer +export type NodeServerSealReason = z.infer +/** One `stop` entry of a node-server journal. */ +export type NodeServerStopEntry = Extract< + NodeServerHistoryEntry, + { type: 'stop' } +> +/** One `source` entry of a node-server journal. */ +export type NodeServerSourceEntry = Extract< + NodeServerHistoryEntry, + { type: 'source' } +> +/** A journal entry before its sequence is assigned, as the writer accepts it. */ +export type NodeServerHistoryEntryInput = + NodeServerHistoryEntry extends infer Entry + ? Entry extends NodeServerHistoryEntry + ? Omit + : never + : never diff --git a/extension/src/nodeServerHistoryTypes.ts b/extension/src/nodeServerHistoryTypes.ts new file mode 100644 index 00000000..f5d740d0 --- /dev/null +++ b/extension/src/nodeServerHistoryTypes.ts @@ -0,0 +1,55 @@ +import type { + NodeServerHistoryEntry, + NodeServerHistoryHeader, + NodeServerHistorySeal, + NodeServerSealReason, +} from './nodeServerHistorySchemas' +import type { NodeServerHistoryWriter } from './nodeServerHistoryWriter' +import type { + NodeServerLearningCoverage, + NodeServerLearningPart, +} from './utils/evaluateNodeServerLearning' +import type { NodeServerJournalProgress } from './utils/nodeServerJournalRules' + +/** + * One server history as the library and the history resources read it: the request line, its answer, the stops, and the three verdicts. A history whose journal does not verify reads `unavailable` with its header's identity and the reason. + * The lists are capped for an answer ({@link capMissingList}); the seal keeps every reason. + */ +export type NodeServerHistorySummary = { + studyId: string + createdAt: string + profile: 'node-server' + status: 'recording' | 'complete' | 'partial' | 'interrupted' + reason?: string + verification: 'verified' | 'unavailable' + question: string + /** The declared request as the header stores it, `POST /api/x`. */ + requestLine: string + /** The recorded response status, absent before a response. */ + responseStatus?: number + /** The code of a recorded request failure. */ + requestFailureCode?: string + declaredStopCount: number + /** The distinct declared stops with at least one recorded hit. */ + reachedStopCount: number + /** Recorded stop hits. */ + stopCount: number + /** Whether the capture is complete: no partial reason. */ + captureComplete: boolean + captureMissing: NodeServerSealReason[] + captureMissingCount: number + learningCoverage: NodeServerLearningCoverage + learningMissing: NodeServerLearningPart[] + learningMissingCount: number + materialRevision: number +} + +/** The types the node-server profile fixes. */ +export type NodeServerHistoryTypes = { + Header: NodeServerHistoryHeader + Entry: NodeServerHistoryEntry + Seal: NodeServerHistorySeal + Progress: NodeServerJournalProgress + Summary: NodeServerHistorySummary + Writer: NodeServerHistoryWriter +} diff --git a/extension/src/nodeServerHistoryWriter.ts b/extension/src/nodeServerHistoryWriter.ts new file mode 100644 index 00000000..b7ced3b0 --- /dev/null +++ b/extension/src/nodeServerHistoryWriter.ts @@ -0,0 +1,425 @@ +import { + NODE_SERVER_HISTORY_LIMIT_BYTES, + NODE_SERVER_HISTORY_MAX_ENTRIES, + NODE_SERVER_HISTORY_SEAL_LIMIT_BYTES, + NODE_SERVER_OUTPUT_BUDGET_BYTES, + NODE_SERVER_OUTPUT_BUDGET_ENTRIES, + NODE_SERVER_SOURCE_TEXT_LIMIT_BYTES, + NODE_SERVER_STRUCTURAL_HEADROOM_BYTES, + NODE_SERVER_STRUCTURAL_HEADROOM_ENTRIES, + NODE_SERVER_WRITER_QUEUE_LIMIT_BYTES, + NODE_SERVER_WRITER_QUEUE_LIMIT_ENTRIES, +} from './constants' +import type { HistoryWriterRules } from './historyStorageDescriptor' +import { HistoryJournalWriter } from './nodeHistoryJournalWriter' +import { + nodeServerHistoryEntrySchema, + nodeServerHistorySealSchema, + type NodeServerHistoryEntry, + type NodeServerHistoryEntryInput, + type NodeServerHistoryHeader, + type NodeServerHistorySeal, + type NodeServerStopEntry, +} from './nodeServerHistorySchemas' +import type { NodeServerHistoryTypes } from './nodeServerHistoryTypes' +import type { ReplayDebugSnapshot } from './replayDebugSchemas' +import { sha256 } from './runtimeSupport/utils/sha256' +import { cutHistoryReason } from './utils/cutHistoryReason' +import { dehydrateServerStopSnapshot } from './utils/dehydrateServerStopSnapshot' +import { + describeLastNodeServerEntry, + evaluateNodeServerCapture, +} from './utils/evaluateNodeServerCapture' +import { maskSecretQueryValues } from './utils/maskSecretQueryValues' +import { + acceptNodeServerEntry, + cloneNodeServerJournalProgress, + newNodeServerJournalProgress, +} from './utils/nodeServerJournalRules' + +/** The node-server profile's writer rules: the journal rules every reader shares, plus the evaluator that builds the seal; holds the state of one writer. */ +class NodeServerWriterRules implements HistoryWriterRules { + readonly journalLimitBytes = NODE_SERVER_HISTORY_LIMIT_BYTES + readonly maxEntries = NODE_SERVER_HISTORY_MAX_ENTRIES + readonly sealLimitBytes = NODE_SERVER_HISTORY_SEAL_LIMIT_BYTES + private readonly progress = newNodeServerJournalProgress() + private lastEntry?: NodeServerHistoryEntry + captureBudgetExhausted = false + + constructor( + private readonly header: NodeServerHistoryHeader, + private readonly describeCause: (error: unknown) => string, + ) {} + + parseEntry(value: unknown): NodeServerHistoryEntry { + return nodeServerHistoryEntrySchema.parse(value) + } + + check(entry: NodeServerHistoryEntry): void { + // The same function the incremental scan and the full read call, tried on a copy: an entry that is then refused or never becomes durable changes nothing. + acceptNodeServerEntry( + cloneNodeServerJournalProgress(this.progress), + entry, + this.header, + ) + } + + commit(entry: NodeServerHistoryEntry): void { + // The line is durable: the rules now count the entry. + acceptNodeServerEntry(this.progress, entry, this.header) + this.lastEntry = entry + } + + /** The time of the last durable entry, the earliest an `end` may carry. */ + get lastElapsedMs(): number { + return this.progress.lastElapsedMs + } + + /** A server history with no request holds nothing to replay. */ + holdsNothingToReplay(): boolean { + return this.progress.lastAttempt === 0 + } + + /** The seal's status comes from the evaluator, so any requested status is accepted and then decided by the evidence. */ + assertSealable(): void {} + + buildSeal(input: { + studyId: string + journal: Buffer + entryCount: number + endedAt: string + appendFailure?: Error + limitReached: boolean + }): NodeServerHistorySeal { + const entries = input.journal + .toString('utf8') + .split('\n') + .filter((line) => line.length > 0) + .map((line) => this.parseEntry(JSON.parse(line))) + const reasons = evaluateNodeServerCapture(this.header, entries, { + ...(input.appendFailure + ? { + appendFailure: { + lastEntry: this.lastEntry + ? describeLastNodeServerEntry(this.lastEntry) + : 'the start of the history', + // Cut to the seal's param bound so a long cause never makes the whole seal unwritable. + cause: cutHistoryReason(this.describeCause(input.appendFailure)), + }, + } + : {}), + sizeLimitReached: input.limitReached, + captureBudgetExhausted: this.captureBudgetExhausted, + }) + const complete = reasons.length === 0 + return nodeServerHistorySealSchema.parse({ + version: 1, + studyId: input.studyId, + entryCount: input.entryCount, + journalBytes: input.journal.byteLength, + journalHash: sha256(input.journal), + status: complete ? 'complete' : 'partial', + endedAt: input.endedAt, + reasons, + capture: complete ? 'complete' : 'incomplete', + ...(reasons[0] ? { reason: cutHistoryReason(reasons[0].text) } : {}), + }) + } +} + +type EntryInputOf = Omit< + Extract, + 'sequence' +> + +/** The entry `end` of a run, without its sequence; what {@link NodeServerHistoryWriter.seal} appends. */ +export type NodeServerEndInput = Omit, 'type'> + +/** What the run's capture of one stop hit hands the writer. */ +export type NodeServerStopCapture = { + elapsedMs: number + declaredStopIndex: number + hit: number + generation: number + source: { path: string; hash: string; line: number; column: number } + frameName: string + facts: NodeServerStopEntry['facts'] + unavailable: Array<{ path: string; reason: string }> + /** The redacted snapshot, its top frame still carrying the executed source text. */ + snapshot: ReplayDebugSnapshot +} + +/** Estimates the bytes of an entry's journal line before it is numbered; close enough to keep the bounds honest. */ +function estimateLineBytes(input: unknown): number { + return Buffer.byteLength(JSON.stringify(input)) + 24 +} + +/** + * Writes a node-server run's evidence as it happens: output, request attempts, executed sources, stops, the response and the end, in the one verified order every reader checks. + * Source text is stored once per `(path, hash)` just before the first stop that needs it. Output has its own budget, so a chatty server never crowds out the request, the stops or the answer; past a bound output is dropped with one `output-truncated` entry while structural entries still wait for their turn. + * Built by {@link nodeServerStorageDescriptor} when the store reserves a history; the run (pt3) feeds it through a history sink. + * @example const writer = await servers.start(header); await writer.appendRequest({ … }); await writer.seal({ reason: 'stopped', elapsedMs: 900 }) + */ +export class NodeServerHistoryWriter extends HistoryJournalWriter { + private readonly serverRules: NodeServerWriterRules + /** The `(hash)` of sources this writer already queued, by path and hash. */ + private readonly queuedSources = new Set() + private acceptedEntries = 0 + private acceptedBytes = 0 + private outputEntries = 0 + private outputBytes = 0 + private outputWasCut = false + private pendingEntries = 0 + private pendingBytes = 0 + private sealOperation?: Promise + /** The one write of `end`, kept so a retried seal never writes it twice. */ + private endOperation?: Promise + /** Set in the turn the seal began: from then on the writer is closed to new appends while the accepted ones drain. */ + private closedToAppends = false + + constructor( + directory: string, + header: NodeServerHistoryHeader, + onClosed: () => void, + describeCause: (error: unknown) => string, + ) { + const rules = new NodeServerWriterRules(header, describeCause) + super(directory, header, onClosed, rules) + this.serverRules = rules + } + + /** + * Queues one entry exactly as given; the low-level door the typed methods use, public so a test can offer an entry the typed methods would never build. + * @param input - The entry without its sequence. + * @returns Settles when the entry is durable; rejects when the rules or the disk refuse it. + * @example await writer.appendEntry({ type: 'request-sent', elapsedMs: 3, attempt: 1 }) + */ + appendEntry(input: NodeServerHistoryEntryInput): Promise { + // Closed to new appends from the moment the seal began: the entry is not written and is no failure, so a run that ended cleanly still seals complete. + if (this.closedToAppends) return Promise.resolve() + const bytes = estimateLineBytes(input) + this.acceptedEntries++ + this.acceptedBytes += bytes + this.pendingEntries++ + this.pendingBytes += bytes + const operation = this.append(input) + const release = (): void => { + this.pendingEntries-- + this.pendingBytes -= bytes + } + operation.then(release, release) + return operation + } + + /** + * Queues one stdout or stderr chunk, redacted by the caller, unless a bound says it must be dropped: the output budget, the structural headroom or the writer queue. The first drop appends one `output-truncated` entry; later output is dropped silently. + * @param event - The chunk with its time since launch. + * @returns Settles when the chunk is durable, or at once when it was dropped. + * @example await writer.appendOutput({ elapsedMs: 12, channel: 'stdout', text: 'listening\n' }) + */ + appendOutput(event: { + elapsedMs: number + channel: 'stdout' | 'stderr' + text: string + }): Promise { + if (this.outputWasCut) return Promise.resolve() + const input = { type: 'output' as const, ...event } + const bytes = estimateLineBytes(input) + const limit = this.outputBoundReached(bytes) + if (limit) { + this.outputWasCut = true + return this.appendEntry({ + type: 'output-truncated', + elapsedMs: event.elapsedMs, + limit, + }) + } + this.outputEntries++ + this.outputBytes += bytes + return this.appendEntry(input) + } + + /** Names the bound an output entry of `bytes` would cross, or `undefined` when it fits every one. */ + private outputBoundReached( + bytes: number, + ): 'entries' | 'bytes' | 'queue' | undefined { + if ( + this.pendingEntries >= NODE_SERVER_WRITER_QUEUE_LIMIT_ENTRIES || + this.pendingBytes + bytes > NODE_SERVER_WRITER_QUEUE_LIMIT_BYTES + ) + return 'queue' + if ( + this.outputEntries >= NODE_SERVER_OUTPUT_BUDGET_ENTRIES || + this.acceptedEntries + NODE_SERVER_STRUCTURAL_HEADROOM_ENTRIES >= + NODE_SERVER_HISTORY_MAX_ENTRIES + ) + return 'entries' + if ( + this.outputBytes + bytes > NODE_SERVER_OUTPUT_BUDGET_BYTES || + this.acceptedBytes + bytes + NODE_SERVER_STRUCTURAL_HEADROOM_BYTES > + NODE_SERVER_HISTORY_LIMIT_BYTES + ) + return 'bytes' + return undefined + } + + /** + * Records that Codebook opens a send attempt; the path is stored with secret-named query values masked. + * @param attempt - The attempt's number, counted from 1. + * @example await writer.appendRequest({ elapsedMs: 40, attempt: 1, method: 'POST', path: '/api/x?token=abc', bodyBytes: 12 }) + */ + appendRequest(input: { + elapsedMs: number + attempt: number + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + path: string + bodyBytes: number + }): Promise { + return this.appendEntry({ + type: 'request', + ...input, + path: maskSecretQueryValues(input.path), + }) + } + + /** Records that an attempt was refused before its bytes left, so the run may try again. @example await writer.appendRequestWithdrawn({ elapsedMs: 41, attempt: 1, code: 'RUN_BUSY' }) */ + // Consumed by the run's history sink (Cycle 0059 pt3); pt1 has no producer yet. + // fallow-ignore-next-line unused-class-member + appendRequestWithdrawn(input: { + elapsedMs: number + attempt: number + code: string + }): Promise { + return this.appendEntry({ type: 'request-withdrawn', ...input }) + } + + /** Records that an attempt's bytes were handed to the socket. @example await writer.appendRequestSent({ elapsedMs: 42, attempt: 1 }) */ + appendRequestSent(input: { + elapsedMs: number + attempt: number + }): Promise { + return this.appendEntry({ type: 'request-sent', ...input }) + } + + /** + * Records one hit of a declared stop. The executed source text is written once per `(path, hash)` in a `source` entry just before the first stop that needs it (hash only with `sourceUnavailable` for a text above 512 KiB or one that does not hash to its name), and the stop keeps its top frame's source by hash. + * @param capture - The run's capture of the hit. + * @returns Settles when the stop (and its source entry, if new) is durable. + * @example await writer.appendStop({ elapsedMs: 60, declaredStopIndex: 0, hit: 1, generation: 1, source, frameName: 'handler', facts: {}, unavailable: [], snapshot }) + */ + async appendStop(capture: NodeServerStopCapture): Promise { + const { source } = capture + const key = `${source.path}\u0000${source.hash}` + const queued: Array> = [] + // Decided in the caller's turn: two stops of one turn queue one source entry. + if (!this.queuedSources.has(key)) { + this.queuedSources.add(key) + queued.push(this.appendEntry(this.sourceEntryFor(capture))) + } + queued.push( + this.appendEntry({ + type: 'stop', + elapsedMs: capture.elapsedMs, + declaredStopIndex: capture.declaredStopIndex, + hit: capture.hit, + generation: capture.generation, + source, + frame: { name: capture.frameName }, + facts: capture.facts, + unavailable: capture.unavailable, + debuggerSnapshot: dehydrateServerStopSnapshot(capture.snapshot), + }), + ) + await Promise.all(queued) + } + + /** Builds the `source` entry of a stop's executed file: its text when verified and small enough, else its hash with the reason. */ + private sourceEntryFor( + capture: NodeServerStopCapture, + ): NodeServerHistoryEntryInput { + const text = capture.snapshot.frames[0]?.source?.text + const base = { + type: 'source' as const, + elapsedMs: capture.elapsedMs, + path: capture.source.path, + hash: capture.source.hash, + } + if (text === undefined || sha256(text) !== capture.source.hash) + return { ...base, sourceUnavailable: 'not captured' } + if (Buffer.byteLength(text) > NODE_SERVER_SOURCE_TEXT_LIMIT_BYTES) + return { ...base, sourceUnavailable: 'too large' } + return { ...base, text } + } + + /** Records the response the run shows: its status, content type, the shown body, its size, whether the read was cut, the redirect and the request's duration. @example await writer.appendResponse({ elapsedMs: 90, status: 200, contentType: 'application/json', body: '{}', bodyBytes: 2, readCut: false, location: null, requestDurationMs: 50 }) */ + // Consumed by the run's history sink (Cycle 0059 pt3); pt1 has no producer yet. + // fallow-ignore-next-line unused-class-member + appendResponse( + input: EntryInputOf<'response'> extends infer Entry + ? Omit + : never, + ): Promise { + return this.appendEntry({ type: 'response', ...input }) + } + + /** Records that the request failed, with its code and a message that already passed the run's redaction. @example await writer.appendRequestFailed({ elapsedMs: 95, code: 'REQUEST_TIMEOUT', message: 'No answer.' }) */ + // Consumed by the run's history sink (Cycle 0059 pt3); pt1 has no producer yet. + // fallow-ignore-next-line unused-class-member + appendRequestFailed(input: { + elapsedMs: number + code: string + message: string + }): Promise { + return this.appendEntry({ type: 'request-failed', ...input }) + } + + /** + * Notes that the run refused a hit beyond its capture budget, so the seal says later hits were not recorded. + * @example writer.markCaptureBudgetExhausted() + */ + // Consumed by the run's history sink (Cycle 0059 pt3); pt1 has no producer yet. + // fallow-ignore-next-line unused-class-member + markCaptureBudgetExhausted(): void { + this.serverRules.captureBudgetExhausted = true + } + + /** + * Ends and seals the history: from this call the writer is closed to new appends (an entry offered later is not written and is no failure), the entries accepted before it are written, then `end`, then `sealed.json`; Stop, Save, dispose, self-exit and failure all call it and share one seal. + * `end` is written once: a retry after a failed `sealed.json` write only retries the finish. An `end` that cannot be written (the history already failed) does not stop the seal: a history without `end` seals as a partial one. + * @param end - How the run ended. + * @returns The sealed journal's hash; `undefined` for a writer abandoned before the call. + * @throws When the seal cannot be written or the writer is abandoned while the seal is in flight. + * @example await writer.seal({ elapsedMs: 900, reason: 'stopped' }) + */ + seal(end: NodeServerEndInput): Promise { + if (this.sealOperation) return this.sealOperation + // Decided in this turn, before any later append is offered. + this.closedToAppends = true + // The end is queued in this turn, behind every entry accepted before it, through the base door that the closed flag does not shut. + this.endOperation ??= this.append({ type: 'end', ...end }).catch( + () => undefined, + ) + this.sealOperation = this.endOperation.then(() => super.finish('partial')) + // A failed seal may be tried again. + this.sealOperation.catch(() => { + this.sealOperation = undefined + }) + return this.sealOperation + } + + /** + * Seals through {@link seal}, so no server `sealed.json` is ever written without its `end`, which the read path would refuse. The status and reason are decided by the evidence, so the `status` and `reason` arguments are ignored; the `end` says the run was stopped, at the time of the last durable entry. + * @returns The sealed journal's hash; `undefined` for a writer abandoned before the call. + * @throws When the seal cannot be written or the writer is abandoned while it is in flight. + * @example await writer.finish('partial') + */ + override finish( + _status?: 'complete' | 'partial', + _reason?: string, + ): Promise { + return this.seal({ + elapsedMs: this.serverRules.lastElapsedMs, + reason: 'stopped', + }) + } +} diff --git a/extension/src/nodeServerStorageDescriptor.ts b/extension/src/nodeServerStorageDescriptor.ts new file mode 100644 index 00000000..87e94a25 --- /dev/null +++ b/extension/src/nodeServerStorageDescriptor.ts @@ -0,0 +1,134 @@ +import { + NODE_SERVER_HISTORY_DIRECTORY, + NODE_SERVER_HISTORY_LIMIT_BYTES, + NODE_SERVER_HISTORY_SEAL_LIMIT_BYTES, +} from './constants' +import type { HistoryStorageDescriptor } from './historyStorageDescriptor' +import { + nodeServerHistoryEntrySchema, + nodeServerHistoryHeaderSchema, + nodeServerHistorySealSchema, + type NodeServerHistoryEntry, + type NodeServerHistoryHeader, +} from './nodeServerHistorySchemas' +import type { + NodeServerHistorySummary, + NodeServerHistoryTypes, +} from './nodeServerHistoryTypes' +import { NodeServerHistoryWriter } from './nodeServerHistoryWriter' +import { capMissingList } from './utils/capMissingList' +import { evaluateNodeServerCapture } from './utils/evaluateNodeServerCapture' +import { evaluateNodeServerLearning } from './utils/evaluateNodeServerLearning' +import { + acceptNodeServerEntry, + newNodeServerJournalProgress, +} from './utils/nodeServerJournalRules' + +/** + * Starts a server history's summary from its header, with nothing counted yet; the store fills in what the verified journal says, and an unavailable history keeps these values. + * @param header - The history's header. + * @returns A summary with the header's identity, question and request line, and no evidence. + * @example emptyNodeServerSummary(header).requestLine // => 'POST /api/x' + */ +function emptyNodeServerSummary( + header: NodeServerHistoryHeader, +): NodeServerHistorySummary { + return { + studyId: header.studyId, + createdAt: header.createdAt, + profile: 'node-server', + status: 'partial', + verification: 'unavailable', + question: header.question, + requestLine: `${header.declaredRequest.method} ${header.declaredRequest.path}`, + declaredStopCount: header.declaredStops.length, + reachedStopCount: 0, + stopCount: 0, + captureComplete: false, + captureMissing: [], + captureMissingCount: 0, + learningCoverage: 'none', + learningMissing: [], + learningMissingCount: 0, + materialRevision: 0, + } +} + +/** + * The node-server profile's storage descriptor: `node-servers.v1/`, the server header and entries, the structured seal and the shared journal rules. Give it to a {@link NodeHistoryStore} to read, write, list and delete server histories; no live code produces one yet. + * @example new NodeHistoryStore(nodeServerStorageDescriptor, learningDirectory, workspaceKey, capacity, savedStudyCount) + */ +export const nodeServerStorageDescriptor: HistoryStorageDescriptor = + { + profile: 'node-server', + directoryName: NODE_SERVER_HISTORY_DIRECTORY, + journalLimitBytes: NODE_SERVER_HISTORY_LIMIT_BYTES, + sealLimitBytes: NODE_SERVER_HISTORY_SEAL_LIMIT_BYTES, + words: { + interruptedPrefix: + 'Interrupted Node server history after its verified prefix:', + noSeal: 'Node server history has no verified final seal.', + subject: 'Node server history', + }, + supportsLearningMaterial: false, + stampHeader: (input) => ({ ...input, formatVersion: 1 }), + parseHeader: (value) => nodeServerHistoryHeaderSchema.parse(value), + parseSeal: (value) => nodeServerHistorySealSchema.parse(value), + parseIndex: (value) => value, + scanRules: { + journalLimitBytes: NODE_SERVER_HISTORY_LIMIT_BYTES, + parseEntry: (value) => nodeServerHistoryEntrySchema.parse(value), + start: newNodeServerJournalProgress, + accept: acceptNodeServerEntry, + // Server entries are not citable until their learning material arrives. + citableOf: () => undefined, + // A recording keeps what a summary counts: everything but output and the storage-only source texts. + keeps: (entry: NodeServerHistoryEntry) => + entry.type !== 'output' && entry.type !== 'source', + }, + createWriter: (directory, header, onClosed, describeCause) => + new NodeServerHistoryWriter(directory, header, onClosed, describeCause), + verifySeal: ({ seal, entries, progress, hasIncompleteLine }) => { + if ( + hasIncompleteLine || + seal.entryCount !== entries.length || + // A complete seal needs the whole story: the request closed by its terminal entry and the run's end. + (seal.status === 'complete' && + !(progress.hasEnd && progress.hasTerminal)) + ) + throw new Error( + 'Sealed Node server history does not contain what its seal claims.', + ) + }, + emptySummary: emptyNodeServerSummary, + summarize: ({ history }) => { + const { header, entries, seal } = history + const stops = entries.filter((entry) => entry.type === 'stop') + const response = entries.find((entry) => entry.type === 'response') + const failure = entries.find((entry) => entry.type === 'request-failed') + // A sealed history keeps its reasons as sealed (identical after a restart); any other is judged from its journal now. + const reasons = + seal?.reasons ?? evaluateNodeServerCapture(header, entries) + const capture = capMissingList(reasons) + const learning = evaluateNodeServerLearning(entries, undefined) + const learningMissing = capMissingList(learning.missing) + const summary: NodeServerHistorySummary = { + ...emptyNodeServerSummary(header), + status: history.status, + ...(history.reason ? { reason: history.reason } : {}), + verification: 'verified', + ...(response ? { responseStatus: response.status } : {}), + ...(failure ? { requestFailureCode: failure.code } : {}), + stopCount: stops.length, + reachedStopCount: new Set(stops.map((stop) => stop.declaredStopIndex)) + .size, + captureComplete: reasons.length === 0, + captureMissing: capture.items, + captureMissingCount: capture.count, + learningCoverage: learning.coverage, + learningMissing: learningMissing.items, + learningMissingCount: learningMissing.count, + } + return { summary } + }, + } diff --git a/extension/src/nodeTestHistorySchemas.ts b/extension/src/nodeTestHistorySchemas.ts index 9b292586..01bf6bec 100644 --- a/extension/src/nodeTestHistorySchemas.ts +++ b/extension/src/nodeTestHistorySchemas.ts @@ -208,6 +208,7 @@ export const nodeTestLearningMaterialSchema = z.strictObject({ content: nodeTestLearningMaterialContentSchema, }) +export type NodeTestHistorySeal = z.infer export type NodeTestHistoryHeader = z.infer export type NodeTestHistoryEntry = z.infer export type NodeTestHistoryIndex = z.infer diff --git a/extension/src/nodeTestHistoryStore.ts b/extension/src/nodeTestHistoryStore.ts index 1a69c235..42e82dbe 100644 --- a/extension/src/nodeTestHistoryStore.ts +++ b/extension/src/nodeTestHistoryStore.ts @@ -13,12 +13,8 @@ import { join } from 'node:path' import { NODE_HISTORY_CITABLE_MEMO_LIMIT, NODE_HISTORY_SUMMARY_READ_CONCURRENCY, - NODE_TEST_HISTORY_DIRECTORY, NODE_TEST_HISTORY_WRITER_FILE, NODE_TEST_WRITER_RECHECK_MS, - NODE_TEST_HISTORY_LIMIT_BYTES, - NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, - NODE_TEST_HISTORY_MAX_ENTRIES, NODE_TEST_LEARNING_DECISION_LIMIT_BYTES, NODE_TEST_LEARNING_DECISION_VERSION, NODE_TEST_LEARNING_MATERIAL_LIMIT_BYTES, @@ -33,60 +29,45 @@ import { SavedStudyStorageFullError, StudyError, } from './errors' +import type { + HistoryIndexOf, + HistoryStorageDescriptor, + HistoryTypes, +} from './historyStorageDescriptor' +import { + HISTORY_HEADER_FILE, + HISTORY_JOURNAL_FILE, + HISTORY_SEAL_FILE, +} from './nodeHistoryJournalWriter' import { - nodeTestHistoryEntrySchema, - nodeTestHistoryHeaderSchema, - nodeTestHistoryIndexSchema, nodeTestHistoryLearningDecisionSchema, nodeTestHistoryReaderPositionSchema, - nodeTestHistorySealSchema, nodeTestLearningMaterialContentSchema, nodeTestLearningMaterialSchema, - type NodeTestHistoryEntry, - type NodeTestHistoryHeader, - type NodeTestHistoryIndex, type NodeTestHistoryLearningDecision, type NodeTestHistoryReaderPosition, type NodeTestLearningMaterial, type NodeTestLearningMaterialContent, } from './nodeTestHistorySchemas' -import type { NodeTestSourceStop } from './ownedNodeTestRun' +import type { NodeTestHistoryTypes } from './nodeTestHistoryTypes' +import { nodeTestStorageDescriptor } from './nodeTestStorageDescriptor' +export { NodeTestHistoryWriter } from './nodeTestHistoryWriter' +export type { NodeTestHistorySummary } from './nodeTestHistoryTypes' import { sha256 } from './runtimeSupport/utils/sha256' import { atomicPrivateWrite } from './utils/atomicPrivateWrite' import { causeRootsFor } from './utils/causeRootsFor' -import { cutTextToFit } from './utils/cutTextToFit' -import { - describeNodeTestResult, - type NodeTestVerdict, -} from './utils/describeNodeTestResult' -import { holdsLearnerFiles } from './utils/holdsLearnerFiles' -import { isSealedEmptyHistory } from './utils/isSealedEmptyHistory' +import { cutHistoryReason } from './utils/cutHistoryReason' +import { isNodeHistoryIdentity } from './utils/isNodeHistoryIdentity' +import { isSealedEmptyJournal } from './utils/isSealedEmptyHistory' import { isTransientFileError } from './utils/isTransientFileError' import { judgeNodeTestWriter, type NodeTestWriterVerdict, } from './utils/judgeNodeTestWriter' -import type { NodeTestOutputEvent } from './utils/launchNodeTest' -import { - listMissingNodeExplanations, - type NodeLearningCoverage, - type NodeLearningExplanationPart, - type NodeLearningNeeds, -} from './utils/listMissingNodeExplanations' -import { - listMissingNodeLearning, - type NodeLearningCapturePart, -} from './utils/listMissingNodeLearning' +import { type NodeLearningNeeds } from './utils/listMissingNodeExplanations' import { mapWithConcurrencyLimit } from './utils/mapWithConcurrencyLimit' import { - nodeTestResultKind, - type NodeTestResultKind, -} from './utils/nodeTestResultKind' -import { - acceptJournalEntry, - advanceRecordingScan, - citableEventOf, - newJournalProgress, + advanceHistoryScan, type CitableEvent, type RecordingScan, } from './utils/recordingJournalScan' @@ -94,100 +75,42 @@ import { sanitizeCause } from './utils/sanitizeCause' import { systemErrorCode } from './utils/systemErrorCode' import { withStorageLock } from './utils/withStorageLock' import { writeNodeTestWriterRecord } from './utils/writeNodeTestWriterRecord' -type NodeTestHistoryEntryInput = NodeTestHistoryEntry extends infer Entry - ? Entry extends NodeTestHistoryEntry - ? Omit - : never - : never -const HEADER_FILE = 'header.json' -const JOURNAL_FILE = 'events.jsonl' -const SEAL_FILE = 'sealed.json' +/** The clock and the judge of another window's writer; a test names its own to count the queries. */ +export type NodeHistoryWriterJudge = { + now: () => number + judge: (historyDirectory: string) => Promise +} + const READER_POSITION_FILE = 'reader-position.json' const LEARNING_DECISION_FILE = 'learning-decision.json' const LEARNING_MATERIAL_FILE = 'learning-material.json' const HEADER_LIMIT_BYTES = 8_388_608 -const REASON_LIMIT_CHARS = 1_024 - -/** - * Cuts a history's reason to {@link REASON_LIMIT_CHARS} between characters, never inside a surrogate pair, saying it was cut, so every reason fits the seal's and the index's bound (F265); called wherever the store words a reason. - * @param reason - The whole reason. - * @returns The reason itself when it fits, else its beginning with the cut marker. - * @example cutReason('x'.repeat(2_000)).length // => 1_024 - */ -function cutReason(reason: string): string { - return cutTextToFit( - reason, - (candidate) => candidate.length <= REASON_LIMIT_CHARS, - ) -} -/** The name of a history's folder: its study id. Any other entry of the history directory (a `.DS_Store`, a stray folder) is nobody's history. */ -const HISTORY_IDENTITY = /^[0-9a-f-]{36}_[0-9a-f-]{36}$/ - -/** - * One test history as Saved studies, the `codebook://node-history` resources and Save read it: one projection per history, so the three agree (plan version 7, Design 13). - * A history whose journal does not verify reads `unavailable` with its header's identity and the reason; its learning fields then say nothing was checked. - */ -export type NodeTestHistorySummary = { - studyId: string - createdAt: string - profile: 'node-test' - status: NodeTestHistoryIndex['status'] - reason?: string - verification: 'verified' | 'unavailable' - /** The cited test file and its title path, as the header names them, for labels such as a failed open's notification. */ - testFile: string - testTitles?: string[] - verdict: NodeTestVerdict - testResult: NodeTestResultKind - stopCount: number - explainedStopCount: number - learningCoverage: NodeLearningCoverage - learningMissing: NodeLearningExplanationPart[] - captureMissing: NodeLearningCapturePart[] - /** The revision of the learning material the summary counted; 0 before the first save. */ - materialRevision: number -} /** What {@link NodeTestHistoryStore.summarize} remembers of one history, so a refresh that finds its files unchanged reads none of them. */ -type SummaryMemo = { +type SummaryMemo = { /** The size, time and identity of the header, journal and seal files, and whether this window writes the history: when any changes, the history is read again. */ historySignal: string /** The same of the learning material file: when only it changes, only the material is read again. */ materialSignal: string - summary: NodeTestHistorySummary + summary: Types['Summary'] /** Present for a verified history: what a material may cite, and what it is asked to explain. */ citable?: Map needs?: NodeLearningNeeds /** Present for a history this window still writes: what the next refresh continues from. */ - scan?: RecordingScan + scan?: RecordingScan /** Set when a transient file error (a busy file, too many open files) shaped the summary: the summary is shown but not remembered, so the next refresh reads again. */ transient?: boolean } /** A history as a summary counts it: its header, entries (a recording keeps only its stops and result), status and reason, with its citable events and, for a recording, the scan to continue from. */ -type LoadedHistory = { +type LoadedHistory = { history: Pick< - NodeTestHistoryIndex, - 'header' | 'entries' | 'status' | 'reason' + HistoryIndexOf, + 'header' | 'entries' | 'status' | 'reason' | 'seal' > citable: Map - scan?: RecordingScan -} - -/** - * Lists the events of a verified history a learning step may cite, by sequence. - * @param history - The verified history. - * @returns The stops, outputs and result by sequence. - * @example citableEvents(history).get(2) // => { type: 'stop', sourceHash: '…', path: 'src/scheduler.ts', line: 3 } - */ -function citableEvents( - history: Pick, -): Map { - const events = new Map() - for (const entry of history.entries) - events.set(entry.sequence, citableEventOf(entry)) - return events + scan?: RecordingScan } /** @@ -227,10 +150,10 @@ function saveStepLead(stepId: string): string { /** * Refuses a learning material whose step cites an event the sealed history does not hold as cited, naming the step, the sequence, what that sequence is and what the step claims (X19), so the agent can correct the one step; called by {@link NodeTestHistoryStore} for every material it saves or reads. * @param content - The material's content. - * @param citable - The history's citable events ({@link citableEvents}). + * @param citable - The history's citable events ({@link NodeTestHistoryStore.citableEventsOf}). * @param lead - Words the start of the refusal for a step id: {@link saveStepLead} on the save path, where the agent called the tool, {@link neutralStepLead} on read paths. * @throws An `INVALID_REFERENCE` {@link StudyError} for the first step whose reference does not match. - * @example assertLearningCitations(content, citableEvents(history), neutralStepLead) // returns for a material whose every reference matches + * @example assertLearningCitations(content, store.citableEventsOf(history), neutralStepLead) // returns for a material whose every reference matches */ function assertLearningCitations( content: NodeTestLearningMaterialContent, @@ -291,37 +214,10 @@ function describeCitationClaim(reference: LearningReference): string { : 'the result' } -/** - * Starts a history's summary from its header, with nothing counted yet; {@link NodeTestHistoryStore} fills in what the verified journal says, and an unavailable history keeps these values. - * @param header - The history's header. - * @returns A summary with the header's identity, the cited test, no result and no learning. - * @example emptySummary(header).verdict // => 'No result recorded' - */ -function emptySummary(header: NodeTestHistoryHeader): NodeTestHistorySummary { - return { - studyId: header.studyId, - createdAt: header.createdAt, - profile: header.profile, - status: 'partial', - verification: 'unavailable', - testFile: header.configuration.testFile, - ...(header.configuration.testTitles - ? { testTitles: header.configuration.testTitles } - : {}), - verdict: 'No result recorded', - testResult: 'none', - stopCount: 0, - explainedStopCount: 0, - learningCoverage: 'none', - learningMissing: [], - captureMissing: [], - materialRevision: 0, - } -} - /** Appends bounded, workspace-bound Node execution evidence before test input and seals it after owned cleanup. @example const writer = await store.start(header); await writer.appendStop(stop); await writer.finish('complete') */ -export class NodeTestHistoryStore { +export class NodeHistoryStore { private readonly directory: string + private readonly descriptor: HistoryStorageDescriptor private readonly active = new Set() /** The last verdict on the writer of each history another window records, with the writer record's file signal and the time it was judged: "gone" is final for that record, "live" and "unread" are kept until the record changes or {@link NODE_TEST_WRITER_RECHECK_MS} passes (F260, F347). */ private readonly writerVerdicts = new Map< @@ -336,11 +232,11 @@ export class NodeTestHistoryStore { Promise >() /** The last summary of each listed history with the file signals it was made from ({@link summarize}). */ - private readonly summaries = new Map() + private readonly summaries = new Map>() /** The summary read in flight for each history: a recording's scan is continued by one read at a time (F216). */ private readonly summaryReads = new Map< string, - Promise + Promise >() /** How a failure's cause is worded for a reason ({@link describeCause}). */ private causeDescriber: (error: unknown) => string = (error) => @@ -367,18 +263,25 @@ export class NodeTestHistoryStore { } constructor( + /** The profile this store keeps: its folder, codec, rules and summary ({@link nodeTestStorageDescriptor} for test histories). */ + descriptor: HistoryStorageDescriptor, /** The learning directory the histories live in; a private storage root that no cause shown to the learner or the agent names ({@link sanitizeCause}). */ readonly learningDirectory: string, readonly workspaceKey: string, private readonly capacity: () => number = () => 64, private readonly savedArtifactCount: () => Promise = async () => 0, /** The clock and the judge of another window's writer; a test names its own to count the queries. */ - private readonly writerJudge: { - now: () => number - judge: (historyDirectory: string) => Promise - } = { now: Date.now, judge: judgeNodeTestWriter }, + private readonly writerJudge: NodeHistoryWriterJudge = { + now: Date.now, + judge: judgeNodeTestWriter, + }, + /** Counts the histories of the other profiles under the same learning directory, up to the given number, so one saved-study limit counts every kind. */ + private readonly otherHistoryCount: ( + places: number, + ) => Promise = async () => 0, ) { - this.directory = join(learningDirectory, NODE_TEST_HISTORY_DIRECTORY) + this.descriptor = descriptor + this.directory = join(learningDirectory, this.descriptor.directoryName) } /** @@ -391,9 +294,11 @@ export class NodeTestHistoryStore { * @example const writer = await store.start(header) */ async start( - input: Omit, - ): Promise { - const header = nodeTestHistoryHeaderSchema.parse({ ...input, version: 1 }) + input: Omit, + ): Promise { + const header = this.descriptor.parseHeader( + this.descriptor.stampHeader(input), + ) if (header.workspaceKey !== this.workspaceKey) throw new Error('Node history belongs to another workspace.') return withStorageLock(this.learningDirectory, () => this.reserve(header)) @@ -414,12 +319,12 @@ export class NodeTestHistoryStore { const savedStudies = await this.savedArtifactCount() // The histories may take the places the saved studies leave of the limit. const places = limit - savedStudies - const histories = await this.listedHistoryCount(places).catch( - (error: unknown) => { - if (systemErrorCode(error) === 'ENOENT') return 0 - throw error - }, + const ownHistories = await this.countListedHistories(places) + // One limit counts every kind of saved study: the histories of the other profiles take places too. + const otherHistories = await this.otherHistoryCount( + Math.max(places - ownHistories, 0), ) + const histories = ownHistories + otherHistories return { admits: histories < places, taken: savedStudies + histories, @@ -434,9 +339,7 @@ export class NodeTestHistoryStore { * @throws What {@link start} names for a full store, a header above its limit and files that cannot be written. * @example const writer = await this.reserve(header) */ - private async reserve( - header: NodeTestHistoryHeader, - ): Promise { + private async reserve(header: Types['Header']): Promise { await mkdir(this.directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE, @@ -456,8 +359,11 @@ export class NodeTestHistoryStore { this.startedHere.add(header.studyId) // Other windows tell this history from a crashed one by this record (F260). await writeNodeTestWriterRecord(runDirectory) - return new NodeTestHistoryWriter(runDirectory, header, () => - this.active.delete(header.studyId), + return this.descriptor.createWriter( + runDirectory, + header, + () => this.active.delete(header.studyId), + (error) => this.describeCause(error), ) } @@ -509,7 +415,7 @@ export class NodeTestHistoryStore { headerBytes: Buffer, ): Promise { const headerHandle = await open( - join(runDirectory, HEADER_FILE), + join(runDirectory, HISTORY_HEADER_FILE), 'wx', PRIVATE_FILE_MODE, ) @@ -520,7 +426,7 @@ export class NodeTestHistoryStore { await headerHandle.close() } const journalHandle = await open( - join(runDirectory, JOURNAL_FILE), + join(runDirectory, HISTORY_JOURNAL_FILE), 'wx', PRIVATE_FILE_MODE, ) @@ -539,11 +445,32 @@ export class NodeTestHistoryStore { */ private async dropSealedEmpty(studyId: string): Promise { const folder = this.path(studyId) - if (!(await isSealedEmptyHistory(folder))) return false + if ( + !(await isSealedEmptyJournal( + folder, + this.descriptor.sealLimitBytes, + this.descriptor.parseSeal, + )) + ) + return false await rm(folder, { recursive: true, force: true }).catch(() => undefined) return true } + /** + * Counts this profile's listed histories up to `places`, a folder that does not exist yet counting none; called by {@link admission} and by the history repository for the other profiles' stores, both under the storage lock or for a read-only answer. + * @param places - The places the histories may take; the count is not carried beyond it. + * @returns The number of listed histories, or `places` once that many are found. + * @throws When the history directory exists and cannot be read. + * @example await store.countListedHistories(64) // => 3 + */ + async countListedHistories(places: number): Promise { + return this.listedHistoryCount(places).catch((error: unknown) => { + if (systemErrorCode(error) === 'ENOENT') return 0 + throw error + }) + } + /** * Counts the histories the list shows, so that nothing takes a place of the saved study limit that no list shows; called by {@link reserve} for every start, under the storage lock. * A history counts when {@link listedHeader} reads its header: a folder without one (made by hand, or left by a failed start of an earlier build), one whose header cannot be read, and any entry not named like a study id count nothing. A history that is being started is never met without its header here: its start holds the storage lock until the header is written. @@ -558,7 +485,7 @@ export class NodeTestHistoryStore { for (const name of await readdir(this.directory)) { if (listed >= places) break // A history sealed with no entry (written before D6) is removed when met and counts nothing, as the list shows nothing of it (D28, F213); so is one whose header a removal at its seal took before it failed (F266). - if (HISTORY_IDENTITY.test(name) && (await this.dropSealedEmpty(name))) + if (isNodeHistoryIdentity(name) && (await this.dropSealedEmpty(name))) continue if (await this.listedHeader(name)) listed += 1 } @@ -573,13 +500,13 @@ export class NodeTestHistoryStore { */ private async listedHeader( name: string, - ): Promise { + ): Promise { try { - const header = nodeTestHistoryHeaderSchema.parse( + const header = this.descriptor.parseHeader( JSON.parse( ( await this.readPrivateFile( - join(this.path(name), HEADER_FILE), + join(this.path(name), HISTORY_HEADER_FILE), HEADER_LIMIT_BYTES, ) ).toString('utf8'), @@ -597,12 +524,12 @@ export class NodeTestHistoryStore { * @returns The summaries of the listed histories. * @example await store.list() // => [{ studyId, status: 'complete', verdict: 'Test passed · exit code 0', learningCoverage: 'partial', … }] */ - async list(): Promise { + async list(): Promise { const names = await readdir(this.directory).catch((error) => { if (error.code === 'ENOENT') return [] throw error }) - const studyIds = names.filter((name) => HISTORY_IDENTITY.test(name)) + const studyIds = names.filter((name) => isNodeHistoryIdentity(name)) // A memo of a history whose folder is gone would keep a summary nobody lists; a set lookup keeps the prune linear (F77). const present = new Set(studyIds) for (const studyId of [...this.summaries.keys()]) @@ -613,9 +540,7 @@ export class NodeTestHistoryStore { (studyId) => this.summarize(studyId), ) return summaries - .filter( - (summary): summary is NodeTestHistorySummary => summary !== undefined, - ) + .filter((summary): summary is Types['Summary'] => summary !== undefined) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) } @@ -637,7 +562,7 @@ export class NodeTestHistoryStore { * @example store.holdsHistoryFolder(earlierSessionStudyId) // => true before any list of this window */ holdsHistoryFolder(studyId: string): boolean { - return HISTORY_IDENTITY.test(studyId) && existsSync(this.path(studyId)) + return isNodeHistoryIdentity(studyId) && existsSync(this.path(studyId)) } /** @@ -647,7 +572,7 @@ export class NodeTestHistoryStore { * @returns The history's summary, or `undefined` for a folder that holds no history of this workspace. * @example await store.summarize(studyId) // => { status: 'complete', learningCoverage: 'complete', learningMissing: [], … } */ - summarize(studyId: string): Promise { + summarize(studyId: string): Promise { // One read per history at a time, as the material writes are: two reads of a recording would continue its shared scan from one offset and apply the same entries twice (F216). A later call finds the memo the earlier one made. const previous = this.summaryReads.get(studyId) ?? Promise.resolve() const operation = previous @@ -668,14 +593,14 @@ export class NodeTestHistoryStore { */ private async summarizeOnce( studyId: string, - ): Promise { + ): Promise { const signals = await this.fileSignals(studyId) const { history: historySignal, material: materialSignal } = signals const memo = this.summaries.get(studyId) if (memo?.historySignal === historySignal) { if (memo.materialSignal === materialSignal) return memo.summary // Only the material changed: its citations are checked against the kept events. - if (memo.citable && memo.needs) { + if (memo.citable && memo.needs && this.descriptor.applyMaterial) { const counted = await this.summaryWithMaterial( studyId, memo.summary, @@ -714,7 +639,7 @@ export class NodeTestHistoryStore { * @param memo - Its new memo. * @example this.rememberSummary(studyId, memo) */ - private rememberSummary(studyId: string, memo: SummaryMemo): void { + private rememberSummary(studyId: string, memo: SummaryMemo): void { this.summaries.set(studyId, memo) if (memo.citable) this.citableOrder.add(studyId) else this.citableOrder.delete(studyId) @@ -769,9 +694,9 @@ export class NodeTestHistoryStore { (error: unknown) => `missing:${systemErrorCode(error) || 'unknown'}`, ) const [header, journal, seal, material] = await Promise.all([ - signal(HEADER_FILE), - signal(JOURNAL_FILE), - signal(SEAL_FILE), + signal(HISTORY_HEADER_FILE), + signal(HISTORY_JOURNAL_FILE), + signal(HISTORY_SEAL_FILE), signal(LEARNING_MATERIAL_FILE), ]) // A history another live window writes is told by its writer record; only an unsealed one is asked (F260). @@ -840,11 +765,13 @@ export class NodeTestHistoryStore { header: string recording: boolean }, - previousScan: RecordingScan | undefined, - previousSummary: NodeTestHistorySummary | undefined, - ): Promise { + previousScan: + | RecordingScan + | undefined, + previousSummary: Types['Summary'] | undefined, + ): Promise | undefined> { const { history: historySignal, material: materialSignal } = signals - let loaded: LoadedHistory | undefined + let loaded: LoadedHistory | undefined try { loaded = await this.loadHistory( studyId, @@ -862,35 +789,17 @@ export class NodeTestHistoryStore { studyId, citable, ) - const missing = listMissingNodeLearning(history, material?.content) - const result = history.entries.find((entry) => entry.type === 'result') - const stops = history.entries.filter((entry) => entry.type === 'stop') - const needs: NodeLearningNeeds = { - stopSequences: stops.map((stop) => stop.sequence), - ...(result ? { resultSequence: result.sequence } : {}), - } - const summary: NodeTestHistorySummary = { - ...emptySummary(history.header), - status: history.status, - ...(history.reason ? { reason: history.reason } : {}), - verification: 'verified', - verdict: describeNodeTestResult(result), - testResult: nodeTestResultKind(result), - stopCount: stops.length, - explainedStopCount: - stops.length - - missing.explanations.filter((part) => part.kind === 'stop').length, - learningCoverage: missing.coverage, - learningMissing: missing.explanations, - captureMissing: missing.capture, + const { summary, needs } = this.descriptor.summarize({ + history, + materialContent: material?.content, materialRevision: material?.revision ?? 0, - } + }) return { historySignal, materialSignal, summary, citable, - needs, + ...(needs ? { needs } : {}), ...(scan ? { scan } : {}), ...(transient ? { transient: true } : {}), } @@ -910,8 +819,10 @@ export class NodeTestHistoryStore { studyId: string, headerSignal: string, recording: boolean, - previousScan: RecordingScan | undefined, - ): Promise { + previousScan: + | RecordingScan + | undefined, + ): Promise | undefined> { if (recording) { // The bytes appended since the last refresh are all it reads (F64). const scan = await this.scanRecording(studyId, headerSignal, previousScan) @@ -921,7 +832,7 @@ export class NodeTestHistoryStore { entries: scan.kept, status: 'recording', ...(scan.brokenReason - ? { reason: cutReason(scan.brokenReason) } + ? { reason: cutHistoryReason(scan.brokenReason) } : {}), }, citable: scan.citable, @@ -933,7 +844,7 @@ export class NodeTestHistoryStore { await this.dropSealedEmpty(studyId) return undefined } - return { history: verified, citable: citableEvents(verified) } + return { history: verified, citable: this.citableEventsOf(verified) } } /** @@ -949,8 +860,8 @@ export class NodeTestHistoryStore { studyId: string, signals: { history: string; material: string }, error: unknown, - previousSummary: NodeTestHistorySummary | undefined, - ): Promise { + previousSummary: Types['Summary'] | undefined, + ): Promise | undefined> { const header = await this.listedHeader(studyId) if (!header) { // A header that cannot be read for a transient reason does not make the history vanish: its earlier summary stays, not remembered, and the next refresh reads again (F217). @@ -963,10 +874,10 @@ export class NodeTestHistoryStore { } : undefined } - const summary: NodeTestHistorySummary = { - ...emptySummary(header), + const summary: Types['Summary'] = { + ...this.descriptor.emptySummary(header), status: 'partial', - reason: cutReason( + reason: cutHistoryReason( `History integrity check failed: ${this.describeCause(error)}`, ), verification: 'unavailable', @@ -992,8 +903,12 @@ export class NodeTestHistoryStore { private async scanRecording( studyId: string, headerSignal: string, - previous: RecordingScan | undefined, - ): Promise { + previous: + | RecordingScan + | undefined, + ): Promise< + RecordingScan + > { const directory = this.path(studyId) const directoryStat = await lstat(directory) if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) @@ -1004,12 +919,14 @@ export class NodeTestHistoryStore { previous?.headerSignal === headerSignal ? previous.header : await this.readHeader(studyId) - return advanceRecordingScan( + return advanceHistoryScan( previous, header, headerSignal, - join(directory, JOURNAL_FILE), + join(directory, HISTORY_JOURNAL_FILE), (error) => this.describeCause(error), + this.descriptor.scanRules, + this.descriptor.words.interruptedPrefix, ) } @@ -1024,28 +941,23 @@ export class NodeTestHistoryStore { */ private async summaryWithMaterial( studyId: string, - summary: NodeTestHistorySummary, + summary: Types['Summary'], citable: ReadonlyMap, needs: NodeLearningNeeds, - ): Promise<{ summary: NodeTestHistorySummary; transient: boolean }> { + ): Promise<{ summary: Types['Summary']; transient: boolean }> { const { material, transient } = await this.readCitedLearningMaterial( studyId, citable, ) - const { missing, coverage } = listMissingNodeExplanations( - needs, - material?.content, - ) return { - summary: { - ...summary, - explainedStopCount: - summary.stopCount - - missing.filter((part) => part.kind === 'stop').length, - learningCoverage: coverage, - learningMissing: missing, - materialRevision: material?.revision ?? 0, - }, + summary: this.descriptor.applyMaterial + ? this.descriptor.applyMaterial({ + summary, + needs, + materialContent: material?.content, + materialRevision: material?.revision ?? 0, + }) + : summary, transient, } } @@ -1055,7 +967,7 @@ export class NodeTestHistoryStore { * @param studyId - The history's study id. * @param citable - The journal's citable events. * @returns The material, or none for no file, one that does not parse, or one that cites what the journal does not hold, which explains nothing; `transient` is set when a transient file error (EBUSY, EMFILE, EIO …) stopped the read, so the caller does not remember "no material". - * @example await this.readCitedLearningMaterial(studyId, citableEvents(history)) // => { material: the saved material, transient: false } + * @example await this.readCitedLearningMaterial(studyId, this.citableEventsOf(history)) // => { material: the saved material, transient: false } */ private async readCitedLearningMaterial( studyId: string, @@ -1064,6 +976,9 @@ export class NodeTestHistoryStore { material: NodeTestLearningMaterial | undefined transient: boolean }> { + // A profile without learning material (server histories until their material arrives) has none to read. + if (!this.descriptor.supportsLearningMaterial) + return { material: undefined, transient: false } try { const saved = await this.readLearningMaterialFile(studyId) if (saved) assertLearningCitations(saved.content, citable) @@ -1080,12 +995,12 @@ export class NodeTestHistoryStore { * @throws When the header is no bounded regular file, does not parse, or names another workspace or run. * @example const header = await this.readHeader(studyId) */ - private async readHeader(studyId: string): Promise { - const header = nodeTestHistoryHeaderSchema.parse( + private async readHeader(studyId: string): Promise { + const header = this.descriptor.parseHeader( JSON.parse( ( await this.readPrivateFile( - join(this.path(studyId), HEADER_FILE), + join(this.path(studyId), HISTORY_HEADER_FILE), HEADER_LIMIT_BYTES, ) ).toString('utf8'), @@ -1097,7 +1012,7 @@ export class NodeTestHistoryStore { } /** Verifies a sealed journal or returns only its valid crash-recovery prefix. @example await store.read(studyId) */ - async read(studyId: string): Promise { + async read(studyId: string): Promise> { const directory = this.path(studyId) const directoryStat = await lstat(directory) if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) @@ -1106,19 +1021,19 @@ export class NodeTestHistoryStore { ) const header = await this.readHeader(studyId) const journal = await this.readPrivateFile( - join(directory, JOURNAL_FILE), - NODE_TEST_HISTORY_LIMIT_BYTES, + join(directory, HISTORY_JOURNAL_FILE), + this.descriptor.journalLimitBytes, ) const sealBytes = await this.readPrivateFile( - join(directory, SEAL_FILE), - NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, + join(directory, HISTORY_SEAL_FILE), + this.descriptor.sealLimitBytes, ).catch((error) => { if (error.code === 'ENOENT') return undefined throw error }) const seal = sealBytes && - nodeTestHistorySealSchema.parse(JSON.parse(sealBytes.toString('utf8'))) + this.descriptor.parseSeal(JSON.parse(sealBytes.toString('utf8'))) if ( seal && (seal.studyId !== studyId || @@ -1128,41 +1043,36 @@ export class NodeTestHistoryStore { throw new Error('Sealed Node history journal is corrupt.') const lines = journal.toString('utf8').split('\n'), incomplete = lines.pop() - const entries: NodeTestHistoryEntry[] = [] - const progress = newJournalProgress() + const entries: Types['Entry'][] = [] + const progress = this.descriptor.scanRules.start() let reason: string | undefined for (const line of lines) { try { - const entry = nodeTestHistoryEntrySchema.parse(JSON.parse(line)) - acceptJournalEntry(progress, entry) + const entry = this.descriptor.scanRules.parseEntry(JSON.parse(line)) + this.descriptor.scanRules.accept(progress, entry, header) entries.push(entry) } catch (error) { if (seal) throw error // The failure can quote the line, which holds test output: its words lose private paths, and the whole reason keeps the index's bound (F243). - reason = cutReason( - `Interrupted Node history after its verified prefix: ${this.describeCause(error)}`, + reason = cutHistoryReason( + `${this.descriptor.words.interruptedPrefix} ${this.describeCause(error)}`, ) break } } - if ( - seal && - (incomplete || - seal.entryCount !== entries.length || - (seal.status === 'complete' && - (!progress.hasResult || - !progress.passingResult || - progress.stopCount !== - 1 + (header.configuration.additionalStops?.length ?? 0)))) - ) - throw new Error( - 'Sealed Node history does not contain its required passing result and ordered stops.', - ) + if (seal) + this.descriptor.verifySeal({ + seal, + header, + entries, + progress, + hasIncompleteLine: Boolean(incomplete), + }) const isRecording = !seal && (this.active.has(studyId) || (await this.writtenElsewhere(studyId))) const status = seal?.status ?? (isRecording ? 'recording' : 'interrupted') - return nodeTestHistoryIndexSchema.parse({ + return this.descriptor.parseIndex({ header, entries, seal, @@ -1170,9 +1080,7 @@ export class NodeTestHistoryStore { reason: reason ?? seal?.reason ?? - (!seal && !isRecording - ? 'Node test history has no verified final seal.' - : undefined), + (!seal && !isRecording ? this.descriptor.words.noSeal : undefined), }) } @@ -1267,7 +1175,7 @@ export class NodeTestHistoryStore { if (!material) return undefined assertLearningCitations( material.content, - citableEvents(await this.read(studyId)), + this.citableEventsOf(await this.read(studyId)), ) return material } @@ -1313,6 +1221,10 @@ export class NodeTestHistoryStore { expectedRevision: number, content: NodeTestLearningMaterialContent, ): Promise { + if (!this.descriptor.supportsLearningMaterial) + throw new Error( + 'Learning material is not available for this kind of history yet.', + ) const previous = this.learningMaterialWrites.get(studyId) ?? Promise.resolve(undefined) const operation = previous @@ -1341,7 +1253,11 @@ export class NodeTestHistoryStore { currentRevision, ) const parsedContent = nodeTestLearningMaterialContentSchema.parse(content) - assertLearningCitations(parsedContent, citableEvents(history), saveStepLead) + assertLearningCitations( + parsedContent, + this.citableEventsOf(history), + saveStepLead, + ) const material = nodeTestLearningMaterialSchema.parse({ version: NODE_TEST_LEARNING_MATERIAL_VERSION, studyId, @@ -1367,7 +1283,7 @@ export class NodeTestHistoryStore { saveReaderPosition( studyId: string, sequence: number, - verified?: NodeTestHistoryIndex, + verified?: Pick, 'header' | 'entries'>, ): Promise { const previous = this.readerPositionWrites.get(studyId) ?? Promise.resolve() const operation = previous @@ -1410,8 +1326,25 @@ export class NodeTestHistoryStore { await Promise.allSettled([...this.readerPositionWrites.values()]) } + /** + * Lists the events of a verified history a learning step may cite, by sequence; a profile that offers none for an entry (server histories, until their material arrives) leaves it out. + * @param history - The verified history. + * @returns The citable events by sequence. + * @example store.citableEventsOf(history).get(2) // => { type: 'stop', sourceHash: '…', path: 'src/scheduler.ts', line: 3 } + */ + citableEventsOf( + history: Pick, 'entries'>, + ): Map { + const events = new Map() + for (const entry of history.entries) { + const citable = this.descriptor.scanRules.citableOf(entry) + if (citable) events.set(entry.sequence, citable) + } + return events + } + private path(studyId: string): string { - if (!HISTORY_IDENTITY.test(studyId)) + if (!isNodeHistoryIdentity(studyId)) throw new Error('Invalid Node history identity.') return join(this.directory, studyId) } @@ -1427,270 +1360,31 @@ export class NodeTestHistoryStore { } } -/** Serializes fsynced journal appends so output and debugger stops keep one verified timeline. The first failed entry ends that timeline; the verified prefix can still be sealed as partial. @example await writer.appendOutput(output) */ -export class NodeTestHistoryWriter { - private sequence = 0 - private lastElapsedMs = 0 - private entries = 0 - /** Journal bytes written by confirmed entries; anything beyond them is a torn write. */ - private confirmedBytes = 0 - /** The first refused or failed entry; later evidence would follow a gap. */ - private failure?: Error - private pending: Promise = Promise.resolve() - private closed = false - private closing = false - private stopCount = 0 - private resultWritten = false - private resultPassed = false - /** The journal sequence of each stop entry this writer confirmed, in stop order, and of its result entry. */ - private readonly stopSequences: number[] = [] - private resultSequence?: number - private finishOperation?: Promise - /** The SHA-256 of the journal this writer sealed; `undefined` before the seal and after an abandoned one. */ - private sealedHash?: string - +/** + * The store of test histories: the generic {@link NodeHistoryStore} with the node-test descriptor, whose constructor takes the descriptor as an optional last-but-one parameter so existing callers and tests need no edit, and the count of the other profiles' histories as the last, so one saved-study limit counts every kind. + * @example const store = new NodeTestHistoryStore(learningDirectory, workspaceKey) + */ +export class NodeTestHistoryStore extends NodeHistoryStore { constructor( - private readonly directory: string, - readonly header: NodeTestHistoryHeader, - private readonly onClosed: () => void, - ) {} - - /** Queues one stdout/stderr receipt in stamp order; the promise settles when it is durable. @example await writer.appendOutput(event) */ - appendOutput(event: NodeTestOutputEvent): Promise { - return this.append({ - type: 'output', - elapsedMs: event.elapsedMs, - outputSequence: event.sequence, - channel: event.channel, - text: event.text, - }) - } - - /** Persists only the declared source, required facts and bounded historical debugger snapshot. @example await writer.appendStop(stop) */ - appendStop(stop: NodeTestSourceStop): Promise { - return this.append({ - type: 'stop', - elapsedMs: stop.elapsedMs, - index: stop.index, - source: { - path: stop.source.path, - hash: stop.source.hash, - line: stop.frame.line, - column: stop.frame.column, - }, - frame: { - name: stop.frame.name, - line: stop.frame.line, - column: stop.frame.column, - }, - facts: stop.facts, - debuggerSnapshot: stop.debuggerSnapshot, - }) - } - - /** Records the actual process exit and bounded final output tail. @example await writer.appendResult(result, elapsedMs) */ - appendResult( - result: { - exitCode: number | null - signal: NodeJS.Signals | null - output: string + learningDirectory: string, + workspaceKey: string, + capacity: () => number = () => 64, + savedArtifactCount: () => Promise = async () => 0, + writerJudge: NodeHistoryWriterJudge = { + now: Date.now, + judge: judgeNodeTestWriter, }, - elapsedMs: number, - ): Promise { - return this.append({ - type: 'result', - elapsedMs, - exitCode: result.exitCode, - signal: result.signal, - outputTail: result.output, - }) - } - - /** - * Counts the entries this writer confirmed, the journal's entry count once sealed; read by {@link StudyService.sealNodeHistory}, which keeps it for the run's outcome: a replay needs at least one entry. - * @example writer.entryCount // => 4 - */ - get entryCount(): number { - return this.entries - } - - /** - * Gives what the sealed journal asks a learning material to explain, from the sequences this writer assigned, so {@link StudyService} caches the run's learning coverage at the seal without reading the journal again (EN2). - * @returns The sequence of each stop entry in stop order, and of the result entry when one was written. - * @example writer.learningNeeds() // => { stopSequences: [2, 5], resultSequence: 7 } - */ - learningNeeds(): NodeLearningNeeds { - return { - stopSequences: [...this.stopSequences], - ...(this.resultSequence === undefined - ? {} - : { resultSequence: this.resultSequence }), - } - } - - /** - * Fsyncs and seals complete evidence only when the caller verified all ordered stops and a passing result; after a failed entry only the verified prefix can be sealed, as partial; called by {@link StudyService.stopRecord}. - * @param status - `complete` for a run with every declared stop and a passing result, else `partial`. - * @param reason - Why a partial history ended. - * @returns The SHA-256 of the sealed journal, the run's `recording.contextHash`; `undefined` for a writer that was abandoned before. - * @throws When `complete` is asked without every stop and a passing result, the journal holds bytes no entry confirmed, or the seal cannot be written. - * @example await writer.finish('partial', 'Cancelled by the learner.') // => 'a3f…' - */ - finish( - status: 'complete' | 'partial', - reason?: string, - ): Promise { - if (this.closed) return Promise.resolve(this.sealedHash) - if (this.finishOperation) return this.finishOperation - const operation = (async () => { - this.closing = true - await this.pending - if ( - status === 'complete' && - (this.failure || - !this.resultPassed || - this.stopCount !== - 1 + (this.header.configuration.additionalStops?.length ?? 0)) - ) - throw new Error( - 'Node history cannot be marked complete without every ordered stop and an observed passing result.', - ) - const path = join(this.directory, JOURNAL_FILE), - journal = await readFile(path) - // A write torn by an I/O failure leaves bytes no entry confirmed; sealing them would certify a journal readers must refuse. - if (journal.byteLength !== this.confirmedBytes) { - this.failure ??= new Error( - 'Node history journal has bytes its writer never confirmed.', - ) - throw new Error( - 'Node history journal has bytes its writer never confirmed; it remains an interrupted prefix.', - ) - } - // A history that ends with no entry holds nothing to replay: its folder is removed when it seals, so it is listed nowhere, takes no place of the saved study limit and agrees with a run that reads `replayAvailable: false` (D6, F85). A folder that cannot be removed is sealed as any other. - if ( - this.entries === 0 && - // Learning material or understanding marks saved before the first entry keep the folder, sealed as any other (F274). - !(await holdsLearnerFiles(this.directory)) && - (await rm(this.directory, { recursive: true, force: true }).then( - () => true, - () => false, - )) - ) { - this.sealedHash = sha256(journal) - this.closed = true - this.onClosed() - return this.sealedHash - } - const seal = nodeTestHistorySealSchema.parse({ - version: 1, - studyId: this.header.studyId, - entryCount: this.entries, - journalBytes: journal.byteLength, - journalHash: sha256(journal), - status, - endedAt: new Date().toISOString(), - reason: reason === undefined ? undefined : cutReason(reason), - }) - await atomicPrivateWrite( - join(this.directory, SEAL_FILE), - seal, - NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, - ) - this.sealedHash = seal.journalHash - this.closed = true - // The seal is published first: another window never finds neither the seal nor the record. - await this.removeWriterRecord() - this.onClosed() - return seal.journalHash - })().catch((error: unknown) => { - this.closing = false - this.finishOperation = undefined - throw error - }) - this.finishOperation = operation - return operation - } - - /** Marks a failed finalization as an interrupted prefix so library readers do not show it as live. @example writer.abandon() */ - abandon(): void { - this.closed = true - this.closing = false - // Other windows read the history as interrupted once the record is gone (F260); the removal is awaited by nobody. - void this.removeWriterRecord() - this.onClosed() - } - - /** Removes the record of the editor process that writes this history ({@link writeNodeTestWriterRecord}); a record that stays is ignored once its process is gone, and by the seal. @example await this.removeWriterRecord() */ - private removeWriterRecord(): Promise { - return rm(join(this.directory, NODE_TEST_HISTORY_WRITER_FILE), { - force: true, - }).catch(() => undefined) - } - - private append(input: NodeTestHistoryEntryInput): Promise { - const operation = this.pending.then(async () => { - // Evidence after a failed entry would follow a gap, so the verified prefix ends at the first failure. - if (this.failure) - throw new Error( - `Node history stopped accepting evidence after an earlier entry failed: ${this.failure.message}`, - ) - if (this.closed || this.closing) - throw new Error('Node history is already sealing or sealed.') - if (this.entries >= NODE_TEST_HISTORY_MAX_ENTRIES) - throw new Error('Node history reached its event limit.') - const entry = nodeTestHistoryEntrySchema.parse({ - ...input, - sequence: this.sequence + 1, - }) - if (entry.elapsedMs < this.lastElapsedMs) - throw new Error('Node history time moved backward.') - if ( - this.resultWritten || - (entry.type === 'stop' && entry.index !== this.stopCount) - ) - throw new Error( - 'Node history source stops or terminal result are missing or reordered.', - ) - if ( - entry.type === 'result' && - this.stopCount !== - 1 + (this.header.configuration.additionalStops?.length ?? 0) - ) - throw new Error( - 'Node history result arrived before every declared stop.', - ) - const line = Buffer.from(`${JSON.stringify(entry)}\n`) - const path = join(this.directory, JOURNAL_FILE), - handle = await open(path, 'a', PRIVATE_FILE_MODE) - try { - const stat = await handle.stat() - if (stat.size + line.byteLength > NODE_TEST_HISTORY_LIMIT_BYTES) - throw new Error( - 'Node history reached its storage limit; existing evidence remains readable.', - ) - await handle.writeFile(line) - await handle.sync() - } finally { - await handle.close() - } - this.sequence = entry.sequence - this.lastElapsedMs = entry.elapsedMs - this.entries++ - this.confirmedBytes += line.byteLength - if (entry.type === 'stop') { - this.stopCount++ - this.stopSequences.push(entry.sequence) - } - if (entry.type === 'result') { - this.resultWritten = true - this.resultPassed = entry.exitCode === 0 && entry.signal === null - this.resultSequence = entry.sequence - } - }) - // Later entries and the seal wait for this one without inheriting its rejection. - this.pending = operation.catch((error: unknown) => { - this.failure ??= error instanceof Error ? error : new Error(String(error)) - }) - return operation + descriptor: HistoryStorageDescriptor = nodeTestStorageDescriptor, + otherHistoryCount: (places: number) => Promise = async () => 0, + ) { + super( + descriptor, + learningDirectory, + workspaceKey, + capacity, + savedArtifactCount, + writerJudge, + otherHistoryCount, + ) } } diff --git a/extension/src/nodeTestHistoryTypes.ts b/extension/src/nodeTestHistoryTypes.ts new file mode 100644 index 00000000..677e03c9 --- /dev/null +++ b/extension/src/nodeTestHistoryTypes.ts @@ -0,0 +1,50 @@ +import type { + NodeTestHistoryEntry, + NodeTestHistoryHeader, + NodeTestHistoryIndex, + NodeTestHistorySeal, +} from './nodeTestHistorySchemas' +import type { NodeTestHistoryWriter } from './nodeTestHistoryWriter' +import type { NodeTestVerdict } from './utils/describeNodeTestResult' +import type { + NodeLearningCoverage, + NodeLearningExplanationPart, +} from './utils/listMissingNodeExplanations' +import type { NodeLearningCapturePart } from './utils/listMissingNodeLearning' +import type { NodeTestResultKind } from './utils/nodeTestResultKind' +import type { JournalProgress } from './utils/recordingJournalScan' + +/** + * One test history as Saved studies, the `codebook://node-history` resources and Save read it: one projection per history, so the three agree (plan version 7, Design 13). + * A history whose journal does not verify reads `unavailable` with its header's identity and the reason; its learning fields then say nothing was checked. + */ +export type NodeTestHistorySummary = { + studyId: string + createdAt: string + profile: 'node-test' + status: NodeTestHistoryIndex['status'] + reason?: string + verification: 'verified' | 'unavailable' + /** The cited test file and its title path, as the header names them, for labels such as a failed open's notification. */ + testFile: string + testTitles?: string[] + verdict: NodeTestVerdict + testResult: NodeTestResultKind + stopCount: number + explainedStopCount: number + learningCoverage: NodeLearningCoverage + learningMissing: NodeLearningExplanationPart[] + captureMissing: NodeLearningCapturePart[] + /** The revision of the learning material the summary counted; 0 before the first save. */ + materialRevision: number +} + +/** The types the node-test profile fixes; the default of {@link NodeTestHistoryStore}. */ +export type NodeTestHistoryTypes = { + Header: NodeTestHistoryHeader + Entry: NodeTestHistoryEntry + Seal: NodeTestHistorySeal + Progress: JournalProgress + Summary: NodeTestHistorySummary + Writer: NodeTestHistoryWriter +} diff --git a/extension/src/nodeTestHistoryWriter.ts b/extension/src/nodeTestHistoryWriter.ts new file mode 100644 index 00000000..f73c957c --- /dev/null +++ b/extension/src/nodeTestHistoryWriter.ts @@ -0,0 +1,198 @@ +import { + NODE_TEST_HISTORY_LIMIT_BYTES, + NODE_TEST_HISTORY_MAX_ENTRIES, + NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, +} from './constants' +import type { HistoryWriterRules } from './historyStorageDescriptor' +import { HistoryJournalWriter } from './nodeHistoryJournalWriter' +import { + nodeTestHistoryEntrySchema, + nodeTestHistorySealSchema, + type NodeTestHistoryEntry, + type NodeTestHistoryHeader, + type NodeTestHistorySeal, +} from './nodeTestHistorySchemas' +import type { NodeTestHistoryTypes } from './nodeTestHistoryTypes' +import type { NodeTestSourceStop } from './ownedNodeTestRun' +import { sha256 } from './runtimeSupport/utils/sha256' +import type { NodeTestOutputEvent } from './utils/launchNodeTest' +import type { NodeLearningNeeds } from './utils/listMissingNodeExplanations' + +/** The node-test profile's writer rules: declared stops in order, then one result, time never going back; holds the state of one writer. */ +class NodeTestWriterRules implements HistoryWriterRules { + readonly journalLimitBytes = NODE_TEST_HISTORY_LIMIT_BYTES + readonly maxEntries = NODE_TEST_HISTORY_MAX_ENTRIES + readonly sealLimitBytes = NODE_TEST_HISTORY_SEAL_LIMIT_BYTES + private lastElapsedMs = 0 + private stopCount = 0 + private resultWritten = false + private resultPassed = false + /** The journal sequence of each stop entry confirmed, in stop order, and of the result entry. */ + readonly stopSequences: number[] = [] + resultSequence?: number + + constructor(private readonly header: NodeTestHistoryHeader) {} + + /** The number of stops the declaration asks for: its first plus the additional ones. */ + private get declaredStopCount(): number { + return 1 + (this.header.configuration.additionalStops?.length ?? 0) + } + + parseEntry(value: unknown): NodeTestHistoryEntry { + return nodeTestHistoryEntrySchema.parse(value) + } + + check(entry: NodeTestHistoryEntry): void { + if (entry.elapsedMs < this.lastElapsedMs) + throw new Error('Node history time moved backward.') + if ( + this.resultWritten || + (entry.type === 'stop' && entry.index !== this.stopCount) + ) + throw new Error( + 'Node history source stops or terminal result are missing or reordered.', + ) + if (entry.type === 'result' && this.stopCount !== this.declaredStopCount) + throw new Error('Node history result arrived before every declared stop.') + } + + commit(entry: NodeTestHistoryEntry): void { + this.lastElapsedMs = entry.elapsedMs + if (entry.type === 'stop') { + this.stopCount++ + this.stopSequences.push(entry.sequence) + } + if (entry.type === 'result') { + this.resultWritten = true + this.resultPassed = entry.exitCode === 0 && entry.signal === null + this.resultSequence = entry.sequence + } + } + + holdsNothingToReplay(entryCount: number): boolean { + return entryCount === 0 + } + + assertSealable(status: 'complete' | 'partial', failed: boolean): void { + if ( + status === 'complete' && + (failed || + !this.resultPassed || + this.stopCount !== this.declaredStopCount) + ) + throw new Error( + 'Node history cannot be marked complete without every ordered stop and an observed passing result.', + ) + } + + buildSeal(input: { + studyId: string + status: 'complete' | 'partial' + reason?: string + journal: Buffer + entryCount: number + endedAt: string + }): NodeTestHistorySeal { + return nodeTestHistorySealSchema.parse({ + version: 1, + studyId: input.studyId, + entryCount: input.entryCount, + journalBytes: input.journal.byteLength, + journalHash: sha256(input.journal), + status: input.status, + endedAt: input.endedAt, + reason: input.reason, + }) + } +} + +type NodeTestHistoryEntryInput = NodeTestHistoryEntry extends infer Entry + ? Entry extends NodeTestHistoryEntry + ? Omit + : never + : never + +/** Serializes fsynced journal appends so output and debugger stops keep one verified timeline. The first failed entry ends that timeline; the verified prefix can still be sealed as partial. @example await writer.appendOutput(output) */ +export class NodeTestHistoryWriter extends HistoryJournalWriter { + private readonly testRules: NodeTestWriterRules + + constructor( + directory: string, + header: NodeTestHistoryHeader, + onClosed: () => void, + ) { + const rules = new NodeTestWriterRules(header) + super(directory, header, onClosed, rules) + this.testRules = rules + } + + /** Queues one stdout/stderr receipt in stamp order; the promise settles when it is durable. @example await writer.appendOutput(event) */ + appendOutput(event: NodeTestOutputEvent): Promise { + return this.appendTestEntry({ + type: 'output', + elapsedMs: event.elapsedMs, + outputSequence: event.sequence, + channel: event.channel, + text: event.text, + }) + } + + /** Persists only the declared source, required facts and bounded historical debugger snapshot. @example await writer.appendStop(stop) */ + appendStop(stop: NodeTestSourceStop): Promise { + return this.appendTestEntry({ + type: 'stop', + elapsedMs: stop.elapsedMs, + index: stop.index, + source: { + path: stop.source.path, + hash: stop.source.hash, + line: stop.frame.line, + column: stop.frame.column, + }, + frame: { + name: stop.frame.name, + line: stop.frame.line, + column: stop.frame.column, + }, + facts: stop.facts, + debuggerSnapshot: stop.debuggerSnapshot, + }) + } + + /** Records the actual process exit and bounded final output tail. @example await writer.appendResult(result, elapsedMs) */ + appendResult( + result: { + exitCode: number | null + signal: NodeJS.Signals | null + output: string + }, + elapsedMs: number, + ): Promise { + return this.appendTestEntry({ + type: 'result', + elapsedMs, + exitCode: result.exitCode, + signal: result.signal, + outputTail: result.output, + }) + } + + /** + * Gives what the sealed journal asks a learning material to explain, from the sequences this writer assigned, so {@link StudyService} caches the run's learning coverage at the seal without reading the journal again (EN2). + * @returns The sequence of each stop entry in stop order, and of the result entry when one was written. + * @example writer.learningNeeds() // => { stopSequences: [2, 5], resultSequence: 7 } + */ + learningNeeds(): NodeLearningNeeds { + return { + stopSequences: [...this.testRules.stopSequences], + ...(this.testRules.resultSequence === undefined + ? {} + : { resultSequence: this.testRules.resultSequence }), + } + } + + /** Queues a test entry; the typed door to the generic append. */ + private appendTestEntry(input: NodeTestHistoryEntryInput): Promise { + return this.append(input) + } +} diff --git a/extension/src/nodeTestStorageDescriptor.ts b/extension/src/nodeTestStorageDescriptor.ts new file mode 100644 index 00000000..b8885f36 --- /dev/null +++ b/extension/src/nodeTestStorageDescriptor.ts @@ -0,0 +1,159 @@ +import { + NODE_TEST_HISTORY_DIRECTORY, + NODE_TEST_HISTORY_LIMIT_BYTES, + NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, +} from './constants' +import type { HistoryStorageDescriptor } from './historyStorageDescriptor' +import { + nodeTestHistoryHeaderSchema, + nodeTestHistoryIndexSchema, + nodeTestHistorySealSchema, + type NodeTestHistoryHeader, + type NodeTestHistoryIndex, + type NodeTestLearningMaterialContent, +} from './nodeTestHistorySchemas' +import type { + NodeTestHistorySummary, + NodeTestHistoryTypes, +} from './nodeTestHistoryTypes' +import { NodeTestHistoryWriter } from './nodeTestHistoryWriter' +import { describeNodeTestResult } from './utils/describeNodeTestResult' +import { + listMissingNodeExplanations, + type NodeLearningNeeds, +} from './utils/listMissingNodeExplanations' +import { listMissingNodeLearning } from './utils/listMissingNodeLearning' +import { nodeTestResultKind } from './utils/nodeTestResultKind' +import { NODE_TEST_SCAN_RULES } from './utils/recordingJournalScan' + +/** + * Starts a test history's summary from its header, with nothing counted yet; the store fills in what the verified journal says, and an unavailable history keeps these values. + * @param header - The history's header. + * @returns A summary with the header's identity, the cited test, no result and no learning. + * @example emptyNodeTestSummary(header).verdict // => 'No result recorded' + */ +function emptyNodeTestSummary( + header: NodeTestHistoryHeader, +): NodeTestHistorySummary { + return { + studyId: header.studyId, + createdAt: header.createdAt, + profile: header.profile, + status: 'partial', + verification: 'unavailable', + testFile: header.configuration.testFile, + ...(header.configuration.testTitles + ? { testTitles: header.configuration.testTitles } + : {}), + verdict: 'No result recorded', + testResult: 'none', + stopCount: 0, + explainedStopCount: 0, + learningCoverage: 'none', + learningMissing: [], + captureMissing: [], + materialRevision: 0, + } +} + +/** + * The node-test profile's storage descriptor: today's folder, header, entries, seal, order rules and summary, unchanged. {@link NodeTestHistoryStore} uses it unless it is given another, so existing callers and histories need no edit. + * @example new NodeTestHistoryStore(directory, workspaceKey) // uses this descriptor + */ +export const nodeTestStorageDescriptor: HistoryStorageDescriptor = + { + profile: 'node-test', + directoryName: NODE_TEST_HISTORY_DIRECTORY, + journalLimitBytes: NODE_TEST_HISTORY_LIMIT_BYTES, + sealLimitBytes: NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, + words: { + interruptedPrefix: 'Interrupted Node history after its verified prefix:', + noSeal: 'Node test history has no verified final seal.', + subject: 'Node history', + }, + supportsLearningMaterial: true, + stampHeader: (input) => ({ ...input, version: 1 }), + parseHeader: (value) => nodeTestHistoryHeaderSchema.parse(value), + parseSeal: (value) => nodeTestHistorySealSchema.parse(value), + parseIndex: (value) => nodeTestHistoryIndexSchema.parse(value), + scanRules: NODE_TEST_SCAN_RULES, + createWriter: (directory, header, onClosed) => + new NodeTestHistoryWriter(directory, header, onClosed), + verifySeal: ({ seal, header, entries, progress, hasIncompleteLine }) => { + if ( + hasIncompleteLine || + seal.entryCount !== entries.length || + (seal.status === 'complete' && + (!progress.hasResult || + !progress.passingResult || + progress.stopCount !== + 1 + (header.configuration.additionalStops?.length ?? 0))) + ) + throw new Error( + 'Sealed Node history does not contain its required passing result and ordered stops.', + ) + }, + emptySummary: emptyNodeTestSummary, + summarize({ + history, + materialContent, + materialRevision, + }: { + history: Pick< + NodeTestHistoryIndex, + 'header' | 'entries' | 'status' | 'reason' + > + materialContent?: NodeTestLearningMaterialContent + materialRevision?: number + }) { + const missing = listMissingNodeLearning(history, materialContent) + const result = history.entries.find((entry) => entry.type === 'result') + const stops = history.entries.filter((entry) => entry.type === 'stop') + const needs: NodeLearningNeeds = { + stopSequences: stops.map((stop) => stop.sequence), + ...(result ? { resultSequence: result.sequence } : {}), + } + const summary: NodeTestHistorySummary = { + ...emptyNodeTestSummary(history.header), + status: history.status, + ...(history.reason ? { reason: history.reason } : {}), + verification: 'verified', + verdict: describeNodeTestResult(result), + testResult: nodeTestResultKind(result), + stopCount: stops.length, + explainedStopCount: + stops.length - + missing.explanations.filter((part) => part.kind === 'stop').length, + learningCoverage: missing.coverage, + learningMissing: missing.explanations, + captureMissing: missing.capture, + materialRevision: materialRevision ?? 0, + } + return { summary, needs } + }, + applyMaterial({ + summary, + needs, + materialContent, + materialRevision, + }: { + summary: NodeTestHistorySummary + needs: NodeLearningNeeds + materialContent?: NodeTestLearningMaterialContent + materialRevision: number + }) { + const { missing, coverage } = listMissingNodeExplanations( + needs, + materialContent, + ) + return { + ...summary, + explainedStopCount: + summary.stopCount - + missing.filter((part) => part.kind === 'stop').length, + learningCoverage: coverage, + learningMissing: missing, + materialRevision, + } + }, + } diff --git a/extension/src/recordingStore.ts b/extension/src/recordingStore.ts index 0977658c..7f2e38e5 100644 --- a/extension/src/recordingStore.ts +++ b/extension/src/recordingStore.ts @@ -154,6 +154,7 @@ export class RecordingStore { * @param io - The disk operations, replaced by a test that injects a fault. * @param capacity - Reads the Saved Study Limit setting at each admission. * @param removalAvailability - Reads at each refused reservation whether this window can remove a saved study, so the refusal names a removal only while one can work here. + * @param savedHistoryCount - Counts the node histories that take places of the same limit, up to the given number; the default counts none. * @example new RecordingStore(learningDirectory, workspaceKey, {}, undefined, () => 64, () => service.studyRemovalAvailability) */ constructor( @@ -165,6 +166,10 @@ export class RecordingStore { limits.slots ?? DEFAULT_SAVED_STUDY_LIMIT, private readonly removalAvailability: () => StudyRemovalAvailability = () => 'available', + /** Counts the histories (test and server) that share the saved-study limit, up to the given number; the default counts none. */ + private readonly savedHistoryCount: ( + places: number, + ) => Promise = async () => 0, ) { recordingHashSchema.parse(workspaceKey) this.directory = join(learningDirectory, CONFIGURED_RECORDING_DIRECTORY) @@ -212,6 +217,8 @@ export class RecordingStore { this.learningDirectory, this.workspaceKey, this.capacity, + this.removalAvailability, + this.savedHistoryCount, ).admitsStudy(studyId) } @@ -253,7 +260,12 @@ export class RecordingStore { const identities = await this.identities() await this.dropOwnerRecordsWithoutRecording(identities) const limit = savedStudyLimit(this.capacity()) - if (identities.length >= limit) + const freePlaces = limit - identities.length + // The histories of every kind share the limit: a place is free only while saved studies and histories leave one. + if ( + freePlaces <= 0 || + (await this.savedHistoryCount(freePlaces)) >= freePlaces + ) throw new Error( fullLibraryMessage('recording', limit, this.removalAvailability()), ) diff --git a/extension/src/service.ts b/extension/src/service.ts index f70876a3..19e723df 100644 --- a/extension/src/service.ts +++ b/extension/src/service.ts @@ -63,10 +63,8 @@ import { import { describeNodeServerProfile } from './nodeServerProfile' import type { NodeTestConfiguration } from './nodeTestConfigSchemas' import type { NodeTestLearningMaterialContent } from './nodeTestHistorySchemas' -import type { - NodeTestHistoryStore, - NodeTestHistoryWriter, -} from './nodeTestHistoryStore' +import type { NodeTestHistoryStore } from './nodeTestHistoryStore' +import type { NodeTestHistoryWriter } from './nodeTestHistoryWriter' import type { NodeTestSourceStop } from './ownedNodeTestRun' import type { PlaybackController } from './playbackController' import type { PlaybackCommand } from './playbackSchemas' diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index c5283b94..f2305bb3 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -123,6 +123,7 @@ import { type NamedUnconfirmedWriters, type StudyRemoval, } from './learningLibrary' +import { NodeHistoryRepository } from './nodeHistoryRepository' import { NodeLearningSave } from './nodeLearningSave' import { nodeLearningStepIdSchema, @@ -131,7 +132,7 @@ import { type NodeTestHistoryLearningDecision, type NodeTestLearningMaterial, } from './nodeTestHistorySchemas' -import { NodeTestHistoryStore } from './nodeTestHistoryStore' +import type { NodeTestHistoryStore } from './nodeTestHistoryStore' import { PlaybackController, type PlaybackBundle, @@ -789,6 +790,8 @@ export class StudyWorkspace { private library?: LearningLibrary private recordings?: RecordingStore private nodeHistoryStore?: NodeTestHistoryStore + /** Every history profile's store under the learning directory, with the shared admission wired. */ + private nodeHistoryRepository?: NodeHistoryRepository /** The saved test replays Saved studies lists, with their test files and titles already free of controls ({@link stripNodeHistorySummaryTexts}), stripped once per listing (F131, F326). */ private nodeHistoryCatalog: Awaited< ReturnType @@ -10710,6 +10713,8 @@ export class StudyWorkspace { key, capacity, removalAvailability, + // Test and server histories take places of the same limit as saved studies. + (places) => this.nodeHistoryRepository!.countHistories(places), ) this.recordings = new RecordingStore( this.library.directory, @@ -10718,14 +10723,17 @@ export class StudyWorkspace { undefined, capacity, removalAvailability, + // The repository is built right after: read at admission time, like the library's counter. + (places) => this.nodeHistoryRepository!.countHistories(places), ) - this.nodeHistoryStore = new NodeTestHistoryStore( + this.nodeHistoryRepository = new NodeHistoryRepository( this.library.directory, key, () => this.library!.storageLimit(), // The saved studies as the library's own admission counts them: what is left of a study without its archive is listed and takes no place. () => this.library!.savedStudyCount(), ) + this.nodeHistoryStore = this.nodeHistoryRepository.testHistories // What the reader before this one could not save waits for Retry storage / refresh here. this.queueHandedOverArchives() // An unreadable Node history store stays visible without blocking browser studies, progress or playback. diff --git a/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/events.jsonl b/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/events.jsonl new file mode 100644 index 00000000..b1a2550b --- /dev/null +++ b/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/events.jsonl @@ -0,0 +1,5 @@ +{"type":"output","sequence":1,"elapsedMs":100,"outputSequence":1,"channel":"stdout","text":"RUN src/theme.test.ts\n"} +{"type":"stop","sequence":2,"elapsedMs":200,"index":0,"source":{"path":"src/theme.ts","hash":"f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771","line":1,"column":1},"frame":{"name":"applyTheme","line":1,"column":1},"facts":[{"path":"nextTheme","mode":"value","display":"dark","type":"string"}],"debuggerSnapshot":{"version":1,"frames":[{"name":"applyTheme","line":1,"column":1,"source":{"text":"const nextTheme = \"dark\";\n","hash":"f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771","line":1,"name":"src/theme.ts"},"scopes":[{"name":"Local","variables":[0]}]}],"variables":[{"name":"nextTheme","value":"dark","type":"string","children":[]}],"limitations":[]}} +{"type":"output","sequence":3,"elapsedMs":250,"outputSequence":2,"channel":"stderr","text":"theme switched\n"} +{"type":"stop","sequence":4,"elapsedMs":300,"index":1,"source":{"path":"src/theme.ts","hash":"f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771","line":1,"column":1},"frame":{"name":"applyTheme","line":1,"column":1},"facts":[{"path":"nextTheme","mode":"value","display":"dark","type":"string"}],"debuggerSnapshot":{"version":1,"frames":[{"name":"applyTheme","line":1,"column":1,"source":{"text":"const nextTheme = \"dark\";\n","hash":"f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771","line":1,"name":"src/theme.ts"},"scopes":[{"name":"Local","variables":[0]}]}],"variables":[{"name":"nextTheme","value":"dark","type":"string","children":[]}],"limitations":[]}} +{"type":"result","sequence":5,"elapsedMs":400,"exitCode":0,"signal":null,"outputTail":"Tests 1 passed"} diff --git a/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/header.json b/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/header.json new file mode 100644 index 00000000..41547dba --- /dev/null +++ b/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/header.json @@ -0,0 +1,97 @@ +{ + "version": 1, + "profile": "node-test", + "workspaceKey": "f20e5044e0d34b8a1238e6b48ac5b441502a69a4c751cfdbfa33d287b30786c6", + "studyId": "11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222", + "instanceId": "11111111-1111-4111-8111-111111111111", + "runId": "22222222-2222-4222-8222-222222222222", + "createdAt": "2026-09-28T00:00:00.000Z", + "configurationHash": "e0e727c7d5ccc8aa051e4a6f041942f1575b1bb2d840d46182a76ed2fe3c6dd8", + "configuration": { + "version": 1, + "adapter": "vitest-node", + "book": { + "bookId": "interface", + "revision": 1, + "nodeId": "theme", + "anchorIndex": 0 + }, + "testFile": "src/theme.test.ts", + "testHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "source": { + "path": "src/theme.ts", + "hash": "f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771", + "line": 1, + "column": 1 + }, + "inspections": [{ "path": "nextTheme", "mode": "value", "required": true }], + "additionalStops": [ + { + "path": "src/theme.ts", + "hash": "f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771", + "line": 1, + "column": 1, + "anchorIndex": 0, + "inspections": [ + { "path": "nextTheme", "mode": "value", "required": true } + ] + } + ] + }, + "descriptor": { + "itemId": "repository/interface/theme", + "bookTitle": "Interface", + "nodeTitle": "Theme", + "anchors": [ + { + "path": "src/theme.ts", + "hash": "f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771", + "startLine": 1, + "endLine": 1 + }, + { + "path": "src/mode.ts", + "hash": "f6d4386593d51ec67ad6d6811031238456f33ee12b3fa92a9df833159a355501", + "startLine": 1, + "endLine": 1 + } + ], + "citationContext": { + "kind": "citations", + "hash": "610d563bfd8797cd1e9d513afaac0a3a4ab0f48274d009544bb24d2203c52c07", + "anchors": [ + { + "path": "src/mode.ts", + "hash": "f6d4386593d51ec67ad6d6811031238456f33ee12b3fa92a9df833159a355501", + "startLine": 1, + "endLine": 1 + }, + { + "path": "src/theme.ts", + "hash": "f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771", + "startLine": 1, + "endLine": 1 + } + ] + }, + "sources": [ + { + "path": "src/theme.ts", + "hash": "f117f21caab18af046f2e57a64ff045dd846130ab2e86fbe6c9360cfcc275771", + "text": "const nextTheme = \"dark\";\n", + "lineCount": 2 + }, + { + "path": "src/mode.ts", + "hash": "f6d4386593d51ec67ad6d6811031238456f33ee12b3fa92a9df833159a355501", + "text": "export const mode = \"display\";\n", + "lineCount": 2 + } + ] + }, + "sourceBuild": { + "revision": "test", + "dirty": true, + "sourceHash": "26f1de33979d065ba8d86789de634228e3540fee2f6e5a66eebf93f78d83077d" + } +} diff --git a/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/sealed.json b/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/sealed.json new file mode 100644 index 00000000..1a46d754 --- /dev/null +++ b/extension/src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1/11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222/sealed.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "studyId": "11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222", + "entryCount": 5, + "journalBytes": 1719, + "journalHash": "832fed860d94f9873182ee51bc93d6c145a11632a7464e9237d275d0314dac25", + "status": "complete", + "endedAt": "2026-10-28T03:45:19.749Z" +} diff --git a/extension/src/tests/maskNodeServerRequest.test.ts b/extension/src/tests/maskNodeServerRequest.test.ts new file mode 100644 index 00000000..7747afd4 --- /dev/null +++ b/extension/src/tests/maskNodeServerRequest.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { maskNodeServerRequestBody } from '../utils/maskNodeServerRequestBody' +import { maskSecretQueryValues } from '../utils/maskSecretQueryValues' + +// protects=secret-named values of a declared request never reach a history header; fails_when=a nested JSON field, a form field or a query value keeps its secret; no_claim=a secret in a field with an innocent name +test('a nested JSON password, a form token and a query api key are masked while innocent fields stay', () => { + // Arrange + const json = + '{"user":{"name":"ada","password":"p1"},"items":[{"accessToken":"t1","sku":"a"}]}' + const form = 'name=ada&access_token=t2&page=2' + const path = '/api/users?id=3&api_key=k3&redirect=%2Fhome' + + // Act + const maskedJson = maskNodeServerRequestBody(json, 'application/json') + const maskedForm = maskNodeServerRequestBody( + form, + 'application/x-www-form-urlencoded', + ) + const maskedPath = maskSecretQueryValues(path) + + // Assert + assert.equal( + maskedJson, + '{"user":{"name":"ada","password":"[masked]"},"items":[{"accessToken":"[masked]","sku":"a"}]}', + ) + assert.equal(maskedForm, 'name=ada&access_token=[masked]&page=2') + assert.equal(maskedPath, '/api/users?id=3&api_key=[masked]&redirect=%2Fhome') +}) + +test('a body that does not parse as its content type is masked as plain text', () => { + // Arrange + const body = 'not json Bearer abcdefgh12345' + + // Act + const masked = maskNodeServerRequestBody(body, 'application/json') + + // Assert + assert.equal(masked, 'not json Bearer [masked]') +}) diff --git a/extension/src/tests/nodeHistoryAdmission.test.ts b/extension/src/tests/nodeHistoryAdmission.test.ts new file mode 100644 index 00000000..e10faa83 --- /dev/null +++ b/extension/src/tests/nodeHistoryAdmission.test.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import fsPromises from 'node:fs/promises' +import { before, test, type TestContext } from 'node:test' + +import { SavedStudyStorageFullError } from '../errors' +import { LearningLibrary } from '../learningLibrary' +import { RecordingStore } from '../recordingStore' + +import { createConfiguredStudy } from './utils/createConfiguredStudy' +import { + nodeServerHistoryHeader, + openServerRepository, +} from './utils/nodeServerHistoryFixture' +import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +/** One window: a repository and a library over one learning directory, sharing the saved-study limit, as the workspace wires them. */ +async function openWindow( + context: TestContext, + limit: number, + learningDirectory?: string, +) { + const study = createConfiguredStudy() + // The workspace wires the repository to the library's own count, and the library to the repository's. + let libraryOfWindow: LearningLibrary | undefined + const opened = await openServerRepository(context, { + limit, + workspaceKey: study.workspaceKey, + learningDirectory, + savedStudies: () => libraryOfWindow!.savedStudyCount(), + }) + const library = (libraryOfWindow = new LearningLibrary( + opened.learningDirectory, + study.workspaceKey, + () => limit, + () => 'available', + (places) => opened.repository.countHistories(places), + )) + return { ...opened, library, study } +} + +/** Counts how many of the settled results are fulfilled and how many are refused for a full store. */ +function tally(results: PromiseSettledResult[]) { + return { + won: results.filter((result) => result.status === 'fulfilled').length, + refusedFull: results.filter( + (result) => + result.status === 'rejected' && + (result.reason instanceof SavedStudyStorageFullError || + /storage is full|Saved Study Limit|saved study limit/i.test( + String(result.reason), + )), + ).length, + } +} + +// protects=one saved-study limit counts test and server histories together under the storage lock; fails_when=two windows racing for the last place both succeed in either order; no_claim=the Retry wording the learner reads +for (const order of ['server first', 'test first'] as const) { + test(`with one place left, a server start and a test start from two windows give one success (${order})`, async (context) => { + // Arrange + const first = await openWindow(context, 1) + const second = await openWindow(context, 1, first.learningDirectory) + const serverHeader = nodeServerHistoryHeader(first.study.workspaceKey) + const testHeader = nodeTestHistoryHeader(first.study.workspaceKey) + const startServer = () => + first.repository.serverHistories.start(serverHeader) + const startTest = () => second.repository.testHistories.start(testHeader) + + // Act + const results = await Promise.allSettled( + order === 'server first' + ? [startServer(), startTest()] + : [startTest(), startServer()], + ) + + // Assert + assert.deepEqual(tally(results), { won: 1, refusedFull: 1 }) + }) +} + +// protects=a browser save counts the server histories (and test histories) as saved studies count them; fails_when=a server history and a browser save both take the last place; no_claim=other windows' library caches +test('with one place left, a server history already started refuses a browser save', async (context) => { + // Arrange + const window = await openWindow(context, 1) + await window.repository.serverHistories.start( + nodeServerHistoryHeader(window.study.workspaceKey), + ) + + // Act + const save = window.library.saveStudy(window.study) + + // Assert + await assert.rejects(save, /Saved study storage is full|Saved Study Limit/i) + assert.equal(await window.library.admitsStudy(window.study.id), false) +}) + +test('with one place left, a browser save already made refuses a server start', async (context) => { + // Arrange + const window = await openWindow(context, 1) + await window.library.saveStudy(window.study) + + // Act + const start = window.repository.serverHistories.start( + nodeServerHistoryHeader(window.study.workspaceKey), + ) + + // Assert + await assert.rejects(start, SavedStudyStorageFullError) +}) + +test('with one place left, a browser save and a server start asked at once give one success', async (context) => { + // Arrange + const window = await openWindow(context, 1) + + // Act + const results = await Promise.allSettled([ + window.library.saveStudy(window.study), + window.repository.serverHistories.start( + nodeServerHistoryHeader(window.study.workspaceKey), + ), + ]) + + // Assert + assert.deepEqual(tally(results), { won: 1, refusedFull: 1 }) +}) + +// protects=updating a study the library already keeps is never a new reservation; fails_when=a full store refuses the next checkpoint of a saved study; no_claim=checkpoint content rules +test('a saved study keeps accepting its own checkpoint while server histories fill the limit', async (context) => { + // Arrange + const window = await openWindow(context, 2) + await window.library.saveStudy(window.study) + await window.repository.serverHistories.start( + nodeServerHistoryHeader(window.study.workspaceKey), + ) + + // Act + const resave = window.library.saveStudy(window.study) + + // Assert + await assert.doesNotReject(resave) +}) + +// protects=an unreadable history directory never blocks a browser save, as an unreadable Node history store never did; fails_when=the library's admission throws the directory's read error and the save fails; no_claim=the histories' own start, which still refuses on an unreadable directory +for (const code of ['EACCES', 'EMFILE', 'EBUSY']) { + test(`a browser save on the last free place succeeds while both history directories fail to read with ${code}`, async (context) => { + // Arrange + const window = await openWindow(context, 1) + const readdirBefore = fsPromises.readdir + context.mock.method( + fsPromises, + 'readdir', + async (...input: Parameters) => { + if (/node-(tests|servers)\.v1$/.test(String(input[0]))) + throw Object.assign(new Error(`${code}: cannot read`), { code }) + return readdirBefore(...input) + }, + ) + + // Act + await window.library.saveStudy(window.study) + + // Assert + assert.equal(await window.library.admitsStudy(window.study.id), true) + assert.equal(await window.library.savedStudyCount(), 1) + }) +} + +/** A recording store wired to the repository's history count over the window's directory, as the workspace wires it. */ +function recordingStoreOf( + window: Awaited>, + limit: number, +) { + return new RecordingStore( + window.learningDirectory, + window.study.workspaceKey, + {}, + undefined, + () => limit, + () => 'available', + (places) => window.repository.countHistories(places), + ) +} + +/** A binding for a new run of the window's workspace, distinct from the saved study's id. */ +function newRunBinding(window: Awaited>) { + const { study } = window + const runId = randomUUID() + return { + workspaceKey: study.workspaceKey, + studyId: `${study.instanceId}_${runId}`, + instanceId: study.instanceId, + runId, + runtimeId: randomUUID(), + profile: study.profile, + sourceBuild: { + revision: 'admission-test', + dirty: false, + sourceHash: '1'.repeat(64), + }, + } +} + +// protects=the run-start check and a recording's start count test and server histories as the save does; fails_when=saved studies plus histories fill the limit and a run is still admitted, to be refused at its save; no_claim=the wording of the refusal +for (const kind of ['test', 'server'] as const) { + test(`with a saved study and a ${kind} history filling the limit, a run is not admitted and a recording does not start`, async (context) => { + // Arrange + const window = await openWindow(context, 2) + await window.library.saveStudy(window.study) + const workspaceKey = window.study.workspaceKey + if (kind === 'test') + await window.repository.testHistories.start( + nodeTestHistoryHeader(workspaceKey), + ) + else + await window.repository.serverHistories.start( + nodeServerHistoryHeader(workspaceKey), + ) + const recordings = recordingStoreOf(window, 2) + // The saved study's own recording takes the place the recording library counts. + await ( + await recordings.start( + { + ...newRunBinding(window), + studyId: window.study.id, + runId: window.study.runId, + }, + 0, + ) + ).finish(1) + const binding = newRunBinding(window) + + // Act + const admitted = await recordings.admitsStudy(binding.studyId) + const start = recordings.start(binding, 0) + + // Assert + assert.equal(admitted, false) + await assert.rejects(start, /full|limit/i) + }) + + test(`with one place free beside a ${kind} history, a run is admitted and a recording starts`, async (context) => { + // Arrange + const window = await openWindow(context, 2) + const workspaceKey = window.study.workspaceKey + if (kind === 'test') + await window.repository.testHistories.start( + nodeTestHistoryHeader(workspaceKey), + ) + else + await window.repository.serverHistories.start( + nodeServerHistoryHeader(workspaceKey), + ) + const recordings = recordingStoreOf(window, 2) + const binding = newRunBinding(window) + + // Act + const admitted = await recordings.admitsStudy(binding.studyId) + const writer = await recordings.start(binding, 0) + + // Assert + assert.equal(admitted, true) + assert.equal(writer.header.studyId, binding.studyId) + }) +} diff --git a/extension/src/tests/nodeHistorySealCoordinator.test.ts b/extension/src/tests/nodeHistorySealCoordinator.test.ts new file mode 100644 index 00000000..25673117 --- /dev/null +++ b/extension/src/tests/nodeHistorySealCoordinator.test.ts @@ -0,0 +1,437 @@ +import assert from 'node:assert/strict' +import fsPromises, { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { before, test, type TestContext } from 'node:test' + +import { NodeTestHistoryStore } from '../nodeTestHistoryStore' +import { sha256 } from '../runtimeSupport/utils/sha256' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +/** Starts a test history and returns its writer, store and folder. */ +async function startTestHistory(context: TestContext) { + const learningDirectory = await mkdtemp(join(tmpdir(), 'codebook-seal-')) + context.after(() => rm(learningDirectory, { recursive: true, force: true })) + const store = new NodeTestHistoryStore( + learningDirectory, + sha256('node-history-test-workspace'), + ) + const header = nodeTestHistoryHeader(store.workspaceKey) + const writer = await store.start(header) + return { + writer, + store, + header, + folder: join(learningDirectory, 'node-tests.v1', header.studyId), + } +} + +/** Holds the `sync` of every handle opened on a path ending `suffix` until `release` is called; other files are untouched. */ +function holdSyncOf(context: TestContext, suffix: string) { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let reportHeld!: () => void + const held = new Promise((resolve) => { + reportHeld = resolve + }) + const openBefore = fsPromises.open + context.mock.method( + fsPromises, + 'open', + async (...input: Parameters) => { + const handle = await openBefore(...input) + if (!String(input[0]).endsWith(suffix)) return handle + const syncBefore = handle.sync.bind(handle) + handle.sync = async (): Promise => { + reportHeld() + await gate + return syncBefore() + } + return handle + }, + ) + return { held, release } +} + +// protects=an entry accepted before the seal began is written even when the seal is requested in the same synchronous step; fails_when=the queued entry is refused with "already sealing" after the seal call set closing; no_claim=entries offered after the seal began +test('a test history keeps an output appended in the same synchronous turn as its finish', async (context) => { + // Arrange + const { writer, store, header } = await startTestHistory(context) + + // Act + const accepted = writer.appendOutput({ + sequence: 1, + elapsedMs: 5, + channel: 'stdout', + text: 'kept\n', + }) + const sealed = writer.finish('partial', 'Cancelled by the learner.') + await Promise.all([accepted, sealed]) + const opened = await store.read(header.studyId) + + // Assert + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['output'], + ) + assert.equal(opened.status, 'partial') +}) + +// protects=the same drain rule for a server history through its seal; fails_when=the accepted output is lost or the end is written before it; no_claim=timing of fsync +test('a server history keeps an output and a request appended in the same synchronous turn as its seal and writes end after them', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + + // Act + const output = writer.appendOutput({ + elapsedMs: 1, + channel: 'stdout', + text: 'kept\n', + }) + const request = writer.appendRequest({ + elapsedMs: 2, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + const sealed = writer.seal({ elapsedMs: 3, reason: 'stopped' }) + await Promise.all([output, request, sealed]) + const opened = await repository.serverHistories.read(header.studyId) + + // Assert + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['output', 'request', 'end'], + ) +}) + +// protects=an entry offered after the seal began is refused at the call; fails_when=a late entry slips behind the seal; no_claim=which message the learner reads +test('an entry offered after the seal began is refused, and the sealed journal stays as it was', async (context) => { + // Arrange + const { writer, store, header } = await startTestHistory(context) + await writer.appendOutput({ + sequence: 1, + elapsedMs: 1, + channel: 'stdout', + text: 'a\n', + }) + const sealed = writer.finish('partial', 'Stopped.') + + // Act + const lateOutcome = writer + .appendOutput({ + sequence: 2, + elapsedMs: 2, + channel: 'stdout', + text: 'late\n', + }) + .then( + () => 'accepted', + (error: Error) => error.message, + ) + await sealed + + // Assert + assert.match(await lateOutcome, /already sealing or sealed/) + assert.equal((await store.read(header.studyId)).entries.length, 1) +}) + +// protects=a finish that arrives during an fsync waits for that entry and seals it; fails_when=the seal reads the journal before the in-flight entry is durable; no_claim=power-loss durability +test('a finish called while an entry is being fsynced seals with that entry in the journal', async (context) => { + // Arrange + const { writer, store, header } = await startTestHistory(context) + const hold = holdSyncOf(context, 'events.jsonl') + const accepted = writer.appendOutput({ + sequence: 1, + elapsedMs: 5, + channel: 'stdout', + text: 'in flight\n', + }) + await hold.held + + // Act + const sealed = writer.finish('partial', 'Stopped.') + hold.release() + await Promise.all([accepted, sealed]) + context.mock.restoreAll() + const opened = await store.read(header.studyId) + + // Assert + assert.equal(opened.entries.length, 1) + assert.equal(opened.status, 'partial') +}) + +// protects=a timed-out seal that finishes its I/O later publishes nothing; fails_when=sealed.json appears after the folder was reported interrupted; no_claim=the status line the service words +test('a seal that times out and finishes its I/O afterwards leaves no sealed.json and the folder reads interrupted', async (context) => { + // Arrange + const { writer, store, header, folder } = await startTestHistory(context) + await writer.appendOutput({ + sequence: 1, + elapsedMs: 1, + channel: 'stdout', + text: 'a\n', + }) + const hold = holdSyncOf(context, '.tmp') + const sealed = writer.finish('partial', 'Stopped.') + await hold.held + + // Act + writer.abandon() + hold.release() + const outcome = await sealed.then( + () => 'published', + (error: Error) => error.message, + ) + context.mock.restoreAll() + const opened = await store.read(header.studyId) + + // Assert + assert.match(outcome, /abandoned before it was published/) + assert.equal((await readdir(folder)).includes('sealed.json'), false) + assert.equal(opened.status, 'interrupted') +}) + +// protects=Save, Stop and a self-exit that all ask for the seal at once give one seal; fails_when=two sealed.json writes or different hashes; no_claim=the service's own coordinator +test('three seals requested in one turn publish one sealed.json and return one hash', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + const renameBefore = fsPromises.rename + const publishedSeals: string[] = [] + context.mock.method( + fsPromises, + 'rename', + async (...input: Parameters) => { + if (String(input[1]).endsWith('sealed.json')) + publishedSeals.push(String(input[1])) + return renameBefore(...input) + }, + ) + + // Act + const hashes = await Promise.all([ + writer.seal({ elapsedMs: 2, reason: 'stopped' }), + writer.seal({ elapsedMs: 3, reason: 'closed', exitCode: 0 }), + writer.seal({ elapsedMs: 4, reason: 'failed', code: 'RUNTIME_FAILED' }), + ]) + + // Assert + assert.equal(new Set(hashes).size, 1) + assert.equal(publishedSeals.length, 1) + const opened = await repository.serverHistories.read(header.studyId) + assert.equal(opened.entries.filter((entry) => entry.type === 'end').length, 1) +}) + +/** Writes the request, its sent mark, one stop hit and the response of a one-stop server run: everything but its seal. */ +async function writeWholeServerRun( + writer: Awaited>['writer'], +): Promise { + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 3 }), + ) + await writer.appendResponse({ + elapsedMs: 4, + status: 200, + contentType: null, + body: '', + bodyBytes: 0, + readCut: false, + location: null, + requestDurationMs: 1, + }) +} + +/** Starts a server history declaring one stop and returns its writer and store. */ +async function startServerRun(context: TestContext) { + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + return { writer, store: repository.serverHistories, header } +} + +// protects=a cleanly finished run seals complete although its output keeps arriving while the seal drains; fails_when=an output offered after the seal began is queued behind end, refused, and the run seals partial with SERVER_HISTORY_APPEND_FAILED; no_claim=entries accepted before the seal began (their own test) +test('output offered after the seal began, in the same turn and in a later one, is not written and the run still seals complete', async (context) => { + // Arrange + const { writer, store, header } = await startServerRun(context) + await writeWholeServerRun(writer) + const hold = holdSyncOf(context, 'events.jsonl') + + // Act + const sealed = writer.seal({ elapsedMs: 5, reason: 'stopped' }) + const sameTurn = writer.appendOutput({ + elapsedMs: 6, + channel: 'stdout', + text: 'same turn\n', + }) + await hold.held + const laterTurn = writer.appendOutput({ + elapsedMs: 7, + channel: 'stdout', + text: 'later turn\n', + }) + hold.release() + await Promise.all([sealed, sameTurn, laterTurn]) + context.mock.restoreAll() + const opened = await store.read(header.studyId) + + // Assert + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['request', 'request-sent', 'source', 'stop', 'response', 'end'], + ) + assert.equal(opened.status, 'complete') + assert.deepEqual(opened.seal?.reasons, []) +}) + +// protects=a retried seal after a failed sealed.json write writes end once and seals complete; fails_when=the retry appends a second end, which is refused and recorded as SERVER_HISTORY_APPEND_FAILED; no_claim=which write failed +test('a seal retried after its sealed.json write failed keeps one end and seals complete', async (context) => { + // Arrange + const { writer, store, header } = await startServerRun(context) + await writeWholeServerRun(writer) + const renameBefore = fsPromises.rename + let failed = false + context.mock.method( + fsPromises, + 'rename', + async (...input: Parameters) => { + if (!failed && String(input[1]).endsWith('sealed.json')) { + failed = true + throw new Error('rename refused once') + } + return renameBefore(...input) + }, + ) + await assert.rejects( + writer.seal({ elapsedMs: 5, reason: 'stopped' }), + /rename refused once/, + ) + + // Act + await writer.seal({ elapsedMs: 6, reason: 'stopped' }) + context.mock.restoreAll() + const opened = await store.read(header.studyId) + + // Assert + assert.equal(opened.entries.filter((entry) => entry.type === 'end').length, 1) + assert.equal(opened.status, 'complete') + assert.deepEqual(opened.seal?.reasons, []) +}) + +// protects=the inherited finish never publishes a server seal that the read path refuses; fails_when=finish seals complete without end and the history then reads corrupt; no_claim=the status finish is asked for +test('finish on a server history writes end first, so the sealed history reads back', async (context) => { + // Arrange + const { writer, store, header } = await startServerRun(context) + await writeWholeServerRun(writer) + + // Act + await writer.finish('complete') + const opened = await store.read(header.studyId) + + // Assert + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['request', 'request-sent', 'source', 'stop', 'response', 'end'], + ) + assert.equal(opened.status, 'complete') +}) + +// protects=an entry refused or failing after the rules checked it leaves the rules' progress unchanged; fails_when=a request whose write failed still counts as the history's request, so the history is kept instead of removed at its seal; no_claim=the disk error's wording +test('a request whose write failed leaves nothing to replay, so the history is removed at its seal', async (context) => { + // Arrange + const { writer, store, header } = await startServerRun(context) + const openBefore = fsPromises.open + context.mock.method( + fsPromises, + 'open', + async (...input: Parameters) => { + if (String(input[0]).endsWith('events.jsonl')) + throw new Error('disk full') + return openBefore(...input) + }, + ) + await assert.rejects( + writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }), + /disk full/, + ) + context.mock.restoreAll() + + // Act + await writer.seal({ elapsedMs: 2, reason: 'stopped' }) + + // Assert + await assert.rejects(store.read(header.studyId), /ENOENT/) +}) + +// protects=an abandon that lands while sealed.json is being published leaves no sealed.json, so the folder lists interrupted as reported; fails_when=the seal published after the abandon check and the file stays; no_claim=a rename that fails +test('a seal abandoned while its sealed.json is being published removes it and the folder reads interrupted', async (context) => { + // Arrange + const { writer, store, header, folder } = await startTestHistory(context) + await writer.appendOutput({ + sequence: 1, + elapsedMs: 1, + channel: 'stdout', + text: 'a\n', + }) + const renameBefore = fsPromises.rename + context.mock.method( + fsPromises, + 'rename', + async (...input: Parameters) => { + // The abandon lands while the publish is in flight. + if (String(input[1]).endsWith('sealed.json')) writer.abandon() + return renameBefore(...input) + }, + ) + + // Act + const outcome = await writer.finish('partial', 'Stopped.').then( + () => 'published', + (error: Error) => error.message, + ) + context.mock.restoreAll() + const opened = await store.read(header.studyId) + + // Assert + assert.match(outcome, /abandoned/) + assert.equal((await readdir(folder)).includes('sealed.json'), false) + assert.equal(opened.status, 'interrupted') +}) diff --git a/extension/src/tests/nodeServerHistoryBounds.test.ts b/extension/src/tests/nodeServerHistoryBounds.test.ts new file mode 100644 index 00000000..bfc3f9e2 --- /dev/null +++ b/extension/src/tests/nodeServerHistoryBounds.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import { + NODE_SERVER_OUTPUT_BUDGET_ENTRIES, + NODE_SERVER_WRITER_QUEUE_LIMIT_ENTRIES, +} from '../constants' +import type { NodeServerHistoryWriter } from '../nodeServerHistoryWriter' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +/** Starts a one-stop server history and opens its request, so structural entries can follow. */ +async function startOpenRequest( + context: Parameters[0], +) { + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + return { writer, store: repository.serverHistories, header } +} + +/** Appends `count` one-line output chunks, waiting for each batch so the writer queue never holds more than a batch. */ +async function appendOutputLines( + writer: NodeServerHistoryWriter, + count: number, + batch = 200, +): Promise { + for (let done = 0; done < count; done += batch) { + const size = Math.min(batch, count - done) + await Promise.all( + Array.from({ length: size }, (_, index) => + writer.appendOutput({ + elapsedMs: 3 + (done + index) / 1000, + channel: 'stdout', + text: `line ${done + index}\n`, + }), + ), + ) + } +} + +// protects=output has its own budget so a chatty server cannot crowd out the stops and the answer; fails_when=output past 2,000 entries is written or the structural entries are refused; no_claim=the 2 MiB byte budget +test('output past 2,000 entries is dropped with one output-truncated entry while the stop and the response are still written', async (context) => { + // Arrange + const { writer, store, header } = await startOpenRequest(context) + + // Act + await appendOutputLines(writer, NODE_SERVER_OUTPUT_BUDGET_ENTRIES + 50) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 10 }), + ) + await writer.appendResponse({ + elapsedMs: 11, + status: 200, + contentType: null, + body: '', + bodyBytes: 0, + readCut: false, + location: null, + requestDurationMs: 9, + }) + await writer.seal({ elapsedMs: 12, reason: 'stopped' }) + const opened = await store.read(header.studyId) + const types = opened.entries.map((entry) => entry.type) + + // Assert + assert.equal(types.filter((type) => type === 'output').length, 2_000) + assert.equal(types.filter((type) => type === 'output-truncated').length, 1) + assert.equal(types.includes('stop'), true) + assert.equal(types.includes('response'), true) + assert.equal(opened.status, 'partial') + assert.equal( + opened.seal?.reasons.some( + (reason) => reason.code === 'SERVER_HISTORY_OUTPUT_TRUNCATED', + ), + true, + ) +}) + +// protects=output past 2 MiB is dropped the same way; fails_when=large chunks fill the journal; no_claim=exact byte counts +test('output past its 2 MiB byte budget is dropped with one output-truncated entry', async (context) => { + // Arrange + const { writer, store, header } = await startOpenRequest(context) + const chunk = 'y'.repeat(4_000) + + // Act + for (let index = 0; index < 560; index++) + await writer.appendOutput({ + elapsedMs: 3 + index / 1000, + channel: 'stdout', + text: chunk, + }) + await writer.seal({ elapsedMs: 10, reason: 'stopped' }) + const opened = await store.read(header.studyId) + + // Assert + const outputs = opened.entries.filter( + (entry) => entry.type === 'output', + ).length + assert.equal(outputs < 560 && outputs > 400, true) + const truncation = opened.entries.find( + (entry) => entry.type === 'output-truncated', + ) + assert.equal( + truncation?.type === 'output-truncated' ? truncation.limit : '', + 'bytes', + ) +}) + +// protects=the writer queue drops output only when it holds 1,000 entries pending; fails_when=a flood of same-turn output queues without bound or drops a structural entry; no_claim=slow-disk timing +test('output offered in one turn past the 1,000-entry queue is dropped under the same code and the structural entries still wait their turn', async (context) => { + // Arrange + const { writer, store, header } = await startOpenRequest(context) + + // Act + const flood = Array.from( + { length: NODE_SERVER_WRITER_QUEUE_LIMIT_ENTRIES + 500 }, + (_, index) => + writer.appendOutput({ + elapsedMs: 3 + index / 10_000, + channel: 'stdout', + text: `n${index}\n`, + }), + ) + const structural = writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 9 }), + ) + await Promise.all([...flood, structural]) + await writer.seal({ elapsedMs: 10, reason: 'stopped' }) + const opened = await store.read(header.studyId) + + // Assert + const types = opened.entries.map((entry) => entry.type) + assert.equal(types.filter((type) => type === 'output').length < 1_100, true) + assert.equal(types.filter((type) => type === 'output-truncated').length, 1) + const truncation = opened.entries.find( + (entry) => entry.type === 'output-truncated', + ) + assert.equal( + truncation?.type === 'output-truncated' ? truncation.limit : '', + 'queue', + ) + assert.equal(types.includes('stop'), true) +}) diff --git a/extension/src/tests/nodeServerHistoryJournalIntegrity.test.ts b/extension/src/tests/nodeServerHistoryJournalIntegrity.test.ts new file mode 100644 index 00000000..e77bc2fc --- /dev/null +++ b/extension/src/tests/nodeServerHistoryJournalIntegrity.test.ts @@ -0,0 +1,372 @@ +import assert from 'node:assert/strict' +import fsPromises, { appendFile, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { before, test, type TestContext } from 'node:test' + +import type { NodeServerHistoryEntryInput } from '../nodeServerHistorySchemas' +import { nodeServerStorageDescriptor } from '../nodeServerStorageDescriptor' +import { advanceHistoryScan } from '../utils/recordingJournalScan' + +import { + nodeServerHistoryHeader, + openServerRepository, + serverStopCapture, + SERVER_WORKSPACE_KEY, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +const request: NodeServerHistoryEntryInput = { + type: 'request', + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, +} +const sent: NodeServerHistoryEntryInput = { + type: 'request-sent', + elapsedMs: 2, + attempt: 1, +} + +/** A source entry and a stop of the fixture handler, by declared stop and hit. */ +function sourceAndStop( + hit: number, + elapsedMs: number, +): NodeServerHistoryEntryInput[] { + const capture = serverStopCapture({ + declaredStopIndex: 0, + hit, + line: 2, + elapsedMs, + }) + const [top] = capture.snapshot.frames + const stop: NodeServerHistoryEntryInput = { + type: 'stop', + elapsedMs, + declaredStopIndex: 0, + hit, + generation: hit, + source: capture.source, + frame: { name: 'handler' }, + facts: capture.facts, + unavailable: [], + debuggerSnapshot: { + ...capture.snapshot, + frames: [{ ...top!, source: { ...top!.source!, text: '' } }], + }, + } + const source: NodeServerHistoryEntryInput = { + type: 'source', + elapsedMs, + path: capture.source.path, + hash: capture.source.hash, + text: top!.source!.text, + } + return hit === 1 ? [source, stop] : [stop] +} + +const response: NodeServerHistoryEntryInput = { + type: 'response', + elapsedMs: 30, + status: 200, + contentType: null, + body: '', + bodyBytes: 0, + readCut: false, + location: null, + requestDurationMs: 5, +} +const end: NodeServerHistoryEntryInput = { + type: 'end', + elapsedMs: 40, + reason: 'stopped', +} + +/** Each journal with the number of entries every reader must accept before it refuses the rest. */ +const JOURNALS: Array<{ + name: string + entries: NodeServerHistoryEntryInput[] + accepted: number +}> = [ + { + name: 'a whole run', + entries: [request, sent, ...sourceAndStop(1, 10), response, end], + accepted: 6, + }, + { + name: 'a stop before the request', + entries: [...sourceAndStop(1, 1), request], + accepted: 1, + }, + { + name: 'a response before its request was sent', + entries: [request, response], + accepted: 1, + }, + { + name: 'a stop whose source entry is missing', + entries: [request, sent, sourceAndStop(1, 10)[1]!], + accepted: 2, + }, + { + name: 'time moving back', + entries: [request, { ...sent, elapsedMs: 0.5 }], + accepted: 1, + }, + { + name: 'an entry after the end', + entries: [ + request, + end, + { type: 'output', elapsedMs: 50, channel: 'stdout', text: 'late' }, + ], + accepted: 2, + }, + { + name: 'a repeated source entry', + entries: [request, sent, ...sourceAndStop(1, 10), sourceAndStop(1, 11)[0]!], + accepted: 4, + }, + // Value: protects=a withdrawn unsent attempt lets the run try again and every reader accepts the retry; fails_when=a reader refuses attempt 2 after a withdrawal; why_new=no test offers request-withdrawn; seam=none; no_claim=none beyond protects + { + name: 'a withdrawn attempt followed by a second attempt', + entries: [ + request, + { type: 'request-withdrawn', elapsedMs: 2, attempt: 1, code: 'RUN_BUSY' }, + { ...request, elapsedMs: 3, attempt: 2 }, + { ...sent, elapsedMs: 4, attempt: 2 }, + response, + end, + ], + accepted: 6, + }, + // Value: protects=an attempt whose bytes left cannot be withdrawn; fails_when=a reader accepts a withdrawal after request-sent; why_new=no test offers request-withdrawn; seam=none; no_claim=none beyond protects + { + name: 'a withdrawal of an attempt already sent', + entries: [ + request, + sent, + { type: 'request-withdrawn', elapsedMs: 3, attempt: 1, code: 'RUN_BUSY' }, + ], + accepted: 2, + }, + // Value: protects=a request failure needs an opened request; fails_when=a reader accepts request-failed as the first entry; why_new=the failure is only written after a request in other tests; seam=none; no_claim=none beyond protects + { + name: 'a request failure before any request', + entries: [ + { type: 'request-failed', elapsedMs: 1, code: 'X', message: 'no' }, + ], + accepted: 0, + }, + // Value: protects=a terminal entry closes the request for good; fails_when=a reader accepts a new attempt after the response; why_new=only two terminals are covered, not a new request; seam=none; no_claim=none beyond protects + { + name: 'a new request after the response', + entries: [ + request, + sent, + response, + { ...request, elapsedMs: 31, attempt: 2 }, + ], + accepted: 3, + }, + // Value: protects=no stop is accepted once the response closed the request; fails_when=a reader accepts a stop after a terminal entry; why_new=stops are only refused before the request in other rows; seam=none; no_claim=none beyond protects + { + name: 'a stop after the response', + entries: [request, sent, response, ...sourceAndStop(1, 35)], + accepted: 4, + }, + // Value: protects=output after output-truncated and a second truncation are refused; fails_when=a reader accepts either; why_new=bounds tests only drive the writer's drop, not the shared rule; seam=none; no_claim=none beyond protects + { + name: 'output after its truncation', + entries: [ + request, + { type: 'output-truncated', elapsedMs: 2, limit: 'entries' }, + { type: 'output', elapsedMs: 3, channel: 'stdout', text: 'late' }, + ], + accepted: 2, + }, + { + name: 'a second output truncation', + entries: [ + { type: 'output-truncated', elapsedMs: 1, limit: 'bytes' }, + { type: 'output-truncated', elapsedMs: 2, limit: 'bytes' }, + ], + accepted: 1, + }, +] + +/** Runs one journal through the writer, the incremental scan and the full read, each on its own folder. */ +async function acceptedByEachReader( + context: TestContext, + entries: NodeServerHistoryEntryInput[], +) { + const { repository, learningDirectory } = await openServerRepository(context) + const store = repository.serverHistories + // The writer: entries offered one after another; the first refusal ends the timeline. + const writerHeader = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await store.start(writerHeader) + let throughWriter = 0 + for (const entry of entries) { + const accepted = await writer.appendEntry(entry).then( + () => true, + () => false, + ) + if (!accepted) break + throughWriter++ + } + writer.abandon() + // The scan and the read: the same journal as lines of a folder no writer holds. + const folderHeader = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const folderWriter = await store.start(folderHeader) + folderWriter.abandon() + const journalPath = join( + learningDirectory, + 'node-servers.v1', + folderHeader.studyId, + 'events.jsonl', + ) + const lines = entries.map((entry, index) => + JSON.stringify({ ...entry, sequence: index + 1 }), + ) + await writeFile(journalPath, `${lines.join('\n')}\n`) + const scan = await advanceHistoryScan( + undefined, + nodeServerStorageDescriptor.parseHeader({ + ...folderHeader, + formatVersion: 1, + }), + 'signal', + journalPath, + String, + nodeServerStorageDescriptor.scanRules, + 'Interrupted:', + ) + const read = await store.read(folderHeader.studyId) + return { + throughWriter, + throughScan: scan.progress.entryCount, + throughRead: read.entries.length, + } +} + +for (const journal of JOURNALS) { + // protects=the writer, the incremental scan and the full read accept the same journals; fails_when=any of the three accepts an entry another refuses; no_claim=the wording of the refusal + test(`the writer, the scan and the read accept the same ${journal.accepted} entries of ${journal.name}`, async (context) => { + // Arrange + const expected = { + throughWriter: journal.accepted, + throughScan: journal.accepted, + throughRead: journal.accepted, + } + + // Act + const accepted = await acceptedByEachReader(context, journal.entries) + + // Assert + assert.deepEqual(accepted, expected) + }) +} + +// protects=a torn last line is cut at the verified prefix of an unsealed history; fails_when=a half-written line fails the open or its bytes are read as an entry; no_claim=sealed histories +test('an unsealed server history with a torn last line opens its verified prefix as interrupted', async (context) => { + // Arrange + const { repository, learningDirectory } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendEntry(request) + await writer.appendEntry(sent) + writer.abandon() + await appendFile( + join(learningDirectory, 'node-servers.v1', header.studyId, 'events.jsonl'), + '{"type":"output","sequence":3,"elapsedMs":3,"chan', + ) + + // Act + const opened = await repository.serverHistories.read(header.studyId) + + // Assert + assert.equal(opened.entries.length, 2) + assert.equal(opened.status, 'interrupted') +}) + +// protects=a sealed history whose journal changed stays refused and is never shown as interrupted; fails_when=an edited sealed journal opens as a prefix; no_claim=unsealed histories +test('a sealed server history whose journal was edited stays refused and its summary reads unavailable, not interrupted', async (context) => { + // Arrange + const { repository, learningDirectory } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendEntry(request) + await writer.seal({ elapsedMs: 5, reason: 'stopped' }) + const journalPath = join( + learningDirectory, + 'node-servers.v1', + header.studyId, + 'events.jsonl', + ) + await writeFile( + journalPath, + (await readFile(journalPath, 'utf8')).replace('/x', '/y'), + ) + + // Act + const reading = assert.rejects( + repository.serverHistories.read(header.studyId), + /Sealed Node history journal is corrupt/, + ) + const summary = await repository.serverHistories.summarize(header.studyId) + + // Assert + await reading + assert.equal(summary?.verification, 'unavailable') + assert.equal(summary?.status, 'partial') + assert.match(summary?.reason ?? '', /integrity check failed/i) +}) + +// protects=a history whose append failed has no end and reads as a valid partial with the append reason; fails_when=it reads as corrupt or complete; no_claim=which disk error +test('a server history whose append failed seals partial without an end and names the entry before the gap', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendEntry(request) + const openBefore = fsPromises.open + let failJournal = true + context.mock.method( + fsPromises, + 'open', + async (...input: Parameters) => { + if (failJournal && String(input[0]).endsWith('events.jsonl')) + throw Object.assign(new Error('EIO: disk gone'), { code: 'EIO' }) + return openBefore(...input) + }, + ) + const refused = writer + .appendEntry(sent) + .catch((error: Error) => error.message) + await refused + failJournal = false + + // Act + await writer.seal({ elapsedMs: 9, reason: 'stopped' }) + context.mock.restoreAll() + const opened = await repository.serverHistories.read(header.studyId) + + // Assert + assert.equal(opened.status, 'partial') + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['request'], + ) + const reason = opened.seal?.reasons.find( + (candidate) => candidate.code === 'SERVER_HISTORY_APPEND_FAILED', + ) + assert.match( + reason?.text ?? '', + /could not be written after the request \(entry 1\)/, + ) +}) diff --git a/extension/src/tests/nodeServerHistorySeal.test.ts b/extension/src/tests/nodeServerHistorySeal.test.ts new file mode 100644 index 00000000..360aa282 --- /dev/null +++ b/extension/src/tests/nodeServerHistorySeal.test.ts @@ -0,0 +1,758 @@ +import assert from 'node:assert/strict' +import fsPromises from 'node:fs/promises' +import { before, test, type TestContext } from 'node:test' + +import { NodeHistoryRepository } from '../nodeHistoryRepository' +import type { + NodeServerHistoryEntry, + NodeServerHistoryHeader, +} from '../nodeServerHistorySchemas' +import type { + NodeServerEndInput, + NodeServerStopCapture, +} from '../nodeServerHistoryWriter' +import { capMissingList } from '../utils/capMissingList' +import { evaluateNodeServerCapture } from '../utils/evaluateNodeServerCapture' +import { evaluateNodeServerLearning } from '../utils/evaluateNodeServerLearning' + +import { + HANDLER_SOURCE_TEXT, + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +type Terminal = + | { kind: 'response'; status: number } + | { kind: 'request-failed'; code: string } + | { kind: 'none' } + +/** Writes a whole server run to a store and seals it; returns what a reader sees. */ +async function sealedServerRun( + context: TestContext, + options: { + declaredStops?: number + stops?: NodeServerStopCapture[] + terminal?: Terminal + end?: NodeServerEndInput + budgetExhausted?: boolean + }, +) { + const { repository, learningDirectory } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + declaredStops: options.declaredStops ?? 1, + }) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + for (const stop of options.stops ?? []) await writer.appendStop(stop) + const terminal = options.terminal ?? { kind: 'response', status: 200 } + if (terminal.kind === 'response') + await writer.appendResponse({ + elapsedMs: 30, + status: terminal.status, + contentType: 'application/json', + body: '{}', + bodyBytes: 2, + readCut: false, + location: null, + requestDurationMs: 5, + }) + if (terminal.kind === 'request-failed') + await writer.appendRequestFailed({ + elapsedMs: 30, + code: terminal.code, + message: 'Refused.', + }) + if (options.budgetExhausted) writer.markCaptureBudgetExhausted() + await writer.seal(options.end ?? { elapsedMs: 40, reason: 'stopped' }) + const opened = await repository.serverHistories.read(header.studyId) + return { repository, learningDirectory, header, opened } +} + +const oneStop = [ + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 10 }), +] + +/** The `[code, text]` pairs of a sealed history's reasons. */ +function reasonsOf(opened: { + seal?: { reasons: Array<{ code: string; text: string }> } +}) { + return (opened.seal?.reasons ?? []).map((reason) => [ + reason.code, + reason.text, + ]) +} + +// protects=a recorded 4xx or 5xx answer is a complete capture of what happened; fails_when=a status makes the seal partial; no_claim=the replay never words a status as success +for (const status of [200, 404, 500]) { + test(`a server run with a ${status} response and every declared stop seals complete`, async (context) => { + // Arrange + const options = { + stops: oneStop, + terminal: { kind: 'response', status } as const, + } + + // Act + const { opened } = await sealedServerRun(context, options) + + // Assert + assert.equal(opened.status, 'complete') + assert.deepEqual(opened.seal?.reasons, []) + assert.equal(opened.seal?.capture, 'complete') + }) +} + +// protects=each partial reason of the plan's table, in its words and codes; fails_when=a reason is missing, misworded or recoded; no_claim=the size-limit and append reasons (their own tests) +const REASON_CASES: Array<{ + name: string + options: Parameters[1] + expected: string[][] +}> = [ + { + name: 'a failed request', + options: { + stops: oneStop, + terminal: { kind: 'request-failed', code: 'ECONNRESET' }, + }, + expected: [ + [ + 'SERVER_HISTORY_NO_RESPONSE_REQUEST_FAILED', + 'No response was recorded: the request failed with ECONNRESET.', + ], + ], + }, + { + name: 'a run stopped before the answer', + options: { stops: oneStop, terminal: { kind: 'none' } }, + expected: [ + [ + 'SERVER_HISTORY_NO_RESPONSE_STOPPED', + 'No response was recorded: the run was stopped before the server answered.', + ], + ], + }, + { + name: 'a server that exited with a code', + options: { + stops: oneStop, + terminal: { kind: 'none' }, + end: { elapsedMs: 40, reason: 'closed', exitCode: 1 }, + }, + expected: [ + [ + 'SERVER_HISTORY_NO_RESPONSE_EXITED', + 'No response was recorded: the server exited (exit code 1) before it answered.', + ], + ], + }, + { + name: 'a server that exited by a signal', + options: { + stops: oneStop, + terminal: { kind: 'none' }, + end: { elapsedMs: 40, reason: 'closed', signal: 'SIGTERM' }, + }, + expected: [ + [ + 'SERVER_HISTORY_NO_RESPONSE_EXITED', + 'No response was recorded: the server exited (signal SIGTERM) before it answered.', + ], + ], + }, + { + name: 'a run that failed', + options: { + stops: oneStop, + terminal: { kind: 'none' }, + end: { elapsedMs: 40, reason: 'failed', code: 'PORT_IN_USE' }, + }, + expected: [ + [ + 'SERVER_HISTORY_NO_RESPONSE_RUN_FAILED', + 'No response was recorded: the run failed with PORT_IN_USE.', + ], + ], + }, + { + name: 'a declared stop that was never reached', + options: { declaredStops: 2, stops: oneStop }, + expected: [ + ['SERVER_HISTORY_STOP_NOT_REACHED', 'Declared stop 2 was not reached.'], + ], + }, + { + name: 'an unreadable value', + options: { + stops: [ + serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + unavailable: [{ path: 'req.user', reason: 'not readable' }], + }), + ], + }, + expected: [ + [ + 'SERVER_HISTORY_VALUE_UNAVAILABLE', + 'req.user at stop 1 could not be read.', + ], + ], + }, + { + name: 'a spent capture budget', + options: { stops: oneStop, budgetExhausted: true }, + expected: [ + [ + 'SERVER_HISTORY_CAPTURE_BUDGET_EXHAUSTED', + 'The capture budget (16 stop hits) was used up; later hits were not recorded.', + ], + ], + }, +] + +for (const reasonCase of REASON_CASES) { + test(`a server run with ${reasonCase.name} seals partial with its reason in the plan's words`, async (context) => { + // Arrange + const options = reasonCase.options + + // Act + const { opened } = await sealedServerRun(context, options) + + // Assert + assert.equal(opened.status, 'partial') + assert.deepEqual(reasonsOf(opened), reasonCase.expected) + assert.equal(opened.seal?.capture, 'incomplete') + }) +} + +// protects=reasons come in the table's order, the three "No response" rows are exclusive, and the first reason is the one a row shows; fails_when=order or exclusivity changes; no_claim=library wording +test('a run stopped after stop 1 of 2 seals with no response first and the unreached stop second', async (context) => { + // Arrange + const options = { + declaredStops: 2, + stops: oneStop, + terminal: { kind: 'none' }, + } as const + + // Act + const { opened } = await sealedServerRun(context, options) + + // Assert + assert.deepEqual(reasonsOf(opened), [ + [ + 'SERVER_HISTORY_NO_RESPONSE_STOPPED', + 'No response was recorded: the run was stopped before the server answered.', + ], + ['SERVER_HISTORY_STOP_NOT_REACHED', 'Declared stop 2 was not reached.'], + ]) + assert.equal( + opened.seal?.reason, + 'No response was recorded: the run was stopped before the server answered.', + ) +}) + +// protects=size-limit and append-failure reasons the writer alone knows; fails_when=they are dropped from the evaluator; no_claim=how the writer notices them +test('the evaluator words the size-limit and append-failure reasons the writer reports', () => { + // Arrange + const header = nodeServerHistoryHeader( + SERVER_WORKSPACE_KEY, + ) as NodeServerHistoryHeader + const entries: NodeServerHistoryEntry[] = [] + + // Act + const reasons = evaluateNodeServerCapture(header, entries, { + appendFailure: { lastEntry: 'stop 2 (entry 14)', cause: 'EIO' }, + sizeLimitReached: true, + }) + + // Assert + assert.deepEqual( + reasons.map((reason) => [reason.code, reason.text]), + [ + ['SERVER_HISTORY_STOP_NOT_REACHED', 'Declared stop 1 was not reached.'], + [ + 'SERVER_HISTORY_APPEND_FAILED', + 'The history could not be written after stop 2 (entry 14): EIO.', + ], + [ + 'SERVER_HISTORY_SIZE_LIMIT', + 'The history reached its size limit; later events were not recorded.', + ], + ], + ) +}) + +// protects=a snapshot that cannot be presented makes the capture incomplete: empty frames, another position, another source, unread scopes; fails_when=such a stop reads as complete; no_claim=lower frames, which may lack source +const SNAPSHOT_CASES: Array<{ + name: string + snapshot: NodeServerStopCapture['snapshot'] + /** Whether the writer also finds no executed source text in the snapshot's top frame, which adds the source reason after the debugger-state one. */ + sourceNotCaptured?: boolean +}> = [ + { + name: 'no frames', + sourceNotCaptured: true, + snapshot: { version: 1, frames: [], variables: [], limitations: [] }, + }, + { + name: 'a top frame on another line', + snapshot: { + version: 1, + frames: [ + { + name: 'handler', + line: 9, + column: 1, + source: { + name: 'src/handler.ts', + text: HANDLER_SOURCE_TEXT, + hash: serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 1, + }).source.hash, + line: 9, + }, + scopes: [{ name: 'Local', variables: [] }], + }, + ], + variables: [], + limitations: [], + }, + }, + { + name: 'a top frame from another source', + sourceNotCaptured: true, + snapshot: { + version: 1, + frames: [ + { + name: 'handler', + line: 2, + column: 1, + source: { + name: 'src/handler.ts', + text: 'other\n', + hash: '0'.repeat(64), + line: 2, + }, + scopes: [{ name: 'Local', variables: [] }], + }, + ], + variables: [], + limitations: [], + }, + }, + { + name: 'a scope that was not read', + snapshot: { + version: 1, + frames: [ + { + name: 'handler', + line: 2, + column: 1, + source: { + name: 'src/handler.ts', + text: HANDLER_SOURCE_TEXT, + hash: serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 1, + }).source.hash, + line: 2, + }, + scopes: [ + { + name: 'Local', + variables: [], + unavailable: 'The debugger refused.', + }, + ], + }, + ], + variables: [], + limitations: [], + }, + }, +] + +for (const snapshotCase of SNAPSHOT_CASES) { + test(`a stop with ${snapshotCase.name} makes the capture incomplete with the debugger-state reason`, async (context) => { + // Arrange + const stop = serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + snapshot: snapshotCase.snapshot, + }) + + // Act + const { opened } = await sealedServerRun(context, { stops: [stop] }) + + // Assert + assert.deepEqual(reasonsOf(opened), [ + [ + 'SERVER_HISTORY_SNAPSHOT_MISSING', + 'The debugger state at stop 1 was not recorded.', + ], + ...(snapshotCase.sourceNotCaptured + ? [ + [ + 'SERVER_HISTORY_SOURCE_UNAVAILABLE', + 'The source at stop 1 was not recorded (not captured).', + ], + ] + : []), + ]) + }) +} + +// protects=one source hash reused by stops on different lines stays presentable per stop; fails_when=dedupe merges the stops' lines or the second stop loses its source; no_claim=the replay's slot behavior +test('two stops that share one source hash on different lines are both presentable and the run seals complete', async (context) => { + // Arrange + const stops = [ + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 10 }), + serverStopCapture({ declaredStopIndex: 1, hit: 1, line: 4, elapsedMs: 11 }), + ] + + // Act + const { opened } = await sealedServerRun(context, { declaredStops: 2, stops }) + + // Assert + assert.equal(opened.status, 'complete') + assert.equal( + opened.entries.filter((entry) => entry.type === 'source').length, + 1, + ) +}) + +// protects=thirty unreadable values keep every reason in the seal and answer with twenty and a count; fails_when=the seal cuts reasons or an answer lists more than twenty; no_claim=the library row text +test('thirty unavailable values keep thirty reasons in the seal and twenty plus a count in the summary', async (context) => { + // Arrange: three stops of ten unreadable values each, the most one stop can hold. + const stops = [0, 1, 2].map((declaredStopIndex) => + serverStopCapture({ + declaredStopIndex, + hit: 1, + line: 2, + elapsedMs: 10 + declaredStopIndex, + unavailable: Array.from({ length: 10 }, (_, index) => ({ + path: `value${declaredStopIndex * 10 + index}`, + reason: 'not readable', + })), + }), + ) + + // Act + const { repository, opened, header } = await sealedServerRun(context, { + declaredStops: 3, + stops, + }) + const summary = await repository.serverHistories.summarize(header.studyId) + + // Assert + assert.equal(opened.seal?.reasons.length, 30) + assert.equal(summary?.captureMissing.length, 20) + assert.equal(summary?.captureMissingCount, 30) + assert.equal( + summary?.captureMissing[0]?.text, + 'value0 at stop 1 could not be read.', + ) +}) + +// protects=the largest legal capture (16 stop hits of 10 unreadable values with maximum-length paths) seals with every reason uncut and reads back; fails_when=the seal exceeds its size limit so it can never be written, or a reason is cut; no_claim=paths that JSON escapes +test('sixteen stop hits with ten unreadable values of the longest path keep all 160 reasons in a seal that reads back', async (context) => { + // Arrange + const longPath = (index: number): string => + `${'p'.repeat(500)}${String(index).padStart(12, '0')}` + // Eight declared stops, two hits each: sixteen hits, the run's capture limit. + const stops = Array.from({ length: 16 }, (_, hitNumber) => + serverStopCapture({ + declaredStopIndex: hitNumber % 8, + hit: Math.floor(hitNumber / 8) + 1, + line: 2, + elapsedMs: 10 + hitNumber, + unavailable: Array.from({ length: 10 }, (_, index) => ({ + path: longPath(hitNumber * 10 + index), + reason: 'r'.repeat(1_024), + })), + }), + ) + + // Act + const { opened } = await sealedServerRun(context, { + declaredStops: 8, + stops, + }) + + // Assert + assert.equal(opened.seal?.reasons.length, 160) + assert.equal(opened.status, 'partial') + assert.equal( + opened.seal?.reasons[159]?.text, + `${longPath(159)} at stop 8 could not be read.`, + ) +}) + +// protects=a stop lists at most as many unreadable values as the declaration may inspect (10); fails_when=the writer accepts an eleventh value, which would let the seal outgrow its size limit; no_claim=the run's own inspection bound +test('the server writer refuses a stop with more unreadable values than a stop may inspect', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + + // Act + const refused = writer.appendStop( + serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 3, + unavailable: Array.from({ length: 11 }, (_, index) => ({ + path: `value${index}`, + reason: 'not readable', + })), + }), + ) + + // Assert + await assert.rejects(refused, /<=10 items/) +}) + +// protects=a stop whose executed source was not stored cannot read as a complete capture, because the replay would not show the code that ran; fails_when=a stop with a too-large or uncaptured source seals complete or words another reason; no_claim=which stops of a run have the same source +for (const sourceCase of [ + { + name: 'is larger than 512 KiB (stored as its hash only)', + reason: 'too large', + capture: () => + serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + text: `${'// padding\n'.repeat(60_000)}export const a = 1\n`, + }), + }, + { + name: 'text was not captured', + reason: 'not captured', + capture: () => { + const base = serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + }) + const [top] = base.snapshot.frames + return { + ...base, + snapshot: { + ...base.snapshot, + frames: [ + { + ...top!, + source: { ...top!.source!, text: 'not the executed text' }, + }, + ], + }, + } + }, + }, +]) { + test(`a stop whose source ${sourceCase.name} seals partial with the source-unavailable reason`, async (context) => { + // Arrange + const stop = sourceCase.capture() + + // Act + const { opened } = await sealedServerRun(context, { stops: [stop] }) + + // Assert + assert.equal(opened.status, 'partial') + assert.deepEqual(reasonsOf(opened), [ + [ + 'SERVER_HISTORY_SOURCE_UNAVAILABLE', + `The source at stop 1 was not recorded (${sourceCase.reason}).`, + ], + ]) + assert.deepEqual(opened.seal?.reasons[0]?.params, { + stop: 1, + reason: sourceCase.reason, + }) + }) +} + +// protects=reasons, codes and params read the same before and after a restart; fails_when=a fresh store recomputes or reorders them; no_claim=other windows +test('a sealed server history reads the same reasons, codes and params after a restart', async (context) => { + // Arrange + const unavailable = [{ path: 'req.user', reason: 'not readable' }] + const stop = serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + unavailable, + }) + const { repository, learningDirectory, opened, header } = + await sealedServerRun(context, { + stops: [stop], + terminal: { kind: 'none' }, + declaredStops: 2, + }) + const before = await repository.serverHistories.summarize(header.studyId) + + // Act + const restarted = new NodeHistoryRepository( + learningDirectory, + SERVER_WORKSPACE_KEY, + () => 64, + async () => 0, + ) + const after = await restarted.serverHistories.summarize(header.studyId) + const reopened = await restarted.serverHistories.read(header.studyId) + + // Assert + assert.deepEqual(reopened.seal?.reasons, opened.seal?.reasons) + assert.deepEqual(after?.captureMissing, before?.captureMissing) + assert.deepEqual( + reopened.seal?.reasons.map((reason) => [reason.code, reason.params]), + [ + ['SERVER_HISTORY_NO_RESPONSE_STOPPED', {}], + ['SERVER_HISTORY_STOP_NOT_REACHED', { stop: 2 }], + [ + 'SERVER_HISTORY_VALUE_UNAVAILABLE', + { path: 'req.user', stop: 1, hit: 1 }, + ], + ], + ) +}) + +// protects=learning complete needs the request, one hit of every reached stop and the terminal, nothing more; fails_when=an uncited second hit, output or end blocks complete, or a missing request does not; no_claim=the saved material's schema (arrives with MCP 1.9) +test('learning is complete when the request, one hit of each reached stop and the response are cited, even with a second hit and output uncited', async (context) => { + // Arrange + const stops = [ + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 10 }), + serverStopCapture({ declaredStopIndex: 0, hit: 2, line: 2, elapsedMs: 11 }), + ] + const { opened } = await sealedServerRun(context, { stops }) + const sequenceOf = (type: string, hit?: number) => + opened.entries.find( + (entry) => + entry.type === type && + (hit === undefined || (entry.type === 'stop' && entry.hit === hit)), + )!.sequence + + // Act + const complete = evaluateNodeServerLearning( + opened.entries, + new Set([ + sequenceOf('request'), + sequenceOf('stop', 1), + sequenceOf('response'), + ]), + ) + const missingRequest = evaluateNodeServerLearning( + opened.entries, + new Set([sequenceOf('stop', 1), sequenceOf('response')]), + ) + const nothing = evaluateNodeServerLearning(opened.entries, undefined) + + // Assert + assert.deepEqual(complete, { coverage: 'complete', missing: [] }) + assert.deepEqual( + missingRequest.missing.map((part) => part.type), + ['request'], + ) + assert.equal(missingRequest.coverage, 'partial') + assert.equal(nothing.coverage, 'none') + assert.deepEqual( + nothing.missing.map((part) => part.type), + ['request', 'stop', 'response'], + ) +}) + +// protects=the missing-list cap is twenty plus the whole count; fails_when=a long list is returned whole or the count is lost; no_claim=which lists use it +test('a missing list of thirty is capped to twenty with the count of thirty', () => { + // Arrange + const thirty = Array.from({ length: 30 }, (_, index) => index) + + // Act + const capped = capMissingList(thirty) + + // Assert + assert.equal(capped.items.length, 20) + assert.equal(capped.count, 30) +}) + +// protects=a refused entry with a very long cause still seals: the partial seal is written with the append-failure reason and a cut cause; fails_when=the uncut cause exceeds the seal schema's bound and the seal never writes; no_claim=which disk error +test('a history whose append was refused with a 2 KB cause seals partial with the append-failure reason and a cut cause', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + const openBefore = fsPromises.open + let failJournal = true + context.mock.method( + fsPromises, + 'open', + async (...input: Parameters) => { + if (failJournal && String(input[0]).endsWith('events.jsonl')) + throw Object.assign(new Error('disk gone, '.repeat(250)), { + code: 'EIO', + }) + return openBefore(...input) + }, + ) + await writer + .appendRequestSent({ elapsedMs: 2, attempt: 1 }) + .catch(() => undefined) + failJournal = false + + // Act + await writer.seal({ elapsedMs: 9, reason: 'stopped' }) + context.mock.restoreAll() + const opened = await repository.serverHistories.read(header.studyId) + + // Assert + assert.equal(opened.status, 'partial') + const reason = opened.seal?.reasons.find( + (candidate) => candidate.code === 'SERVER_HISTORY_APPEND_FAILED', + ) + assert.match( + reason?.text ?? '', + /^The history could not be written after the request \(entry 1\): disk gone, .*… \[cut\]\.$/, + ) + assert.equal(reason?.params.cause?.toString().length, 1_024) +}) diff --git a/extension/src/tests/nodeServerHistoryStore.test.ts b/extension/src/tests/nodeServerHistoryStore.test.ts new file mode 100644 index 00000000..ba1ecaff --- /dev/null +++ b/extension/src/tests/nodeServerHistoryStore.test.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict' +import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { before, test } from 'node:test' + +import { NodeHistoryRepository } from '../nodeHistoryRepository' +import { NodeTestHistoryStore } from '../nodeTestHistoryStore' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +// protects=each profile reads only its own header; fails_when=a test header in the server folder, or a server header in the test folder, is listed, counted or opened; no_claim=hand-edited histories beyond the header profile +test('a test header placed in the server folder is refused and listed nowhere', async (context) => { + // Arrange + const { repository, learningDirectory } = await openServerRepository(context) + const intruder = nodeTestHistoryHeader(SERVER_WORKSPACE_KEY) + const folder = join(learningDirectory, 'node-servers.v1', intruder.studyId) + await mkdir(folder, { recursive: true }) + await writeFile( + join(folder, 'header.json'), + JSON.stringify({ ...intruder, version: 1 }), + ) + await writeFile(join(folder, 'events.jsonl'), '') + + // Act + const reading = assert.rejects( + repository.serverHistories.read(intruder.studyId), + ) + const listed = await repository.serverHistories.list() + const counted = await repository.serverHistories.countListedHistories(10) + + // Assert + await reading + assert.deepEqual(listed, []) + assert.equal(counted, 0) +}) + +test('a server header placed in the test folder is refused by the test store', async (context) => { + // Arrange + const { repository, learningDirectory } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + writer.abandon() + await mkdir(join(learningDirectory, 'node-tests.v1'), { recursive: true }) + await cp( + join(learningDirectory, 'node-servers.v1', header.studyId), + join(learningDirectory, 'node-tests.v1', header.studyId), + { recursive: true }, + ) + + // Act + const listed = await repository.testHistories.list() + + // Assert + assert.deepEqual(listed, []) +}) + +// protects=a node-test header written by the new code has the keys v0.2.53.0 wrote, so an older extension still parses it; fails_when=a new required field or a changed profile appears in a test header; no_claim=other files of the folder +test('a test header written by the generalized store has exactly the keys of the v0.2.53.0 fixture header', async (context) => { + // Arrange + const { learningDirectory } = await openServerRepository(context) + const store = new NodeTestHistoryStore( + learningDirectory, + SERVER_WORKSPACE_KEY, + ) + const header = nodeTestHistoryHeader(SERVER_WORKSPACE_KEY) + const fixtureFolder = join( + __dirname, + '../../src/tests/fixtures/node-test-history-v0.2.53.0/node-tests.v1', + ) + const [fixtureStudy] = await readdir(fixtureFolder) + const fixtureHeader = JSON.parse( + await readFile(join(fixtureFolder, fixtureStudy!, 'header.json'), 'utf8'), + ) + + // Act + const writer = await store.start(header) + writer.abandon() + const written = JSON.parse( + await readFile( + join(learningDirectory, 'node-tests.v1', header.studyId, 'header.json'), + 'utf8', + ), + ) + + // Assert + assert.deepEqual( + Object.keys(written).sort(), + Object.keys(fixtureHeader).sort(), + ) + assert.equal(written.profile, 'node-test') + assert.equal(written.version, 1) +}) + +// protects=a server history another window records lists as recording with what has been written, and opens its verified prefix; fails_when=it lists as interrupted or unavailable while its writer lives; no_claim=deleting it, which belongs to the workspace's Trash path (pt2) +test('a live server history recorded in one window lists as recording in another with its request line and stops', async (context) => { + // Arrange + const first = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + declaredStops: 2, + }) + const writer = await first.repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 10 }), + ) + const secondWindow = new NodeHistoryRepository( + first.learningDirectory, + SERVER_WORKSPACE_KEY, + () => 64, + async () => 0, + ) + + // Act + const listed = await secondWindow.serverHistories.list() + const opened = await secondWindow.serverHistories.read(header.studyId) + + // Assert + assert.equal(listed.length, 1) + assert.equal(listed[0]?.status, 'recording') + assert.equal(listed[0]?.requestLine, 'POST /api/items') + assert.equal(listed[0]?.stopCount, 1) + assert.equal(listed[0]?.reachedStopCount, 1) + assert.equal(listed[0]?.declaredStopCount, 2) + assert.equal(opened.status, 'recording') + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['request', 'request-sent', 'source', 'stop'], + ) + writer.abandon() +}) + +// protects=nothing secret-named or secret-shaped from the declaration reaches any file of the folder; fails_when=a body field, query value or token in the request path is written; no_claim=output and failure messages, which the run's redactors own and pt3 tests end to end +test('a secret-named body field and a query token of the declaration reach no file of the history folder', async (context) => { + // Arrange + const { repository, learningDirectory } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + path: '/api/login?api_key=CANARY-QUERY-9d1', + body: '{"password":"CANARY-BODY-7c4","name":"ada"}', + }) + const writer = await repository.serverHistories.start(header) + + // Act + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/login?api_key=CANARY-QUERY-9d1&Authorization=Bearer abcdefgh12345', + bodyBytes: 41, + }) + await writer.seal({ elapsedMs: 2, reason: 'stopped' }) + const folder = join(learningDirectory, 'node-servers.v1', header.studyId) + const contents = await Promise.all( + (await readdir(folder)).map((name) => readFile(join(folder, name), 'utf8')), + ) + + // Assert + const everything = contents.join('\n') + assert.equal(everything.includes('CANARY-QUERY-9d1'), false) + assert.equal(everything.includes('CANARY-BODY-7c4'), false) + assert.equal(everything.includes('abcdefgh12345'), false) + // The non-secret field survives, escaped inside the header's JSON string. + assert.equal(everything.includes('name\\":\\"ada'), true) +}) + +// protects=a server history with no request holds nothing to replay and its folder is removed at the seal; fails_when=an empty server history takes a place of the saved-study limit; no_claim=a history with learner files, which stays like a test history +test('a server history that never opened a request is removed when it seals and takes no place of the limit', async (context) => { + // Arrange + const { repository } = await openServerRepository(context, { limit: 1 }) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendOutput({ + elapsedMs: 1, + channel: 'stdout', + text: 'booting\n', + }) + + // Act + await writer.seal({ elapsedMs: 2, reason: 'failed', code: 'PORT_IN_USE' }) + + // Assert + assert.deepEqual(await repository.serverHistories.list(), []) + assert.equal(await repository.countHistories(1), 0) + assert.equal(repository.resolve(header.studyId), undefined) +}) + +// protects=the repository resolves a study id to the profile whose folder holds it and validates the id and sequence; fails_when=a path-like id resolves, or a bad sequence reads an entry; no_claim=ids of other workspaces +test('the repository resolves a server history by id and refuses a malformed id and a missing sequence', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.seal({ elapsedMs: 2, reason: 'stopped' }) + + // Act + const resolved = repository.resolve(header.studyId) + const entry = await repository.entryAt(header.studyId, 1) + + // Assert + assert.equal(resolved?.profile, 'node-server') + assert.equal(entry.entry.type, 'request') + assert.equal(repository.resolve('../../etc_passwd'), undefined) + await assert.rejects( + repository.entryAt(header.studyId, 0), + /positive integer/, + ) + await assert.rejects( + repository.entryAt(header.studyId, 99), + /no entry with this sequence/, + ) +}) diff --git a/extension/src/tests/nodeServerHistoryWriter.test.ts b/extension/src/tests/nodeServerHistoryWriter.test.ts new file mode 100644 index 00000000..0ea945f7 --- /dev/null +++ b/extension/src/tests/nodeServerHistoryWriter.test.ts @@ -0,0 +1,521 @@ +import assert from 'node:assert/strict' +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { before, test } from 'node:test' + +import { nodeServerHistoryEntrySchema } from '../nodeServerHistorySchemas' +import type { NodeServerHistoryWriter } from '../nodeServerHistoryWriter' +import { sha256 } from '../runtimeSupport/utils/sha256' +import { rehydrateNodeServerEntries } from '../utils/rehydrateServerStopSnapshot' + +import { + HANDLER_SOURCE_TEXT, + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +// Every start of a history takes the storage lock, which names this process's own start. +before(async () => { + await realProcessStart(process.pid) +}) + +/** Starts a server history with `declaredStops` declared stops and returns its writer, folder and store. */ +async function startServerHistory( + context: Parameters[0], + declaredStops = 2, +) { + const { repository, learningDirectory } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + declaredStops, + }) + const writer = await repository.serverHistories.start(header) + const folder = join(learningDirectory, 'node-servers.v1', header.studyId) + return { + repository, + writer, + folder, + header, + store: repository.serverHistories, + } +} + +/** Writes request, sent mark and one stop hit, the opening every refusal test builds on. */ +async function openRequestWithOneStop( + writer: NodeServerHistoryWriter, +): Promise { + await writer.appendRequest({ + elapsedMs: 10, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 11, attempt: 1 }) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 20 }), + ) +} + +// protects=a server run's evidence is written as files a reader can list, read and seal; fails_when=the header, journal, writer record or seal go missing or the sealed run does not read back complete; no_claim=a live run produces it +test('a valid server run writes its header, journal and writer record, and seals complete with its response and end', async (context) => { + // Arrange + const { writer, folder, store, header } = await startServerHistory(context, 1) + const filesWhileRecording = (await readdir(folder)).sort() + + // Act + await openRequestWithOneStop(writer) + await writer.appendOutput({ + elapsedMs: 25, + channel: 'stdout', + text: 'created\n', + }) + await writer.appendResponse({ + elapsedMs: 30, + status: 201, + contentType: 'application/json', + body: '{"id":7}', + bodyBytes: 8, + readCut: false, + location: null, + requestDurationMs: 20, + }) + await writer.seal({ elapsedMs: 40, reason: 'stopped' }) + const opened = await store.read(header.studyId) + + // Assert + assert.deepEqual(filesWhileRecording, [ + 'events.jsonl', + 'header.json', + 'writer.json', + ]) + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['request', 'request-sent', 'source', 'stop', 'output', 'response', 'end'], + ) + assert.equal(opened.status, 'complete') + assert.equal(opened.seal?.entryCount, 7) + assert.deepEqual((await readdir(folder)).sort(), [ + 'events.jsonl', + 'header.json', + 'sealed.json', + ]) +}) + +// protects=the server header round-trips with its identity and masked declaration; fails_when=the header loses the declared request, stops or revision, or stores the secret body field; no_claim=the hash of the original is reversible +test('a server header round-trips and stores the declared request with its secret-named body field masked', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const input = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + path: '/api/login?access_token=abc&next=/home', + body: '{"user":"ada","password":"hunter2"}', + }) + + // Act + const writer = await repository.serverHistories.start(input) + writer.abandon() + const stored = await readFile( + join( + repository.serverHistories.learningDirectory, + 'node-servers.v1', + input.studyId, + 'header.json', + ), + 'utf8', + ) + const parsed = JSON.parse(stored) + + // Assert + assert.equal(parsed.formatVersion, 1) + assert.equal(parsed.profile, 'node-server') + assert.equal( + parsed.declaredRequest.path, + '/api/login?access_token=[masked]&next=/home', + ) + assert.equal( + parsed.configuration.request.body, + '{"user":"ada","password":"[masked]"}', + ) + assert.equal(stored.includes('hunter2'), false) + assert.equal(stored.includes('abc&'), false) +}) + +// protects=entries offered in one synchronous step get consecutive numbers in call order; fails_when=numbers are taken at the call or the queue reorders; no_claim=durability across a crash +test('two server entries appended in one synchronous step are numbered one and two in call order', async (context) => { + // Arrange + const { writer, store, header } = await startServerHistory(context, 1) + + // Act + const first = writer.appendOutput({ + elapsedMs: 1, + channel: 'stdout', + text: 'a', + }) + const second = writer.appendOutput({ + elapsedMs: 2, + channel: 'stderr', + text: 'b', + }) + await Promise.all([first, second]) + await writer.appendRequest({ + elapsedMs: 3, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.seal({ elapsedMs: 4, reason: 'stopped' }) + const opened = await store.read(header.studyId) + + // Assert + assert.deepEqual( + opened.entries + .slice(0, 2) + .map((entry) => [ + entry.sequence, + entry.type === 'output' ? entry.text : '', + ]), + [ + [1, 'a'], + [2, 'b'], + ], + ) +}) + +const REFUSALS: Array<{ + name: string + prepare: (writer: NodeServerHistoryWriter) => Promise + offer: (writer: NodeServerHistoryWriter) => Promise + refusal: RegExp + /** `removed` for a history that never opened a request: it holds nothing to replay. */ + afterSeal?: 'partial' | 'removed' +}> = [ + { + name: 'a stop before any request', + prepare: async (): Promise => {}, + offer: (writer): Promise => + writer.appendStop( + serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 5, + }), + ), + refusal: /stops outside an open request/, + afterSeal: 'removed', + }, + { + name: 'a stop at a declared index out of range', + prepare: async (writer): Promise => { + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + }, + offer: (writer): Promise => + writer.appendStop( + serverStopCapture({ + declaredStopIndex: 5, + hit: 1, + line: 2, + elapsedMs: 5, + }), + ), + refusal: /undeclared stop/, + }, + { + name: 'two terminal entries', + prepare: async (writer): Promise => { + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.appendResponse({ + elapsedMs: 3, + status: 200, + contentType: null, + body: '', + bodyBytes: 0, + readCut: false, + location: null, + requestDurationMs: 1, + }) + }, + offer: (writer): Promise => + writer.appendRequestFailed({ elapsedMs: 4, code: 'X', message: 'late' }), + refusal: /fails a request never opened/, + }, + { + name: 'an entry after end', + prepare: async (writer): Promise => { + await writer.appendRequest({ + elapsedMs: 0, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendEntry({ type: 'end', elapsedMs: 1, reason: 'stopped' }) + }, + offer: (writer): Promise => + writer.appendOutput({ elapsedMs: 2, channel: 'stdout', text: 'late' }), + refusal: /after its end/, + }, + { + name: 'time moving back', + prepare: async (writer): Promise => { + await writer.appendRequest({ + elapsedMs: 0, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendOutput({ elapsedMs: 50, channel: 'stdout', text: 'a' }) + }, + offer: (writer): Promise => + writer.appendOutput({ elapsedMs: 49, channel: 'stdout', text: 'b' }), + refusal: /time moved backward/, + }, + { + name: 'a stop whose source hash has no earlier source entry', + prepare: async (writer): Promise => { + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + }, + offer: async (writer): Promise => { + const capture = serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 5, + }) + await writer.appendEntry({ + type: 'stop', + elapsedMs: 5, + declaredStopIndex: 0, + hit: 1, + generation: 1, + source: capture.source, + frame: { name: 'handler' }, + facts: {}, + unavailable: [], + debuggerSnapshot: capture.snapshot, + }) + }, + refusal: /no earlier source entry/, + }, + { + name: 'a second open attempt', + prepare: async (writer): Promise => { + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + }, + offer: (writer): Promise => + writer.appendRequest({ + elapsedMs: 2, + attempt: 2, + method: 'POST', + path: '/x', + bodyBytes: 0, + }), + refusal: /while one is open or finished/, + }, +] + +for (const refusalCase of REFUSALS) { + // protects=each ordering rule of the server journal; fails_when=the writer accepts the violation and a reader would then refuse the sealed journal; no_claim=rules between profiles other than node-server + test(`the server writer refuses ${refusalCase.name} and keeps the verified prefix sealable`, async (context) => { + // Arrange + const { writer, store, header } = await startServerHistory(context, 2) + await refusalCase.prepare(writer) + + // Act + const refused = refusalCase.offer(writer) + + // Assert + await assert.rejects(refused, refusalCase.refusal) + await writer.seal({ elapsedMs: 100, reason: 'stopped' }) + if (refusalCase.afterSeal === 'removed') { + await assert.rejects(store.read(header.studyId), /ENOENT/) + return + } + const opened = await store.read(header.studyId) + assert.equal(opened.status, 'partial') + }) +} + +// protects=the 17th stop is refused by the writer's own limit; fails_when=a history holds more stops than the run's capture budget; no_claim=the run's refusal of the 17th hit +test('the server writer refuses a 17th stop entry', async (context) => { + // Arrange + const { writer } = await startServerHistory(context, 1) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + for (let hit = 1; hit <= 16; hit++) + await writer.appendStop( + serverStopCapture({ + declaredStopIndex: 0, + hit, + line: 2, + elapsedMs: 10 + hit, + }), + ) + + // Act + const seventeenth = writer.appendStop( + serverStopCapture({ + declaredStopIndex: 0, + hit: 17, + line: 2, + elapsedMs: 40, + }), + ) + + // Assert + await assert.rejects(seventeenth, /exceeds its stop limit/) +}) + +// protects=sixteen hits of one handler store its text once and each reads back with the full snapshot; fails_when=the text is duplicated or a stop reads without its source text; no_claim=binding of the snapshot to a live debugger +test('three hits of one handler store its text in one source entry and each rehydrates to the full snapshot', async (context) => { + // Arrange + const { writer, store, header, folder } = await startServerHistory(context, 3) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + + // Act + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 10 }), + ) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 1, hit: 1, line: 3, elapsedMs: 11 }), + ) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 2, hit: 1, line: 4, elapsedMs: 12 }), + ) + await writer.seal({ elapsedMs: 20, reason: 'stopped' }) + const opened = await store.read(header.studyId) + const rehydrated = rehydrateNodeServerEntries(opened.entries) + const journal = await readFile(join(folder, 'events.jsonl'), 'utf8') + + // Assert + assert.equal( + opened.entries.filter((entry) => entry.type === 'source').length, + 1, + ) + assert.equal(journal.split(HANDLER_SOURCE_TEXT.split('\n')[0]!).length - 1, 1) + const texts = rehydrated + .filter((entry) => entry.type === 'stop') + .map((entry) => entry.debuggerSnapshot.frames[0]?.source?.text) + assert.deepEqual(texts, [ + HANDLER_SOURCE_TEXT, + HANDLER_SOURCE_TEXT, + HANDLER_SOURCE_TEXT, + ]) + assert.deepEqual( + rehydrated + .filter((entry) => entry.type === 'stop') + .map((entry) => entry.debuggerSnapshot.frames[0]?.line), + [2, 3, 4], + ) +}) + +// protects=a source above 512 KiB is stored as its hash with the reason; fails_when=a huge file is written into the journal or the replay loses the reason; no_claim=the replay's wording of the reason +test('a 600 KiB source is stored as its hash only and rehydrates without text and with the reason', async (context) => { + // Arrange + const { writer, store, header } = await startServerHistory(context, 1) + const hugeText = `${'x'.repeat(600 * 1024)}\n` + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/x', + bodyBytes: 0, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + + // Act + await writer.appendStop( + serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + text: hugeText, + }), + ) + await writer.seal({ elapsedMs: 20, reason: 'stopped' }) + const opened = await store.read(header.studyId) + const source = opened.entries.find((entry) => entry.type === 'source') + const stop = rehydrateNodeServerEntries(opened.entries).find( + (entry) => entry.type === 'stop', + ) + + // Assert + assert.equal(source?.type === 'source' ? source.text : 'present', undefined) + assert.equal( + source?.type === 'source' ? source.sourceUnavailable : '', + 'too large', + ) + assert.equal(source?.type === 'source' ? source.hash : '', sha256(hugeText)) + assert.equal( + stop?.type === 'stop' ? stop.debuggerSnapshot.frames[0]?.source : 'x', + undefined, + ) + assert.equal( + stop?.type === 'stop' + ? stop.debuggerSnapshot.frames[0]?.sourceUnavailable + : '', + 'too large', + ) +}) + +// protects=entry schemas refuse a source entry whose text does not hash to its name; fails_when=an edited text passes as the executed source; no_claim=anything about the debugger +test('a source entry whose text does not hash to its name is refused by the schema', () => { + // Arrange + const entry = { + type: 'source', + sequence: 1, + elapsedMs: 0, + path: 'a.ts', + hash: sha256('one'), + text: 'two', + } + + // Act + const parsed = nodeServerHistoryEntrySchema.safeParse(entry) + + // Assert + assert.equal(parsed.success, false) +}) diff --git a/extension/src/tests/nodeTestHistoryV0253Compatibility.test.ts b/extension/src/tests/nodeTestHistoryV0253Compatibility.test.ts new file mode 100644 index 00000000..7d0d651f --- /dev/null +++ b/extension/src/tests/nodeTestHistoryV0253Compatibility.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { cp, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { NodeTestHistoryStore } from '../nodeTestHistoryStore' +import { sha256 } from '../runtimeSupport/utils/sha256' + +const FIXTURE_FOLDER = '../../src/tests/fixtures/node-test-history-v0.2.53.0' +const STUDY_ID = + '11111111-1111-4111-8111-111111111111_22222222-2222-4222-8222-222222222222' + +/** + * Copies the history that v0.2.53.0 saved into a fresh learning directory so a test reads it exactly as an upgraded window would. + * @param context - Supplies the cleanup hook. + * @returns A store over the copied learning directory. + */ +async function openFixtureStore(context: { + after: (cleanup: () => Promise) => void +}): Promise { + const learningDirectory = await mkdtemp(join(tmpdir(), 'codebook-fixture-')) + context.after(() => rm(learningDirectory, { recursive: true, force: true })) + await cp( + join(__dirname, FIXTURE_FOLDER, 'node-tests.v1'), + join(learningDirectory, 'node-tests.v1'), + { recursive: true }, + ) + return new NodeTestHistoryStore( + learningDirectory, + sha256('node-history-test-workspace'), + ) +} + +// protects=a test replay saved before the storage generalization still lists and opens after an upgrade; +// fails_when=the node-test descriptor changes its folder, header, entry, seal or order rules; +// no_claim=histories of other versions or of the node-server profile. +test('a test replay saved by v0.2.53.0 lists as complete with its stops and opens with all five entries', async (context) => { + // Arrange + const store = await openFixtureStore(context) + + // Act + const summaries = await store.list() + const opened = await store.read(STUDY_ID) + + // Assert + assert.equal(summaries.length, 1) + assert.equal(summaries[0]?.studyId, STUDY_ID) + assert.equal(summaries[0]?.profile, 'node-test') + assert.equal(summaries[0]?.status, 'complete') + assert.equal(summaries[0]?.stopCount, 2) + assert.equal(summaries[0]?.testResult, 'passed') + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['output', 'stop', 'output', 'stop', 'result'], + ) + assert.equal(opened.seal?.status, 'complete') +}) diff --git a/extension/src/tests/utils/nodeServerHistoryFixture.ts b/extension/src/tests/utils/nodeServerHistoryFixture.ts new file mode 100644 index 00000000..5408b1f0 --- /dev/null +++ b/extension/src/tests/utils/nodeServerHistoryFixture.ts @@ -0,0 +1,168 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { TestContext } from 'node:test' + +import { NodeHistoryRepository } from '../../nodeHistoryRepository' +import { nodeServerConfigSchema } from '../../nodeServerConfigSchemas' +import type { NodeServerHistoryHeader } from '../../nodeServerHistorySchemas' +import type { NodeServerStopCapture } from '../../nodeServerHistoryWriter' +import type { ReplayDebugSnapshot } from '../../replayDebugSchemas' +import { sha256 } from '../../runtimeSupport/utils/sha256' +import { maskNodeServerConfiguration } from '../../utils/maskNodeServerConfiguration' + +import { createConfiguredStudy } from './createConfiguredStudy' + +/** The workspace key every server history test uses. */ +export const SERVER_WORKSPACE_KEY = sha256('node-server-history-test-workspace') + +/** The source text a stop at `handler.ts` executes, one function over four lines. */ +export const HANDLER_SOURCE_TEXT = + 'export function handler(req) {\n const id = req.params.id\n const item = load(id)\n return item\n}\n' + +/** + * Makes a private learning directory and a repository over it, both removed when the test ends. + * @param context - The test whose end removes the directory. + * @param options - The saved-study limit and the saved studies the library holds. + * @returns The repository and its directory. + * @example const { repository } = await openServerRepository(context) + */ +export async function openServerRepository( + context: TestContext, + options: { + limit?: number + savedStudies?: () => Promise + workspaceKey?: string + /** Another window over the same learning directory. */ + learningDirectory?: string + } = {}, +): Promise<{ repository: NodeHistoryRepository; learningDirectory: string }> { + const learningDirectory = + options.learningDirectory ?? + (await mkdtemp(join(tmpdir(), 'codebook-server-'))) + if (!options.learningDirectory) + context.after(() => rm(learningDirectory, { recursive: true, force: true })) + const repository = new NodeHistoryRepository( + learningDirectory, + options.workspaceKey ?? SERVER_WORKSPACE_KEY, + () => options.limit ?? 64, + options.savedStudies ?? (async () => 0), + ) + return { repository, learningDirectory } +} + +/** + * Builds the header input of a server history that declares `declaredStops` stops on `handler.ts`, one POST request and a masked configuration, the way the run builds it. + * @param workspaceKey - The workspace key. + * @param options - How many stops to declare and the request path. + * @returns The header without its `formatVersion`, which the store stamps. + * @example const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { declaredStops: 2 }) + */ +export function nodeServerHistoryHeader( + workspaceKey: string, + options: { + declaredStops?: number + path?: string + body?: string + workspaceInstance?: string + } = {}, +): Omit { + const browser = createConfiguredStudy().configuration + const sourceHash = sha256(HANDLER_SOURCE_TEXT) + const declaredStops = options.declaredStops ?? 1 + const declaration = { + version: 1, + adapter: 'node-server', + book: browser.book, + entry: 'src/server.ts', + port: 4100, + request: { + method: 'POST', + path: options.path ?? '/api/items', + contentType: 'application/json', + body: options.body ?? '{"name":"pen"}', + }, + source: { path: 'src/handler.ts', hash: sourceHash, line: 2, column: 1 }, + inspections: [{ path: 'id', mode: 'value', required: true }], + additionalStops: Array.from({ length: declaredStops - 1 }, (_, index) => ({ + path: 'src/handler.ts', + hash: sourceHash, + line: 3 + index, + column: 1, + anchorIndex: 0, + inspections: [{ path: 'id', mode: 'value', required: true }], + })), + } + const configuration = maskNodeServerConfiguration( + nodeServerConfigSchema.parse(declaration), + ) + const instanceId = randomUUID() + const runId = randomUUID() + return { + profile: 'node-server', + workspaceKey, + studyId: `${instanceId}_${runId}`, + instanceId, + runId, + createdAt: '2026-10-28T00:00:00.000Z', + question: 'How does the server create an item?', + configurationHash: sha256(JSON.stringify(declaration)), + configuration, + declaredRequest: { + method: 'POST', + path: configuration.request.path, + }, + declaredStops: Array.from({ length: declaredStops }, (_, index) => ({ + path: 'src/handler.ts', + line: 2 + index, + })), + repositoryRevision: { revision: 'abc1234', dirty: false }, + sourceHashes: [{ path: 'src/handler.ts', hash: sourceHash }], + } +} + +/** + * Builds the run's capture of one stop hit with a presentable snapshot: the top frame at `line` of `handler.ts` with its source text, a local scope and one variable. + * @param options - The declared stop index, hit number, line and time; `text` replaces the executed source, `facts` the inspected values. + * @returns A capture the writer accepts and the evaluator calls presentable. + * @example await writer.appendStop(serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 50 })) + */ +export function serverStopCapture(options: { + declaredStopIndex: number + hit: number + line: number + elapsedMs: number + text?: string + facts?: NodeServerStopCapture['facts'] + unavailable?: NodeServerStopCapture['unavailable'] + snapshot?: ReplayDebugSnapshot +}): NodeServerStopCapture { + const text = options.text ?? HANDLER_SOURCE_TEXT + const hash = sha256(text) + const snapshot: ReplayDebugSnapshot = options.snapshot ?? { + version: 1, + frames: [ + { + name: 'handler', + line: options.line, + column: 1, + source: { name: 'src/handler.ts', text, hash, line: options.line }, + scopes: [{ name: 'Local', variables: [0] }], + }, + ], + variables: [{ name: 'id', value: '7', type: 'string', children: [] }], + limitations: [], + } + return { + elapsedMs: options.elapsedMs, + declaredStopIndex: options.declaredStopIndex, + hit: options.hit, + generation: options.hit, + source: { path: 'src/handler.ts', hash, line: options.line, column: 1 }, + frameName: 'handler', + facts: options.facts ?? { 'value:id': '7' }, + unavailable: options.unavailable ?? [], + snapshot, + } +} diff --git a/extension/src/utils/atomicPrivateBytes.ts b/extension/src/utils/atomicPrivateBytes.ts index 9a25de19..3191ef6e 100644 --- a/extension/src/utils/atomicPrivateBytes.ts +++ b/extension/src/utils/atomicPrivateBytes.ts @@ -7,13 +7,15 @@ import { PRIVATE_FILE_MODE } from '../constants' * Publishes a complete private file only after its temporary bytes are flushed; called by {@link atomicPrivateWrite} for JSON values and by {@link LearningLibrary.removeStudy} to write a ledger's original bytes back. * @param path - The file to replace. * @param bytes - The complete new content. - * @throws When the temporary file cannot be written or renamed; the existing file is then unchanged. + * @param beforePublish - Called synchronously after the bytes are flushed and just before the rename; throwing cancels the publication and leaves the existing file unchanged. + * @throws When the temporary file cannot be written or renamed, or `beforePublish` throws; the existing file is then unchanged. * @example * await atomicPrivateBytes(ledgerPath, bytesBeforeTheRemoval) */ export async function atomicPrivateBytes( path: string, bytes: string | Buffer, + beforePublish?: () => void, ): Promise { const temporary = `${path}.${randomUUID()}.tmp` const handle = await open(temporary, 'wx', PRIVATE_FILE_MODE) @@ -24,6 +26,7 @@ export async function atomicPrivateBytes( } finally { await handle.close() } + beforePublish?.() await rename(temporary, path) } finally { await unlink(temporary).catch((error) => { diff --git a/extension/src/utils/atomicPrivateWrite.ts b/extension/src/utils/atomicPrivateWrite.ts index 90519072..b34f5733 100644 --- a/extension/src/utils/atomicPrivateWrite.ts +++ b/extension/src/utils/atomicPrivateWrite.ts @@ -1,15 +1,16 @@ import { atomicPrivateBytes } from './atomicPrivateBytes' -/** Publishes a complete private JSON replacement only after its temporary bytes are flushed. @example await atomicPrivateWrite(path, json, limit) */ +/** Publishes a complete private JSON replacement only after its temporary bytes are flushed; `beforePublish` may throw to cancel the publication ({@link atomicPrivateBytes}). @example await atomicPrivateWrite(path, json, limit) */ export async function atomicPrivateWrite( path: string, value: unknown, limit: number, + beforePublish?: () => void, ): Promise { const bytes = JSON.stringify(value) if (Buffer.byteLength(bytes) > limit) throw new Error( `Storage limit exceeded (${limit} bytes); existing data was retained.`, ) - await atomicPrivateBytes(path, bytes) + await atomicPrivateBytes(path, bytes, beforePublish) } diff --git a/extension/src/utils/capMissingList.ts b/extension/src/utils/capMissingList.ts new file mode 100644 index 00000000..e61752c6 --- /dev/null +++ b/extension/src/utils/capMissingList.ts @@ -0,0 +1,18 @@ +import { NODE_SERVER_MISSING_LIST_LIMIT } from '../constants' + +/** + * Caps a missing-evidence list for an answer: the first {@link NODE_SERVER_MISSING_LIST_LIMIT} items and how many there were, so thirty unreadable values never flood a reply while the seal keeps every one. + * Called wherever an answer lists `learningMissing` or `captureMissing` of a server history. + * @param items - The whole list. + * @returns The leading items and the whole list's length. + * @example capMissingList(thirtyReasons) // => { items: [first twenty], count: 30 } + */ +export function capMissingList(items: readonly Item[]): { + items: Item[] + count: number +} { + return { + items: items.slice(0, NODE_SERVER_MISSING_LIST_LIMIT), + count: items.length, + } +} diff --git a/extension/src/utils/cutHistoryReason.ts b/extension/src/utils/cutHistoryReason.ts new file mode 100644 index 00000000..58c47781 --- /dev/null +++ b/extension/src/utils/cutHistoryReason.ts @@ -0,0 +1,17 @@ +import { cutTextToFit } from './cutTextToFit' + +/** The longest reason a history's seal or index holds. */ +const REASON_LIMIT_CHARS = 1_024 + +/** + * Cuts a history's reason to {@link REASON_LIMIT_CHARS} between characters, never inside a surrogate pair, saying it was cut, so every reason fits the seal's and the index's bound (F265); called wherever a store or writer words a reason. + * @param reason - The whole reason. + * @returns The reason itself when it fits, else its beginning with the cut marker. + * @example cutHistoryReason('x'.repeat(2_000)).length // => 1_024 + */ +export function cutHistoryReason(reason: string): string { + return cutTextToFit( + reason, + (candidate) => candidate.length <= REASON_LIMIT_CHARS, + ) +} diff --git a/extension/src/utils/dehydrateServerStopSnapshot.ts b/extension/src/utils/dehydrateServerStopSnapshot.ts new file mode 100644 index 00000000..4c3d4bbc --- /dev/null +++ b/extension/src/utils/dehydrateServerStopSnapshot.ts @@ -0,0 +1,19 @@ +import type { ReplayDebugSnapshot } from '../replayDebugSchemas' + +/** + * Keeps a stop's top-frame source by hash only: the text is stored once in a `source` entry, so sixteen hits of one handler store it once. The text becomes an empty marker that {@link rehydrateServerStopSnapshot} replaces when the history is read. + * Called by the node-server writer for each stop it appends, after it wrote (or found) the stop's `source` entry. + * @param snapshot - The run's captured snapshot with its full top-frame source. + * @returns The snapshot with the top frame's source text emptied; unchanged when the top frame has no source. + * @example dehydrateServerStopSnapshot(snapshot).frames[0]?.source?.text // => '' + */ +export function dehydrateServerStopSnapshot( + snapshot: ReplayDebugSnapshot, +): ReplayDebugSnapshot { + const [top, ...lower] = snapshot.frames + if (!top?.source) return snapshot + return { + ...snapshot, + frames: [{ ...top, source: { ...top.source, text: '' } }, ...lower], + } +} diff --git a/extension/src/utils/evaluateNodeServerCapture.ts b/extension/src/utils/evaluateNodeServerCapture.ts new file mode 100644 index 00000000..25c33b6f --- /dev/null +++ b/extension/src/utils/evaluateNodeServerCapture.ts @@ -0,0 +1,193 @@ +import { + NODE_SERVER_HISTORY_REASON_CODES as CODES, + NODE_SERVER_REQUEST_CAPTURE_LIMIT, +} from '../constants' +import type { + NodeServerHistoryEntry, + NodeServerHistoryHeader, + NodeServerSealReason, + NodeServerSourceEntry, + NodeServerStopEntry, +} from '../nodeServerHistorySchemas' + +import { isPresentableServerStop } from './isPresentableServerStop' + +/** What only the writer knows when it seals: the failed append, the bounds it reached and the run's capture budget. */ +export type NodeServerCaptureFacts = { + /** The first failed append: the entry before the gap and the failure's cause (already without private paths). */ + appendFailure?: { lastEntry: string; cause: string } + sizeLimitReached?: boolean + captureBudgetExhausted?: boolean +} + +/** + * Words an entry the way a reason names the last good entry of a broken history. + * @param entry - The entry. + * @returns `stop 2 (entry 14)`, `the request (entry 3)`, `the response (entry 9)` or `output (entry 5)`. + * @example describeLastNodeServerEntry(stopEntry) // => 'stop 2 (entry 14)' + */ +export function describeLastNodeServerEntry( + entry: NodeServerHistoryEntry, +): string { + const where = `(entry ${entry.sequence})` + if (entry.type === 'stop') + return `stop ${entry.declaredStopIndex + 1} ${where}` + if (entry.type === 'request') return `the request ${where}` + if (entry.type === 'response') return `the response ${where}` + return `${entry.type} ${where}` +} + +/** Words how a closed server exited: `exit code 1` or `signal SIGTERM`. */ +function describeExit( + end: Extract, +): string { + if (end.signal) return `signal ${end.signal}` + return end.exitCode === undefined || end.exitCode === null + ? 'exit code unknown' + : `exit code ${end.exitCode}` +} + +/** The "No response was recorded" reason of a history with no terminal entry, or the request-failure one. */ +function noResponseReason( + entries: readonly NodeServerHistoryEntry[], +): NodeServerSealReason | undefined { + if (entries.some((entry) => entry.type === 'response')) return undefined + const failed = entries.find((entry) => entry.type === 'request-failed') + if (failed) + return { + code: CODES.noResponseRequestFailed, + params: { code: failed.code }, + text: `No response was recorded: the request failed with ${failed.code}.`, + } + const end = entries.find((entry) => entry.type === 'end') + // A history with no `end` (an append failed) says why in the append reason instead. + if (!end) return undefined + if (end.reason === 'stopped') + return { + code: CODES.noResponseStopped, + params: {}, + text: 'No response was recorded: the run was stopped before the server answered.', + } + if (end.reason === 'closed') + return { + code: CODES.noResponseExited, + params: { exit: describeExit(end) }, + text: `No response was recorded: the server exited (${describeExit(end)}) before it answered.`, + } + const failure = end.code ?? 'an unknown failure' + return { + code: CODES.noResponseRunFailed, + params: { code: failure }, + text: `No response was recorded: the run failed with ${failure}.`, + } +} + +/** The reasons of the stops a history holds: unreached declared stops, unpresentable snapshots, executed sources that were not stored and unreadable values. */ +function stopReasons( + header: Pick, + stops: readonly NodeServerStopEntry[], + sources: readonly NodeServerSourceEntry[], +): NodeServerSealReason[] { + const reasons: NodeServerSealReason[] = [] + const reached = new Set(stops.map((stop) => stop.declaredStopIndex)) + header.declaredStops.forEach((_declared, index) => { + if (!reached.has(index)) + reasons.push({ + code: CODES.stopNotReached, + params: { stop: index + 1 }, + text: `Declared stop ${index + 1} was not reached.`, + }) + }) + const reportedSnapshots = new Set() + const reportedSources = new Set() + for (const stop of stops) { + const number = stop.declaredStopIndex + 1 + if ( + !isPresentableServerStop(stop) && + !reportedSnapshots.has(stop.declaredStopIndex) + ) { + reportedSnapshots.add(stop.declaredStopIndex) + reasons.push({ + code: CODES.snapshotMissing, + params: { stop: number, hit: stop.hit }, + text: `The debugger state at stop ${number} was not recorded.`, + }) + } + // The replay must show the source that executed: a stop whose top-frame text was not stored is not a complete capture. + const unstored = sources.find( + (source) => + source.path === stop.source.path && + source.hash === stop.source.hash && + source.sourceUnavailable !== undefined, + ) + if ( + unstored?.sourceUnavailable && + !reportedSources.has(stop.declaredStopIndex) + ) { + reportedSources.add(stop.declaredStopIndex) + reasons.push({ + code: CODES.sourceUnavailable, + params: { stop: number, reason: unstored.sourceUnavailable }, + text: `The source at stop ${number} was not recorded (${unstored.sourceUnavailable}).`, + }) + } + for (const value of stop.unavailable) + reasons.push({ + code: CODES.valueUnavailable, + params: { path: value.path, stop: number, hit: stop.hit }, + text: `${value.path} at stop ${number} could not be read.`, + }) + } + return reasons +} + +/** + * Decides what a node-server history lacks, as structured reasons in the plan's table order; the one evaluator behind the seal, the live cache, the library, Save and MCP, so capture completeness is never computed twice. + * A 4xx or 5xx response is a complete capture; a status is never a reason. The list is uncut: answers cap it with {@link capMissingList}. + * @param header - The history's header (the declared stops). + * @param entries - The journal's entries as written (stored snapshots, top-frame text by hash). + * @param facts - What only the writer knows when it seals. + * @returns Every reason that holds, empty for a complete capture. + * @example evaluateNodeServerCapture(header, entries, {}) // => [] + */ +export function evaluateNodeServerCapture( + header: Pick, + entries: readonly NodeServerHistoryEntry[], + facts: NodeServerCaptureFacts = {}, +): NodeServerSealReason[] { + const reasons: NodeServerSealReason[] = [] + const noResponse = noResponseReason(entries) + if (noResponse) reasons.push(noResponse) + reasons.push( + ...stopReasons( + header, + entries.filter((entry) => entry.type === 'stop'), + entries.filter((entry) => entry.type === 'source'), + ), + ) + if (facts.appendFailure) + reasons.push({ + code: CODES.appendFailed, + params: facts.appendFailure, + text: `The history could not be written after ${facts.appendFailure.lastEntry}: ${facts.appendFailure.cause}.`, + }) + if (facts.sizeLimitReached) + reasons.push({ + code: CODES.sizeLimit, + params: {}, + text: 'The history reached its size limit; later events were not recorded.', + }) + if (facts.captureBudgetExhausted) + reasons.push({ + code: CODES.captureBudgetExhausted, + params: { hits: NODE_SERVER_REQUEST_CAPTURE_LIMIT }, + text: `The capture budget (${NODE_SERVER_REQUEST_CAPTURE_LIMIT} stop hits) was used up; later hits were not recorded.`, + }) + if (entries.some((entry) => entry.type === 'output-truncated')) + reasons.push({ + code: CODES.outputTruncated, + params: {}, + text: 'The history cut its output at the output budget; later output was not recorded.', + }) + return reasons +} diff --git a/extension/src/utils/evaluateNodeServerLearning.ts b/extension/src/utils/evaluateNodeServerLearning.ts new file mode 100644 index 00000000..f4e72f0d --- /dev/null +++ b/extension/src/utils/evaluateNodeServerLearning.ts @@ -0,0 +1,69 @@ +import type { NodeServerHistoryEntry } from '../nodeServerHistorySchemas' + +/** One citation a server history's learning material still owes: the entry a step should cite, with its declared stop and hit for a stop. */ +export type NodeServerLearningPart = + | { type: 'request' | 'response' | 'request-failed'; sequence: number } + | { type: 'stop'; sequence: number; declaredStopIndex: number; hit: number } + +/** How much of a server history the chat's material explains: `none` before any needed entry is cited, `complete` when every need is, else `partial`. */ +export type NodeServerLearningCoverage = 'none' | 'partial' | 'complete' + +/** + * Decides the learning-complete verdict of a server history: the material cites the request, at least one recorded hit of every reached declared stop and the recorded terminal entry; further hits and output are optional, and `source` and `end` are never required. + * Called by the store when it summarizes a server history, by Save and by the MCP resources; the answers cap `missing` with {@link capMissingList}. + * @param entries - The journal's entries. + * @param citedSequences - The sequences the saved material cites; `undefined` before any material was saved. + * @returns The coverage and every citation still owed, in journal order. + * @example evaluateNodeServerLearning(entries, new Set([1, 3, 6])) // => { coverage: 'complete', missing: [] } + */ +export function evaluateNodeServerLearning( + entries: readonly NodeServerHistoryEntry[], + citedSequences: ReadonlySet | undefined, +): { coverage: NodeServerLearningCoverage; missing: NodeServerLearningPart[] } { + const needs: Array<{ part: NodeServerLearningPart; satisfiedBy: number[] }> = + [] + const request = entries.findLast((entry) => entry.type === 'request') + if (request) + needs.push({ + part: { type: 'request', sequence: request.sequence }, + satisfiedBy: [request.sequence], + }) + const hitsByStop = new Map>() + for (const entry of entries) + if (entry.type === 'stop') + hitsByStop.set(entry.declaredStopIndex, [ + ...(hitsByStop.get(entry.declaredStopIndex) ?? []), + { sequence: entry.sequence, hit: entry.hit }, + ]) + for (const [declaredStopIndex, hits] of hitsByStop) + needs.push({ + part: { + type: 'stop', + sequence: hits[0]!.sequence, + declaredStopIndex, + hit: hits[0]!.hit, + }, + satisfiedBy: hits.map((hit) => hit.sequence), + }) + const terminal = entries.find( + (entry) => entry.type === 'response' || entry.type === 'request-failed', + ) + if ( + terminal && + (terminal.type === 'response' || terminal.type === 'request-failed') + ) + needs.push({ + part: { type: terminal.type, sequence: terminal.sequence }, + satisfiedBy: [terminal.sequence], + }) + const unmet = needs.filter( + ({ satisfiedBy }) => + !satisfiedBy.some((sequence) => citedSequences?.has(sequence)), + ) + const missing = unmet + .map(({ part }) => part) + .sort((left, right) => left.sequence - right.sequence) + if (needs.length > 0 && unmet.length === needs.length) + return { coverage: 'none', missing } + return { coverage: missing.length === 0 ? 'complete' : 'partial', missing } +} diff --git a/extension/src/utils/isNodeHistoryIdentity.ts b/extension/src/utils/isNodeHistoryIdentity.ts new file mode 100644 index 00000000..4b10eac4 --- /dev/null +++ b/extension/src/utils/isNodeHistoryIdentity.ts @@ -0,0 +1,13 @@ +/** The name of a history's folder: its study id, two UUIDs joined by an underscore. Any other entry of a history directory (a `.DS_Store`, a stray folder) is nobody's history. */ +const NODE_HISTORY_IDENTITY = /^[0-9a-f-]{36}_[0-9a-f-]{36}$/ + +/** + * Tells whether a string is shaped like a node history's study id; the one validator behind every history folder path, for test and server histories alike. + * Called by the stores before they build a path, and by the history repository before it resolves an id. + * @param studyId - Any string an agent or a listing produced. + * @returns `true` for `_` in lowercase hexadecimal form. + * @example isNodeHistoryIdentity('../x') // => false + */ +export function isNodeHistoryIdentity(studyId: string): boolean { + return NODE_HISTORY_IDENTITY.test(studyId) +} diff --git a/extension/src/utils/isPresentableServerStop.ts b/extension/src/utils/isPresentableServerStop.ts new file mode 100644 index 00000000..e5e6efaa --- /dev/null +++ b/extension/src/utils/isPresentableServerStop.ts @@ -0,0 +1,23 @@ +import type { NodeServerStopEntry } from '../nodeServerHistorySchemas' + +/** + * Tells whether a stored server stop can be presented in the replay debugger: the snapshot has frames, the top frame's path, line, column and source hash match the stop, and its scopes were read (lower frames may lack source). + * Called by {@link evaluateNodeServerCapture} for every stop, so the live cache, the library, Save and MCP agree on capture completeness; it applies the identity checks of {@link nodeStopSourceMatches} to the server stop's hash-only top frame, whose text the `source` entry already verified against the hash. + * @param stop - The stored stop entry. + * @returns `true` when the stop is presentable; `false` for empty frames, a frame at another position, another source, or unread scopes. + * @example isPresentableServerStop(stopWithEmptyFrames) // => false + */ +export function isPresentableServerStop(stop: NodeServerStopEntry): boolean { + const top = stop.debuggerSnapshot.frames[0] + if (!top?.source) return false + return [ + top.source.name === stop.source.path, + top.source.hash === stop.source.hash, + top.source.line === stop.source.line, + top.line === stop.source.line, + top.column === stop.source.column, + top.scopes.length > 0, + top.scopes.every((scope) => scope.unavailable === undefined), + top.unavailable === undefined, + ].every(Boolean) +} diff --git a/extension/src/utils/isSealedEmptyHistory.ts b/extension/src/utils/isSealedEmptyHistory.ts index 6b0b78a7..de73ccc3 100644 --- a/extension/src/utils/isSealedEmptyHistory.ts +++ b/extension/src/utils/isSealedEmptyHistory.ts @@ -12,20 +12,26 @@ import { systemErrorCode } from './systemErrorCode' * The seal's own counts decide, not the journal's size alone: a history whose seal records entries but whose journal is gone or empty is damaged, not empty, and it stays to be read as unavailable with its learning material and header (F251). * A folder that holds learning material or understanding marks is never empty: what was saved for the learner stays ({@link holdsLearnerFiles}, F274). * @param historyDirectory - The history's folder. + * @param sealLimitBytes - The largest seal the profile writes. + * @param parseSeal - Parses the profile's seal; it throws for a seal that does not parse. * @returns `true` when `sealed.json` is a regular file of at most {@link NODE_TEST_HISTORY_SEAL_LIMIT_BYTES} bytes whose seal records `entryCount` 0 and `journalBytes` 0, `events.jsonl` is empty or absent, and no learner file is present; `false` otherwise, also when a file cannot be inspected or the seal cannot be read or parsed. * @example await isSealedEmptyHistory('/…/node-tests.v1/') // => true for a legacy history sealed with no entry, false for one whose seal records 4 entries but whose journal was lost */ -export async function isSealedEmptyHistory( +export async function isSealedEmptyJournal( historyDirectory: string, + sealLimitBytes: number, + parseSeal: (value: unknown) => { + entryCount: number + journalBytes: number + }, ): Promise { const sealPath = join(historyDirectory, 'sealed.json') const seal = await lstat(sealPath).catch(() => undefined) - if (!seal?.isFile() || seal.size > NODE_TEST_HISTORY_SEAL_LIMIT_BYTES) - return false + if (!seal?.isFile() || seal.size > sealLimitBytes) return false const recorded = await readFile(sealPath, 'utf8').then( (text) => { try { - return nodeTestHistorySealSchema.safeParse(JSON.parse(text)) + return { success: true as const, data: parseSeal(JSON.parse(text)) } } catch { return undefined } @@ -47,3 +53,19 @@ export async function isSealedEmptyHistory( // Learning material or understanding marks saved before the first entry stay with their folder (F274). return !(await holdsLearnerFiles(historyDirectory)) } + +/** + * Tells whether a test history's folder holds a seal that records no entry and no journal byte; the node-test form of {@link isSealedEmptyJournal}, the one the saved-study count and its tests use. + * @param historyDirectory - The history's folder. + * @returns What {@link isSealedEmptyJournal} answers for the node-test seal. + * @example await isSealedEmptyHistory('/…/node-tests.v1/') // => true for a legacy history sealed with no entry + */ +export function isSealedEmptyHistory( + historyDirectory: string, +): Promise { + return isSealedEmptyJournal( + historyDirectory, + NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, + (value) => nodeTestHistorySealSchema.parse(value), + ) +} diff --git a/extension/src/utils/maskNodeServerConfiguration.ts b/extension/src/utils/maskNodeServerConfiguration.ts new file mode 100644 index 00000000..f43a901d --- /dev/null +++ b/extension/src/utils/maskNodeServerConfiguration.ts @@ -0,0 +1,29 @@ +import type { NodeServerConfiguration } from '../nodeServerConfigSchemas' + +import { maskNodeServerRequestBody } from './maskNodeServerRequestBody' +import { maskSecretQueryValues } from './maskSecretQueryValues' + +/** + * Masks the secrets a node-server declaration can carry, its request path's query values and its request body's secret-named fields, so the history header stores a declaration that is safe at rest. + * Called by the node-server run when it builds the history header (pt3); the SHA-256 of the original declaration is kept beside it as identity. + * @param configuration - The validated declaration. + * @returns A copy with the request's path and body masked; everything else unchanged. + * @example maskNodeServerConfiguration(configuration).request.path // => '/api?token=[masked]' + */ +export function maskNodeServerConfiguration( + configuration: NodeServerConfiguration, +): NodeServerConfiguration { + const { request } = configuration + return { + ...configuration, + request: { + ...request, + path: maskSecretQueryValues(request.path), + ...(request.body === undefined + ? {} + : { + body: maskNodeServerRequestBody(request.body, request.contentType), + }), + }, + } +} diff --git a/extension/src/utils/maskNodeServerRequestBody.ts b/extension/src/utils/maskNodeServerRequestBody.ts new file mode 100644 index 00000000..e8b29490 --- /dev/null +++ b/extension/src/utils/maskNodeServerRequestBody.ts @@ -0,0 +1,43 @@ +import { REDACTION_MASK } from '../constants' + +import { isSecretName } from './isSecretName' +import { maskSecretFormPairs } from './maskSecretFormPairs' +import { redactSecretText } from './redactSecretText' + +/** Masks every secret-named property of parsed JSON, at any depth, and secret-shaped strings. */ +function maskJsonValue(value: unknown): unknown { + if (typeof value === 'string') return redactSecretText(value) + if (Array.isArray(value)) return value.map(maskJsonValue) + if (value !== null && typeof value === 'object') + return Object.fromEntries( + Object.entries(value).map(([name, entry]) => [ + name, + isSecretName(name) ? REDACTION_MASK : maskJsonValue(entry), + ]), + ) + return value +} + +/** + * Masks the secret-named fields of a declared request body for the history header: JSON properties at any depth, form fields by name, and secret-shaped text in a plain body. + * Called by {@link maskNodeServerConfiguration}; the header's `configurationHash` still hashes the original, so identity survives and the secret never does. + * @param body - The declared body. + * @param contentType - The declared content type; absent for a request with no body. + * @returns The body with secret values masked; a body that does not parse as its content type is masked as plain text. + * @example maskNodeServerRequestBody('{"password":"x","name":"a"}', 'application/json') // => '{"password":"[masked]","name":"a"}' + */ +export function maskNodeServerRequestBody( + body: string, + contentType: string | undefined, +): string { + if (contentType === 'application/json') { + try { + return JSON.stringify(maskJsonValue(JSON.parse(body))) + } catch { + return redactSecretText(body) + } + } + if (contentType === 'application/x-www-form-urlencoded') + return redactSecretText(maskSecretFormPairs(body)) + return redactSecretText(body) +} diff --git a/extension/src/utils/maskSecretFormPairs.ts b/extension/src/utils/maskSecretFormPairs.ts new file mode 100644 index 00000000..0daeea9a --- /dev/null +++ b/extension/src/utils/maskSecretFormPairs.ts @@ -0,0 +1,28 @@ +import { REDACTION_MASK } from '../constants' + +import { isSecretName } from './isSecretName' + +/** + * Masks the values of secret-named pairs in an `a=b&c=d` string, the shape of both a URL query and a form body; a name that cannot be decoded is masked whole, as it cannot be told from a secret. + * Called by {@link maskSecretQueryValues} for request paths and {@link maskNodeServerRequestBody} for form bodies. + * @param pairs - The text after `?`, or a form body. + * @returns The pairs with each secret-named value replaced by the redaction mask; pairs without `=` unchanged. + * @example maskSecretFormPairs('id=3&api_key=k') // => 'id=3&api_key=[masked]' + */ +export function maskSecretFormPairs(pairs: string): string { + return pairs + .split('&') + .map((pair) => { + const separator = pair.indexOf('=') + if (separator < 0) return pair + const name = pair.slice(0, separator) + try { + return isSecretName(decodeURIComponent(name.replace(/\+/g, ' '))) + ? `${name}=${REDACTION_MASK}` + : pair + } catch { + return `${name}=${REDACTION_MASK}` + } + }) + .join('&') +} diff --git a/extension/src/utils/maskSecretQueryValues.ts b/extension/src/utils/maskSecretQueryValues.ts new file mode 100644 index 00000000..798c3f6a --- /dev/null +++ b/extension/src/utils/maskSecretQueryValues.ts @@ -0,0 +1,17 @@ +import { maskSecretFormPairs } from './maskSecretFormPairs' +import { redactSecretText } from './redactSecretText' + +/** + * Masks the values of secret-named query parameters of a request path, and secret-shaped text anywhere in it, so a history never stores a token the declaration carried in its URL. + * Called by {@link maskNodeServerConfiguration} for the header and by the writer for each `request` entry. + * @param path - A request path such as `/api/users?id=3&access_token=abc`. + * @returns The path with each secret-named parameter's value replaced by the redaction mask. + * @example maskSecretQueryValues('/api?id=3&access_token=abc') // => '/api?id=3&access_token=[masked]' + */ +export function maskSecretQueryValues(path: string): string { + const queryStart = path.indexOf('?') + if (queryStart < 0) return redactSecretText(path) + return redactSecretText( + `${path.slice(0, queryStart)}?${maskSecretFormPairs(path.slice(queryStart + 1))}`, + ) +} diff --git a/extension/src/utils/nodeServerJournalRules.ts b/extension/src/utils/nodeServerJournalRules.ts new file mode 100644 index 00000000..35372192 --- /dev/null +++ b/extension/src/utils/nodeServerJournalRules.ts @@ -0,0 +1,210 @@ +import { NODE_SERVER_HISTORY_MAX_STOPS } from '../constants' +import type { + NodeServerHistoryEntry, + NodeServerHistoryHeader, +} from '../nodeServerHistorySchemas' + +/** What the node-server journal's ordering rules have accepted so far; the writer, the incremental scan and the full read each hold one and call {@link acceptNodeServerEntry}, so all three accept the same journals. */ +export type NodeServerJournalProgress = { + entryCount: number + lastElapsedMs: number + /** The attempt number of the latest `request` entry; the next one must be this plus one. */ + lastAttempt: number + /** The attempt whose window is open: `request` seen, no withdrawal and no terminal entry yet. */ + openAttempt?: number + /** Whether the open attempt has its `request-sent` mark. */ + openAttemptSent: boolean + /** Whether a `response` or `request-failed` entry closed the run's request for good. */ + hasTerminal: boolean + stopCount: number + /** How many hits each declared stop index has so far. */ + hitsByStop: Map + /** The `(path, hash)` pairs a `source` entry already holds. */ + sources: Set + hasOutputTruncated: boolean + hasEnd: boolean +} + +/** + * Starts a node-server journal's progress, with nothing accepted yet. + * @returns A progress at the journal's beginning. + * @example newNodeServerJournalProgress().entryCount // => 0 + */ +export function newNodeServerJournalProgress(): NodeServerJournalProgress { + return { + entryCount: 0, + lastElapsedMs: 0, + lastAttempt: 0, + openAttemptSent: false, + hasTerminal: false, + stopCount: 0, + hitsByStop: new Map(), + sources: new Set(), + hasOutputTruncated: false, + hasEnd: false, + } +} + +/** + * Copies a progress so a rule can be tried on the copy; the writer's `check` validates on a copy and applies the entry to its own progress only once the line is durable. + * @param progress - The progress to copy. + * @returns An independent progress with the same state. + * @example const trial = cloneNodeServerJournalProgress(progress) + */ +export function cloneNodeServerJournalProgress( + progress: NodeServerJournalProgress, +): NodeServerJournalProgress { + return { + ...progress, + hitsByStop: new Map(progress.hitsByStop), + sources: new Set(progress.sources), + } +} + +/** + * Names a source by its path and hash, the identity under which a `source` entry is written once. + * @param path - The repository-relative path. + * @param hash - The SHA-256 of the file's text. + * @returns The key a progress keeps. + * @example nodeServerSourceKey('src/a.ts', 'ab') // => 'src/a.ts\u0000ab' + */ +export function nodeServerSourceKey(path: string, hash: string): string { + return `${path}\u0000${hash}` +} + +/** + * Applies the node-server journal's ordering rules to the next entry: consecutive sequences, time that never goes back, at most one open request attempt, stops only while an attempt is open and after their `source` exists, at most 16 stops, one terminal entry that closes the request, and `end` last. Called for each entry by the node-server writer before it writes, by {@link advanceRecordingScan} while another window records, and by the store's full read. + * @param progress - What the entries before this one established; updated only when the entry is accepted. + * @param entry - The next parsed entry. + * @param header - The history's header, whose declared stops bound `declaredStopIndex`. + * @throws When the entry breaks an ordering rule; `progress` is left as it was. + * @example acceptNodeServerEntry(progress, requestEntry, header) // progress.openAttempt => 1 + */ +export function acceptNodeServerEntry( + progress: NodeServerJournalProgress, + entry: NodeServerHistoryEntry, + header: Pick, +): void { + if (progress.hasEnd) + throw new Error('Node server history has an entry after its end.') + if (entry.sequence !== progress.entryCount + 1) + throw new Error('Node server history sequence is not consecutive.') + if (entry.elapsedMs < progress.lastElapsedMs) + throw new Error('Node server history time moved backward.') + applyEntryRule(progress, entry, header) + progress.lastElapsedMs = entry.elapsedMs + progress.entryCount++ +} + +type EntryOfType = Extract< + NodeServerHistoryEntry, + { type: Type } +> +type RuleHeader = Pick + +/** One rule per entry type: checks the entry against the progress and records what it establishes. */ +const ENTRY_RULES: { + [Type in NodeServerHistoryEntry['type']]: ( + progress: NodeServerJournalProgress, + entry: EntryOfType, + header: RuleHeader, + ) => void +} = { + output: (progress) => { + if (progress.hasOutputTruncated) + throw new Error('Node server history output follows its truncation.') + }, + 'output-truncated': (progress) => { + if (progress.hasOutputTruncated) + throw new Error('Node server history truncates its output twice.') + progress.hasOutputTruncated = true + }, + request: (progress, entry) => { + if (progress.hasTerminal || progress.openAttempt !== undefined) + throw new Error( + 'Node server history opens a request while one is open or finished.', + ) + if (entry.attempt !== progress.lastAttempt + 1) + throw new Error('Node server history request attempts are not counted.') + progress.lastAttempt = entry.attempt + progress.openAttempt = entry.attempt + progress.openAttemptSent = false + }, + 'request-withdrawn': (progress, entry) => { + if (progress.openAttempt !== entry.attempt || progress.openAttemptSent) + throw new Error('Node server history withdraws no open, unsent request.') + progress.openAttempt = undefined + }, + 'request-sent': (progress, entry) => { + if (progress.openAttempt !== entry.attempt || progress.openAttemptSent) + throw new Error('Node server history marks no open request as sent.') + progress.openAttemptSent = true + }, + source: (progress, entry) => { + const key = nodeServerSourceKey(entry.path, entry.hash) + if (progress.sources.has(key)) + throw new Error('Node server history repeats a source entry.') + progress.sources.add(key) + }, + stop: acceptStop, + response: (progress) => { + if (progress.openAttempt === undefined || !progress.openAttemptSent) + throw new Error('Node server history answers a request never sent.') + closeRequest(progress) + }, + 'request-failed': (progress) => { + if (progress.hasTerminal || progress.lastAttempt === 0) + throw new Error('Node server history fails a request never opened.') + closeRequest(progress) + }, + end: (progress) => { + progress.hasEnd = true + }, +} + +/** Checks the rule of one entry type and records what it establishes; the per-type half of {@link acceptNodeServerEntry}. */ +function applyEntryRule( + progress: NodeServerJournalProgress, + entry: NodeServerHistoryEntry, + header: RuleHeader, +): void { + // The table is keyed by type, so each entry meets exactly its own rule; the lookup is the one place the union is narrowed. + const rule = ENTRY_RULES[entry.type] as ( + progress: NodeServerJournalProgress, + entry: NodeServerHistoryEntry, + header: RuleHeader, + ) => void + rule(progress, entry, header) +} + +/** Checks one `stop` entry against the open attempt, the declared stops, the stop limit, the hit count and its `source` entry. */ +function acceptStop( + progress: NodeServerJournalProgress, + entry: EntryOfType<'stop'>, + header: RuleHeader, +): void { + if (progress.openAttempt === undefined || progress.hasTerminal) + throw new Error('Node server history stops outside an open request.') + if (entry.declaredStopIndex >= header.declaredStops.length) + throw new Error('Node server history stops at an undeclared stop.') + if (progress.stopCount >= NODE_SERVER_HISTORY_MAX_STOPS) + throw new Error('Node server history exceeds its stop limit.') + const hits = progress.hitsByStop.get(entry.declaredStopIndex) ?? 0 + if (entry.hit !== hits + 1) + throw new Error('Node server history stop hits are not counted.') + if ( + !progress.sources.has( + nodeServerSourceKey(entry.source.path, entry.source.hash), + ) + ) + throw new Error('Node server history stop has no earlier source entry.') + progress.hitsByStop.set(entry.declaredStopIndex, entry.hit) + progress.stopCount++ +} + +/** Closes the run's request for good after its terminal entry. */ +function closeRequest(progress: NodeServerJournalProgress): void { + progress.hasTerminal = true + progress.openAttempt = undefined + progress.openAttemptSent = false +} diff --git a/extension/src/utils/recordingJournalScan.ts b/extension/src/utils/recordingJournalScan.ts index a35460e1..20269539 100644 --- a/extension/src/utils/recordingJournalScan.ts +++ b/extension/src/utils/recordingJournalScan.ts @@ -1,11 +1,18 @@ import { lstat, open } from 'node:fs/promises' import { NODE_TEST_HISTORY_LIMIT_BYTES } from '../constants' +import type { + HistoryEntryBase, + HistoryHeaderBase, + HistoryScanRules, + HistoryTypes, +} from '../historyStorageDescriptor' import { nodeTestHistoryEntrySchema, type NodeTestHistoryEntry, type NodeTestHistoryHeader, } from '../nodeTestHistorySchemas' +import type { NodeTestHistoryTypes } from '../nodeTestHistoryTypes' /** A journal event a learning step can cite, as the store keeps it to check a learning material without reading the journal again. */ export type CitableEvent = @@ -26,28 +33,42 @@ export type JournalProgress = { * What a test history this window still writes keeps between two refreshes, so that a refresh reads only the bytes appended since the last one instead of the whole journal (a journal of thousands of output entries was read, parsed and summarized in full at every refresh after it grew). * Only the stops and the result are kept whole, as they are all a summary counts; every entry is kept as a {@link CitableEvent}. */ -export type RecordingScan = { +export type RecordingScan< + Header extends HistoryHeaderBase = NodeTestHistoryHeader, + Entry extends HistoryEntryBase = NodeTestHistoryEntry, + Progress = JournalProgress, +> = { /** The header as read when the scan began, and the signal of the header file it was read from: a changed header file restarts the scan. */ - header: NodeTestHistoryHeader + header: Header headerSignal: string /** The journal's inode: a replaced file restarts the scan. */ journalInode: number /** The bytes of the journal consumed through its last complete line; the next refresh reads from here. */ offset: number - progress: JournalProgress - /** The stop entries and the result entry, in journal order. */ - kept: NodeTestHistoryEntry[] + progress: Progress + /** The entries a summary counts (for a test history the stops and the result), in journal order. */ + kept: Entry[] citable: Map /** Why the verified prefix ended before the journal's end, for a bad entry; nothing after it is read. */ brokenReason?: string } +/** The node-test profile's journal-scan rules: the same rules the full read applies. */ +export const NODE_TEST_SCAN_RULES: HistoryScanRules = { + journalLimitBytes: NODE_TEST_HISTORY_LIMIT_BYTES, + parseEntry: (value) => nodeTestHistoryEntrySchema.parse(value), + start: () => newJournalProgress(), + accept: (progress, entry) => acceptJournalEntry(progress, entry), + citableOf: citableEventOf, + keeps: (entry) => entry.type !== 'output', +} + /** * Starts a journal's progress, with nothing accepted yet. * @returns A progress at the journal's beginning. * @example newJournalProgress() // => { entryCount: 0, lastElapsedMs: 0, stopCount: 0, hasResult: false, passingResult: false } */ -export function newJournalProgress(): JournalProgress { +function newJournalProgress(): JournalProgress { return { entryCount: 0, lastElapsedMs: 0, @@ -64,7 +85,7 @@ export function newJournalProgress(): JournalProgress { * @throws When the entry breaks an ordering rule; `progress` is left as it was. * @example acceptJournalEntry(progress, entry) // progress.entryCount grows by one */ -export function acceptJournalEntry( +function acceptJournalEntry( progress: JournalProgress, entry: NodeTestHistoryEntry, ): void { @@ -91,7 +112,7 @@ export function acceptJournalEntry( * @returns The citable form of the entry. * @example citableEventOf(outputEntry) // => { type: 'output', channel: 'stdout' } */ -export function citableEventOf(entry: NodeTestHistoryEntry): CitableEvent { +function citableEventOf(entry: NodeTestHistoryEntry): CitableEvent { if (entry.type === 'stop') return { type: 'stop', @@ -117,18 +138,26 @@ export function citableEventOf(entry: NodeTestHistoryEntry): CitableEvent { * @throws When the journal is no bounded regular file or cannot be read. * @example scan = await advanceRecordingScan(scan, header, headerSignal, join(directory, 'events.jsonl'), (error) => store.describeCause(error)) */ -export async function advanceRecordingScan( - previous: RecordingScan | undefined, - header: NodeTestHistoryHeader, +export async function advanceHistoryScan( + previous: + | RecordingScan + | undefined, + header: Types['Header'], headerSignal: string, journalPath: string, describeCause: (error: unknown) => string, -): Promise { + rules: HistoryScanRules, + interruptedPrefix: string, +): Promise> { const journal = await lstat(journalPath) // lstat follows no link, so a link is no file here. - if (!journal.isFile() || journal.size > NODE_TEST_HISTORY_LIMIT_BYTES) + if (!journal.isFile() || journal.size > rules.journalLimitBytes) throw new Error('Node history file is not a bounded regular file.') - const scan: RecordingScan = + const scan: RecordingScan< + Types['Header'], + Types['Entry'], + Types['Progress'] + > = previous && previous.headerSignal === headerSignal && previous.journalInode === journal.ino && @@ -139,7 +168,7 @@ export async function advanceRecordingScan( headerSignal, journalInode: journal.ino, offset: 0, - progress: newJournalProgress(), + progress: rules.start(), kept: [], citable: new Map(), } @@ -160,15 +189,45 @@ export async function advanceRecordingScan( const lines = appended.subarray(0, lastLineEnd).toString('utf8').split('\n') for (const line of lines) { try { - const entry = nodeTestHistoryEntrySchema.parse(JSON.parse(line)) - acceptJournalEntry(scan.progress, entry) - scan.citable.set(entry.sequence, citableEventOf(entry)) - if (entry.type !== 'output') scan.kept.push(entry) + const entry = rules.parseEntry(JSON.parse(line)) + rules.accept(scan.progress, entry, scan.header) + const citable = rules.citableOf(entry) + if (citable) scan.citable.set(entry.sequence, citable) + if (rules.keeps(entry)) scan.kept.push(entry) } catch (error) { - scan.brokenReason = `Interrupted Node history after its verified prefix: ${describeCause(error)}` + scan.brokenReason = `${interruptedPrefix} ${describeCause(error)}` break } } scan.offset += lastLineEnd + 1 return scan } + +/** + * Continues a test history's recording scan with the node-test rules; the signature the store and its tests used before the scan became generic ({@link advanceHistoryScan}). + * @param previous - The scan of the last refresh, if any. + * @param header - The history's header as read now. + * @param headerSignal - The header file's signal as read now. + * @param journalPath - The journal file. + * @param describeCause - Words a broken entry's failure without private paths. + * @returns The scan (the same object as `previous` when it could be continued). + * @throws When the journal is no bounded regular file or cannot be read. + * @example scan = await advanceRecordingScan(scan, header, headerSignal, join(directory, 'events.jsonl'), (error) => store.describeCause(error)) + */ +export function advanceRecordingScan( + previous: RecordingScan | undefined, + header: NodeTestHistoryHeader, + headerSignal: string, + journalPath: string, + describeCause: (error: unknown) => string, +): Promise { + return advanceHistoryScan( + previous, + header, + headerSignal, + journalPath, + describeCause, + NODE_TEST_SCAN_RULES, + 'Interrupted Node history after its verified prefix:', + ) +} diff --git a/extension/src/utils/rehydrateServerStopSnapshot.ts b/extension/src/utils/rehydrateServerStopSnapshot.ts new file mode 100644 index 00000000..440dc21c --- /dev/null +++ b/extension/src/utils/rehydrateServerStopSnapshot.ts @@ -0,0 +1,66 @@ +import type { + NodeServerHistoryEntry, + NodeServerSourceEntry, + NodeServerStopEntry, +} from '../nodeServerHistorySchemas' +import { + replayDebugSnapshotSchema, + type ReplayDebugSnapshot, +} from '../replayDebugSchemas' + +import { nodeServerSourceKey } from './nodeServerJournalRules' + +/** + * Restores a stop's full snapshot from the `source` entry its top frame names by hash, so a replay presents the executed text exactly as the debugger captured it. + * Called by {@link rehydrateNodeServerEntries} for each stop the store's read path returns to a replay, and by the capture evaluator before it judges presentability. + * @param stop - The stored stop, whose top-frame source text is the empty marker of {@link dehydrateServerStopSnapshot}. + * @param sources - The journal's `source` entries by {@link nodeServerSourceKey}. + * @returns The snapshot with the top frame's text restored; with the text removed and `sourceUnavailable: 'too large'` set when the source entry holds only a hash; unchanged when the top frame names no stored source. + * @example rehydrateServerStopSnapshot(stop, sources).frames[0]?.source?.text // => 'export const handler = …' + */ +function rehydrateServerStopSnapshot( + stop: NodeServerStopEntry, + sources: ReadonlyMap, +): ReplayDebugSnapshot { + const snapshot = stop.debuggerSnapshot + const [top, ...lower] = snapshot.frames + const stored = sources.get( + nodeServerSourceKey(stop.source.path, stop.source.hash), + ) + // Only a top frame that names the stop's own hash is restored; a mismatching frame stays as stored and the capture evaluator reports it. + if (!top?.source || !stored || top.source.hash !== stop.source.hash) + return snapshot + const restored = + stored.text === undefined + ? { + ...top, + source: undefined, + sourceUnavailable: stored.sourceUnavailable ?? 'too large', + } + : { ...top, source: { ...top.source, text: stored.text } } + return replayDebugSnapshotSchema.parse({ + ...snapshot, + frames: [restored, ...lower], + }) +} + +/** + * Lists a journal's entries with every stop's snapshot restored, which is the one place the read path rehydrates; `source` entries are kept as storage-only entries. + * @param entries - The verified journal's entries in order. + * @returns The same entries, each stop carrying its full snapshot. + * @example rehydrateNodeServerEntries(history.entries).filter((entry) => entry.type === 'stop') + */ +export function rehydrateNodeServerEntries( + entries: readonly NodeServerHistoryEntry[], +): NodeServerHistoryEntry[] { + const sources = new Map() + return entries.map((entry) => { + if (entry.type === 'source') + sources.set(nodeServerSourceKey(entry.path, entry.hash), entry) + if (entry.type !== 'stop') return entry + return { + ...entry, + debuggerSnapshot: rehydrateServerStopSnapshot(entry, sources), + } + }) +} diff --git a/extension/test-timings.json b/extension/test-timings.json index 546a0116..0aee6461 100644 --- a/extension/test-timings.json +++ b/extension/test-timings.json @@ -838,6 +838,9 @@ "managedProfileRoot.test.js": { "totalMs": 1 }, + "maskNodeServerRequest.test.js": { + "totalMs": 1 + }, "maximizedEditorAreaWidthPx.test.js": { "totalMs": 1 }, @@ -1081,9 +1084,15 @@ "nextNodeHistoryStepId.test.js": { "totalMs": 1 }, + "nodeHistoryAdmission.test.js": { + "totalMs": 159 + }, "nodeHistoryPlaybackController.test.js": { "totalMs": 362 }, + "nodeHistorySealCoordinator.test.js": { + "totalMs": 192 + }, "nodeHistoryStepTarget.test.js": { "totalMs": 1 }, @@ -1093,6 +1102,26 @@ "nodeLearningSave.test.js": { "totalMs": 3 }, + "nodeServerHistoryBounds.test.js": { + "totalMs": 19644, + "tests": { + "output past 2,000 entries is dropped with one output-truncated entry while the stop and the response are still written": 11660, + "output past its 2 MiB byte budget is dropped with one output-truncated entry": 2760, + "output offered in one turn past the 1,000-entry queue is dropped under the same code and the structural entries still wait their turn": 5224 + } + }, + "nodeServerHistoryJournalIntegrity.test.js": { + "totalMs": 434 + }, + "nodeServerHistorySeal.test.js": { + "totalMs": 1909 + }, + "nodeServerHistoryStore.test.js": { + "totalMs": 175 + }, + "nodeServerHistoryWriter.test.js": { + "totalMs": 590 + }, "nodeStopSourceMatches.test.js": { "totalMs": 189 }, @@ -1108,6 +1137,9 @@ "nodeTestHistorySummary.test.js": { "totalMs": 343 }, + "nodeTestHistoryV0253Compatibility.test.js": { + "totalMs": 12 + }, "nodeTestOutputTerminal.test.js": { "totalMs": 2 },