From 8691e48e67ca6efd096d48dcecbbb04dbeb00467 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:54:37 +0800 Subject: [PATCH] refactor(evidence): return import counts from ledger owner --- .../binary/BinarySessionRecords.ts | 12 +++- .../investigation/InvestigationRecordPort.ts | 9 ++- src/server/registerSessionRecordTools.ts | 21 +------ .../mcp/evidenceImportCancellation.test.ts | 15 ++--- .../mcp/evidenceImportLifecycle.test.ts | 58 +++++++++++++++++++ .../analysis-sessions/binarySession.test.ts | 29 +++++++++- .../investigationRecords.test.ts | 12 +++- 7 files changed, 123 insertions(+), 33 deletions(-) diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 23330f7b7e..5b04f55a07 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -21,6 +21,7 @@ import type { UpdateUnknownInput, } from "../../domain/residualUnknown.js"; import type { + EvidenceImportReceipt, UnknownPage, UnknownQuery, } from "../investigation/InvestigationRecordPort.js"; @@ -167,12 +168,19 @@ export abstract class BinarySessionRecords { importEvidenceBundle( bundle: unknown, - ): Result { + ): Result< + EvidenceImportReceipt, + EvidenceIntegrityError | AnalysisInputError + > { const imported = this.#records.mergeEvidenceBundle(bundle); if (!imported.ok) return imported; this.#snapshot.refreshEvidenceMetadata(imported.value.replacements); if (imported.value.changed) this.#emitSnapshotChanged(); - return ok(imported.value.recordsAdded); + return ok({ + recordsAdded: imported.value.recordsAdded, + unknownsAdded: imported.value.unknownsAdded, + totalRecords: this.#records.immutableEvidenceBundle().records.length, + }); } protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined; diff --git a/src/application/investigation/InvestigationRecordPort.ts b/src/application/investigation/InvestigationRecordPort.ts index c7c8cf6a98..0d1daf8b6f 100644 --- a/src/application/investigation/InvestigationRecordPort.ts +++ b/src/application/investigation/InvestigationRecordPort.ts @@ -77,6 +77,13 @@ export interface UnknownRegistryPort { >; } +/** Counts from the committed merge, before adapter response projection. */ +export interface EvidenceImportReceipt { + readonly recordsAdded: number; + readonly unknownsAdded: number; + readonly totalRecords: number; +} + /** Investigation record surface; binary execution and snapshots remain separate. */ export interface InvestigationRecordPort extends @@ -86,5 +93,5 @@ export interface InvestigationRecordPort UnknownRegistryPort { importEvidenceBundle( bundle: unknown, - ): Result; + ): Result; } diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts index a3b938391d..ae745bf5f4 100644 --- a/src/server/registerSessionRecordTools.ts +++ b/src/server/registerSessionRecordTools.ts @@ -8,7 +8,6 @@ import { writeEvidenceBundle, } from "../application/EvidenceBundleFiles.js"; import { toolContract } from "../contracts/toolContracts.js"; -import type { EvidenceBundle } from "../domain/evidenceBundle.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; @@ -117,11 +116,6 @@ const registerImportEvidenceTool = ({ ); if (!loaded.ok) return server.delivery.toCallToolResult(loaded, importContract); - const retainedUnknownRevisions = new Set( - session - .immutableEvidenceBundle() - .unknowns.map((unknown) => unknownRevisionKey(unknown)), - ); if (signal.aborted) return server.delivery.toCallToolResult( err( @@ -135,14 +129,9 @@ const registerImportEvidenceTool = ({ return imported.ok ? server.delivery.toCallToolResult( ok({ - imported: imported.value, - unknowns_added: loaded.value.unknowns.filter( - (unknown) => - !retainedUnknownRevisions.has( - unknownRevisionKey(unknown), - ), - ).length, - total: session.immutableEvidenceBundle().records.length, + imported: imported.value.recordsAdded, + unknowns_added: imported.value.unknownsAdded, + total: imported.value.totalRecords, }), importContract, ) @@ -152,10 +141,6 @@ const registerImportEvidenceTool = ({ ); }; -const unknownRevisionKey = ( - unknown: EvidenceBundle["unknowns"][number], -): string => `${unknown.unknown_id}:${String(unknown.revision)}`; - interface UnknownToolRegistration { readonly server: EvidenceMcpServer; readonly session: BinarySessionPort; diff --git a/tests/boundary/mcp/evidenceImportCancellation.test.ts b/tests/boundary/mcp/evidenceImportCancellation.test.ts index 0e62746af1..cd1fe90b33 100644 --- a/tests/boundary/mcp/evidenceImportCancellation.test.ts +++ b/tests/boundary/mcp/evidenceImportCancellation.test.ts @@ -10,6 +10,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createServer } from "../../../src/server/createServer.js"; import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; import { ToolResultDelivery } from "../../../src/server/ToolResultDelivery.js"; +import * as evidenceBundleFiles from "../../../src/application/EvidenceBundleFiles.js"; import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -51,13 +52,13 @@ it("preserves the exact reason when import is cancelled after reading the bundle const reason = { request: "cancel after authenticated bundle read" }; const controller = new AbortController(); const before = session.exportEvidenceBundle(); - const snapshot = session.immutableEvidenceBundle.bind(session); - const snapshotRead = vi - .spyOn(session, "immutableEvidenceBundle") - .mockImplementationOnce(() => { - const bundle = snapshot(); + const read = evidenceBundleFiles.readImmutableEvidenceBundle; + const authenticatedRead = vi + .spyOn(evidenceBundleFiles, "readImmutableEvidenceBundle") + .mockImplementationOnce(async (...args) => { + const loaded = await read(...args); controller.abort(reason); - return bundle; + return loaded; }); const imported = vi.spyOn(session, "importEvidenceBundle"); const delivery = vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult"); @@ -73,7 +74,7 @@ it("preserves the exact reason when import is cancelled after reading the bundle expect(response.isError).toBe(true); expect(delivered.error._tag).toBe("AnalysisCancelledError"); expect(delivered.error.cause).toBe(reason); - expect(snapshotRead).toHaveBeenCalledOnce(); + expect(authenticatedRead).toHaveBeenCalledOnce(); expect(imported).not.toHaveBeenCalled(); expect(session.exportEvidenceBundle()).toEqual(before); }); diff --git a/tests/boundary/mcp/evidenceImportLifecycle.test.ts b/tests/boundary/mcp/evidenceImportLifecycle.test.ts index f0371ef897..23ddbb531f 100644 --- a/tests/boundary/mcp/evidenceImportLifecycle.test.ts +++ b/tests/boundary/mcp/evidenceImportLifecycle.test.ts @@ -153,3 +153,61 @@ it("admits a later import after close_binary without retaining the earlier bundl ).toEqual(["after_close"]); await fixture.client.ping(); }); + +it("reports newly committed Unknown revisions and counts a repeated import once", async () => { + const root = await createTestTempDirectory("rea-mcp-import-counts-"); + const source = createTestBinarySession(() => { + throw new Error("Evidence import must not start a binary provider"); + }); + const unknown = source.recordUnknown({ + question: "Does this artifact have an unresolved dependency?", + severity: "medium", + domain: "investigation", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "analyst-inference", + required_confidence: "derived", + required_environment: null, + recommended_probes: [], + relationships: [], + }); + if (!unknown.ok) throw unknown.error; + const bundle = source.exportEvidenceBundle(); + const path = join(root, "unknowns.json"); + await writeFile(path, JSON.stringify(bundle)); + const session = createTestBinarySession(() => { + throw new Error("Evidence import must not start a binary provider"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "import-counts", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + await source.close(); + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + const first = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(first.isError).not.toBe(true); + expect(first.structuredContent).toEqual({ + result: { + imported: bundle.records.length, + unknowns_added: 1, + total: bundle.records.length, + }, + }); + const repeated = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(repeated.isError).not.toBe(true); + expect(repeated.structuredContent).toEqual({ + result: { imported: 0, unknowns_added: 0, total: bundle.records.length }, + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 64b9acac88..535144585b 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -606,10 +606,24 @@ describe("evidence metadata imports and snapshot cache", () => { ); expect( session.importEvidenceBundle(createEvidenceBundle([addition])), - ).toEqual({ ok: true, value: 1 }); + ).toEqual({ + ok: true, + value: { + recordsAdded: 1, + unknownsAdded: 0, + totalRecords: before.value.evidence_bundle.records.length + 1, + }, + }); expect( session.importEvidenceBundle(createEvidenceBundle([addition])), - ).toEqual({ ok: true, value: 0 }); + ).toEqual({ + ok: true, + value: { + recordsAdded: 0, + unknownsAdded: 0, + totalRecords: before.value.evidence_bundle.records.length + 1, + }, + }); expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: true, value: { @@ -630,7 +644,9 @@ describe("evidence metadata imports and snapshot cache", () => { expect(session.exportAnalysisSnapshot().ok).toBe(true); await session.close(); }); +}); +describe("Evidence display metadata refresh", () => { it.each(["active", "unrelated"] as const)( "refreshes %s Evidence display metadata without invalidating provider observations", async (scope) => { @@ -671,7 +687,14 @@ describe("evidence metadata imports and snapshot cache", () => { const unsubscribe = session.onAnalysisSnapshotChanged(changed); expect( session.importEvidenceBundle(createEvidenceBundle([relocated])), - ).toEqual({ ok: true, value: 0 }); + ).toEqual({ + ok: true, + value: { + recordsAdded: 0, + unknownsAdded: 0, + totalRecords: session.immutableEvidenceBundle().records.length, + }, + }); expect(changed).toHaveBeenCalledTimes(1); unsubscribe(); expect(session.evidenceById(evidence.evidence_id)).toEqual(relocated); diff --git a/tests/composition/analysis-sessions/investigationRecords.test.ts b/tests/composition/analysis-sessions/investigationRecords.test.ts index 9a18120eb4..61e757ce6d 100644 --- a/tests/composition/analysis-sessions/investigationRecords.test.ts +++ b/tests/composition/analysis-sessions/investigationRecords.test.ts @@ -138,12 +138,20 @@ it("notifies an Unknown-only bundle change even when no new Evidence is added", }); expect(recipient.importEvidenceBundle(bundle)).toEqual({ ok: true, - value: 0, + value: { + recordsAdded: 0, + unknownsAdded: 1, + totalRecords: bundle.records.length, + }, }); expect(revisions).toEqual([1]); expect(recipient.importEvidenceBundle(bundle)).toEqual({ ok: true, - value: 0, + value: { + recordsAdded: 0, + unknownsAdded: 0, + totalRecords: bundle.records.length, + }, }); expect(revisions).toEqual([1]); });