fix(workspace): migrate legacy integrity identities
This commit is contained in:
parent
6540d64848
commit
009fa3311e
|
|
@ -13,6 +13,7 @@ import {
|
|||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import {
|
||||
createLegacyInvestigationRunIdentity,
|
||||
createInvestigationRunIdentity,
|
||||
createInvestigationWorkspace,
|
||||
crossVersionInvestigationInputSchema,
|
||||
|
|
@ -137,8 +138,20 @@ const continueInvestigation = async (context: {
|
|||
integrity_continue_approved: context.input.integrity_continue_approved,
|
||||
max_integrity_mismatches: context.input.max_integrity_mismatches,
|
||||
});
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity({
|
||||
left,
|
||||
right,
|
||||
options: context.input.options,
|
||||
});
|
||||
const existing = context.initial?.runs.find(
|
||||
({ run_id: id }) => id === identity.runId,
|
||||
(run) =>
|
||||
run.run_id === identity.runId ||
|
||||
(context.input.integrity_policy === "fail" &&
|
||||
!context.input.integrity_continue_approved &&
|
||||
context.input.max_integrity_mismatches === 10 &&
|
||||
"legacy_request_identity" in run &&
|
||||
run.max_integrity_mismatches === 10 &&
|
||||
run.run_id === legacyIdentity.runId),
|
||||
);
|
||||
if (context.initial !== null && existing?.status === "complete")
|
||||
return completeOutcome(context.initial, existing, true, context.session);
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ const stageSchema = z.enum([
|
|||
"find_changed_behavior",
|
||||
]);
|
||||
|
||||
export const investigationRunSchema = z.object({
|
||||
const investigationRunBaseSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
|
||||
request_sha256: digestSchema,
|
||||
|
|
@ -119,6 +119,31 @@ export const investigationRunSchema = z.object({
|
|||
limitations: z.array(z.string().max(4_096)).max(100),
|
||||
});
|
||||
|
||||
export const investigationRunSchema = z
|
||||
.union([
|
||||
investigationRunBaseSchema.extend({
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
investigationRunBaseSchema,
|
||||
])
|
||||
.superRefine((run, context) => {
|
||||
if (!("legacy_request_identity" in run)) return;
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
run.integrity_policy !== "fail" ||
|
||||
run.integrity_continue_approved ||
|
||||
run.max_integrity_mismatches !== 10 ||
|
||||
run.run_id !== legacyIdentity.runId ||
|
||||
run.request_sha256 !== legacyIdentity.requestSha256
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["legacy_request_identity"],
|
||||
message:
|
||||
"Legacy request identity is valid only for migrated strict runs",
|
||||
});
|
||||
});
|
||||
|
||||
export type InvestigationRun = z.infer<typeof investigationRunSchema>;
|
||||
|
||||
export const investigationRunSummarySchema = z.object({
|
||||
|
|
@ -146,6 +171,20 @@ const investigationWorkspaceSchema = z.object({
|
|||
runs: z.array(investigationRunSchema).max(1_000),
|
||||
});
|
||||
|
||||
const legacyInvestigationRunSchema = investigationRunBaseSchema
|
||||
.omit({
|
||||
integrity_policy: true,
|
||||
integrity_continue_approved: true,
|
||||
max_integrity_mismatches: true,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema
|
||||
.extend({
|
||||
runs: z.array(legacyInvestigationRunSchema).max(1_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type InvestigationWorkspace = z.infer<
|
||||
typeof investigationWorkspaceSchema
|
||||
>;
|
||||
|
|
@ -207,6 +246,7 @@ export const reviseInvestigationWorkspace = (
|
|||
export const parseInvestigationWorkspace = (
|
||||
input: unknown,
|
||||
): InvestigationWorkspace => {
|
||||
if (isLegacyWorkspace(input)) return migrateLegacyWorkspace(input);
|
||||
const parsed = investigationWorkspaceSchema.parse(input);
|
||||
const bundle = parseEvidenceBundle(parsed.bundle);
|
||||
const canonical = buildWorkspace({
|
||||
|
|
@ -268,9 +308,16 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
|
|||
);
|
||||
for (const run of workspace.runs) {
|
||||
const expectedIdentity = createInvestigationRunIdentity(run);
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
run.run_id !== expectedIdentity.runId ||
|
||||
run.request_sha256 !== expectedIdentity.requestSha256
|
||||
(run.run_id !== expectedIdentity.runId ||
|
||||
run.request_sha256 !== expectedIdentity.requestSha256) &&
|
||||
(!("legacy_request_identity" in run) ||
|
||||
run.integrity_policy !== "fail" ||
|
||||
run.integrity_continue_approved ||
|
||||
run.max_integrity_mismatches !== 10 ||
|
||||
run.run_id !== legacyIdentity.runId ||
|
||||
run.request_sha256 !== legacyIdentity.requestSha256)
|
||||
)
|
||||
throw new TypeError("Investigation run identity is invalid");
|
||||
validateRunStages(run);
|
||||
|
|
@ -287,6 +334,85 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
|
|||
}
|
||||
};
|
||||
|
||||
/** Derive the pre-integrity-policy run identity for strict workspace migration. */
|
||||
export const createLegacyInvestigationRunIdentity = (input: {
|
||||
readonly left: InvestigationRunTarget;
|
||||
readonly right: InvestigationRunTarget;
|
||||
readonly options: InvestigationRunOptions;
|
||||
}): { readonly runId: string; readonly requestSha256: string } => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: input.left,
|
||||
right: input.right,
|
||||
options: input.options,
|
||||
});
|
||||
return { runId: `run_${requestSha256}`, requestSha256 };
|
||||
};
|
||||
|
||||
const isLegacyWorkspace = (input: unknown): boolean => {
|
||||
if (typeof input !== "object" || input === null || !("runs" in input))
|
||||
return false;
|
||||
const runs = input.runs;
|
||||
return (
|
||||
Array.isArray(runs) &&
|
||||
runs.some(
|
||||
(run) =>
|
||||
typeof run === "object" && run !== null && !("integrity_policy" in run),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const migrateLegacyWorkspace = (input: unknown): InvestigationWorkspace => {
|
||||
const parsed = legacyInvestigationWorkspaceSchema.parse(input);
|
||||
const bundle = parseEvidenceBundle(parsed.bundle);
|
||||
const runs = [...parsed.runs].sort((left, right) =>
|
||||
left.run_id.localeCompare(right.run_id),
|
||||
);
|
||||
const semantic = {
|
||||
workspace_version: 1,
|
||||
workspace_id: parsed.workspace_id,
|
||||
name: parsed.name,
|
||||
revision: parsed.revision,
|
||||
previous_revision_digest: parsed.previous_revision_digest,
|
||||
bundle,
|
||||
runs,
|
||||
} satisfies JsonValue;
|
||||
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
|
||||
throw new TypeError("Investigation workspace ID does not match its name");
|
||||
if (parsed.revision_digest !== `wrev_${digestCanonical(semantic)}`)
|
||||
throw new TypeError("Investigation workspace revision digest is invalid");
|
||||
const canonical = legacyInvestigationWorkspaceSchema.parse({
|
||||
...semantic,
|
||||
revision_digest: parsed.revision_digest,
|
||||
});
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
|
||||
throw new TypeError("Investigation workspace is not canonical");
|
||||
for (const run of runs) {
|
||||
const identity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
run.run_id !== identity.runId ||
|
||||
run.request_sha256 !== identity.requestSha256
|
||||
)
|
||||
throw new TypeError("Investigation run identity is invalid");
|
||||
}
|
||||
const migrated = buildWorkspace({
|
||||
workspaceId: parsed.workspace_id,
|
||||
name: parsed.name,
|
||||
revision: parsed.revision + 1,
|
||||
previousRevisionDigest: parsed.revision_digest,
|
||||
bundle,
|
||||
runs: runs.map((run) => ({
|
||||
...run,
|
||||
legacy_request_identity: true as const,
|
||||
integrity_policy: "fail" as const,
|
||||
integrity_continue_approved: false,
|
||||
max_integrity_mismatches: 10,
|
||||
})),
|
||||
});
|
||||
validateRunReferences(migrated);
|
||||
return migrated;
|
||||
};
|
||||
|
||||
const validateRunStages = (run: InvestigationRun): void => {
|
||||
const expected = [
|
||||
"inventory_left",
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import {
|
|||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js";
|
||||
import {
|
||||
|
|
@ -26,9 +28,16 @@ import {
|
|||
createInvestigationWorkspace,
|
||||
crossVersionInvestigationInputSchema,
|
||||
investigationRunSchema,
|
||||
parseInvestigationWorkspace,
|
||||
serializeInvestigationWorkspace,
|
||||
} from "../src/domain/investigationWorkspace.js";
|
||||
|
||||
const digestCanonical = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined) throw new TypeError("fixture is not canonical");
|
||||
return createHash("sha256").update(encoded).digest("hex");
|
||||
};
|
||||
|
||||
let directory: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -68,6 +77,116 @@ const fixture = async () => {
|
|||
};
|
||||
|
||||
describe("persistent cross-version investigation workspace", () => {
|
||||
it("migrates legacy v1 runs without losing their revision chain", async () => {
|
||||
const { input } = await fixture();
|
||||
if (directory === undefined) throw new Error("missing fixture root");
|
||||
const completed = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{ inputRoots: [directory] },
|
||||
);
|
||||
if (!completed.ok) throw completed.error;
|
||||
const current = completed.value.workspace;
|
||||
const currentRun = current.runs[0];
|
||||
if (currentRun === undefined) throw new Error("missing completed run");
|
||||
expect(() =>
|
||||
investigationRunSchema.parse({
|
||||
...currentRun,
|
||||
legacy_request_identity: true,
|
||||
}),
|
||||
).toThrow(/migrated strict runs/iu);
|
||||
const legacyIdentity = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: currentRun.left,
|
||||
right: currentRun.right,
|
||||
options: currentRun.options,
|
||||
});
|
||||
expect(() =>
|
||||
investigationRunSchema.parse({
|
||||
...currentRun,
|
||||
legacy_request_identity: true,
|
||||
run_id: `run_${legacyIdentity}`,
|
||||
request_sha256: legacyIdentity,
|
||||
max_integrity_mismatches: 100,
|
||||
}),
|
||||
).toThrow(/migrated strict runs/iu);
|
||||
const legacyRuns = current.runs.map((run) => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: run.left,
|
||||
right: run.right,
|
||||
options: run.options,
|
||||
});
|
||||
const {
|
||||
integrity_policy: _integrityPolicy,
|
||||
integrity_continue_approved: _integrityApproved,
|
||||
max_integrity_mismatches: _integrityLimit,
|
||||
...legacy
|
||||
} = run;
|
||||
return {
|
||||
...legacy,
|
||||
run_id: `run_${requestSha256}`,
|
||||
request_sha256: requestSha256,
|
||||
};
|
||||
});
|
||||
const legacySemantic = {
|
||||
workspace_version: 1 as const,
|
||||
workspace_id: current.workspace_id,
|
||||
name: current.name,
|
||||
revision: current.revision,
|
||||
previous_revision_digest: current.previous_revision_digest,
|
||||
bundle: current.bundle,
|
||||
runs: legacyRuns,
|
||||
};
|
||||
const legacyDigest = `wrev_${digestCanonical(legacySemantic)}`;
|
||||
expect(() =>
|
||||
parseInvestigationWorkspace({
|
||||
...legacySemantic,
|
||||
runs: legacyRuns.map((run) => ({
|
||||
...run,
|
||||
max_integrity_mismatches: 100,
|
||||
})),
|
||||
revision_digest: legacyDigest,
|
||||
}),
|
||||
).toThrow(/unrecognized key/iu);
|
||||
const migrated = parseInvestigationWorkspace({
|
||||
...legacySemantic,
|
||||
revision_digest: legacyDigest,
|
||||
});
|
||||
|
||||
expect(migrated).toMatchObject({
|
||||
revision: current.revision + 1,
|
||||
previous_revision_digest: legacyDigest,
|
||||
runs: [
|
||||
{
|
||||
legacy_request_identity: true,
|
||||
integrity_policy: "fail",
|
||||
integrity_continue_approved: false,
|
||||
max_integrity_mismatches: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const encodedLegacy = canonicalize({
|
||||
...legacySemantic,
|
||||
revision_digest: legacyDigest,
|
||||
});
|
||||
if (encodedLegacy === undefined)
|
||||
throw new TypeError("legacy fixture is not canonical");
|
||||
await writeFile(input.workspace_path, encodedLegacy, { mode: 0o600 });
|
||||
await expect(
|
||||
runCrossVersionInvestigation(input, policy(directory), {
|
||||
inputRoots: [directory],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, value: { reused: true } });
|
||||
await expect(
|
||||
runCrossVersionInvestigation(
|
||||
{ ...input, integrity_continue_approved: true },
|
||||
policy(directory),
|
||||
{ inputRoots: [directory] },
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, value: { reused: false } });
|
||||
});
|
||||
|
||||
it("checkpoints, validates, and reuses a completed deterministic run", async () => {
|
||||
const { path, input } = await fixture();
|
||||
if (directory === undefined) throw new Error("missing fixture root");
|
||||
|
|
|
|||
Loading…
Reference in New Issue