refactor(evidence): return import counts from ledger owner

This commit is contained in:
morluto
2026-10-27 12:54:37 +08:00
parent 1dc33c0543
commit 8691e48e67
7 changed files with 123 additions and 33 deletions
+10 -2
View File
@@ -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<number, EvidenceIntegrityError | AnalysisInputError> {
): 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;
@@ -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<number, EvidenceIntegrityError | AnalysisInputError>;
): Result<EvidenceImportReceipt, EvidenceIntegrityError | AnalysisInputError>;
}
+3 -18
View File
@@ -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;
@@ -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);
});
@@ -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 },
});
});
@@ -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);
@@ -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]);
});