fast-browser-with-jev
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s

This commit is contained in:
jb
2026-09-20 12:24:08 -04:00
parent fd0edb1511
commit fd63b0ccec
73 changed files with 21212 additions and 2 deletions
+296
View File
@@ -0,0 +1,296 @@
import { BrowserOperationError } from "./contracts.ts";
import type {
BrowserBridge, Decision, DecisionInput, EvidenceRequest, Observation, RunResult, Step, StepTrace,
} from "./contracts.ts";
import { DecisionError } from "./jev.ts";
import { CredentialError } from "./credentials.ts";
/** Only fixed, non-sensitive messages may be used for this error type. */
export class TextGenerationError extends Error {}
export interface ControllerDeps {
browser: BrowserBridge;
decide: (input: DecisionInput, signal: AbortSignal) => Promise<Decision>;
generateText?: (prompt: string, signal: AbortSignal) => Promise<string>;
resolveCredential?: (name: string, signal: AbortSignal) => Promise<string>;
/** Best-effort local failure capture; returned artifacts are never forwarded. */
captureEvidence?: (request: EvidenceRequest, signal: AbortSignal) => Promise<unknown>;
}
export interface RunOptions {
signal?: AbortSignal;
maxSteps?: number;
timeoutMs?: number;
minConfidence?: number;
/** Caller/scenario identity used only to scope local failure evidence. */
runId?: string;
}
const MAX_TEXT = 8_000;
function nonempty(value: unknown, max: number): value is string {
return typeof value === "string" && value.trim().length > 0 && value.length <= max;
}
export function validateSteps(steps: Step[], maxSteps = 10): void {
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10
|| !Array.isArray(steps) || steps.length < 1 || steps.length > maxSteps) {
throw new Error("Run requires 1–10 bounded steps");
}
for (const step of steps) {
if (!step || !nonempty(step.instruction, 2_000) || !["click", "fill"].includes(step.action)) {
throw new Error("Invalid step instruction or action");
}
if (step.action === "click") {
if (step.text !== undefined || step.compose !== undefined || step.credential !== undefined || !step.postcondition) {
throw new Error("Click requires a postcondition and no text");
}
} else {
if ([step.text, step.compose, step.credential].filter((value) => value !== undefined).length !== 1) {
throw new Error("Fill requires exactly one of text, compose, or credential");
}
if (step.credential !== undefined && (typeof step.credential !== "string" || !/^[A-Za-z0-9_.-]{1,100}$/.test(step.credential))) {
throw new Error("Invalid credential reference name");
}
if (step.text !== undefined && (typeof step.text !== "string" || step.text.length > MAX_TEXT)) {
throw new Error("Literal text exceeds the supported size");
}
if (step.compose !== undefined && !nonempty(step.compose, 2_000)) {
throw new Error("Invalid composition instruction");
}
}
if (step.postcondition) {
const p = step.postcondition;
if (!["url", "text"].includes(p.kind) || !nonempty(p.value, 2_000)) {
throw new Error("Invalid postcondition");
}
if (p.kind === "url") {
let url: URL;
try { url = new URL(p.value); } catch { throw new Error("Postcondition requires an absolute URL"); }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw new Error("Postcondition requires an HTTP(S) URL without credentials");
}
}
}
}
}
/** Validate even fake/plugin bridges: unknown IDs and malformed handles cannot reach execution. */
export function validateObservation(value: Observation): void {
if (!value || !nonempty(value.observationId, 200) || !nonempty(value.url, 8_000)
|| typeof value.title !== "string" || value.title.length > 2_000
|| !Array.isArray(value.candidates) || value.candidates.length > 60) {
throw new Error("Invalid browser observation");
}
const url = new URL(value.url);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw new Error("Invalid browser URL");
}
const ids = new Set<string>();
for (const candidate of value.candidates) {
if (!candidate || !nonempty(candidate.id, 200) || ids.has(candidate.id)
|| !nonempty(candidate.role, 100) || typeof candidate.name !== "string" || candidate.name.length > 500
|| !Array.isArray(candidate.actions) || candidate.actions.length < 1 || candidate.actions.length > 2
|| candidate.actions.some((action) => action !== "click" && action !== "fill")) {
throw new Error("Invalid browser candidate");
}
ids.add(candidate.id);
}
}
/** Bound waits even if an injected adapter forgets to honor AbortSignal. */
async function abortable<T>(signal: AbortSignal, operation: () => Promise<T>): Promise<T> {
signal.throwIfAborted();
return new Promise<T>((resolve, reject) => {
let settled = false;
const cleanup = () => signal.removeEventListener("abort", abort);
const settleResolve = (value: T) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const settleReject = (error: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const abort = () => settleReject(signal.reason ?? new Error("Aborted"));
signal.addEventListener("abort", abort, { once: true });
Promise.resolve().then(() => {
signal.throwIfAborted();
return operation();
}).then(settleResolve, (error) => {
// This handler deliberately consumes late rejection after cancellation.
settleReject(error);
});
if (signal.aborted) abort();
});
}
function evidenceFailureReason(error: unknown): string {
if (error instanceof BrowserOperationError) {
if (error.details.code === "browser_closed") return "Failure evidence unavailable: browser session is unavailable";
if (error.details.code === "aborted") return "Failure evidence unavailable: capture was cancelled";
if (error.details.code === "worker_failure") return "Failure evidence unavailable: browser transport is unavailable";
}
return "Failure evidence unavailable: browser evidence capture failed";
}
/** One bounded plan. Caller owns the browser lifetime and must close it in finally. */
export async function runSteps(steps: Step[], deps: ControllerDeps, options: RunOptions = {}): Promise<RunResult> {
validateSteps(steps, options.maxSteps ?? 10);
const timeoutMs = options.timeoutMs ?? 60_000;
const minConfidence = options.minConfidence ?? 0.85;
if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) throw new Error("Invalid run timeout");
if (!Number.isFinite(minConfidence) || minConfidence < 0 || minConfidence > 1) throw new Error("Invalid confidence threshold");
const controller = new AbortController();
const abortParent = () => controller.abort(options.signal?.reason);
options.signal?.addEventListener("abort", abortParent, { once: true });
if (options.signal?.aborted) abortParent();
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(new Error("Run deadline exceeded")); }, timeoutMs);
const signal = controller.signal;
const started = performance.now();
const traces: StepTrace[] = [];
const result = (status: RunResult["status"], reason: string): RunResult => ({
status, reason, steps: traces, elapsedMs: Math.round(performance.now() - started),
actionMayHaveExecuted: traces.some((item) => item.mutationState === "unknown"),
});
const captureFailure = async (operation: EvidenceRequest["operation"]): Promise<string | undefined> => {
if (!deps.captureEvidence) return undefined;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
timer.unref?.();
try {
const evidence = await abortable(controller.signal, () => deps.captureEvidence!({
runId: options.runId ?? `run-${Date.now()}`,
reason: "failure",
...(operation ? { operation } : {}),
}, controller.signal));
if (evidence && typeof evidence === "object" && "status" in evidence) {
const status = (evidence as { status?: unknown }).status;
if (status === "unavailable") return "Failure evidence unavailable: browser returned no evidence";
if (status === "partial") return "Failure evidence partial: browser returned incomplete evidence";
}
return undefined;
} catch (error) {
return evidenceFailureReason(error);
} finally { clearTimeout(timer); }
};
let phase = "observe";
let trace: StepTrace | undefined;
try {
for (const [index, step] of steps.entries()) {
trace = { step: index + 1, status: "error", timingsMs: {}, mutationState: "not_attempted" };
traces.push(trace);
const timed = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
phase = name;
const start = performance.now();
try { return await abortable(signal, fn); }
finally { trace!.timingsMs[name] = Math.round(performance.now() - start); }
};
const observation = await timed("observe", () => deps.browser.observe(signal));
validateObservation(observation);
const eligible = observation.candidates.filter((candidate) => candidate.actions.includes(step.action));
if (!eligible.length) {
trace.status = "abstained";
return result("stopped", "No eligible controls; return to Pi for recovery");
}
// This shortcut is intentionally narrow: an exact, unique accessible name.
const exact = eligible.filter((candidate) => candidate.name === step.instruction);
const decision = exact.length === 1
? { candidateId: exact[0].id, confidence: 1, model: "deterministic-exact-name" }
: await timed("decide", () => deps.decide({ instruction: step.instruction, action: step.action, observation }, signal));
signal.throwIfAborted();
if (!decision || typeof decision.confidence !== "number" || !Number.isFinite(decision.confidence)
|| decision.confidence < 0 || decision.confidence > 1 || !nonempty(decision.model, 100)) {
throw new DecisionError("Malformed decision");
}
trace.confidence = decision.confidence;
trace.model = decision.model;
if (decision.candidateId === null || decision.confidence < minConfidence) {
trace.status = "abstained";
return result("stopped", "Decision uncertain; return to Pi for recovery");
}
const candidate = eligible.find((candidate) => candidate.id === decision.candidateId);
if (!candidate) throw new DecisionError("Decision selected an unknown or ineligible candidate");
trace.candidateId = candidate.id;
let text = step.text;
if (step.credential !== undefined) {
if (!deps.resolveCredential) throw new CredentialError("No credential resolver is configured for this run");
text = await timed("resolveCredential", () => deps.resolveCredential!(step.credential!, signal));
if (typeof text !== "string" || text.length > MAX_TEXT) {
throw new CredentialError("Resolved credential must be a bounded string");
}
}
if (step.compose !== undefined) {
if (!deps.generateText) return result("stopped", "Text generation model is not configured");
text = await timed("generate", () => deps.generateText!(step.compose!, signal));
if (!nonempty(text, MAX_TEXT)) return result("stopped", "Generated text is empty or exceeds the size limit");
}
const request = {
observationId: observation.observationId,
candidateId: candidate.id,
action: step.action,
...(text !== undefined ? { text } : {}),
...(step.postcondition ? { postcondition: step.postcondition } : {}),
};
// The initiating caller owns authorization and supplies this bounded plan.
// No interactive per-action permission layer is introduced here.
signal.throwIfAborted();
const acted = await timed("actAndVerify", async () => {
trace!.mutationState = "unknown";
try {
return await deps.browser.act(request, signal);
} catch (error) {
// A typed pre-dispatch rejection is safe; once transport dispatch
// began, preserve unknown and never retry the mutation.
if (error instanceof BrowserOperationError && error.details.mutationState === "not_attempted") {
trace!.mutationState = "not_attempted";
}
throw error;
}
});
signal.throwIfAborted();
if (!acted || typeof acted.verified !== "boolean" || typeof acted.evidence !== "string") {
throw new Error("Invalid browser action result");
}
const safeEvidence = new Set([
"field value verified", "field value and postcondition verified",
"field value or postcondition could not be verified", "field value could not be verified",
"URL postcondition verified", "visible-text postcondition verified", "click postcondition was not verified",
]);
trace.evidence = safeEvidence.has(acted.evidence) ? acted.evidence : (acted.verified ? "Postcondition verified" : "Postcondition not verified");
trace.mutationState = acted.verified ? "verified" : "unknown";
trace.status = acted.verified ? "verified" : "unverified";
if (!acted.verified) {
const evidenceStatus = await captureFailure("act");
if (evidenceStatus) trace.evidence = evidenceStatus;
return result("stopped", "Postcondition not verified; action was not retried");
}
}
return result("completed", "All requested steps verified");
} catch (error) {
if (trace) trace.status = "error";
if (signal.aborted) {
const evidenceStatus = await captureFailure(phase === "actAndVerify" ? "act" : undefined);
if (evidenceStatus && trace && !trace.evidence) trace.evidence = evidenceStatus;
return result("stopped", timedOut
? "Run deadline exceeded; an in-flight action may have executed, inspect before retrying"
: "Run cancelled; an in-flight action may have executed, inspect before retrying");
}
const evidenceStatus = await captureFailure(phase === "actAndVerify" ? "act" : undefined);
if (evidenceStatus && trace && !trace.evidence) trace.evidence = evidenceStatus;
const detail = error instanceof DecisionError || error instanceof TextGenerationError || error instanceof CredentialError
? error.message
: error instanceof BrowserOperationError ? error.details.message : "operation failed";
return result("stopped", `${phase}: ${detail}; no action retried`);
} finally {
clearTimeout(timer);
options.signal?.removeEventListener("abort", abortParent);
// End the lifetime of every adapter operation belonging to this run.
controller.abort(new Error("Run finished"));
}
}