/** * Checkpoints before navigation and unknown exploration. * * Navigating away throws out everything typed into the page. An unmatched click can * do the same. Without a checkpoint a retry restarts from an empty form, which on a * long application is the difference between a recoverable hiccup and losing the work. * Restore is a first-class act (`kind: "restore"`), not a test-only helper. */ import { readFile } from "node:fs/promises"; import path from "node:path"; import { act, type ActOptions } from "./act.ts"; import { writeJsonAtomic } from "./atomic.ts"; import type { BrowserPort } from "./browser.ts"; import { ensureGoalDirs, goalPaths } from "./paths.ts"; import { redactDeep } from "./redact.ts"; export interface Checkpoint { url: string; title: string; /** Control name to value. Passwords are already redacted upstream. */ values: Record; createdAt: string; } function checkpointFile(root: string, goalId: string, tag: string): string { return path.join(goalPaths(root, goalId).dir, `checkpoint-${tag}.json`); } export async function saveCheckpoint( browser: BrowserPort, options: { root: string; goalId: string; tag: string; tabId?: string }, ): Promise { const observation = await browser.observe(options.tabId); const values: Record = {}; for (const control of observation.controls) { // Only fields worth restoring: something was entered, and it is not a redacted secret. if (!control.value || control.value === "***") continue; if (control.role === "submit" || control.tag === "button" || control.tag === "a") continue; values[control.name] = control.value; } const checkpoint: Checkpoint = { url: observation.url, title: observation.title, values, createdAt: new Date().toISOString(), }; await ensureGoalDirs(goalPaths(options.root, options.goalId)); await writeJsonAtomic(checkpointFile(options.root, options.goalId, options.tag), redactDeep(checkpoint)); return checkpoint; } export async function loadCheckpoint( root: string, goalId: string, tag: string, ): Promise { const raw = await readFile(checkpointFile(root, goalId, tag), "utf8").catch(() => ""); if (!raw.trim()) return undefined; return JSON.parse(raw) as Checkpoint; } /** * Return to a checkpoint: reopen the page and re-enter what was there. Fields that no * longer exist are reported rather than silently skipped, because a form that changed * shape is information the agent needs. */ export async function restoreCheckpoint( browser: BrowserPort, checkpoint: Checkpoint, options: { tabId?: string } & ActOptions = {}, ): Promise<{ restored: string[]; missing: string[] }> { const { tabId, ...actOptions } = options; await act(browser, { kind: "navigate", tabId, url: checkpoint.url }, actOptions); const restored: string[] = []; const missing: string[] = []; for (const [name, value] of Object.entries(checkpoint.values)) { const observation = await browser.observe(tabId); const control = observation.controls.find((candidate) => candidate.name === name); if (!control) { missing.push(name); continue; } const kind = control.tag === "select" ? "select" : "type"; const result = await act( browser, { kind, tabId, ref: control.ref, text: value, value, intent: `restore "${name}" from checkpoint`, }, actOptions, ); if (result.ok) restored.push(name); else missing.push(name); } return { restored, missing }; }