mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
fix(observation): keep incomplete cancellation observations
Service projection treated a retained observation that failed the completed schema as a malformed completed result. Cancellation became unreadable_output and the partial was dropped. Incomplete observations now stay on the owner error, including the exact abort reason.
This commit is contained in:
@@ -3,9 +3,13 @@ import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import { listBrowserTargets } from "./BrowserObservationService.js";
|
||||
import type { BrowserTargetList } from "../domain/browserObservationSchemas.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
|
||||
import { inspectWebPage } from "./BrowserObservationService.js";
|
||||
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
|
||||
@@ -215,3 +219,71 @@ it("preserves a prior caller reason without admitting browser work", async () =>
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
});
|
||||
|
||||
it("keeps page cancellation when the retained inspection is incomplete", async () => {
|
||||
const reason = { requested: "stop during page inspection" };
|
||||
const partial = {
|
||||
target: { target_id: "page" },
|
||||
network: {},
|
||||
capture_window: {},
|
||||
};
|
||||
const failure = new AnalysisCancelledError("inspect_web_page", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const browser = {
|
||||
...provider(async () => ok(report)),
|
||||
inspectPage: async () => err(failure),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const pageInput = inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
allowed_origins: [],
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: false,
|
||||
});
|
||||
const result = await inspectWebPage(browser, pageInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBe(partial);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { partial_observation: partial },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bundle cancellation and an earlier failure when retained facts are incomplete", async () => {
|
||||
const reason = { requested: "stop during bundle analysis" };
|
||||
const partial = { observations: [], capture: { status: "partial" } };
|
||||
const cancelled = new AnalysisCancelledError("analyze_web_bundle", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const timedOut = new BrowserObservationError(
|
||||
"analyze_web_bundle",
|
||||
"timeout",
|
||||
{ partialObservation: partial as never },
|
||||
);
|
||||
const selected = analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
});
|
||||
for (const failure of [cancelled, timedOut]) {
|
||||
const browser = {
|
||||
...provider(async () => ok(report)),
|
||||
analyzeBundle: async () => err(failure),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const result = await analyzeWebBundle(browser, selected);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected the owner failure");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.partialObservation).toBe(partial);
|
||||
}
|
||||
expect(projectAnalysisError(cancelled)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { partial_observation: partial },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ import { createBrowserEvidence } from "./BrowserEvidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
|
||||
/** List browser targets within the endpoint and origin scope in the request. */
|
||||
export const listBrowserTargets = async (
|
||||
@@ -291,22 +290,7 @@ const retainBrowserObservation = (
|
||||
"capture" in partial
|
||||
) {
|
||||
const bundle = webBundleAnalysisSchema.safeParse(partial);
|
||||
if (!bundle.success) {
|
||||
const malformed = new AnalysisOutputError(
|
||||
errorOperation,
|
||||
"Browser provider retained malformed completed bundle facts.",
|
||||
{ cause: bundle.error },
|
||||
);
|
||||
return err(
|
||||
error.cleanupIncomplete
|
||||
? analysisErrorWithCleanupFailure(
|
||||
malformed,
|
||||
error,
|
||||
errorOperation,
|
||||
).bindCleanupDependencies([malformed, error])
|
||||
: malformed,
|
||||
);
|
||||
}
|
||||
if (!bundle.success) return err(error);
|
||||
error.partialObservation = createBrowserEvidence(
|
||||
"analyze_web_bundle",
|
||||
input,
|
||||
@@ -324,17 +308,7 @@ const retainBrowserObservation = (
|
||||
)
|
||||
return err(error);
|
||||
const inspection = webPageInspectionSchema.safeParse(partial);
|
||||
if (!inspection.success) {
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
errorOperation,
|
||||
"Browser provider retained a malformed completed page inspection.",
|
||||
{ cause: inspection.error },
|
||||
),
|
||||
);
|
||||
return err(error);
|
||||
}
|
||||
if (!inspection.success) return err(error);
|
||||
const pageInput: InspectWebPageInput =
|
||||
"fetch_source_maps" in input
|
||||
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import type {
|
||||
BrowserScenarioCapture,
|
||||
BrowserScenarioPartialObservation,
|
||||
} from "../domain/browserScenarioCapture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js";
|
||||
@@ -103,3 +111,55 @@ it.each([
|
||||
expect(calls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps startup cancellation and its incomplete capture", async () => {
|
||||
const reason = { requested: "stop during initial navigation" };
|
||||
const completed = scriptScenarioFixture();
|
||||
const {
|
||||
completeness: _completeness,
|
||||
steps: _steps,
|
||||
browser,
|
||||
...retained
|
||||
} = completed;
|
||||
const partial: BrowserScenarioPartialObservation = {
|
||||
kind: "browser-scenario-observation",
|
||||
capture: {
|
||||
...retained,
|
||||
browser: { ...browser, cleanup: "incomplete" },
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
const failure = new AnalysisCancelledError("capture_browser_scenario", {
|
||||
cause: reason,
|
||||
partialObservation: partial,
|
||||
});
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({ id: "fixture", name: "fixture", version: "1" }),
|
||||
captureScenario: async () => err(failure),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBe(partial);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { partial_observation: partial },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a successful capture that is not a completed scenario", async () => {
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({ id: "fixture", name: "fixture", version: "1" }),
|
||||
captureScenario: async () => ok({} as BrowserScenarioCapture),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected a completed-capture refusal");
|
||||
expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expect(result.error.message).toContain(
|
||||
"returned a malformed completed capture",
|
||||
);
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ export const captureBrowserScenario = async (
|
||||
partial.kind === "browser-scenario-observation"
|
||||
) {
|
||||
const report = browserScenarioCaptureSchema.safeParse(partial.capture);
|
||||
// Only a completed capture becomes Evidence. An incomplete observation
|
||||
// stays on the owner error, including typed cancellation.
|
||||
if (
|
||||
report.success &&
|
||||
report.data.steps.every(({ status }) => status === "completed")
|
||||
@@ -52,16 +54,6 @@ export const captureBrowserScenario = async (
|
||||
report.data,
|
||||
provider.identity(),
|
||||
);
|
||||
} else if (!report.success) {
|
||||
if (captured.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Browser scenario provider retained a malformed completed capture.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
return captured;
|
||||
|
||||
@@ -6,10 +6,14 @@ import {
|
||||
webListenersFixture,
|
||||
} from "./WebRuntimeService.fixture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
it("rejects malformed input and prior cancellation without calling a provider", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
@@ -161,3 +165,61 @@ it("retains completed execution Evidence on late abort and keeps invalid output
|
||||
});
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
operation: "observe" as const,
|
||||
partial: {
|
||||
instrumentation: { cleanup: "unconfirmed" },
|
||||
window: { requested_ms: 5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "inspect" as const,
|
||||
partial: {
|
||||
selected_node: { selector: "#selected" },
|
||||
listeners: [],
|
||||
},
|
||||
},
|
||||
])(
|
||||
"keeps $operation cancellation when the retained observation is incomplete",
|
||||
async ({ operation, partial }) => {
|
||||
const reason = { requested: `stop incomplete ${operation}` };
|
||||
const failure = new AnalysisCancelledError(
|
||||
operation === "observe"
|
||||
? "observe_web_execution"
|
||||
: "inspect_web_event_listeners",
|
||||
{ cause: reason, partialObservation: partial as never },
|
||||
);
|
||||
const service = new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async () => err(failure),
|
||||
inspectEventListeners: async () => err(failure),
|
||||
});
|
||||
const result =
|
||||
operation === "observe"
|
||||
? await service.observe({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
observation_ms: 5,
|
||||
})
|
||||
: await service.inspect({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
selector: "#selected",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBe(partial);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { partial_observation: partial },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -56,14 +56,7 @@ export class WebRuntimeService {
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained a malformed completed execution observation.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -118,14 +111,7 @@ export class WebRuntimeService {
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained malformed completed event-listener data.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
@@ -244,3 +245,32 @@ it.each(["list", "inspect"] as const)(
|
||||
expect(providerCalls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps cancellation when the retained Electron inspection is incomplete", async () => {
|
||||
const reason = { requested: "stop before page inspection completed" };
|
||||
const partial = {
|
||||
kind: "electron-page-observation",
|
||||
inspection: { target_id: "electron-page" },
|
||||
cleanup: "incomplete",
|
||||
};
|
||||
const failure = new AnalysisCancelledError("inspect_electron_page", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const provider: ElectronObservationPort = {
|
||||
identity: () => identity,
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_electron_targets")),
|
||||
inspectPage: async () => err(failure),
|
||||
};
|
||||
const result = await inspectElectronPage(provider, input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBe(partial);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { partial_observation: partial },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,17 +79,7 @@ export const inspectElectronPage = async (
|
||||
partial.kind === "electron-page-observation"
|
||||
) {
|
||||
const report = electronPageInspectionSchema.safeParse(partial.inspection);
|
||||
if (!report.success) {
|
||||
if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"inspect_electron_page",
|
||||
"Electron provider retained a malformed completed page inspection.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
if (!report.success) return result;
|
||||
const inspection = {
|
||||
...report.data,
|
||||
limitations: [
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import type { JavaScriptRuntimeTargetList } from "../../domain/javascript/javascriptRuntimeObservation.js";
|
||||
@@ -317,3 +318,32 @@ it.each(["list", "observe"] as const)(
|
||||
expect(providerCalls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps cancellation when the retained runtime observation is incomplete", async () => {
|
||||
const reason = { requested: "stop before runtime observation completed" };
|
||||
const partial = { runtime: "partial", scripts: [] };
|
||||
const failure = new AnalysisCancelledError("observe_javascript_runtime", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const provider: JavaScriptRuntimeObservationPort = {
|
||||
identity: () => ({ id: "test", name: "Test", version: "1" }),
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_javascript_runtime_targets")),
|
||||
observe: async () => err(failure),
|
||||
};
|
||||
const result = await observeJavaScriptRuntime(provider, {
|
||||
inspector_endpoint: "http://127.0.0.1:9229",
|
||||
target_id: "target",
|
||||
observation_ms: 0,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBe(partial);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { partial_observation: partial },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,14 +76,6 @@ export const observeJavaScriptRuntime = async (
|
||||
!("kind" in partial)
|
||||
) {
|
||||
const report = javascriptRuntimeObservationSchema.safeParse(partial);
|
||||
if (!report.success && result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"observe_javascript_runtime",
|
||||
"Inspector retained a malformed completed runtime observation.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
if (report.success)
|
||||
result.error.partialObservation =
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
|
||||
Reference in New Issue
Block a user