Files
harness-tools/extensions/fast-browser/controller.ts
T
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

297 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"));
}
}