297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
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"));
|
||
}
|
||
}
|