mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(evidence): align bundle receipts and conflict recovery (#7849)
This commit is contained in:
1 parent
4e663beeb8
commit
6c205d8a79
7 files changed
+198
-14
No files matched your search
@@ -33,6 +33,7 @@ import {
|
||||
readImmutableEvidenceBundle,
|
||||
readComparisonEvidenceBundles,
|
||||
writeEvidenceBundle,
|
||||
evidenceBundleExportReceipt,
|
||||
} from "./EvidenceBundleFiles.js";
|
||||
import { inspectEvidenceBundle } from "./investigation/InspectEvidenceBundle.js";
|
||||
import type { EvidenceRecordFilters } from "../domain/evidenceRecordSummary.js";
|
||||
@@ -284,11 +285,7 @@ const projectWrite = (
|
||||
written: Result<PublishedEvidenceFile, AnalysisError>,
|
||||
): Result<JsonValue, AnalysisError> =>
|
||||
written.ok
|
||||
? ok({
|
||||
path: written.value.path,
|
||||
bytes: written.value.bytes,
|
||||
records: bundle.records.length,
|
||||
})
|
||||
? ok(evidenceBundleExportReceipt(bundle, written.value))
|
||||
: err(written.error);
|
||||
|
||||
/** Standalone bundle discovery shares the same read owner as import and comparison. */
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
EvidenceFileError,
|
||||
EvidenceIntegrityError,
|
||||
type PublishedEvidenceFile,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
@@ -36,6 +37,17 @@ type EvidenceWriteFailure =
|
||||
| EvidenceIntegrityError
|
||||
| AnalysisCancelledError;
|
||||
|
||||
/** Project a published bundle with record and residual-unknown revision counts. */
|
||||
export const evidenceBundleExportReceipt = (
|
||||
bundle: EvidenceBundle,
|
||||
published: PublishedEvidenceFile,
|
||||
) => ({
|
||||
path: published.path,
|
||||
bytes: published.bytes,
|
||||
records: bundle.records.length,
|
||||
unknowns: bundle.unknowns.length,
|
||||
});
|
||||
|
||||
/** Read and validate an evidence bundle at the caller-supplied path. */
|
||||
export const readEvidenceBundle = (
|
||||
path: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import canonicalize from "canonicalize";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { EvidenceLedger } from "./EvidenceLedger.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
createEvidence,
|
||||
withEvidenceCallerInputPath,
|
||||
@@ -60,6 +61,21 @@ const input = (
|
||||
...overrides,
|
||||
});
|
||||
const mutation = (label: string) => evidence(`mutation-${label}`);
|
||||
const expectHistoryConflict = (
|
||||
error: import("../../domain/analysisErrorBase.js").AnalysisError,
|
||||
unknownId: string,
|
||||
) => {
|
||||
expect(projectAnalysisError(error)).toMatchObject({
|
||||
code: "evidence_integrity_mismatch",
|
||||
retryable: false,
|
||||
message: expect.stringContaining("conflicts with a revision retained"),
|
||||
remediation: { action: expect.stringContaining("cannot reconcile") },
|
||||
details: {
|
||||
reason: "Conflicting unknown revision",
|
||||
diagnostics: { unknown_id: unknownId, revision: 2 },
|
||||
},
|
||||
});
|
||||
};
|
||||
const update = (
|
||||
unknown: ResidualUnknown,
|
||||
overrides: Readonly<Record<string, unknown>> = {},
|
||||
@@ -432,7 +448,10 @@ describe("residual unknown registry resolution and history", () => {
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const before = serializeEvidenceBundle(base.export());
|
||||
expect(base.import(branch.export()).ok).toBe(false);
|
||||
const conflict = base.import(branch.export());
|
||||
expect(conflict.ok).toBe(false);
|
||||
if (conflict.ok) throw new Error("Expected divergent history refusal");
|
||||
expectHistoryConflict(conflict.error, created.value.unknown_id);
|
||||
expect(base.get(source.evidence_id)?.caller_input_path).toBe("./original");
|
||||
expect(serializeEvidenceBundle(base.export())).toBe(before);
|
||||
});
|
||||
|
||||
@@ -146,7 +146,16 @@ export class EvidenceLedger {
|
||||
existing !== undefined &&
|
||||
existing.revision_digest !== unknown.revision_digest
|
||||
)
|
||||
return err(new EvidenceIntegrityError("Conflicting unknown revision"));
|
||||
return err(
|
||||
new EvidenceIntegrityError("Conflicting unknown revision", {
|
||||
userMessage:
|
||||
"The incoming residual-unknown history conflicts with a revision retained in this session. Inspect each history in a separate session; importing the unchanged bundle into this session cannot reconcile them.",
|
||||
diagnostics: {
|
||||
unknown_id: unknown.unknown_id,
|
||||
revision: unknown.revision,
|
||||
},
|
||||
}),
|
||||
);
|
||||
pendingUnknowns.set(key, unknown);
|
||||
if (existing === undefined) unknownsAdded += 1;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BinarySessionPort } from "../application/binary/BinarySessionPort.
|
||||
import {
|
||||
readImmutableEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
evidenceBundleExportReceipt,
|
||||
} from "../application/EvidenceBundleFiles.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
@@ -60,12 +61,7 @@ const registerExportEvidenceTool = ({
|
||||
);
|
||||
return written.ok
|
||||
? server.delivery.toCallToolResult(
|
||||
ok({
|
||||
path: written.value.path,
|
||||
bytes: written.value.bytes,
|
||||
records: bundle.records.length,
|
||||
unknowns: bundle.unknowns.length,
|
||||
}),
|
||||
ok(evidenceBundleExportReceipt(bundle, written.value)),
|
||||
exportContract,
|
||||
)
|
||||
: server.delivery.toCallToolResult(written, exportContract);
|
||||
|
||||
@@ -213,6 +213,42 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports exported residual-unknown revision counts",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-evidence-export-counts-");
|
||||
for (const [label, bundle] of [
|
||||
["empty", createEvidenceBundle([])],
|
||||
["revisions", revisionBundle()],
|
||||
] as const) {
|
||||
const source = join(root, `${label}.json`);
|
||||
await writeFile(source, JSON.stringify(bundle));
|
||||
for (const full of [false, true]) {
|
||||
const output = join(root, `${label}-${String(full)}.json`);
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"evidence-export",
|
||||
source,
|
||||
output,
|
||||
"--json",
|
||||
...(full ? ["--full-output"] : []),
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const receipt = full
|
||||
? (result.json as { data: unknown }).data
|
||||
: result.json;
|
||||
expect(receipt).toMatchObject({
|
||||
path: output,
|
||||
records: bundle.records.length,
|
||||
unknowns: bundle.unknowns.length,
|
||||
});
|
||||
expect(JSON.parse(await readFile(output, "utf8"))).toEqual(bundle);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports a standalone Evidence record as the wrong input file kind",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -19,7 +19,17 @@ import { err, ok as resultOk } from "../../../src/domain/result.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
parseEvidenceBundle,
|
||||
} from "../../../src/domain/evidenceBundle.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
updateResidualUnknown,
|
||||
} from "../../../src/domain/residualUnknown.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
|
||||
|
||||
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
|
||||
@@ -92,6 +102,111 @@ const structured = (result: CallToolResult): Record<string, unknown> => {
|
||||
return Object.fromEntries(Object.entries(result.structuredContent));
|
||||
};
|
||||
|
||||
it("rejects divergent imported histories without suggesting an unchanged retry", async () => {
|
||||
const root = await createTestTempDirectory("rea-unknown-divergent-");
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const records = ["create", "left", "right"].map((operation) =>
|
||||
createEvidence(undefined, provider, {
|
||||
operation,
|
||||
parameters: {},
|
||||
result: {},
|
||||
}),
|
||||
);
|
||||
const [created, left, right] = records;
|
||||
if (created === undefined || left === undefined || right === undefined)
|
||||
throw new Error("Missing fixture records");
|
||||
const fields = {
|
||||
question: "Does this branch conflict?",
|
||||
domain: "fixture",
|
||||
severity: "medium" as const,
|
||||
required_authority: null,
|
||||
required_confidence: "observed" as const,
|
||||
required_environment: null,
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
supporting_evidence_ids: [],
|
||||
contradicting_evidence_ids: [],
|
||||
};
|
||||
const initial = createResidualUnknown(fields, created.evidence_id, null);
|
||||
const update = {
|
||||
...fields,
|
||||
unknown_id: initial.unknown_id,
|
||||
expected_revision: 1,
|
||||
status: "investigating" as const,
|
||||
resolution: null,
|
||||
};
|
||||
const first = createEvidenceBundle(records, [
|
||||
initial,
|
||||
updateResidualUnknown(initial, update, left.evidence_id),
|
||||
]);
|
||||
const second = createEvidenceBundle(records, [
|
||||
initial,
|
||||
updateResidualUnknown(
|
||||
initial,
|
||||
{ ...update, status: "blocked" },
|
||||
right.evidence_id,
|
||||
),
|
||||
]);
|
||||
const paths = [join(root, "first.json"), join(root, "second.json")];
|
||||
await Promise.all([
|
||||
writeFile(paths[0]!, JSON.stringify(first)),
|
||||
writeFile(paths[1]!, JSON.stringify(second)),
|
||||
]);
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("no target");
|
||||
});
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "conflicting-history", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const emptyExport = await client.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: join(root, "empty-export.json") },
|
||||
});
|
||||
expect(emptyExport.isError).not.toBe(true);
|
||||
expect(emptyExport.structuredContent).toMatchObject({
|
||||
result: { records: 0, unknowns: 0 },
|
||||
});
|
||||
const imported = await client.callTool({
|
||||
name: "import_evidence_bundle",
|
||||
arguments: { path: paths[0]! },
|
||||
});
|
||||
expect(imported.isError).not.toBe(true);
|
||||
const exportResult = await client.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: join(root, "revisions-export.json") },
|
||||
});
|
||||
expect(exportResult.isError).not.toBe(true);
|
||||
expect(exportResult.structuredContent).toMatchObject({
|
||||
result: { records: records.length, unknowns: 2 },
|
||||
});
|
||||
const before = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const conflict = await client.callTool({
|
||||
name: "import_evidence_bundle",
|
||||
arguments: { path: paths[1]! },
|
||||
});
|
||||
expect(conflict.isError).toBe(true);
|
||||
expect(parseMcpToolError(conflict).error).toMatchObject({
|
||||
code: "evidence_integrity_mismatch",
|
||||
retryable: false,
|
||||
remediation: { action: expect.stringContaining("cannot reconcile") },
|
||||
details: { diagnostics: { unknown_id: initial.unknown_id, revision: 2 } },
|
||||
});
|
||||
const after = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
expect(after.structuredContent).toEqual(before.structuredContent);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not record capability unavailability without supporting Evidence", async () => {
|
||||
const session = createTestBinarySession(
|
||||
providerWithCapabilities(["procedure_pseudo_code"]),
|
||||
|
||||
Reference in new issue
Block a user