From 6b1bfdcba922186728e799af1dbe06b6e02db74f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 15:13:52 +0900 Subject: [PATCH 01/19] fix: an interrupted server history with no answer reads capture-incomplete, and a recording reports a source it could not store (Cycle 0059 pt2) Also pins the writer's sealed, abandoned and seal-failed states, a second or early request-sent mark, and the request mask's undecodable name and composition. --- extension/src/nodeServerStorageDescriptor.ts | 11 +- .../tests/maskNodeServerConfiguration.test.ts | 73 +++++++ .../nodeServerHistoryInterrupted.test.ts | 99 ++++++++++ .../nodeServerHistoryWriterStates.test.ts | 183 ++++++++++++++++++ .../src/utils/evaluateNodeServerCapture.ts | 19 +- 5 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 extension/src/tests/maskNodeServerConfiguration.test.ts create mode 100644 extension/src/tests/nodeServerHistoryInterrupted.test.ts create mode 100644 extension/src/tests/nodeServerHistoryWriterStates.test.ts diff --git a/extension/src/nodeServerStorageDescriptor.ts b/extension/src/nodeServerStorageDescriptor.ts index 87e94a251..b13fda3d1 100644 --- a/extension/src/nodeServerStorageDescriptor.ts +++ b/extension/src/nodeServerStorageDescriptor.ts @@ -82,9 +82,11 @@ export const nodeServerStorageDescriptor: HistoryStorageDescriptor undefined, - // A recording keeps what a summary counts: everything but output and the storage-only source texts. + // A recording keeps what a summary counts: everything but output and the storage-only source texts; a source entry stays only when its text was not stored, so the summary words it like the seal. keeps: (entry: NodeServerHistoryEntry) => - entry.type !== 'output' && entry.type !== 'source', + entry.type === 'source' + ? entry.sourceUnavailable !== undefined + : entry.type !== 'output', }, createWriter: (directory, header, onClosed, describeCause) => new NodeServerHistoryWriter(directory, header, onClosed, describeCause), @@ -108,7 +110,10 @@ export const nodeServerStorageDescriptor: HistoryStorageDescriptor 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) + seal?.reasons ?? + evaluateNodeServerCapture(header, entries, { + interrupted: history.status === 'interrupted', + }) const capture = capMissingList(reasons) const learning = evaluateNodeServerLearning(entries, undefined) const learningMissing = capMissingList(learning.missing) diff --git a/extension/src/tests/maskNodeServerConfiguration.test.ts b/extension/src/tests/maskNodeServerConfiguration.test.ts new file mode 100644 index 000000000..dfcb7994c --- /dev/null +++ b/extension/src/tests/maskNodeServerConfiguration.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { nodeServerConfigSchema } from '../nodeServerConfigSchemas' +import { maskNodeServerConfiguration } from '../utils/maskNodeServerConfiguration' +import { maskSecretFormPairs } from '../utils/maskSecretFormPairs' + +import { nodeServerHistoryHeader } from './utils/nodeServerHistoryFixture' + +/** The fixture's declaration parsed, with `request` replaced by `request`. */ +function declarationWith(request: Record) { + const { configuration } = nodeServerHistoryHeader('a'.repeat(64)) + return nodeServerConfigSchema.parse({ + ...configuration, + request, + }) +} + +// protects=a form pair whose name cannot be percent-decoded is masked whole, as it cannot be told from a secret; fails_when=the undecodable name keeps its value or throws; no_claim=names that decode +test('a form pair whose name is not valid percent-encoding has its value masked', () => { + // Arrange + const pairs = 'page=2&%E0%A4%A=hunter2&plain' + + // Act + const masked = maskSecretFormPairs(pairs) + + // Assert + assert.equal(masked, 'page=2&%E0%A4%A=[masked]&plain') +}) + +// protects=a declaration with no request body is masked without inventing a body; fails_when=the mask adds a body key or throws when body is absent; no_claim=the query mask itself +test('masking a declaration with no request body masks the path and adds no body', () => { + // Arrange + const configuration = declarationWith({ + method: 'GET', + path: '/api/items?id=3&api_key=k3', + }) + + // Act + const masked = maskNodeServerConfiguration(configuration) + + // Assert + assert.equal(masked.request.path, '/api/items?id=3&api_key=[masked]') + assert.equal('body' in masked.request, false) +}) + +// protects=the mask composes: path query values and body fields are masked together and every other declared field is unchanged; fails_when=one of them keeps a secret or the mask edits the port, entry or source; no_claim=the original declaration (it stays untouched) +test('masking a declaration masks the path and the JSON body together and leaves the rest and the original unchanged', () => { + // Arrange + const configuration = declarationWith({ + method: 'POST', + path: '/api/login?access_token=abc&next=/home', + contentType: 'application/json', + body: '{"user":"ada","password":"hunter2"}', + }) + + // Act + const masked = maskNodeServerConfiguration(configuration) + + // Assert + assert.equal( + masked.request.path, + '/api/login?access_token=[masked]&next=/home', + ) + assert.equal(masked.request.body, '{"user":"ada","password":"[masked]"}') + assert.equal(masked.port, configuration.port) + assert.equal(masked.entry, configuration.entry) + assert.deepEqual(masked.source, configuration.source) + assert.equal( + configuration.request.body, + '{"user":"ada","password":"hunter2"}', + ) +}) diff --git a/extension/src/tests/nodeServerHistoryInterrupted.test.ts b/extension/src/tests/nodeServerHistoryInterrupted.test.ts new file mode 100644 index 000000000..d3fc1d247 --- /dev/null +++ b/extension/src/tests/nodeServerHistoryInterrupted.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +// protects=a server history interrupted before any answer reads capture-incomplete with the no-response reason after a re-read; fails_when=the library reads it as a complete capture because the journal has no end and no terminal entry; no_claim=a history whose append failed (it names the append reason instead) +test('a re-read interrupted server history with no end and no terminal entry reads capture-incomplete with the no-response reason', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + declaredStops: 1, + }) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 3 }), + ) + // The window crashes: its writer is gone and nothing sealed the folder. + writer.abandon() + + // Act + const [summary] = await repository.serverHistories.list() + + // Assert + assert.equal(summary?.status, 'interrupted') + assert.equal(summary?.captureComplete, false) + assert.deepEqual( + summary?.captureMissing.map((reason) => [reason.code, reason.text]), + [ + [ + 'SERVER_HISTORY_NO_RESPONSE_STOPPED', + 'No response was recorded: the recording ended before the server answered.', + ], + ], + ) +}) + +// protects=the pre-seal summary of a recording words an executed source that was not stored like the seal does; fails_when=the incremental scan drops the source entry so SERVER_HISTORY_SOURCE_UNAVAILABLE is missing before the seal; no_claim=the reason's wording for a text above the size bound +test('a server history still recording reports the source-unavailable reason of a stop whose text was not stored', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + declaredStops: 1, + }) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + const capture = serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 3, + }) + const [top] = capture.snapshot.frames + // The frame's text does not hash to the stop's source hash, so the source entry holds its hash with `not captured`. + await writer.appendStop({ + ...capture, + snapshot: { + ...capture.snapshot, + frames: [ + { ...top!, source: { ...top!.source!, text: 'not the executed text' } }, + ], + }, + }) + + // Act + const [summary] = await repository.serverHistories.list() + + // Assert + assert.equal(summary?.status, 'recording') + assert.deepEqual( + summary?.captureMissing.map((reason) => reason.code), + ['SERVER_HISTORY_SOURCE_UNAVAILABLE'], + ) + writer.abandon() +}) diff --git a/extension/src/tests/nodeServerHistoryWriterStates.test.ts b/extension/src/tests/nodeServerHistoryWriterStates.test.ts new file mode 100644 index 000000000..7168c7ac8 --- /dev/null +++ b/extension/src/tests/nodeServerHistoryWriterStates.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict' +import fsPromises, { readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { before, test } from 'node:test' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' + +before(async () => { + await realProcessStart(process.pid) +}) + +/** Starts a server history, opens its request with the sent mark and returns what the state tests need. */ +async function startOpenRequest( + context: Parameters[0], +) { + const { repository, learningDirectory } = 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: 14, + }) + return { + repository, + writer, + header, + store: repository.serverHistories, + folder: join(learningDirectory, 'node-servers.v1', header.studyId), + } +} + +// protects=a seal that already published stays the history's truth when the writer is abandoned afterwards; fails_when=abandon after a seal removes the seal or reads the history as interrupted; no_claim=an abandon during the publish (covered by the seal coordinator tests) +test('abandoning a writer after its seal keeps the sealed history sealed', async (context) => { + // Arrange + const { writer, store, header, folder } = await startOpenRequest(context) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.seal({ elapsedMs: 3, reason: 'stopped' }) + + // Act + writer.abandon() + const opened = await store.read(header.studyId) + + // Assert + assert.equal(opened.status, 'partial') + assert.equal(opened.seal?.entryCount, 3) + assert.ok((await readdir(folder)).includes('sealed.json')) +}) + +// protects=an abandoned writer accepts nothing more; fails_when=an entry offered after abandon is written after the history was reported interrupted; no_claim=the wording of the refusal beyond naming the closed history +test('an abandoned writer refuses a later entry and leaves its journal as it was', async (context) => { + // Arrange + const { writer, store, header } = await startOpenRequest(context) + writer.abandon() + + // Act + const refused = writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + + // Assert + await assert.rejects(refused, /already sealing or sealed/) + const opened = await store.read(header.studyId) + assert.deepEqual( + opened.entries.map((entry) => entry.type), + ['request'], + ) + assert.equal(opened.status, 'interrupted') +}) + +// protects=a seal requested on an abandoned writer publishes nothing and does not throw; fails_when=finish after abandon writes sealed.json over a folder already reported interrupted; no_claim=the return value of a normal seal +test('finish on an abandoned writer resolves with no hash and publishes no seal', async (context) => { + // Arrange + const { writer, store, header, folder } = await startOpenRequest(context) + writer.abandon() + + // Act + const hash = await writer.finish('partial') + + // Assert + assert.equal(hash, undefined) + assert.equal((await readdir(folder)).includes('sealed.json'), false) + assert.equal((await store.read(header.studyId)).status, 'interrupted') +}) + +// protects=abandoning twice is harmless; fails_when=the second abandon throws or changes the interrupted state; no_claim=the writer record's removal timing +test('abandoning an abandoned writer again keeps the history interrupted', async (context) => { + // Arrange + const { writer, store, header } = await startOpenRequest(context) + writer.abandon() + + // Act + writer.abandon() + const opened = await store.read(header.studyId) + + // Assert + assert.equal(opened.status, 'interrupted') + assert.equal(opened.entries.length, 1) +}) + +// protects=a writer abandoned after its sealed.json write failed stays abandoned and a late retry publishes nothing; fails_when=the retry seals the folder that was reported interrupted; no_claim=which write failed +test('a writer abandoned after its seal write failed publishes no seal on a later retry', async (context) => { + // Arrange + const { writer, store, header, folder } = await startOpenRequest(context) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + const renameBefore = fsPromises.rename + context.mock.method( + fsPromises, + 'rename', + async (...input: Parameters) => { + if (String(input[1]).endsWith('sealed.json')) + throw new Error('rename refused') + return renameBefore(...input) + }, + ) + await assert.rejects( + writer.seal({ elapsedMs: 3, reason: 'stopped' }), + /rename refused/, + ) + + // Act + writer.abandon() + context.mock.restoreAll() + const retried = await writer.seal({ elapsedMs: 4, reason: 'stopped' }) + const opened = await store.read(header.studyId) + + // Assert + assert.equal(retried, undefined) + assert.equal((await readdir(folder)).includes('sealed.json'), false) + assert.equal(opened.status, 'interrupted') +}) + +// protects=a request-sent mark is accepted once, for the open attempt; fails_when=a second mark for the same attempt or a mark before any request is written; no_claim=attempts beyond the first +for (const markCase of [ + { + name: 'a second request-sent mark for the same attempt', + prepare: async ( + writer: Awaited>['writer'], + ): Promise => { + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + }, + }, + { + name: 'a request-sent mark for an attempt never opened', + prepare: async (): Promise => {}, + attempt: 2, + }, +]) { + test(`the server writer refuses ${markCase.name}`, async (context) => { + // Arrange + const { writer } = await startOpenRequest(context) + await markCase.prepare(writer) + + // Act + const refused = writer.appendRequestSent({ + elapsedMs: 3, + attempt: markCase.attempt ?? 1, + }) + + // Assert + await assert.rejects(refused, /marks no open request as sent/) + }) +} + +// protects=a request-sent mark with no request at all is refused by the journal rules; fails_when=the writer accepts a mark that no request opened; no_claim=the writer's later seal +test('the server writer refuses a request-sent mark before any request was written', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const writer = await repository.serverHistories.start( + nodeServerHistoryHeader(SERVER_WORKSPACE_KEY), + ) + + // Act + const refused = writer.appendRequestSent({ elapsedMs: 1, attempt: 1 }) + + // Assert + await assert.rejects(refused, /marks no open request as sent/) +}) diff --git a/extension/src/utils/evaluateNodeServerCapture.ts b/extension/src/utils/evaluateNodeServerCapture.ts index 25c33b6f1..89420d07a 100644 --- a/extension/src/utils/evaluateNodeServerCapture.ts +++ b/extension/src/utils/evaluateNodeServerCapture.ts @@ -18,6 +18,8 @@ export type NodeServerCaptureFacts = { appendFailure?: { lastEntry: string; cause: string } sizeLimitReached?: boolean captureBudgetExhausted?: boolean + /** Whether the journal is read as an interrupted history: no seal and no writer, so the missing `end` means the recording stopped without one. */ + interrupted?: boolean } /** @@ -50,6 +52,7 @@ function describeExit( /** The "No response was recorded" reason of a history with no terminal entry, or the request-failure one. */ function noResponseReason( entries: readonly NodeServerHistoryEntry[], + interrupted: boolean, ): NodeServerSealReason | undefined { if (entries.some((entry) => entry.type === 'response')) return undefined const failed = entries.find((entry) => entry.type === 'request-failed') @@ -60,8 +63,15 @@ function noResponseReason( 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) + // An interrupted history ended with no answer and no `end`; a history whose append failed says why in the append reason instead. + return interrupted + ? { + code: CODES.noResponseStopped, + params: { interrupted: 'true' }, + text: 'No response was recorded: the recording ended before the server answered.', + } + : undefined if (end.reason === 'stopped') return { code: CODES.noResponseStopped, @@ -156,7 +166,10 @@ export function evaluateNodeServerCapture( facts: NodeServerCaptureFacts = {}, ): NodeServerSealReason[] { const reasons: NodeServerSealReason[] = [] - const noResponse = noResponseReason(entries) + const noResponse = noResponseReason( + entries, + facts.interrupted === true && !facts.appendFailure, + ) if (noResponse) reasons.push(noResponse) reasons.push( ...stopReasons( From 912ab73b789b251f7f936d507511eb4e815a2261 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 15:22:57 +0900 Subject: [PATCH 02/19] feat: server histories keep the chat's learning material under the storage lock, cited by request, stop, output and response (Cycle 0059 pt2) One learning-material codec per profile; two windows saving one revision get one success and one conflict, and a save racing a Trash move leaves no folder. --- extension/src/historyStorageDescriptor.ts | 44 ++- extension/src/nodeServerHistoryTypes.ts | 8 + extension/src/nodeServerLearningSchemas.ts | 60 ++++ extension/src/nodeServerStorageDescriptor.ts | 68 +++- extension/src/nodeTestHistorySchemas.ts | 119 ++++--- extension/src/nodeTestHistoryStore.ts | 151 ++------- extension/src/nodeTestHistoryTypes.ts | 4 + extension/src/nodeTestStorageDescriptor.ts | 17 +- .../src/tests/nodeHistoryMaterialLock.test.ts | 297 ++++++++++++++++++ .../src/tests/utils/sealedServerHistory.ts | 110 +++++++ .../src/utils/assertNodeLearningCitations.ts | 181 +++++++++++ extension/src/utils/recordingJournalScan.ts | 3 + 12 files changed, 871 insertions(+), 191 deletions(-) create mode 100644 extension/src/nodeServerLearningSchemas.ts create mode 100644 extension/src/tests/nodeHistoryMaterialLock.test.ts create mode 100644 extension/src/tests/utils/sealedServerHistory.ts create mode 100644 extension/src/utils/assertNodeLearningCitations.ts diff --git a/extension/src/historyStorageDescriptor.ts b/extension/src/historyStorageDescriptor.ts index ee4832626..dc34488f4 100644 --- a/extension/src/historyStorageDescriptor.ts +++ b/extension/src/historyStorageDescriptor.ts @@ -1,3 +1,4 @@ +import type { CitationReference } from './utils/assertNodeLearningCitations' import type { NodeLearningNeeds } from './utils/listMissingNodeExplanations' import type { CitableEvent } from './utils/recordingJournalScan' @@ -39,13 +40,25 @@ export type HistorySummaryBase = { verification: 'verified' | 'unavailable' } -/** The five types one profile fixes: what a store with its descriptor reads and writes. */ +/** The fields every saved learning material has, whatever its profile. */ +export type HistoryMaterialBase = { + studyId: string + revision: number + updatedAt: string + content: { steps: readonly { id: string; reference?: CitationReference }[] } +} + +/** The 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 learning material the chat saves for a history of this profile. */ + Material: HistoryMaterialBase + /** The content of that material, as the save route offers it. */ + MaterialContent: HistoryMaterialBase['content'] /** The writer class a store of this profile hands to its caller. */ Writer: object } @@ -105,6 +118,27 @@ export type HistoryScanRules = { keeps(entry: Types['Entry']): boolean } +/** What a store asks of a profile to keep the chat's learning material beside its journal. */ +export type HistoryLearningMaterialCodec = { + /** Parses a saved material file. @throws When it does not parse. */ + parseMaterial(value: unknown): Types['Material'] + /** Parses the content an agent offers. @throws When it does not parse; the refusal names the offending element. */ + parseContent(value: unknown): Types['MaterialContent'] + /** Builds the saved material of a content at its revision. */ + buildMaterial(input: { + studyId: string + revision: number + updatedAt: string + content: Types['MaterialContent'] + }): Types['Material'] + /** Refuses a content whose step cites what the journal does not hold. @throws An `INVALID_REFERENCE` error. */ + assertCitations( + content: Types['MaterialContent'], + citable: ReadonlyMap, + lead?: (stepId: string) => string, + ): void +} + /** * 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}. @@ -140,8 +174,8 @@ export type HistoryStorageDescriptor = { 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 + /** How this profile's learning material is parsed, built and checked against the journal. */ + readonly learningMaterial: HistoryLearningMaterialCodec emptySummary(header: Types['Header']): Types['Summary'] /** Counts a verified history into a summary. */ summarize(input: { @@ -149,14 +183,14 @@ export type HistoryStorageDescriptor = { HistoryIndexOf, 'header' | 'entries' | 'status' | 'reason' | 'seal' > - materialContent?: unknown + materialContent?: Types['MaterialContent'] 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 + materialContent?: Types['MaterialContent'] materialRevision: number }): Types['Summary'] } diff --git a/extension/src/nodeServerHistoryTypes.ts b/extension/src/nodeServerHistoryTypes.ts index f5d740d09..cd742c192 100644 --- a/extension/src/nodeServerHistoryTypes.ts +++ b/extension/src/nodeServerHistoryTypes.ts @@ -5,6 +5,10 @@ import type { NodeServerSealReason, } from './nodeServerHistorySchemas' import type { NodeServerHistoryWriter } from './nodeServerHistoryWriter' +import type { + NodeServerLearningMaterial, + NodeServerLearningMaterialContent, +} from './nodeServerLearningSchemas' import type { NodeServerLearningCoverage, NodeServerLearningPart, @@ -23,6 +27,8 @@ export type NodeServerHistorySummary = { reason?: string verification: 'verified' | 'unavailable' question: string + /** The saved learning title, absent before the chat saved its explanation. */ + title?: string /** The declared request as the header stores it, `POST /api/x`. */ requestLine: string /** The recorded response status, absent before a response. */ @@ -51,5 +57,7 @@ export type NodeServerHistoryTypes = { Seal: NodeServerHistorySeal Progress: NodeServerJournalProgress Summary: NodeServerHistorySummary + Material: NodeServerLearningMaterial + MaterialContent: NodeServerLearningMaterialContent Writer: NodeServerHistoryWriter } diff --git a/extension/src/nodeServerLearningSchemas.ts b/extension/src/nodeServerLearningSchemas.ts new file mode 100644 index 000000000..71faaee4a --- /dev/null +++ b/extension/src/nodeServerLearningSchemas.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +import { + NODE_SERVER_HISTORY_MAX_ENTRIES, + NODE_TEST_LEARNING_MATERIAL_VERSION, +} from './constants' +import { studyIdSchema } from './legacyStudySchemas' +import { buildNodeLearningMaterialContentSchema } from './nodeTestHistorySchemas' +import { repositoryHashSchema } from './sourceSchemas' + +const sequence = z + .number() + .int() + .positive() + .max(NODE_SERVER_HISTORY_MAX_ENTRIES) + +/** The entries of a server history a learning step may cite: the request, a stop (with its source hash), an output (with its channel), the response or the request failure. */ +const nodeServerLearningReferenceSchema = z.discriminatedUnion('type', [ + z.strictObject({ type: z.literal('request'), sequence }), + z.strictObject({ + type: z.literal('stop'), + sequence, + sourceHash: repositoryHashSchema, + }), + z.strictObject({ + type: z.literal('output'), + sequence, + channel: z.enum(['stdout', 'stderr']), + }), + z.strictObject({ type: z.literal('response'), sequence }), + z.strictObject({ type: z.literal('request-failed'), sequence }), +]) + +/** + * The learning material the chat saves for a server history: the same question, title, summary and evidence-classified sequence a test history has, citing the request, a stop, an output, the response or the request failure by journal sequence. + * Read by {@link nodeServerStorageDescriptor}'s material codec; the save route that offers it to an agent is published by the producer part of Cycle 0059. + * @example nodeServerLearningMaterialContentSchema.parse({ question, title, summary, participants, steps }) + */ +export const nodeServerLearningMaterialContentSchema = + buildNodeLearningMaterialContentSchema( + nodeServerLearningReferenceSchema, + 'Required for an observed or source-derived step: the request, a stop, an output, the response or the request failure of this history by its sequence number (a stop with its sourceHash, an output with its channel); omit it only for an unknown step.', + 'Give it the reference of the request, a stop, an output, the response or the request failure of this history ({type, sequence}, with sourceHash for a stop and channel for an output), or label it unknown.', + ) + +/** A saved server learning material: the content at a revision. */ +export const nodeServerLearningMaterialSchema = z.strictObject({ + version: z.literal(NODE_TEST_LEARNING_MATERIAL_VERSION), + studyId: studyIdSchema, + revision: z.number().int().positive(), + updatedAt: z.iso.datetime(), + content: nodeServerLearningMaterialContentSchema, +}) + +export type NodeServerLearningMaterialContent = z.infer< + typeof nodeServerLearningMaterialContentSchema +> +export type NodeServerLearningMaterial = z.infer< + typeof nodeServerLearningMaterialSchema +> diff --git a/extension/src/nodeServerStorageDescriptor.ts b/extension/src/nodeServerStorageDescriptor.ts index b13fda3d1..8b433412a 100644 --- a/extension/src/nodeServerStorageDescriptor.ts +++ b/extension/src/nodeServerStorageDescriptor.ts @@ -2,6 +2,7 @@ import { NODE_SERVER_HISTORY_DIRECTORY, NODE_SERVER_HISTORY_LIMIT_BYTES, NODE_SERVER_HISTORY_SEAL_LIMIT_BYTES, + NODE_TEST_LEARNING_MATERIAL_VERSION, } from './constants' import type { HistoryStorageDescriptor } from './historyStorageDescriptor' import { @@ -16,6 +17,11 @@ import type { NodeServerHistoryTypes, } from './nodeServerHistoryTypes' import { NodeServerHistoryWriter } from './nodeServerHistoryWriter' +import { + nodeServerLearningMaterialContentSchema, + nodeServerLearningMaterialSchema, +} from './nodeServerLearningSchemas' +import { assertNodeLearningCitations } from './utils/assertNodeLearningCitations' import { capMissingList } from './utils/capMissingList' import { evaluateNodeServerCapture } from './utils/evaluateNodeServerCapture' import { evaluateNodeServerLearning } from './utils/evaluateNodeServerLearning' @@ -23,6 +29,35 @@ import { acceptNodeServerEntry, newNodeServerJournalProgress, } from './utils/nodeServerJournalRules' +import type { CitableEvent } from './utils/recordingJournalScan' + +/** + * Reduces a server entry to what a learning step may cite: the request, a stop (by its source hash), an output (by its channel), the response or the request failure; the storage-only entries (`source`, `end`, the send marks) are never citable. + * @param entry - A journal entry. + * @returns The citable form, or `undefined` for an entry no step may cite. + * @example citableServerEvent(outputEntry) // => { type: 'output', channel: 'stdout' } + */ +function citableServerEvent( + entry: NodeServerHistoryEntry, +): CitableEvent | undefined { + switch (entry.type) { + case 'stop': + return { + type: 'stop', + sourceHash: entry.source.hash, + path: entry.source.path, + line: entry.source.line, + } + case 'output': + return { type: 'output', channel: entry.channel } + case 'request': + case 'response': + case 'request-failed': + return { type: entry.type } + default: + return undefined + } +} /** * 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. @@ -70,7 +105,21 @@ export const nodeServerStorageDescriptor: HistoryStorageDescriptor nodeServerLearningMaterialSchema.parse(value), + parseContent: (value) => + nodeServerLearningMaterialContentSchema.parse(value), + buildMaterial: (input) => + nodeServerLearningMaterialSchema.parse({ + version: NODE_TEST_LEARNING_MATERIAL_VERSION, + ...input, + }), + assertCitations: (content, citable, lead) => + assertNodeLearningCitations(content, citable, { + lead, + withNearest: true, + }), + }, stampHeader: (input) => ({ ...input, formatVersion: 1 }), parseHeader: (value) => nodeServerHistoryHeaderSchema.parse(value), parseSeal: (value) => nodeServerHistorySealSchema.parse(value), @@ -80,8 +129,7 @@ export const nodeServerStorageDescriptor: HistoryStorageDescriptor nodeServerHistoryEntrySchema.parse(value), start: newNodeServerJournalProgress, accept: acceptNodeServerEntry, - // Server entries are not citable until their learning material arrives. - citableOf: () => undefined, + citableOf: citableServerEvent, // A recording keeps what a summary counts: everything but output and the storage-only source texts; a source entry stays only when its text was not stored, so the summary words it like the seal. keeps: (entry: NodeServerHistoryEntry) => entry.type === 'source' @@ -103,7 +151,7 @@ export const nodeServerStorageDescriptor: HistoryStorageDescriptor { + summarize: ({ history, materialContent, materialRevision }) => { const { header, entries, seal } = history const stops = entries.filter((entry) => entry.type === 'stop') const response = entries.find((entry) => entry.type === 'response') @@ -115,7 +163,15 @@ export const nodeServerStorageDescriptor: HistoryStorageDescriptor + step.reference ? [step.reference.sequence] : [], + ), + ) + const learning = evaluateNodeServerLearning( + entries, + materialContent ? cited : undefined, + ) const learningMissing = capMissingList(learning.missing) const summary: NodeServerHistorySummary = { ...emptyNodeServerSummary(header), @@ -133,6 +189,8 @@ export const nodeServerStorageDescriptor: HistoryStorageDescriptor { - // Each broken rule is its own issue at the offending element, as for a configured learning summary. - for (const issue of learningSummaryIssues( - value, - 'content', - 'Give it the reference of a stop, an output or the result of this history ({type, sequence}, with sourceHash for a stop and channel for an output), or label it unknown.', - )) - context.addIssue({ code: 'custom', ...issue }) - }) - .refine( - (value) => - Buffer.byteLength(JSON.stringify(value), 'utf8') <= - LEARNING_SUMMARY_LIMITS.bytes, - 'Node learning material exceeds its total byte limit.', +/** + * Builds a learning material's content schema around the references one history profile lets a step cite: the question, title, summary, participants and evidence-classified steps are the same for every profile. + * Called for the node-test profile here and for the node-server profile by {@link nodeServerLearningMaterialContentSchema}. + * @param reference - The profile's step reference schema. + * @param referenceDescription - The sentence the published tool schema carries for a step's `reference`. + * @param referenceHint - What a step without a reference is told to give. + * @returns The content schema. + * @example const schema = buildNodeLearningMaterialContentSchema(nodeLearningReferenceSchema, 'Required for …', 'Give it the reference …') + */ +export function buildNodeLearningMaterialContentSchema< + Reference extends z.ZodType, +>(reference: Reference, referenceDescription: string, referenceHint: string) { + return z + .strictObject({ + question: z.string().min(1).max(MAX_EXPLANATION_CHARS), + title: z.string().min(1).max(LEARNING_SUMMARY_LIMITS.titleChars), + summary: z.string().min(1).max(MAX_EXPLANATION_CHARS), + participants: z + .array( + z.strictObject({ + id: nodeLearningStepIdSchema, + label: z + .string() + .min(1) + .max(LEARNING_SUMMARY_LIMITS.participantLabelChars), + }), + ) + .min(1) + .max(LEARNING_SUMMARY_LIMITS.participants), + steps: z + .array( + z.strictObject({ + id: nodeLearningStepIdSchema, + from: nodeLearningStepIdSchema, + to: nodeLearningStepIdSchema, + label: z + .string() + .min(1) + .max(LEARNING_SUMMARY_LIMITS.stepLabelChars), + explanation: z.string().min(1).max(MAX_EXPLANATION_CHARS), + evidence: z.enum(['observed', 'source-derived', 'unknown']), + reference: reference.optional().describe(referenceDescription), + }), + ) + .min(1) + .max(LEARNING_SUMMARY_LIMITS.steps), + }) + .superRefine((value, context) => { + // Each broken rule is its own issue at the offending element, as for a configured learning summary. + for (const issue of learningSummaryIssues( + value, + 'content', + referenceHint, + )) + context.addIssue({ code: 'custom', ...issue }) + }) + .refine( + (value) => + Buffer.byteLength(JSON.stringify(value), 'utf8') <= + LEARNING_SUMMARY_LIMITS.bytes, + 'Node learning material exceeds its total byte limit.', + ) +} + +export const nodeTestLearningMaterialContentSchema = + buildNodeLearningMaterialContentSchema( + nodeLearningReferenceSchema, + 'Required for an observed or source-derived step: a stop, an output or the result of this history by its sequence number (a stop with its sourceHash, an output with its channel); omit it only for an unknown step.', + 'Give it the reference of a stop, an output or the result of this history ({type, sequence}, with sourceHash for a stop and channel for an output), or label it unknown.', ) export const nodeTestLearningMaterialSchema = z.strictObject({ version: z.literal(NODE_TEST_LEARNING_MATERIAL_VERSION), diff --git a/extension/src/nodeTestHistoryStore.ts b/extension/src/nodeTestHistoryStore.ts index 42e82dbea..a09bdc312 100644 --- a/extension/src/nodeTestHistoryStore.ts +++ b/extension/src/nodeTestHistoryStore.ts @@ -18,7 +18,6 @@ import { NODE_TEST_LEARNING_DECISION_LIMIT_BYTES, NODE_TEST_LEARNING_DECISION_VERSION, NODE_TEST_LEARNING_MATERIAL_LIMIT_BYTES, - NODE_TEST_LEARNING_MATERIAL_VERSION, NODE_TEST_READER_POSITION_LIMIT_BYTES, NODE_TEST_READER_POSITION_VERSION, PRIVATE_DIRECTORY_MODE, @@ -27,7 +26,6 @@ import { import { NodeLearningRevisionChangedError, SavedStudyStorageFullError, - StudyError, } from './errors' import type { HistoryIndexOf, @@ -42,18 +40,15 @@ import { import { nodeTestHistoryLearningDecisionSchema, nodeTestHistoryReaderPositionSchema, - nodeTestLearningMaterialContentSchema, - nodeTestLearningMaterialSchema, type NodeTestHistoryLearningDecision, type NodeTestHistoryReaderPosition, - type NodeTestLearningMaterial, - type NodeTestLearningMaterialContent, } from './nodeTestHistorySchemas' 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 { saveStepLead } from './utils/assertNodeLearningCitations' import { atomicPrivateWrite } from './utils/atomicPrivateWrite' import { causeRootsFor } from './utils/causeRootsFor' import { cutHistoryReason } from './utils/cutHistoryReason' @@ -113,107 +108,6 @@ type LoadedHistory = { scan?: RecordingScan } -/** - * Words what a sequence is in the sealed history, for the step refusal of {@link assertLearningCitations}. - * @param event - The cited event, or `undefined` for a sequence the history does not have. - * @returns The event's words. - * @example describeCitedEvent({ type: 'output', channel: 'stderr' }) // => 'an output on stderr' - */ -function describeCitedEvent(event: CitableEvent | undefined): string { - if (!event) return 'no event' - if (event.type === 'stop') return `a stop at ${event.path}:${event.line}` - return event.type === 'output' - ? `an output on ${event.channel}` - : 'the result' -} - -/** - * Words the start of a step refusal on a read path (the history resource, a summary), where nobody called a tool. - * @param stepId - The step's id. - * @returns The start of the sentence. - * @example neutralStepLead('s1') // => 'Saved learning step s1' - */ -function neutralStepLead(stepId: string): string { - return `Saved learning step ${stepId}` -} - -/** - * Words the start of a step refusal on the save path, where the agent called `codebook_explain_node_history` and reads which tool refused. - * @param stepId - The step's id. - * @returns The start of the sentence. - * @example saveStepLead('s1') // => 'codebook_explain_node_history refused step s1' - */ -function saveStepLead(stepId: string): string { - return `codebook_explain_node_history refused step ${stepId}` -} - -/** - * 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 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, store.citableEventsOf(history), neutralStepLead) // returns for a material whose every reference matches - */ -function assertLearningCitations( - content: NodeTestLearningMaterialContent, - citable: ReadonlyMap, - lead: (stepId: string) => string = neutralStepLead, -): void { - for (const step of content.steps) { - const reference = step.reference - if (!reference) continue - const event = citable.get(reference.sequence) - if (citesEvent(reference, event)) continue - // A stop cited with another hash is told the hash the journal holds. - const expectedHash = - reference.type === 'stop' && event?.type === 'stop' - ? `; expected sourceHash ${event.sourceHash}` - : '' - throw new StudyError( - 'INVALID_REFERENCE', - `${lead(step.id)}: sequence ${reference.sequence} is ${describeCitedEvent(event)} in the sealed history, but the step claims ${describeCitationClaim(reference)}${expectedHash}. Read the history's events again and cite each event by its sequence, type and source hash.`, - ) - } -} - -/** A learning step's reference to one journal event, as the learning material holds it. */ -type LearningReference = NonNullable< - NodeTestLearningMaterialContent['steps'][number]['reference'] -> - -/** - * Says whether a learning step's reference matches the event the sealed journal holds at its sequence: a stop with the same source hash, an output on the same channel, or the result; read by {@link assertLearningCitations}. - * @param reference - The step's reference. - * @param event - The journal's event at that sequence, or `undefined` when there is none. - * @returns `true` when the reference cites the event as the journal holds it. - * @example citesEvent({ type: 'result', sequence: 4 }, { type: 'result' }) // => true - */ -function citesEvent( - reference: LearningReference, - event: CitableEvent | undefined, -): boolean { - if (reference.type === 'stop') - return event?.type === 'stop' && event.sourceHash === reference.sourceHash - if (reference.type === 'output') - return event?.type === 'output' && event.channel === reference.channel - return event?.type === 'result' -} - -/** - * Words what a learning step's reference claims, for the step refusal of {@link assertLearningCitations}. - * @param reference - The step's reference. - * @returns The claim's words. - * @example describeCitationClaim({ type: 'output', sequence: 1, channel: 'stderr' }) // => 'an output on stderr' - */ -function describeCitationClaim(reference: LearningReference): string { - if (reference.type === 'stop') - return `a stop with sourceHash ${reference.sourceHash}` - return reference.type === 'output' - ? `an output on ${reference.channel}` - : 'the result' -} - /** 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 NodeHistoryStore { private readonly directory: string @@ -229,7 +123,7 @@ export class NodeHistoryStore { private readonly readerPositionWrites = new Map>() private readonly learningMaterialWrites = new Map< string, - Promise + Promise >() /** The last summary of each listed history with the file signals it was made from ({@link summarize}). */ private readonly summaries = new Map>() @@ -973,15 +867,13 @@ export class NodeHistoryStore { studyId: string, citable: ReadonlyMap, ): Promise<{ - material: NodeTestLearningMaterial | undefined + material: Types['Material'] | 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) + if (saved) + this.descriptor.learningMaterial.assertCitations(saved.content, citable) return { material: saved, transient: false } } catch (error) { return { material: undefined, transient: isTransientFileError(error) } @@ -1170,10 +1062,10 @@ export class NodeHistoryStore { */ async readLearningMaterial( studyId: string, - ): Promise { + ): Promise { const material = await this.readLearningMaterialFile(studyId) if (!material) return undefined - assertLearningCitations( + this.descriptor.learningMaterial.assertCitations( material.content, this.citableEventsOf(await this.read(studyId)), ) @@ -1189,7 +1081,7 @@ export class NodeHistoryStore { */ private async readLearningMaterialFile( studyId: string, - ): Promise { + ): Promise { const bytes = await this.readPrivateFile( join(this.path(studyId), LEARNING_MATERIAL_FILE), NODE_TEST_LEARNING_MATERIAL_LIMIT_BYTES, @@ -1198,7 +1090,7 @@ export class NodeHistoryStore { throw error }) if (!bytes) return undefined - const material = nodeTestLearningMaterialSchema.parse( + const material = this.descriptor.learningMaterial.parseMaterial( JSON.parse(bytes.toString('utf8')), ) if (material.studyId !== studyId) @@ -1219,18 +1111,17 @@ export class NodeHistoryStore { async saveLearningMaterial( studyId: string, expectedRevision: number, - content: NodeTestLearningMaterialContent, - ): Promise { - if (!this.descriptor.supportsLearningMaterial) - throw new Error( - 'Learning material is not available for this kind of history yet.', - ) + content: Types['MaterialContent'], + ): Promise { const previous = this.learningMaterialWrites.get(studyId) ?? Promise.resolve(undefined) + // This window's saves queue in order; the read, the revision check and the write of each run under the storage lock, so a save of another window never interleaves between them. const operation = previous .catch(() => undefined) .then(() => - this.writeLearningMaterial(studyId, expectedRevision, content), + withStorageLock(this.learningDirectory, () => + this.writeLearningMaterial(studyId, expectedRevision, content), + ), ) this.learningMaterialWrites.set(studyId, operation) return operation.finally(() => { @@ -1242,8 +1133,9 @@ export class NodeHistoryStore { private async writeLearningMaterial( studyId: string, expectedRevision: number, - content: NodeTestLearningMaterialContent, - ): Promise { + content: Types['MaterialContent'], + ): Promise { + const codec = this.descriptor.learningMaterial const history = await this.read(studyId) const current = await this.readLearningMaterial(studyId) const currentRevision = current?.revision ?? 0 @@ -1252,14 +1144,13 @@ export class NodeHistoryStore { expectedRevision, currentRevision, ) - const parsedContent = nodeTestLearningMaterialContentSchema.parse(content) - assertLearningCitations( + const parsedContent = codec.parseContent(content) + codec.assertCitations( parsedContent, this.citableEventsOf(history), saveStepLead, ) - const material = nodeTestLearningMaterialSchema.parse({ - version: NODE_TEST_LEARNING_MATERIAL_VERSION, + const material = codec.buildMaterial({ studyId, revision: currentRevision + 1, updatedAt: new Date().toISOString(), diff --git a/extension/src/nodeTestHistoryTypes.ts b/extension/src/nodeTestHistoryTypes.ts index 677e03c91..9b2912e70 100644 --- a/extension/src/nodeTestHistoryTypes.ts +++ b/extension/src/nodeTestHistoryTypes.ts @@ -3,6 +3,8 @@ import type { NodeTestHistoryHeader, NodeTestHistoryIndex, NodeTestHistorySeal, + NodeTestLearningMaterial, + NodeTestLearningMaterialContent, } from './nodeTestHistorySchemas' import type { NodeTestHistoryWriter } from './nodeTestHistoryWriter' import type { NodeTestVerdict } from './utils/describeNodeTestResult' @@ -46,5 +48,7 @@ export type NodeTestHistoryTypes = { Seal: NodeTestHistorySeal Progress: JournalProgress Summary: NodeTestHistorySummary + Material: NodeTestLearningMaterial + MaterialContent: NodeTestLearningMaterialContent Writer: NodeTestHistoryWriter } diff --git a/extension/src/nodeTestStorageDescriptor.ts b/extension/src/nodeTestStorageDescriptor.ts index b8885f362..d48e1797d 100644 --- a/extension/src/nodeTestStorageDescriptor.ts +++ b/extension/src/nodeTestStorageDescriptor.ts @@ -2,12 +2,15 @@ import { NODE_TEST_HISTORY_DIRECTORY, NODE_TEST_HISTORY_LIMIT_BYTES, NODE_TEST_HISTORY_SEAL_LIMIT_BYTES, + NODE_TEST_LEARNING_MATERIAL_VERSION, } from './constants' import type { HistoryStorageDescriptor } from './historyStorageDescriptor' import { nodeTestHistoryHeaderSchema, nodeTestHistoryIndexSchema, nodeTestHistorySealSchema, + nodeTestLearningMaterialContentSchema, + nodeTestLearningMaterialSchema, type NodeTestHistoryHeader, type NodeTestHistoryIndex, type NodeTestLearningMaterialContent, @@ -17,6 +20,7 @@ import type { NodeTestHistoryTypes, } from './nodeTestHistoryTypes' import { NodeTestHistoryWriter } from './nodeTestHistoryWriter' +import { assertNodeLearningCitations } from './utils/assertNodeLearningCitations' import { describeNodeTestResult } from './utils/describeNodeTestResult' import { listMissingNodeExplanations, @@ -71,7 +75,18 @@ export const nodeTestStorageDescriptor: HistoryStorageDescriptor nodeTestLearningMaterialSchema.parse(value), + parseContent: (value) => + nodeTestLearningMaterialContentSchema.parse(value), + buildMaterial: (input) => + nodeTestLearningMaterialSchema.parse({ + version: NODE_TEST_LEARNING_MATERIAL_VERSION, + ...input, + }), + assertCitations: (content, citable, lead) => + assertNodeLearningCitations(content, citable, { lead }), + }, stampHeader: (input) => ({ ...input, version: 1 }), parseHeader: (value) => nodeTestHistoryHeaderSchema.parse(value), parseSeal: (value) => nodeTestHistorySealSchema.parse(value), diff --git a/extension/src/tests/nodeHistoryMaterialLock.test.ts b/extension/src/tests/nodeHistoryMaterialLock.test.ts new file mode 100644 index 000000000..32ccfbeaf --- /dev/null +++ b/extension/src/tests/nodeHistoryMaterialLock.test.ts @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import fsPromises, { mkdir, readdir, rename } from 'node:fs/promises' +import { join } from 'node:path' +import { before, test, type TestContext } from 'node:test' + +import { NodeLearningRevisionChangedError } from '../errors' +import type { NodeServerLearningMaterialContent } from '../nodeServerLearningSchemas' +import { NodeTestHistoryStore } from '../nodeTestHistoryStore' +import { sha256 } from '../runtimeSupport/utils/sha256' + +import { + HANDLER_SOURCE_TEXT, + openServerRepository, +} from './utils/nodeServerHistoryFixture' +import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +/** A server learning material citing the given entries: the request, a stop and the response. */ +function serverMaterial( + cites: { + request: number + stop: number + response?: number + }, + title = 'Creating an item', +): NodeServerLearningMaterialContent { + const stopHash = sha256(HANDLER_SOURCE_TEXT) + return { + question: 'How does the server create an item?', + title, + summary: 'The handler reads the id, loads the item and returns it.', + participants: [ + { id: 'client', label: 'Codebook request' }, + { id: 'server', label: 'Handler' }, + ], + steps: [ + { + id: 'request', + from: 'client', + to: 'server', + label: 'POST /api/items', + explanation: 'Codebook sends the declared request.', + evidence: 'observed', + reference: { type: 'request', sequence: cites.request }, + }, + { + id: 'stop', + from: 'server', + to: 'server', + label: 'load the item', + explanation: 'The handler stops after reading the id.', + evidence: 'observed', + reference: { + type: 'stop', + sequence: cites.stop, + sourceHash: stopHash, + }, + }, + ...(cites.response + ? [ + { + id: 'response', + from: 'server', + to: 'client', + label: '201', + explanation: 'The server answers 201.', + evidence: 'observed' as const, + reference: { + type: 'response' as const, + sequence: cites.response, + }, + }, + ] + : []), + ], + } +} + +/** Two windows over one learning directory: each its own repository and stores. */ +async function twoWindows(context: TestContext) { + const first = await openServerRepository(context) + const second = await openServerRepository(context, { + learningDirectory: first.learningDirectory, + }) + return { first, second } +} + +// protects=two windows saving the same revision of a server history's material get one success and one conflict; fails_when=both saves succeed and the later silently replaces the earlier, or neither reports the current revision; no_claim=a test history (its own test below) +test('two windows save the same revision of a server learning material and exactly one succeeds', async (context) => { + // Arrange + const { first, second } = await twoWindows(context) + const { header, sequences } = await writeServerHistory(first.repository) + const content = serverMaterial({ + request: sequences.request, + stop: sequences.stops[0]!, + response: sequences.response, + }) + + // Act + const results = await Promise.allSettled([ + first.repository.serverHistories.saveLearningMaterial( + header.studyId, + 0, + content, + ), + second.repository.serverHistories.saveLearningMaterial( + header.studyId, + 0, + serverMaterial( + { + request: sequences.request, + stop: sequences.stops[0]!, + response: sequences.response, + }, + 'Another title', + ), + ), + ]) + + // Assert + assert.deepEqual(results.map((result) => result.status).sort(), [ + 'fulfilled', + 'rejected', + ]) + const rejected = results.find((result) => result.status === 'rejected') + assert.ok(rejected?.reason instanceof NodeLearningRevisionChangedError) + assert.match(String(rejected.reason), /expected 0, current 1/) + const stored = await first.repository.serverHistories.readLearningMaterial( + header.studyId, + ) + assert.equal(stored?.revision, 1) +}) + +// protects=two windows saving the same revision of a test history's material get one success and one conflict under the storage lock; fails_when=the second window's read passes before the first window's write; no_claim=server histories (their own test above) +test('two windows save the same revision of a test learning material and exactly one succeeds', async (context) => { + // Arrange + const { first } = await twoWindows(context) + const key = sha256('node-history-test-workspace') + const storeFor = () => new NodeTestHistoryStore(first.learningDirectory, key) + const [one, other] = [storeFor(), storeFor()] + const header = nodeTestHistoryHeader(key) + const writer = await one.start(header) + await writer.appendOutput({ + sequence: 1, + elapsedMs: 1, + channel: 'stdout', + text: 'RUN', + }) + await writer.finish('partial', 'The learner stopped this run.') + const content = { + question: 'Why dark?', + title: 'Theme', + summary: 'The theme turns dark.', + participants: [{ id: 'ui', label: 'Theme' }], + steps: [ + { + id: 's1', + from: 'ui', + to: 'ui', + label: 'run', + explanation: 'The run starts.', + evidence: 'observed' as const, + reference: { + type: 'output' as const, + sequence: 1, + channel: 'stdout' as const, + }, + }, + ], + } + + // Act + const results = await Promise.allSettled([ + one.saveLearningMaterial(header.studyId, 0, content), + other.saveLearningMaterial(header.studyId, 0, content), + ]) + + // Assert + assert.deepEqual(results.map((result) => result.status).sort(), [ + 'fulfilled', + 'rejected', + ]) +}) + +// protects=a material saved in another window shows in this window's library summary without a restart; fails_when=this window keeps its memoized summary of the history before the save; no_claim=the Study pane's refresh +test('a server learning material saved in another window shows its title and coverage in this window', async (context) => { + // Arrange + const { first, second } = await twoWindows(context) + const { header, sequences } = await writeServerHistory(first.repository) + const [before] = await first.repository.serverHistories.list() + + // Act + await second.repository.serverHistories.saveLearningMaterial( + header.studyId, + 0, + serverMaterial({ + request: sequences.request, + stop: sequences.stops[0]!, + response: sequences.response, + }), + ) + const [after] = await first.repository.serverHistories.list() + + // Assert + assert.equal(before?.title, undefined) + assert.equal(before?.learningCoverage, 'none') + assert.equal(after?.title, 'Creating an item') + assert.equal(after?.learningCoverage, 'complete') + assert.equal(after?.materialRevision, 1) +}) + +// protects=a server material that cites a missing or mismatched entry is refused with the offending reference and the nearest valid sequences; fails_when=it saves, or the refusal does not tell the agent which sequence to cite; no_claim=the MCP tool's wording (published by the producer part) +test('a server learning material citing a stop as the response is refused with the nearest valid response sequences', async (context) => { + // Arrange + const { first } = await twoWindows(context) + const { header, sequences } = await writeServerHistory(first.repository) + const content = serverMaterial({ + request: sequences.request, + stop: sequences.stops[0]!, + // The sequence of the stop, claimed as the response. + response: sequences.stops[0]!, + }) + + // Act + const refused = first.repository.serverHistories.saveLearningMaterial( + header.studyId, + 0, + content, + ) + + // Assert + await assert.rejects(refused, (error: Error) => { + assert.equal((error as { code?: string }).code, 'INVALID_REFERENCE') + assert.match( + error.message, + new RegExp( + `sequence ${sequences.stops[0]} is a stop at src/handler.ts:2 in the sealed history, but the step claims the response\\. Nearest valid sequences for the response: ${sequences.response}\\.`, + ), + ) + return true + }) +}) + +// protects=a save racing a move of the history's folder to the Trash never resurrects the folder; fails_when=the material write recreates the folder path the Trash moved away; no_claim=the Trash itself +test('a material save racing a Trash move leaves no folder behind', async (context) => { + // Arrange + const { first } = await twoWindows(context) + const { header, sequences } = await writeServerHistory(first.repository) + const folder = join( + first.learningDirectory, + 'node-servers.v1', + header.studyId, + ) + const trash = join(first.learningDirectory, 'trash') + await mkdir(trash) + const renameBefore = fsPromises.rename + let moved = false + context.mock.method( + fsPromises, + 'rename', + async (...input: Parameters) => { + // The learner's Trash move lands between the revision check and the publication. + if (!moved && String(input[1]).endsWith('learning-material.json')) { + moved = true + await renameBefore(folder, join(trash, header.studyId)) + } + return renameBefore(...input) + }, + ) + + // Act + const refused = first.repository.serverHistories.saveLearningMaterial( + header.studyId, + 0, + serverMaterial({ + request: sequences.request, + stop: sequences.stops[0]!, + response: sequences.response, + }), + ) + + // Assert + await assert.rejects(refused, /ENOENT/) + context.mock.restoreAll() + assert.equal(existsSync(folder), false) + assert.deepEqual( + await readdir(join(first.learningDirectory, 'node-servers.v1')), + [], + ) + void rename +}) diff --git a/extension/src/tests/utils/sealedServerHistory.ts b/extension/src/tests/utils/sealedServerHistory.ts new file mode 100644 index 000000000..ef85ff81e --- /dev/null +++ b/extension/src/tests/utils/sealedServerHistory.ts @@ -0,0 +1,110 @@ +import type { NodeHistoryRepository } from '../../nodeHistoryRepository' +import type { NodeServerHistoryHeader } from '../../nodeServerHistorySchemas' + +import { + nodeServerHistoryHeader, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './nodeServerHistoryFixture' + +/** What a sealed fixture server history holds beyond its request: how many stops it records, its output lines and how it answers. */ +export type SealedServerHistoryOptions = { + workspaceKey?: string + declaredStops?: number + /** The stop hits to record, in order; each is `[declaredStopIndex, hit]`. Defaults to one hit of stop 1. */ + hits?: Array<[number, number]> + /** The output lines recorded before the response. */ + output?: string[] + /** The recorded response; `null` ends the run with no answer. */ + response?: { status: number; body: string; contentType?: string } | null + question?: string + path?: string + /** Seals the history; `false` leaves its writer abandoned, so it reads interrupted. */ + seal?: boolean +} + +/** + * Writes a server history with the fixture writer, the way a producer will: request, send mark, output lines, stop hits, the response and the end; the test fixture is the only writer of a server history until the producer part of Cycle 0059. + * @param repository - The repository whose server store receives the history. + * @param options - What the history holds. + * @returns The header and the sequence of each kind of entry written, so a test can cite them. + * @example const { header, sequences } = await writeServerHistory(repository, { response: { status: 201, body: '{}' } }) + */ +export async function writeServerHistory( + repository: NodeHistoryRepository, + options: SealedServerHistoryOptions = {}, +): Promise<{ + header: Omit + sequences: { + request: number + stops: number[] + output: number[] + response?: number + } +}> { + const hits = options.hits ?? [[0, 1]] + const header = { + ...nodeServerHistoryHeader(options.workspaceKey ?? SERVER_WORKSPACE_KEY, { + declaredStops: options.declaredStops ?? 1, + ...(options.path ? { path: options.path } : {}), + }), + ...(options.question ? { question: options.question } : {}), + } + const writer = await repository.serverHistories.start(header) + let elapsedMs = 1 + let sequence = 0 + const sequences: { + request: number + stops: number[] + output: number[] + response?: number + } = { request: 0, stops: [], output: [] } + await writer.appendRequest({ + elapsedMs, + attempt: 1, + method: 'POST', + path: header.declaredRequest.path, + bodyBytes: 14, + }) + sequences.request = ++sequence + await writer.appendRequestSent({ elapsedMs: ++elapsedMs, attempt: 1 }) + sequence++ + for (const [declaredStopIndex, hit] of hits) { + // The first hit of a source writes its source entry first. + if (!sequences.stops.length) sequence++ + await writer.appendStop( + serverStopCapture({ + declaredStopIndex, + hit, + line: 2 + declaredStopIndex, + elapsedMs: (elapsedMs += 10), + }), + ) + sequences.stops.push(++sequence) + } + for (const text of options.output ?? []) { + await writer.appendOutput({ + elapsedMs: (elapsedMs += 5), + channel: 'stdout', + text, + }) + sequences.output.push(++sequence) + } + if (options.response !== null) { + const response = options.response ?? { status: 201, body: '{"id":7}' } + await writer.appendResponse({ + elapsedMs: (elapsedMs += 10), + status: response.status, + contentType: response.contentType ?? 'application/json', + body: response.body, + bodyBytes: Buffer.byteLength(response.body), + readCut: false, + location: null, + requestDurationMs: 30, + }) + sequences.response = ++sequence + } + if (options.seal === false) writer.abandon() + else await writer.seal({ elapsedMs: (elapsedMs += 10), reason: 'stopped' }) + return { header, sequences } +} diff --git a/extension/src/utils/assertNodeLearningCitations.ts b/extension/src/utils/assertNodeLearningCitations.ts new file mode 100644 index 000000000..00ab2461e --- /dev/null +++ b/extension/src/utils/assertNodeLearningCitations.ts @@ -0,0 +1,181 @@ +import { StudyError } from '../errors' + +import type { CitableEvent } from './recordingJournalScan' + +/** A learning step's reference to one journal event, in any profile's material: the event's type and sequence, with the source hash a stop claims and the channel an output claims. */ +export type CitationReference = { + type: CitableEvent['type'] + sequence: number + sourceHash?: string + channel?: 'stdout' | 'stderr' +} + +/** What a material's content offers the citation check: its steps and their references. */ +type CitingContent = { + steps: readonly { id: string; reference?: CitationReference }[] +} + +/** + * Words what a sequence is in the sealed history, for the step refusal of {@link assertNodeLearningCitations}. + * @param event - The cited event, or `undefined` for a sequence the history does not have. + * @returns The event's words. + * @example describeCitedEvent({ type: 'output', channel: 'stderr' }) // => 'an output on stderr' + */ +function describeCitedEvent(event: CitableEvent | undefined): string { + if (!event) return 'no event' + if (event.type === 'stop') return `a stop at ${event.path}:${event.line}` + return describeEventKind(event.type, 'channel' in event ? event.channel : '') +} + +/** Words an event kind without its position; `channel` names the stream of an output. */ +function describeEventKind( + type: CitableEvent['type'], + channel: string | undefined, +): string { + if (type === 'output') return `an output on ${channel}` + if (type === 'result') return 'the result' + if (type === 'stop') return 'a stop' + if (type === 'request') return 'the request' + if (type === 'response') return 'the response' + return 'the request failure' +} + +/** + * Words the start of a step refusal on a read path (the history resource, a summary), where nobody called a tool. + * @param stepId - The step's id. + * @returns The start of the sentence. + * @example neutralStepLead('s1') // => 'Saved learning step s1' + */ +export function neutralStepLead(stepId: string): string { + return `Saved learning step ${stepId}` +} + +/** + * Words the start of a step refusal on the save path, where the agent called `codebook_explain_node_history` and reads which tool refused. + * @param stepId - The step's id. + * @returns The start of the sentence. + * @example saveStepLead('s1') // => 'codebook_explain_node_history refused step s1' + */ +export function saveStepLead(stepId: string): string { + return `codebook_explain_node_history refused step ${stepId}` +} + +/** + * Says whether a learning step's reference matches the event the sealed journal holds at its sequence: a stop with the same source hash, an output on the same channel, or an event of the same type. + * @param reference - The step's reference. + * @param event - The journal's event at that sequence, or `undefined` when there is none. + * @returns `true` when the reference cites the event as the journal holds it. + * @example citesEvent({ type: 'result', sequence: 4 }, { type: 'result' }) // => true + */ +function citesEvent( + reference: CitationReference, + event: CitableEvent | undefined, +): boolean { + if (reference.type === 'stop') + return event?.type === 'stop' && event.sourceHash === reference.sourceHash + if (reference.type === 'output') + return event?.type === 'output' && event.channel === reference.channel + return event?.type === reference.type +} + +/** + * Words what a learning step's reference claims, for the step refusal. + * @param reference - The step's reference. + * @returns The claim's words. + * @example describeCitationClaim({ type: 'output', sequence: 1, channel: 'stderr' }) // => 'an output on stderr' + */ +function describeCitationClaim(reference: CitationReference): string { + if (reference.type === 'stop') + return `a stop with sourceHash ${reference.sourceHash}` + return describeEventKind(reference.type, reference.channel) +} + +/** + * Lists the sequences closest to the cited one whose event the reference could cite: the same type, with the same source hash for a stop and the same channel for an output. + * @param reference - The refused reference. + * @param citable - The history's citable events. + * @returns At most three sequences, nearest first, ties by the lower sequence. + * @example nearestValidSequences({ type: 'stop', sequence: 9, sourceHash: 'ab' }, citable) // => [8, 5] + */ +function nearestValidSequences( + reference: CitationReference, + citable: ReadonlyMap, +): number[] { + return [...citable.entries()] + .filter(([, event]) => citesEvent(reference, event)) + .map(([sequence]) => sequence) + .sort( + (left, right) => + Math.abs(left - reference.sequence) - + Math.abs(right - reference.sequence) || left - right, + ) + .slice(0, 3) +} + +/** + * Words the refusal of one step whose reference does not match the event the journal holds at its sequence. + * @param lead - The start of the sentence for the step. + * @param reference - The step's reference. + * @param citable - The history's citable events. + * @param withNearest - Whether to name the nearest valid sequences. + * @returns The refusal's sentence. + * @example refuseCitation('Saved learning step s1', reference, citable, true) + */ +function refuseCitation( + lead: string, + reference: CitationReference, + citable: ReadonlyMap, + withNearest: boolean, +): string { + const event = citable.get(reference.sequence) + // A stop cited with another hash is told the hash the journal holds. + const expectedHash = + reference.type === 'stop' && event?.type === 'stop' + ? `; expected sourceHash ${event.sourceHash}` + : '' + return `${lead}: sequence ${reference.sequence} is ${describeCitedEvent(event)} in the sealed history, but the step claims ${describeCitationClaim(reference)}${expectedHash}.${withNearest ? nearestWords(reference, citable) : ''} Read the history's events again and cite each event by its sequence, type and source hash.` +} + +/** Words the nearest valid sequences of a refused reference, or that the history holds none. */ +function nearestWords( + reference: CitationReference, + citable: ReadonlyMap, +): string { + const nearest = nearestValidSequences(reference, citable) + return nearest.length > 0 + ? ` Nearest valid sequences for ${describeCitationClaim(reference)}: ${nearest.join(', ')}.` + : ` No sequence of this history holds ${describeCitationClaim(reference)}.` +} + +/** + * 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 the history store for every material it saves or reads. + * @param content - The material's content. + * @param citable - The history's citable events. + * @param options - `lead` words the start of the refusal for a step id ({@link saveStepLead} on the save path, {@link neutralStepLead} on read paths); `withNearest` adds the nearest valid sequences (the server profile's recovery). + * @throws An `INVALID_REFERENCE` {@link StudyError} for the first step whose reference does not match. + * @example assertNodeLearningCitations(content, citable, { lead: neutralStepLead }) // returns for a material whose every reference matches + */ +export function assertNodeLearningCitations( + content: CitingContent, + citable: ReadonlyMap, + options: { + lead?: (stepId: string) => string + withNearest?: boolean + } = {}, +): void { + const lead = options.lead ?? neutralStepLead + for (const step of content.steps) { + const reference = step.reference + if (!reference || citesEvent(reference, citable.get(reference.sequence))) + continue + throw new StudyError( + 'INVALID_REFERENCE', + refuseCitation( + lead(step.id), + reference, + citable, + options.withNearest ?? false, + ), + ) + } +} diff --git a/extension/src/utils/recordingJournalScan.ts b/extension/src/utils/recordingJournalScan.ts index 20269539e..e05e05d24 100644 --- a/extension/src/utils/recordingJournalScan.ts +++ b/extension/src/utils/recordingJournalScan.ts @@ -19,6 +19,9 @@ export type CitableEvent = | { type: 'stop'; sourceHash: string; path: string; line: number } | { type: 'output'; channel: 'stdout' | 'stderr' } | { type: 'result' } + | { type: 'request' } + | { type: 'response' } + | { type: 'request-failed' } /** What the journal's ordering rules have accepted so far; the same rules verify a whole journal ({@link NodeTestHistoryStore.read}) and each appended part of one still being written ({@link advanceRecordingScan}). */ export type JournalProgress = { From 12e42e5d155fcda843e0d29d39c864a2940e166f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 15:35:16 +0900 Subject: [PATCH 03/19] feat: Saved studies lists server replays by their learning title, request line and status, and refuses to delete one in server words (Cycle 0059 pt2) --- extension/src/constants.ts | 3 + extension/src/nodeHistoryRepository.ts | 39 ++- extension/src/nodeHistorySummary.ts | 9 + extension/src/studyWorkspace.ts | 101 ++++---- .../tests/describeNodeServerReplay.test.ts | 79 ++++++ .../tests/studyWorkspaceServerLibrary.test.ts | 230 ++++++++++++++++++ extension/src/ui/studyHtml.ts | 25 +- .../src/utils/describeNodeServerReplay.ts | 158 ++++++++++++ .../src/utils/stripNodeHistorySummaryTexts.ts | 27 +- 9 files changed, 624 insertions(+), 47 deletions(-) create mode 100644 extension/src/nodeHistorySummary.ts create mode 100644 extension/src/tests/describeNodeServerReplay.test.ts create mode 100644 extension/src/tests/studyWorkspaceServerLibrary.test.ts create mode 100644 extension/src/utils/describeNodeServerReplay.ts diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 8169c3feb..93b893b71 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -223,6 +223,7 @@ export const UNSAVED_STUDY_HANDED_OVER_REASON = export const STUDY_REMOVAL_REFUSALS = { nothingChosen: 'Choose a saved study to delete.', nodeHistory: 'Test replays cannot be deleted here yet.', + serverHistory: 'Server replays cannot be deleted here yet.', runNotStopped: 'Stop the run before deleting its study.', recordingOpen: 'Choose Overview or Live to leave Recording before deleting this study.', @@ -240,6 +241,8 @@ export const STUDY_REMOVAL_REFUSALS_WITHOUT_TRASH: Record< 'This window has no Trash, so Codebook cannot delete a saved study here. Choose a saved study, then open Remove by hand to list its files.', nodeHistory: 'Codebook cannot list the files of a test replay for removal by hand yet.', + serverHistory: + 'Codebook cannot list the files of a server replay for removal by hand yet.', runNotStopped: "Stop the run before removing its study's files by hand.", recordingOpen: "Choose Overview or Live to leave Recording before removing this study's files by hand.", diff --git a/extension/src/nodeHistoryRepository.ts b/extension/src/nodeHistoryRepository.ts index a66f78e99..67209b454 100644 --- a/extension/src/nodeHistoryRepository.ts +++ b/extension/src/nodeHistoryRepository.ts @@ -1,9 +1,14 @@ -import type { NodeServerHistoryTypes } from './nodeServerHistoryTypes' +import type { NodeHistorySummary } from './nodeHistorySummary' +import type { + NodeServerHistorySummary, + NodeServerHistoryTypes, +} from './nodeServerHistoryTypes' import { nodeServerStorageDescriptor } from './nodeServerStorageDescriptor' import { NodeHistoryStore, NodeTestHistoryStore, type NodeHistoryWriterJudge, + type NodeTestHistorySummary, } from './nodeTestHistoryStore' import { isNodeHistoryIdentity } from './utils/isNodeHistoryIdentity' @@ -55,6 +60,38 @@ export class NodeHistoryRepository { ) } + /** + * Lists every saved history of the workspace, test and server, newest first, as one catalog for Saved studies. + * @returns The summaries of both profiles; a profile whose folder cannot be listed rejects the whole listing, so the library keeps its earlier catalog and reports the failure as it does for test histories. + * @throws What a store's listing throws for an unreadable history directory. + * @example (await repository.list()).map((summary) => summary.profile) // => ['node-server', 'node-test'] + */ + async list(): Promise { + const { tests, servers } = await this.listByProfile() + return [...tests, ...servers].sort((left, right) => + right.createdAt === left.createdAt + ? left.studyId.localeCompare(right.studyId) + : right.createdAt.localeCompare(left.createdAt), + ) + } + + /** + * Lists the saved histories of each profile apart, for the library, which keeps one catalog per profile. + * @returns The test summaries and the server summaries. + * @throws What a store's listing throws for an unreadable history directory. + * @example const { tests, servers } = await repository.listByProfile() + */ + async listByProfile(): Promise<{ + tests: NodeTestHistorySummary[] + servers: NodeServerHistorySummary[] + }> { + const [tests, servers] = await Promise.all([ + this.testHistories.list(), + this.serverHistories.list(), + ]) + return { tests, servers } + } + /** * 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. diff --git a/extension/src/nodeHistorySummary.ts b/extension/src/nodeHistorySummary.ts new file mode 100644 index 000000000..f8c194b89 --- /dev/null +++ b/extension/src/nodeHistorySummary.ts @@ -0,0 +1,9 @@ +import type { NodeServerHistorySummary } from './nodeServerHistoryTypes' +import type { NodeTestHistorySummary } from './nodeTestHistoryTypes' + +/** + * One saved history as Saved studies, the history resources and Save read it, whatever its profile: a test history or a server history, told apart by `profile`. + * Produced by {@link NodeHistoryRepository.list}; the Study workspace words each row through the profile's UI adapter. + */ +export type NodeHistorySummary = + NodeTestHistorySummary | NodeServerHistorySummary diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index f2305bb36..82ff1422c 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -125,6 +125,7 @@ import { } from './learningLibrary' import { NodeHistoryRepository } from './nodeHistoryRepository' import { NodeLearningSave } from './nodeLearningSave' +import type { NodeServerHistorySummary } from './nodeServerHistoryTypes' import { nodeLearningStepIdSchema, type NodeTestHistoryEntry, @@ -132,7 +133,10 @@ import { type NodeTestHistoryLearningDecision, type NodeTestLearningMaterial, } from './nodeTestHistorySchemas' -import type { NodeTestHistoryStore } from './nodeTestHistoryStore' +import type { + NodeTestHistorySummary, + NodeTestHistoryStore, +} from './nodeTestHistoryStore' import { PlaybackController, type PlaybackBundle, @@ -240,6 +244,7 @@ import { describeNodeRecordedSource, NODE_RECORDED_SOURCE_TEXT, } from './utils/describeNodeRecordedSource' +import { describeServerReplayRow } from './utils/describeNodeServerReplay' import { describeNodeServerStatus } from './utils/describeNodeServerStatus' import { describeNodeTestReplay } from './utils/describeNodeTestReplay' import { describePaneIdentity } from './utils/describePaneIdentity' @@ -793,9 +798,9 @@ export class StudyWorkspace { /** 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 - > = [] + private nodeHistoryCatalog: NodeTestHistorySummary[] = [] + /** The saved server replays Saved studies lists, texts free of controls, listed with {@link nodeHistoryCatalog} ({@link listNodeHistoryCatalog}). */ + private nodeServerHistoryCatalog: NodeServerHistorySummary[] = [] private selectedNodeHistory?: NodeTestHistoryIndex private selectedNodeHistoryLearningDecision?: NodeTestHistoryLearningDecision private selectedNodeHistoryLearningMaterial?: NodeTestLearningMaterial @@ -10737,10 +10742,7 @@ export class StudyWorkspace { // 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. - this.nodeHistoryCatalog = await this.listNodeHistoryCatalog( - this.nodeHistoryStore, - [], - ) + await this.listNodeHistoryCatalog(this.nodeHistoryRepository) this.playback = new PlaybackController({ load: (studyId, signal) => this.loadPlayback(studyId, signal), frame: async (studyId, hash, signal) => { @@ -10969,11 +10971,8 @@ export class StudyWorkspace { const record = this.currentRecord() // A run that started after a removal ended retires that removal's outcome line. this.retireRemovalOutcomeOfEarlierRun(record?.id) - if (this.nodeHistoryStore) - this.nodeHistoryCatalog = await this.listNodeHistoryCatalog( - this.nodeHistoryStore, - this.nodeHistoryCatalog, - ) + if (this.nodeHistoryRepository) + await this.listNodeHistoryCatalog(this.nodeHistoryRepository) // Refresh every retained projection without letting an older run replace current runtime ownership. if (this.service) for (const retained of this.service.records.values()) { @@ -12184,11 +12183,16 @@ export class StudyWorkspace { const summary = this.nodeHistoryCatalog.find( (history) => history.studyId === studyId, ) + const serverSummary = this.nodeServerHistoryCatalog.find( + (history) => history.studyId === studyId, + ) const failure = describeNodeLearningSave({ kind: 'open-failed', title: summary ? [summary.testFile, ...(summary.testTitles ?? [])].join(' › ') - : studyId, + : serverSummary + ? (serverSummary.title ?? serverSummary.question) + : studyId, cause, }) // The answer is handled whenever it arrives, outside every entry's turn. @@ -13508,22 +13512,24 @@ export class StudyWorkspace { } /** - * Lists the saved test replays for Saved studies with their test files and titles already free of controls, so every Study message reuses one stripped catalog instead of stripping it again (F131, F326); called when the reader starts and at each refresh of its library. - * @param store - The workspace's history store. - * @param fallback - The catalog kept when the store cannot be listed; the failure goes to the `archive` row. - * @returns The stripped catalog, or the fallback. - * @example this.nodeHistoryCatalog = await this.listNodeHistoryCatalog(store, []) + * Lists the saved test and server replays for Saved studies with their recorded texts already free of controls, so every Study message reuses one stripped catalog instead of stripping it again (F131, F326); called when the reader starts and at each refresh of its library. + * @param repository - The workspace's history repository. + * @example await this.listNodeHistoryCatalog(this.nodeHistoryRepository) */ private async listNodeHistoryCatalog( - store: NodeTestHistoryStore, - fallback: StudyWorkspace['nodeHistoryCatalog'], - ): Promise { - return store.list().then( - (catalog) => catalog.map(stripNodeHistorySummaryTexts), - (error: unknown) => { - this.report('archive', error) - return fallback + repository: NodeHistoryRepository, + ): Promise { + await repository.listByProfile().then( + ({ tests, servers }) => { + this.nodeHistoryCatalog = tests.map((summary) => + stripNodeHistorySummaryTexts(summary), + ) + this.nodeServerHistoryCatalog = servers.map((summary) => + stripNodeHistorySummaryTexts(summary), + ) }, + // A listing that fails keeps both earlier catalogs. + (error: unknown) => this.report('archive', error), ) } @@ -13658,21 +13664,27 @@ export class StudyWorkspace { : undefined const studyCatalog: StudyCatalogEntry[] = [ ...this.catalog, - ...this.nodeHistoryCatalog.map((history) => ({ - id: history.studyId, - savedAt: history.createdAt, - profile: 'node-test' as const, - format: 'node-test-history' as const, - outcome: describeNodeTestReplay({ - kind: 'saved-row', - summary: history, + ...[...this.nodeHistoryCatalog, ...this.nodeServerHistoryCatalog].map( + (history) => ({ + id: history.studyId, + savedAt: history.createdAt, + profile: history.profile, + // A server replay shares the test replay's row format: both open through the Node history reader, told apart by `profile`. + format: 'node-test-history' as const, + outcome: + history.profile === 'node-server' + ? describeServerReplayRow(history) + : describeNodeTestReplay({ + kind: 'saved-row', + summary: history, + }), + metadata: + history.verification === 'verified' + ? ('validated' as const) + : ('unavailable' as const), + ...(history.reason ? { reason: history.reason } : {}), }), - metadata: - history.verification === 'verified' - ? ('validated' as const) - : ('unavailable' as const), - ...(history.reason ? { reason: history.reason } : {}), - })), + ), ].sort((left, right) => right.savedAt.localeCompare(left.savedAt)) return { type: 'study', @@ -13706,6 +13718,7 @@ export class StudyWorkspace { }, // The recorded test names come without terminal and bidirectional controls, stripped when the catalog was listed (F131, F326). nodeHistoryCatalog: this.nodeHistoryCatalog, + nodeServerHistoryCatalog: this.nodeServerHistoryCatalog, nodeHistory: selectedNodeHistory && { ...this.shownNodeHistory(selectedNodeHistory), entries: this.shownNodeHistory(selectedNodeHistory).entries.slice( @@ -24295,6 +24308,12 @@ export class StudyWorkspace { this.nodeHistoryCatalog.some((history) => history.studyId === studyId), sentences.nodeHistory, ], + [ + this.nodeServerHistoryCatalog.some( + (history) => history.studyId === studyId, + ), + sentences.serverHistory, + ], [ !this.catalog.some((entry) => entry.id === studyId), sentences.unavailable, diff --git a/extension/src/tests/describeNodeServerReplay.test.ts b/extension/src/tests/describeNodeServerReplay.test.ts new file mode 100644 index 000000000..66a26e113 --- /dev/null +++ b/extension/src/tests/describeNodeServerReplay.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + describeServerRequestClaim, + describeServerResponseState, + describeServerStop, + NODE_SERVER_REPLAY_TEXT, +} from '../utils/describeNodeServerReplay' + +// protects=the replay claims a request was sent only when its bytes left; fails_when=a withdrawn attempt reads as sent, or the refusal code is dropped; no_claim=whether the server received the request +test('the request region says Request sent by Codebook only for a sent attempt and names the code of a withdrawn one', () => { + // Arrange + const sent = { sent: true } + const withdrawn = { sent: false, withdrawnCode: 'RUN_BUSY' } + const unmarked = { sent: false } + + // Act + const words = [sent, withdrawn, unmarked].map(describeServerRequestClaim) + + // Assert + assert.deepEqual(words, [ + 'Request sent by Codebook', + 'Request not sent: RUN_BUSY', + 'Request not sent: unknown', + ]) +}) + +// protects=the response region words each state of the selected moment; fails_when=a 4xx or 5xx reads as a success word, an earlier moment shows a later response, or a failure drops its code; no_claim=colors +test('the response region words the moment before the response, a 404, a failed request and a history that ended with no answer', () => { + // Arrange + const before = { reached: false } + const notFound = { + reached: true, + response: { status: 404, contentType: 'text/plain' }, + } + const failed = { reached: true, failureCode: 'REQUEST_TIMEOUT' } + const stopped = { + reached: true, + noResponseReason: + 'No response was recorded: the run was stopped before the server answered.', + } + + // Act + const words = [before, notFound, failed, stopped].map( + describeServerResponseState, + ) + + // Assert + assert.deepEqual(words, [ + 'No response recorded yet at this moment.', + 'Response 404 · text/plain', + 'Request failed: REQUEST_TIMEOUT', + 'No response was recorded: the run was stopped before the server answered.', + ]) +}) + +// protects=a stop hit more than once reads Stop · hit ; fails_when=the hit number is dropped for a repeated stop or added to a single hit; no_claim=the stop's position in the list +test('a stop reads Stop 2 · hit 3 only when its declared stop was hit more than once', () => { + // Arrange / Act + const single = describeServerStop(2, 1, false) + const repeated = describeServerStop(2, 3, true) + + // Assert + assert.equal(single, 'Stop 2') + assert.equal(repeated, 'Stop 2 · hit 3') +}) + +// protects=no server word of the catalog says test; fails_when=a catalog entry reuses the test replay's wording; no_claim=the Study pane's own labels +test('the server catalog never uses the word test', () => { + // Arrange + const words = Object.values(NODE_SERVER_REPLAY_TEXT) + + // Act + const withTest = words.filter((word) => /\btests?\b/i.test(word)) + + // Assert + assert.deepEqual(withTest, []) +}) diff --git a/extension/src/tests/studyWorkspaceServerLibrary.test.ts b/extension/src/tests/studyWorkspaceServerLibrary.test.ts new file mode 100644 index 000000000..1f3c4df49 --- /dev/null +++ b/extension/src/tests/studyWorkspaceServerLibrary.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import { sha256 } from '../runtimeSupport/utils/sha256' + +import { readerSeams, workspaceHarness } from './studyWorkspaceHarness' +import { nodeServerHistoryHeader } from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +type Harness = Awaited> + +/** The workspace's history repository, over the learning directory the fixture writer shares. */ +function repositoryOf(harness: Harness) { + return harness.reader['nodeHistoryRepository']! +} + +/** The Saved studies rows of the reader's current view, by study id. */ +function rowOf(harness: Harness, studyId: string) { + return harness.reader + .inspect() + .view!.studies.find((entry) => entry.id === studyId) +} + +// protects=a saved server replay is listed in Saved studies with its question first, its request line and answer, and its status; fails_when=the row is missing, worded as a test replay, or does not name the request and its status; no_claim=the Study pane's rendering of the row +test('Saved studies lists a server replay by its question, request line, response status and Partial status before the chat explained it', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header } = await writeServerHistory(repositoryOf(harness), { + workspaceKey: harness.service.nodeHistoryStore!.workspaceKey, + question: 'How does the server create an item?', + }) + + // Act + await harness.reader.action('retry') + const row = rowOf(harness, header.studyId) + + // Assert + assert.equal( + row?.outcome, + "How does the server create an item? · Server replay · POST /api/items → 201 · Partial: the chat's explanation is not saved yet", + ) + assert.equal(row?.profile, 'node-server') + } finally { + await harness.close() + } +}) + +// protects=a server row leads with the saved learning title and reads Saved once the explanation covers the capture, and two replays of one route are told apart by what they explain; fails_when=the title is not used, two rows of one route read the same, or the status stays Partial; no_claim=a partial capture +test('two server replays of one route are told apart by their saved learning titles and read Saved', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const repository = repositoryOf(harness) + const workspaceKey = harness.service.nodeHistoryStore!.workspaceKey + const first = await writeServerHistory(repository, { workspaceKey }) + const second = await writeServerHistory(repository, { workspaceKey }) + const material = ( + title: string, + sequences: { request: number; stops: number[]; response?: number }, + ) => ({ + question: 'How does the server create an item?', + title, + summary: 'The handler creates the item.', + participants: [{ id: 'server', label: 'Handler' }], + steps: [ + { + id: 'request', + from: 'server', + to: 'server', + label: 'request', + explanation: 'Codebook sends the request.', + evidence: 'observed' as const, + reference: { type: 'request' as const, sequence: sequences.request }, + }, + { + id: 'stop', + from: 'server', + to: 'server', + label: 'stop', + explanation: 'The handler stops.', + evidence: 'observed' as const, + reference: { + type: 'stop' as const, + sequence: sequences.stops[0]!, + sourceHash: sha256( + 'export function handler(req) {\n const id = req.params.id\n const item = load(id)\n return item\n}\n', + ), + }, + }, + { + id: 'response', + from: 'server', + to: 'server', + label: 'response', + explanation: 'The server answers.', + evidence: 'observed' as const, + reference: { + type: 'response' as const, + sequence: sequences.response!, + }, + }, + ], + }) + + // Act + await repository.serverHistories.saveLearningMaterial( + first.header.studyId, + 0, + material('Creating an item with a name', first.sequences), + ) + await repository.serverHistories.saveLearningMaterial( + second.header.studyId, + 0, + material('Creating an item twice', second.sequences), + ) + await harness.reader.action('retry') + + // Assert + assert.equal( + rowOf(harness, first.header.studyId)?.outcome, + 'Creating an item with a name · Server replay · POST /api/items → 201 · Saved', + ) + assert.equal( + rowOf(harness, second.header.studyId)?.outcome, + 'Creating an item twice · Server replay · POST /api/items → 201 · Saved', + ) + } finally { + await harness.close() + } +}) + +// protects=a partial server capture shows its first reason and the count of the others in its row; fails_when=the row hides the reason or lists every reason; no_claim=the full reasons (the replay's capture line lists them all) +test('a server replay stopped before its answer lists its first reason and the count of the others', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header } = await writeServerHistory(repositoryOf(harness), { + workspaceKey: harness.service.nodeHistoryStore!.workspaceKey, + declaredStops: 2, + response: null, + }) + + // Act + await harness.reader.action('retry') + + // Assert + assert.equal( + rowOf(harness, header.studyId)?.outcome, + 'How does the server create an item? · Server replay · POST /api/items → no response · Partial: No response was recorded: the run was stopped before the server answered. (+1 more)', + ) + } finally { + await harness.close() + } +}) + +// protects=a server replay cannot be deleted from Saved studies, as a test replay cannot, and the refusal says server replay; fails_when=the delete request moves its files to the Trash or answers with the test words; no_claim=deletion of a test replay +test('deleting a server replay is refused in server words and moves nothing to the Trash', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header } = await writeServerHistory(repositoryOf(harness), { + workspaceKey: harness.service.nodeHistoryStore!.workspaceKey, + }) + await harness.reader.action('retry') + + // Act + await readerSeams(harness).receive( + { + type: 'delete-study', + revision: harness.reader.inspect().view!.revision, + studyId: header.studyId, + }, + 'context', + ) + + // Assert + assert.deepEqual( + structuredClone(harness.reader.inspect().view!.studyRemoval), + { + status: 'refused', + studyId: header.studyId, + text: 'Server replays cannot be deleted here yet.', + }, + ) + assert.deepEqual(harness.trashMoves, []) + assert.deepEqual(harness.warningDialogs, []) + } finally { + await harness.close() + } +}) + +// protects=a server history another window is still recording lists as recording and its row names no result; fails_when=it lists as interrupted or partial while its writer lives; no_claim=opening it +test('a server history another window records lists as Recording with its request line', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const repository = repositoryOf(harness) + const writer = await repository.serverHistories.start( + nodeServerHistoryHeader(harness.service.nodeHistoryStore!.workspaceKey), + ) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }) + + // Act + await harness.reader.action('retry') + const row = harness.reader + .inspect() + .view!.studies.find((entry) => entry.profile === 'node-server') + writer.abandon() + + // Assert + assert.equal( + row?.outcome, + 'How does the server create an item? · Server replay · POST /api/items · Recording', + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index adb767aa5..882c18076 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -1,10 +1,11 @@ import { randomUUID } from 'node:crypto' +import type { NodeServerHistorySummary } from '../nodeServerHistoryTypes' import type { NodeTestHistoryIndex, NodeTestHistoryLearningDecision, } from '../nodeTestHistorySchemas' -import type { NodeTestHistoryStore } from '../nodeTestHistoryStore' +import type { NodeTestHistorySummary } from '../nodeTestHistoryStore' import type { PlaybackState, PlaybackBundle } from '../playbackController' import type { PresentationState, @@ -113,7 +114,9 @@ export interface StudyViewMessage { runRefusal?: string /** The saved study of this window's run that has not stopped, which cannot be deleted. */ unstoppedStudyId?: string - nodeHistoryCatalog?: Awaited> + nodeHistoryCatalog?: NodeTestHistorySummary[] + /** The saved server replays, beside {@link nodeHistoryCatalog}. */ + nodeServerHistoryCatalog?: NodeServerHistorySummary[] nodeHistory?: NodeTestHistoryIndex nodeHistoryPage?: number nodeHistoryPageCount?: number @@ -1367,7 +1370,16 @@ function studyClient( ): string => { const sentences = removalRefusals(message) const refusals: [applies: boolean, sentence: string][] = [ - [chosen.format === 'node-test-history', sentences.nodeHistory], + [ + chosen.format === 'node-test-history' && + chosen.profile !== 'node-server', + sentences.nodeHistory, + ], + [ + chosen.format === 'node-test-history' && + chosen.profile === 'node-server', + sentences.serverHistory, + ], // The host moves nothing of a study a newer Codebook saved: which files that format keeps is not known here. [chosen.format === 'newer', sentences.newerFormat], [message.unstoppedStudyId === chosen.id, sentences.runNotStopped], @@ -2527,6 +2539,10 @@ function studyClient( */ const testReplayTitle = (message: StudyViewMessage): string => { const studyId = message.nodeHistory?.header.studyId + const serverSummary = message.nodeServerHistoryCatalog?.find( + (entry) => entry.studyId === studyId, + ) + if (serverSummary) return serverSummary.title ?? serverSummary.question const summary = message.nodeHistoryCatalog?.find( (entry) => entry.studyId === studyId, ) @@ -2578,7 +2594,8 @@ function studyClient( ): void => { const idle = suppressed ? '' - : message.nodeHistoryCatalog?.length + : message.nodeHistoryCatalog?.length || + message.nodeServerHistoryCatalog?.length ? options.testReplay.chooseReplay : options.testReplay.noReplay renderTestReplayLines(section, idle) diff --git a/extension/src/utils/describeNodeServerReplay.ts b/extension/src/utils/describeNodeServerReplay.ts new file mode 100644 index 000000000..c8d115ae1 --- /dev/null +++ b/extension/src/utils/describeNodeServerReplay.ts @@ -0,0 +1,158 @@ +import type { NodeServerHistorySummary } from '../nodeServerHistoryTypes' + +/** + * The fixed words of a server replay's surfaces: the server counterpart of `NODE_TEST_REPLAY_TEXT`, with the same keys (so a surface swaps catalogs by profile) and the server's own words for the request, the response and the understanding. Part of the node-server UI adapter: the storage layer never imports it. + * The Study pane receives them through its options, as its client code imports nothing. + */ +export const NODE_SERVER_REPLAY_TEXT = { + stderrMark: '[stderr] ', + loading: 'Loading recorded server output…', + nothingYet: 'No server output recorded up to this moment.', + noEntries: 'No server output recorded.', + endedEarly: 'Recording ended before the server answered.', + loadingSuffix: ' · loading…', + jumpToLatest: 'Jump to latest', + applicationFooter: + 'Historical server request and output. Reading it does not resend the request or restart the server.', + heading: 'Server replay', + noStepForStop: 'No saved explanation for this stop.', + noStepForResult: 'No saved explanation for the response.', + notRecorded: 'Source and debugger not recorded at this moment.', + unverifiedStop: 'Recorded source unavailable for this stop.', + outputPointer: + 'Request, response and output up to this moment are in the Application pane.', + applicationClosed: 'The Application pane is closed.', + showRecordedOutput: 'Show recorded output', + noMaterial: + 'No learning summary was saved for this server replay. The chat can save one with codebook_explain_node_history.', + sameMoment: 'Same recorded moment', + previousStop: 'Previous stop', + nextStop: 'Next stop', + previousEvent: 'Previous event', + nextEvent: 'Next event', + play: 'Play', + pause: 'Pause', + stepDetails: 'Step details', + playingStatus: 'Server replay playing', + pausedStatus: 'Server replay paused', + showMore: 'Show more', + showLess: 'Show less', + eventsGroup: 'Event steps', + stepHeading: 'Step', + timelineLegend: + 'Ticks mark recorded stops; source and debugger are not recorded between them.', + noSelection: 'Select an event to move through the recorded server history.', + chooseReplay: + 'Choose a server replay from Saved studies to step through its request, output, debugger stops and response.', + noReplay: 'No server replay has been saved in this workspace.', + noLinkedEvent: 'No recorded event is linked to this step.', + eventRequest: 'Request', + eventOutput: 'Output', + eventStop: 'Stop', + eventResult: 'Response', + /** The understanding checkbox's label. */ + understanding: 'I understand this server history', + /** Labels the fixed request region of the Application pane when the request's bytes left. */ + requestSent: 'Request sent by Codebook', + /** The fixed response region before the response entry. */ + noResponseYet: 'No response recorded yet at this moment.', + emptyBody: 'Response body is empty.', + /** Names the slider for a screen reader. */ + recordedTime: 'recorded server time', + /** The label of the sequence diagram's group. */ + sequenceLabel: 'Server replay learning sequence', +} as const + +/** + * Words the request region of the Application pane: the request line with the claim the replay may make about it. + * @param input - The request line, and what the journal recorded about the attempt's send and refusal. + * @returns `Request sent by Codebook` only when the bytes were handed to the socket, else `Request not sent: ` (the refusal's code, or `unknown`). + * @example describeServerRequestClaim({ sent: true }) // => 'Request sent by Codebook' + */ +export function describeServerRequestClaim(input: { + sent: boolean + withdrawnCode?: string +}): string { + return input.sent + ? NODE_SERVER_REPLAY_TEXT.requestSent + : `Request not sent: ${input.withdrawnCode ?? 'unknown'}` +} + +/** + * Words the response region of the Application pane for the selected moment. + * @param input - What the history recorded and whether the selected moment reached it: `response` the recorded answer, `failure` the request's failure code, `reason` the first partial reason of a history that ended with neither. + * @returns One line: the pre-response line, `Response · `, `Request failed: ` or `No response was recorded: ` (the reason already starts with that phrase when the evaluator worded it, so it is not repeated). + * @example describeServerResponseState({ reached: true, response: { status: 404, contentType: 'text/plain' } }) // => 'Response 404 · text/plain' + */ +export function describeServerResponseState(input: { + reached: boolean + response?: { status: number; contentType: string | null } + failureCode?: string + noResponseReason?: string +}): string { + if (!input.reached) return NODE_SERVER_REPLAY_TEXT.noResponseYet + if (input.response) + return `Response ${input.response.status} · ${input.response.contentType ?? 'no content type'}` + if (input.failureCode) return `Request failed: ${input.failureCode}` + return ( + input.noResponseReason ?? + 'No response was recorded: the history ended before the server answered.' + ) +} + +/** + * Words a stop's place in the replay: `Stop ` for the first hit and `Stop · hit ` for a later one of a stop that was hit again. + * @param stopNumber - The declared stop's number, counted from 1. + * @param hit - The hit's number, counted from 1. + * @param hitMoreThanOnce - Whether the declared stop has more than one recorded hit. + * @returns The words. + * @example describeServerStop(2, 3, true) // => 'Stop 2 · hit 3' + */ +export function describeServerStop( + stopNumber: number, + hit: number, + hitMoreThanOnce: boolean, +): string { + return hitMoreThanOnce + ? `Stop ${stopNumber} · hit ${hit}` + : `Stop ${stopNumber}` +} + +/** What a saved row words: the status after `Server replay · → `. */ +function describeServerRowStatus(summary: NodeServerHistorySummary): string { + if (summary.verification === 'unavailable') return 'Unavailable' + if (summary.status === 'recording') return 'Recording' + if (summary.status === 'interrupted') + return `Interrupted: ${summary.captureMissing[0]?.text ?? summary.reason ?? 'the recording ended without a seal'}` + if (!summary.captureComplete) { + const [first] = summary.captureMissing + const more = summary.captureMissingCount - 1 + return `Partial: ${first?.text ?? summary.reason ?? 'the capture is incomplete'}${more > 0 ? ` (+${more} more)` : ''}` + } + // A complete capture is saved once the chat's explanation covers it. + if (summary.learningCoverage === 'complete') return 'Saved' + return summary.learningCoverage === 'none' + ? "Partial: the chat's explanation is not saved yet" + : `Partial: the explanation does not cover ${summary.learningMissingCount} recorded ${summary.learningMissingCount === 1 ? 'entry' : 'entries'}` +} + +/** + * Words one Saved studies row of a server history: it leads with the saved learning title (else the question), then `Server replay · → ` (or `→ `), then its status, so two replays of one route are told apart by what they explain. + * Called by the Study workspace for each server history of the library and by the removal and open notices that name one. + * @param summary - The history's summary as the library reads it. + * @returns The row's outcome text. + * @example describeServerReplayRow(summary) // => 'Creating an item · Server replay · POST /api/items → 201 · Saved' + */ +export function describeServerReplayRow( + summary: NodeServerHistorySummary, +): string { + const answer = + summary.responseStatus !== undefined + ? ` → ${summary.responseStatus}` + : summary.requestFailureCode + ? ` → ${summary.requestFailureCode}` + : summary.verification === 'verified' && summary.status !== 'recording' + ? ' → no response' + : '' + return `${summary.title ?? summary.question} · Server replay · ${summary.requestLine}${answer} · ${describeServerRowStatus(summary)}` +} diff --git a/extension/src/utils/stripNodeHistorySummaryTexts.ts b/extension/src/utils/stripNodeHistorySummaryTexts.ts index 80bfef72e..755425702 100644 --- a/extension/src/utils/stripNodeHistorySummaryTexts.ts +++ b/extension/src/utils/stripNodeHistorySummaryTexts.ts @@ -1,3 +1,6 @@ +import type { NodeHistorySummary } from '../nodeHistorySummary' +import type { NodeServerHistorySummary } from '../nodeServerHistoryTypes' + import { stripTerminalControls } from './stripTerminalControls' /** @@ -9,7 +12,29 @@ import { stripTerminalControls } from './stripTerminalControls' */ export function stripNodeHistorySummaryTexts< Summary extends { testFile: string; testTitles?: string[] }, ->(summary: Summary): Summary { +>(summary: Summary): Summary +export function stripNodeHistorySummaryTexts( + summary: NodeServerHistorySummary, +): NodeServerHistorySummary +export function stripNodeHistorySummaryTexts( + summary: NodeHistorySummary | { testFile: string; testTitles?: string[] }, +): unknown { + // A server history's recorded words are its question, saved title, request line and capture reasons. + if ('profile' in summary && summary.profile === 'node-server') + return { + ...summary, + question: stripTerminalControls(summary.question), + ...(summary.title ? { title: stripTerminalControls(summary.title) } : {}), + requestLine: stripTerminalControls(summary.requestLine), + ...(summary.reason + ? { reason: stripTerminalControls(summary.reason) } + : {}), + captureMissing: summary.captureMissing.map((reason) => ({ + ...reason, + text: stripTerminalControls(reason.text), + })), + } + if (!('testFile' in summary)) return summary return { ...summary, testFile: stripTerminalControls(summary.testFile), From 3477d93fb85b010403211792235179181a272822 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 15:39:06 +0900 Subject: [PATCH 04/19] feat: the node-history resources list server histories, read their events as a lean pinned list with per-entry detail, and state the three verdicts (Cycle 0059 pt2) --- extension/src/resources.ts | 17 +- extension/src/service.ts | 3 + extension/src/studyWorkspace.ts | 1 + .../tests/nodeServerHistoryResources.test.ts | 313 ++++++++++++++++++ .../src/utils/readServerHistoryResource.ts | 206 ++++++++++++ 5 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 extension/src/tests/nodeServerHistoryResources.test.ts create mode 100644 extension/src/utils/readServerHistoryResource.ts diff --git a/extension/src/resources.ts b/extension/src/resources.ts index fc2006c6f..7b1350b02 100644 --- a/extension/src/resources.ts +++ b/extension/src/resources.ts @@ -33,6 +33,7 @@ import { REPOSITORY_ANNOUNCED_PAUSE_RESUME_LIMIT_MS, } from './constants' import { ERROR_RECOVERY, EvidenceIntegrityError, StudyError } from './errors' +import type { NodeHistorySummary } from './nodeHistorySummary' import { nodeServerHelp } from './nodeServerHelp' import { nodeTestConfigSchema } from './nodeTestConfigSchemas' import { retainedRepositoryConfigSchema } from './repositoryConfigSchemas' @@ -45,6 +46,7 @@ import { readBoundedFile } from './utils/readBoundedFile' import { readJsonPage } from './utils/readJsonPage' import { readRecordingResource } from './utils/readRecordingResource' import { readRepositoryResource } from './utils/readRepositoryResource' +import { readServerHistoryResource } from './utils/readServerHistoryResource' import { secondsText } from './utils/secondsText' import { snapshotSourceHashes } from './utils/snapshotSourceHashes' import { withKnownNodeHistory } from './utils/withKnownNodeHistory' @@ -127,7 +129,10 @@ export async function readResource( 'NOT_FOUND', 'Node history storage is unavailable for this workspace.', ) - const histories = await service.nodeHistoryStore.list() + // The repository lists test and server histories together; a service without one lists its test store. + const histories: NodeHistorySummary[] = service.nodeHistoryRepository + ? await service.nodeHistoryRepository.list() + : await service.nodeHistoryStore.list() return readJsonPage( 'codebook://node-history', histories.map((history) => ({ @@ -140,7 +145,7 @@ export async function readResource( ) } const nodeHistoryMatch = - /^codebook:\/\/node-history\/([0-9a-f-]{36}_[0-9a-f-]{36})(?:\/(events(?:\/pages\/[a-f0-9]{64}\/\d+)?|learning-material(?:\/pages\/[a-f0-9]{64}\/\d+)?|pages\/[a-f0-9]{64}\/\d+))?$/.exec( + /^codebook:\/\/node-history\/([0-9a-f-]{36}_[0-9a-f-]{36})(?:\/(events(?:\/through\/\d+)?(?:\/pages\/[a-f0-9]{64}\/\d+)?|events\/\d+(?:\/pages\/[a-f0-9]{64}\/\d+)?|learning-material(?:\/pages\/[a-f0-9]{64}\/\d+)?|pages\/[a-f0-9]{64}\/\d+))?$/.exec( uri, ) if (nodeHistoryMatch?.[1]) { @@ -151,6 +156,14 @@ export async function readResource( ) const store = service.nodeHistoryStore, studyId = nodeHistoryMatch[1] + // A server history reads through its own resource module: lean events, per-entry detail and the three verdicts. + const resolved = service.nodeHistoryRepository?.resolve(studyId) + if (resolved?.profile === 'node-server') + return readServerHistoryResource( + resolved.store, + studyId, + nodeHistoryMatch[2], + ) // An unknown id, or a history removed while or right after it is read, is a reference to correct, not a failed study operation (Cycle 0052 tasks 60, 75, 95); each read below is checked the same way. const history = await withKnownNodeHistory(store, studyId, () => store.read(studyId), diff --git a/extension/src/service.ts b/extension/src/service.ts index 19e723dff..fb64736a2 100644 --- a/extension/src/service.ts +++ b/extension/src/service.ts @@ -56,6 +56,7 @@ import { learningSummarySchema, type LearningSummary, } from './learningSummarySchemas' +import type { NodeHistoryRepository } from './nodeHistoryRepository' import { nodeServerConfigSchema, type NodeServerConfiguration, @@ -473,6 +474,8 @@ export class StudyService { > repository?: RepositoryBookStore nodeHistoryStore?: NodeTestHistoryStore + /** The workspace's history repository, which resolves a study id to the test or server store that holds it; set with {@link nodeHistoryStore}. */ + nodeHistoryRepository?: NodeHistoryRepository repositoryPublished?: () => Promise repositoryReader?: ( input: OpenBookInput, diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 82ff1422c..71d59c51b 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -8098,6 +8098,7 @@ export class StudyWorkspace { this.service = service service.recordings = this.recordings service.nodeHistoryStore = this.nodeHistoryStore! + service.nodeHistoryRepository = this.nodeHistoryRepository // A summary's reason names no private path of this window (F15). this.nodeHistoryStore!.describeCause = (error) => sanitizeCause(error, service.causeRoots) diff --git a/extension/src/tests/nodeServerHistoryResources.test.ts b/extension/src/tests/nodeServerHistoryResources.test.ts new file mode 100644 index 000000000..140f01446 --- /dev/null +++ b/extension/src/tests/nodeServerHistoryResources.test.ts @@ -0,0 +1,313 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import { StudyError } from '../errors' +import { readResource } from '../resources' + +import { createHarness } from './helpers' +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +/** Reads a resource and returns the page object its text holds. */ +async function pageOf( + service: Parameters[0], + uri: string, +) { + const resource = await readResource(service, uri) + if (!('text' in resource)) throw new Error(`${uri} did not return JSON.`) + return JSON.parse(resource.text) as { + sourceUri: string + content: string + complete: boolean + nextUri?: string + totalBytes: number + } +} + +/** Reads every page of a resource by following `nextUri` and parses the joined content. */ +async function wholeJson( + service: Parameters[0], + uri: string, +): Promise<{ value: unknown; pages: number }> { + let page = await pageOf(service, uri) + let content = page.content + let pages = 1 + while (page.nextUri) { + page = await pageOf(service, page.nextUri) + content += page.content + pages++ + } + return { value: JSON.parse(content), pages } +} + +/** A service over a repository whose server store holds the history `build` writes. */ +async function serviceWith( + context: Parameters[0], + build: ( + repository: Awaited>['repository'], + ) => Promise, +) { + const harness = await createHarness() + context.after(() => harness.close()) + const { repository } = await openServerRepository(context) + const built = await build(repository) + harness.service.nodeHistoryStore = repository.testHistories + harness.service.nodeHistoryRepository = repository + return { service: harness.service, repository, built } +} + +// protects=the node-history catalog lists a server history with its profile, request line, response status and capture status; fails_when=a server history is missing from the catalog or lacks one of those members; no_claim=the catalog's paging +test('the node-history catalog lists a server history with its request line, response status and capture status', async (context) => { + // Arrange + const { service, built } = await serviceWith(context, (repository) => + writeServerHistory(repository, { output: ['created\n'] }), + ) + + // Act + const { value } = await wholeJson(service, 'codebook://node-history') + + // Assert + const [entry] = value as Array> + assert.equal(entry?.studyId, built.header.studyId) + assert.equal(entry?.profile, 'node-server') + assert.equal(entry?.requestLine, 'POST /api/items') + assert.equal(entry?.responseStatus, 201) + assert.equal(entry?.status, 'complete') + assert.equal(entry?.captureComplete, true) + assert.equal( + entry?.eventsUri, + `codebook://node-history/${built.header.studyId}/events`, + ) +}) + +// protects=the history resource states the three verdicts with capped missing lists of {code, text, params}; fails_when=a verdict is missing, the lists are uncut, or a reason loses its code or params; no_claim=the run resource (producer part) +test('the history resource returns saved, capture and learning verdicts with twenty reasons and the count of thirty-one', async (context) => { + // Arrange + const { service, built } = await serviceWith(context, async (repository) => { + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + declaredStops: 3, + }) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + // Three stops of ten unreadable values each. + for (const index of [0, 1, 2]) + await writer.appendStop( + serverStopCapture({ + declaredStopIndex: index, + hit: 1, + line: 2 + index, + elapsedMs: 10 + index, + unavailable: Array.from({ length: 10 }, (_, value) => ({ + path: `value${value}`, + reason: 'not readable', + })), + }), + ) + await writer.seal({ elapsedMs: 50, reason: 'stopped' }) + return header + }) + + // Act + const { value } = await wholeJson( + service, + `codebook://node-history/${built.studyId}`, + ) + + // Assert + const body = value as Record + assert.equal(body.profile, 'node-server') + assert.equal(body.saved, true) + assert.equal(body.captureComplete, false) + assert.equal(body.captureMissingCount, 31) + const missing = body.captureMissing as Array<{ + code: string + text: string + params: Record + }> + assert.equal(missing.length, 20) + assert.deepEqual(missing[0], { + code: 'SERVER_HISTORY_NO_RESPONSE_STOPPED', + text: 'No response was recorded: the run was stopped before the server answered.', + params: {}, + }) + assert.equal(body.learningCoverage, 'none') + assert.equal(body.learningMissingCount, 4) +}) + +// protects=the events list is lean: citable events only, no executed source text and no snapshot; fails_when=a stop in the list carries its snapshot or source text, or source and end entries are listed; no_claim=the per-entry detail +test('the events list names citable events with a stop reduced to its position, hash and facts', async (context) => { + // Arrange + const { service, built } = await serviceWith(context, (repository) => + writeServerHistory(repository, { output: ['created\n'] }), + ) + + // Act + const { value } = await wholeJson( + service, + `codebook://node-history/${built.header.studyId}/events`, + ) + + // Assert + const events = value as Array> + assert.deepEqual( + events.map((event) => [event.sequence, event.type]), + [ + [1, 'request'], + [4, 'stop'], + [5, 'output'], + [6, 'response'], + ], + ) + const stop = events[1]! + assert.equal(stop.path, 'src/handler.ts') + assert.equal(stop.line, 2) + assert.deepEqual(stop.facts, { 'value:id': '7' }) + assert.equal('debuggerSnapshot' in stop, false) + assert.equal( + JSON.stringify(events).includes('export function handler'), + false, + ) +}) + +// protects=a 16-hit history with 200 output lines reads its events in at most 16 pages; fails_when=the lean list outgrows the page budget the skill plans its reads by; no_claim=a larger output budget +test('a sixteen-hit history with two hundred output lines lists its events in at most sixteen pages', async (context) => { + // Arrange + const { service, built } = await serviceWith(context, (repository) => + writeServerHistory(repository, { + declaredStops: 2, + hits: Array.from({ length: 16 }, (_, index) => [ + index % 2, + index / 2 + 1 - (index % 2) / 2, + ]), + output: Array.from({ length: 200 }, (_, index) => `line ${index}\n`), + }), + ) + + // Act + const { pages } = await wholeJson( + service, + `codebook://node-history/${built.header.studyId}/events`, + ) + + // Assert + assert.ok(pages <= 16, `the events list took ${pages} pages`) +}) + +// protects=one entry's detail returns the whole snapshot with the executed source text restored; fails_when=the detail is the hash-only stored form or misses the body of a response; no_claim=the lean list +test('an entry detail returns a stop with its rehydrated snapshot and a response with its body', async (context) => { + // Arrange + const { service, built } = await serviceWith(context, (repository) => + writeServerHistory(repository, {}), + ) + + // Act + const stop = ( + await wholeJson( + service, + `codebook://node-history/${built.header.studyId}/events/4`, + ) + ).value as { + type: string + debuggerSnapshot: { frames: Array<{ source: { text: string } }> } + } + const response = ( + await wholeJson( + service, + `codebook://node-history/${built.header.studyId}/events/5`, + ) + ).value as { type: string; body: string } + + // Assert + assert.equal(stop.type, 'stop') + assert.match( + stop.debuggerSnapshot.frames[0]!.source.text, + /export function handler/, + ) + assert.equal(response.type, 'response') + assert.equal(response.body, '{"id":7}') +}) + +// protects=a sequence the history does not hold is refused as NOT_FOUND naming the events list; fails_when=the read answers an empty page or a raw error; no_claim=an unknown history id +test('an entry detail for a sequence the history does not hold is refused as NOT_FOUND', async (context) => { + // Arrange + const { service, built } = await serviceWith(context, (repository) => + writeServerHistory(repository, {}), + ) + + // Act + const refused = readResource( + service, + `codebook://node-history/${built.header.studyId}/events/99`, + ) + + // Assert + await assert.rejects(refused, (error: unknown) => { + assert.ok(error instanceof StudyError) + assert.equal(error.code, 'NOT_FOUND') + assert.match(error.message, /has no entry with sequence 99/) + return true + }) +}) + +// protects=every page of one events read is pinned to the prefix its first page saw, so a history that keeps recording reads consistently; fails_when=output appended between two pages fails the read with changed evidence or leaks into a later page; no_claim=a read that starts after the append +test('output appended between two pages of one events read does not change the pages that follow', async (context) => { + // Arrange + const { service, built } = await serviceWith(context, async (repository) => { + 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: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + for (let line = 0; line < 120; line++) + await writer.appendOutput({ + elapsedMs: 3 + line, + channel: 'stdout', + text: `output line ${line}\n`, + }) + return { header, writer } + }) + const uri = `codebook://node-history/${built.header.studyId}/events` + const first = await pageOf(service, uri) + + // Act + await built.writer.appendOutput({ + elapsedMs: 500, + channel: 'stdout', + text: 'a line recorded after the first page\n', + }) + let page = first + let content = first.content + while (page.nextUri) { + page = await pageOf(service, page.nextUri) + content += page.content + } + + // Assert + assert.match(first.nextUri ?? '', /\/events\/through\/122\/pages\//) + const events = JSON.parse(content) as Array<{ sequence: number }> + assert.equal(events.length, 121) + assert.equal(events.at(-1)?.sequence, 122) + built.writer.abandon() +}) diff --git a/extension/src/utils/readServerHistoryResource.ts b/extension/src/utils/readServerHistoryResource.ts new file mode 100644 index 000000000..580a6e7e2 --- /dev/null +++ b/extension/src/utils/readServerHistoryResource.ts @@ -0,0 +1,206 @@ +import { StudyError } from '../errors' +import type { NodeServerHistoryEntry } from '../nodeServerHistorySchemas' +import type { NodeServerHistoryTypes } from '../nodeServerHistoryTypes' +import type { NodeHistoryStore } from '../nodeTestHistoryStore' +import type { ResourceContent } from '../resources' + +import { readJsonPage } from './readJsonPage' +import { rehydrateNodeServerEntries } from './rehydrateServerStopSnapshot' +import { withKnownNodeHistory } from './withKnownNodeHistory' + +/** The entries of a server history an agent may cite, in the lean form the events list returns: never the executed source text or a whole snapshot. */ +type LeanServerEvent = Record & { + sequence: number + type: string + elapsedMs: number +} + +/** + * Reduces one journal entry to its lean, citable form, or to nothing for an entry no learning step may cite (`source`, `end`, the send marks and `output-truncated`). + * Called by {@link readServerHistoryResource} for the events list; the whole entry, snapshot and body included, is {@link readServerHistoryResource}'s per-entry detail. + * @param entry - A server journal entry as stored. + * @returns The lean event, or `undefined` for a storage-only entry. + * @example leanServerEvent(stopEntry) // => { sequence: 4, type: 'stop', elapsedMs: 60, declaredStopIndex: 0, hit: 1, path: 'src/handler.ts', line: 2, sourceHash: '…', facts: { 'value:id': '7' } } + */ +export function leanServerEvent( + entry: NodeServerHistoryEntry, +): LeanServerEvent | undefined { + const head = { + sequence: entry.sequence, + type: entry.type, + elapsedMs: entry.elapsedMs, + } + switch (entry.type) { + case 'request': + return { + ...head, + attempt: entry.attempt, + method: entry.method, + path: entry.path, + bodyBytes: entry.bodyBytes, + } + case 'stop': + return { + ...head, + declaredStopIndex: entry.declaredStopIndex, + hit: entry.hit, + path: entry.source.path, + line: entry.source.line, + sourceHash: entry.source.hash, + facts: entry.facts, + unavailable: entry.unavailable, + } + case 'output': + return { ...head, channel: entry.channel, text: entry.text } + case 'response': + return { + ...head, + status: entry.status, + contentType: entry.contentType, + bodyBytes: entry.bodyBytes, + readCut: entry.readCut, + } + case 'request-failed': + return { ...head, code: entry.code, message: entry.message } + default: + return undefined + } +} + +/** + * The page a server history's events list reads: every page of one read is pinned to the prefix that existed at its first page, so a history still recording reads consistently; the first read pins the last sequence it sees and returns `through` in its `nextUri`. + * @param baseUri - `codebook://node-history/`. + * @param entries - The journal's entries as stored. + * @param through - The pinned last sequence, or `undefined` for a first read, which pins the journal's current end. + * @param page - The returned page cursor, `pages//`, absent for the first page. + * @returns One page of the lean list of events with sequence up to `through`. + * @example readServerEventsPage('codebook://node-history/a_b', history.entries, undefined, undefined) + */ +function readServerEventsPage( + baseUri: string, + entries: readonly NodeServerHistoryEntry[], + through: number | undefined, + page: string | undefined, +): ResourceContent { + const last = through ?? entries.at(-1)?.sequence ?? 0 + const events = entries + .filter((entry) => entry.sequence <= last) + .flatMap((entry) => leanServerEvent(entry) ?? []) + return readJsonPage(`${baseUri}/events/through/${last}`, events, page) +} + +/** The three verdicts and their capped lists as the history resource and the run resource state them. */ +function serverVerdicts( + summary: Awaited< + ReturnType['summarize']> + >, + sealed: boolean, +) { + return { + profile: 'node-server' as const, + // Saved: the history is sealed. Capture complete: no partial reason. Learning complete: the material cites the request, a hit of every reached stop and the terminal entry. + saved: sealed, + captureComplete: summary?.captureComplete ?? false, + captureMissing: summary?.captureMissing ?? [], + captureMissingCount: summary?.captureMissingCount ?? 0, + learningCoverage: summary?.learningCoverage ?? 'none', + learningMissing: summary?.learningMissing ?? [], + learningMissingCount: summary?.learningMissingCount ?? 0, + materialRevision: summary?.materialRevision ?? 0, + } +} + +/** + * Answers a `codebook://node-history/` resource for a server history: the history with its three verdicts, the lean events list, one entry's detail, or the learning material. + * Called by {@link readResource} for an id the history repository resolves to the node-server profile. + * @param store - The workspace's server history store. + * @param studyId - The history's study id. + * @param suffix - What follows the id in the URI: `events`, `events/through/` with an optional `/pages/…`, `events/` with an optional `/pages/…`, `learning-material`, a `pages/…` cursor, or nothing. + * @returns The resource's first or requested page. + * @throws `NOT_FOUND` for an id without a history folder or an entry the history does not hold; `INVALID_REFERENCE` for a page cursor the content does not match. + * @example await readServerHistoryResource(store, studyId, 'events/4') + */ +export async function readServerHistoryResource( + store: NodeHistoryStore, + studyId: string, + suffix: string | undefined, +): Promise { + const baseUri = `codebook://node-history/${studyId}` + const history = await withKnownNodeHistory(store, studyId, () => + store.read(studyId), + ) + const events = + /^events(?:\/through\/(\d+))?(\/pages\/[a-f0-9]{64}\/\d+)?$/.exec( + suffix ?? '', + ) + if (events) + return readServerEventsPage( + baseUri, + history.entries, + events[1] === undefined ? undefined : Number(events[1]), + events[2]?.slice(1), + ) + const detail = /^events\/(\d+)(\/pages\/[a-f0-9]{64}\/\d+)?$/.exec( + suffix ?? '', + ) + if (detail) { + const sequence = Number(detail[1]) + // The stored stop keeps its top frame's source by hash; the detail restores the whole snapshot. + const entry = rehydrateNodeServerEntries(history.entries).find( + (candidate) => candidate.sequence === sequence, + ) + if (!entry) + throw new StudyError( + 'NOT_FOUND', + `The history ${studyId} has no entry with sequence ${sequence}. Read ${baseUri}/events for the sequences it holds.`, + ) + return readJsonPage( + `${baseUri}/events/${sequence}`, + entry, + detail[2]?.slice(1), + ) + } + if ( + suffix === 'learning-material' || + suffix?.startsWith('learning-material/pages/') + ) { + const material = await withKnownNodeHistory(store, studyId, () => + store.readLearningMaterial(studyId), + ) + return readJsonPage( + `${baseUri}/learning-material`, + material ?? { + studyId, + revision: 0, + status: 'not-saved', + content: undefined, + }, + suffix.startsWith('learning-material/pages/') + ? suffix.slice('learning-material/'.length) + : undefined, + ) + } + const summary = await withKnownNodeHistory(store, studyId, () => + store.summarize(studyId), + ) + return readJsonPage( + baseUri, + { + header: history.header, + status: history.status, + reason: history.reason, + seal: history.seal + ? { + status: history.seal.status, + endedAt: history.seal.endedAt, + entryCount: history.seal.entryCount, + capture: history.seal.capture, + } + : undefined, + entryCount: history.entries.length, + ...serverVerdicts(summary, history.seal !== undefined), + eventsUri: `${baseUri}/events`, + }, + suffix, + ) +} From e96f5b476b7c8fc24de1d74063d8b7b51f1867a5 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 15:44:32 +0900 Subject: [PATCH 05/19] feat: a server history is read once into a replay model, and the Application pane projects its request, response and output regions (Cycle 0059 pt2) --- .../src/tests/buildServerReplayModel.test.ts | 231 ++++++++++++++++++ .../tests/projectServerApplication.test.ts | 191 +++++++++++++++ .../src/tests/utils/sealedServerHistory.ts | 13 +- .../src/utils/assertNodeLearningCitations.ts | 2 +- extension/src/utils/buildServerReplayModel.ts | 216 ++++++++++++++++ extension/src/utils/indexNodeTestOutput.ts | 5 +- .../src/utils/projectServerApplication.ts | 217 ++++++++++++++++ .../src/utils/readServerHistoryResource.ts | 2 +- 8 files changed, 871 insertions(+), 6 deletions(-) create mode 100644 extension/src/tests/buildServerReplayModel.test.ts create mode 100644 extension/src/tests/projectServerApplication.test.ts create mode 100644 extension/src/utils/buildServerReplayModel.ts create mode 100644 extension/src/utils/projectServerApplication.ts diff --git a/extension/src/tests/buildServerReplayModel.test.ts b/extension/src/tests/buildServerReplayModel.test.ts new file mode 100644 index 000000000..544466828 --- /dev/null +++ b/extension/src/tests/buildServerReplayModel.test.ts @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import type { NodeServerHistoryEntryInput } from '../nodeServerHistorySchemas' +import { + buildServerReplayModel, + findServerStop, + neighbourServerStop, + openingServerEvent, + restoreServerPlayhead, +} from '../utils/buildServerReplayModel' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +const COMPLETE = { + saved: true, + captureComplete: true, + learningCoverage: 'complete', +} as const + +/** Reads a history written by the fixture writer back through the store and builds its replay model. */ +async function modelOf( + context: Parameters[0], + options: Parameters[1] = {}, +) { + const { repository } = await openServerRepository(context) + const { header, sequences } = await writeServerHistory(repository, options) + const history = await repository.serverHistories.read(header.studyId) + return { + model: buildServerReplayModel(history, COMPLETE), + sequences, + history, + } +} + +// protects=the model lists learner events only and finds each by its journal sequence although source entries leave gaps in the numbers; fails_when=a source or send-mark entry becomes an event, or a lookup by sequence lands on the neighbour; no_claim=the workspace's selection order +test('the replay model skips storage-only entries and looks events up by their non-contiguous journal sequences', async (context) => { + // Arrange / Act + const { model, history } = await modelOf(context, { output: ['created\n'] }) + + // Assert + assert.deepEqual( + history.entries.map((entry) => [entry.sequence, entry.type]), + [ + [1, 'request'], + [2, 'request-sent'], + [3, 'source'], + [4, 'stop'], + [5, 'output'], + [6, 'response'], + [7, 'end'], + ], + ) + assert.deepEqual( + model.events.map((event) => [event.sequence, event.type]), + [ + [1, 'request'], + [4, 'stop'], + [5, 'output'], + [6, 'response'], + ], + ) + assert.equal(model.indexBySequence.get(4), 1) + assert.equal(model.indexBySequence.get(3), undefined) +}) + +// protects=every stop carries its full rehydrated snapshot, so the replay shows the executed source; fails_when=a stop keeps the empty text marker of its stored form; no_claim=the debugger's presentation +test('the replay model rehydrates each stop snapshot with the executed source text', async (context) => { + // Arrange / Act + const { model } = await modelOf(context) + + // Assert + const stop = findServerStop(model, 1, 1) + assert.match( + stop?.debuggerSnapshot.frames[0]?.source?.text ?? '', + /export function handler/, + ) +}) + +// protects=two hits recorded at one time keep their journal order and each hit of a repeated stop is reachable by Previous and Next stop; fails_when=same-time entries are reordered or a hit is skipped; no_claim=the Play timer's tie-break +test('two hits of one stop at the same time keep journal order and Next stop visits both', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY, { + declaredStops: 1, + }) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + for (const hit of [1, 2]) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit, line: 2, elapsedMs: 20 }), + ) + await writer.seal({ elapsedMs: 30, reason: 'stopped' }) + const model = buildServerReplayModel( + await repository.serverHistories.read(header.studyId), + COMPLETE, + ) + + // Act + const first = findServerStop(model, 1, 1)! + const second = neighbourServerStop(model, first.sequence, 'next') + const back = neighbourServerStop(model, second!.sequence, 'previous') + const none = neighbourServerStop(model, second!.sequence, 'next') + + // Assert + assert.deepEqual([first.hit, second?.hit, back?.hit], [1, 2, 1]) + assert.equal(none, undefined) + assert.deepEqual([...model.repeatedStops], [0]) + assert.equal(first.elapsedMs, second?.elapsedMs) +}) + +// protects=a late end after the terminal entry moves only the timeline's end; fails_when=the end becomes an event or the response is treated as missing; no_claim=the Application pane +test('a late end after the response ends the timeline there and the history still answered', async (context) => { + // Arrange / Act + const { model } = await modelOf(context) + + // Assert + assert.equal(model.terminal?.type, 'response') + assert.equal(model.endedBeforeAnswer, false) + assert.ok(model.endedAtMs > (model.terminal?.elapsedMs ?? 0)) + assert.equal( + model.events.some((event) => event.type === 'response'), + true, + ) +}) + +// protects=a run stopped before its answer reads as ended before the answer; fails_when=the model invents a terminal or ends the timeline before the last event; no_claim=the wording of the line +test('a history stopped before its answer has no terminal and ends at its end entry', async (context) => { + // Arrange / Act + const { model } = await modelOf(context, { response: null }) + + // Assert + assert.equal(model.terminal, undefined) + assert.equal(model.endedBeforeAnswer, true) + assert.equal(model.endedAtMs, model.events.at(-1)!.elapsedMs + 10) +}) + +// protects=a saved playhead on a storage-only entry restores the nearest earlier learner event, and one before every event restores the opening moment; fails_when=the restore lands on the source entry, throws, or skips to a later event; no_claim=a saved sequence the journal never held +test('a saved playhead on a source entry restores the request before it and a playhead on end restores the response', async (context) => { + // Arrange + const { model } = await modelOf(context) + + // Act + const onSource = restoreServerPlayhead(model, 3) + const onEnd = restoreServerPlayhead(model, 7) + const onStop = restoreServerPlayhead(model, 4) + + // Assert + assert.equal(onSource?.type, 'request') + assert.equal(onEnd?.type, 'response') + assert.equal(onStop?.sequence, 4) +}) + +// protects=the opening rule: a replay opens on its first stop, else its request; fails_when=a history with a stop opens on the request or one with no stop opens on nothing; no_claim=the saved position, which wins over this rule +test('a replay opens on its first recorded stop and, with no stop, on its request', async (context) => { + // Arrange + const withStop = await modelOf(context) + const noStop = await modelOf(context, { hits: [], response: null }) + + // Act + const first = openingServerEvent(withStop.model) + const second = openingServerEvent(noStop.model) + + // Assert + assert.equal(first?.type, 'stop') + assert.equal(second?.type, 'request') +}) + +// protects=a withdrawn attempt is not claimed as sent; fails_when=the attempt reads as sent without its mark; no_claim=the refusal's code wording +test('an attempt without its send mark is not sent and keeps the code of its withdrawal', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + const entries: NodeServerHistoryEntryInput[] = [ + { + type: 'request', + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }, + { type: 'request-withdrawn', elapsedMs: 2, attempt: 1, code: 'RUN_BUSY' }, + { + type: 'request', + elapsedMs: 3, + attempt: 2, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }, + { type: 'request-sent', elapsedMs: 4, attempt: 2 }, + ] + for (const entry of entries) await writer.appendEntry(entry) + await writer.seal({ elapsedMs: 9, reason: 'stopped' }) + + // Act + const model = buildServerReplayModel( + await repository.serverHistories.read(header.studyId), + COMPLETE, + ) + + // Assert + assert.deepEqual(model.attempts, [ + { attempt: 1, sent: false, withdrawnCode: 'RUN_BUSY' }, + { attempt: 2, sent: true }, + ]) + assert.deepEqual( + model.events.map((event) => event.type), + ['request', 'request-withdrawn', 'request'], + ) +}) diff --git a/extension/src/tests/projectServerApplication.test.ts b/extension/src/tests/projectServerApplication.test.ts new file mode 100644 index 000000000..e1dae4129 --- /dev/null +++ b/extension/src/tests/projectServerApplication.test.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import { + buildServerReplayModel, + type ServerReplayModel, +} from '../utils/buildServerReplayModel' +import { + indexServerOutput, + projectServerApplication, +} from '../utils/projectServerApplication' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +const VERDICTS = { + saved: true, + captureComplete: true, + learningCoverage: 'complete', +} as const + +/** Writes a fixture history and returns its replay model with the sequences of what it holds. */ +async function modelOf( + context: Parameters[0], + options: Parameters[1] = {}, +) { + const { repository } = await openServerRepository(context) + const { header, sequences } = await writeServerHistory(repository, options) + const model = buildServerReplayModel( + await repository.serverHistories.read(header.studyId), + VERDICTS, + ) + return { model, sequences } +} + +/** The Application view at one sequence. */ +function viewAt(model: ServerReplayModel, sequence: number, reason?: string) { + const selected = model.events[model.indexBySequence.get(sequence)!] + return projectServerApplication(model, indexServerOutput(model), selected, { + generation: 1, + ...(reason ? { noResponseReason: reason } : {}), + }) +} + +// protects=the response region shows no response before the response entry, the response at it and clears it again when the playhead moves back; fails_when=a later response is visible at an earlier moment; no_claim=colors, which carry no status +test('the response region reads no response yet at the stop, the response at the answer and no response again after moving back', async (context) => { + // Arrange + const { model, sequences } = await modelOf(context, { + output: ['created\n'], + }) + + // Act + const atStop = viewAt(model, sequences.stops[0]!) + const atResponse = viewAt(model, sequences.response!) + const backAtStop = viewAt(model, sequences.stops[0]!) + + // Assert + assert.equal( + atStop.response?.state, + 'No response recorded yet at this moment.', + ) + assert.equal(atResponse.response?.state, 'Response 201 · application/json') + assert.equal(atResponse.response?.body, '{"id":7}') + assert.deepEqual(backAtStop.response, atStop.response) +}) + +// protects=the request region says Request sent by Codebook only with the send mark and shows the declared request line; fails_when=the claim or the line is missing from any moment; no_claim=that the server received the request +test('the request region shows the request line with the sent claim at every moment', async (context) => { + // Arrange + const { model, sequences } = await modelOf(context, {}) + + // Act + const atRequest = viewAt(model, sequences.request) + const atResponse = viewAt(model, sequences.response!) + + // Assert + assert.deepEqual(atRequest.request, { + line: 'POST /api/items', + claim: 'Request sent by Codebook', + }) + assert.deepEqual(atResponse.request, atRequest.request) +}) + +// protects=an empty body, a cut body and a response that never came are worded apart; fails_when=an empty body shows a block, a cut body hides its size, or the missing answer is not given its reason at the last event; no_claim=the body bound itself +test('the response region words an empty body, a cut body and a run stopped before its answer', async (context) => { + // Arrange + const empty = await modelOf(context, { + response: { status: 204, body: '', contentType: 'text/plain' }, + }) + const stopped = await modelOf(context, { response: null }) + const cut = await modelOf(context, { + response: { status: 200, body: '{"id":7}', bodyBytes: 9000, readCut: true }, + }) + const reason = + 'No response was recorded: the run was stopped before the server answered.' + + // Act + const emptyView = viewAt(empty.model, empty.sequences.response!) + const cutView = viewAt(cut.model, cut.sequences.response!) + const earlier = viewAt(stopped.model, stopped.sequences.request, reason) + const lastEvent = stopped.model.events.at(-1)! + const atEnd = viewAt(stopped.model, lastEvent.sequence, reason) + + // Assert + assert.deepEqual(emptyView.response, { + state: 'Response 204 · text/plain', + note: 'Response body is empty.', + }) + assert.equal(cutView.response?.note, 'Body shown cut at 8 bytes.') + assert.equal( + earlier.response?.state, + 'No response recorded yet at this moment.', + ) + assert.equal(atEnd.response?.state, reason) + assert.equal(atEnd.end, 'Recording ended before the server answered.') +}) + +// protects=the output log up to the moment reuses the test replay's window: lines before the selected event only; fails_when=output recorded after the selected moment shows; no_claim=the 4,096-character window's cut line +test('the output log shows only the lines recorded up to the selected moment', async (context) => { + // Arrange + const { model, sequences } = await modelOf(context, { + output: ['first\n', 'second\n'], + }) + + // Act + const afterFirst = viewAt(model, sequences.output[0]!) + const afterSecond = viewAt(model, sequences.output[1]!) + const atStop = viewAt(model, sequences.stops[0]!) + + // Assert + assert.deepEqual( + afterFirst.lines.map((line) => line.text), + ['first'], + ) + assert.deepEqual( + afterSecond.lines.map((line) => line.text), + ['first', 'second'], + ) + assert.deepEqual(atStop.lines, []) + assert.equal( + atStop.placeholder, + 'No server output recorded up to this moment.', + ) +}) + +// protects=a withdrawn attempt reads Request not sent with its code; fails_when=the region claims the bytes were sent; no_claim=a later successful attempt +test('a request withdrawn before its bytes left reads Request not sent with its code', 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: '/api/items', + bodyBytes: 14, + }) + await writer.appendRequestWithdrawn({ + elapsedMs: 2, + attempt: 1, + code: 'RUN_BUSY', + }) + await writer.appendRequestFailed({ + elapsedMs: 3, + code: 'RUN_BUSY', + message: 'The run was busy.', + }) + await writer.seal({ elapsedMs: 4, reason: 'failed' }) + const model = buildServerReplayModel( + await repository.serverHistories.read(header.studyId), + VERDICTS, + ) + + // Act + const view = viewAt(model, model.events[0]!.sequence) + const atFailure = viewAt(model, model.terminal!.sequence) + + // Assert + assert.equal(view.request?.claim, 'Request not sent: RUN_BUSY') + assert.equal(atFailure.response?.state, 'Request failed: RUN_BUSY') +}) diff --git a/extension/src/tests/utils/sealedServerHistory.ts b/extension/src/tests/utils/sealedServerHistory.ts index ef85ff81e..2ec956149 100644 --- a/extension/src/tests/utils/sealedServerHistory.ts +++ b/extension/src/tests/utils/sealedServerHistory.ts @@ -16,7 +16,14 @@ export type SealedServerHistoryOptions = { /** The output lines recorded before the response. */ output?: string[] /** The recorded response; `null` ends the run with no answer. */ - response?: { status: number; body: string; contentType?: string } | null + response?: { + status: number + body: string + contentType?: string + /** The bytes the server sent, when more than the shown body holds. */ + bodyBytes?: number + readCut?: boolean + } | null question?: string path?: string /** Seals the history; `false` leaves its writer abandoned, so it reads interrupted. */ @@ -97,8 +104,8 @@ export async function writeServerHistory( status: response.status, contentType: response.contentType ?? 'application/json', body: response.body, - bodyBytes: Buffer.byteLength(response.body), - readCut: false, + bodyBytes: response.bodyBytes ?? Buffer.byteLength(response.body), + readCut: response.readCut ?? false, location: null, requestDurationMs: 30, }) diff --git a/extension/src/utils/assertNodeLearningCitations.ts b/extension/src/utils/assertNodeLearningCitations.ts index 00ab2461e..67e6d5326 100644 --- a/extension/src/utils/assertNodeLearningCitations.ts +++ b/extension/src/utils/assertNodeLearningCitations.ts @@ -46,7 +46,7 @@ function describeEventKind( * @returns The start of the sentence. * @example neutralStepLead('s1') // => 'Saved learning step s1' */ -export function neutralStepLead(stepId: string): string { +function neutralStepLead(stepId: string): string { return `Saved learning step ${stepId}` } diff --git a/extension/src/utils/buildServerReplayModel.ts b/extension/src/utils/buildServerReplayModel.ts new file mode 100644 index 000000000..1a18c8a58 --- /dev/null +++ b/extension/src/utils/buildServerReplayModel.ts @@ -0,0 +1,216 @@ +import type { + NodeServerHistoryEntry, + NodeServerHistoryHeader, + NodeServerStopEntry, +} from '../nodeServerHistorySchemas' +import type { NodeServerHistorySummary } from '../nodeServerHistoryTypes' + +import { rehydrateNodeServerEntries } from './rehydrateServerStopSnapshot' + +/** The entries of a server journal a learner moves through: the request attempt and its withdrawal, output, a stop hit, the response and the request failure. `source` is storage only and `end` ends the timeline, so neither is an event. */ +export type ServerReplayEvent = Exclude< + NodeServerHistoryEntry, + { type: 'source' | 'end' | 'request-sent' | 'output-truncated' } +> + +/** What the model keeps of one request attempt: whether its bytes were handed to the socket, and the code of a refusal before that. */ +export type ServerReplayAttempt = { + attempt: number + sent: boolean + withdrawnCode?: string +} + +/** The three verdicts a replay states beside its events: sealed, capture complete, learning complete. */ +export type ServerReplayVerdicts = { + saved: boolean + captureComplete: boolean + learningCoverage: NodeServerHistorySummary['learningCoverage'] +} + +/** + * A server history read once into what a replay asks of it: the learner events in journal order, a lookup by sequence, the stops by number and hit with their rehydrated snapshots, when the recording ended and the three verdicts. + * Built by {@link buildServerReplayModel}; the workspace's select, supersede and commit order moves over it unchanged. It holds data only: it imports no launcher and no HTTP sender, so a replay can never start the server or resend the request. + */ +export type ServerReplayModel = { + studyId: string + header: NodeServerHistoryHeader + status: 'recording' | 'complete' | 'partial' | 'interrupted' + events: readonly ServerReplayEvent[] + /** The position of each event in {@link events}, by journal sequence. */ + indexBySequence: ReadonlyMap + attempts: readonly ServerReplayAttempt[] + /** The recorded response or request failure, when the journal holds one. */ + terminal?: Extract + /** The time the recording ended: the `end` entry's, else the last event's; the timeline ends here. */ + endedAtMs: number + /** Whether the journal ended with neither a response nor a request failure. */ + endedBeforeAnswer: boolean + /** The declared stops hit more than once, by declared index. */ + repeatedStops: ReadonlySet + verdicts: ServerReplayVerdicts +} + +/** + * Reads a verified server history once into a {@link ServerReplayModel}: every stop's snapshot is rehydrated from its `source` entry, the learner events keep their journal order (entries sharing one time stay in order), a late `end` after the terminal entry only moves the timeline's end, and the three verdicts come from the seal and the learning coverage the summary already counted. + * Called once per opened history by the Study workspace. + * @param history - The verified history as the store reads it. + * @param verdicts - The verdicts of the history's summary. + * @returns The model. + * @example const model = buildServerReplayModel(await store.read(studyId), { saved: true, captureComplete: true, learningCoverage: 'complete' }) + */ +export function buildServerReplayModel( + history: { + header: NodeServerHistoryHeader + entries: readonly NodeServerHistoryEntry[] + status: ServerReplayModel['status'] + seal?: unknown + }, + verdicts: ServerReplayVerdicts, +): ServerReplayModel { + const entries = rehydrateNodeServerEntries(history.entries) + const events = entries.filter(isReplayEvent) + const terminal = events.find( + (event) => event.type === 'response' || event.type === 'request-failed', + ) + const end = entries.find((entry) => entry.type === 'end') + return { + studyId: history.header.studyId, + header: history.header, + status: history.status, + events, + indexBySequence: new Map( + events.map((event, position) => [event.sequence, position]), + ), + attempts: collectAttempts(entries), + ...(terminal?.type === 'response' || terminal?.type === 'request-failed' + ? { terminal } + : {}), + // The timeline ends at `end`; a journal with no `end` ends at its last event. + endedAtMs: Math.max(end?.elapsedMs ?? 0, events.at(-1)?.elapsedMs ?? 0), + endedBeforeAnswer: terminal === undefined, + repeatedStops: repeatedStopIndexes(events), + verdicts, + } +} + +/** Reads each request attempt's send mark and withdrawal code from the journal's entries. */ +function collectAttempts( + entries: readonly NodeServerHistoryEntry[], +): ServerReplayAttempt[] { + const attempts = new Map() + for (const entry of entries) { + if (entry.type === 'request') + attempts.set(entry.attempt, { attempt: entry.attempt, sent: false }) + const attempt = + entry.type === 'request-sent' || entry.type === 'request-withdrawn' + ? attempts.get(entry.attempt) + : undefined + if (attempt && entry.type === 'request-sent') attempt.sent = true + if (attempt && entry.type === 'request-withdrawn') + attempt.withdrawnCode = entry.code + } + return [...attempts.values()] +} + +/** Finds the declared stops that were hit more than once. */ +function repeatedStopIndexes( + events: readonly ServerReplayEvent[], +): ReadonlySet { + const hits = new Map() + for (const event of events) + if (event.type === 'stop') + hits.set( + event.declaredStopIndex, + (hits.get(event.declaredStopIndex) ?? 0) + 1, + ) + return new Set( + [...hits].filter(([, count]) => count > 1).map(([index]) => index), + ) +} + +/** Says whether a journal entry is a learner event. */ +function isReplayEvent( + entry: NodeServerHistoryEntry, +): entry is ServerReplayEvent { + return ( + entry.type !== 'source' && + entry.type !== 'end' && + entry.type !== 'request-sent' && + entry.type !== 'output-truncated' + ) +} + +/** + * Finds the event a saved playhead restores: the event itself, or for a sequence that names a storage-only entry (`source`, a send mark, `end`) the nearest earlier learner event; a playhead before every event, or none, restores the replay's opening moment. + * @param model - The replay model. + * @param sequence - The saved reader position's journal sequence. + * @returns The event to restore, or the opening moment when no earlier event exists; `undefined` for a history with no events. + * @example restoreServerPlayhead(model, 3)?.sequence // => 2 when sequence 3 is a source entry after the request at 2 + */ +export function restoreServerPlayhead( + model: ServerReplayModel, + sequence: number, +): ServerReplayEvent | undefined { + const exact = model.indexBySequence.get(sequence) + if (exact !== undefined) return model.events[exact] + const earlier = model.events.findLast((event) => event.sequence < sequence) + return earlier ?? openingServerEvent(model) +} + +/** + * Finds the moment a replay opens at when nothing was saved: its first recorded stop, else its request, else its first event. + * @param model - The replay model. + * @returns The opening event, or `undefined` for a history with no events. + * @example openingServerEvent(model)?.type // => 'stop' + */ +export function openingServerEvent( + model: ServerReplayModel, +): ServerReplayEvent | undefined { + return ( + model.events.find((event) => event.type === 'stop') ?? + model.events.find((event) => event.type === 'request') ?? + model.events[0] + ) +} + +/** + * Finds the previous or next recorded stop hit from a moment, in journal order, so Previous and Next stop visit every hit of a stop that was hit again. + * @param model - The replay model. + * @param sequence - The moment the learner is at. + * @param direction - `next` for a later hit, `previous` for an earlier one. + * @returns The stop event, or `undefined` when there is none in that direction. + * @example neighbourServerStop(model, 1, 'next')?.hit // => 1 + */ +export function neighbourServerStop( + model: ServerReplayModel, + sequence: number, + direction: 'next' | 'previous', +): NodeServerStopEntry | undefined { + const stops = model.events.filter( + (event): event is NodeServerStopEntry => event.type === 'stop', + ) + return direction === 'next' + ? stops.find((stop) => stop.sequence > sequence) + : stops.findLast((stop) => stop.sequence < sequence) +} + +/** + * Finds one recorded hit of a declared stop by its number and hit, the lookup a `Stop · hit ` link and a saved reading position use. + * @param model - The replay model. + * @param stopNumber - The declared stop's number, counted from 1. + * @param hit - The hit's number, counted from 1. + * @returns The stop event, or `undefined` for a hit the journal does not hold. + * @example findServerStop(model, 2, 3)?.sequence // => 14 + */ +export function findServerStop( + model: ServerReplayModel, + stopNumber: number, + hit: number, +): NodeServerStopEntry | undefined { + return model.events.find( + (event): event is NodeServerStopEntry => + event.type === 'stop' && + event.declaredStopIndex === stopNumber - 1 && + event.hit === hit, + ) +} diff --git a/extension/src/utils/indexNodeTestOutput.ts b/extension/src/utils/indexNodeTestOutput.ts index 54acde1ec..a3ed94432 100644 --- a/extension/src/utils/indexNodeTestOutput.ts +++ b/extension/src/utils/indexNodeTestOutput.ts @@ -1,3 +1,4 @@ +import type { NodeServerHistoryEntry } from '../nodeServerHistorySchemas' import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' import { countCodePoints } from './countCodePoints' @@ -64,7 +65,9 @@ const INSPECTOR_LINES = [ * // => { lineCount: 1, pending: { channel: 'stdout', start: 4, end: 6, codePoints: 2 }, inspectorLines: 0 } */ export function indexNodeTestOutput( - entries: readonly NodeTestHistoryIndex['entries'][number][], + entries: readonly ( + NodeTestHistoryIndex['entries'][number] | NodeServerHistoryEntry + )[], ): NodeTestOutputIndex { const outputEntries = entries.filter((entry) => entry.type === 'output') // The output is shown text already: stripping it again would pass over the whole output for nothing (F315, F339). diff --git a/extension/src/utils/projectServerApplication.ts b/extension/src/utils/projectServerApplication.ts new file mode 100644 index 000000000..912b03d40 --- /dev/null +++ b/extension/src/utils/projectServerApplication.ts @@ -0,0 +1,217 @@ +import { + accumulateNodeTestOutput, + type NodeTestOutputViewLine, +} from './accumulateNodeTestOutput' +import type { + ServerReplayEvent, + ServerReplayModel, +} from './buildServerReplayModel' +import { + describeServerRequestClaim, + describeServerResponseState, + NODE_SERVER_REPLAY_TEXT, +} from './describeNodeServerReplay' +import { describeNodeTestReplay } from './describeNodeTestReplay' +import { formatEventSeconds } from './formatEventSeconds' +import { + indexNodeTestOutput, + type NodeTestOutputIndex, +} from './indexNodeTestOutput' + +/** What the Application pane shows of a server replay at one moment: the fixed request region, the fixed response region, then the output log. Every field is text, rendered as text and never as markup. */ +export type NodeServerApplicationView = { + generation: number + /** Header line 1: `Recorded server output · s`, or `Recorded server output` alone without a selected event. */ + time: string + /** The pane's accessible name. */ + name: string + /** The request region: the request line and the claim the replay may make about it. */ + request?: { line: string; claim: string } + /** The response region for the selected moment. */ + response?: { + state: string + /** The recorded body, in its own scrolling block; absent before the response and for an empty body. */ + body?: string + /** `Body shown cut at bytes.` for a cut body, `Response body is empty.` for none. */ + note?: string + } + /** The output log's accessible name, `Recorded server output up to s`. */ + region?: string + lines: NodeTestOutputViewLine[] + cut?: string + inspector?: string + /** The line that stands for the log when there is none to show: loading, nothing yet or no entries. */ + placeholder?: string + /** The last line: that the recording ended before the server answered. */ + end?: string +} + +/** Words the Application pane's first line and name. */ +function timeWords(elapsedMs: number | undefined): string { + return elapsedMs === undefined + ? 'Recorded server output' + : `Recorded server output · ${formatEventSeconds(elapsedMs)} s` +} + +/** + * Finds the request attempt the selected moment belongs to: the latest attempt opened at or before it, else the first. + * @param model - The replay model. + * @param selected - The selected event. + * @returns The attempt's request line and its mark state. + */ +function requestRegion( + model: ServerReplayModel, + selected: ServerReplayEvent, +): NodeServerApplicationView['request'] { + const opened = model.events.filter( + (event): event is Extract => + event.type === 'request', + ) + const request = + opened.findLast((event) => event.sequence <= selected.sequence) ?? opened[0] + if (!request) return undefined + const attempt = model.attempts.find( + (candidate) => candidate.attempt === request.attempt, + ) + return { + line: `${request.method} ${request.path}`, + claim: describeServerRequestClaim({ + sent: attempt?.sent ?? false, + ...(attempt?.withdrawnCode + ? { withdrawnCode: attempt.withdrawnCode } + : {}), + }), + } +} + +/** + * Words the response region for the selected moment: before the terminal entry the pre-response line, at or after a response its status, content type and body, for a failed request its code, and for a history that ended with no answer its reason once the replay reaches the end. + * @param model - The replay model. + * @param selected - The selected event. + * @param noResponseReason - The first partial reason of a history that ended with no answer, worded by the capture evaluator. + * @returns The region. + */ +function responseRegion( + model: ServerReplayModel, + selected: ServerReplayEvent, + noResponseReason: string | undefined, +): NonNullable { + const { terminal } = model + // A history with no terminal entry reaches its end at its last event. + const reached = terminal + ? selected.sequence >= terminal.sequence + : selected.sequence === model.events.at(-1)?.sequence + if (terminal?.type === 'response' && reached) { + const shownBytes = Buffer.byteLength(terminal.body) + const cut = terminal.readCut || shownBytes < terminal.bodyBytes + return { + state: describeServerResponseState({ reached, response: terminal }), + ...(terminal.bodyBytes === 0 + ? { note: NODE_SERVER_REPLAY_TEXT.emptyBody } + : { body: terminal.body }), + ...(cut && terminal.bodyBytes > 0 + ? { note: `Body shown cut at ${shownBytes} bytes.` } + : {}), + } + } + return { + state: describeServerResponseState({ + reached, + ...(terminal?.type === 'request-failed' + ? { failureCode: terminal.code } + : {}), + ...(noResponseReason ? { noResponseReason } : {}), + }), + } +} + +/** + * Projects what the Application pane shows of a server replay at the selected moment: the request region, the response region and the output log up to the moment, reading the output with the test replay's window, follow and cut rules. + * Called by the Study workspace for each Study message while a server replay is selected. + * @param model - The replay model. + * @param index - The replay's output read once ({@link indexNodeTestOutput} over the journal's output entries). + * @param selected - The selected event, absent before one is selected. + * @param options - `generation` supersedes older updates; `loading` shows the opening state; `leaving` names the surface alone while the selection goes elsewhere; `noResponseReason` is the first partial reason of a history that never answered. + * @returns The view. + * @example projectServerApplication(model, index, model.events[0], { generation: 1 }).request?.claim // => 'Request sent by Codebook' + */ +export function projectServerApplication( + model: ServerReplayModel, + index: NodeTestOutputIndex, + selected: ServerReplayEvent | undefined, + options: { + generation: number + loading?: boolean + leaving?: boolean + noResponseReason?: string + }, +): NodeServerApplicationView { + const { generation, loading = false, leaving = false } = options + if (loading || !selected) + return { + generation, + time: timeWords(undefined), + name: NODE_SERVER_REPLAY_TEXT.heading, + lines: [], + placeholder: loading + ? NODE_SERVER_REPLAY_TEXT.loading + : model.events.length + ? NODE_SERVER_REPLAY_TEXT.nothingYet + : NODE_SERVER_REPLAY_TEXT.noEntries, + } + const tail = accumulateNodeTestOutput(index, selected.sequence) + const last = model.events.at(-1) + return { + generation, + time: timeWords(leaving ? undefined : selected.elapsedMs), + name: `${NODE_SERVER_REPLAY_TEXT.heading} · ${model.header.declaredRequest.method} ${model.header.declaredRequest.path}`, + ...(requestRegion(model, selected) + ? { request: requestRegion(model, selected) } + : {}), + response: responseRegion(model, selected, options.noResponseReason), + ...(leaving + ? {} + : { + region: `Recorded server output up to ${formatEventSeconds(selected.elapsedMs)} s`, + }), + lines: tail.lines, + ...(tail.omittedCharacters > 0 + ? { + cut: describeNodeTestReplay({ + kind: 'application-cut', + characters: tail.omittedCharacters, + startsMidLine: tail.startsMidLine, + }), + } + : {}), + ...(tail.inspectorLines > 0 + ? { + inspector: describeNodeTestReplay({ + kind: 'application-inspector', + lines: tail.inspectorLines, + }), + } + : {}), + ...(tail.hasOutput + ? {} + : { placeholder: NODE_SERVER_REPLAY_TEXT.nothingYet }), + // A history that ended with no answer says so at its last event, while the recording is no longer being written. + ...(model.status !== 'recording' && + model.endedBeforeAnswer && + last?.sequence === selected.sequence + ? { end: NODE_SERVER_REPLAY_TEXT.endedEarly } + : {}), + } +} + +/** + * Reads a server replay's output once for {@link projectServerApplication}. + * @param model - The replay model. + * @returns The output index over the model's output events. + * @example const index = indexServerOutput(model) + */ +export function indexServerOutput( + model: ServerReplayModel, +): NodeTestOutputIndex { + return indexNodeTestOutput(model.events) +} diff --git a/extension/src/utils/readServerHistoryResource.ts b/extension/src/utils/readServerHistoryResource.ts index 580a6e7e2..9dac85f6d 100644 --- a/extension/src/utils/readServerHistoryResource.ts +++ b/extension/src/utils/readServerHistoryResource.ts @@ -22,7 +22,7 @@ type LeanServerEvent = Record & { * @returns The lean event, or `undefined` for a storage-only entry. * @example leanServerEvent(stopEntry) // => { sequence: 4, type: 'stop', elapsedMs: 60, declaredStopIndex: 0, hit: 1, path: 'src/handler.ts', line: 2, sourceHash: '…', facts: { 'value:id': '7' } } */ -export function leanServerEvent( +function leanServerEvent( entry: NodeServerHistoryEntry, ): LeanServerEvent | undefined { const head = { From 88eae5e18e1919119aae866c1f16cf8959d0ce9d Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 15:47:00 +0900 Subject: [PATCH 06/19] refactor: the learning summary and the Node learning material share their participants and steps schemas, and the server Application view shares the output cut lines (Cycle 0059 pt2) --- extension/src/learningSummarySchemas.ts | 89 ++++++++++++------- extension/src/nodeTestHistorySchemas.ts | 38 ++------ .../src/utils/projectNodeTestApplication.ts | 2 +- .../src/utils/projectServerApplication.ts | 20 +---- 4 files changed, 71 insertions(+), 78 deletions(-) diff --git a/extension/src/learningSummarySchemas.ts b/extension/src/learningSummarySchemas.ts index 4b2a38339..f0d134b4a 100644 --- a/extension/src/learningSummarySchemas.ts +++ b/extension/src/learningSummarySchemas.ts @@ -22,41 +22,70 @@ const terminalReference = z.strictObject({ captureHash: z.string().regex(/^[a-f0-9]{64}$/), }) const reference = z.union([sourceReference, terminalReference]) + +/** + * Builds the steps list every learning summary and learning material has: at least one, each from one participant to another with a label, an explanation, its evidence class and the reference that backs it. + * Shared by {@link learningSummarySchema} and the Node learning material's content schema, so the two never drift. + * @param id - The schema of a step's and a participant's id. + * @param referenceField - The step's reference field, already optional and described. + * @returns The steps schema. + * @example learningStepsSchema(learningStepIdSchema, reference.optional()) + */ +export function learningStepsSchema< + Id extends z.ZodType, + ReferenceField extends z.ZodType, +>(id: Id, referenceField: ReferenceField) { + return z + .array( + z.strictObject({ + id, + from: id, + to: id, + label: z.string().min(1).max(LEARNING_SUMMARY_LIMITS.stepLabelChars), + explanation: z.string().min(1).max(MAX_EXPLANATION_CHARS), + evidence: z.enum(['observed', 'source-derived', 'unknown']), + reference: referenceField, + }), + ) + .min(1) + .max(LEARNING_SUMMARY_LIMITS.steps) +} + +/** + * Builds the participants list every learning summary and learning material has: at least one, each with an id and a label. + * Shared by {@link learningSummarySchema} and the Node learning material's content schema, so the two never drift. + * @param id - The schema of a participant's id. + * @returns The participants schema. + * @example learningParticipantsSchema(learningStepIdSchema).parse([{ id: 'ui', label: 'UI' }]) + */ +export function learningParticipantsSchema(id: Id) { + return z + .array( + z.strictObject({ + id, + label: z + .string() + .min(1) + .max(LEARNING_SUMMARY_LIMITS.participantLabelChars), + }), + ) + .min(1) + .max(LEARNING_SUMMARY_LIMITS.participants) +} export const learningSummarySchema = z .strictObject({ question: z.string().min(1).max(MAX_EXPLANATION_CHARS), title: z.string().min(1).max(LEARNING_SUMMARY_LIMITS.titleChars), summary: z.string().min(1).max(MAX_EXPLANATION_CHARS), - participants: z - .array( - z.strictObject({ - id: learningStepIdSchema, - label: z - .string() - .min(1) - .max(LEARNING_SUMMARY_LIMITS.participantLabelChars), - }), - ) - .min(1) - .max(LEARNING_SUMMARY_LIMITS.participants), - steps: z - .array( - z.strictObject({ - id: learningStepIdSchema, - from: learningStepIdSchema, - to: learningStepIdSchema, - label: z.string().min(1).max(LEARNING_SUMMARY_LIMITS.stepLabelChars), - explanation: z.string().min(1).max(MAX_EXPLANATION_CHARS), - evidence: z.enum(['observed', 'source-derived', 'unknown']), - reference: reference - .optional() - .describe( - "Required for an observed or source-derived step: the snapshotUri and sourceHash of a committed observation of this run, or after the result the run's terminalReference; omit it only for an unknown step.", - ), - }), - ) - .min(1) - .max(LEARNING_SUMMARY_LIMITS.steps), + participants: learningParticipantsSchema(learningStepIdSchema), + steps: learningStepsSchema( + learningStepIdSchema, + reference + .optional() + .describe( + "Required for an observed or source-derived step: the snapshotUri and sourceHash of a committed observation of this run, or after the result the run's terminalReference; omit it only for an unknown step.", + ), + ), }) .superRefine((value, context) => { // Each broken rule is its own issue at the offending element, so an agent corrects that element instead of dropping the summary. diff --git a/extension/src/nodeTestHistorySchemas.ts b/extension/src/nodeTestHistorySchemas.ts index 37fe79d9e..9bcd970a8 100644 --- a/extension/src/nodeTestHistorySchemas.ts +++ b/extension/src/nodeTestHistorySchemas.ts @@ -14,6 +14,10 @@ import { REPOSITORY_LITERAL_LIMIT_CHARS, NODE_TEST_PROFILE, } from './constants' +import { + learningParticipantsSchema, + learningStepsSchema, +} from './learningSummarySchemas' import { studyIdSchema } from './legacyStudySchemas' import { nodeTestConfigSchema } from './nodeTestConfigSchemas' import { replayDebugSnapshotSchema } from './replayDebugSchemas' @@ -166,35 +170,11 @@ export function buildNodeLearningMaterialContentSchema< question: z.string().min(1).max(MAX_EXPLANATION_CHARS), title: z.string().min(1).max(LEARNING_SUMMARY_LIMITS.titleChars), summary: z.string().min(1).max(MAX_EXPLANATION_CHARS), - participants: z - .array( - z.strictObject({ - id: nodeLearningStepIdSchema, - label: z - .string() - .min(1) - .max(LEARNING_SUMMARY_LIMITS.participantLabelChars), - }), - ) - .min(1) - .max(LEARNING_SUMMARY_LIMITS.participants), - steps: z - .array( - z.strictObject({ - id: nodeLearningStepIdSchema, - from: nodeLearningStepIdSchema, - to: nodeLearningStepIdSchema, - label: z - .string() - .min(1) - .max(LEARNING_SUMMARY_LIMITS.stepLabelChars), - explanation: z.string().min(1).max(MAX_EXPLANATION_CHARS), - evidence: z.enum(['observed', 'source-derived', 'unknown']), - reference: reference.optional().describe(referenceDescription), - }), - ) - .min(1) - .max(LEARNING_SUMMARY_LIMITS.steps), + participants: learningParticipantsSchema(nodeLearningStepIdSchema), + steps: learningStepsSchema( + nodeLearningStepIdSchema, + reference.optional().describe(referenceDescription), + ), }) .superRefine((value, context) => { // Each broken rule is its own issue at the offending element, as for a configured learning summary. diff --git a/extension/src/utils/projectNodeTestApplication.ts b/extension/src/utils/projectNodeTestApplication.ts index a850e4eaf..b907f44ac 100644 --- a/extension/src/utils/projectNodeTestApplication.ts +++ b/extension/src/utils/projectNodeTestApplication.ts @@ -114,7 +114,7 @@ export function projectNodeTestApplication( * @returns `cut` and `inspector`, each only when something was left out. * @example leftOutLines({ omittedCharacters: 120, startsMidLine: false, inspectorLines: 0, … }) // => { cut: '… 120 earlier characters not shown' } */ -function leftOutLines( +export function leftOutLines( tail: NodeTestOutputTail, ): Pick { return { diff --git a/extension/src/utils/projectServerApplication.ts b/extension/src/utils/projectServerApplication.ts index 912b03d40..dc6324344 100644 --- a/extension/src/utils/projectServerApplication.ts +++ b/extension/src/utils/projectServerApplication.ts @@ -11,12 +11,12 @@ import { describeServerResponseState, NODE_SERVER_REPLAY_TEXT, } from './describeNodeServerReplay' -import { describeNodeTestReplay } from './describeNodeTestReplay' import { formatEventSeconds } from './formatEventSeconds' import { indexNodeTestOutput, type NodeTestOutputIndex, } from './indexNodeTestOutput' +import { leftOutLines } from './projectNodeTestApplication' /** What the Application pane shows of a server replay at one moment: the fixed request region, the fixed response region, then the output log. Every field is text, rendered as text and never as markup. */ export type NodeServerApplicationView = { @@ -175,23 +175,7 @@ export function projectServerApplication( region: `Recorded server output up to ${formatEventSeconds(selected.elapsedMs)} s`, }), lines: tail.lines, - ...(tail.omittedCharacters > 0 - ? { - cut: describeNodeTestReplay({ - kind: 'application-cut', - characters: tail.omittedCharacters, - startsMidLine: tail.startsMidLine, - }), - } - : {}), - ...(tail.inspectorLines > 0 - ? { - inspector: describeNodeTestReplay({ - kind: 'application-inspector', - lines: tail.inspectorLines, - }), - } - : {}), + ...leftOutLines(tail), ...(tail.hasOutput ? {} : { placeholder: NODE_SERVER_REPLAY_TEXT.nothingYet }), From 63cec5607b9d1260b65b0b5d8442bf6713464da7 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 15:52:27 +0900 Subject: [PATCH 07/19] fix: server replay deletion words live in their own constant, so the pane's pinned refusal options stay as they were; record timings of the new test files (Cycle 0059 pt2) --- extension/src/constants.ts | 9 ++++++--- extension/src/studyWorkspace.ts | 5 ++++- extension/src/ui/constants.ts | 3 +++ extension/src/ui/studyHtml.ts | 4 +++- extension/test-timings.json | 27 +++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 93b893b71..abec4774c 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -223,7 +223,6 @@ export const UNSAVED_STUDY_HANDED_OVER_REASON = export const STUDY_REMOVAL_REFUSALS = { nothingChosen: 'Choose a saved study to delete.', nodeHistory: 'Test replays cannot be deleted here yet.', - serverHistory: 'Server replays cannot be deleted here yet.', runNotStopped: 'Stop the run before deleting its study.', recordingOpen: 'Choose Overview or Live to leave Recording before deleting this study.', @@ -241,8 +240,6 @@ export const STUDY_REMOVAL_REFUSALS_WITHOUT_TRASH: Record< 'This window has no Trash, so Codebook cannot delete a saved study here. Choose a saved study, then open Remove by hand to list its files.', nodeHistory: 'Codebook cannot list the files of a test replay for removal by hand yet.', - serverHistory: - 'Codebook cannot list the files of a server replay for removal by hand yet.', runNotStopped: "Stop the run before removing its study's files by hand.", recordingOpen: "Choose Overview or Live to leave Recording before removing this study's files by hand.", @@ -251,6 +248,12 @@ export const STUDY_REMOVAL_REFUSALS_WITHOUT_TRASH: Record< newerFormat: 'This study was saved by a newer Codebook and is kept as it is. Update the extension to list its files for removal by hand.', } +/** Why a server replay cannot be removed from Saved studies, as a window with a Trash and one without word it; a test replay's sentences are {@link STUDY_REMOVAL_REFUSALS}'s `nodeHistory`. */ +export const STUDY_REMOVAL_SERVER_HISTORY_REFUSAL = { + withTrash: 'Server replays cannot be deleted here yet.', + withoutTrash: + 'Codebook cannot list the files of a server replay for removal by hand yet.', +} as const /** What Saved studies answers a request for a study's files with in a window without a Trash; the "Remove by hand" disclosure shows it above the files the answer lists, and the reader deletes nothing itself. */ export const STUDY_REMOVAL_WITHOUT_TRASH_MESSAGE = 'This window has no Trash, so Codebook cannot delete a saved study here. Remove these files by hand, then press Retry storage / refresh.' diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 71d59c51b..dc5fc446c 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -80,6 +80,7 @@ import { STUDY_REFRESH_INTERVAL_MS, STUDY_REMOVAL_REFUSALS, STUDY_REMOVAL_REFUSALS_WITHOUT_TRASH, + STUDY_REMOVAL_SERVER_HISTORY_REFUSAL, STUDY_REMOVAL_WITHOUT_TRASH_MESSAGE, STUDY_SOURCE_SCHEME, SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX, @@ -24313,7 +24314,9 @@ export class StudyWorkspace { this.nodeServerHistoryCatalog.some( (history) => history.studyId === studyId, ), - sentences.serverHistory, + this.studyRemovalAvailability() === 'no-trash' + ? STUDY_REMOVAL_SERVER_HISTORY_REFUSAL.withoutTrash + : STUDY_REMOVAL_SERVER_HISTORY_REFUSAL.withTrash, ], [ !this.catalog.some((entry) => entry.id === studyId), diff --git a/extension/src/ui/constants.ts b/extension/src/ui/constants.ts index 9a5104d46..9bf20ef65 100644 --- a/extension/src/ui/constants.ts +++ b/extension/src/ui/constants.ts @@ -13,6 +13,7 @@ import { PRESENTATION_FOCUS_ACTIONS, STUDY_REMOVAL_REFUSALS, STUDY_REMOVAL_REFUSALS_WITHOUT_TRASH, + STUDY_REMOVAL_SERVER_HISTORY_REFUSAL, STUDY_REMOVAL_WITHOUT_TRASH_MESSAGE, } from '../constants' import { NODE_TEST_REPLAY_TEXT } from '../utils/describeNodeTestReplay' @@ -168,6 +169,8 @@ export const STUDY_UI_OPTIONS = { studyRemovalRefusals: STUDY_REMOVAL_REFUSALS, // Stand where the reasons above stand in a window with a Trash: a window without one deletes nothing and lists the chosen study's files under "Remove by hand". studyRemovalRefusalsWithoutTrash: STUDY_REMOVAL_REFUSALS_WITHOUT_TRASH, + // Why a server replay cannot be deleted, in the words of a window with a Trash and of one without. + serverHistoryRefusal: STUDY_REMOVAL_SERVER_HISTORY_REFUSAL, // The note under the Saved studies list for a chosen entry that cannot be opened, and for one whose catalog checkpoint can be repaired: each says what is wrong and what the learner can still do in a window with a Trash and in one without. studyNotes: { unopenable: { diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index 882c18076..95cf59feb 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -1378,7 +1378,9 @@ function studyClient( [ chosen.format === 'node-test-history' && chosen.profile === 'node-server', - sentences.serverHistory, + message.removalWithoutTrash + ? options.serverHistoryRefusal.withoutTrash + : options.serverHistoryRefusal.withTrash, ], // The host moves nothing of a study a newer Codebook saved: which files that format keeps is not known here. [chosen.format === 'newer', sentences.newerFormat], diff --git a/extension/test-timings.json b/extension/test-timings.json index 0aee64614..4cc47e01e 100644 --- a/extension/test-timings.json +++ b/extension/test-timings.json @@ -33,6 +33,9 @@ "browserRecordingSealFailure.test.js": { "totalMs": 356 }, + "buildServerReplayModel.test.js": { + "totalMs": 595 + }, "buildWikiUpdatePrompt.test.js": { "totalMs": 1 }, @@ -526,6 +529,9 @@ "describeNodeRecordedSource.test.js": { "totalMs": 6 }, + "describeNodeServerReplay.test.js": { + "totalMs": 36 + }, "describeNodeTestReplay.test.js": { "totalMs": 1 }, @@ -838,6 +844,9 @@ "managedProfileRoot.test.js": { "totalMs": 1 }, + "maskNodeServerConfiguration.test.js": { + "totalMs": 82 + }, "maskNodeServerRequest.test.js": { "totalMs": 1 }, @@ -1087,6 +1096,9 @@ "nodeHistoryAdmission.test.js": { "totalMs": 159 }, + "nodeHistoryMaterialLock.test.js": { + "totalMs": 378 + }, "nodeHistoryPlaybackController.test.js": { "totalMs": 362 }, @@ -1110,9 +1122,15 @@ "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 } }, + "nodeServerHistoryInterrupted.test.js": { + "totalMs": 202 + }, "nodeServerHistoryJournalIntegrity.test.js": { "totalMs": 434 }, + "nodeServerHistoryResources.test.js": { + "totalMs": 2706 + }, "nodeServerHistorySeal.test.js": { "totalMs": 1909 }, @@ -1122,6 +1140,9 @@ "nodeServerHistoryWriter.test.js": { "totalMs": 590 }, + "nodeServerHistoryWriterStates.test.js": { + "totalMs": 290 + }, "nodeStopSourceMatches.test.js": { "totalMs": 189 }, @@ -1239,6 +1260,9 @@ "projectRecording.test.js": { "totalMs": 17 }, + "projectServerApplication.test.js": { + "totalMs": 506 + }, "protocol.test.js": { "totalMs": 17467, "tests": { @@ -3532,6 +3556,9 @@ "studyWorkspaceRepositoryReader.test.js": { "totalMs": 6600 }, + "studyWorkspaceServerLibrary.test.js": { + "totalMs": 712 + }, "studyWorkspaceStorage.test.js": { "totalMs": 15018, "tests": { From d86b2ad644067fca1d7f136b579bfa7f2fd6e7ca Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 16:30:52 +0900 Subject: [PATCH 08/19] feat: the workspace replays a saved server history through the test replay's select, supersede and commit path, with the server catalog on the Study pane and the Application document (Cycle 0059 pt2) A server history is read once into its replay model and presented as a test-shaped view of learner events; the Application pane gets its two fixed request and response regions, the debugger thread and the source placeholder say server, and a saved playhead on a storage-only entry restores the nearest earlier event. --- extension/src/nodeTestHistoryStore.ts | 5 +- extension/src/studyWorkspace.ts | 488 ++++++++-- .../tests/studyWorkspaceServerReplay.test.ts | 888 ++++++++++++++++++ extension/src/tests/utils/shownTestOutput.ts | 31 +- extension/src/ui/constants.ts | 25 +- extension/src/ui/studyHtml.ts | 89 +- extension/src/utils/adaptServerReplay.ts | 174 ++++ .../src/utils/describeNodeRecordedSource.ts | 9 +- .../src/utils/describeNodeServerReplay.ts | 181 +++- extension/src/utils/describeNodeTestReplay.ts | 11 + extension/src/utils/indexNodeTestOutput.ts | 4 +- extension/src/utils/installNodeTestOutput.ts | 26 +- .../utils/nodeHistoryPlaybackController.ts | 19 +- extension/src/utils/presentServerHistory.ts | 64 ++ .../src/utils/projectNodeHistoryTimeline.ts | 8 +- .../src/utils/projectNodeLearningSummary.ts | 23 +- .../src/utils/projectNodeTestApplication.ts | 8 +- extension/src/utils/projectNodeTestSection.ts | 22 +- .../src/utils/projectServerApplication.ts | 43 +- extension/src/utils/projectServerSection.ts | 199 ++++ extension/src/utils/testOutputHtml.ts | 22 +- 21 files changed, 2192 insertions(+), 147 deletions(-) create mode 100644 extension/src/tests/studyWorkspaceServerReplay.test.ts create mode 100644 extension/src/utils/adaptServerReplay.ts create mode 100644 extension/src/utils/presentServerHistory.ts create mode 100644 extension/src/utils/projectServerSection.ts diff --git a/extension/src/nodeTestHistoryStore.ts b/extension/src/nodeTestHistoryStore.ts index a09bdc312..44c6f5c3a 100644 --- a/extension/src/nodeTestHistoryStore.ts +++ b/extension/src/nodeTestHistoryStore.ts @@ -1174,7 +1174,10 @@ export class NodeHistoryStore { saveReaderPosition( studyId: string, sequence: number, - verified?: Pick, 'header' | 'entries'>, + verified?: { + header: Pick + entries: readonly Pick[] + }, ): Promise { const previous = this.readerPositionWrites.get(studyId) ?? Promise.resolve() const operation = previous diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index dc5fc446c..ecda0fef5 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -126,18 +126,22 @@ import { } from './learningLibrary' import { NodeHistoryRepository } from './nodeHistoryRepository' import { NodeLearningSave } from './nodeLearningSave' -import type { NodeServerHistorySummary } from './nodeServerHistoryTypes' +import type { + NodeServerHistorySummary, + NodeServerHistoryTypes, +} from './nodeServerHistoryTypes' import { nodeLearningStepIdSchema, type NodeTestHistoryEntry, type NodeTestHistoryIndex, type NodeTestHistoryLearningDecision, - type NodeTestLearningMaterial, } from './nodeTestHistorySchemas' import type { + NodeHistoryStore, NodeTestHistorySummary, NodeTestHistoryStore, } from './nodeTestHistoryStore' +import type { NodeTestHistoryTypes } from './nodeTestHistoryTypes' import { PlaybackController, type PlaybackBundle, @@ -221,9 +225,14 @@ import { type StudyRemovalView, type StudyViewMessage, } from './ui/studyHtml' +import type { ServerReplayPresentation } from './utils/adaptServerReplay' import { adoptWikiGroupReturn } from './utils/adoptWikiGroupReturn' import { awaitDocumentSettled } from './utils/awaitDocumentSettled' import { bookObservationHash } from './utils/bookObservationHash' +import { + openingServerEvent, + restoreServerPlayhead, +} from './utils/buildServerReplayModel' import { capturedAppHtml } from './utils/capturedAppHtml' import { capturedSourceDeclaredPath } from './utils/capturedSourceDeclaredPath' import { @@ -245,7 +254,11 @@ import { describeNodeRecordedSource, NODE_RECORDED_SOURCE_TEXT, } from './utils/describeNodeRecordedSource' -import { describeServerReplayRow } from './utils/describeNodeServerReplay' +import { + describeServerEventRow, + describeServerReplayRow, + NODE_SERVER_REPLAY_TEXT, +} from './utils/describeNodeServerReplay' import { describeNodeServerStatus } from './utils/describeNodeServerStatus' import { describeNodeTestReplay } from './utils/describeNodeTestReplay' import { describePaneIdentity } from './utils/describePaneIdentity' @@ -323,18 +336,28 @@ import { type RefusalTabView, } from './utils/placementRefusalReport' import { plannedStopLine } from './utils/plannedStopLine' +import { presentServerHistory } from './utils/presentServerHistory' import { projectLearningSummary } from './utils/projectLearningSummary' import { type NodeHistoryTimeline, projectNodeHistoryTimeline, } from './utils/projectNodeHistoryTimeline' -import { projectNodeLearningSummary } from './utils/projectNodeLearningSummary' +import { + type NodeLearningMaterialOfHistory, + type NodeLearningSummaryProjection, + projectNodeLearningSummary, +} from './utils/projectNodeLearningSummary' import { projectNodeTestApplication, type NodeTestApplicationView, } from './utils/projectNodeTestApplication' -import { projectNodeTestSection } from './utils/projectNodeTestSection' +import { + projectNodeTestSection, + type NodeTestSectionInput, +} from './utils/projectNodeTestSection' import { projectPlaybackStudy } from './utils/projectPlaybackStudy' +import { projectServerApplication } from './utils/projectServerApplication' +import { projectServerSection } from './utils/projectServerSection' import { pruneClosedTabRecord } from './utils/pruneClosedTabRecord' import { readNodeTestOutputReport } from './utils/readNodeTestOutputReport' import { @@ -436,6 +459,16 @@ type NodeTestHistoryStop = Extract /** How {@link StudyWorkspace}'s return to a test replay's committed moment ended: the slot shows that moment again, a newer selection took over, or the slot could not show it (its row says why). */ type NodeMomentReturn = 'returned' | 'superseded' | 'not-shown' +/** What opening a replay reads: its history (a server replay's in its test-shaped view), the sidecars and the server presentation when it is one; see {@link StudyWorkspace.readNodeHistoryOpening}. */ +type NodeHistoryOpening = { + history: NodeTestHistoryIndex + learningDecision?: NodeTestHistoryLearningDecision + learningMaterial?: NodeLearningMaterialOfHistory + savedSequence?: number + positionError?: unknown + serverReplay?: ServerReplayPresentation +} + /** How one selection of a test replay's moment behaves; see {@link StudyWorkspace.selectNodeHistoryEvent}. */ type NodeHistorySelectionOptions = { requireDebugger?: boolean @@ -804,7 +837,9 @@ export class StudyWorkspace { private nodeServerHistoryCatalog: NodeServerHistorySummary[] = [] private selectedNodeHistory?: NodeTestHistoryIndex private selectedNodeHistoryLearningDecision?: NodeTestHistoryLearningDecision - private selectedNodeHistoryLearningMaterial?: NodeTestLearningMaterial + private selectedNodeHistoryLearningMaterial?: NodeLearningMaterialOfHistory + /** The selected history as a server replay presents it, when it is one: its model and its test-shaped view, which is {@link selectedNodeHistory} itself. Every surface that words or draws a moment asks it first ({@link nodeHistoryViews}). */ + private selectedServerReplay?: ServerReplayPresentation private selectedNodeHistoryPage = 0 private selectedNodeHistorySequence?: number /** The moment the recorded-source slot and the replay debugger show now: set when a selection commits with its slot, cleared when a selection leaves the moment and when the slot could not show it. The next selection of the committed moment leaves it again when a failed or overtaken selection took the surfaces off it (F191). */ @@ -8121,7 +8156,7 @@ export class StudyWorkspace { */ nodeHistoryLearningMaterialSaved( studyId: string, - material: NodeTestLearningMaterial, + material: NodeLearningMaterialOfHistory, ): void { if (this.selectedNodeHistory?.header.studyId !== studyId || this.closing) return @@ -11877,6 +11912,9 @@ export class StudyWorkspace { private isTestReplayStudy(studyId: string): boolean { return ( this.nodeHistoryCatalog.some((history) => history.studyId === studyId) || + this.nodeServerHistoryCatalog.some( + (history) => history.studyId === studyId, + ) || [...(this.service?.records.values() ?? [])].some( (record) => record.profile === NODE_TEST_PROFILE && @@ -12251,6 +12289,7 @@ export class StudyWorkspace { learningMaterial, savedSequence, positionError, + serverReplay, } = opening this.nodeHistoryPlaybackController?.dispose() this.replayDebugger?.invalidate() @@ -12259,6 +12298,7 @@ export class StudyWorkspace { if (this.selectedNodeHistory?.header.studyId !== history.header.studyId) this.nodeOutputReadings.clear() this.selectedNodeHistory = history + this.selectedServerReplay = serverReplay this.nodeHistoryOpenToken = request // The pending close failure of another replay's withdrawn selection is not this replay's (F365). this.nodeSourceSlotCloseFailure = undefined @@ -12292,16 +12332,19 @@ export class StudyWorkspace { failed: (error) => this.report('control', error), }) // The saved reader position, else the first stop whose source verifies, else the first event (DS1). - const initialSequence = - savedSequence ?? - firstVerifiedStopSequence(history) ?? - history.entries[0]?.sequence + const initialSequence = this.landingSequence( + history, + savedSequence, + serverReplay, + ) this.nodeHistoryPlaybackController.open( history.entries.map((entry) => ({ sequence: entry.sequence, elapsedMs: entry.elapsedMs, })), initialSequence, + // A server recording's timeline ends at its `end` entry, which may come after the last event. + serverReplay?.model.endedAtMs, ) if (positionError) this.reportTestReplayFailure('history-position', positionError) @@ -12381,19 +12424,96 @@ export class StudyWorkspace { studyId: string, request: number, revision: number, + ): Promise { + const resolved = this.nodeHistoryRepository?.resolve(studyId) + if (resolved?.profile === 'node-server') + return this.readServerHistoryOpening( + resolved.store, + studyId, + request, + revision, + ) + const store = this.nodeHistoryStore! + const history = await store.read(studyId) + if (this.historyOpenSuperseded(request, revision)) return undefined + const sidecars = await this.readOpeningSidecars( + store, + studyId, + request, + revision, + ) + return sidecars && { history, ...sidecars } + } + + /** + * Reads a server history for its replay: the journal, the same sidecars a test replay reads, its summary's learning verdict, then the presentation that dresses it in the shared replay path; a history another window is still recording is refused, as it opens only once sealed or interrupted. Called by {@link readNodeHistoryOpening} for a study id the repository resolves to the server store. + * A saved reader position on a storage-only entry (a source entry or a send mark) restores the nearest earlier learner event. + * @param store - The server store the repository resolved. + * @param studyId - The server history's study id. + * @param request - The open's request token. + * @param revision - The reader revision the open belongs to. + * @returns The test-shaped history with the presentation and what was read, or `undefined` once the open is superseded. + * @throws When the journal cannot be read or verified, or the history is still being recorded. + * @example await this.readServerHistoryOpening(store, studyId, request, this.revision) // => { history, serverReplay, savedSequence } + */ + private async readServerHistoryOpening( + store: NodeHistoryStore, + studyId: string, + request: number, + revision: number, + ): Promise { + const journal = await store.read(studyId) + if (this.historyOpenSuperseded(request, revision)) return undefined + if (journal.status === 'recording') + throw new Error(NODE_SERVER_REPLAY_TEXT.stillRecording) + const sidecars = await this.readOpeningSidecars( + store, + studyId, + request, + revision, + ) + if (!sidecars) return undefined + const summary = await store.summarize(studyId).catch(() => undefined) + if (this.historyOpenSuperseded(request, revision)) return undefined + const serverReplay = presentServerHistory(journal, summary) + const restored = + sidecars.savedSequence === undefined + ? undefined + : restoreServerPlayhead(serverReplay.model, sidecars.savedSequence) + return { + history: serverReplay.history, + serverReplay, + ...sidecars, + savedSequence: restored?.sequence, + } + } + + /** + * Reads what opening a history shows besides its journal (the learner's decision, the learning material and the saved reader position) from either profile's store, stopping as soon as a newer request, a newer reader revision or the reader's close supersedes the open; called by {@link readNodeHistoryOpening} and {@link readServerHistoryOpening}. + * A decision or material that cannot be read is reported and left out, and a position that cannot be read is returned as its error, which the open reports once the replay is selected. + * @param store - The store that holds the history. + * @param studyId - The history's study id. + * @param request - The open's request token. + * @param revision - The reader revision the open belongs to. + * @returns What was read, or `undefined` once the open is superseded. + * @example await this.readOpeningSidecars(store, studyId, request, this.revision) // => { learningDecision, learningMaterial, savedSequence, positionError } + */ + private async readOpeningSidecars( + store: + | NodeHistoryStore + | NodeHistoryStore, + studyId: string, + request: number, + revision: number, ): Promise< | { - history: NodeTestHistoryIndex learningDecision?: NodeTestHistoryLearningDecision - learningMaterial?: NodeTestLearningMaterial + learningMaterial?: NodeLearningMaterialOfHistory savedSequence?: number positionError?: unknown } | undefined > { - const store = this.nodeHistoryStore! - const history = await store.read(studyId) - if (this.historyOpenSuperseded(request, revision)) return undefined const learningDecision = await store .readLearningDecision(studyId) .catch((error: unknown) => { @@ -12413,7 +12533,25 @@ export class StudyWorkspace { (positionError: unknown) => ({ savedSequence: undefined, positionError }), ) if (this.historyOpenSuperseded(request, revision)) return undefined - return { history, learningDecision, learningMaterial, ...position } + return { learningDecision, learningMaterial, ...position } + } + + /** + * Chooses the moment a replay opens at: its saved reader position, else the first stop whose source verifies (a server replay: its first recorded stop, else its request), else its first event (DS1); called by {@link openNodeHistory}. + * @param history - The history the replay moves over. + * @param savedSequence - The saved position, restored to a learner event for a server replay. + * @param serverReplay - The server presentation, when the replay is one. + * @returns The journal sequence to select; `undefined` for a history with no event. + * @example this.landingSequence(history, undefined, undefined) // => 2 for an output, then a verified stop + */ + private landingSequence( + history: NodeTestHistoryIndex, + savedSequence: number | undefined, + serverReplay: ServerReplayPresentation | undefined, + ): number | undefined { + if (savedSequence !== undefined) return savedSequence + if (serverReplay) return openingServerEvent(serverReplay.model)?.sequence + return firstVerifiedStopSequence(history) ?? history.entries[0]?.sequence } /** @@ -13083,6 +13221,7 @@ export class StudyWorkspace { this.nodeHistoryPlaybackController?.dispose() this.nodeHistoryPlaybackController = undefined this.selectedNodeHistory = undefined + this.selectedServerReplay = undefined this.nodeHistoryOpenToken = undefined this.selectedNodeHistoryLearningDecision = undefined this.selectedNodeHistoryLearningMaterial = undefined @@ -13336,6 +13475,9 @@ export class StudyWorkspace { | 'nodeHistoryLearningSummary' > { if (!history) return {} + const serverReplay = this.selectedServerReplay + if (serverReplay?.history === history) + return this.serverReplayViews(serverReplay, selected) const leavingPosition = this.leavingNodeHistoryPosition(history) // The recorded texts the surfaces print come without terminal and bidirectional controls (F131). const shown = this.shownNodeHistory(history) @@ -13397,17 +13539,225 @@ export class StudyWorkspace { selectedStopVerified: selectedAsRecorded?.type === 'stop' && nodeStopSourceMatches(selectedAsRecorded), - ...(learning ? { learning } : {}), - ...(this.selectedNodeHistoryStepId - ? { chosenStepId: this.selectedNodeHistoryStepId } - : {}), - applicationClosed: !this.app, - marks: this.nodeHistoryTimelineMarks(history), + ...this.replaySectionContext(history, learning), }), ...(learning ? { nodeHistoryLearningSummary: learning } : {}), } } + /** + * Projects a selected server replay's three views for the Study message with the server catalog, reading the replay model instead of the test journal; called by {@link nodeHistoryViews} for a history that is a server replay's test-shaped view. + * The Application view is projected once per shown moment, as a test replay's is, and a moment the selection is going to names the Study position and the Application header while it loads (D29). + * @param replay - The selected server replay. + * @param selected - The selected event in the replay's test-shaped history. + * @returns The Application view, the Study section and the learning material for the one summary renderer. + * @example this.serverReplayViews(replay, event).nodeHistorySection?.position // => 'Stop 1 · 0.42 s' + */ + private serverReplayViews( + replay: ServerReplayPresentation, + selected: NodeTestHistoryIndex['entries'][number] | undefined, + ): Pick< + StudyViewMessage, + | 'nodeHistoryApplication' + | 'nodeHistorySection' + | 'nodeHistoryLearningSummary' + > { + const { shownModel: model, history } = replay + const eventAt = (sequence: number | undefined) => { + const position = + sequence === undefined ? undefined : model.indexBySequence.get(sequence) + return position === undefined ? undefined : model.events[position] + } + const leavingPosition = this.leavingNodeHistoryPosition(history) + const pendingEvent = + leavingPosition === undefined ? undefined : model.events[leavingPosition] + const selectedEvent = eventAt(selected?.sequence) + const opening = + this.pendingNodeHistory !== undefined && + this.pendingNodeHistoryStudyId === model.studyId + const key = JSON.stringify([ + model.studyId, + model.events.length, + model.status, + selectedEvent?.sequence, + opening, + pendingEvent !== undefined, + ]) + if (this.nodeHistoryApplicationMemo?.key !== key) { + const generation = ++this.nodeHistoryApplicationGeneration + this.nodeHistoryApplicationMemo = { + key, + generation, + view: projectServerApplication( + model, + replay.outputIndex, + selectedEvent, + { + generation, + loading: opening, + leaving: pendingEvent !== undefined, + ...(model.endedBeforeAnswer && replay.captureReasons[0] + ? { noResponseReason: replay.captureReasons[0] } + : {}), + }, + ), + } + } + const learning = projectNodeLearningSummary( + this.selectedNodeHistoryLearningMaterial, + history, + ) + return { + nodeHistoryApplication: this.nodeHistoryApplicationMemo.view, + nodeHistorySection: projectServerSection({ + replay, + ...(pendingEvent + ? { selected: pendingEvent, pending: true } + : selectedEvent + ? { selected: selectedEvent } + : {}), + selectedStopVerified: + selected?.type === 'stop' && nodeStopSourceMatches(selected), + ...this.replaySectionContext(history, learning), + }), + ...(learning ? { nodeHistoryLearningSummary: learning } : {}), + } + } + + /** + * Finds the store that holds a history by its profile, so a server replay's decision and reader position are written to the server store; falls back to the test store for an id no folder holds. + * @param studyId - The history's study id. + * @returns The store, or `undefined` once the reader has none. + * @example this.nodeHistoryStoreOf(studyId)?.saveReaderPosition(studyId, 3) + */ + private nodeHistoryStoreOf( + studyId: string, + ): + | NodeHistoryStore + | NodeHistoryStore + | undefined { + return ( + this.nodeHistoryRepository?.resolve(studyId)?.store ?? + this.nodeHistoryStore + ) + } + + /** + * Words a refusal sentence for the replay that is shown: a server replay is never called a test replay, in the sentences that reach the transport's status line. + * @param sentence - The sentence in the test replay's words. + * @returns The sentence, naming a server replay when one is selected. + * @example this.inReplayWords('Select the matching test replay before paging.') // => 'Select the matching server replay before paging.' for a server replay + */ + private inReplayWords(sentence: string): string { + return this.selectedServerReplay + ? sentence.replaceAll('test replay', 'server replay') + : sentence + } + + /** + * Gives the text a recorded-source slot holds at a moment that has no recorded stop, in the words of the replay that is shown: a server replay names its server journal, a test replay its test journal. + * @returns The placeholder document. + * @example this.recordedSourcePlaceholder() // => '// Recorded source · Not recorded at this moment\n// The server journal holds no source state for this moment.\n' for a server replay + */ + private recordedSourcePlaceholder(): string { + return this.selectedServerReplay + ? NODE_RECORDED_SOURCE_TEXT.serverPlaceholder + : NODE_RECORDED_SOURCE_TEXT.placeholder + } + + /** + * Gives the server words of a stop's debugger thread (its hit, and whether the stop was hit again), so the Call Stack names a server stop and never a test stop; nothing for a stop of a test replay. + * @param stop - The stop the debugger presents, as the selected history holds it. + * @returns The `server` field of the thread message, or nothing. + * @example this.serverStopThread(stop) // => { server: { hit: 2, repeated: true } } + */ + private serverStopThread( + stop: NodeTestHistoryStop, + ): { server: { hit: number; repeated: boolean } } | undefined { + const model = this.selectedServerReplay?.model + const position = model?.indexBySequence.get(stop.sequence) + const event = position === undefined ? undefined : model?.events[position] + return model && event?.type === 'stop' + ? { + server: { + hit: event.hit, + repeated: model.repeatedStops.has(event.declaredStopIndex), + }, + } + : undefined + } + + /** + * Gives the replay a navigation message moves, after the checks every move shares: no browser recording is shown, and the message names the selected replay. + * @param studyId - The study id the message names. + * @returns The selected replay's history. + * @throws When a browser recording is shown or another replay is selected, in the shown replay's words. + * @example const history = this.replayForMove(message.studyId) + */ + private replayForMove(studyId: string): NodeTestHistoryIndex { + if (this.recordingActive) + throw new Error( + this.inReplayWords( + 'Close browser replay before navigating a test replay.', + ), + ) + const history = this.selectedNodeHistory + if (!history || history.header.studyId !== studyId) + throw new Error( + this.inReplayWords( + 'Select the matching saved test replay before navigating its timeline.', + ), + ) + return history + } + + /** + * Gives what a replay's Study section reads beside the selected event, the same for both profiles: the learning material, the step the learner chose, whether the Application pane is closed and the slider's marks; called by {@link nodeHistoryViews} and {@link serverReplayViews}. + * @param history - The selected replay's history. + * @param learning - Its projected learning material, if any. + * @returns The section input's shared fields. + * @example this.replaySectionContext(history, undefined).applicationClosed // => false while the pane is open + */ + private replaySectionContext( + history: NodeTestHistoryIndex, + learning: NodeLearningSummaryProjection | undefined, + ): Pick< + NodeTestSectionInput, + 'learning' | 'chosenStepId' | 'applicationClosed' | 'marks' + > { + return { + ...(learning ? { learning } : {}), + ...(this.selectedNodeHistoryStepId + ? { chosenStepId: this.selectedNodeHistoryStepId } + : {}), + applicationClosed: !this.app, + marks: this.nodeHistoryTimelineMarks(history), + } + } + + /** + * Gives the Study message's server fields: the replay's profile, which switches the pane's words, and the words of each event on the page the event list shows; none for a test replay. + * @returns `nodeReplayProfile` and `nodeServerEvents`, or nothing without a selected server replay. + * @example this.serverReplayMessage().nodeServerEvents?.[0]?.heading // => 'Request · 0.00 s' + */ + private serverReplayMessage(): Pick< + StudyViewMessage, + 'nodeReplayProfile' | 'nodeServerEvents' + > { + const replay = this.selectedServerReplay + if (!replay || this.selectedNodeHistory !== replay.history) return {} + const first = this.selectedNodeHistoryPage * NODE_HISTORY_READER_PAGE_SIZE + return { + nodeReplayProfile: 'node-server', + nodeServerEvents: replay.shownModel.events + .slice(first, first + NODE_HISTORY_READER_PAGE_SIZE) + .map((event) => ({ + sequence: event.sequence, + ...describeServerEventRow(event, replay.shownModel), + })), + } + } + /** * Waits, for at most {@link NODE_HISTORY_POSITION_DRAIN_MS}, until no reader position write runs, so a close does not leave a write running into storage that is being removed (F197); called by {@link close}. * A loop that {@link runNodeHistoryPositionWrites} restarts from its own end (F366) is not in the writes seen at the first look, so the wait looks again after each round until none runs or the bound is reached (F395). @@ -13450,7 +13800,13 @@ export class StudyWorkspace { timeline?: NodeHistoryTimeline } { if (this.nodeHistoryTimeline?.history !== history) { - const timeline = projectNodeHistoryTimeline(history) + // A server recording's timeline ends at its `end` entry. + const timeline = projectNodeHistoryTimeline( + history, + this.selectedServerReplay?.history === history + ? this.selectedServerReplay.model.endedAtMs + : undefined, + ) this.nodeHistoryTimeline = { history, marks: timeline ? { timeline } : {}, @@ -13484,6 +13840,9 @@ export class StudyWorkspace { private shownNodeHistory( history: NodeTestHistoryIndex, ): NodeTestHistoryIndex { + // A server replay's display copy was made with its presentation. + if (this.selectedServerReplay?.history === history) + return this.selectedServerReplay.shown if (this.selectedNodeHistoryShown?.history !== history) this.selectedNodeHistoryShown = { history, @@ -13728,6 +14087,7 @@ export class StudyWorkspace { (this.selectedNodeHistoryPage + 1) * NODE_HISTORY_READER_PAGE_SIZE, ), }, + ...this.serverReplayMessage(), nodeHistoryLearningDecision: this.selectedNodeHistoryLearningDecision, // The reader cannot hash captured text, so every shown stop whose source fails verification is named here and gets no Inspect. nodeHistoryUnverifiedStops: this.selectedNodeHistory?.entries @@ -20314,7 +20674,7 @@ export class StudyWorkspace { if (isNodeSourceSlotPath(uri.path)) return this.nodeSourceSlot?.uri.toString() === uri.toString() ? this.nodeSourceSlot.text - : NODE_RECORDED_SOURCE_TEXT.placeholder + : this.recordedSourcePlaceholder() // A view is ephemeral presentation, never a recoverable hash-addressed saved snapshot. if (isLiveSourceViewUri(uri)) throw new Error( @@ -23884,12 +24244,13 @@ export class StudyWorkspace { this.selectedNodeHistory.header.studyId !== message.studyId ) throw new Error( - 'Select the matching test replay before saving an understanding choice.', + this.inReplayWords( + 'Select the matching test replay before saving an understanding choice.', + ), ) - const decision = await this.nodeHistoryStore!.saveLearningDecision( + const decision = await this.nodeHistoryStoreOf( message.studyId, - message.understood, - ) + )!.saveLearningDecision(message.studyId, message.understood) if ( this.selectedNodeHistory?.header.studyId === message.studyId && !this.closing @@ -23908,7 +24269,9 @@ export class StudyWorkspace { this.selectedNodeHistory.header.studyId !== message.studyId ) throw new Error( - 'Select the matching test replay before saving its learning material.', + this.inReplayWords( + 'Select the matching test replay before saving its learning material.', + ), ) const material = await this.service?.explainNodeHistory({ studyId: message.studyId, @@ -23929,7 +24292,9 @@ export class StudyWorkspace { } if (message.type === 'node-history-page') { if (this.selectedNodeHistory?.header.studyId !== message.studyId) - throw new Error('Select the matching test replay before paging.') + throw new Error( + this.inReplayWords('Select the matching test replay before paging.'), + ) const pageCount = Math.max( 1, Math.ceil( @@ -23939,19 +24304,15 @@ export class StudyWorkspace { ) if (message.page >= pageCount) throw new Error( - 'The requested page of this test replay is unavailable.', + this.inReplayWords( + 'The requested page of this test replay is unavailable.', + ), ) this.selectedNodeHistoryPage = message.page this.publish() } if (message.type === 'node-history-step') { - if (this.recordingActive) - throw new Error('Close browser replay before navigating a test replay.') - const history = this.selectedNodeHistory - if (!history || history.header.studyId !== message.studyId) - throw new Error( - 'Select the matching saved test replay before navigating its timeline.', - ) + const history = this.replayForMove(message.studyId) const target = nodeHistoryStepTarget( history.entries, this.requestedNodeHistorySequence ?? this.selectedNodeHistorySequence, @@ -23960,7 +24321,9 @@ export class StudyWorkspace { ) if (target === undefined) throw new Error( - `There is no ${message.direction} ${message.unit ?? 'event'} in this test replay.`, + this.inReplayWords( + `There is no ${message.direction} ${message.unit ?? 'event'} in this test replay.`, + ), ) // Next and Previous show the event's first step (DS21), once the move commits. await this.requestNodeHistoryEvent(history, target, { @@ -23968,13 +24331,7 @@ export class StudyWorkspace { }) } if (message.type === 'node-history-event') { - if (this.recordingActive) - throw new Error('Close browser replay before navigating a test replay.') - const history = this.selectedNodeHistory - if (!history || history.header.studyId !== message.studyId) - throw new Error( - 'Select the matching saved test replay before navigating its timeline.', - ) + const history = this.replayForMove(message.studyId) // A step's action selects its event and that step; an event's own control, the event's first step (DS21), once the selection commits. await this.requestNodeHistoryEvent(history, message.sequence, { stepChoice: { stepId: message.stepId }, @@ -23983,19 +24340,25 @@ export class StudyWorkspace { if (message.type === 'node-history-stop') { if (this.recordingActive) throw new Error( - 'Close browser replay before inspecting a stop of a test replay.', + this.inReplayWords( + 'Close browser replay before inspecting a stop of a test replay.', + ), ) const history = this.selectedNodeHistory if (!history || history.header.studyId !== message.studyId) throw new Error( - 'Select the matching saved test replay before inspecting its debugger state.', + this.inReplayWords( + 'Select the matching saved test replay before inspecting its debugger state.', + ), ) if ( history.entries.find((entry) => entry.sequence === message.sequence) ?.type !== 'stop' ) throw new Error( - 'The requested event of this test replay is not a debugger stop.', + this.inReplayWords( + 'The requested event of this test replay is not a debugger stop.', + ), ) await this.requestNodeHistoryEvent(history, message.sequence, { requireDebugger: true, @@ -24004,23 +24367,35 @@ export class StudyWorkspace { if (message.type === 'node-history-playback') { if (this.recordingActive) throw new Error( - 'Close browser replay before controlling a test replay.', + this.inReplayWords( + 'Close browser replay before controlling a test replay.', + ), ) if ( this.selectedNodeHistory?.header.studyId !== message.studyId || !this.nodeHistoryPlaybackController ) throw new Error( - 'Select the matching saved test replay before controlling its timeline.', + this.inReplayWords( + 'Select the matching saved test replay before controlling its timeline.', + ), ) if (message.action !== 'seek' && message.positionMs !== undefined) - throw new Error('Only a seek in a test replay accepts a position.') + throw new Error( + this.inReplayWords( + 'Only a seek in a test replay accepts a position.', + ), + ) if (message.action === 'play') this.nodeHistoryPlaybackController.play() else if (message.action === 'pause') this.nodeHistoryPlaybackController.pause() else if (message.action === 'seek') { if (message.positionMs === undefined) - throw new Error('A seek in a test replay needs a recorded time.') + throw new Error( + this.inReplayWords( + 'A seek in a test replay needs a recorded time.', + ), + ) await this.nodeHistoryPlaybackController.seek(message.positionMs) } } @@ -25717,8 +26092,8 @@ export class StudyWorkspace { while (writer.latest) { const next = writer.latest writer.latest = undefined - const store = this.nodeHistoryStore // A reader that closed meanwhile has no store to write to and nobody to tell. + const store = this.nodeHistoryStore && this.nodeHistoryStoreOf(studyId) if (!store) break try { await store.saveReaderPosition(studyId, next.sequence, next.history) @@ -26101,7 +26476,7 @@ export class StudyWorkspace { : this.nodeSourceSlotUri(studyId, nodeSourceSlotName(history)) const text = verifiedStop?.debuggerSnapshot.frames[0]?.source?.text ?? - NODE_RECORDED_SOURCE_TEXT.placeholder + this.recordedSourcePlaceholder() // A slot whose open tab already shows these bytes is left as it is: no provider event, no open, no show (Play over outputs and the result, F174). if (this.nodeSourceSlotShows(uri, text)) { this.nodeSourceSlot = { studyId, uri, text } @@ -26441,6 +26816,7 @@ export class StudyWorkspace { stop: verifiedStop.index + 1, path: verifiedStop.source.path, line: verifiedStop.frame.line, + ...this.serverStopThread(verifiedStop), }), new AbortController().signal, { uri: slotUri.toString(), hash: verifiedStop.source.hash }, diff --git a/extension/src/tests/studyWorkspaceServerReplay.test.ts b/extension/src/tests/studyWorkspaceServerReplay.test.ts new file mode 100644 index 000000000..7695edcec --- /dev/null +++ b/extension/src/tests/studyWorkspaceServerReplay.test.ts @@ -0,0 +1,888 @@ +import assert from 'node:assert/strict' +import childProcess from 'node:child_process' +import { appendFile } from 'node:fs/promises' +import http from 'node:http' +import { join } from 'node:path' +import { before, test } from 'node:test' + +import { NODE_SERVER_HISTORY_DIRECTORY } from '../constants' +import { HISTORY_JOURNAL_FILE } from '../nodeHistoryJournalWriter' +import type { NodeHistoryRepository } from '../nodeHistoryRepository' +import type { ReplayDebugSnapshot } from '../replayDebugSchemas' +import { sha256 } from '../runtimeSupport/utils/sha256' +import { waitUntil } from '../runtimeSupport/utils/waitUntil' + +import { + recordedReaderHarness, + workspaceHarness, +} from './studyWorkspaceHarness' +import { + HANDLER_SOURCE_TEXT, + nodeServerHistoryHeader, + openServerRepository, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' +import { shownApplicationView } from './utils/shownTestOutput' +import { lastView, saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +type Harness = Awaited> + +/** The workspace's history repository, over the learning directory the fixture writer shares. */ +function repositoryOf(harness: Harness): NodeHistoryRepository { + return harness.reader['nodeHistoryRepository']! +} + +/** The workspace key every history of the harness's workspace carries. */ +function workspaceKeyOf(harness: Harness): string { + return harness.service.nodeHistoryStore!.workspaceKey +} + +/** + * Finds the address of the replay's recorded-source slot document. + * @param harness - The workspace harness. + * @returns The slot's address. + * @example harness.documentTextOf(slotUri(harness)) + */ +function slotUri(harness: Harness): string { + return harness.historicalDebugPresentations.find( + (presentation) => presentation.sourceDocument, + )!.sourceDocument!.uri +} + +/** + * Opens a saved server replay from the library and waits until the pane shows it settled. + * @param harness - The workspace harness. + * @param studyId - The server history's study id. + * @example await openServerReplay(harness, header.studyId) + */ +async function openServerReplay( + harness: Harness, + studyId: string, +): Promise { + await harness.reader.action('retry') + await harness.reader.openLearningReplay(studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === studyId && + !lastView(harness)?.loading, + 'the server replay is shown', + READER_RESULT_BOUND_MS, + ) +} + +/** + * Presses a transport move as the Study pane's button does and waits until the playhead has committed at `sequence`. + * @param harness - The workspace harness. + * @param studyId - The shown history. + * @param move - Which way and by what unit. + * @param sequence - The sequence the move must commit. + * @example await moveTo(harness, studyId, { direction: 'next', unit: 'stop' }, 5) + */ +async function moveTo( + harness: Harness, + studyId: string, + move: { direction: 'previous' | 'next'; unit: 'event' | 'stop' }, + sequence: number, +): Promise { + harness.receiveContext({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId, + ...move, + }) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === sequence && + !lastView(harness)?.nodeHistoryPosition?.pending, + `the playhead commits at ${sequence}`, + READER_RESULT_BOUND_MS, + ) +} + +/** + * Selects one event as a summary step's link or the event list's control does. + * @param harness - The workspace harness. + * @param studyId - The shown history. + * @param sequence - The event to select. + * @param stepId - The step the link names, when it is a step's. + * @example await selectEvent(harness, studyId, 6, 'response') + */ +async function selectEvent( + harness: Harness, + studyId: string, + sequence: number, + stepId?: string, +): Promise { + harness.receiveContext({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId, + sequence, + ...(stepId ? { stepId } : {}), + }) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === sequence && + !lastView(harness)?.nodeHistoryPosition?.pending, + `the playhead commits at ${sequence}`, + READER_RESULT_BOUND_MS, + ) +} + +/** + * Builds a learning material that cites a request, a stop, an output and a response of a fixture history, each by its journal sequence. + * @param sequences - The sequences the fixture writer returned. + * @returns The material's content. + * @example await store.saveLearningMaterial(studyId, 0, serverMaterial(sequences)) + */ +function serverMaterial(sequences: { + request: number + stops: number[] + output: number[] + response?: number +}) { + const step = ( + id: string, + explanation: string, + reference: Record, + ) => ({ + id, + from: 'server', + to: 'server', + label: id, + explanation, + evidence: 'observed' as const, + reference, + }) + return { + question: 'How does the server create an item?', + title: 'Creating an item', + summary: 'The handler creates the item and answers.', + participants: [{ id: 'server', label: 'Handler' }], + steps: [ + step('request', 'Codebook sends the request.', { + type: 'request', + sequence: sequences.request, + }), + step('stop', 'The handler stops at its first line.', { + type: 'stop', + sequence: sequences.stops[0], + sourceHash: sha256(HANDLER_SOURCE_TEXT), + }), + step('response', 'The server answers with the item.', { + type: 'response', + sequence: sequences.response, + }), + ], + } as never +} + +// protects=a sealed server history opens from the library on its first recorded stop with the executed source stored in the history and its snapshot in the debugger, worded with the server catalog; fails_when=the replay opens on the request, shows the test catalog, or the slot and debugger lack the stored source; no_claim=the native editor's drawing of the slot +test('Open replay of a sealed server history lands on its first stop with the stored source in the slot and the snapshot in the debugger', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness), output: ['listening\n'] }, + ) + + // Act + await openServerReplay(harness, header.studyId) + const view = lastView(harness) + const presented = harness.historicalDebugPresentations.at(-1) + + // Assert + assert.equal(view?.nodeReplayProfile, 'node-server') + assert.equal(view?.nodeHistoryPosition?.sequence, sequences.stops[0]) + assert.equal(view?.nodeHistorySection?.position, 'Stop 1 · 0.01 s') + assert.equal( + view?.nodeHistorySection?.status, + 'Response 201 · capture complete · 0 of 1 stops explained', + ) + assert.equal( + presented?.snapshot?.frames[0]?.source?.text, + HANDLER_SOURCE_TEXT, + ) + assert.equal( + harness.documentTextOf(presented.sourceDocument!.uri), + HANDLER_SOURCE_TEXT, + ) + assert.deepEqual( + view?.nodeServerEvents?.map((event) => event.heading), + [ + 'Request · 0.00 s', + 'Stop 1 · src/handler.ts:2:1 · 0.01 s', + 'Output · stdout · 0.02 s', + 'Response 201 · application/json · 0.03 s', + ], + ) + } finally { + await harness.close() + } +}) + +// protects=a server replay's Application pane has two fixed regions, the request line with the claim only a sent request earns and the response state that reads "No response recorded yet" before the response; fails_when=the request is worded as received, the response shows before its entry, or a region is missing; no_claim=the pane's document markup +test('the Application pane of a server replay shows the sent request and no response yet at the first stop', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header } = await writeServerHistory(repositoryOf(harness), { + workspaceKey: workspaceKeyOf(harness), + }) + + // Act + await openServerReplay(harness, header.studyId) + const application = shownApplicationView(harness) + + // Assert + assert.equal(application?.profile, 'node-server') + assert.deepEqual(application?.request, { + line: 'POST /api/items', + claim: 'Request sent by Codebook', + }) + assert.equal( + application?.response?.state, + 'No response recorded yet at this moment.', + ) + } finally { + await harness.close() + } +}) + +// protects=a server history with a request and a response but no stop opens at its request, and Next stop is refused in server words; fails_when=the replay opens elsewhere, Next stop moves, or the refusal calls it a test replay; no_claim=the refusal's place in the transport +test('a server history without a stop opens at its request and Next stop answers that there is no next stop in this server replay', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness), hits: [] }, + ) + await openServerReplay(harness, header.studyId) + + // Act + harness.receiveContext({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: header.studyId, + direction: 'next', + unit: 'stop', + }) + await waitUntil( + () => (lastView(harness)?.errors.length ?? 0) > 0, + 'the move is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.nodeHistoryPosition?.sequence, + sequences.request, + ) + assert.equal( + lastView(harness)?.errors[0], + 'Error: There is no next stop in this server replay.', + ) + } finally { + await harness.close() + } +}) + +// protects=a server journal damaged mid-file opens its verified prefix as an interrupted replay that lists its capture reason; fails_when=the replay refuses to open, reads complete, or hides the reason; no_claim=the wording of the reason beyond naming the server replay +test('a server history whose journal is damaged opens its verified prefix as an interrupted replay with its reason', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness), seal: false }, + ) + await appendFile( + join( + harness.service.nodeHistoryStore!.learningDirectory, + NODE_SERVER_HISTORY_DIRECTORY, + header.studyId, + HISTORY_JOURNAL_FILE, + ), + '{"type":"output","sequence":99,\n', + ) + + // Act + await openServerReplay(harness, header.studyId) + const view = lastView(harness) + + // Assert + assert.equal(view?.nodeHistory?.status, 'interrupted') + assert.equal(view?.nodeHistoryPosition?.sequence, sequences.stops[0]) + assert.match( + view?.nodeHistorySection?.captureReason ?? '', + /^Capture interrupted: Interrupted server replay after its verified prefix:/, + ) + } finally { + await harness.close() + } +}) + +// protects=a server history another window is still recording refuses to open and opens once its recording is interrupted; fails_when=the replay opens while it is recorded, or stays closed afterwards; no_claim=another window's own writer (the harness keeps both writers in one process) +test('a server history that is still being recorded opens only once its recording is interrupted', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const repository = repositoryOf(harness) + const header = nodeServerHistoryHeader(workspaceKeyOf(harness)) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: header.declaredRequest.path, + bodyBytes: 14, + }) + await harness.reader.action('retry') + + // Act + await harness.reader.openLearningReplay(header.studyId) + await waitUntil( + () => (lastView(harness)?.errors.length ?? 0) > 0, + 'the open is refused', + READER_RESULT_BOUND_MS, + ) + const whileRecording = lastView(harness) + writer.abandon() + await openServerReplay(harness, header.studyId) + + // Assert + assert.equal(whileRecording?.nodeHistory, undefined) + assert.equal(lastView(harness)?.nodeHistory?.status, 'interrupted') + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 1) + } finally { + await harness.close() + } +}) + +// protects=a stop's lower frames reach the debugger with the reason their source is unavailable, so the Call Stack lists them as it does for a test replay; fails_when=the lower frame or its reason is dropped; no_claim=the Call Stack's own drawing +test('a stop with a lower frame presents it with its unavailable source reason', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const repository = repositoryOf(harness) + const header = nodeServerHistoryHeader(workspaceKeyOf(harness)) + const capture = serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + }) + const snapshot: ReplayDebugSnapshot = { + ...capture.snapshot, + frames: [ + ...capture.snapshot.frames, + { + name: 'dispatch', + line: 9, + column: 1, + sourceUnavailable: 'not captured', + scopes: [], + }, + ], + } + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: header.declaredRequest.path, + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.appendStop({ ...capture, snapshot }) + await writer.appendResponse({ + elapsedMs: 30, + status: 201, + contentType: 'application/json', + body: '{}', + bodyBytes: 2, + readCut: false, + location: null, + requestDurationMs: 28, + }) + await writer.seal({ elapsedMs: 40, reason: 'stopped' }) + + // Act + await openServerReplay(harness, header.studyId) + const frames = harness.historicalDebugPresentations.at(-1)?.snapshot?.frames + + // Assert + assert.deepEqual( + frames?.map((frame) => [frame.name, frame.sourceUnavailable]), + [ + ['handler', undefined], + ['dispatch', 'not captured'], + ], + ) + } finally { + await harness.close() + } +}) + +// protects=Next and Previous stop visit every recorded hit of a stop hit twice, and Next and Previous event move over the output and the response; fails_when=a hit is skipped, the second hit is not told apart as `Stop 1 · hit 2`, or an event move leaves the journal order; no_claim=Play's pacing +test('Next and Previous stop visit each hit of a repeated stop and Next event moves on to the output and the response', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { + workspaceKey: workspaceKeyOf(harness), + hits: [ + [0, 1], + [0, 2], + ], + output: ['saved\n'], + }, + ) + await openServerReplay(harness, header.studyId) + const [firstHit, secondHit] = sequences.stops + + // Act + await moveTo( + harness, + header.studyId, + { direction: 'next', unit: 'stop' }, + secondHit!, + ) + const atSecondHit = lastView(harness)?.nodeHistorySection?.position + await moveTo( + harness, + header.studyId, + { direction: 'previous', unit: 'stop' }, + firstHit!, + ) + const atFirstHit = lastView(harness)?.nodeHistorySection?.position + await moveTo( + harness, + header.studyId, + { direction: 'next', unit: 'event' }, + secondHit!, + ) + await moveTo( + harness, + header.studyId, + { direction: 'next', unit: 'event' }, + sequences.output[0]!, + ) + await moveTo( + harness, + header.studyId, + { direction: 'next', unit: 'event' }, + sequences.response!, + ) + + // Assert + assert.equal(atFirstHit, 'Stop 1 · hit 1 · 0.01 s') + assert.equal(atSecondHit, 'Stop 1 · hit 2 · 0.02 s') + assert.equal( + shownApplicationView(harness)?.response?.state, + 'Response 201 · application/json', + ) + } finally { + await harness.close() + } +}) + +// protects=a seek to a non-adjacent moment and back presents the same source slot and snapshot as the first visit, and moving back from the response clears it from the Application pane; fails_when=the return shows another stop's bytes or snapshot, or the response stays after moving back; no_claim=the slider's pixel position +test('seeking to the response and back to the stop presents the same snapshot and source and clears the response', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { + workspaceKey: workspaceKeyOf(harness), + hits: [ + [0, 1], + [0, 2], + ], + output: ['saved\n'], + }, + ) + await openServerReplay(harness, header.studyId) + const firstVisit = harness.historicalDebugPresentations.at(-1) + + // Act + await selectEvent(harness, header.studyId, sequences.response!) + const atResponse = shownApplicationView(harness)?.response + await selectEvent(harness, header.studyId, sequences.stops[0]!) + const secondVisit = harness.historicalDebugPresentations.at(-1) + const backAtStop = shownApplicationView(harness)?.response + + // Assert + assert.equal(atResponse?.state, 'Response 201 · application/json') + assert.equal(atResponse?.body, '{"id":7}') + assert.equal(backAtStop?.state, 'No response recorded yet at this moment.') + assert.equal(backAtStop?.body, undefined) + assert.deepEqual(secondVisit?.snapshot, firstVisit?.snapshot) + assert.equal( + secondVisit?.sourceDocument?.hash, + firstVisit?.sourceDocument?.hash, + ) + } finally { + await harness.close() + } +}) + +// protects=a moment that is not a stop (the output, the response) clears the source highlight and the debugger and says so in the 4A line; fails_when=the debugger keeps the earlier stop at an output, or the line is missing; no_claim=the Run and Debug view's own text +test('a non-stop moment clears the debugger and says source and debugger are not recorded at this moment', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness), output: ['saved\n'] }, + ) + await openServerReplay(harness, header.studyId) + + // Act + const invalidationsBefore = harness.historicalDebugInvalidations() + const presentationsBefore = harness.historicalDebugPresentations.length + await selectEvent(harness, header.studyId, sequences.output[0]!) + + // Assert + assert.equal( + harness.historicalDebugInvalidations() - invalidationsBefore, + 1, + ) + assert.equal( + harness.historicalDebugInvalidationLabels().at(-1), + 'Not recorded at this moment', + ) + assert.equal( + harness.historicalDebugPresentations.length, + presentationsBefore, + ) + assert.equal( + lastView(harness)?.nodeHistorySection?.notRecorded, + 'Source and debugger not recorded at this moment.', + ) + assert.match( + harness.documentTextOf(slotUri(harness))!, + /The server journal holds no source state for this moment\./, + ) + } finally { + await harness.close() + } +}) + +// protects=understanding is saved only by the checkbox and returns with the playhead after the replay is opened again; fails_when=a move or a response marks understanding, or the decision or the playhead is lost at reopen; no_claim=the checkbox's markup +test('understanding is saved only by the checkbox, and the playhead and understanding return when the replay is opened again', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness), output: ['saved\n'] }, + ) + await openServerReplay(harness, header.studyId) + await selectEvent(harness, header.studyId, sequences.response!) + const afterMoves = lastView(harness)?.nodeHistoryLearningDecision + + // Act + harness.receiveContext({ + type: 'node-history-understanding', + revision: lastView(harness)!.revision, + studyId: header.studyId, + understood: true, + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryLearningDecision?.understood === true, + 'the decision is saved', + READER_RESULT_BOUND_MS, + ) + await harness.reader.close() + await harness.reader.openLearningReplay(header.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === header.studyId && + !lastView(harness)?.loading, + 'the replay is shown again', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(afterMoves, undefined) + assert.equal( + lastView(harness)?.nodeHistoryLearningDecision?.understood, + true, + ) + assert.equal( + lastView(harness)?.nodeHistoryPosition?.sequence, + sequences.response, + ) + } finally { + await harness.close() + } +}) + +// protects=a saved playhead on a storage-only entry (the source entry the first stop needs) restores the nearest earlier learner event, here the request; fails_when=the replay refuses the position or jumps to a later stop; no_claim=a position another window wrote +test('a saved playhead on a source entry restores the request before it', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness) }, + ) + // The fixture journal is request 1, send mark 2, source 3, stop 4: the position names the source entry. + await repositoryOf(harness).serverHistories.saveReaderPosition( + header.studyId, + sequences.stops[0]! - 1, + ) + + // Act + await openServerReplay(harness, header.studyId) + + // Assert + assert.equal( + lastView(harness)?.nodeHistoryPosition?.sequence, + sequences.request, + ) + } finally { + await harness.close() + } +}) + +// protects=two stop hits at the same recorded time keep their journal order and the timeline ends at the history's end entry, which comes after the response; fails_when=the hits swap, or the slider ends at the response; no_claim=the slider's pixel range +test('two hits at one time keep journal order and the slider ends at the end entry after the response', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const repository = repositoryOf(harness) + const header = nodeServerHistoryHeader(workspaceKeyOf(harness)) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: header.declaredRequest.path, + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + for (const hit of [1, 2]) + await writer.appendStop( + serverStopCapture({ + declaredStopIndex: 0, + hit, + line: 2, + elapsedMs: 50, + }), + ) + await writer.appendResponse({ + elapsedMs: 80, + status: 200, + contentType: 'text/plain', + body: 'ok', + bodyBytes: 2, + readCut: false, + location: null, + requestDurationMs: 70, + }) + await writer.seal({ elapsedMs: 900, reason: 'stopped' }) + + // Act + await openServerReplay(harness, header.studyId) + const headings = lastView(harness)?.nodeServerEvents?.map( + (event) => event.heading, + ) + + // Assert + assert.deepEqual(headings?.slice(1, 3), [ + 'Stop 1 · hit 1 · src/handler.ts:2:1 · 0.05 s', + 'Stop 1 · hit 2 · src/handler.ts:2:1 · 0.05 s', + ]) + assert.equal(lastView(harness)?.nodeHistoryPlayback?.endMs, 900) + } finally { + await harness.close() + } +}) + +// protects=a summary step that cites the request or the response moves the playhead to it, the explanation arriving while the replay is open keeps the playhead and shows the step, and an event the material cites reads its own step action; fails_when=the link does nothing, the explanation resets the playhead, or the action labels are missing; no_claim=the diagram's drawing +test('a step citing the response moves the playhead there, and the explanation arriving while the replay is open keeps the playhead', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const repository = repositoryOf(harness) + const { header, sequences } = await writeServerHistory(repository, { + workspaceKey: workspaceKeyOf(harness), + }) + await openServerReplay(harness, header.studyId) + const withoutMaterial = lastView(harness)?.nodeHistoryLearningSummary + + // Act + const material = await repository.serverHistories.saveLearningMaterial( + header.studyId, + 0, + serverMaterial(sequences), + ) + harness.reader.nodeHistoryLearningMaterialSaved(header.studyId, material) + const keptPlayhead = lastView(harness)?.nodeHistoryPosition?.sequence + await selectEvent(harness, header.studyId, sequences.response!, 'response') + const atResponse = lastView(harness)?.nodeHistorySection + await selectEvent(harness, header.studyId, sequences.request, 'request') + const atRequest = lastView(harness)?.nodeHistorySection + + // Assert + assert.equal(withoutMaterial, undefined) + assert.equal(keptPlayhead, sequences.stops[0]) + assert.equal(atResponse?.selectedStepId, 'response') + assert.equal(atResponse?.explanation, 'The server answers with the item.') + assert.equal(atRequest?.selectedStepId, 'request') + assert.deepEqual(atRequest?.stepActions, { + request: 'Show request · 0.00 s', + stop: 'Inspect Stop 1 · 0.01 s', + response: 'Show response · 0.02 s', + }) + } finally { + await harness.close() + } +}) + +// protects=moving through a server replay never calls the launcher or the HTTP sender, so no request is sent and no server starts; fails_when=any replay move reaches a child process or an HTTP request; no_claim=the run's own request path +test('moving through a server replay sends no request and starts no server', async (context) => { + // Arrange + const refused = (name: string) => () => { + throw new Error(`${name} must not be called by a replay`) + } + const sender = context.mock.method(http, 'request', refused('HTTP sender')) + const launcher = context.mock.method( + childProcess, + 'spawn', + refused('launcher'), + ) + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness), output: ['saved\n'] }, + ) + const sendersBefore = sender.mock.callCount() + const launchersBefore = launcher.mock.callCount() + + // Act + await openServerReplay(harness, header.studyId) + await selectEvent(harness, header.studyId, sequences.request) + await selectEvent(harness, header.studyId, sequences.response!) + + // Assert + assert.equal(sender.mock.callCount(), sendersBefore) + assert.equal(launcher.mock.callCount(), launchersBefore) + assert.equal(lastView(harness)?.errors.length, 0) + } finally { + await harness.close() + } +}) + +// protects=a test replay, a server replay and a browser recording opened one after the other, while the first one's native presentation is still loading, leave only the last one shown: the late presentation of the earlier replay commits nothing over it; fails_when=the server replay's words or events survive the browser recording, or the finished presentation of the test replay brings its output back; no_claim=the native Run and Debug view itself (the harness holds the presentation) +test('opening a server replay and then a browser recording while a test replay presentation is still loading leaves only the recording shown', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const testHistory = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const { header } = await writeServerHistory(repositoryOf(harness), { + workspaceKey: workspaceKeyOf(harness), + }) + await harness.reader.action('retry') + await harness.reader.openLearningReplay(testHistory.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + !lastView(harness)?.loading, + 'the test replay lands on its first stop', + READER_RESULT_BOUND_MS, + ) + const held = harness.holdHistoricalDebugPresentation(true) + harness.receiveContext({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: testHistory.studyId, + direction: 'next', + unit: 'stop', + }) + await held.started + + // Act + await harness.reader.openLearningReplay(header.studyId) + const whileServerShown = lastView(harness) + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + held.release() + await harness.controller.settle() + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser') + + // Assert + assert.equal(whileServerShown?.nodeReplayProfile, 'node-server') + assert.equal(whileServerShown?.nodeHistory?.header.studyId, header.studyId) + assert.equal(lastView(harness)?.nodeHistory, undefined) + assert.equal(lastView(harness)?.nodeReplayProfile, undefined) + assert.equal(lastView(harness)?.nodeServerEvents, undefined) + assert.equal(lastView(harness)?.contentTab, 'recording') + assert.doesNotMatch(application!.webview.html, /id="test-output"/) + } finally { + await harness.close() + } +}) + +// protects=an explanation another window saved reaches this window's library on its next refresh and its replay when it is opened; fails_when=the row stays Partial or the opened replay carries no learning summary; no_claim=a Trash move of a server replay, which Saved studies refuses (studyWorkspaceServerLibrary.test.ts) so no save can race it here +test('an explanation saved from another window shows in this window as Saved and in the replay it opens', async (context) => { + // Arrange + const harness = await workspaceHarness() + try { + const learningDirectory = + harness.service.nodeHistoryStore!.learningDirectory + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { workspaceKey: workspaceKeyOf(harness) }, + ) + const otherWindow = await openServerRepository(context, { + learningDirectory, + workspaceKey: workspaceKeyOf(harness), + }) + + // Act + await otherWindow.repository.serverHistories.saveLearningMaterial( + header.studyId, + 0, + serverMaterial(sequences), + ) + await harness.reader.action('retry') + const row = harness.reader + .inspect() + .view!.studies.find((entry) => entry.id === header.studyId) + await openServerReplay(harness, header.studyId) + + // Assert + assert.equal( + row?.outcome, + 'Creating an item · Server replay · POST /api/items → 201 · Saved', + ) + assert.deepEqual( + lastView(harness)?.nodeHistoryLearningSummary?.positions.map( + (position) => position.stepId, + ), + ['request', 'stop', 'response'], + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/utils/shownTestOutput.ts b/extension/src/tests/utils/shownTestOutput.ts index 024529ea5..80aae3d20 100644 --- a/extension/src/tests/utils/shownTestOutput.ts +++ b/extension/src/tests/utils/shownTestOutput.ts @@ -30,10 +30,29 @@ function writtenView(document: string): NodeTestApplicationView | undefined { * @returns The shown output lines joined by line feeds; empty without a test replay document or output. * @example shownTestOutput(harness) // => 'output 21\noutput 22' */ -export function shownTestOutput(harness: { +export function shownTestOutput(harness: ApplicationHarness): string { + return ( + shownApplicationView(harness) + ?.lines.map((line) => line.text) + .join('\n') ?? '' + ) +} + +/** What the harness records of the Application pane: its in-place updates and its panels. */ +type ApplicationHarness = { applicationOutputs(): NodeTestOutputUpdate[] nativePanels(): { kind: string; webview: { html: string } }[] -}): string { +} + +/** + * Reads the view the Application pane shows: the latest in-place update when it is newer than the view the document was written with, else that written view; used by the Study workspace's test and server replay tests. + * @param harness - The Study workspace harness. + * @returns The shown view; `undefined` without a replay document. + * @example shownApplicationView(harness)?.response?.state // => 'Response 201 · application/json' + */ +export function shownApplicationView( + harness: ApplicationHarness, +): NodeTestApplicationView | undefined { const document = harness .nativePanels() @@ -42,9 +61,7 @@ export function shownTestOutput(harness: { const written = writtenView(document) const update = harness.applicationOutputs().at(-1) // A document draws an update only when it is newer than the view it was written with. - const shown = - update && (!written || update.generation > written.generation) - ? update.view - : written - return shown?.lines.map((line) => line.text).join('\n') ?? '' + return update && (!written || update.generation > written.generation) + ? update.view + : written } diff --git a/extension/src/ui/constants.ts b/extension/src/ui/constants.ts index 9bf20ef65..16047c3de 100644 --- a/extension/src/ui/constants.ts +++ b/extension/src/ui/constants.ts @@ -16,6 +16,7 @@ import { STUDY_REMOVAL_SERVER_HISTORY_REFUSAL, STUDY_REMOVAL_WITHOUT_TRASH_MESSAGE, } from '../constants' +import { NODE_SERVER_REPLAY_TEXT } from '../utils/describeNodeServerReplay' import { NODE_TEST_REPLAY_TEXT } from '../utils/describeNodeTestReplay' import type { ShortcutHelpEntry } from '../utils/installShortcutHelp' const STUDY_FOCUS_MARGIN_PX = 4 @@ -46,7 +47,11 @@ const STUDY_SEQUENCE_LAYOUT = { /** The shared slider while it shows a browser recording: the page marks it `data-replay-family="test"` during a test replay (DS-C), so each family's shortcut entries name only its own slider. */ const BROWSER_PLAYHEAD_SELECTOR = '#playhead:not([data-replay-family="test"])' /** The shared slider while it shows a test replay. */ -const TEST_PLAYHEAD_SELECTOR = '#playhead[data-replay-family="test"]' +const TEST_PLAYHEAD_SELECTOR = + '#playhead[data-replay-family="test"]:not([data-replay-profile="node-server"])' +/** The shared slider while it shows a server replay: the page marks it with its profile, so a server replay's shortcut entries name a server replay and never a test. */ +const SERVER_PLAYHEAD_SELECTOR = + '#playhead[data-replay-family="test"][data-replay-profile="node-server"]' /** The test replay timeline's keys (C2 and DS-C): arrows step by event, Page keys by stop (Page Up forward, as for a recording), Home and End select the first and last event. */ const TEST_REPLAY_TIMELINE_SHORTCUTS: ShortcutHelpEntry[] = ( [ @@ -63,6 +68,13 @@ const TEST_REPLAY_TIMELINE_SHORTCUTS: ShortcutHelpEntry[] = ( context: 'Focused test replay timeline', selector: TEST_PLAYHEAD_SELECTOR, })) +/** The server replay timeline's keys: the test replay's, under the server replay's own name and slider. */ +const SERVER_REPLAY_TIMELINE_SHORTCUTS: ShortcutHelpEntry[] = + TEST_REPLAY_TIMELINE_SHORTCUTS.map((entry) => ({ + ...entry, + context: 'Focused server replay timeline', + selector: SERVER_PLAYHEAD_SELECTOR, + })) export const STUDY_SHORTCUTS: ShortcutHelpEntry[] = [ { action: 'Keyboard shortcuts', @@ -112,6 +124,7 @@ export const STUDY_SHORTCUTS: ShortcutHelpEntry[] = [ selector: BROWSER_PLAYHEAD_SELECTOR, }, ...TEST_REPLAY_TIMELINE_SHORTCUTS, + ...SERVER_REPLAY_TIMELINE_SHORTCUTS, { action: 'Previous / next content tab', keys: '← / →', @@ -148,6 +161,8 @@ const TIMELINE_COVERAGE_TEXT = { export const STUDY_UI_OPTIONS = { /** A test replay's fixed words (the String catalog); the pane's client code imports nothing, so they arrive here. */ testReplay: NODE_TEST_REPLAY_TEXT, + /** A server replay's fixed words, with the same keys; the pane switches to them while a server replay is shown. */ + serverReplay: NODE_SERVER_REPLAY_TEXT, /** The words of both transports' coverage strips. */ coverage: TIMELINE_COVERAGE_TEXT, sequence: STUDY_SEQUENCE_LAYOUT, @@ -203,6 +218,10 @@ export const STUDY_UI_OPTIONS = { testReplayTimelineTitle: TEST_REPLAY_TIMELINE_SHORTCUTS.map( (entry) => `${entry.action} — ${entry.keys} (${entry.context})`, ).join('; '), + // The same tooltip while the slider shows a server replay: its own name for the context, never `test`. + serverReplayTimelineTitle: SERVER_REPLAY_TIMELINE_SHORTCUTS.map( + (entry) => `${entry.action} — ${entry.keys} (${entry.context})`, + ).join('; '), // The shared slider's keyboard shortcuts while it shows a test replay, for its `aria-keyshortcuts`. testReplayTimelineKeys: 'ArrowLeft ArrowRight ArrowUp ArrowDown PageUp PageDown Home End', @@ -270,7 +289,7 @@ body.moments-only #recording-transport{display:flex;flex-wrap:wrap;align-items:c /* A test replay's transport stays in view while its summary and steps scroll under it (DS16); Previous stop and Next stop lead it (DS12). */ #node-history-moment p{margin:4px 0}#node-history-explanation:empty,#node-history-not-recorded:empty,#node-history-unverified:empty,#node-history-capture-reason:empty{display:none}.same-moment{color:var(--vscode-descriptionForeground,#aaa);font-size:12px} #node-history-moment>p:empty{margin:0;padding:0;border:0} -#node-learning-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#node-learning-title:focus{display:block;overflow:visible}#node-learning-title:focus-visible,#test-output-test:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px} +#node-learning-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#node-learning-title:focus{display:block;overflow:visible}#node-learning-title:focus-visible,#test-output-test:focus-visible,#test-output-request-line:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px} /* A test replay's Learning summary (Cycle 0051, DS-B, DS-E): long texts clamped to three lines with Show more, a capped diagram, the moment's step heading, the drawing's step mark (not color alone) and the summary's focusable headings. */ .clamp{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}.clamped-text>p{margin:4px 0}.clamped-text>.show-more{min-height:24px;padding:0;margin:0 0 8px}#node-learning-sequence .sequence-diagram{max-height:${TEST_REPLAY_DIAGRAM_MAX_HEIGHT_PX}px}.sequence-diagram text.current-step{fill:var(--vscode-textLink-foreground,#4daafc);font-weight:600;text-decoration:underline}.sequence-arrow.current-step{stroke-width:3}#node-history-step-heading{font-size:14px;margin:8px 0 4px}#node-history-step-heading:focus-visible,#title:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px}body[data-replay=true] #node-history-heading{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} /* A test replay's summary leads with its own content (DS-B): the learning title has no section padding of its own, and the article's pane controls follow the summary instead of standing above it. */ @@ -278,7 +297,7 @@ body.moments-only #recording-transport{display:flex;flex-wrap:wrap;align-items:c /* The shared transport's event group wraps as one: it takes its own line before the slider shrinks below its minimum width (DS-C); the 5A button ends the status row, apart from the move controls. */ .playback-events{display:inline-flex;gap:6px;flex:none}.playback-status-row #show-learning-summary{flex:none;margin-left:auto;min-height:32px;padding:4px 8px} /* A test replay's Application pane: the header and footer stay pinned and the output region is the only scroller (DS4); a standard-error line starts with a mark in the terminal's red, and the mark, never the color alone, says so. */ -body.test-output{height:100vh;padding:0;overflow:hidden}body.test-output main{display:flex;flex-direction:column;height:100%;min-height:0;padding:12px 16px}#test-output-test{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#test-output-test:focus{display:block;overflow:visible}#test-output{flex:1;min-height:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}.stderr-mark{color:var(--vscode-terminal-ansiRed,#f14c4c)}#test-output-jump{align-self:flex-start}body.test-output p{margin:4px 0} +body.test-output{height:100vh;padding:0;overflow:hidden}body.test-output main{display:flex;flex-direction:column;height:100%;min-height:0;padding:12px 16px}#test-output-test,#test-output-request-line{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}#test-output-test:focus,#test-output-request-line:focus{display:block;overflow:visible}.server-region{flex:none;margin:4px 0}#test-output-response-body{max-height:30vh;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:4px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output-response-body:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}#test-output-response-state{overflow-wrap:anywhere}#test-output{flex:1;min-height:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}.stderr-mark{color:var(--vscode-terminal-ansiRed,#f14c4c)}#test-output-jump{align-self:flex-start}body.test-output p{margin:4px 0} /* A Learning summary in a narrow side bar keeps a long title to two lines and its sequence in view; the full title stays in its tooltip, and a title the clamp cuts is a tab stop that is shown whole while it has the keyboard focus. A wider summary shows the title whole. */ #title:focus-visible,#learning-title:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px} /* Compact replay keeps its empty header spacing; a disclosed layout notice uses the shared gap. */ diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index 95cf59feb..26544c2b6 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -118,6 +118,10 @@ export interface StudyViewMessage { /** The saved server replays, beside {@link nodeHistoryCatalog}. */ nodeServerHistoryCatalog?: NodeServerHistorySummary[] nodeHistory?: NodeTestHistoryIndex + /** `node-server` while a server replay is shown, which switches the pane's fixed words to the server catalog; absent for a test replay. */ + nodeReplayProfile?: 'node-server' + /** The words of each event of the page `nodeHistory` holds, for a server replay: its heading and body, by journal sequence. */ + nodeServerEvents?: { sequence: number; heading: string; body: string }[] nodeHistoryPage?: number nodeHistoryPageCount?: number nodeHistoryEventCount?: number @@ -226,7 +230,7 @@ export function studyHtml(kind: 'context' | 'moments'): string {
Study controls

Ask your editor agent to explain a code path in the open repository.

Workspace panes

-

${STUDY_UI_OPTIONS.testReplay.heading}

Events
+

${STUDY_UI_OPTIONS.testReplay.heading}

Events

Observed moments

Select a captured observation or open Recording to follow its time.

Saved studies

${markupOnlyIn(kind, 'context', valuesHint)}
History coverage unknown
Playback details

Recorded history only. Playback does not run application code or mark understanding.

${markupOnlyIn(kind, 'moments', learningSummaryButton)}

${markupOnlyIn(kind, 'moments', valuesHint)}
@@ -532,6 +536,9 @@ function studyClient( readerOptions.querySelector('summary')?.addEventListener('click', () => { readerOptionsOpenedByLearner = !readerOptions.hasAttribute('open') }) + /** The fixed words the replay surfaces print now: the test catalog, or the server catalog while the message names a server replay; set at the start of each Study message ({@link useReplayWords}). */ + let replayWords: Readonly> = + options.testReplay const transport = element('recording-transport') // The shared slider of both replay families (DS-C), and the tooltip the shortcut help gave it for a recording, which it gets back when it leaves a test replay. const playheadInput = element('playhead') as HTMLInputElement @@ -2505,6 +2512,27 @@ function studyClient( renderTestReplaySharedTransport(message, isTransportShown) else releaseTestReplayPlayhead() } + /** + * Chooses the replay's fixed words for a Study message and writes the ones the page's markup holds: the section's heading, the sequence's group name, the no-summary line, the step details and Show recorded output labels, and the understanding label; a server replay reads every one in the server catalog and never as a test. Called first for each Study message. + * @param message - The current Study message; `nodeReplayProfile` names a server replay. + * @example useReplayWords(message) // heading reads 'Server replay' for a server replay + */ + const useReplayWords = (message: StudyViewMessage): void => { + replayWords = + message.nodeReplayProfile === 'node-server' + ? options.serverReplay + : options.testReplay + text('node-history-heading', replayWords.heading) + element('node-learning-sequence').setAttribute( + 'aria-label', + replayWords.sequenceLabel, + ) + text('node-history-no-material', replayWords.noMaterial) + text('node-learning-steps-summary', replayWords.stepDetails) + const showOutput = element('node-history-closed').querySelector('button') + if (showOutput) showOutput.textContent = replayWords.showRecordedOutput + text('node-history-understanding-label', replayWords.understanding) + } /** * Gives the reader's heading: a test replay's summary is headed by its test, as Saved studies names it (DS-B); any other reader by the selected article; read on every Study message. * @param message - The current Study message. @@ -2550,7 +2578,7 @@ function studyClient( ) return summary ? [summary.testFile, ...(summary.testTitles ?? [])].join(' › ') - : options.testReplay.heading + : replayWords.heading } /** * Renders the Study pane's test replay section for a Study message (Designs 5 and 6, DS16): status, the selected moment and its explanation, the transport, the summary with the selected step, the learner's decision and the collapsed event list; called by the message listener, which holds no branch of the section's own. @@ -2598,8 +2626,8 @@ function studyClient( ? '' : message.nodeHistoryCatalog?.length || message.nodeServerHistoryCatalog?.length - ? options.testReplay.chooseReplay - : options.testReplay.noReplay + ? replayWords.chooseReplay + : replayWords.noReplay renderTestReplayLines(section, idle) // While a selection is pending the earlier stop's fields may still ride on the position: no stop control is offered then (D29, F370). const settled = isTestReplayStopSettled(message, suppressed) @@ -2708,8 +2736,7 @@ function studyClient( */ const testReplayPositionText = ( section: StudyViewMessage['nodeHistorySection'], - ): string => - section ? (section.position ?? options.testReplay.noSelection) : '' + ): string => (section ? (section.position ?? replayWords.noSelection) : '') /** * Writes a test replay into the shared transport (Cycle 0051, DS-C): the status line, Replay from start, Previous and Next recorded stop, Play or Pause, the slider with its coverage and the readout, and the event group; a browser recording's own parts (playback details, pending and reason lines, the covered-application remedy) stay out. Called by the message listener while the summary composition shows a test replay. * The status regions carry text only in the pane that shows the transport, as for a recording. @@ -2735,7 +2762,7 @@ function studyClient( element('history-coverage'), element('history-coverage-label'), section?.timeline, - section?.timeline ? options.testReplay.timelineLegend : '', + section?.timeline ? replayWords.timelineLegend : '', ) } /** @@ -2823,8 +2850,8 @@ function studyClient( playing: boolean, ): string => { const status = playing - ? options.testReplay.playingStatus - : options.testReplay.pausedStatus + ? replayWords.playingStatus + : replayWords.pausedStatus return message.nodeHistoryRefusal ? `${status} · ${message.nodeHistoryRefusal}` : status @@ -2856,10 +2883,8 @@ function studyClient( const toggle = element('playback-toggle') if (!(toggle instanceof HTMLButtonElement)) return toggle.dataset.playback = playing ? 'pause' : 'play' - toggle.textContent = playing - ? options.testReplay.pause - : options.testReplay.play - const label = playing ? 'Pause test replay' : 'Play test replay' + toggle.textContent = playing ? replayWords.pause : replayWords.play + const label = playing ? replayWords.pauseLabel : replayWords.playLabel toggle.setAttribute('aria-label', label) toggle.title = label toggle.disabled = !canToggleTestReplay(message, playing) @@ -2895,7 +2920,14 @@ function studyClient( ): void => { const range = testReplayPlayheadRange(playbackState) playheadInput.dataset.replayFamily = 'test' - playheadInput.title = options.testReplayTimelineTitle + // The page names the slider's profile, so the shortcut list says server replay for a server replay. + if (current?.nodeReplayProfile === 'node-server') + playheadInput.dataset.replayProfile = 'node-server' + else delete playheadInput.dataset.replayProfile + playheadInput.title = + current?.nodeReplayProfile === 'node-server' + ? options.serverReplayTimelineTitle + : options.testReplayTimelineTitle playheadInput.setAttribute( 'aria-keyshortcuts', options.testReplayTimelineKeys, @@ -2921,6 +2953,7 @@ function studyClient( const releaseTestReplayPlayhead = (): void => { if (playheadInput.dataset.replayFamily !== 'test') return delete playheadInput.dataset.replayFamily + delete playheadInput.dataset.replayProfile playheadInput.title = browserPlayheadTitle playheadInput.removeAttribute('aria-keyshortcuts') element('playback-events').hidden = true @@ -2963,7 +2996,7 @@ function studyClient( */ const testReplayLoadingSuffix = ( playbackState: StudyViewMessage['nodeHistoryPlayback'], - ): string => (playbackState?.pending ? options.testReplay.loadingSuffix : '') + ): string => (playbackState?.pending ? replayWords.loadingSuffix : '') /** * Says the selected test time to assistive technology and in the shared readout (` s / s`), never the raw millisecond value; called by {@link renderTestReplayPlayhead} and while the learner drags the slider. * @param positionMs - The selected time, if any. @@ -2979,13 +3012,13 @@ function studyClient( playheadInput.setAttribute( 'aria-valuetext', positionMs === undefined - ? 'No recorded test time selected' - : `${seconds(positionMs)} of recorded test time${suffix}`, + ? replayWords.noRecordedTime + : `${seconds(positionMs)} of ${replayWords.recordedTime}${suffix}`, ) text( 'playback-position', positionMs === undefined - ? 'Test journal playback' + ? replayWords.journalPlayback : `${seconds(positionMs)} / ${seconds(endMs ?? positionMs)}${suffix}`, ) } @@ -3080,7 +3113,7 @@ function studyClient( const heading = element('node-history-step-heading') heading.hidden = !step heading.textContent = step - ? `${options.testReplay.stepHeading} ${index + 1} · ${step.label}` + ? `${replayWords.stepHeading} ${index + 1} · ${step.label}` : '' } /** @@ -3099,8 +3132,8 @@ function studyClient( !target.textContent || (!expanded && target.scrollHeight <= target.clientHeight) control.textContent = expanded - ? options.testReplay.showLess - : options.testReplay.showMore + ? replayWords.showLess + : replayWords.showMore } } window.addEventListener('resize', offerShowMore) @@ -3178,7 +3211,7 @@ function studyClient( if (!position || !label) { const missing = document.createElement('p') missing.className = 'muted' - missing.textContent = options.testReplay.noLinkedEvent + missing.textContent = replayWords.noLinkedEvent return missing } const action = document.createElement('div'), @@ -3190,7 +3223,7 @@ function studyClient( control.dataset.nodeStep = stepId control.textContent = label note.className = 'same-moment' - note.textContent = options.testReplay.sameMoment + note.textContent = replayWords.sameMoment note.hidden = true action.append(control, ' ', note) return action @@ -3217,7 +3250,7 @@ function studyClient( ? decision.understood ? `Learner marked understood · ${decision.at}` : `Learner marked for review · ${decision.at}` - : 'Only your explicit choice is saved; test success never marks understanding.', + : replayWords.understandingNote, ) } /** @@ -3312,6 +3345,7 @@ function studyClient( > = message.nodeHistoryPosition ?? {} return [ message.nodeHistory?.entries ?? [], + message.nodeServerEvents ?? [], replaying, position.sequence, Boolean(position.pending), @@ -3360,7 +3394,11 @@ function studyClient( heading = document.createElement('h3'), body = document.createElement('pre') row.className = 'node-history-event' - const words = testReplayEventWords(entry, section) + // A server replay's host words each of its events; a test replay's are worded here. + const words = + message.nodeServerEvents?.find( + (event) => event.sequence === entry.sequence, + ) ?? testReplayEventWords(entry, section) heading.textContent = words.heading body.textContent = words.body const { isSelected, loading } = testReplayEventRowState(entry, message) @@ -3672,6 +3710,7 @@ function studyClient( ) poseTransaction = undefined current = message + useReplayWords(message) // The summary composition lays out either replay family alike (Cycle 0051, DS-A); `recording` is read for a browser recording's own parts only. const { model, recording } = message, replayFamily = replayFamilyOf(message), diff --git a/extension/src/utils/adaptServerReplay.ts b/extension/src/utils/adaptServerReplay.ts new file mode 100644 index 000000000..03b0a3733 --- /dev/null +++ b/extension/src/utils/adaptServerReplay.ts @@ -0,0 +1,174 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import type { + ServerReplayEvent, + ServerReplayModel, +} from './buildServerReplayModel' +import { + indexNodeTestOutput, + stripOutputEntries, + type NodeTestOutputIndex, +} from './indexNodeTestOutput' +import { stripTerminalControls } from './stripTerminalControls' + +type TestShapedEntry = NodeTestHistoryIndex['entries'][number] + +/** + * A server replay as the Study workspace presents it: the replay model, an in-memory test-shaped history that the workspace's select, supersede and commit order moves over unchanged, the same two with every text free of terminal controls for display, and the output read once. + * Made once per opened history by {@link adaptServerReplay}; it holds data only, so a replay never reaches the launcher or the HTTP sender. + */ +export type ServerReplayPresentation = { + /** The model as the store read it; the host verifies a stop against it. */ + model: ServerReplayModel + /** The model with every recorded text free of terminal and bidirectional controls; the surfaces print it. */ + shownModel: ServerReplayModel + /** The learner events as test-shaped entries (stop, output, and `result` for the request, the response and the failure); read by the workspace's shared selection path. */ + history: NodeTestHistoryIndex + /** The same entries as the surfaces print them. */ + shown: NodeTestHistoryIndex + /** The shown output read once ({@link indexNodeTestOutput}). */ + outputIndex: NodeTestOutputIndex + /** Why the capture is partial or interrupted, every reason, for the replay's capture-reason line. */ + captureReasons: readonly string[] +} + +/** + * Removes terminal and bidirectional controls from every recorded text of a server replay that a surface prints, as the test replay does for its own: output is read as one stream per channel, and a request path, a response body, a failure code and a stop's path and frame name each lose them. Hashes, lines, columns, snapshots and counts are untouched. + * @param model - The replay model as the store read it. + * @returns A model whose events print safely; the input is not changed. + * @example sanitizeServerReplayModel(model).events[1] // => { type: 'output', text: 'ok\n', … } for a recorded 'o‮k\n' + */ +function sanitizeServerReplayModel( + model: ServerReplayModel, +): ServerReplayModel { + const shownOutput = stripOutputEntries(model.events) + const events = model.events.map((event): ServerReplayEvent => { + switch (event.type) { + case 'output': + return { ...event, text: shownOutput.get(event.sequence)! } + case 'request': + return { ...event, path: stripTerminalControls(event.path) } + case 'request-withdrawn': + return { ...event, code: stripTerminalControls(event.code) } + case 'stop': + return { + ...event, + source: { + ...event.source, + path: stripTerminalControls(event.source.path), + }, + frame: { name: stripTerminalControls(event.frame.name) }, + } + case 'response': + return { + ...event, + contentType: + event.contentType === null + ? null + : stripTerminalControls(event.contentType), + body: stripTerminalControls(event.body), + location: + event.location === null + ? null + : stripTerminalControls(event.location), + } + case 'request-failed': + return { + ...event, + code: stripTerminalControls(event.code), + message: stripTerminalControls(event.message), + } + } + }) + const terminal = events.find( + (event) => event.type === 'response' || event.type === 'request-failed', + ) + return { + ...model, + events, + ...(terminal?.type === 'response' || terminal?.type === 'request-failed' + ? { terminal } + : {}), + } +} + +/** + * Maps a model's learner events onto the entry types the workspace's replay path knows: a stop keeps its snapshot and position, an output keeps its channel and text, and the request, the withdrawal, the response and the failure are non-stop moments (`result` entries with no exit code), which every surface treats as the 4A moments they are. + * Called by {@link adaptServerReplay} for the stored model and for the shown one. + * @param model - The replay model. + * @returns The test-shaped history: a minimal header (study id, creation time and the first declared stop's file for the placeholder slot's name), the entries, the status. + * @example toTestShapedHistory(model).entries.map((entry) => entry.type) // => ['result', 'stop', 'output', 'result'] + */ +function toTestShapedHistory(model: ServerReplayModel): NodeTestHistoryIndex { + let outputSequence = 0 + const entries = model.events.map((event): TestShapedEntry => { + const base = { sequence: event.sequence, elapsedMs: event.elapsedMs } + if (event.type === 'output') + return { + type: 'output', + ...base, + outputSequence: ++outputSequence, + channel: event.channel, + text: event.text, + } + if (event.type === 'stop') + return { + type: 'stop', + ...base, + index: event.declaredStopIndex, + source: event.source, + frame: { + name: event.frame.name, + line: event.source.line, + column: event.source.column, + }, + facts: [], + debuggerSnapshot: event.debuggerSnapshot, + } + return { + type: 'result', + ...base, + exitCode: null, + signal: null, + outputTail: '', + } + }) + const { header } = model + // The one place a server replay is dressed as a test history: only the fields the shared path reads exist. + return { + header: { + workspaceKey: header.workspaceKey, + studyId: header.studyId, + instanceId: header.instanceId, + runId: header.runId, + createdAt: header.createdAt, + configuration: { testFile: header.declaredStops[0]?.path ?? 'server' }, + }, + entries, + status: model.status, + } as unknown as NodeTestHistoryIndex +} + +/** + * Presents a server replay model through the workspace's test replay path: the test-shaped history the selection moves over, its display copy with controls stripped, the model's own display copy and the output read once. + * Called once per opened server history by the Study workspace. + * @param model - The replay model {@link buildServerReplayModel} made. + * @param captureReasons - Every reason the capture is partial or interrupted, worded. + * @returns The presentation. + * @example const replay = adaptServerReplay(model, []); replay.history.entries[0]?.type // => 'result' for the request + */ +export function adaptServerReplay( + model: ServerReplayModel, + captureReasons: readonly string[], +): ServerReplayPresentation { + const shownModel = sanitizeServerReplayModel(model) + const shown = toTestShapedHistory(shownModel) + return { + model, + shownModel, + history: toTestShapedHistory(model), + shown, + outputIndex: indexNodeTestOutput(shown.entries), + captureReasons, + } +} diff --git a/extension/src/utils/describeNodeRecordedSource.ts b/extension/src/utils/describeNodeRecordedSource.ts index 85f9094de..5ea2b3fa0 100644 --- a/extension/src/utils/describeNodeRecordedSource.ts +++ b/extension/src/utils/describeNodeRecordedSource.ts @@ -12,6 +12,9 @@ import { stripTerminalControls } from './stripTerminalControls' export const NODE_RECORDED_SOURCE_TEXT = { placeholder: '// Recorded source · Not recorded at this moment\n// The test journal holds no source state for this moment.\n', + /** The placeholder of a server replay's slot: the same as the test replay's, naming the server journal. */ + serverPlaceholder: + '// Recorded source · Not recorded at this moment\n// The server journal holds no source state for this moment.\n', loading: '// Loading recorded source…\n', notRecordedThread: 'Not recorded at this moment', } as const @@ -31,6 +34,8 @@ export type NodeRecordedSourceMessage = stop: number path: string line: number + /** For a server replay: the hit's number and whether the stop was hit more than once; the thread then names a server stop and its hit. */ + server?: { hit: number; repeated: boolean } } | { /** The Study row of a slot that cannot open. */ @@ -67,7 +72,9 @@ export function describeNodeRecordedSource( case 'label': return `Recorded: ${stripTerminalControls(message.path)}:${message.line} · ${formatEventSeconds(message.elapsedMs)} s` case 'thread': - return `Recorded test stop ${message.stop} · ${stripTerminalControls(message.path)}:${message.line}` + return message.server + ? `Recorded server stop ${message.stop}${message.server.repeated ? ` · hit ${message.server.hit}` : ''} · ${stripTerminalControls(message.path)}:${message.line}` + : `Recorded test stop ${message.stop} · ${stripTerminalControls(message.path)}:${message.line}` case 'open-failed': return message.stop === undefined ? `Codebook could not open the recorded source: ${message.cause}. Details: Output › Codebook.` diff --git a/extension/src/utils/describeNodeServerReplay.ts b/extension/src/utils/describeNodeServerReplay.ts index c8d115ae1..d22f3c013 100644 --- a/extension/src/utils/describeNodeServerReplay.ts +++ b/extension/src/utils/describeNodeServerReplay.ts @@ -1,5 +1,12 @@ import type { NodeServerHistorySummary } from '../nodeServerHistoryTypes' +import type { + ServerReplayEvent, + ServerReplayModel, +} from './buildServerReplayModel' +import { formatEventSeconds } from './formatEventSeconds' +import { stripTerminalControls } from './stripTerminalControls' + /** * The fixed words of a server replay's surfaces: the server counterpart of `NODE_TEST_REPLAY_TEXT`, with the same keys (so a surface swaps catalogs by profile) and the server's own words for the request, the response and the understanding. Part of the node-server UI adapter: the storage layer never imports it. * The Study pane receives them through its options, as its client code imports nothing. @@ -57,8 +64,19 @@ export const NODE_SERVER_REPLAY_TEXT = { /** The fixed response region before the response entry. */ noResponseYet: 'No response recorded yet at this moment.', emptyBody: 'Response body is empty.', - /** Names the slider for a screen reader. */ + /** Why Open answers a server replay another window is still recording. */ + stillRecording: + 'This server replay is still being recorded in another window. It opens once the recording is sealed or interrupted.', + /** Names the scrolling block of the recorded response body for a screen reader. */ + responseBodyLabel: 'Recorded response body', + /** Names the slider, the readout, Play and Pause for a screen reader, and words the note under the understanding checkbox. */ recordedTime: 'recorded server time', + noRecordedTime: 'No recorded server time selected', + journalPlayback: 'Server journal playback', + playLabel: 'Play server replay', + pauseLabel: 'Pause server replay', + understandingNote: + 'Only your explicit choice is saved; a response never marks understanding.', /** The label of the sequence diagram's group. */ sequenceLabel: 'Server replay learning sequence', } as const @@ -156,3 +174,164 @@ export function describeServerReplayRow( : '' return `${summary.title ?? summary.question} · Server replay · ${summary.requestLine}${answer} · ${describeServerRowStatus(summary)}` } + +/** The words of one event in the Study pane's event list: its heading and its body. */ +export type ServerReplayEventRow = { heading: string; body: string } + +/** + * Words a stop's place for the locator, the event list and the step actions: `Stop ` or `Stop · hit ` for a stop hit more than once. + * @param event - The stop event. + * @param model - The replay model, which knows the declared stops hit more than once. + * @returns The stop's words. + * @example describeServerStopOf(stop, model) // => 'Stop 2 · hit 3' + */ +function describeServerStopOf( + event: Extract, + model: Pick, +): string { + return describeServerStop( + event.declaredStopIndex + 1, + event.hit, + model.repeatedStops.has(event.declaredStopIndex), + ) +} + +/** + * Words the Study section's locator of the selected event of a server replay. + * @param event - The selected event. + * @param model - The replay model. + * @returns `Request · s`, `Request not sent: · s`, `Output · s`, `Stop · hit · s`, `Response · s` or `Request failed: · s`. + * @example describeServerPosition(response, model) // => 'Response 201 · 0.12 s' + */ +export function describeServerPosition( + event: ServerReplayEvent, + model: Pick, +): string { + const time = `${formatEventSeconds(event.elapsedMs)} s` + switch (event.type) { + case 'request': + return `${NODE_SERVER_REPLAY_TEXT.eventRequest} · ${time}` + case 'request-withdrawn': + return `Request not sent: ${event.code} · ${time}` + case 'output': + return `${NODE_SERVER_REPLAY_TEXT.eventOutput} · ${time}` + case 'stop': + return `${describeServerStopOf(event, model)} · ${time}` + case 'response': + return `${NODE_SERVER_REPLAY_TEXT.eventResult} ${event.status} · ${time}` + case 'request-failed': + return `Request failed: ${event.code} · ${time}` + } +} + +/** + * Words the action of a summary step that cites an event of a server replay. + * @param event - The cited event. + * @param model - The replay model. + * @returns `Inspect stop · hit · s`, `Show request · s`, `Show output · s`, `Show response · s` and so on. + * @example describeServerStepAction(stop, model) // => 'Inspect Stop 1 · 0.42 s' + */ +export function describeServerStepAction( + event: ServerReplayEvent, + model: Pick, +): string { + const time = `${formatEventSeconds(event.elapsedMs)} s` + switch (event.type) { + case 'stop': + return `Inspect ${describeServerStopOf(event, model)} · ${time}` + case 'request': + case 'request-withdrawn': + return `Show request · ${time}` + case 'output': + return `Show output · ${time}` + case 'response': + return `Show response · ${time}` + case 'request-failed': + return `Show request failure · ${time}` + } +} + +/** + * Words the Study section's status line of a server replay: the answer, the capture and how many stops the chat explained. + * @param input - The history's status, how it answered, and the stops counted and explained. + * @returns `Server replay · recording`, or ` · capture · of stops explained`. + * @example describeServerStudyStatus({ status: 'complete', answer: 'Response 201', stopCount: 2, explainedStopCount: 1 }) // => 'Response 201 · capture complete · 1 of 2 stops explained' + */ +export function describeServerStudyStatus(input: { + status: ServerReplayModel['status'] + answer: string + stopCount: number + explainedStopCount: number +}): string { + return input.status === 'recording' + ? `${NODE_SERVER_REPLAY_TEXT.heading} · recording` + : `${input.answer} · capture ${input.status} · ${input.explainedStopCount} of ${input.stopCount} stops explained` +} + +/** + * Words how a server replay answered, for its status line and the response row of its event list. + * @param terminal - The recorded response or request failure, absent when the journal holds neither. + * @returns `Response `, `Request failed: ` or `No response recorded`. + * @example describeServerAnswer(model.terminal) // => 'Response 201' + */ +export function describeServerAnswer( + terminal: ServerReplayModel['terminal'], +): string { + if (terminal?.type === 'response') + return `${NODE_SERVER_REPLAY_TEXT.eventResult} ${terminal.status}` + return terminal ? `Request failed: ${terminal.code}` : 'No response recorded' +} + +/** + * Words one event of the server replay's event list: its heading with the time, and its body (the request line, the output text, a stop's frame and inspected values, the response's content type and body, the failure's message). + * The event comes from the shown model, so every text is free of terminal controls already. + * @param event - The event. + * @param model - The replay model. + * @returns The heading and the body. + * @example describeServerEventRow(output, model) // => { heading: 'Output · stdout · 0.12 s', body: 'listening\n' } + */ +export function describeServerEventRow( + event: ServerReplayEvent, + model: Pick, +): ServerReplayEventRow { + const time = `${formatEventSeconds(event.elapsedMs)} s` + switch (event.type) { + case 'request': + return { + heading: `${NODE_SERVER_REPLAY_TEXT.eventRequest} · ${time}`, + body: `${event.method} ${event.path}`, + } + case 'request-withdrawn': + return { + heading: `Request not sent: ${event.code} · ${time}`, + body: '', + } + case 'output': + return { + heading: `${NODE_SERVER_REPLAY_TEXT.eventOutput} · ${event.channel} · ${time}`, + body: event.text, + } + case 'stop': + return { + heading: `${describeServerStopOf(event, model)} · ${event.source.path}:${event.source.line}:${event.source.column} · ${time}`, + body: [ + event.frame.name, + ...Object.entries(event.facts).map(([name, value]) => + stripTerminalControls( + `${name}: ${typeof value === 'string' ? value : JSON.stringify(value)}`, + ), + ), + ].join('\n'), + } + case 'response': + return { + heading: `${NODE_SERVER_REPLAY_TEXT.eventResult} ${event.status} · ${event.contentType ?? 'no content type'} · ${time}`, + body: event.body, + } + case 'request-failed': + return { + heading: `Request failed: ${event.code} · ${time}`, + body: event.message, + } + } +} diff --git a/extension/src/utils/describeNodeTestReplay.ts b/extension/src/utils/describeNodeTestReplay.ts index 9c9a13d97..5c67d973e 100644 --- a/extension/src/utils/describeNodeTestReplay.ts +++ b/extension/src/utils/describeNodeTestReplay.ts @@ -56,6 +56,17 @@ export const NODE_TEST_REPLAY_TEXT = { eventOutput: 'Output', eventStop: 'Stop', eventResult: 'Result', + /** The understanding checkbox's label, the note under it, and the words the shared transport's slider, readout, Play and Pause give a screen reader; a server replay words each with its own catalog. */ + understanding: 'I understand this test history', + understandingNote: + 'Only your explicit choice is saved; test success never marks understanding.', + recordedTime: 'recorded test time', + noRecordedTime: 'No recorded test time selected', + journalPlayback: 'Test journal playback', + playLabel: 'Play test replay', + pauseLabel: 'Pause test replay', + /** The label of the sequence diagram's group. */ + sequenceLabel: 'Test replay learning sequence', } as const /** What a test replay shows the learner outside its Save, keyed by surface and value (the String catalog of plan version 7). */ diff --git a/extension/src/utils/indexNodeTestOutput.ts b/extension/src/utils/indexNodeTestOutput.ts index a3ed94432..a4eb3554a 100644 --- a/extension/src/utils/indexNodeTestOutput.ts +++ b/extension/src/utils/indexNodeTestOutput.ts @@ -164,7 +164,9 @@ export function indexNodeTestOutput( * @example stripOutputEntries([{ type: 'output', sequence: 1, elapsedMs: 5, channel: 'stdout', text: '\u001b[3' }, { type: 'output', sequence: 2, elapsedMs: 6, channel: 'stdout', text: '2mok' }]) // => Map { 1 => '', 2 => 'ok' } */ export function stripOutputEntries( - entries: readonly NodeTestHistoryIndex['entries'][number][], + entries: readonly ( + NodeTestHistoryIndex['entries'][number] | NodeServerHistoryEntry + )[], ): Map { const shown = new Map() for (const channel of ['stdout', 'stderr'] as const) { diff --git a/extension/src/utils/installNodeTestOutput.ts b/extension/src/utils/installNodeTestOutput.ts index 088c2b1fe..d8b5c5c95 100644 --- a/extension/src/utils/installNodeTestOutput.ts +++ b/extension/src/utils/installNodeTestOutput.ts @@ -228,15 +228,35 @@ export function installNodeTestOutput( else if (anchor && drawn.has(anchor.line)) restoreAnchor(anchor) else settle() } + /** Brings a server replay's request and response regions to a view's in place; a region the view lacks is hidden. */ + const applyServerRegions = (view: NodeTestApplicationView): void => { + const request = view.request + element('test-output-request').hidden = !request + const requestLine = element('test-output-request-line') + requestLine.textContent = request?.line ?? '' + requestLine.title = request?.line ?? '' + element('test-output-request-claim').textContent = request?.claim ?? '' + const response = view.response + element('test-output-response').hidden = !response + element('test-output-response-state').textContent = response?.state ?? '' + const body = element('test-output-response-body') + body.textContent = response?.body ?? '' + body.hidden = response?.body === undefined + line('test-output-response-note', response?.note) + } const apply = (view: NodeTestApplicationView): void => { // The line the learner reads, taken before the lines change: a view that drops earlier lines would otherwise move it up (F354). const readingAnchor = follow ? undefined : currentAnchor() document.title = view.name element('test-output-main').setAttribute('aria-label', view.name) element('test-output-time').textContent = view.time - const test = element('test-output-test') - test.textContent = view.test - test.title = view.test + // A server replay has no cited test; its request and response regions take that place. + if (view.profile === 'node-server') applyServerRegions(view) + else { + const test = element('test-output-test') + test.textContent = view.test + test.title = view.test + } output.setAttribute('aria-label', view.region ?? view.name) line('test-output-inspector', view.inspector) line('test-output-cut', view.cut) diff --git a/extension/src/utils/nodeHistoryPlaybackController.ts b/extension/src/utils/nodeHistoryPlaybackController.ts index 99871982f..3e89622ef 100644 --- a/extension/src/utils/nodeHistoryPlaybackController.ts +++ b/extension/src/utils/nodeHistoryPlaybackController.ts @@ -35,8 +35,16 @@ export class NodeHistoryPlaybackController { this.clock = dependencies.now ?? (() => performance.now()) } - /** Opens one verified event list at its restored sequence and stops any prior history. @param events - Ordered events from the sealed journal. @param sequence - Previously selected event, if still present. @example controller.open([{sequence:1,elapsedMs:10}], 1) */ - open(events: NodeHistoryPlaybackEvent[], sequence?: number): void { + /** The time the recording ended when it ended after its last event (a server replay's `end` entry); the slider's range reaches it. */ + private recordingEndMs?: number + + /** Opens one verified event list at its restored sequence and stops any prior history. @param events - Ordered events from the sealed journal. @param sequence - Previously selected event, if still present. @param endedAtMs - When the recording ended, when after the last event. @example controller.open([{sequence:1,elapsedMs:10}], 1) */ + open( + events: NodeHistoryPlaybackEvent[], + sequence?: number, + endedAtMs?: number, + ): void { + this.recordingEndMs = endedAtMs this.generation++ this.stopTimer() this.mode = 'paused' @@ -67,7 +75,10 @@ export class NodeHistoryPlaybackController { }), ...(this.events.length && { startMs: this.events[0]!.elapsedMs, - endMs: this.events.at(-1)!.elapsedMs, + endMs: Math.max( + this.events.at(-1)!.elapsedMs, + this.recordingEndMs ?? 0, + ), }), loading: Boolean(this.pending), } @@ -134,7 +145,7 @@ export class NodeHistoryPlaybackController { !last || !Number.isFinite(positionMs) || positionMs < first.elapsedMs || - positionMs > last.elapsedMs + positionMs > Math.max(last.elapsedMs, this.recordingEndMs ?? 0) ) { throw new Error( 'Seek lies outside the verified range of this test replay.', diff --git a/extension/src/utils/presentServerHistory.ts b/extension/src/utils/presentServerHistory.ts new file mode 100644 index 000000000..77186527a --- /dev/null +++ b/extension/src/utils/presentServerHistory.ts @@ -0,0 +1,64 @@ +import type { HistoryIndexOf } from '../historyStorageDescriptor' +import type { + NodeServerHistorySummary, + NodeServerHistoryTypes, +} from '../nodeServerHistoryTypes' + +import { + adaptServerReplay, + type ServerReplayPresentation, +} from './adaptServerReplay' +import { buildServerReplayModel } from './buildServerReplayModel' +import { stripTerminalControls } from './stripTerminalControls' + +/** + * Words a stored capture reason for a server replay's surfaces: free of terminal controls and line breaks, with the storage layer's name for the history replaced by the learner's word for it. + * @param reason - A reason as the store wrote it. + * @returns The reason as the replay reads it. + * @example wordServerCaptureReason('Interrupted Node server history after its verified prefix: EIO') // => 'Interrupted server replay after its verified prefix: EIO' + */ +function wordServerCaptureReason(reason: string): string { + return stripTerminalControls(reason) + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/\bNode server histor(y|ies)\b/gi, (_match, ending: string) => + ending === 'y' ? 'server replay' : 'server replays', + ) + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Lists every reason a server history's capture is partial or interrupted, in the words of its replay: a seal's reasons uncut, else the reason the store gave an unsealed history. + * @param history - The verified history. + * @returns The reasons; empty for a complete capture. + * @example serverCaptureReasons(history) // => ['No response was recorded: the history ended before the server answered.'] + */ +function serverCaptureReasons( + history: Pick, 'seal' | 'reason'>, +): string[] { + if (history.seal) + return history.seal.reasons.map((reason) => + wordServerCaptureReason(reason.text), + ) + return history.reason ? [wordServerCaptureReason(history.reason)] : [] +} + +/** + * Reads a verified server history once into what the Study workspace presents: the replay model with its three verdicts, its test-shaped view for the shared selection path, and its capture reasons. + * Called by the Study workspace when it opens a server replay. + * @param history - The history the server store read, its source entries intact. + * @param summary - The history's summary, for its learning verdict; absent when it could not be read. + * @returns The presentation. + * @example const replay = presentServerHistory(await store.read(studyId), await store.summarize(studyId)) + */ +export function presentServerHistory( + history: HistoryIndexOf, + summary: NodeServerHistorySummary | undefined, +): ServerReplayPresentation { + const model = buildServerReplayModel(history, { + saved: history.seal !== undefined, + captureComplete: history.seal?.capture === 'complete', + learningCoverage: summary?.learningCoverage ?? 'none', + }) + return adaptServerReplay(model, serverCaptureReasons(history)) +} diff --git a/extension/src/utils/projectNodeHistoryTimeline.ts b/extension/src/utils/projectNodeHistoryTimeline.ts index 67f3dbd0c..4207f8aa8 100644 --- a/extension/src/utils/projectNodeHistoryTimeline.ts +++ b/extension/src/utils/projectNodeHistoryTimeline.ts @@ -20,18 +20,22 @@ export type NodeHistoryTimeline = { * Called by {@link StudyWorkspace} while it projects the Study pane's test replay section; the slider itself stays an event navigator. * * @param history - The test replay's events in journal order. + * @param endedAtMs - When the recording ended, if after its last event; the timeline ends there. * @returns The range, its one gap and the ticks; `undefined` for a history of fewer than two events or of events all at one time, which have nothing to mark. * @example * projectNodeHistoryTimeline({ entries: [output0s, verifiedStop1s, result2s] }) // => { startMs: 0, endMs: 2000, gaps: [{ startMs: 0, endMs: 2000 }], recordedPoints: [1000] } */ export function projectNodeHistoryTimeline( history: Pick, + endedAtMs?: number, ): NodeHistoryTimeline | undefined { const first = history.entries[0], last = history.entries.at(-1) + // A recording that ended after its last event (a server replay's `end`) ends its timeline there. + const endMs = Math.max(last?.elapsedMs ?? 0, endedAtMs ?? 0) // One event, or events at one time, have no span to mark (the browser transport draws none either). - if (!first || !last || last.elapsedMs <= first.elapsedMs) return undefined - const range = { startMs: first.elapsedMs, endMs: last.elapsedMs } + if (!first || !last || endMs <= first.elapsedMs) return undefined + const range = { startMs: first.elapsedMs, endMs } const timeline = projectHistoricalTimeline( { pausedIntervals: [], diff --git a/extension/src/utils/projectNodeLearningSummary.ts b/extension/src/utils/projectNodeLearningSummary.ts index 50896a87c..cb803b35a 100644 --- a/extension/src/utils/projectNodeLearningSummary.ts +++ b/extension/src/utils/projectNodeLearningSummary.ts @@ -1,3 +1,4 @@ +import type { NodeServerLearningMaterial } from '../nodeServerLearningSchemas' import type { NodeTestHistoryIndex, NodeTestLearningMaterial, @@ -11,9 +12,19 @@ export type NodeLearningStepPosition = { sequence: number } -/** A test replay's learning material as the Study pane's summary renderer takes it, the browser summary's shape, with each referenced step's event. */ +/** The learning material of either history profile: the same question, title, summary, participants and steps, citing the entries of its own journal. */ +export type NodeLearningMaterialOfHistory = + NodeTestLearningMaterial | NodeServerLearningMaterial + +/** A replay's learning material as the Study pane's summary renderer takes it, the browser summary's shape, with each referenced step's event. */ export type NodeLearningSummaryProjection = { - content: NodeTestLearningMaterial['content'] + // The summary renderer reads no step reference: each profile's own references stay in the object, and positions carry the event each one names. + content: Omit & { + steps: Omit< + NodeTestLearningMaterial['content']['steps'][number], + 'reference' + >[] + } positions: NodeLearningStepPosition[] } @@ -27,7 +38,7 @@ export type NodeLearningSummaryProjection = { * projectNodeLearningSummary(material, history)?.positions // => [{ stepId: 'stop-1', sequence: 3 }] */ export function projectNodeLearningSummary( - material: NodeTestLearningMaterial | undefined, + material: NodeLearningMaterialOfHistory | undefined, history: Pick, ): NodeLearningSummaryProjection | undefined { if (!material) return @@ -53,9 +64,9 @@ export function projectNodeLearningSummary( * @returns The content with its question, title, summary, participant labels, step labels and explanations free of controls; ids, evidence and references unchanged. * @example shownLearningContent(content).title // => 'Theme switch' for a saved 'Theme\u202E switch' */ -function shownLearningContent( - content: NodeTestLearningMaterial['content'], -): NodeTestLearningMaterial['content'] { +function shownLearningContent< + Content extends NodeLearningMaterialOfHistory['content'], +>(content: Content): Content { return { ...content, question: stripTerminalControls(content.question), diff --git a/extension/src/utils/projectNodeTestApplication.ts b/extension/src/utils/projectNodeTestApplication.ts index b907f44ac..75691119e 100644 --- a/extension/src/utils/projectNodeTestApplication.ts +++ b/extension/src/utils/projectNodeTestApplication.ts @@ -20,8 +20,14 @@ export type NodeTestApplicationView = { generation: number /** Header line 1: `Recorded test output · s`, or `Recorded test output` alone without a selected event (loading, no entries). */ time: string - /** Header line 2: the cited test, shown in at most two lines with this whole text in its tooltip. */ + /** Header line 2: the cited test, shown in at most two lines with this whole text in its tooltip; a server replay leaves it empty (its request has its own region). */ test: string + /** `node-server` for a server replay, whose document words its footer and Jump to latest with the server catalog and draws the two fixed regions below. */ + profile?: 'node-server' + /** A server replay's request region: the request line and the claim the replay may make about it. */ + request?: { line: string; claim: string } + /** A server replay's response region for the selected moment: its state line, the recorded body in its own block and a note on a cut or empty body. */ + response?: { state: string; body?: string; note?: string } /** The pane's accessible name. */ name: string /** The output region's accessible name, `Recorded test output up to s`. */ diff --git a/extension/src/utils/projectNodeTestSection.ts b/extension/src/utils/projectNodeTestSection.ts index 15f886c43..f3d00531d 100644 --- a/extension/src/utils/projectNodeTestSection.ts +++ b/extension/src/utils/projectNodeTestSection.ts @@ -124,13 +124,29 @@ export function projectNodeTestSection( count: history.entries.length, }), verdict, + ...stopMoves(stops, selected), + ...marks, + } +} + +/** + * Says whether a stop lies before and after the selected event, for Previous stop and Next stop; shared by the test replay's section and the server replay's ({@link projectServerSection}). + * @param stops - The replay's stop events. + * @param selected - The selected event; absent before one is selected, when every stop lies after it. + * @returns `previousStop` and `nextStop`. + * @example stopMoves([{ sequence: 2 }, { sequence: 4 }], { sequence: 3 }) // => { previousStop: true, nextStop: true } + */ +export function stopMoves( + stops: readonly Pick[], + selected: Pick | undefined, +): Pick { + return { previousStop: stops.some( (stop) => selected !== undefined && stop.sequence < selected.sequence, ), nextStop: stops.some( (stop) => selected === undefined || stop.sequence > selected.sequence, ), - ...marks, } } @@ -238,8 +254,8 @@ function positionLine( * @returns The shown step's id and content, and the other steps' ids. * @example momentSteps(stop, learning, 'keep').selectedStepId // => 'keep' */ -function momentSteps( - selected: HistoryEvent | undefined, +export function momentSteps( + selected: Pick | undefined, learning: NodeLearningSummaryProjection | undefined, chosenStepId: string | undefined, ): { diff --git a/extension/src/utils/projectServerApplication.ts b/extension/src/utils/projectServerApplication.ts index dc6324344..2716f40c4 100644 --- a/extension/src/utils/projectServerApplication.ts +++ b/extension/src/utils/projectServerApplication.ts @@ -1,7 +1,4 @@ -import { - accumulateNodeTestOutput, - type NodeTestOutputViewLine, -} from './accumulateNodeTestOutput' +import { accumulateNodeTestOutput } from './accumulateNodeTestOutput' import type { ServerReplayEvent, ServerReplayModel, @@ -16,34 +13,14 @@ import { indexNodeTestOutput, type NodeTestOutputIndex, } from './indexNodeTestOutput' -import { leftOutLines } from './projectNodeTestApplication' +import { + leftOutLines, + type NodeTestApplicationView, +} from './projectNodeTestApplication' -/** What the Application pane shows of a server replay at one moment: the fixed request region, the fixed response region, then the output log. Every field is text, rendered as text and never as markup. */ -export type NodeServerApplicationView = { - generation: number - /** Header line 1: `Recorded server output · s`, or `Recorded server output` alone without a selected event. */ - time: string - /** The pane's accessible name. */ - name: string - /** The request region: the request line and the claim the replay may make about it. */ - request?: { line: string; claim: string } - /** The response region for the selected moment. */ - response?: { - state: string - /** The recorded body, in its own scrolling block; absent before the response and for an empty body. */ - body?: string - /** `Body shown cut at bytes.` for a cut body, `Response body is empty.` for none. */ - note?: string - } - /** The output log's accessible name, `Recorded server output up to s`. */ - region?: string - lines: NodeTestOutputViewLine[] - cut?: string - inspector?: string - /** The line that stands for the log when there is none to show: loading, nothing yet or no entries. */ - placeholder?: string - /** The last line: that the recording ended before the server answered. */ - end?: string +/** What the Application pane shows of a server replay at one moment: the fixed request region, the fixed response region, then the output log. It is the test replay's Application view with its server fields, so one document draws both. Every field is text, rendered as text and never as markup. */ +export type NodeServerApplicationView = NodeTestApplicationView & { + profile: 'node-server' } /** Words the Application pane's first line and name. */ @@ -149,6 +126,8 @@ export function projectServerApplication( const { generation, loading = false, leaving = false } = options if (loading || !selected) return { + profile: 'node-server', + test: '', generation, time: timeWords(undefined), name: NODE_SERVER_REPLAY_TEXT.heading, @@ -162,6 +141,8 @@ export function projectServerApplication( const tail = accumulateNodeTestOutput(index, selected.sequence) const last = model.events.at(-1) return { + profile: 'node-server', + test: '', generation, time: timeWords(leaving ? undefined : selected.elapsedMs), name: `${NODE_SERVER_REPLAY_TEXT.heading} · ${model.header.declaredRequest.method} ${model.header.declaredRequest.path}`, diff --git a/extension/src/utils/projectServerSection.ts b/extension/src/utils/projectServerSection.ts new file mode 100644 index 000000000..40abbe26c --- /dev/null +++ b/extension/src/utils/projectServerSection.ts @@ -0,0 +1,199 @@ +import type { ServerReplayPresentation } from './adaptServerReplay' +import type { ServerReplayEvent } from './buildServerReplayModel' +import { + describeServerAnswer, + describeServerPosition, + describeServerStepAction, + describeServerStudyStatus, + NODE_SERVER_REPLAY_TEXT, +} from './describeNodeServerReplay' +import type { NodeHistoryTimeline } from './projectNodeHistoryTimeline' +import type { NodeLearningSummaryProjection } from './projectNodeLearningSummary' +import { + momentSteps, + stopMoves, + type NodeTestSectionView, +} from './projectNodeTestSection' + +/** The inputs of a server replay's Study section. */ +export type ServerSectionInput = { + replay: ServerReplayPresentation + /** The selected event, from the shown model; absent before one is selected. */ + selected?: ServerReplayEvent + /** Whether the selected event is a stop whose recorded source verifies. */ + selectedStopVerified: boolean + learning?: NodeLearningSummaryProjection + chosenStepId?: string + applicationClosed: boolean + marks?: { timeline?: NodeHistoryTimeline } + /** `true` while the selected event is only the moment a selection is going to. */ + pending?: boolean +} + +/** + * Words the Study pane's section for a server replay with the server catalog: status, locator, the selected step and its explanation, the not-recorded lines, every capture reason and the steps that share the moment. It has the shape of {@link NodeTestSectionView}, so the pane's one section renderer draws it. + * Called by the Study workspace for each Study message while a server replay is selected. + * @param input - The replay, the selected event, its learning material and the learner's chosen step. + * @returns The section's view. + * @example projectServerSection({ replay, selected: stop, selectedStopVerified: true, applicationClosed: false, marks: {} }).position // => 'Stop 1 · 0.42 s' + */ +export function projectServerSection( + input: ServerSectionInput, +): NodeTestSectionView { + const { replay, selected, learning } = input + const model = replay.shownModel + const moment = input.pending + ? { sameMomentStepIds: [] as string[] } + : momentSteps(selected, learning, input.chosenStepId) + const stops = model.events.filter((event) => event.type === 'stop') + const answer = describeServerAnswer(model.terminal) + return { + status: statusLine(input, stops, answer), + ...captureReasonLine(model.status, replay.captureReasons), + ...(selected + ? { + position: + describeServerPosition(selected, model) + + (input.pending ? NODE_SERVER_REPLAY_TEXT.loadingSuffix : ''), + } + : {}), + ...momentLines(input, moment), + pointer: input.applicationClosed + ? NODE_SERVER_REPLAY_TEXT.applicationClosed + : NODE_SERVER_REPLAY_TEXT.outputPointer, + applicationClosed: input.applicationClosed, + ...('selectedStepId' in moment && moment.selectedStepId + ? { selectedStepId: moment.selectedStepId } + : {}), + sameMomentStepIds: moment.sameMomentStepIds, + stepActions: stepActionLabels(replay, learning), + events: `Events (${model.events.length})`, + verdict: answer, + ...stopMoves(stops, selected), + ...(input.marks?.timeline ? { timeline: input.marks.timeline } : {}), + } +} + +/** + * Words the section's status line, counting as explained the stops a step of the material references. + * @param input - The section's inputs. + * @param stops - The replay's stop events. + * @param answer - How the replay answered. + * @returns ` · capture · of stops explained`, or the recording line. + * @example statusLine(input, stops, 'Response 201') // => 'Response 201 · capture complete · 1 of 2 stops explained' + */ +function statusLine( + input: ServerSectionInput, + stops: readonly ServerReplayEvent[], + answer: string, +): string { + const referenced = new Set( + (input.learning?.positions ?? []).map((position) => position.sequence), + ) + return describeServerStudyStatus({ + status: input.replay.shownModel.status, + answer, + stopCount: stops.length, + explainedStopCount: stops.filter((stop) => referenced.has(stop.sequence)) + .length, + }) +} + +/** + * Words the lines under the locator: the explanation, the 4A not-recorded line (at every moment that is not a stop with recorded source) and the line for a stop whose source is unavailable; a moment that is only pending has none. + * @param input - The section's inputs. + * @param moment - The steps of the selected event. + * @returns The lines that apply. + * @example momentLines(input, moment).notRecorded // => 'Source and debugger not recorded at this moment.' at an output + */ +function momentLines( + input: ServerSectionInput, + moment: ReturnType, +): Pick { + const { selected } = input + if (input.pending || !selected) return {} + const explanation = momentExplanation(selected, moment) + const unverifiedStop = selected.type === 'stop' && !input.selectedStopVerified + return { + ...(explanation === undefined ? {} : { explanation }), + ...(selected.type !== 'stop' || unverifiedStop + ? { notRecorded: NODE_SERVER_REPLAY_TEXT.notRecorded } + : {}), + ...(unverifiedStop && selected.type === 'stop' + ? { unverified: unverifiedSourceLine(selected) } + : {}), + } +} + +/** + * Words the capture-reason line: every reason the capture is partial or interrupted, so the replay says all of them where a library row says the first and a count. + * @param status - The history's status. + * @param reasons - Every worded reason. + * @returns `{ captureReason }` for a partial or interrupted history that has a reason; nothing otherwise. + * @example captureReasonLine('partial', ['A', 'B']) // => { captureReason: 'Capture partial: A; B' } + */ +function captureReasonLine( + status: ServerSectionInput['replay']['model']['status'], + reasons: readonly string[], +): { captureReason?: string } { + if ((status !== 'partial' && status !== 'interrupted') || !reasons.length) + return {} + return { captureReason: `Capture ${status}: ${reasons.join('; ')}` } +} + +/** + * Words why a stop's recorded source is not shown, with the reason the debugger snapshot names when it names one (the source was too large to store). + * @param stop - The selected stop. + * @returns The catalog's line, ending in the reason when known. + * @example unverifiedSourceLine(stop) // => 'Recorded source unavailable for this stop (too large).' + */ +function unverifiedSourceLine( + stop: Extract, +): string { + const reason = stop.debuggerSnapshot.frames[0]?.sourceUnavailable + return reason + ? `${NODE_SERVER_REPLAY_TEXT.unverifiedStop.replace(/\.$/, '')} (${reason}).` + : NODE_SERVER_REPLAY_TEXT.unverifiedStop +} + +/** + * Gives the explanation shown under the locator: the shown step's, else the line that no step explains a selected stop or response; a request or output without a step needs no line. + * @param selected - The selected event, if any. + * @param moment - The steps of the selected event. + * @returns The explanation, or `undefined` for none. + * @example momentExplanation(stop, { sameMomentStepIds: [] }) // => 'No saved explanation for this stop.' + */ +function momentExplanation( + selected: ServerReplayEvent | undefined, + moment: ReturnType, +): string | undefined { + if (moment.selectedStep) return moment.selectedStep.explanation + if (selected?.type === 'stop') return NODE_SERVER_REPLAY_TEXT.noStepForStop + return selected?.type === 'response' || selected?.type === 'request-failed' + ? NODE_SERVER_REPLAY_TEXT.noStepForResult + : undefined +} + +/** + * Words each step's action by the event its reference names, for the steps whose event the replay holds. + * The labels are own data properties, so a step id such as `__proto__` keeps its label. + * @param replay - The replay. + * @param learning - The projected material, if any. + * @returns The action labels by step id. + * @example stepActionLabels(replay, learning) // => { read: 'Inspect Stop 1 · 0.42 s' } + */ +function stepActionLabels( + replay: ServerReplayPresentation, + learning: NodeLearningSummaryProjection | undefined, +): Record { + const model = replay.shownModel + return Object.fromEntries( + (learning?.positions ?? []).flatMap((position) => { + const at = model.indexBySequence.get(position.sequence) + const event = at === undefined ? undefined : model.events[at] + return event + ? [[position.stepId, describeServerStepAction(event, model)] as const] + : [] + }), + ) +} diff --git a/extension/src/utils/testOutputHtml.ts b/extension/src/utils/testOutputHtml.ts index 62527e55a..566583331 100644 --- a/extension/src/utils/testOutputHtml.ts +++ b/extension/src/utils/testOutputHtml.ts @@ -5,6 +5,7 @@ import { import { installWebviewFocus } from '../runtimeSupport/utils/installWebviewFocus' import { STUDY_STYLES } from '../ui/constants' +import { NODE_SERVER_REPLAY_TEXT } from './describeNodeServerReplay' import { NODE_TEST_REPLAY_TEXT } from './describeNodeTestReplay' import { escapeHtml } from './escapeHtml' import { installClampedTitleStop } from './installClampedTitleStop' @@ -31,8 +32,13 @@ export function testOutputHtml( reading: NodeTestOutputReading = { follow: true }, ): string { // The script's options are JSON inside a script element: `<` is written as an escape, so no value can end the element. + // A server replay words its footer and Jump to latest with the server catalog, and draws its two fixed regions between the header and the output. + const words = + view.profile === 'node-server' + ? NODE_SERVER_REPLAY_TEXT + : NODE_TEST_REPLAY_TEXT const scriptOptions = JSON.stringify({ - stderrMark: NODE_TEST_REPLAY_TEXT.stderrMark, + stderrMark: words.stderrMark, history, endTolerancePx: TEST_OUTPUT_END_TOLERANCE_PX, stateReportIntervalMs: TEST_OUTPUT_STATE_REPORT_INTERVAL_MS, @@ -41,5 +47,17 @@ export function testOutputHtml( }).replaceAll('<', '\\u003c') const muted = (id: string, value: string | undefined): string => `

${escapeHtml(value ?? '')}

` - return `${escapeHtml(view.name)}

${escapeHtml(view.time)}

${escapeHtml(view.test)}

${muted('test-output-inspector', view.inspector)}${muted('test-output-cut', view.cut)}
${muted('test-output-placeholder', view.placeholder)}

${escapeHtml(view.end ?? '')}

` + return `${escapeHtml(view.name)}

${escapeHtml(view.time)}

${view.profile === 'node-server' ? '' : `

${escapeHtml(view.test)}

`}
${view.profile === 'node-server' ? serverRegions(view) : ''}${muted('test-output-inspector', view.inspector)}${muted('test-output-cut', view.cut)}
${muted('test-output-placeholder', view.placeholder)}

${escapeHtml(view.end ?? '')}

` +} + +/** + * Writes a server replay's two fixed regions of the Application pane, outside the scrolling output: the request line with the claim the replay may make about it, and the response state with the recorded body in its own scrolling block. Called by {@link testOutputHtml}; {@link installNodeTestOutput} updates them in place. + * @param view - The server replay's Application view. + * @returns The markup of both regions, every text escaped. + * @example serverRegions(view) // => '
…' + */ +function serverRegions(view: NodeTestApplicationView): string { + const request = view.request + const response = view.response + return `

${escapeHtml(request?.line ?? '')}

${escapeHtml(request?.claim ?? '')}

${escapeHtml(response?.state ?? '')}

${escapeHtml(response?.body ?? '')}

${escapeHtml(response?.note ?? '')}

` } From add3b28d56bbb263e527d5a8efdfccd4fe143e51 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 16:35:40 +0900 Subject: [PATCH 09/19] test: the Reader DOM check pins a server replay's words, response states, clamped request path and the explanation arriving while it is open (Cycle 0059 pt2) --- extension/scripts/study-bootstrap-qa.mjs | 493 +++++++++++++++++++++++ 1 file changed, 493 insertions(+) diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index c2ab70c78..a0ec33cd1 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -3583,6 +3583,435 @@ async function check(page, options, collected) { target.evaluate((scrollTop) => { document.getElementById('test-output').scrollTop = scrollTop }, top) + // Arrange: a server replay's Study message and Application document, worded with the server catalog. + const serverReplayHash = 'b'.repeat(64) + const serverTitle = + 'How the server creates an item for POST /api/items when the name is long enough to wrap and clamp in the heading of the Learning summary' + const serverEntries = [ + { + type: 'result', + sequence: 1, + elapsedMs: 10, + exitCode: null, + signal: null, + outputTail: '', + }, + { + type: 'stop', + sequence: 3, + index: 0, + elapsedMs: 50, + source: { + path: 'src/handler.ts', + hash: serverReplayHash, + line: 2, + column: 1, + }, + frame: { name: 'handler', line: 2, column: 1 }, + facts: [], + debuggerSnapshot: { + frames: [ + { + line: 2, + column: 1, + source: { + name: 'src/handler.ts', + hash: serverReplayHash, + line: 2, + }, + }, + ], + variables: [], + }, + }, + { + type: 'result', + sequence: 4, + elapsedMs: 90, + exitCode: null, + signal: null, + outputTail: '', + }, + ] + const serverEvents = [ + { sequence: 1, heading: 'Request · 0.01 s', body: 'POST /api/items' }, + { + sequence: 3, + heading: 'Stop 1 · src/handler.ts:2:1 · 0.05 s', + body: 'handler\nvalue:id: 7', + }, + { + sequence: 4, + heading: 'Response 201 · application/json · 0.09 s', + body: '{"id":7}', + }, + ] + const serverSection = { + status: 'Response 201 · capture complete · 0 of 1 stops explained', + pointer: + 'Request, response and output up to this moment are in the Application pane.', + applicationClosed: false, + sameMomentStepIds: [], + stepActions: { stop: 'Inspect Stop 1 · 0.05 s' }, + events: 'Events (3)', + verdict: 'Response 201', + position: 'Stop 1 · 0.05 s', + explanation: 'No saved explanation for this stop.', + previousStop: false, + nextStop: false, + } + const serverLearning = { + content: { + question: 'How does the server create an item?', + title: serverTitle, + summary: 'The handler creates the item and answers.', + participants: [{ id: 'server', label: 'Handler' }], + steps: [ + { + id: 'stop', + from: 'server', + to: 'server', + label: 'Stop', + explanation: 'The handler stops at its first line.', + evidence: 'observed', + reference: { + type: 'stop', + sequence: 3, + sourceHash: serverReplayHash, + }, + }, + ], + }, + positions: [{ stepId: 'stop', sequence: 3 }], + } + const serverReplayMessage = (withLearning, decision) => ({ + ...nodeReader, + nodeReplayProfile: 'node-server', + nodeHistoryEventCount: 3, + nodeHistory: { + header: { studyId: 'server-study' }, + status: 'complete', + entries: serverEntries, + }, + nodeServerEvents: serverEvents, + nodeServerHistoryCatalog: [ + { + studyId: 'server-study', + title: serverTitle, + question: 'How does the server create an item?', + }, + ], + ...(withLearning ? { nodeHistoryLearningSummary: serverLearning } : {}), + ...(decision + ? { + nodeHistoryLearningDecision: { + understood: true, + at: '2026-10-28T00:00:00.000Z', + }, + } + : {}), + nodeHistoryPosition: { + sequence: 3, + eventIndex: 1, + sourceAvailable: true, + debuggerAvailable: true, + }, + nodeHistorySection: serverSection, + nodeHistoryPlayback: { + mode: 'paused', + sequence: 3, + positionMs: 50, + startMs: 10, + endMs: 90, + loading: false, + firstSequence: 1, + lastSequence: 4, + }, + }) + /** What the Study pane shows of a server replay: its fixed words, the replay's transport names, its event headings and every spoken word (accessible name, tooltip, value text, status region) that names a test. */ + const readServerStudy = () => + page.evaluate(() => { + const element = (id) => document.getElementById(id) + const spoken = Array.from( + document.querySelectorAll('#node-history, #recording-transport'), + ).flatMap((region) => + [region, ...region.querySelectorAll('*')].flatMap((node) => [ + node.getAttribute('aria-label'), + node.getAttribute('title'), + node.getAttribute('aria-valuetext'), + node.matches('[role=status],[aria-live]') + ? node.textContent + : null, + ]), + ) + return { + heading: element('node-history-heading').textContent, + title: element('title').textContent, + understanding: element('node-history-understanding-label') + .textContent, + understandingNote: element('node-history-understanding-state') + .textContent, + understood: element('node-history-understood').checked, + sequenceLabel: element('node-learning-sequence').getAttribute( + 'aria-label', + ), + noMaterial: element('node-history-no-material').hidden + ? 'hidden' + : element('node-history-no-material').textContent, + learning: element('node-history-learning').hidden + ? 'hidden' + : 'shown', + transportStatus: element('recording-status').textContent, + playLabel: element('playback-toggle').getAttribute('aria-label'), + valueText: element('playhead').getAttribute('aria-valuetext'), + readout: element('playback-position').textContent, + playhead: element('playhead').value, + shortcutContext: element('playhead').title.includes( + 'Focused server replay timeline', + ), + events: Array.from( + document.querySelectorAll('#node-history-events h3'), + (heading) => heading.textContent, + ), + focused: document.activeElement?.id, + strayTestWords: spoken.filter( + (word) => typeof word === 'string' && /\btest\b/i.test(word), + ), + } + }) + await publish(serverReplayMessage(false, true)) + await page.locator('#playhead').focus() + const serverBeforeExplanation = await readServerStudy() + // Act: the chat's explanation arrives while the replay is open. + await publish(serverReplayMessage(true, true)) + const serverAfterExplanation = await readServerStudy() + await page.setViewportSize({ width: 320, height: 720 }) + const serverTitleLines = await renderedLines(page, '#node-learning-title') + await page.setViewportSize({ width: 600, height: 800 }) + // Act: the Application document at each response state, in a page of its own. + const serverDocument = await openApplication( + options.html.serverApplication, + ) + const readServerDocument = () => + serverDocument.evaluate(() => { + const element = (id) => document.getElementById(id) + const stray = [...document.querySelectorAll('*')].flatMap((node) => [ + node.getAttribute('aria-label'), + node.getAttribute('title'), + node.tagName === 'TITLE' || + node.classList.contains('test-output-footer') + ? node.textContent + : null, + node.tagName === 'BUTTON' ? node.textContent : null, + ]) + return { + requestLine: element('test-output-request-line').textContent, + claim: element('test-output-request-claim').textContent, + state: element('test-output-response-state').textContent, + body: element('test-output-response-body').hidden + ? 'hidden' + : element('test-output-response-body').textContent, + note: element('test-output-response-note').hidden + ? 'hidden' + : element('test-output-response-note').textContent, + end: element('test-output-end').hidden + ? 'hidden' + : element('test-output-end').textContent, + footer: document.querySelector('.test-output-footer').textContent, + jump: element('test-output-jump').textContent, + testHeaderLine: Boolean( + document.getElementById('test-output-test'), + ), + strayTestWords: stray.filter( + (word) => typeof word === 'string' && /\btest\b/i.test(word), + ), + } + }) + const serverStates = {} + serverStates.beforeResponse = await readServerDocument() + for (const [name, key, generation] of [ + ['withResponse', 'withResponse', 2], + ['emptyBody', 'emptyBody', 3], + ['cutBody', 'cutBody', 4], + ['failed', 'failed', 5], + ['notSent', 'notSent', 6], + ['neverAnswered', 'neverAnswered', 7], + ]) { + await postToDocument(serverDocument, { + type: 'node-history-output', + generation, + view: options.serverReplayViews[key], + }) + await settleFrames(serverDocument) + serverStates[name] = await readServerDocument() + } + await serverDocument.setViewportSize({ width: 320, height: 360 }) + await postToDocument(serverDocument, { + type: 'node-history-output', + generation: 8, + view: { ...options.serverReplayViews.withResponse, generation: 8 }, + }) + await settleFrames(serverDocument) + const serverRequestLines = await renderedLines( + serverDocument, + '#test-output-request-line', + ) + const serverRequestTitle = await serverDocument.evaluate(() => ({ + title: document.getElementById('test-output-request-line').title, + tabStop: document + .getElementById('test-output-request-line') + .getAttribute('tabindex'), + pageScrolls: + document.documentElement.scrollWidth > + document.documentElement.clientWidth, + })) + await serverDocument.close() + // Assert + // Value: protects=a server replay reads in the server catalog on every surface a learner or a screen reader meets: the heading, the understanding label and note, the sequence's group name, the transport's status, Play label, slider name and readout, the shortcut context and the event rows, with no spoken word that names a test; the response region's texts at each state (before the response, with a body, empty, cut, failed, never answered), the request claim (`Request sent by Codebook`, `Request not sent: `), a long request path clamped to two lines with its whole text as tooltip and a tab stop, a long title clamped; and the explanation arriving while the replay is open keeps the playhead, the focus and the understanding; fails_when=any of those words reads as the test replay's, a response state is worded otherwise, the long path overflows the pane or loses its tooltip, or the arriving explanation resets the playhead, the focus or the understanding; why_new=Cycle 0059 task 8 (items 8 and 8a): the Study pane gained a server profile; seam=none; no_claim=the host's projection of these messages (studyWorkspaceServerReplay.test.ts) and VS Code's webview + cases.push({ + name: 'a server replay reads in server words on the Study pane and the Application document: names, response states, clamped path and title, and an explanation that arrives while it is open keeps the playhead, the focus and the understanding (Cycle 0059 task 8)', + actual: { + serverBeforeExplanation, + serverAfterExplanation, + serverTitleLines, + serverStates, + serverRequestLines, + serverRequestTitle, + }, + passed: + JSON.stringify({ + heading: serverAfterExplanation.heading, + title: serverAfterExplanation.title, + understanding: serverAfterExplanation.understanding, + understandingNote: serverAfterExplanation.understandingNote, + sequenceLabel: serverAfterExplanation.sequenceLabel, + transportStatus: serverAfterExplanation.transportStatus, + playLabel: serverAfterExplanation.playLabel, + valueText: serverAfterExplanation.valueText, + readout: serverAfterExplanation.readout, + shortcutContext: serverAfterExplanation.shortcutContext, + events: serverAfterExplanation.events, + stray: [ + ...serverBeforeExplanation.strayTestWords, + ...serverAfterExplanation.strayTestWords, + ], + noMaterialBefore: serverBeforeExplanation.noMaterial, + learningBefore: serverBeforeExplanation.learning, + noMaterialAfter: serverAfterExplanation.noMaterial, + learningAfter: serverAfterExplanation.learning, + keptPlayhead: + serverBeforeExplanation.playhead === + serverAfterExplanation.playhead, + keptFocus: + serverBeforeExplanation.focused === 'playhead' && + serverAfterExplanation.focused === 'playhead', + keptUnderstanding: + serverBeforeExplanation.understood === true && + serverAfterExplanation.understood === true, + titleLines: serverTitleLines, + }) === + JSON.stringify({ + heading: 'Server replay', + title: + 'How the server creates an item for POST /api/items when the name is long enough to wrap and clamp in the heading of the Learning summary', + understanding: 'I understand this server history', + understandingNote: + 'Learner marked understood · 2026-10-28T00:00:00.000Z', + sequenceLabel: 'Server replay learning sequence', + transportStatus: 'Server replay paused', + playLabel: 'Play server replay', + valueText: '0.05 s of recorded server time', + readout: '0.05 s / 0.09 s', + shortcutContext: true, + events: [ + 'Request · 0.01 s', + 'Stop 1 · src/handler.ts:2:1 · 0.05 s', + 'Response 201 · application/json · 0.09 s', + ], + stray: [], + noMaterialBefore: + 'No learning summary was saved for this server replay. The chat can save one with codebook_explain_node_history.', + learningBefore: 'hidden', + noMaterialAfter: 'hidden', + learningAfter: 'shown', + keptPlayhead: true, + keptFocus: true, + keptUnderstanding: true, + titleLines: 2, + }) && + JSON.stringify( + Object.fromEntries( + Object.entries(serverStates).map(([name, state]) => [ + name, + [state.claim, state.state, state.body, state.note, state.end], + ]), + ), + ) === + JSON.stringify({ + beforeResponse: [ + 'Request sent by Codebook', + 'No response recorded yet at this moment.', + 'hidden', + 'hidden', + 'hidden', + ], + withResponse: [ + 'Request sent by Codebook', + 'Response 201 · application/json', + '{"id":7,"name":"pen"}', + 'hidden', + 'hidden', + ], + emptyBody: [ + 'Request sent by Codebook', + 'Response 204 · no content type', + 'hidden', + 'Response body is empty.', + 'hidden', + ], + cutBody: [ + 'Request sent by Codebook', + 'Response 200 · text/plain', + 'abc', + 'Body shown cut at 3 bytes.', + 'hidden', + ], + failed: [ + 'Request sent by Codebook', + 'Request failed: ECONNREFUSED', + 'hidden', + 'hidden', + 'hidden', + ], + notSent: [ + 'Request not sent: RUN_BUSY', + 'No response recorded yet at this moment.', + 'hidden', + 'hidden', + 'hidden', + ], + neverAnswered: [ + 'Request sent by Codebook', + 'No response was recorded: the history ended before the server answered.', + 'hidden', + 'hidden', + 'Recording ended before the server answered.', + ], + }) && + Object.values(serverStates).every( + (state) => + state.strayTestWords.length === 0 && + state.testHeaderLine === false && + state.footer === + 'Historical server request and output. Reading it does not resend the request or restart the server.' && + state.jump === 'Jump to latest', + ) && + serverRequestLines === 2 && + serverRequestTitle.title === + options.serverReplayViews.withResponse.request.line && + serverRequestTitle.tabStop === '0' && + serverRequestTitle.pageScrolls === false, + }) const resultView = options.testReplayViews.result // Value: protects=a document written for a replay opens where the host says the learner was, and ignores state an earlier document left in the webview: a stored choice not to follow does not outlive the host's choice to follow (F151), a choice not to follow with a scroll anchor opens at that line with Jump to latest, and the learner's place is found again by the line's number and offset (F120, F138); fails_when=the document reads the webview state (it opens at the top of the output for a replay the learner left following), drops the anchor (it opens at the oldest kept line), or follows the output although the host kept the learner reading higher up; why_new=D22 moves the follow choice and the scroll anchor from the webview's state to the host, which passes them in the document's options; seam=none; no_claim=how the host files the reading (the workspace tests), VS Code's own webview state, which the stand-in plays // Act: three documents of the same replay at its result: the host's reading is following (with stale webview state saying otherwise), not following at line 20 and 7 px, and the reading a scroll in a first document reported. @@ -11781,6 +12210,65 @@ const testReplayViews = { }, } +// A server replay in the Application pane: the request and response regions and the output log, worded as projectServerApplication words them; the path is long enough to wrap and clamp. +const serverLongPath = `/api/items/${'segment-'.repeat(40)}details` +const serverRequest = { + line: `GET ${serverLongPath}`, + claim: 'Request sent by Codebook', +} +const serverView = (generation, fields) => ({ + profile: 'node-server', + generation, + time: 'Recorded server output · 0.05 s', + test: '', + name: `Server replay · GET ${serverLongPath}`, + region: 'Recorded server output up to 0.05 s', + lines: [{ channel: 'stdout', text: 'listening on :4100', number: 0 }], + request: serverRequest, + ...fields, +}) +const serverReplayViews = { + beforeResponse: serverView(1, { + response: { state: 'No response recorded yet at this moment.' }, + }), + withResponse: serverView(2, { + response: { + state: 'Response 201 · application/json', + body: '{"id":7,"name":"pen"}', + }, + }), + emptyBody: serverView(3, { + response: { + state: 'Response 204 · no content type', + note: 'Response body is empty.', + }, + }), + cutBody: serverView(4, { + response: { + state: 'Response 200 · text/plain', + body: 'abc', + note: 'Body shown cut at 3 bytes.', + }, + }), + failed: serverView(5, { + response: { state: 'Request failed: ECONNREFUSED' }, + }), + notSent: serverView(6, { + request: { + line: 'POST /api/items', + claim: 'Request not sent: RUN_BUSY', + }, + response: { state: 'No response recorded yet at this moment.' }, + }), + neverAnswered: serverView(7, { + response: { + state: + 'No response was recorded: the history ended before the server answered.', + }, + end: 'Recording ended before the server answered.', + }), +} + const report = { mode, qualification: @@ -11814,8 +12302,13 @@ try { ...message, nodeHistoryApplication: testReplayViews.result, }), + serverApplication: capturedAppHtml({ + ...message, + nodeHistoryApplication: serverReplayViews.beforeResponse, + }), }, testReplayViews, + serverReplayViews, // With LF line endings, as the publisher writes a page and a browser hashes its script, also from a checkout that holds the shell with CRLF. wikiShell: wikiShellPage( ( From 939b1267c453b5856de8d1e785b9f7790c846afa Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 16:36:24 +0900 Subject: [PATCH 10/19] test: record the measured timings of the server replay workspace tests (Cycle 0059 pt2) --- extension/test-timings.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/extension/test-timings.json b/extension/test-timings.json index 4cc47e01e..6c9c04d66 100644 --- a/extension/test-timings.json +++ b/extension/test-timings.json @@ -3559,6 +3559,27 @@ "studyWorkspaceServerLibrary.test.js": { "totalMs": 712 }, + "studyWorkspaceServerReplay.test.js": { + "totalMs": 8428, + "tests": { + "Next and Previous stop visit each hit of a repeated stop and Next event moves on to the output and the response": 1574, + "Open replay of a sealed server history lands on its first stop with the stored source in the slot and the snapshot in the debugger": 345, + "a non-stop moment clears the debugger and says source and debugger are not recorded at this moment": 593, + "a saved playhead on a source entry restores the request before it": 296, + "a server history that is still being recorded opens only once its recording is interrupted": 260, + "a server history whose journal is damaged opens its verified prefix as an interrupted replay with its reason": 257, + "a server history without a stop opens at its request and Next stop answers that there is no next stop in this server replay": 326, + "a step citing the response moves the playhead there, and the explanation arriving while the replay is open keeps the playhead": 655, + "a stop with a lower frame presents it with its unavailable source reason": 275, + "an explanation saved from another window shows in this window as Saved and in the replay it opens": 293, + "moving through a server replay sends no request and starts no server": 658, + "opening a server replay and then a browser recording while a test replay presentation is still loading leaves only the recording shown": 860, + "seeking to the response and back to the stop presents the same snapshot and source and clears the response": 653, + "the Application pane of a server replay shows the sent request and no response yet at the first stop": 281, + "two hits at one time keep journal order and the slider ends at the end entry after the response": 291, + "understanding is saved only by the checkbox, and the playhead and understanding return when the replay is opened again": 811 + } + }, "studyWorkspaceStorage.test.js": { "totalMs": 15018, "tests": { From c56bafa641524e0ed278ae3143e38d040fe29941 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 16:44:58 +0900 Subject: [PATCH 11/19] test: the server replay presentation maps events, strips terminal controls and words its reasons, and a stop whose source was too large says why; a bidirectional control in a doc example is written as an escape (Cycle 0059 pt2) --- .../tests/serverReplayPresentation.test.ts | 171 ++++++++++++++++++ .../tests/studyWorkspaceServerReplay.test.ts | 53 +++++- extension/src/utils/adaptServerReplay.ts | 2 +- extension/src/utils/presentServerHistory.ts | 1 + extension/test-timings.json | 38 ++-- 5 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 extension/src/tests/serverReplayPresentation.test.ts diff --git a/extension/src/tests/serverReplayPresentation.test.ts b/extension/src/tests/serverReplayPresentation.test.ts new file mode 100644 index 000000000..a73c6612f --- /dev/null +++ b/extension/src/tests/serverReplayPresentation.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import { presentServerHistory } from '../utils/presentServerHistory' +import { projectServerSection } from '../utils/projectServerSection' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, + serverStopCapture, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +/** Reads a fixture history back through the store and presents it as the workspace does. */ +async function presentationOf( + context: Parameters[0], + options: Parameters[1] = {}, +) { + const { repository } = await openServerRepository(context) + const { header, sequences } = await writeServerHistory(repository, options) + const history = await repository.serverHistories.read(header.studyId) + return { + replay: presentServerHistory(history, undefined), + sequences, + } +} + +// protects=the presentation maps learner events onto the workspace's entry types in journal order, with output numbered apart and every non-stop moment a result-shaped entry; fails_when=a source or send-mark entry becomes an event, or the stop loses its position; no_claim=the workspace's selection order +test('the presentation lists the request, stop, output and response as shared-path entries and skips storage-only entries', async (context) => { + // Arrange / Act + const { replay } = await presentationOf(context, { output: ['created\n'] }) + + // Assert + assert.deepEqual( + replay.history.entries.map((entry) => [entry.sequence, entry.type]), + [ + [1, 'result'], + [4, 'stop'], + [5, 'output'], + [6, 'result'], + ], + ) + assert.equal(replay.history.status, 'complete') + assert.equal(replay.history.header.configuration.testFile, 'src/handler.ts') +}) + +// protects=a right-to-left override in a recorded output line or response body never reaches what the surfaces print, while the stored model the host verifies against keeps the recorded text; fails_when=a control survives in the shown copy, or the stored copy is changed; no_claim=how the pane prints the text +test('the shown copy of a server replay loses terminal and bidirectional controls and the stored copy keeps them', async (context) => { + // Arrange / Act + const { replay } = await presentationOf(context, { + output: ['o\u202Ek\n'], + response: { status: 200, body: '{"a":"\u202Eb"}' }, + }) + const texts = (model: typeof replay.model) => + model.events.flatMap((event) => + event.type === 'output' + ? [event.text] + : event.type === 'response' + ? [event.body] + : [], + ) + + // Assert + assert.deepEqual(texts(replay.shownModel), ['ok\n', '{"a":"b"}']) + assert.deepEqual(texts(replay.model), ['o\u202Ek\n', '{"a":"\u202Eb"}']) +}) + +// protects=the capture-reason line of a replay lists every reason in the replay's words and never names a Node server history; fails_when=a reason is dropped, cut to the first, or still says Node server history; no_claim=the library row's first-reason cut +test('an interrupted server replay words its capture reason as a server replay', async (context) => { + // Arrange / Act + const { replay } = await presentationOf(context, { seal: false }) + + // Assert + assert.equal(replay.model.status, 'interrupted') + assert.deepEqual(replay.captureReasons, [ + 'Server replay has no verified final seal.', + ]) +}) + +// protects=the Study section words a server replay's moments (request, stop with its hit, response) with the server catalog and counts the stops the chat explained; fails_when=a locator reads like a test replay's, a repeated stop is not told apart, or the status does not name the answer; no_claim=the pane's drawing +test('the server section words the request, a repeated stop and the response with server words', async (context) => { + // Arrange + const { replay, sequences } = await presentationOf(context, { + hits: [ + [0, 1], + [0, 2], + ], + }) + const at = (sequence: number) => + replay.shownModel.events[replay.shownModel.indexBySequence.get(sequence)!] + const section = (sequence: number) => + projectServerSection({ + replay, + selected: at(sequence), + selectedStopVerified: true, + applicationClosed: false, + }) + + // Act + const request = section(sequences.request) + const secondHit = section(sequences.stops[1]!) + const response = section(sequences.response!) + + // Assert + assert.equal(request.position, 'Request · 0.00 s') + assert.equal(secondHit.position, 'Stop 1 · hit 2 · 0.02 s') + assert.equal(response.position, 'Response 201 · 0.03 s') + assert.equal( + response.status, + 'Response 201 · capture complete · 0 of 2 stops explained', + ) + assert.equal( + request.notRecorded, + 'Source and debugger not recorded at this moment.', + ) + assert.equal(response.explanation, 'No saved explanation for the response.') + assert.equal(secondHit.explanation, 'No saved explanation for this stop.') + assert.equal(secondHit.notRecorded, undefined) +}) + +// protects=a history that ended with no answer says so in its status and a failed request names its code; fails_when=the status claims a response, or the failure code is missing; no_claim=the library row's words +test('the server section says no response was recorded and names a failed request code', async (context) => { + // Arrange + const unanswered = await presentationOf(context, { response: null }) + 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: header.declaredRequest.path, + bodyBytes: 1, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.appendStop( + serverStopCapture({ declaredStopIndex: 0, hit: 1, line: 2, elapsedMs: 3 }), + ) + await writer.appendRequestFailed({ + elapsedMs: 4, + code: 'ECONNRESET', + message: 'The connection was reset.', + }) + await writer.seal({ elapsedMs: 5, reason: 'stopped' }) + const failed = presentServerHistory( + await repository.serverHistories.read(header.studyId), + undefined, + ) + + // Act + const unansweredStatus = projectServerSection({ + replay: unanswered.replay, + selectedStopVerified: false, + applicationClosed: false, + }).status + const failedStatus = projectServerSection({ + replay: failed, + selectedStopVerified: false, + applicationClosed: false, + }).status + + // Assert + assert.match(unansweredStatus, /^No response recorded · capture partial · /) + assert.match(failedStatus, /^Request failed: ECONNRESET · capture /) +}) diff --git a/extension/src/tests/studyWorkspaceServerReplay.test.ts b/extension/src/tests/studyWorkspaceServerReplay.test.ts index 7695edcec..24c60de2e 100644 --- a/extension/src/tests/studyWorkspaceServerReplay.test.ts +++ b/extension/src/tests/studyWorkspaceServerReplay.test.ts @@ -5,7 +5,10 @@ import http from 'node:http' import { join } from 'node:path' import { before, test } from 'node:test' -import { NODE_SERVER_HISTORY_DIRECTORY } from '../constants' +import { + NODE_SERVER_HISTORY_DIRECTORY, + NODE_SERVER_SOURCE_TEXT_LIMIT_BYTES, +} from '../constants' import { HISTORY_JOURNAL_FILE } from '../nodeHistoryJournalWriter' import type { NodeHistoryRepository } from '../nodeHistoryRepository' import type { ReplayDebugSnapshot } from '../replayDebugSchemas' @@ -433,6 +436,54 @@ test('a stop with a lower frame presents it with its unavailable source reason', } }) +// protects=a stop whose executed source was too large to store opens as a stop with no recorded source: the debugger shows nothing for the moment and the Study section names the reason; fails_when=the stop presents another source or the section hides why the source is unavailable; no_claim=the 512 KiB bound itself (the writer's own tests) +test('a stop whose executed source was too large to store says its recorded source is unavailable and why', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const repository = repositoryOf(harness) + const header = nodeServerHistoryHeader(workspaceKeyOf(harness)) + const capture = serverStopCapture({ + declaredStopIndex: 0, + hit: 1, + line: 2, + elapsedMs: 10, + text: `// ${'x'.repeat(NODE_SERVER_SOURCE_TEXT_LIMIT_BYTES)}\n`, + }) + const writer = await repository.serverHistories.start(header) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: header.declaredRequest.path, + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 2, attempt: 1 }) + await writer.appendStop(capture) + await writer.seal({ elapsedMs: 40, reason: 'stopped' }) + + // Act + await openServerReplay(harness, header.studyId) + const section = lastView(harness)?.nodeHistorySection + + // Assert + assert.equal( + section?.unverified, + 'Recorded source unavailable for this stop (too large).', + ) + assert.equal( + section?.notRecorded, + 'Source and debugger not recorded at this moment.', + ) + assert.equal( + harness.historicalDebugPresentations.at(-1)?.snapshot, + undefined, + ) + } finally { + await harness.close() + } +}) + // protects=Next and Previous stop visit every recorded hit of a stop hit twice, and Next and Previous event move over the output and the response; fails_when=a hit is skipped, the second hit is not told apart as `Stop 1 · hit 2`, or an event move leaves the journal order; no_claim=Play's pacing test('Next and Previous stop visit each hit of a repeated stop and Next event moves on to the output and the response', async () => { // Arrange diff --git a/extension/src/utils/adaptServerReplay.ts b/extension/src/utils/adaptServerReplay.ts index 03b0a3733..410c2fdfa 100644 --- a/extension/src/utils/adaptServerReplay.ts +++ b/extension/src/utils/adaptServerReplay.ts @@ -36,7 +36,7 @@ export type ServerReplayPresentation = { * Removes terminal and bidirectional controls from every recorded text of a server replay that a surface prints, as the test replay does for its own: output is read as one stream per channel, and a request path, a response body, a failure code and a stop's path and frame name each lose them. Hashes, lines, columns, snapshots and counts are untouched. * @param model - The replay model as the store read it. * @returns A model whose events print safely; the input is not changed. - * @example sanitizeServerReplayModel(model).events[1] // => { type: 'output', text: 'ok\n', … } for a recorded 'o‮k\n' + * @example sanitizeServerReplayModel(model).events[1] // => { type: 'output', text: 'ok\n', … } for a recorded 'o\u202Ek\n' */ function sanitizeServerReplayModel( model: ServerReplayModel, diff --git a/extension/src/utils/presentServerHistory.ts b/extension/src/utils/presentServerHistory.ts index 77186527a..a2238e185 100644 --- a/extension/src/utils/presentServerHistory.ts +++ b/extension/src/utils/presentServerHistory.ts @@ -25,6 +25,7 @@ function wordServerCaptureReason(reason: string): string { ) .replace(/\s+/g, ' ') .trim() + .replace(/^./, (first) => first.toUpperCase()) } /** diff --git a/extension/test-timings.json b/extension/test-timings.json index 6c9c04d66..ef4493988 100644 --- a/extension/test-timings.json +++ b/extension/test-timings.json @@ -1823,6 +1823,9 @@ "serverInspectorOwnership.test.js": { "totalMs": 140 }, + "serverReplayPresentation.test.js": { + "totalMs": 408 + }, "service.test.js": { "totalMs": 3052 }, @@ -3560,24 +3563,25 @@ "totalMs": 712 }, "studyWorkspaceServerReplay.test.js": { - "totalMs": 8428, + "totalMs": 8875, "tests": { - "Next and Previous stop visit each hit of a repeated stop and Next event moves on to the output and the response": 1574, - "Open replay of a sealed server history lands on its first stop with the stored source in the slot and the snapshot in the debugger": 345, - "a non-stop moment clears the debugger and says source and debugger are not recorded at this moment": 593, - "a saved playhead on a source entry restores the request before it": 296, - "a server history that is still being recorded opens only once its recording is interrupted": 260, - "a server history whose journal is damaged opens its verified prefix as an interrupted replay with its reason": 257, - "a server history without a stop opens at its request and Next stop answers that there is no next stop in this server replay": 326, - "a step citing the response moves the playhead there, and the explanation arriving while the replay is open keeps the playhead": 655, - "a stop with a lower frame presents it with its unavailable source reason": 275, - "an explanation saved from another window shows in this window as Saved and in the replay it opens": 293, - "moving through a server replay sends no request and starts no server": 658, - "opening a server replay and then a browser recording while a test replay presentation is still loading leaves only the recording shown": 860, - "seeking to the response and back to the stop presents the same snapshot and source and clears the response": 653, - "the Application pane of a server replay shows the sent request and no response yet at the first stop": 281, - "two hits at one time keep journal order and the slider ends at the end entry after the response": 291, - "understanding is saved only by the checkbox, and the playhead and understanding return when the replay is opened again": 811 + "Next and Previous stop visit each hit of a repeated stop and Next event moves on to the output and the response": 1547, + "Open replay of a sealed server history lands on its first stop with the stored source in the slot and the snapshot in the debugger": 335, + "a non-stop moment clears the debugger and says source and debugger are not recorded at this moment": 594, + "a saved playhead on a source entry restores the request before it": 318, + "a server history that is still being recorded opens only once its recording is interrupted": 279, + "a server history whose journal is damaged opens its verified prefix as an interrupted replay with its reason": 282, + "a server history without a stop opens at its request and Next stop answers that there is no next stop in this server replay": 321, + "a step citing the response moves the playhead there, and the explanation arriving while the replay is open keeps the playhead": 678, + "a stop whose executed source was too large to store says its recorded source is unavailable and why": 267, + "a stop with a lower frame presents it with its unavailable source reason": 272, + "an explanation saved from another window shows in this window as Saved and in the replay it opens": 374, + "moving through a server replay sends no request and starts no server": 652, + "opening a server replay and then a browser recording while a test replay presentation is still loading leaves only the recording shown": 907, + "seeking to the response and back to the stop presents the same snapshot and source and clears the response": 629, + "the Application pane of a server replay shows the sent request and no response yet at the first stop": 271, + "two hits at one time keep journal order and the slider ends at the end entry after the response": 320, + "understanding is saved only by the checkbox, and the playhead and understanding return when the replay is opened again": 829 } }, "studyWorkspaceStorage.test.js": { From 13d0798bdbf0b870c99eafe1fbb29d69b3bdf65d Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 17:08:29 +0900 Subject: [PATCH 12/19] fix: the server regions of the Application pane take an explicit padding, so the shared section padding no longer makes them needlessly tall (Cycle 0059 pt2) --- extension/src/ui/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/ui/constants.ts b/extension/src/ui/constants.ts index 16047c3de..45b82e1d2 100644 --- a/extension/src/ui/constants.ts +++ b/extension/src/ui/constants.ts @@ -297,7 +297,7 @@ body.moments-only #recording-transport{display:flex;flex-wrap:wrap;align-items:c /* The shared transport's event group wraps as one: it takes its own line before the slider shrinks below its minimum width (DS-C); the 5A button ends the status row, apart from the move controls. */ .playback-events{display:inline-flex;gap:6px;flex:none}.playback-status-row #show-learning-summary{flex:none;margin-left:auto;min-height:32px;padding:4px 8px} /* A test replay's Application pane: the header and footer stay pinned and the output region is the only scroller (DS4); a standard-error line starts with a mark in the terminal's red, and the mark, never the color alone, says so. */ -body.test-output{height:100vh;padding:0;overflow:hidden}body.test-output main{display:flex;flex-direction:column;height:100%;min-height:0;padding:12px 16px}#test-output-test,#test-output-request-line{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}#test-output-test:focus,#test-output-request-line:focus{display:block;overflow:visible}.server-region{flex:none;margin:4px 0}#test-output-response-body{max-height:30vh;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:4px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output-response-body:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}#test-output-response-state{overflow-wrap:anywhere}#test-output{flex:1;min-height:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}.stderr-mark{color:var(--vscode-terminal-ansiRed,#f14c4c)}#test-output-jump{align-self:flex-start}body.test-output p{margin:4px 0} +body.test-output{height:100vh;padding:0;overflow:hidden}body.test-output main{display:flex;flex-direction:column;height:100%;min-height:0;padding:12px 16px}#test-output-test,#test-output-request-line{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}#test-output-test:focus,#test-output-request-line:focus{display:block;overflow:visible}.server-region{flex:none;margin:4px 0;padding:8px 0}#test-output-response-body{max-height:30vh;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:4px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output-response-body:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}#test-output-response-state{overflow-wrap:anywhere}#test-output{flex:1;min-height:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}.stderr-mark{color:var(--vscode-terminal-ansiRed,#f14c4c)}#test-output-jump{align-self:flex-start}body.test-output p{margin:4px 0} /* A Learning summary in a narrow side bar keeps a long title to two lines and its sequence in view; the full title stays in its tooltip, and a title the clamp cuts is a tab stop that is shown whole while it has the keyboard focus. A wider summary shows the title whole. */ #title:focus-visible,#learning-title:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px} /* Compact replay keeps its empty header spacing; a disclosed layout notice uses the shared gap. */ From b99325d17d6beb2101a5ec8d0a3ac120f9ec0b1e Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 17:08:32 +0900 Subject: [PATCH 13/19] test: the server replay learning material fixture is typed from the schema, so no cast hides a wrong reference (Cycle 0059 pt2) --- .../src/tests/studyWorkspaceServerReplay.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/extension/src/tests/studyWorkspaceServerReplay.test.ts b/extension/src/tests/studyWorkspaceServerReplay.test.ts index 24c60de2e..9ef960162 100644 --- a/extension/src/tests/studyWorkspaceServerReplay.test.ts +++ b/extension/src/tests/studyWorkspaceServerReplay.test.ts @@ -11,6 +11,7 @@ import { } from '../constants' import { HISTORY_JOURNAL_FILE } from '../nodeHistoryJournalWriter' import type { NodeHistoryRepository } from '../nodeHistoryRepository' +import type { NodeServerLearningMaterialContent } from '../nodeServerLearningSchemas' import type { ReplayDebugSnapshot } from '../replayDebugSchemas' import { sha256 } from '../runtimeSupport/utils/sha256' import { waitUntil } from '../runtimeSupport/utils/waitUntil' @@ -35,6 +36,10 @@ before(async () => { await realProcessStart(process.pid) }) +/** One step reference of a server history's learning material, as its schema defines it. */ +type NodeServerLearningStepReference = + NodeServerLearningMaterialContent['steps'][number]['reference'] & {} + type Harness = Awaited> /** The workspace's history repository, over the learning directory the fixture writer shares. */ @@ -150,11 +155,11 @@ function serverMaterial(sequences: { stops: number[] output: number[] response?: number -}) { +}): NodeServerLearningMaterialContent { const step = ( id: string, explanation: string, - reference: Record, + reference: NodeServerLearningStepReference, ) => ({ id, from: 'server', @@ -176,15 +181,15 @@ function serverMaterial(sequences: { }), step('stop', 'The handler stops at its first line.', { type: 'stop', - sequence: sequences.stops[0], + sequence: sequences.stops[0]!, sourceHash: sha256(HANDLER_SOURCE_TEXT), }), step('response', 'The server answers with the item.', { type: 'response', - sequence: sequences.response, + sequence: sequences.response!, }), ], - } as never + } } // protects=a sealed server history opens from the library on its first recorded stop with the executed source stored in the history and its snapshot in the debugger, worded with the server catalog; fails_when=the replay opens on the request, shows the test catalog, or the slot and debugger lack the stored source; no_claim=the native editor's drawing of the slot From 795a6d4a6844bfd7b4f7de9cf6b78061c59f78d6 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 17:08:40 +0900 Subject: [PATCH 14/19] fix: an unreadable profile folder no longer hides the other profile's replays, a server study's learning material is saved in the server store, and a test history's event-detail routes answer as unknown resources (Cycle 0059 pt2) --- extension/src/nodeHistoryRepository.ts | 28 +++- extension/src/resources.ts | 3 + extension/src/service.ts | 21 +++ .../serverHistoryRoutingFindings.test.ts | 121 ++++++++++++++++++ 4 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 extension/src/tests/serverHistoryRoutingFindings.test.ts diff --git a/extension/src/nodeHistoryRepository.ts b/extension/src/nodeHistoryRepository.ts index 67209b454..c502354a9 100644 --- a/extension/src/nodeHistoryRepository.ts +++ b/extension/src/nodeHistoryRepository.ts @@ -62,8 +62,8 @@ export class NodeHistoryRepository { /** * Lists every saved history of the workspace, test and server, newest first, as one catalog for Saved studies. - * @returns The summaries of both profiles; a profile whose folder cannot be listed rejects the whole listing, so the library keeps its earlier catalog and reports the failure as it does for test histories. - * @throws What a store's listing throws for an unreadable history directory. + * @returns The summaries of the profiles that could be listed; a profile whose folder cannot be listed adds nothing and never hides the other. + * @throws What the first store's listing throws when no profile could be listed. * @example (await repository.list()).map((summary) => summary.profile) // => ['node-server', 'node-test'] */ async list(): Promise { @@ -77,19 +77,33 @@ export class NodeHistoryRepository { /** * Lists the saved histories of each profile apart, for the library, which keeps one catalog per profile. - * @returns The test summaries and the server summaries. - * @throws What a store's listing throws for an unreadable history directory. - * @example const { tests, servers } = await repository.listByProfile() + * @returns The test summaries and the server summaries, and the profiles whose folder could not be listed with the failure; such a profile lists empty and only its own histories are unavailable, as an unreadable Node history store never blocks the rest. + * @throws What the first store's listing throws when neither profile could be listed, so the library keeps its earlier catalog. + * @example const { tests, servers, unavailable } = await repository.listByProfile() */ async listByProfile(): Promise<{ tests: NodeTestHistorySummary[] servers: NodeServerHistorySummary[] + unavailable: Array<{ profile: 'node-test' | 'node-server'; error: unknown }> }> { - const [tests, servers] = await Promise.all([ + const [tests, servers] = await Promise.allSettled([ this.testHistories.list(), this.serverHistories.list(), ]) - return { tests, servers } + if (tests.status === 'rejected' && servers.status === 'rejected') + throw tests.reason + return { + tests: tests.status === 'fulfilled' ? tests.value : [], + servers: servers.status === 'fulfilled' ? servers.value : [], + unavailable: [ + ...(tests.status === 'rejected' + ? [{ profile: 'node-test' as const, error: tests.reason }] + : []), + ...(servers.status === 'rejected' + ? [{ profile: 'node-server' as const, error: servers.reason }] + : []), + ], + } } /** diff --git a/extension/src/resources.ts b/extension/src/resources.ts index 7b1350b02..241e07b86 100644 --- a/extension/src/resources.ts +++ b/extension/src/resources.ts @@ -164,6 +164,9 @@ export async function readResource( studyId, nodeHistoryMatch[2], ) + // A test history has no per-event routes: they answer as any unknown resource. + if (/^events\/(?:through\/)?\d+/.test(nodeHistoryMatch[2] ?? '')) + throw new StudyError('NOT_FOUND', 'Unknown resource.') // An unknown id, or a history removed while or right after it is read, is a reference to correct, not a failed study operation (Cycle 0052 tasks 60, 75, 95); each read below is checked the same way. const history = await withKnownNodeHistory(store, studyId, () => store.read(studyId), diff --git a/extension/src/service.ts b/extension/src/service.ts index fb64736a2..cd36fa46e 100644 --- a/extension/src/service.ts +++ b/extension/src/service.ts @@ -61,6 +61,7 @@ import { nodeServerConfigSchema, type NodeServerConfiguration, } from './nodeServerConfigSchemas' +import { nodeServerLearningMaterialContentSchema } from './nodeServerLearningSchemas' import { describeNodeServerProfile } from './nodeServerProfile' import type { NodeTestConfiguration } from './nodeTestConfigSchemas' import type { NodeTestLearningMaterialContent } from './nodeTestHistorySchemas' @@ -2190,8 +2191,28 @@ export class StudyService { 'Workspace-bound Node history storage is unavailable.', ) const store = this.nodeHistoryStore + // A server history keeps its material in the server store, resolved by the study id's folder. + const resolved = this.nodeHistoryRepository?.resolve(parsed.studyId) try { // An unknown id, or a history removed while it is saved, is a reference to correct, as at the history's resources (Cycle 0052 tasks 60, 75). + if (resolved?.profile === 'node-server') { + const { store: serverStore } = resolved + const serverMaterial = await withKnownNodeHistory( + serverStore, + parsed.studyId, + () => + serverStore.saveLearningMaterial( + parsed.studyId, + parsed.expectedRevision, + nodeServerLearningMaterialContentSchema.parse(parsed.content), + ), + ) + this.presentationController?.nodeHistoryLearningMaterialSaved( + parsed.studyId, + serverMaterial, + ) + return serverMaterial + } const material = await withKnownNodeHistory(store, parsed.studyId, () => store.saveLearningMaterial( parsed.studyId, diff --git a/extension/src/tests/serverHistoryRoutingFindings.test.ts b/extension/src/tests/serverHistoryRoutingFindings.test.ts new file mode 100644 index 000000000..f05fafb84 --- /dev/null +++ b/extension/src/tests/serverHistoryRoutingFindings.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { before, test } from 'node:test' + +import { + NODE_SERVER_HISTORY_DIRECTORY, + NODE_TEST_HISTORY_DIRECTORY, +} from '../constants' +import { StudyError } from '../errors' +import { readResource } from '../resources' +import { sha256 } from '../runtimeSupport/utils/sha256' + +import { createHarness } from './helpers' +import { + openServerRepository, + HANDLER_SOURCE_TEXT, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' +import { saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +// protects=one unreadable profile folder leaves the other profile listed and names only its own profile as unavailable; fails_when=the listing rejects and hides the readable profile; no_claim=the library's reporting of the failure +test('an unreadable server history folder leaves the test histories listed and marks only the server profile unavailable', async (context) => { + // Arrange + const { repository, learningDirectory } = await openServerRepository(context) + await mkdir(learningDirectory, { recursive: true }) + // A regular file where the server folder belongs cannot be listed. + await writeFile(join(learningDirectory, NODE_SERVER_HISTORY_DIRECTORY), 'x') + await mkdir(join(learningDirectory, NODE_TEST_HISTORY_DIRECTORY), { + recursive: true, + }) + + // Act + const listing = await repository.listByProfile() + + // Assert + assert.deepEqual(listing.servers, []) + assert.deepEqual( + listing.unavailable.map((profile) => profile.profile), + ['node-server'], + ) +}) + +// protects=a server study id sent through the Study pane's material message is saved in the server store; fails_when=the save goes to the test store and answers not found; no_claim=the material's rendering +test('learning material saved for a server study id is kept with the server history', async (context) => { + // Arrange + const harness = await createHarness() + context.after(() => harness.close()) + const { repository } = await openServerRepository(context) + const { header, sequences } = await writeServerHistory(repository) + harness.service.nodeHistoryStore = repository.testHistories + harness.service.nodeHistoryRepository = repository + + // Act + const material = await harness.service.explainNodeHistory({ + studyId: header.studyId, + expectedRevision: 0, + content: { + question: 'How does the server create an item?', + title: 'Creating an item', + summary: 'The handler stops once.', + participants: [{ id: 'server', label: 'Handler' }], + steps: [ + { + id: 'stop', + from: 'server', + to: 'server', + label: 'Stop', + explanation: 'The handler stops at its first line.', + evidence: 'observed', + reference: { + type: 'stop', + sequence: sequences.stops[0]!, + sourceHash: sha256(HANDLER_SOURCE_TEXT), + }, + }, + ], + }, + }) + + // Assert + assert.equal(material.revision, 1) + assert.equal( + (await repository.serverHistories.readLearningMaterial(header.studyId)) + ?.revision, + 1, + ) +}) + +// protects=an event-detail URI of a test history answers the not-found shape the resources use for an unknown resource; fails_when=it answers an invalid-cursor error; no_claim=server histories' own event detail +test('an event detail URI of a test history is answered as an unknown resource', async (context) => { + // Arrange + const harness = await createHarness() + context.after(() => harness.close()) + const { repository } = await openServerRepository(context) + harness.service.nodeHistoryStore = repository.testHistories + harness.service.nodeHistoryRepository = repository + const { studyId } = await saveTwoStopNodeHistory(repository.testHistories) + + // Act + const refusals = await Promise.all( + [`events/3`, `events/through/3`].map((suffix) => + readResource( + harness.service, + `codebook://node-history/${studyId}/${suffix}`, + ).catch((error: unknown) => error), + ), + ) + + // Assert + for (const refusal of refusals) { + assert.ok(refusal instanceof StudyError) + assert.equal(refusal.code, 'NOT_FOUND') + assert.equal(refusal.message, 'Unknown resource.') + } +}) From 06089b7f4361665784eb9b2326bf03213b340781 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 17:08:43 +0900 Subject: [PATCH 15/19] fix: a server replay's open failure, seek refusal and unresolved ids use server words and never the test store; the request region shows no request before its entry; the response region shows only a no-response reason; the body cut note needs a real cut; the pane name strips controls; an unreadable profile keeps its earlier catalog (Cycle 0059 pt2) --- extension/src/studyWorkspace.ts | 61 ++++-- .../serverReplayApplicationFindings.test.ts | 174 ++++++++++++++++++ ...studyWorkspaceServerReplayFindings.test.ts | 164 +++++++++++++++++ extension/src/utils/adaptServerReplay.ts | 5 + .../src/utils/describeNodeLearningSave.ts | 10 +- .../src/utils/describeNodeServerReplay.ts | 2 + .../utils/nodeHistoryPlaybackController.ts | 6 +- extension/src/utils/presentServerHistory.ts | 30 ++- .../src/utils/projectServerApplication.ts | 26 ++- extension/test-timings.json | 9 + 10 files changed, 456 insertions(+), 31 deletions(-) create mode 100644 extension/src/tests/serverReplayApplicationFindings.test.ts create mode 100644 extension/src/tests/studyWorkspaceServerReplayFindings.test.ts diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index ecda0fef5..74cb5dba1 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -12228,6 +12228,8 @@ export class StudyWorkspace { ) const failure = describeNodeLearningSave({ kind: 'open-failed', + // A server replay that cannot open is worded as one, never as a test replay. + ...(!summary && serverSummary ? { profile: 'node-server' as const } : {}), title: summary ? [summary.testFile, ...(summary.testTitles ?? [])].join(' › ') : serverSummary @@ -12321,7 +12323,11 @@ export class StudyWorkspace { present: async (sequence, isCurrent) => { const selectedHistory = this.selectedNodeHistory if (!selectedHistory || selectedHistory.header.studyId !== studyId) - throw new Error('The selected test replay changed during playback.') + throw new Error( + this.inReplayWords( + 'The selected test replay changed during playback.', + ), + ) await this.requestNodeHistoryEvent(selectedHistory, sequence, { publish: false, alignPlayback: false, @@ -12330,6 +12336,7 @@ export class StudyWorkspace { }, changed: () => this.publish(), failed: (error) => this.report('control', error), + inReplayWords: (sentence) => this.inReplayWords(sentence), }) // The saved reader position, else the first stop whose source verifies, else the first event (DS1). const initialSequence = this.landingSequence( @@ -13596,8 +13603,8 @@ export class StudyWorkspace { generation, loading: opening, leaving: pendingEvent !== undefined, - ...(model.endedBeforeAnswer && replay.captureReasons[0] - ? { noResponseReason: replay.captureReasons[0] } + ...(model.endedBeforeAnswer && replay.noResponseReason + ? { noResponseReason: replay.noResponseReason } : {}), }, ), @@ -13625,9 +13632,9 @@ export class StudyWorkspace { } /** - * Finds the store that holds a history by its profile, so a server replay's decision and reader position are written to the server store; falls back to the test store for an id no folder holds. + * Finds the store that holds a history by its profile, so a server replay's decision and reader position are written to the server store; an id no folder holds resolves to no store, never to the test store. A reader without a repository has only the test store. * @param studyId - The history's study id. - * @returns The store, or `undefined` once the reader has none. + * @returns The store, or `undefined` once the reader has none or no history has this id. * @example this.nodeHistoryStoreOf(studyId)?.saveReaderPosition(studyId, 3) */ private nodeHistoryStoreOf( @@ -13636,10 +13643,10 @@ export class StudyWorkspace { | NodeHistoryStore | NodeHistoryStore | undefined { - return ( - this.nodeHistoryRepository?.resolve(studyId)?.store ?? - this.nodeHistoryStore - ) + // An unresolved id answers that no history has it; it is not read from the test store. + return this.nodeHistoryRepository + ? this.nodeHistoryRepository.resolve(studyId)?.store + : this.nodeHistoryStore } /** @@ -13881,13 +13888,18 @@ export class StudyWorkspace { repository: NodeHistoryRepository, ): Promise { await repository.listByProfile().then( - ({ tests, servers }) => { - this.nodeHistoryCatalog = tests.map((summary) => - stripNodeHistorySummaryTexts(summary), - ) - this.nodeServerHistoryCatalog = servers.map((summary) => - stripNodeHistorySummaryTexts(summary), - ) + ({ tests, servers, unavailable }) => { + const failed = new Set(unavailable.map(({ profile }) => profile)) + // A profile that could not be listed keeps its earlier catalog; the other is replaced. + if (!failed.has('node-test')) + this.nodeHistoryCatalog = tests.map((summary) => + stripNodeHistorySummaryTexts(summary), + ) + if (!failed.has('node-server')) + this.nodeServerHistoryCatalog = servers.map((summary) => + stripNodeHistorySummaryTexts(summary), + ) + for (const { error } of unavailable) this.report('archive', error) }, // A listing that fails keeps both earlier catalogs. (error: unknown) => this.report('archive', error), @@ -24248,9 +24260,12 @@ export class StudyWorkspace { 'Select the matching test replay before saving an understanding choice.', ), ) - const decision = await this.nodeHistoryStoreOf( + const decisionStore = this.nodeHistoryStoreOf(message.studyId) + if (!decisionStore) throw new Error('No Node history has this study id.') + const decision = await decisionStore.saveLearningDecision( message.studyId, - )!.saveLearningDecision(message.studyId, message.understood) + message.understood, + ) if ( this.selectedNodeHistory?.header.studyId === message.studyId && !this.closing @@ -26094,7 +26109,15 @@ export class StudyWorkspace { writer.latest = undefined // A reader that closed meanwhile has no store to write to and nobody to tell. const store = this.nodeHistoryStore && this.nodeHistoryStoreOf(studyId) - if (!store) break + if (!store) { + // A history whose folder vanished while the reader is open has no store to write to; a closed reader has nobody to tell. + if (this.nodeHistoryStore && !this.closing) + this.report( + 'history-position', + new Error('No Node history has this study id.'), + ) + break + } try { await store.saveReaderPosition(studyId, next.sequence, next.history) } catch (error) { diff --git a/extension/src/tests/serverReplayApplicationFindings.test.ts b/extension/src/tests/serverReplayApplicationFindings.test.ts new file mode 100644 index 000000000..9ade8f6af --- /dev/null +++ b/extension/src/tests/serverReplayApplicationFindings.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict' +import { before, test } from 'node:test' + +import { + buildServerReplayModel, + type ServerReplayModel, +} from '../utils/buildServerReplayModel' +import { describeNodeLearningSave } from '../utils/describeNodeLearningSave' +import { + indexServerOutput, + projectServerApplication, +} from '../utils/projectServerApplication' + +import { + nodeServerHistoryHeader, + openServerRepository, + SERVER_WORKSPACE_KEY, +} from './utils/nodeServerHistoryFixture' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +const VERDICTS = { + saved: true, + captureComplete: true, + learningCoverage: 'complete', +} as const + +/** The Application view of a model at one sequence. */ +function viewAt(model: ServerReplayModel, sequence: number) { + const selected = model.events[model.indexBySequence.get(sequence)!] + return projectServerApplication(model, indexServerOutput(model), selected, { + generation: 1, + }) +} + +/** Writes a fixture history through the shared writer and returns its replay model with the sequences it holds. */ +async function modelOf( + context: Parameters[0], + options: Parameters[1] = {}, +) { + const { repository } = await openServerRepository(context) + const { header, sequences } = await writeServerHistory(repository, options) + const model = buildServerReplayModel( + await repository.serverHistories.read(header.studyId), + VERDICTS, + ) + return { model, sequences } +} + +// protects=a server replay whose open failed is worded with server words in the notification and status line; fails_when=the text says test replay for a server history; no_claim=the Show details action +test('a server replay that cannot open says server replay and never test replay', () => { + // Arrange / Act + const failed = describeNodeLearningSave({ + kind: 'open-failed', + profile: 'node-server', + title: 'Creating an item', + cause: + 'This server replay is still being recorded in another window. It opens once the recording is sealed or interrupted.', + }) + + // Assert + assert.equal( + failed.text, + 'Codebook could not open the server replay "Creating an item": This server replay is still being recorded in another window. It opens once the recording is sealed or interrupted. The saved history is unchanged; open it again from Saved studies.', + ) +}) + +// protects=the request region shows no request at a moment before the request entry (server startup output) and the attempt from the request entry on; fails_when=the first request is shown with Request sent by Codebook before it was sent; no_claim=a withdrawn attempt's words +test('startup output recorded before the request shows no request yet and the request region appears at the request entry', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const writer = await repository.serverHistories.start(header) + await writer.appendOutput({ + elapsedMs: 1, + channel: 'stdout', + text: 'listening on 4100\n', + }) + await writer.appendRequest({ + elapsedMs: 2, + attempt: 1, + method: 'POST', + path: header.declaredRequest.path, + bodyBytes: 14, + }) + await writer.appendRequestSent({ elapsedMs: 3, attempt: 1 }) + await writer.seal({ elapsedMs: 4, reason: 'stopped' }) + const model = buildServerReplayModel( + await repository.serverHistories.read(header.studyId), + VERDICTS, + ) + const [startup, request] = model.events + + // Act + const atStartup = viewAt(model, startup!.sequence) + const atRequest = viewAt(model, request!.sequence) + + // Assert + assert.deepEqual(atStartup.request, { + line: 'No request recorded yet at this moment.', + claim: '', + }) + assert.deepEqual(atRequest.request, { + line: 'POST /api/items', + claim: 'Request sent by Codebook', + }) +}) + +// protects=a body the redaction or the binary note made shorter than its byte count carries no cut note, while a read cut and a body cut at the shown bound do; fails_when=the note appears for a redacted body or is missing for a bounded one; no_claim=the redaction itself +test('the body cut note appears only for a read cut or a body cut at the shown bound', async (context) => { + // Arrange + const redacted = await modelOf(context, { + response: { status: 200, body: '{"token":"[REDACTED]"}', bodyBytes: 100 }, + }) + const readCut = await modelOf(context, { + response: { status: 200, body: '{"id":7}', bodyBytes: 9000, readCut: true }, + }) + const shownBody = `${'a'.repeat(8192)} … 808 more bytes` + const bounded = await modelOf(context, { + response: { + status: 200, + body: shownBody, + bodyBytes: 9000, + contentType: 'text/plain', + }, + }) + + // Act + const redactedView = viewAt(redacted.model, redacted.sequences.response!) + const readCutView = viewAt(readCut.model, readCut.sequences.response!) + const boundedView = viewAt(bounded.model, bounded.sequences.response!) + + // Assert + assert.equal(redactedView.response?.note, undefined) + assert.equal(readCutView.response?.note, 'Body shown cut at 8 bytes.') + assert.equal(boundedView.response?.note, 'Body shown cut at 8211 bytes.') +}) + +// protects=the Application pane's name never carries a bidirectional control from the header's request path; fails_when=U+202E reaches the name that titles the document and names it for a screen reader; no_claim=the request region's own path +test('the Application name strips a bidirectional control from the declared request path', async (context) => { + // Arrange + const { repository } = await openServerRepository(context) + const header = nodeServerHistoryHeader(SERVER_WORKSPACE_KEY) + const forged = { + ...header, + declaredRequest: { + method: header.declaredRequest.method, + path: '/api/\u202Eitems', + }, + } + const writer = await repository.serverHistories.start(forged) + await writer.appendRequest({ + elapsedMs: 1, + attempt: 1, + method: 'POST', + path: '/api/items', + bodyBytes: 14, + }) + await writer.seal({ elapsedMs: 2, reason: 'stopped' }) + const model = buildServerReplayModel( + await repository.serverHistories.read(forged.studyId), + VERDICTS, + ) + + // Act + const view = viewAt(model, model.events[0]!.sequence) + + // Assert + assert.equal(view.name, 'Server replay · POST /api/items') +}) diff --git a/extension/src/tests/studyWorkspaceServerReplayFindings.test.ts b/extension/src/tests/studyWorkspaceServerReplayFindings.test.ts new file mode 100644 index 000000000..7bc26317a --- /dev/null +++ b/extension/src/tests/studyWorkspaceServerReplayFindings.test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict' +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { before, test } from 'node:test' + +import { NODE_SERVER_HISTORY_DIRECTORY } from '../constants' +import type { NodeHistoryRepository } from '../nodeHistoryRepository' +import { waitUntil } from '../runtimeSupport/utils/waitUntil' + +import { workspaceHarness } from './studyWorkspaceHarness' +import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' +import { realProcessStart } from './utils/realProcessStart' +import { writeServerHistory } from './utils/sealedServerHistory' +import { shownApplicationView } from './utils/shownTestOutput' +import { lastView } from './utils/twoStopNodeHistory' + +before(async () => { + await realProcessStart(process.pid) +}) + +type Harness = Awaited> + +/** The workspace's history repository, over the learning directory the fixture writer shares. */ +function repositoryOf(harness: Harness): NodeHistoryRepository { + return harness.reader['nodeHistoryRepository']! +} + +/** The workspace key every history of the harness's workspace carries. */ +function workspaceKeyOf(harness: Harness): string { + return harness.service.nodeHistoryStore!.workspaceKey +} + +/** Opens a saved server replay from the library and waits until the pane shows it settled. */ +async function openServerReplay( + harness: Harness, + studyId: string, +): Promise { + await harness.reader.action('retry') + await harness.reader.openLearningReplay(studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === studyId && + !lastView(harness)?.loading, + 'the server replay is shown', + READER_RESULT_BOUND_MS, + ) +} + +// protects=the last moment of a replay whose recording was interrupted reads a no-response reason in the response region, not the interruption's own sentence; fails_when=the region prints the first capture reason whatever it is; no_claim=the end sentence under the output +test('the response region of an interrupted server replay reads that no response was recorded at its last moment', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header, sequences } = await writeServerHistory( + repositoryOf(harness), + { + workspaceKey: workspaceKeyOf(harness), + output: ['listening\n'], + response: null, + seal: false, + }, + ) + await openServerReplay(harness, header.studyId) + + // Act + harness.receiveContext({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: header.studyId, + sequence: sequences.output[0]!, + }) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === + sequences.output[0] && + !lastView(harness)?.nodeHistoryPosition?.pending, + 'the last moment is shown', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + shownApplicationView(harness)?.response?.state, + 'No response was recorded: the history ended before the server answered.', + ) + } finally { + await harness.close() + } +}) + +// protects=a seek beyond the recorded range of a server replay is refused in server words; fails_when=the refusal says test replay; no_claim=the transport's rendering of the refusal +test('a seek outside the recorded range of a server replay is refused as a server replay', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header } = await writeServerHistory(repositoryOf(harness), { + workspaceKey: workspaceKeyOf(harness), + }) + await openServerReplay(harness, header.studyId) + + // Act + harness.receiveContext({ + type: 'node-history-playback', + revision: lastView(harness)!.revision, + studyId: header.studyId, + action: 'seek', + positionMs: 3_600_000, + }) + await waitUntil( + () => (lastView(harness)?.errors.length ?? 0) > 0, + 'the seek is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.errors[0], + 'Error: Seek lies outside the verified range of this server replay.', + ) + } finally { + await harness.close() + } +}) + +// protects=an understanding choice for a replay whose folder vanished answers that no history has this id and does not read the test store; fails_when=the choice falls through to the test store and fails with its own error; no_claim=recovery of the replay +test('an understanding choice for a server replay whose folder is gone answers that no history has this study id', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const { header } = await writeServerHistory(repositoryOf(harness), { + workspaceKey: workspaceKeyOf(harness), + }) + await openServerReplay(harness, header.studyId) + await rm( + join( + harness.service.nodeHistoryStore!.learningDirectory, + NODE_SERVER_HISTORY_DIRECTORY, + header.studyId, + ), + { recursive: true, force: true }, + ) + + // Act + harness.receiveContext({ + type: 'node-history-understanding', + revision: lastView(harness)!.revision, + studyId: header.studyId, + understood: true, + }) + await waitUntil( + () => (lastView(harness)?.errors.length ?? 0) > 0, + 'the choice is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.errors[0], + 'Error: No Node history has this study id.', + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/utils/adaptServerReplay.ts b/extension/src/utils/adaptServerReplay.ts index 410c2fdfa..b5fc8acff 100644 --- a/extension/src/utils/adaptServerReplay.ts +++ b/extension/src/utils/adaptServerReplay.ts @@ -30,6 +30,8 @@ export type ServerReplayPresentation = { outputIndex: NodeTestOutputIndex /** Why the capture is partial or interrupted, every reason, for the replay's capture-reason line. */ captureReasons: readonly string[] + /** The reason, in a replay's words, that the history was recorded with no response; absent when it holds none (a recorded answer, or only a partial reason about stops or writing). */ + noResponseReason?: string } /** @@ -154,12 +156,14 @@ function toTestShapedHistory(model: ServerReplayModel): NodeTestHistoryIndex { * Called once per opened server history by the Study workspace. * @param model - The replay model {@link buildServerReplayModel} made. * @param captureReasons - Every reason the capture is partial or interrupted, worded. + * @param noResponseReason - The worded no-response reason among them, when the history has one. * @returns The presentation. * @example const replay = adaptServerReplay(model, []); replay.history.entries[0]?.type // => 'result' for the request */ export function adaptServerReplay( model: ServerReplayModel, captureReasons: readonly string[], + noResponseReason?: string, ): ServerReplayPresentation { const shownModel = sanitizeServerReplayModel(model) const shown = toTestShapedHistory(shownModel) @@ -170,5 +174,6 @@ export function adaptServerReplay( shown, outputIndex: indexNodeTestOutput(shown.entries), captureReasons, + ...(noResponseReason ? { noResponseReason } : {}), } } diff --git a/extension/src/utils/describeNodeLearningSave.ts b/extension/src/utils/describeNodeLearningSave.ts index 389863d14..07dc8ab2a 100644 --- a/extension/src/utils/describeNodeLearningSave.ts +++ b/extension/src/utils/describeNodeLearningSave.ts @@ -35,7 +35,13 @@ export type NodeLearningSaveMessage = } | { kind: 'nothing-yet' } | { kind: 'open-refused' } - | { kind: 'open-failed'; title: string; cause: string } + | { + kind: 'open-failed' + title: string + cause: string + /** Which replay failed to open; a server replay is worded with server words. Defaults to a test replay. */ + profile?: 'node-test' | 'node-server' + } /** The words of one message: the status item's text with its tooltip, or a question's or notification's text with its actions. */ export type NodeLearningSaveText = { @@ -184,7 +190,7 @@ export function describeNodeLearningSave( case 'open-failed': return { // A cause that ends its own sentence is not given a second full stop. - text: `Codebook could not open the test replay "${message.title}": ${cutCauseForDisplay(message.cause).replace(/\.$/, '')}. The saved history is unchanged; open it again from Saved studies.`, + text: `Codebook could not open the ${message.profile === 'node-server' ? 'server' : 'test'} replay "${message.title}": ${cutCauseForDisplay(message.cause).replace(/\.$/, '')}. The saved history is unchanged; open it again from Saved studies.`, actions: [NODE_LEARNING_SAVE_ACTION.showDetails], } } diff --git a/extension/src/utils/describeNodeServerReplay.ts b/extension/src/utils/describeNodeServerReplay.ts index d22f3c013..349e3ddab 100644 --- a/extension/src/utils/describeNodeServerReplay.ts +++ b/extension/src/utils/describeNodeServerReplay.ts @@ -61,6 +61,8 @@ export const NODE_SERVER_REPLAY_TEXT = { understanding: 'I understand this server history', /** Labels the fixed request region of the Application pane when the request's bytes left. */ requestSent: 'Request sent by Codebook', + /** The fixed request region before the request entry. */ + noRequestYet: 'No request recorded yet at this moment.', /** The fixed response region before the response entry. */ noResponseYet: 'No response recorded yet at this moment.', emptyBody: 'Response body is empty.', diff --git a/extension/src/utils/nodeHistoryPlaybackController.ts b/extension/src/utils/nodeHistoryPlaybackController.ts index 3e89622ef..9bfad16b6 100644 --- a/extension/src/utils/nodeHistoryPlaybackController.ts +++ b/extension/src/utils/nodeHistoryPlaybackController.ts @@ -15,6 +15,8 @@ export interface NodeHistoryPlaybackDependencies { /** Receives an autoplay presentation failure; manual seeks reject to their own caller instead. */ failed?(error: unknown): void now?: () => number + /** Words a refusal for the replay shown (a server replay is never called a test replay); identity unless given. */ + inReplayWords?(sentence: string): string manualTicks?: boolean } @@ -148,7 +150,9 @@ export class NodeHistoryPlaybackController { positionMs > Math.max(last.elapsedMs, this.recordingEndMs ?? 0) ) { throw new Error( - 'Seek lies outside the verified range of this test replay.', + this.dependencies.inReplayWords?.( + 'Seek lies outside the verified range of this test replay.', + ) ?? 'Seek lies outside the verified range of this test replay.', ) } this.mode = 'paused' diff --git a/extension/src/utils/presentServerHistory.ts b/extension/src/utils/presentServerHistory.ts index a2238e185..5b1011961 100644 --- a/extension/src/utils/presentServerHistory.ts +++ b/extension/src/utils/presentServerHistory.ts @@ -1,3 +1,4 @@ +import { NODE_SERVER_HISTORY_REASON_CODES } from '../constants' import type { HistoryIndexOf } from '../historyStorageDescriptor' import type { NodeServerHistorySummary, @@ -44,6 +45,29 @@ function serverCaptureReasons( return history.reason ? [wordServerCaptureReason(history.reason)] : [] } +/** The codes of the seal reasons that say the history was recorded with no response. */ +const NO_RESPONSE_REASON_CODES: ReadonlySet = new Set([ + NODE_SERVER_HISTORY_REASON_CODES.noResponseRequestFailed, + NODE_SERVER_HISTORY_REASON_CODES.noResponseStopped, + NODE_SERVER_HISTORY_REASON_CODES.noResponseExited, + NODE_SERVER_HISTORY_REASON_CODES.noResponseRunFailed, +]) + +/** + * Picks the no-response reason of a sealed history by its code, so a stop or write reason is never worded as why no response came. + * @param history - The verified history. + * @returns The reason in the replay's words; `undefined` for an unsealed history or one whose seal holds no no-response reason. + * @example noResponseReasonOf(history) // => 'No response was recorded: the run was stopped before the server answered.' + */ +function noResponseReasonOf( + history: Pick, 'seal'>, +): string | undefined { + const reason = history.seal?.reasons.find((candidate) => + NO_RESPONSE_REASON_CODES.has(candidate.code), + ) + return reason ? wordServerCaptureReason(reason.text) : undefined +} + /** * Reads a verified server history once into what the Study workspace presents: the replay model with its three verdicts, its test-shaped view for the shared selection path, and its capture reasons. * Called by the Study workspace when it opens a server replay. @@ -61,5 +85,9 @@ export function presentServerHistory( captureComplete: history.seal?.capture === 'complete', learningCoverage: summary?.learningCoverage ?? 'none', }) - return adaptServerReplay(model, serverCaptureReasons(history)) + return adaptServerReplay( + model, + serverCaptureReasons(history), + noResponseReasonOf(history), + ) } diff --git a/extension/src/utils/projectServerApplication.ts b/extension/src/utils/projectServerApplication.ts index 2716f40c4..9835ebd64 100644 --- a/extension/src/utils/projectServerApplication.ts +++ b/extension/src/utils/projectServerApplication.ts @@ -1,3 +1,5 @@ +import { NODE_SERVER_RESPONSE_SHOWN_BYTES } from '../constants' + import { accumulateNodeTestOutput } from './accumulateNodeTestOutput' import type { ServerReplayEvent, @@ -17,6 +19,7 @@ import { leftOutLines, type NodeTestApplicationView, } from './projectNodeTestApplication' +import { stripTerminalControls } from './stripTerminalControls' /** What the Application pane shows of a server replay at one moment: the fixed request region, the fixed response region, then the output log. It is the test replay's Application view with its server fields, so one document draws both. Every field is text, rendered as text and never as markup. */ export type NodeServerApplicationView = NodeTestApplicationView & { @@ -31,7 +34,7 @@ function timeWords(elapsedMs: number | undefined): string { } /** - * Finds the request attempt the selected moment belongs to: the latest attempt opened at or before it, else the first. + * Finds the request attempt the selected moment belongs to: the latest attempt opened at or before it, else the no-request-yet state while the history holds a request later. * @param model - The replay model. * @param selected - The selected event. * @returns The attempt's request line and its mark state. @@ -44,9 +47,14 @@ function requestRegion( (event): event is Extract => event.type === 'request', ) - const request = - opened.findLast((event) => event.sequence <= selected.sequence) ?? opened[0] - if (!request) return undefined + const request = opened.findLast( + (event) => event.sequence <= selected.sequence, + ) + // Before the first request entry the request does not exist yet. + if (!request) + return opened.length + ? { line: NODE_SERVER_REPLAY_TEXT.noRequestYet, claim: '' } + : undefined const attempt = model.attempts.find( (candidate) => candidate.attempt === request.attempt, ) @@ -65,7 +73,7 @@ function requestRegion( * Words the response region for the selected moment: before the terminal entry the pre-response line, at or after a response its status, content type and body, for a failed request its code, and for a history that ended with no answer its reason once the replay reaches the end. * @param model - The replay model. * @param selected - The selected event. - * @param noResponseReason - The first partial reason of a history that ended with no answer, worded by the capture evaluator. + * @param noResponseReason - The no-response reason of a history that ended with no answer, worded by the capture evaluator. * @returns The region. */ function responseRegion( @@ -80,7 +88,9 @@ function responseRegion( : selected.sequence === model.events.at(-1)?.sequence if (terminal?.type === 'response' && reached) { const shownBytes = Buffer.byteLength(terminal.body) - const cut = terminal.readCut || shownBytes < terminal.bodyBytes + // Only a read cut or the shown-body bound cuts a body; redaction, control stripping and the binary note shorten it without a cut. + const cut = + terminal.readCut || shownBytes > NODE_SERVER_RESPONSE_SHOWN_BYTES return { state: describeServerResponseState({ reached, response: terminal }), ...(terminal.bodyBytes === 0 @@ -108,7 +118,7 @@ function responseRegion( * @param model - The replay model. * @param index - The replay's output read once ({@link indexNodeTestOutput} over the journal's output entries). * @param selected - The selected event, absent before one is selected. - * @param options - `generation` supersedes older updates; `loading` shows the opening state; `leaving` names the surface alone while the selection goes elsewhere; `noResponseReason` is the first partial reason of a history that never answered. + * @param options - `generation` supersedes older updates; `loading` shows the opening state; `leaving` names the surface alone while the selection goes elsewhere; `noResponseReason` is the no-response reason of a history that never answered. * @returns The view. * @example projectServerApplication(model, index, model.events[0], { generation: 1 }).request?.claim // => 'Request sent by Codebook' */ @@ -145,7 +155,7 @@ export function projectServerApplication( test: '', generation, time: timeWords(leaving ? undefined : selected.elapsedMs), - name: `${NODE_SERVER_REPLAY_TEXT.heading} · ${model.header.declaredRequest.method} ${model.header.declaredRequest.path}`, + name: `${NODE_SERVER_REPLAY_TEXT.heading} · ${model.header.declaredRequest.method} ${stripTerminalControls(model.header.declaredRequest.path)}`, ...(requestRegion(model, selected) ? { request: requestRegion(model, selected) } : {}), diff --git a/extension/test-timings.json b/extension/test-timings.json index ef4493988..8c97c9603 100644 --- a/extension/test-timings.json +++ b/extension/test-timings.json @@ -1820,9 +1820,15 @@ "serverBundle.test.js": { "totalMs": 29 }, + "serverHistoryRoutingFindings.test.js": { + "totalMs": 377 + }, "serverInspectorOwnership.test.js": { "totalMs": 140 }, + "serverReplayApplicationFindings.test.js": { + "totalMs": 417 + }, "serverReplayPresentation.test.js": { "totalMs": 408 }, @@ -3584,6 +3590,9 @@ "understanding is saved only by the checkbox, and the playhead and understanding return when the replay is opened again": 829 } }, + "studyWorkspaceServerReplayFindings.test.js": { + "totalMs": 1461 + }, "studyWorkspaceStorage.test.js": { "totalMs": 15018, "tests": { From 77b4c3dfb7104774071100e9340cced2d6b661c6 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 17:23:06 +0900 Subject: [PATCH 16/19] test: mark the replay model's journal-time comparison as no wall-time bound for the pre-gate --- extension/src/tests/buildServerReplayModel.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extension/src/tests/buildServerReplayModel.test.ts b/extension/src/tests/buildServerReplayModel.test.ts index 544466828..56bdc1014 100644 --- a/extension/src/tests/buildServerReplayModel.test.ts +++ b/extension/src/tests/buildServerReplayModel.test.ts @@ -135,6 +135,7 @@ test('a late end after the response ends the timeline there and the history stil // Assert assert.equal(model.terminal?.type, 'response') assert.equal(model.endedBeforeAnswer, false) + // gstack-timing-ok: compares two recorded journal times from the fixture, not elapsed wall time assert.ok(model.endedAtMs > (model.terminal?.elapsedMs ?? 0)) assert.equal( model.events.some((event) => event.type === 'response'), From 63f86340f7d0fe59005ce2e43193e7ba70888247 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 28 Oct 2026 17:44:24 +0900 Subject: [PATCH 17/19] docs: two windows saving one test replay's learning material get one revision and one conflict; the root README names 0.2.55.0 --- README.md | 2 +- extension/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab4bc65ab..ab41ec780 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.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. +This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.55.0** once the second pull request of [Cycle 0059](https://github.com/laststance/codebook/issues/147) is merged; until then 0.2.54.0 ([PR #148](https://github.com/laststance/codebook/pull/148), the first pull request of Cycle 0059) 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 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 a saved server history back (Saved studies, `codebook://node-history` and the replay composition) and saves the chat's learning material under the storage lock, so two windows no longer overwrite each other's; the third records the history during the run, which is when a server run stops being refused at **Save learning replay**, 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/extension/README.md b/extension/README.md index 9ae08a3a5..baf836477 100644 --- a/extension/README.md +++ b/extension/README.md @@ -489,7 +489,7 @@ For actual-editor Playwright CLI video, set `CODEBOOK_EDITOR_CDP=1` only on the ## Run one configured repository action -Minimum versions: **extension 0.2.0+ / MCP API 1.4+**. The current preview is the version in this package's manifest (`extension/package.json`) with **MCP API 1.8** (`codebook_status` reports the installed build). API 1.8 changed what a `node-test` run reports: it reads `demonstrated` in `finalOutcome` once its test passed after every declared stop, and in `studyOutcome` and `recordedStudyOutcome` once its history sealed complete (before, it read `partial` at most); its run resource adds `nodeTest.testResult` (`passed`, `failed`, `signal` or `none`), `nodeTest.learningCoverage` (`complete`, `partial` or `none`) and `nodeTest.learningMissing`, and `nextActions` lists `save-node-learning` until the coverage is complete (and only for a history that holds an entry). Both the run resource and the node-history resource also list `nodeTest.captureMissing`, the recorded evidence the replay lacks (a stop whose debugger state was not captured, or an execution that ended before its result), which no explanation adds and which makes Save read partially saved; an agent claims saved learning only when `captureMissing` is empty. A node-history resource (`codebook://node-history/`, its events or its learning material) or a `codebook_explain_node_history` call that names a study id this workspace holds no history for (a mistyped id, for example, or a history removed while it is read) is refused as `NOT_FOUND` with a sentence that names the id and tells the agent to copy the `studyId` or `historyUri` from `codebook://node-history`; the published recovery of `NOT_FOUND` says the same. Causes the run resource shows (`nodeTest.historyReason`, `recording.reason`, `recording.contextReason`) are cut to 300 characters and name no private storage path. A node-test stop whose position does not fit its retained citation is refused during preparation with a sentence that names the stop (`source` or `additionalStops[n]`), its `anchorIndex`, the cited path and lines and the one condition that failed (anchor, path, hash, line or column), and what to send. The column of a declared node-test stop is a preference: when the debugger binds the declared line at another column, the stop is accepted and captured at that column, and only a binding on another line refuses it; that refusal names the declared and the bound position and says that the stop is captured on its declared line. A declared node-test stop in the cited test file itself is reached as well when the debugger reports no entry pause before it: its first pause is the stop, and Codebook captures it instead of continuing past it. A node-test start's checks (a changed stop source, a cited source the editor holds with other text, a changed test file, a path that is not a file, a test's Vitest missing, older than 4, unreadable, outside the repository or without its program, a Vitest root that holds a config-named folder or an outside link, a test path from the Vitest root that starts with `-`, a plan that changed before the launch) run during preparation and end the run with a typed `error` before its first stop, which records no history and frees the run's slot and key; `codebook_status` names a missing or older Vitest at the root or up to two levels below it before any start (`profiles['node-test'].vitestSearch`), and a `node_modules/vitest` that links outside the repository is not run. The node-test manifest's `vitestRoot`, `vitestRootReason`, `rootClaim`, `vitestConfigFiles` and `skippedConfigFiles` and `codebook_status`'s `vitestSearch` are additive output fields of API 1.8, and a node-test start no longer waits on `codebook_status`'s Vitest search. `codebook_stop` answers `HISTORY_SEAL_FAILED` when it could not seal the history of the run it ended, and `codebook_playback` refuses a test replay with `PLAYBACK_PROFILE_UNSUPPORTED`. `codebook_explain_node_history` refuses a step that cites another event than it claims with `INVALID_REFERENCE`, naming what that event is, and the node-test help (`codebook://help/node-test`) holds a complete start example and the ordered walkthrough of the calls. A test that ends with a non-zero exit code or a signal keeps that result even without Vitest's report, and a passing run whose first release failed seals complete once a retried Stop releases it. A test history whose seal fails with a transient file-system code (`EMFILE`, `ENFILE`, `EBUSY`, `EAGAIN` or `EPERM`) is sealed once more after 200 ms before the run reports `HISTORY_SEAL_FAILED`; a repeated `codebook_stop` makes no further attempt. No stored format changed in 1.8: test histories, learning materials and reader positions written by an earlier 0.2 version open unchanged. API 1.7 changed two answers: `codebook_source`, and `codebook_read_resource` for a book's cited source, answer the source's lines as `numberedText` and no longer as `text`, because the two copies doubled every source answer. An MCP `resources/read` of a cited source's URI still answers its exact text. No request states an API version, so a client written for API 1.6 gets these answers too: where it read `text`, it reads `numberedText` (each line after its one-based number and a tab, the lines joined by a line feed). API 1.7 also added a second reason a run resource names, and its evidence is refused with, once the run's saved study was removed: `The archive of this run's saved study is no longer in the study library: its files were removed by hand or by another Codebook window.`, beside `The learner moved this saved study to the Trash.` for a study deleted in this window (see [Delete a saved study](#delete-a-saved-study)). API 1.6 added, without changing the meaning of an existing field: `declaredSource`, the position you declared (`requestedSource` is its older name); `armed`, the place Codebook requested the breakpoint at when that differs; `place`, one sentence that names the line a stop paused on when the debugger bound it on another line than you declared; and `answer: "next"` in the run resource's `continueArguments`, with which a continuation answers with the next state (the next observation, the observed result or the failure). The same version also added `numberedText` to the answer of `codebook_source` and to the answer of `codebook_read_resource` for a book's cited source: the source's lines, each after its one-based number and a tab. Cycle 0029 delivered private preview **0.2.12** and Cycle 0018 **0.2.2**. `repository-browser` appears in `codebook_status.profiles` and its exact input schema at `codebook://help/repository-browser`. External-client execution, source/value pauses, recording/restart, interrupted evidence and native reader flows have separate development evidence in both editors. [Cycle 0017 installed evidence](https://github.com/laststance/codebook/blob/273660c49bb1aa5a88082ab6d43cd1ebae6ee7cf/docs/cycles/evidence/0017/native-validation.md) adds one unchanged real-repository action, live/replay modes and Wiki returns in both editors; its Cursor restart failure was later repaired in the bounded Cycle 0018 schedule. Neither result establishes G12 editor-chat use or full-product acceptance. +Minimum versions: **extension 0.2.0+ / MCP API 1.4+**. The current preview is the version in this package's manifest (`extension/package.json`) with **MCP API 1.8** (`codebook_status` reports the installed build). API 1.8 changed what a `node-test` run reports: it reads `demonstrated` in `finalOutcome` once its test passed after every declared stop, and in `studyOutcome` and `recordedStudyOutcome` once its history sealed complete (before, it read `partial` at most); its run resource adds `nodeTest.testResult` (`passed`, `failed`, `signal` or `none`), `nodeTest.learningCoverage` (`complete`, `partial` or `none`) and `nodeTest.learningMissing`, and `nextActions` lists `save-node-learning` until the coverage is complete (and only for a history that holds an entry). Both the run resource and the node-history resource also list `nodeTest.captureMissing`, the recorded evidence the replay lacks (a stop whose debugger state was not captured, or an execution that ended before its result), which no explanation adds and which makes Save read partially saved; an agent claims saved learning only when `captureMissing` is empty. A node-history resource (`codebook://node-history/`, its events or its learning material) or a `codebook_explain_node_history` call that names a study id this workspace holds no history for (a mistyped id, for example, or a history removed while it is read) is refused as `NOT_FOUND` with a sentence that names the id and tells the agent to copy the `studyId` or `historyUri` from `codebook://node-history`; the published recovery of `NOT_FOUND` says the same. Causes the run resource shows (`nodeTest.historyReason`, `recording.reason`, `recording.contextReason`) are cut to 300 characters and name no private storage path. A node-test stop whose position does not fit its retained citation is refused during preparation with a sentence that names the stop (`source` or `additionalStops[n]`), its `anchorIndex`, the cited path and lines and the one condition that failed (anchor, path, hash, line or column), and what to send. The column of a declared node-test stop is a preference: when the debugger binds the declared line at another column, the stop is accepted and captured at that column, and only a binding on another line refuses it; that refusal names the declared and the bound position and says that the stop is captured on its declared line. A declared node-test stop in the cited test file itself is reached as well when the debugger reports no entry pause before it: its first pause is the stop, and Codebook captures it instead of continuing past it. A node-test start's checks (a changed stop source, a cited source the editor holds with other text, a changed test file, a path that is not a file, a test's Vitest missing, older than 4, unreadable, outside the repository or without its program, a Vitest root that holds a config-named folder or an outside link, a test path from the Vitest root that starts with `-`, a plan that changed before the launch) run during preparation and end the run with a typed `error` before its first stop, which records no history and frees the run's slot and key; `codebook_status` names a missing or older Vitest at the root or up to two levels below it before any start (`profiles['node-test'].vitestSearch`), and a `node_modules/vitest` that links outside the repository is not run. The node-test manifest's `vitestRoot`, `vitestRootReason`, `rootClaim`, `vitestConfigFiles` and `skippedConfigFiles` and `codebook_status`'s `vitestSearch` are additive output fields of API 1.8, and a node-test start no longer waits on `codebook_status`'s Vitest search. `codebook_stop` answers `HISTORY_SEAL_FAILED` when it could not seal the history of the run it ended, and `codebook_playback` refuses a test replay with `PLAYBACK_PROFILE_UNSUPPORTED`. Two windows that save the learning material of the same test replay at once get one saved revision and one revision conflict, because the material is read, checked against its revision and written under the storage lock; a save that meets that lock while another window holds it is refused with the storage lock's sentence. `codebook_explain_node_history` refuses a step that cites another event than it claims with `INVALID_REFERENCE`, naming what that event is, and the node-test help (`codebook://help/node-test`) holds a complete start example and the ordered walkthrough of the calls. A test that ends with a non-zero exit code or a signal keeps that result even without Vitest's report, and a passing run whose first release failed seals complete once a retried Stop releases it. A test history whose seal fails with a transient file-system code (`EMFILE`, `ENFILE`, `EBUSY`, `EAGAIN` or `EPERM`) is sealed once more after 200 ms before the run reports `HISTORY_SEAL_FAILED`; a repeated `codebook_stop` makes no further attempt. No stored format changed in 1.8: test histories, learning materials and reader positions written by an earlier 0.2 version open unchanged. API 1.7 changed two answers: `codebook_source`, and `codebook_read_resource` for a book's cited source, answer the source's lines as `numberedText` and no longer as `text`, because the two copies doubled every source answer. An MCP `resources/read` of a cited source's URI still answers its exact text. No request states an API version, so a client written for API 1.6 gets these answers too: where it read `text`, it reads `numberedText` (each line after its one-based number and a tab, the lines joined by a line feed). API 1.7 also added a second reason a run resource names, and its evidence is refused with, once the run's saved study was removed: `The archive of this run's saved study is no longer in the study library: its files were removed by hand or by another Codebook window.`, beside `The learner moved this saved study to the Trash.` for a study deleted in this window (see [Delete a saved study](#delete-a-saved-study)). API 1.6 added, without changing the meaning of an existing field: `declaredSource`, the position you declared (`requestedSource` is its older name); `armed`, the place Codebook requested the breakpoint at when that differs; `place`, one sentence that names the line a stop paused on when the debugger bound it on another line than you declared; and `answer: "next"` in the run resource's `continueArguments`, with which a continuation answers with the next state (the next observation, the observed result or the failure). The same version also added `numberedText` to the answer of `codebook_source` and to the answer of `codebook_read_resource` for a book's cited source: the source's lines, each after its one-based number and a tab. Cycle 0029 delivered private preview **0.2.12** and Cycle 0018 **0.2.2**. `repository-browser` appears in `codebook_status.profiles` and its exact input schema at `codebook://help/repository-browser`. External-client execution, source/value pauses, recording/restart, interrupted evidence and native reader flows have separate development evidence in both editors. [Cycle 0017 installed evidence](https://github.com/laststance/codebook/blob/273660c49bb1aa5a88082ab6d43cd1ebae6ee7cf/docs/cycles/evidence/0017/native-validation.md) adds one unchanged real-repository action, live/replay modes and Wiki returns in both editors; its Cursor restart failure was later repaired in the bounded Cycle 0018 schedule. Neither result establishes G12 editor-chat use or full-product acceptance. The current adapter is `next-dev-webpack`: a trusted local Git root with dependencies already installed, an executable Next development server, a same-origin `entryPath` (default `/`), default `.next/dev` output, and webpack modules/maps that retain the exact original selected source. Codebook starts the owned server on `127.0.0.1` at the project's own port (Next is started with `--hostname 127.0.0.1`) and opens the app in the recorded browser at `http://localhost:`, the origin that `AUTH_URL`, OAuth callbacks and cookies usually name. `localhost`, `127.0.0.1` and `[::1]` at that port count as the same application origin, so a sign-in redirect between them keeps the run going while another port or host still ends it. Because a host may resolve `localhost` to `::1`, Codebook also listens on `[::1]:` for the whole run and forwards its bytes to the owned server (each direction ends as its source ends, so a client that half-closes still gets the reply), so no other process can answer there. The listener starts after the owned `next dev` has bound its port and reported ready, and never starts when that readiness fails or is cancelled; it closes with the run. If something else already holds `[::1]:`, preparation is refused as a busy port naming `[::1]`; if the system does not let Codebook listen there it is refused as not permitted, and any other listen failure is refused with its error code. On a host without IPv6 loopback no `[::1]` listener is made and `[::1]` is not accepted. The recorded browser resolves `localhost` as the system does, so the app's own calls to your other local services, on `::1` or `127.0.0.1`, reach them as they do in your own browser; Chrome itself runs on a dynamic loopback endpoint. The port is the one the project's own `dev` script would start `next dev` on, and Codebook asserts it only for a fixed set of forms. The `dev` script, and each script it runs, may be one or more statements joined by `&&` or `;`, each one of these: `export PORT=4000`, or a `PORT=4000` or plain `NAME=value` assignment as a prefix before a command; a `next dev` command reached by `next`, `npx next`, `bunx next`, `pnpm exec next`, `yarn next` or a `node_modules/.bin/next` path (the path form only on its own or after `pnpm exec`, since `npx` and `bunx` take a package name and fail on a path), optionally after `PORT=4000`, `cross-env` or `env` with assignments only, with Next's own `dev` flags and literal values (`-p`/`--port` as `-p 4000`, `-p4000`, `--port 4000` or `--port=4000`, the last one winning, `-H`/`--hostname`, `--turbo`, `--turbopack`, `--webpack`, `--experimental-https` and its `--experimental-https-*` options); a script run with no arguments (`npm run x`, `pnpm run x`, `yarn run x`, `bun run x`; `pnpm x` and `yarn x` without `run` are followed only for `dev`, `start`, `serve`, `web`, `app`, `client`, `frontend` and a name with a `:`, since pnpm and yarn have many commands and aliases of their own (`deploy`, `ci`, `t`, `access`, `rebuild`) that run before or instead of a script of that name, and some of them change between versions: write `pnpm run deploy`), followed two levels deep at most; or one `concurrently` whose quoted commands are each read the same way and exactly one of which is a `next dev` command. The port is the last `-p`/`--port`, else the `PORT` the script exports or assigns before the command, else Next's default 3000, and `PORT` from the editor's own environment is not consulted. The `dev` script is read as text and never run. A `package.json` that is not a regular file (a FIFO, for example) or is larger than 1 MiB is not read, and `applicationPortSource` says so. A `dev` script that runs more than 256 statements in all, counting every line of it and of each script it runs, is unread, and `applicationPortSource` says the dev script runs more statements than Codebook reads. The last statement may end with one `;`. The script is read with the meaning of the shell that runs it: `sh` on macOS and Linux, `cmd` on Windows, where npm, pnpm and yarn run scripts with `cmd`. On Windows only the forms that mean the same under `cmd` are read: bare `next` or an `npx`-style launcher with `next dev` and literal flags (a program written as a path with `/`, such as `node_modules/.bin/next`, is unread), `cross-env PORT=4000 next dev`, delegations and a `concurrently` with double-quoted commands; single quotes, a `PORT=4000` or `NAME=value` prefix, `export`, `env`, `;` and a variable named `port` in any case other than `PORT` (Windows ignores the case of variable names) are unread there, and `applicationPortSource` names `cmd`. An empty `dev` script is told as empty. A word that has a quote before its `=` (`"PORT=4000" next dev`) is no assignment. Everything else is unread: any other program or option, a pnpm command whose meaning depends on the installed pnpm version (`pnpm rebuild`), a pipe, `&` or `||`, a group or subshell, `$`, a backtick, a redirection or heredoc, a comment, a backslash or a line break, `if`/`while`/`!`/`time`, `sh -c`, `cmd /c`, `dotenv`, `node`, `set`, `unset`, a bare `PORT=4000;`, a `${PORT:-4000}` default, arguments given to a delegated script, and a script that never runs `next dev`. For an unread script no port is asserted: Next's default 3000 is taken and `applicationPortSource` says so and quotes the statement that left the forms above by its structure only. Only names from fixed lists are quoted (the words of the forms above by their base name, Next's `dev` flags, `PORT=…` and plain `NAME=…` assignment names before the program, and `package.json` script names, without control characters and cut); every value, operand, path, token, header, `user:pass@host` and base64 text is shown as `…`, so declare the port with `-p` in the `dev` script in one of the forms above to name it. `.env` files are not read for the port, because Next reads `PORT` before it loads them. Bun is the exception: it loads `.env` files into the environment of the scripts it runs, so a `next dev` without a `-p`/`--port` flag and without a `PORT` assigned in the script is unread (Next's default 3000 is taken and `applicationPortSource` names Bun's `.env` loading) when it is reached by `bun run