From 477f63ae2155ec77562b04671baf6fff4d2eb6d3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:28:57 +0800 Subject: [PATCH] fix(session)!: classify unavailable snapshot prerequisites (#7837) fix(session): classify unavailable snapshot prerequisites --- .../binary/BinarySessionRecords.ts | 27 +++++++--- .../acceptance/archiveSnapshotRefusal.test.ts | 50 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 tests/acceptance/archiveSnapshotRefusal.test.ts diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 5b04f55a07..472127cadf 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -7,12 +7,14 @@ import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { evidenceBundleForTarget } from "../../domain/evidenceBundle.js"; import { - EvidenceFileError, EvidenceIntegrityError, AnalysisSnapshotMismatchError, } from "../../domain/evidenceErrors.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import type { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCapabilityUnavailableError, + type AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js"; import type { RecordUnknownInput, @@ -50,6 +52,19 @@ import { const SNAPSHOT_MUTATION_RECOVERY = "Export session observations through export_evidence_bundle if needed. Close the active target without saving a snapshot, then reopen it before importing or saving a snapshot."; +/** A missing replay prerequisite is not an Evidence integrity failure. */ +const snapshotUnavailable = (reason: string) => + new AnalysisCapabilityUnavailableError( + "rea", + "export_analysis_snapshot", + reason, + { + userMessage: `Analysis snapshot unavailable: ${reason}`, + remediationAction: + "Export session observations with export_evidence_bundle if needed, or call close_binary without snapshot_path to close the session.", + }, + ); + export interface ActiveAnalysisBinding { readonly target: BinaryTarget; readonly profile: AnalysisProfileCommitment | null; @@ -201,8 +216,8 @@ export abstract class BinarySessionRecords { active?.profile ?? active?.profilelessWorkflowProfile ?? undefined; if (target !== undefined && profile === undefined) return err( - new EvidenceIntegrityError( - "Analysis snapshots require a concrete provider analysis profile", + snapshotUnavailable( + "The active target has no provider analysis profile for a snapshot.", ), ); if ( @@ -217,8 +232,8 @@ export abstract class BinarySessionRecords { )) ) return err( - new EvidenceIntegrityError( - "Profileless native snapshots may contain only current dispatch workflow observations", + snapshotUnavailable( + "The active session does not have a supported snapshot binding with replayable workflow observations for this target.", ), ); return this.#snapshot.export( diff --git a/tests/acceptance/archiveSnapshotRefusal.test.ts b/tests/acceptance/archiveSnapshotRefusal.test.ts new file mode 100644 index 0000000000..84c6d2d2fb --- /dev/null +++ b/tests/acceptance/archiveSnapshotRefusal.test.ts @@ -0,0 +1,50 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createSingleEntryZip } from "../fixtures/zipNames.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "archive snapshot refusal preserves the session and explains the missing replay binding", + async () => { + const root = await createTestTempDirectory("rea-archive-snapshot-refusal-"); + const archive = join(root, "sample.zip"); + const snapshot = join(root, "snapshot.json"); + const bytes = await createSingleEntryZip("sample.txt"); + await writeFile(archive, bytes); + const { call } = await connectLocalToolsMcp(); + expect((await call("open_binary", { path: archive })).isError).not.toBe( + true, + ); + const failure = parseMcpToolError( + await call("close_binary", { snapshot_path: snapshot }), + ); + expect(failure.error).toMatchObject({ + code: "capability_unavailable", + retryable: false, + message: expect.stringContaining("Analysis snapshot unavailable"), + remediation: { + action: expect.stringContaining("close_binary without snapshot_path"), + }, + }); + expect( + await access(snapshot).then( + () => true, + () => false, + ), + ).toBe(false); + expect((await call("binary_session", {})).structuredContent).toMatchObject({ + result: { open: true }, + }); + expect((await call("close_binary", {})).isError).not.toBe(true); + expect((await call("binary_session", {})).structuredContent).toMatchObject({ + result: { open: false }, + }); + expect( + Buffer.from(await readFile(archive)).equals(Buffer.from(bytes)), + ).toBe(true); + }, +);