mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
fix(javascript): retain completed facts through cancellation (#1934)
This commit is contained in:
@@ -159,9 +159,11 @@ completed partial Evidence. MCP failure details return
|
||||
after recording succeeds, so `inspect_analysis_view` can open that analysis
|
||||
directly without serializing the entire partial graph into the error response.
|
||||
CLI failure output retains the complete partial Evidence.
|
||||
Cancellation after source analysis begins likewise
|
||||
retains completed facts; an SDK client may reject its cancelled request before
|
||||
receiving that reply. On the same MCP connection, inspect the Evidence bundle
|
||||
Cancellation after source analysis begins likewise retains completed facts,
|
||||
including cancellation during analyzer cleanup, graph construction, or Evidence
|
||||
sealing. REA finishes projecting those owned facts cooperatively to return partial
|
||||
Evidence. An SDK client may reject its cancelled request before receiving that
|
||||
reply. On the same MCP connection, inspect the Evidence bundle
|
||||
and use the retained analysis ID with `inspect_analysis_view`. Owned analyzer
|
||||
cleanup is verified before its private files are released. Cleanup uncertainty
|
||||
is reported separately and retains ownership for retry.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
analyzeJavaScriptApplicationInputSchema,
|
||||
type JavaScriptApplicationAnalysisResult,
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
@@ -20,7 +21,10 @@ import {
|
||||
checkpointJavaScriptAnalysis,
|
||||
completeJavaScriptAnalysisSteps,
|
||||
} from "./JavaScriptAnalysisControl.js";
|
||||
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
|
||||
import {
|
||||
reconstructJavaScriptArtifact,
|
||||
type JavaScriptArtifactReconstructionResult,
|
||||
} from "./JavaScriptArtifactReconstruction.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
|
||||
const OPERATION = "analyze_javascript_application" as const;
|
||||
@@ -45,6 +49,9 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
let reconstructed: JavaScriptArtifactReconstructionResult | undefined;
|
||||
let result: JavaScriptApplicationAnalysisResult | undefined;
|
||||
let evidence: Evidence | undefined;
|
||||
try {
|
||||
assertJavaScriptAnalysisActive(options.signal);
|
||||
await options.progress?.report({
|
||||
@@ -54,7 +61,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
message: "Inventorying and parsing application artifacts",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const reconstructed = await reconstructJavaScriptArtifact(
|
||||
reconstructed = await reconstructJavaScriptArtifact(
|
||||
{
|
||||
input_path: input.input_path,
|
||||
format: input.format,
|
||||
@@ -78,7 +85,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
message: "Validating result metadata and cross-graph bindings",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const result = await completeJavaScriptAnalysisSteps(
|
||||
result = await completeJavaScriptAnalysisSteps(
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps({
|
||||
...application,
|
||||
summary,
|
||||
@@ -93,13 +100,12 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
message: "Creating and hashing application analysis Evidence",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const evidence =
|
||||
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
result,
|
||||
options.signal,
|
||||
options.progress,
|
||||
);
|
||||
evidence = await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
result,
|
||||
options.signal,
|
||||
options.progress,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
await options.progress?.report({
|
||||
phase: "analyze_javascript_application",
|
||||
@@ -111,6 +117,38 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
assertJavaScriptAnalysisActive(options.signal);
|
||||
return ok(evidence);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
cause.reason === "cancelled" &&
|
||||
reconstructed !== undefined
|
||||
) {
|
||||
try {
|
||||
if (result === undefined) {
|
||||
const { electron_summary: summary, ...application } = reconstructed;
|
||||
result = await completeJavaScriptAnalysisSteps(
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps({
|
||||
...application,
|
||||
summary,
|
||||
limitations: reconstructed.graph.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
evidence ??=
|
||||
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
result,
|
||||
);
|
||||
return err(
|
||||
artifactFailureToOperationError(cause).retainPartialObservation(
|
||||
evidence,
|
||||
),
|
||||
);
|
||||
} catch (completionCause: unknown) {
|
||||
// A validation or Evidence failure while finishing the partial result
|
||||
// remains the primary failure instead of being hidden by cancellation.
|
||||
cause = completionCause;
|
||||
}
|
||||
}
|
||||
// Preserve execution constraints and their completed partial Evidence.
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, lstat, realpath } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascript
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
@@ -68,19 +70,17 @@ export const reconstructJavaScriptArtifact = async (
|
||||
progress?: ProgressReporter,
|
||||
): Promise<JavaScriptArtifactReconstructionResult> =>
|
||||
scope.run(async () => {
|
||||
let effectiveSignal = signal;
|
||||
let effectiveProgress = progress;
|
||||
const reportPhase = async (
|
||||
phase: string,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
await effectiveProgress?.report({
|
||||
await progress?.report({
|
||||
phase,
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message,
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(effectiveSignal);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
};
|
||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||
abortIfNeeded(signal);
|
||||
@@ -182,14 +182,24 @@ export const reconstructJavaScriptArtifact = async (
|
||||
}
|
||||
}
|
||||
const analysis = outcome.analysis;
|
||||
const interruption = cleanupFailure ?? outcome.interruption;
|
||||
if (interruption !== null && interruption !== undefined) {
|
||||
effectiveSignal = undefined;
|
||||
effectiveProgress = undefined;
|
||||
}
|
||||
await checkpointJavaScriptAnalysis(effectiveSignal);
|
||||
abortIfNeeded(effectiveSignal);
|
||||
await reportPhase(
|
||||
let interruption = cleanupFailure ?? outcome.interruption;
|
||||
// Source facts are owned now. Finish their projection cooperatively so a
|
||||
// later cancellation can retain Evidence instead of discarding those facts.
|
||||
const checkpointCompletedAnalysis = async (): Promise<void> => {
|
||||
await setImmediate();
|
||||
if (interruption == null && signal?.aborted === true)
|
||||
interruption = new ArtifactOperationError(OPERATION, "cancelled");
|
||||
};
|
||||
const reportCompletedPhase = async (
|
||||
phase: string,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
await checkpointCompletedAnalysis();
|
||||
if (interruption == null)
|
||||
await progress?.report({ phase, completed: 0, total: 1, message });
|
||||
await checkpointCompletedAnalysis();
|
||||
};
|
||||
await reportCompletedPhase(
|
||||
"build_javascript_application_graph",
|
||||
"Constructing application and Electron boundary relationships",
|
||||
);
|
||||
@@ -198,15 +208,14 @@ export const reconstructJavaScriptArtifact = async (
|
||||
files,
|
||||
analysis,
|
||||
);
|
||||
await reportPhase(
|
||||
await reportCompletedPhase(
|
||||
"seal_javascript_application_graph",
|
||||
"Sealing the validated application graph",
|
||||
);
|
||||
const graph = await completeJavaScriptAnalysisSteps(
|
||||
applicationGraphSteps,
|
||||
effectiveSignal,
|
||||
);
|
||||
await reportPhase(
|
||||
await reportCompletedPhase(
|
||||
"build_javascript_semantic_graph",
|
||||
"Binding and validating static semantic relationships",
|
||||
);
|
||||
@@ -214,15 +223,13 @@ export const reconstructJavaScriptArtifact = async (
|
||||
snapshot.manifest.root_sha256,
|
||||
graph,
|
||||
);
|
||||
await reportPhase(
|
||||
await reportCompletedPhase(
|
||||
"seal_javascript_semantic_graph",
|
||||
"Validating and sealing the semantic graph",
|
||||
);
|
||||
const semanticGraph = await completeJavaScriptAnalysisSteps(
|
||||
semanticGraphSteps,
|
||||
effectiveSignal,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(effectiveSignal);
|
||||
const semanticGraph =
|
||||
await completeJavaScriptAnalysisSteps(semanticGraphSteps);
|
||||
await checkpointCompletedAnalysis();
|
||||
const completed = {
|
||||
input_path: path,
|
||||
format,
|
||||
|
||||
@@ -36,11 +36,30 @@ it("returns a nonzero exit for a streamed JSON analysis failure", async () => {
|
||||
|
||||
// `analyze` routes a JavaScript directory to the same workflow and must keep
|
||||
// its typed cancellation contract.
|
||||
it
|
||||
.skipIf(process.platform === "win32")
|
||||
.each(["analyze-javascript-application", "analyze"])(
|
||||
"returns a typed cancelled CLI result and exit 130 after SIGINT through %s",
|
||||
async (command) => {
|
||||
it.skipIf(process.platform === "win32").each([
|
||||
{
|
||||
command: "analyze-javascript-application",
|
||||
phase: "analyze_javascript_application",
|
||||
parsedFiles: null,
|
||||
},
|
||||
{
|
||||
command: "analyze",
|
||||
phase: "analyze_javascript_application",
|
||||
parsedFiles: null,
|
||||
},
|
||||
{
|
||||
command: "analyze-javascript-application",
|
||||
phase: "build_javascript_application_graph",
|
||||
parsedFiles: 1,
|
||||
},
|
||||
{
|
||||
command: "analyze-javascript-application",
|
||||
phase: "hash_javascript_application_evidence",
|
||||
parsedFiles: 1,
|
||||
},
|
||||
])(
|
||||
"returns typed cancellation and exit 130 after SIGINT through $command during $phase",
|
||||
async ({ command, phase, parsedFiles }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-js-cancellation-");
|
||||
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
|
||||
const child = spawn(
|
||||
@@ -70,7 +89,11 @@ it
|
||||
const line = partial.slice(0, newline);
|
||||
partial = partial.slice(newline + 1);
|
||||
const parsed = progressSchema.safeParse(JSON.parse(line));
|
||||
if (parsed.success && !requested) {
|
||||
if (
|
||||
parsed.success &&
|
||||
parsed.data.rea_progress.phase === phase &&
|
||||
!requested
|
||||
) {
|
||||
requested = true;
|
||||
child.kill("SIGINT");
|
||||
}
|
||||
@@ -89,5 +112,18 @@ it
|
||||
code: "cancelled",
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
if (parsedFiles !== null)
|
||||
expect(JSON.parse(stdout)).toMatchObject({
|
||||
details: {
|
||||
partial_observation: {
|
||||
normalized_result: {
|
||||
statistics: {
|
||||
parsed_javascript_files: parsedFiles,
|
||||
parse_failures: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,13 +1,72 @@
|
||||
import { setImmediate } from "node:timers";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ArtifactResourceScope,
|
||||
type ArtifactResourceOwner,
|
||||
} from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { analyzeJavaScriptApplication as analyzeOwnedApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
|
||||
it.each(["during cleanup", "on the next control turn"] as const)(
|
||||
"retains the final source when cancellation arrives %s after analysis",
|
||||
async (timing) => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-handoff-");
|
||||
await writeFile(
|
||||
join(root, "main.js"),
|
||||
'import { readFile } from "node:fs/promises"; export { readFile };\n',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
class CancellingCleanupScope extends ArtifactResourceScope {
|
||||
override async release(owner: ArtifactResourceOwner) {
|
||||
const result = await super.release(owner);
|
||||
if (owner.kind === "javascript-source-analysis") {
|
||||
if (timing === "during cleanup") controller.abort();
|
||||
else setImmediate(() => controller.abort());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
const scope = new CancellingCleanupScope();
|
||||
onTestFinished(() => scope.close());
|
||||
const result = await analyzeOwnedApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
scope,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Cancelled handoff must not succeed");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.code).toBe("cancelled");
|
||||
const partial = z
|
||||
.object({
|
||||
details: z.object({
|
||||
partial_observation: z.object({
|
||||
normalized_result: javascriptApplicationAnalysisResultSchema,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.parse(projected).details.partial_observation.normalized_result;
|
||||
expect(partial.statistics).toMatchObject({
|
||||
relevant_files: 1,
|
||||
parsed_javascript_files: 1,
|
||||
parse_failures: 0,
|
||||
});
|
||||
expect(partial.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
specifier: "node:fs/promises",
|
||||
module_link_kind: "import",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves imported manifest status when cancelled before visiting manifests", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-package-");
|
||||
const manifests = [
|
||||
@@ -75,156 +134,169 @@ it("preserves imported manifest status when cancelled before visiting manifests"
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript application cancellation", () => {
|
||||
it("interrupts semantic graph commitment before result validation", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-semantic-");
|
||||
await writeFile(
|
||||
join(root, "main.js"),
|
||||
Array.from(
|
||||
{ length: 500 },
|
||||
(_, index) =>
|
||||
`export function inspect${index}(value) { return JSON.stringify(value); }`,
|
||||
).join("\n"),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const phases: string[] = [];
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
phases.push(event.phase);
|
||||
if (event.phase === "seal_javascript_semantic_graph")
|
||||
setImmediate(() => controller.abort());
|
||||
},
|
||||
it("interrupts semantic graph commitment before result validation", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-semantic-");
|
||||
await writeFile(
|
||||
join(root, "main.js"),
|
||||
Array.from(
|
||||
{ length: 500 },
|
||||
(_, index) =>
|
||||
`export function inspect${index}(value) { return JSON.stringify(value); }`,
|
||||
).join("\n"),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const phases: string[] = [];
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
phases.push(event.phase);
|
||||
if (event.phase === "seal_javascript_semantic_graph")
|
||||
setImmediate(() => controller.abort());
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error(
|
||||
"Cancelled commitment must not publish a successful analysis",
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error(
|
||||
"Cancelled commitment must not publish a successful analysis",
|
||||
);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
expect(phases).toContain("seal_javascript_semantic_graph");
|
||||
expect(phases).not.toContain("validate_javascript_application_result");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
expect(phases).toContain("seal_javascript_semantic_graph");
|
||||
expect(phases).not.toContain("validate_javascript_application_result");
|
||||
});
|
||||
|
||||
it("honors control turns and cancellation while preparing one large source for owned analysis (#1462)", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-in-file-");
|
||||
await writeFile(
|
||||
join(root, "index.js"),
|
||||
Array.from(
|
||||
{ length: 1200 },
|
||||
(_, index) =>
|
||||
`export function f${index}(){return {name:'f${index}',value:${index}};}`,
|
||||
).join("\n"),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const phases: string[] = [];
|
||||
let turns = 0;
|
||||
// Count event-loop turns once the file starts; parsing, analysis and
|
||||
// projection of this one file must leave room for them.
|
||||
const tick = (): void => {
|
||||
turns += 1;
|
||||
if (turns === 3) controller.abort();
|
||||
else if (!controller.signal.aborted) setImmediate(tick);
|
||||
};
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
phases.push(event.phase);
|
||||
if (event.phase === "parse_javascript_source") setImmediate(tick);
|
||||
},
|
||||
it("honors control turns and cancellation while preparing one large source for owned analysis (#1462)", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-in-file-");
|
||||
await writeFile(
|
||||
join(root, "index.js"),
|
||||
Array.from(
|
||||
{ length: 1200 },
|
||||
(_, index) =>
|
||||
`export function f${index}(){return {name:'f${index}',value:${index}};}`,
|
||||
).join("\n"),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const phases: string[] = [];
|
||||
let turns = 0;
|
||||
// Count event-loop turns once the file starts; parsing, analysis and
|
||||
// projection of this one file must leave room for them.
|
||||
const tick = (): void => {
|
||||
turns += 1;
|
||||
if (turns === 3) controller.abort();
|
||||
else if (!controller.signal.aborted) setImmediate(tick);
|
||||
};
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
phases.push(event.phase);
|
||||
if (event.phase === "parse_javascript_source") setImmediate(tick);
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok) throw new Error("Cancelled analysis must not succeed");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
});
|
||||
expect(phases).toContain("parse_javascript_source");
|
||||
expect(phases).not.toContain("build_javascript_application_graph");
|
||||
},
|
||||
);
|
||||
if (result.ok) throw new Error("Cancelled analysis must not succeed");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
});
|
||||
expect(phases).toContain("parse_javascript_source");
|
||||
expect(phases).not.toContain("build_javascript_application_graph");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"parse_javascript_source",
|
||||
"build_javascript_application_graph",
|
||||
"seal_javascript_application_graph",
|
||||
"build_javascript_semantic_graph",
|
||||
"seal_javascript_semantic_graph",
|
||||
"validate_javascript_application_result",
|
||||
"create_javascript_application_evidence",
|
||||
"seal_javascript_application_result",
|
||||
"validate_javascript_application_evidence",
|
||||
"hash_javascript_application_evidence",
|
||||
])("rejects cancellation scheduled during %s", async (phase) => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-phase-");
|
||||
await writeFile(
|
||||
join(root, "main.js"),
|
||||
"export function inspect(value) { return JSON.stringify(value); }\n",
|
||||
);
|
||||
const controller = new AbortController();
|
||||
let requested = false;
|
||||
const terminal: string[] = [];
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
if (event.terminal) terminal.push(event.phase);
|
||||
if (event.phase === phase) {
|
||||
requested = true;
|
||||
setImmediate(() => controller.abort());
|
||||
}
|
||||
it.each([
|
||||
"parse_javascript_source",
|
||||
"build_javascript_application_graph",
|
||||
"seal_javascript_application_graph",
|
||||
"build_javascript_semantic_graph",
|
||||
"seal_javascript_semantic_graph",
|
||||
"validate_javascript_application_result",
|
||||
"create_javascript_application_evidence",
|
||||
"seal_javascript_application_result",
|
||||
"validate_javascript_application_evidence",
|
||||
"hash_javascript_application_evidence",
|
||||
])("rejects cancellation scheduled during %s", async (phase) => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-phase-");
|
||||
await writeFile(
|
||||
join(root, "main.js"),
|
||||
"export function inspect(value) { return JSON.stringify(value); }\n",
|
||||
);
|
||||
const controller = new AbortController();
|
||||
let requested = false;
|
||||
const terminal: string[] = [];
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
if (event.terminal) terminal.push(event.phase);
|
||||
if (event.phase === phase) {
|
||||
requested = true;
|
||||
setImmediate(() => controller.abort());
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(requested).toBe(true);
|
||||
if (result.ok) throw new Error("Cancelled analysis must return a failure");
|
||||
const failure = projectAnalysisError(result.error);
|
||||
expect(failure).toMatchObject({ code: "cancelled" });
|
||||
expect(failure).toMatchObject({
|
||||
details: {
|
||||
operation: "analyze_javascript_application",
|
||||
partial_observation: {
|
||||
normalized_result: {
|
||||
statistics: {
|
||||
relevant_files: 1,
|
||||
parsed_javascript_files:
|
||||
phase === "parse_javascript_source" ? 0 : 1,
|
||||
parse_failures: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(requested).toBe(true);
|
||||
if (result.ok) throw new Error("Cancelled analysis must return a failure");
|
||||
const failure = projectAnalysisError(result.error);
|
||||
expect(failure).toMatchObject({ code: "cancelled" });
|
||||
if (phase === "parse_javascript_source")
|
||||
expect(failure).toMatchObject({
|
||||
details: {
|
||||
operation: "analyze_javascript_application",
|
||||
cleanup: "complete",
|
||||
partial_observation: {
|
||||
normalized_result: {
|
||||
statistics: { relevant_files: 1, parse_failures: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(terminal).toEqual([]);
|
||||
},
|
||||
});
|
||||
if (phase === "parse_javascript_source")
|
||||
expect(failure.details).toMatchObject({ cleanup: "complete" });
|
||||
expect(terminal).toEqual([]);
|
||||
});
|
||||
|
||||
it("honors cancellation observed during the terminal publication race", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-terminal-");
|
||||
await writeFile(join(root, "main.js"), "export const value = 1;\n");
|
||||
const controller = new AbortController();
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
if (event.terminal) controller.abort();
|
||||
},
|
||||
it("honors cancellation observed during the terminal publication race", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-terminal-");
|
||||
await writeFile(join(root, "main.js"), "export const value = 1;\n");
|
||||
const controller = new AbortController();
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
if (event.terminal) controller.abort();
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation before admission");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
},
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation before admission");
|
||||
const failure = projectAnalysisError(result.error);
|
||||
expect(failure).toMatchObject({
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
expect(failure).toMatchObject({
|
||||
details: {
|
||||
partial_observation: {
|
||||
normalized_result: {
|
||||
statistics: { relevant_files: 1, parsed_javascript_files: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,128 +12,143 @@ import {
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("retains completed source facts and prior Evidence after SDK cancellation, then accepts subsequent work", async () => {
|
||||
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
|
||||
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
|
||||
await writeFile(join(root, "second.js"), "export const pending = 2;\n");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error(
|
||||
"Static JavaScript analysis must not start a binary provider",
|
||||
);
|
||||
});
|
||||
const prior = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "prior_observation",
|
||||
parameters: {},
|
||||
result: { preserved: true },
|
||||
},
|
||||
);
|
||||
expect(session.recordEvidence(prior).ok).toBe(true);
|
||||
const completed = createDeferred<string>();
|
||||
const completionSchema = z.object({
|
||||
tool: z.string(),
|
||||
status: z.string(),
|
||||
msg: z.string(),
|
||||
});
|
||||
const logger = pino(
|
||||
{ level: "info" },
|
||||
{
|
||||
write(line) {
|
||||
const value: unknown = JSON.parse(line);
|
||||
const parsed = completionSchema.safeParse(value);
|
||||
if (
|
||||
parsed.success &&
|
||||
parsed.data.tool === "analyze_javascript_application" &&
|
||||
parsed.data.msg === "MCP tool execution completed"
|
||||
)
|
||||
completed.resolve(parsed.data.status);
|
||||
it.each([
|
||||
{ phase: "parse_javascript_source", message: "second.js", parsedFiles: 1 },
|
||||
{
|
||||
phase: "analyze_javascript_application",
|
||||
message: "Application graph and Electron boundaries reconstructed",
|
||||
parsedFiles: 2,
|
||||
},
|
||||
])(
|
||||
"retains completed facts and prior Evidence after SDK cancellation during $phase, then accepts subsequent work",
|
||||
async ({ phase, message, parsedFiles }) => {
|
||||
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
|
||||
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
|
||||
await writeFile(join(root, "second.js"), "export const pending = 2;\n");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error(
|
||||
"Static JavaScript analysis must not start a binary provider",
|
||||
);
|
||||
});
|
||||
const prior = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "prior_observation",
|
||||
parameters: {},
|
||||
result: { preserved: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
const server = createServer({ kind: "session", session }, { logger });
|
||||
const client = new Client({ name: "javascript-cancellation", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const controller = new AbortController();
|
||||
let requested = false;
|
||||
const call = client.callTool(
|
||||
{
|
||||
);
|
||||
expect(session.recordEvidence(prior).ok).toBe(true);
|
||||
const completed = createDeferred<string>();
|
||||
const completionSchema = z.object({
|
||||
tool: z.string(),
|
||||
status: z.string(),
|
||||
msg: z.string(),
|
||||
});
|
||||
const logger = pino(
|
||||
{ level: "info" },
|
||||
{
|
||||
write(line) {
|
||||
const value: unknown = JSON.parse(line);
|
||||
const parsed = completionSchema.safeParse(value);
|
||||
if (
|
||||
parsed.success &&
|
||||
parsed.data.tool === "analyze_javascript_application" &&
|
||||
parsed.data.msg === "MCP tool execution completed"
|
||||
)
|
||||
completed.resolve(parsed.data.status);
|
||||
},
|
||||
},
|
||||
);
|
||||
const server = createServer({ kind: "session", session }, { logger });
|
||||
const client = new Client({
|
||||
name: "javascript-cancellation",
|
||||
version: "1",
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const controller = new AbortController();
|
||||
let requested = false;
|
||||
const call = client.callTool(
|
||||
{
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
signal: controller.signal,
|
||||
onprogress(update) {
|
||||
if (
|
||||
!update.message?.startsWith(`${phase}:`) ||
|
||||
!update.message.includes(message)
|
||||
)
|
||||
return;
|
||||
requested = true;
|
||||
controller.abort(new Error("SDK cancellation verification"));
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(call).rejects.toThrow("SDK cancellation verification");
|
||||
expect(requested).toBe(true);
|
||||
// A rejected client promise alone does not prove that the server stopped.
|
||||
expect(await completed.promise).toBe("error");
|
||||
await client.ping();
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const records = z
|
||||
.object({
|
||||
result: z.object({
|
||||
records: z.array(z.object({ evidence_id: z.string() })),
|
||||
}),
|
||||
})
|
||||
.parse(bundle.structuredContent).result.records;
|
||||
expect(records).toContainEqual({ evidence_id: prior.evidence_id });
|
||||
const partial = records.find(
|
||||
({ evidence_id }) => evidence_id !== prior.evidence_id,
|
||||
);
|
||||
if (partial === undefined)
|
||||
throw new Error(
|
||||
"Cancelled analysis discarded its completed source facts",
|
||||
);
|
||||
expect(
|
||||
z
|
||||
.object({
|
||||
statistics: z.object({
|
||||
parsed_javascript_files: z.number(),
|
||||
parse_failures: z.number(),
|
||||
}),
|
||||
})
|
||||
.parse(session.evidenceById(partial.evidence_id)?.normalized_result)
|
||||
.statistics,
|
||||
).toEqual({ parsed_javascript_files: parsedFiles, parse_failures: 0 });
|
||||
const view = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source: { kind: "retained-evidence", evidence_id: partial.evidence_id },
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
});
|
||||
expect(view.isError, JSON.stringify(view)).not.toBe(true);
|
||||
const next = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
signal: controller.signal,
|
||||
onprogress(update) {
|
||||
if (
|
||||
!update.message?.startsWith("parse_javascript_source:") ||
|
||||
!update.message.includes("second.js")
|
||||
)
|
||||
return;
|
||||
requested = true;
|
||||
controller.abort(new Error("SDK cancellation verification"));
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(call).rejects.toThrow("SDK cancellation verification");
|
||||
expect(requested).toBe(true);
|
||||
// A rejected client promise alone does not prove that the server stopped.
|
||||
expect(await completed.promise).toBe("error");
|
||||
await client.ping();
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const records = z
|
||||
.object({
|
||||
result: z.object({
|
||||
records: z.array(z.object({ evidence_id: z.string() })),
|
||||
}),
|
||||
})
|
||||
.parse(bundle.structuredContent).result.records;
|
||||
expect(records).toContainEqual({ evidence_id: prior.evidence_id });
|
||||
const partial = records.find(
|
||||
({ evidence_id }) => evidence_id !== prior.evidence_id,
|
||||
);
|
||||
if (partial === undefined)
|
||||
throw new Error("Cancelled analysis discarded its completed source facts");
|
||||
expect(
|
||||
z
|
||||
.object({
|
||||
statistics: z.object({
|
||||
parsed_javascript_files: z.number(),
|
||||
parse_failures: z.number(),
|
||||
}),
|
||||
})
|
||||
.parse(session.evidenceById(partial.evidence_id)?.normalized_result)
|
||||
.statistics,
|
||||
).toEqual({ parsed_javascript_files: 1, parse_failures: 0 });
|
||||
const view = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source: { kind: "retained-evidence", evidence_id: partial.evidence_id },
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
});
|
||||
expect(view.isError, JSON.stringify(view)).not.toBe(true);
|
||||
const next = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
},
|
||||
});
|
||||
expect(next.isError, JSON.stringify(next)).not.toBe(true);
|
||||
await client.ping();
|
||||
});
|
||||
});
|
||||
expect(next.isError, JSON.stringify(next)).not.toBe(true);
|
||||
await client.ping();
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user