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; generateText?: (prompt: string, signal: AbortSignal) => Promise; resolveCredential?: (name: string, signal: AbortSignal) => Promise; /** Best-effort local failure capture; returned artifacts are never forwarded. */ captureEvidence?: (request: EvidenceRequest, signal: AbortSignal) => Promise; } 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(); 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(signal: AbortSignal, operation: () => Promise): Promise { signal.throwIfAborted(); return new Promise((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 { 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 => { 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 (name: string, fn: () => Promise): Promise => { 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")); } }