Files
rea/src/application/WebRuntimeService.ts
T
morluto 3b698645a0 fix(evidence): retain completed observations on late cancellation
Validate completed reports before cancellation projection, retain valid
Evidence and captured command diagnostics, and convert raw cleanup partials
at their application owner. Record partial Evidence consistently across MCP
handlers. Keep page inspection separate from unfinished bundle analysis.

Refs #4261
2026-10-16 06:46:25 +08:00

203 lines
6.7 KiB
TypeScript

import { z } from "zod";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import type { WebRuntimePort } from "./WebRuntimePort.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
observeWebExecutionInputSchema,
webExecutionSchema,
} from "../domain/webExecution.js";
import {
inspectWebEventListenersInputSchema,
webEventListenersSchema,
} from "../domain/webEventListeners.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
/** Shared public workflows keep instrumentation and listener inspection independently selectable. */
export class WebRuntimeService {
constructor(readonly provider: WebRuntimePort) {}
/** Observe externally triggered execution; selection authorizes the declared instrumentation. */
async observe(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
const operation = "observe_web_execution";
const parsed = parseInput(
observeWebExecutionInputSchema,
rawInput,
operation,
options,
);
if (!parsed.ok) return parsed;
const result = await this.provider.observeExecution(parsed.value, options);
if (!result.ok) {
const partial = result.error.partialObservation;
if (
partial !== undefined &&
!("evidence_id" in partial) &&
"instrumentation" in partial &&
"window" in partial
) {
const verifiedPartial = webExecutionSchema.safeParse(partial);
if (verifiedPartial.success) {
const evidence = this.evidence(
operation,
parsed.value,
verifiedPartial.data,
);
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;
}
const verified = webExecutionSchema.safeParse(result.value);
if (!verified.success)
return err(new AnalysisOutputError(operation, verified.error.message));
if (
verified.data.target.target_id !== parsed.value.target_id ||
verified.data.window.requested_ms !== parsed.value.observation_ms
)
return err(
new AnalysisOutputError(
operation,
"Provider changed the selected target or requested window.",
),
);
return this.evidence(operation, parsed.value, verified.data, options);
}
/** Inspect one selected node's listener declarations without dispatching an event. */
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
const operation = "inspect_web_event_listeners";
const parsed = parseInput(
inspectWebEventListenersInputSchema,
rawInput,
operation,
options,
);
if (!parsed.ok) return parsed;
const result = await this.provider.inspectEventListeners(
parsed.value,
options,
);
if (!result.ok) {
const partial = result.error.partialObservation;
if (
partial !== undefined &&
!("evidence_id" in partial) &&
"selected_node" in partial &&
"listeners" in partial
) {
const verifiedPartial = webEventListenersSchema.safeParse(partial);
if (verifiedPartial.success) {
const evidence = this.evidence(
operation,
parsed.value,
verifiedPartial.data,
);
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;
}
const verified = webEventListenersSchema.safeParse(result.value);
if (!verified.success)
return err(new AnalysisOutputError(operation, verified.error.message));
if (
verified.data.target.target_id !== parsed.value.target_id ||
verified.data.selected_node.selector !== parsed.value.selector
)
return err(
new AnalysisOutputError(
operation,
"Provider changed the selected target or node selector.",
),
);
return this.evidence(operation, parsed.value, verified.data, options);
}
private evidence(
operation: string,
input:
| z.infer<typeof observeWebExecutionInputSchema>
| z.infer<typeof inspectWebEventListenersInputSchema>,
result:
| z.infer<typeof webExecutionSchema>
| z.infer<typeof webEventListenersSchema>,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
if (
input.allowed_origins.length > 0 &&
!input.allowed_origins.includes(result.target.origin)
)
return err(
new AnalysisOutputError(
operation,
"Provider returned a document outside the selected origin scope.",
),
);
return completeProviderEvidence(
ok(
createEvidence(undefined, this.provider.identity(), {
predicateType: `rea.${operation.replaceAll("_", "-")}`,
operation,
parameters: jsonObjectSchema.parse(input),
result,
confidence: "observed",
authority: "external-service",
limitations: result.limitations,
}),
),
operation,
options,
);
}
}
const parseInput = <T>(
schema: z.ZodType<T>,
rawInput: unknown,
operation: string,
options: ExecutionOptions | undefined,
): Result<T, AnalysisError> => {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
const parsed = schema.safeParse(rawInput);
return parsed.success
? ok(parsed.data)
: err(
analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, {
cause: parsed.error,
}),
);
};