diff --git a/packages/computer/package.json b/packages/computer/package.json index 84da591e..aa257b71 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -107,7 +107,8 @@ "prepare": "npm run build:shell-bundle", "build": "rolldown -c", "typecheck": "tsc -p tsconfig.build.json --noEmit", - "test": "vitest run && vitest run --config vitest.config.proxy.ts && vitest run --config vitest.config.worker-backend.ts && vitest run --config vitest.config.script-runner.ts && vitest run --config vitest.config.stub-soak.ts", + "test": "vitest run && vitest run --config vitest.config.proxy.ts && vitest run --config vitest.config.worker-backend.ts && vitest run --config vitest.config.script-runner.ts && vitest run --config vitest.config.repl.ts && vitest run --config vitest.config.stub-soak.ts", + "test:repl": "vitest run --config vitest.config.repl.ts", "test:stub-soak": "vitest run --config vitest.config.stub-soak.ts", "test:worker-backend": "vitest run --config vitest.config.worker-backend.ts", "test:code": "vitest run --config vitest.config.script-runner.ts", diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index 70f2adfd..8e5d3fc2 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -58,6 +58,20 @@ export { WorkspaceServiceProxy, type WorkspaceServiceProxyProps, } from "./proxy.js"; +export { + ReplSession, + type ReplEvalOptions, + type ReplSessionOptions, +} from "./repl/session.js"; +export type { + ReplEffect, + ReplErrorKind, + ReplExecutionError, + ReplExecutionResult, + ReplLogEntry, + ReplLogLevel, + ReplResultData, +} from "./repl/types.js"; export type { WorkspaceEgressPolicy } from "./runtime/egress.js"; export type { ModuleExecutionEnvelope, diff --git a/packages/computer/src/repl/runner.ts b/packages/computer/src/repl/runner.ts new file mode 100644 index 00000000..2ba2ce37 --- /dev/null +++ b/packages/computer/src/repl/runner.ts @@ -0,0 +1,258 @@ +// Isolate-side runner for REPL sessions. +// +// This module's source string ships as the main module of every REPL +// isolate, alongside one module per cell. It executes all cells in order: +// committed cells replay in "serve" mode (every recorded effect answered +// from the log — nothing external fires, randomness and time reproduce +// exactly), and the new cell runs in "record" mode (effects execute for +// real and are captured for the log). +// +// Replay divergence — a served effect whose kind doesn't match, or effects +// left unconsumed after a cell — is a loud, structured failure: silent +// corruption is never an option. +// +// Recorded nondeterminism surface: Math.random, Date.now, +// no-arg new Date() and Date(), crypto.randomUUID, crypto.getRandomValues, +// performance.now. WeakRef, FinalizationRegistry (GC timing) and caches +// (state shared across isolates) are removed so use fails loudly instead +// of silently diverging. The isolate is otherwise hermetic: globalOutbound +// is null (fetch/WebSocket/EventSource fail deterministically), no +// nodejs_compat, performance.timeOrigin is pinned to 0 by workerd, and +// Intl (UTC) / navigator are constants. +// +// Known unshimmed nondeterminism, documented rather than recorded: +// - crypto.subtle randomized ops (e.g. generateKey): their results are +// not plainly loggable; use them through a granted capability, or a +// future recorder that can store exported key material. +// - timers (setTimeout / scheduler.wait): ordering replays +// deterministically, but replay re-waits real delays — committed +// sleeps make replay slow, never wrong. + +export const REPL_RUNNER_MODULE = "__repl_runner__.js"; +export const REPL_CELLS_MODULE = "__repl_cells__.js"; + +export function replCellModuleName(seq: number): string { + return `__repl_cell_${seq}__.js`; +} + +/** Wrap transformed cell code as a module exporting one async cell function. */ +export function replCellModule(transformed: string): string { + return `export default async function cell() {\n${transformed}\n}`; +} + +/** Index module importing every cell in order. */ +export function replCellsModule(count: number): string { + const imports: string[] = []; + const names: string[] = []; + for (let seq = 1; seq <= count; seq++) { + imports.push(`import c${seq} from "./${replCellModuleName(seq)}";`); + names.push(`c${seq}`); + } + return `${imports.join("\n")}\nexport default [${names.join(", ")}];`; +} + +const MAX_LOG_ENTRIES = 1_000; +const MAX_LOG_ENTRY_CHARS = 8_192; + +// The runner source. A template-built string (matching how the +// worker-javascript backend ships its runtime module) so the package build +// needs no extra bundling step for isolate-side code. +export function replRunnerModule(): string { + return ` +import { WorkerEntrypoint } from "cloudflare:workers"; +import cells from "./${REPL_CELLS_MODULE}"; + +const fx = { mode: "record", queue: [], recorded: [] }; +const DIVERGENCE = "__repl_replay_divergence__: "; + +const realRandom = Math.random.bind(Math); +const realNow = Date.now.bind(Date); +const realRandomUUID = crypto.randomUUID ? crypto.randomUUID.bind(crypto) : undefined; +const realGetRandomValues = crypto.getRandomValues ? crypto.getRandomValues.bind(crypto) : undefined; +const RealDate = Date; + +// Divergence checks compare the full call identity. The current shims take +// no arguments, so kind alone is that identity; future effect sources that +// carry arguments (capability calls) must extend this to an args +// comparison — divergence stays a hard error, never a silent re-execution. +function effect(kind, make) { + if (fx.mode === "record") { + const value = make(); + fx.recorded.push({ kind, value }); + return value; + } + const entry = fx.queue.shift(); + if (!entry || entry.kind !== kind) { + throw new Error( + DIVERGENCE + "expected a recorded " + JSON.stringify(kind) + + " effect, log had " + (entry ? JSON.stringify(entry.kind) : "nothing") + + ". Committed cells must replay exactly; this session's log no longer matches its code." + ); + } + return entry.value; +} + +Math.random = () => effect("random", realRandom); +if (realRandomUUID) crypto.randomUUID = () => effect("uuid", realRandomUUID); + +// crypto.getRandomValues fills any integer TypedArray; record the raw bytes +// so serve mode can refill an identical view without touching the CSPRNG. +if (realGetRandomValues) { + crypto.getRandomValues = (array) => { + const bytes = effect("random-bytes", () => { + realGetRandomValues(array); + return Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)); + }); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes); + return array; + }; +} + +// Clock shims. A no-arg new Date() (and bare Date()) reads the clock, so it +// records like Date.now; constructions from explicit arguments pass through +// untouched. Instances stay genuine Dates (same prototype), so instanceof, +// methods, and structured clone are unaffected. +function ReplDate(...args) { + if (!new.target) return new ReplDate().toString(); + if (args.length === 0) return Reflect.construct(RealDate, [effect("now", realNow)], new.target); + return Reflect.construct(RealDate, args, new.target); +} +ReplDate.prototype = RealDate.prototype; +Object.setPrototypeOf(ReplDate, RealDate); +Object.defineProperty(ReplDate, "name", { value: "Date", configurable: true }); +ReplDate.now = () => effect("now", realNow); +globalThis.Date = ReplDate; + +if (globalThis.performance && typeof performance.now === "function") { + const realPerfNow = performance.now.bind(performance); + performance.now = () => effect("perf-now", realPerfNow); +} + +// GC timing (WeakRef/FinalizationRegistry) and cross-isolate cache state +// cannot replay; remove the globals so use is a loud ReferenceError. +for (const name of ["WeakRef", "FinalizationRegistry", "caches"]) { + try { delete globalThis[name]; } catch {} + if (name in globalThis) { + try { Object.defineProperty(globalThis, name, { value: undefined, configurable: true }); } catch {} + } +} + +// console capture: one ordered stream, levels preserved. Display output +// only — never replay input — so capping it here is safe (recorded effect +// values are stored verbatim; size guards on those must reject, not +// truncate). Entries past the cap are counted in logs.dropped. +const logs = { entries: [], dropped: 0 }; +function snapshotLogs() { + const snapshot = { entries: logs.entries.slice() }; + if (logs.dropped > 0) snapshot.dropped = logs.dropped; + return snapshot; +} +function capture(level) { + return (...args) => { + if (logs.entries.length >= ${MAX_LOG_ENTRIES}) { + logs.dropped++; + return; + } + const text = args.map((a) => (typeof a === "string" ? a : inspect(a, 0))).join(" "); + logs.entries.push({ level, text: text.length > ${MAX_LOG_ENTRY_CHARS} ? text.slice(0, ${MAX_LOG_ENTRY_CHARS}) + "…" : text }); + }; +} +for (const level of ["log", "info", "debug", "warn", "error"]) { + console[level] = capture(level); +} + +function inspect(value, depth) { + if (value === null) return "null"; + const t = typeof value; + if (t === "string") return depth === 0 ? value : JSON.stringify(value); + if (t === "number" || t === "boolean" || t === "bigint" || t === "undefined") return String(value); + if (t === "symbol") return value.toString(); + if (t === "function") return "[Function: " + (value.name || "anonymous") + "]"; + if (depth > 4) return "[…]"; + if (Array.isArray(value)) return "[" + value.map((v) => inspect(v, depth + 1)).join(", ") + "]"; + if (value instanceof Error) return (value.stack || value.name + ": " + value.message); + if (value instanceof Map) { + return "Map(" + value.size + ") {" + + [...value.entries()].map(([k, v]) => " " + inspect(k, depth + 1) + " => " + inspect(v, depth + 1)).join(",") + " }"; + } + if (value instanceof Set) { + return "Set(" + value.size + ") {" + [...value.values()].map((v) => " " + inspect(v, depth + 1)).join(",") + " }"; + } + const name = value.constructor && value.constructor.name !== "Object" ? value.constructor.name + " " : ""; + const entries = Object.keys(value).map((k) => k + ": " + inspect(value[k], depth + 1)); + return name + "{ " + entries.join(", ") + " }"; +} + +function describeError(error, kind) { + const isError = error instanceof Error; + return { + name: isError ? error.name : "Error", + message: String(isError ? error.message : error), + traceback: isError && error.stack ? String(error.stack) : undefined, + kind, + }; +} + +export default class ReplRunner extends WorkerEntrypoint { + // Replay every committed cell against its recorded effects, then run the + // final cell in record mode. Returns a structured outcome; never throws + // for cell-level failures (structure survives the RPC boundary, thrown + // errors don't). + async run(effectLog) { + for (let i = 0; i < cells.length - 1; i++) { + fx.mode = "serve"; + fx.queue = (effectLog[i] || []).slice(); + try { + await cells[i](); + } catch (error) { + return { ok: false, phase: "replay", error: describeError(error, "replay-divergence") }; + } + if (fx.queue.length > 0) { + return { + ok: false, + phase: "replay", + error: { + name: "Error", + message: DIVERGENCE + fx.queue.length + " recorded effects were never consumed replaying cell " + (i + 1) + ".", + kind: "replay-divergence", + }, + }; + } + } + + fx.mode = "record"; + fx.recorded = []; + logs.entries = []; + logs.dropped = 0; + let value; + try { + value = await cells[cells.length - 1](); + } catch (error) { + return { + ok: false, + phase: "cell", + error: describeError(error, undefined), + logs: snapshotLogs(), + }; + } + + const outcome = { + ok: true, + effects: fx.recorded, + logs: snapshotLogs(), + results: [], + }; + try { + structuredClone(value); + outcome.value = value; + outcome.hasValue = true; + } catch { + // Unclonable success: omit the value, ship a rendering instead. + outcome.hasValue = false; + outcome.results.push({ text: inspect(value, 0) }); + } + return outcome; + } +} +`; +} diff --git a/packages/computer/src/repl/session.ts b/packages/computer/src/repl/session.ts new file mode 100644 index 00000000..579dbc9c --- /dev/null +++ b/packages/computer/src/repl/session.ts @@ -0,0 +1,295 @@ +// Durable REPL session host. +// +// A session is a log, not a process: committed cells and their recorded +// effects live in the workspace's Durable Object SQLite. Every eval builds +// a fresh isolate containing all cells (dynamic isolates ban runtime eval), +// replays committed cells against the log, and records the new cell's +// effects. The isolate is a disposable cache; the log is the truth — which +// is why sessions survive eviction, deploys, and idle at zero cost. +// +// Evals on one session are serialized (concurrent calls queue in arrival +// order); parallelism is what forks are for. + +import type { Database } from "@cloudflare/dofs"; + +import type { WorkspaceRuntimeLoader } from "../runtime/types.js"; +import { + REPL_CELLS_MODULE, + REPL_RUNNER_MODULE, + replCellModule, + replCellModuleName, + replCellsModule, + replRunnerModule, +} from "./runner.js"; +import { transformCell } from "./transform.js"; +import type { ReplEffect, ReplExecutionError, ReplExecutionResult, ReplLogEntry } from "./types.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_COMPATIBILITY_DATE = "2026-05-23"; + +export interface ReplSessionOptions { + name: string; + db: Database; + loader: WorkspaceRuntimeLoader; + timeoutMs?: number; + compatibilityDate?: string; + now?: () => number; +} + +export interface ReplEvalOptions { + timeoutMs?: number; +} + +interface CommittedCell { + code: string; + transformed: string; + effects: ReplEffect[]; +} + +interface RunOutcome { + ok: boolean; + phase?: "replay" | "cell"; + error?: ReplExecutionError; + effects?: ReplEffect[]; + logs?: { entries: ReplLogEntry[]; dropped?: number }; + results?: Array<{ text: string }>; + hasValue?: boolean; + value?: unknown; +} + +interface RunnerEntrypoint { + run(effectLog: ReplEffect[][]): Promise; + [Symbol.dispose]?: () => void; +} + +const EMPTY_LOGS = () => ({ entries: [] }); + +export class ReplSession { + readonly name: string; + readonly #db: Database; + readonly #loader: WorkspaceRuntimeLoader; + readonly #timeoutMs: number; + readonly #compatibilityDate: string; + readonly #now: () => number; + // Committed log, lazily loaded from SQLite; the DO is the single writer. + #cells: CommittedCell[] | undefined; + // Tail promise serializing evals on this session. + #tail: Promise = Promise.resolve(); + + constructor(options: ReplSessionOptions) { + this.name = options.name; + this.#db = options.db; + this.#loader = options.loader; + this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.#compatibilityDate = options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE; + this.#now = options.now ?? Date.now; + initializeReplSchema(this.#db); + } + + eval(code: string, options?: ReplEvalOptions): Promise { + const run = this.#tail.then(() => this.#eval(code, options), () => this.#eval(code, options)); + this.#tail = run.catch(() => undefined); + return run; + } + + async #eval(code: string, options?: ReplEvalOptions): Promise { + const cells = this.#load(); + const executionCount = cells.length + 1; + + let transformed: string; + try { + transformed = transformCell(code); + } catch (error) { + return { + code, + logs: EMPTY_LOGS(), + results: [], + error: { + name: error instanceof SyntaxError ? "SyntaxError" : "Error", + message: error instanceof Error ? error.message : String(error), + }, + executionCount, + }; + } + + const timeoutMs = options?.timeoutMs ?? this.#timeoutMs; + const outcome = await this.#run(cells, transformed, timeoutMs); + + if (outcome.ok) { + this.#commit(cells, { code, transformed, effects: outcome.effects ?? [] }); + const result: ReplExecutionResult = { + code, + logs: outcome.logs ?? EMPTY_LOGS(), + results: outcome.results ?? [], + executionCount, + }; + if (outcome.hasValue === true) result.value = outcome.value; + return result; + } + + // Failed cells never enter the log; the session stays as it was. + return { + code, + logs: outcome.logs ?? EMPTY_LOGS(), + results: [], + error: outcome.error ?? { name: "Error", message: "REPL evaluation failed." }, + executionCount, + }; + } + + async #run(cells: CommittedCell[], transformed: string, timeoutMs: number): Promise { + const modules: Record = { + [REPL_RUNNER_MODULE]: replRunnerModule(), + [REPL_CELLS_MODULE]: replCellsModule(cells.length + 1), + }; + cells.forEach((cell, index) => { + modules[replCellModuleName(index + 1)] = replCellModule(cell.transformed); + }); + modules[replCellModuleName(cells.length + 1)] = replCellModule(transformed); + + const worker = this.#loader.load({ + compatibilityDate: this.#compatibilityDate, + limits: { cpuMs: timeoutMs }, + mainModule: REPL_RUNNER_MODULE, + modules, + globalOutbound: null, + }); + const entrypoint = worker.getEntrypoint(undefined, { + limits: { cpuMs: timeoutMs }, + }) as RunnerEntrypoint; + + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(timeoutOutcome(timeoutMs)), timeoutMs); + }); + try { + const effectLog = cells.map((cell) => cell.effects); + const run = Promise.resolve().then(() => entrypoint.run(effectLog)); + // If the timeout wins the race, the losing run promise rejects later + // (its isolate is disposed) with nobody awaiting it — swallow that so + // it can't surface as an unhandled rejection. + run.catch(() => undefined); + return await Promise.race([run, timeout]); + } catch (error) { + // Loader/runtime-level failure: the isolate was killed by its CPU + // limit, or workerd proved the cell can never finish (hung promise). + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("exceeded its CPU limit") || + message.includes("hung and would never generate a response") + ) { + return timeoutOutcome(timeoutMs); + } + return { + ok: false, + phase: "cell", + error: { + name: error instanceof Error ? error.name : "Error", + message, + }, + }; + } finally { + if (timer !== undefined) clearTimeout(timer); + disposeQuietly(entrypoint); + disposeQuietly(worker as { [Symbol.dispose]?: () => void }); + } + } + + #load(): CommittedCell[] { + if (this.#cells !== undefined) return this.#cells; + const rows = this.#db.all<{ seq: number; code: string; transformed: string }>( + "SELECT seq, code, transformed FROM repl_cells WHERE session = ? ORDER BY seq", + this.name, + ); + const effectRows = this.#db.all<{ cell_seq: number; kind: string; value: string }>( + "SELECT cell_seq, kind, value FROM repl_effects WHERE session = ? ORDER BY cell_seq, call_seq", + this.name, + ); + const effectsBySeq = new Map(); + for (const row of effectRows) { + const list = effectsBySeq.get(row.cell_seq) ?? []; + list.push({ kind: row.kind, value: (JSON.parse(row.value) as { v?: unknown }).v }); + effectsBySeq.set(row.cell_seq, list); + } + this.#cells = rows.map((row) => ({ + code: row.code, + transformed: row.transformed, + effects: effectsBySeq.get(row.seq) ?? [], + })); + return this.#cells; + } + + #commit(cells: CommittedCell[], cell: CommittedCell): void { + // Effect values are stored verbatim — they are replay input and must + // never be truncated. Today's effects are ≤36-byte scalars by + // construction; future effect sources with sizable results (capability + // calls) must REJECT the cell, not truncate the value. + const seq = cells.length + 1; + this.#db.transactionSync(() => { + this.#db.run( + "INSERT INTO repl_cells (session, seq, code, transformed, created_at) VALUES (?, ?, ?, ?, ?)", + this.name, + seq, + cell.code, + cell.transformed, + this.#now(), + ); + cell.effects.forEach((effect, index) => { + this.#db.run( + "INSERT INTO repl_effects (session, cell_seq, call_seq, kind, value) VALUES (?, ?, ?, ?, ?)", + this.name, + seq, + index, + effect.kind, + JSON.stringify({ v: effect.value }), + ); + }); + }); + cells.push(cell); + } +} + +function timeoutOutcome(timeoutMs: number): RunOutcome { + return { + ok: false, + phase: "cell", + error: { + name: "TimeoutError", + message: + `REPL evaluation did not finish within ${timeoutMs}ms. The cell was not ` + + "committed; split long work into smaller cells or raise timeoutMs.", + kind: "timeout", + }, + }; +} + +function initializeReplSchema(db: Database): void { + db.run( + `CREATE TABLE IF NOT EXISTS repl_cells ( + session TEXT NOT NULL, + seq INTEGER NOT NULL, + code TEXT NOT NULL, + transformed TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (session, seq) + )`, + ); + db.run( + `CREATE TABLE IF NOT EXISTS repl_effects ( + session TEXT NOT NULL, + cell_seq INTEGER NOT NULL, + call_seq INTEGER NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (session, cell_seq, call_seq) + )`, + ); +} + +function disposeQuietly(target: { [Symbol.dispose]?: () => void }): void { + try { + target[Symbol.dispose]?.(); + } catch { + // Disposal is best-effort; the isolate is disposable by design. + } +} diff --git a/packages/computer/src/repl/transform.test.ts b/packages/computer/src/repl/transform.test.ts new file mode 100644 index 00000000..54411c23 --- /dev/null +++ b/packages/computer/src/repl/transform.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { runInNewContext } from "node:vm"; + +import { transformCell } from "./transform.js"; + +// Behavior seam: transformed cells run inside an async function with a +// shared `globalThis`; later cells must see earlier cells' top-level +// bindings, and a trailing expression (or explicit return) is the value. +// +// This vm harness is the fast unit layer, not a workerd equivalent: it +// approximates the real path (an ESM module function in a fresh isolate) +// with a strict-mode script against a sandbox global. Full-fidelity +// coverage of the same transform output lives in tests/repl.test.ts, +// which runs cells through real workerd isolates. +async function runCells(cells: string[]): Promise { + const sandbox: Record = {}; + sandbox.globalThis = sandbox; + let last: unknown; + for (const cell of cells) { + // "use strict" matches module-code strictness in the real isolate. + const code = `(async () => {\n"use strict";\n${transformCell(cell)}\n})()`; + last = await runInNewContext(code, sandbox); + } + return last; +} + +describe("transformCell", () => { + it("returns a trailing expression as the cell value", async () => { + expect(await runCells(["const a = 2;\na * 3"])).toBe(6); + }); + + it("honors an explicit return; trailing code after it is unreachable", async () => { + // Standard function semantics: `return` ends the cell, so the trailing + // expression (still rewritten to a return) never runs. + expect(await runCells(["const a = 2;\nreturn a + 1;\na * 100"])).toBe(3); + }); + + it("yields undefined when the cell ends in a declaration", async () => { + expect(await runCells(["const a = 2;"])).toBeUndefined(); + }); + + it("persists const/let bindings to later cells", async () => { + expect(await runCells(["const a = 2; let b = 3;", "b += 1;", "a * b"])).toBe(8); + }); + + it("persists function and class declarations", async () => { + const value = await runCells([ + "function double(x) { return x * 2; }\nclass Box { constructor(v) { this.v = v; } }", + "new Box(double(4)).v", + ]); + expect(value).toBe(8); + }); + + it("persists destructured bindings, defaults, and rest", async () => { + const value = await runCells([ + "const { a, b: renamed, c = 30, ...rest } = { a: 1, b: 2, d: 4 };\nconst [x, , y] = [10, 20, 30];", + "({ a, renamed, c, rest, x, y })", + ]); + expect(value).toEqual({ a: 1, renamed: 2, c: 30, rest: { d: 4 }, x: 10, y: 30 }); + }); + + it("persists multi-declarator statements and no-init lets", async () => { + const value = await runCells(["const a = 1, b = 2; let c;", "c = a + b;\nc"]); + expect(value).toBe(3); + }); + + it("does not persist loop variables", async () => { + const cell = "let total = 0;\nfor (const n of [1, 2, 3]) total += n;\ntotal"; + expect(await runCells([cell])).toBe(6); + const sandbox: Record = {}; + sandbox.globalThis = sandbox; + await runInNewContext(`(async () => {\n${transformCell(cell)}\n})()`, sandbox); + expect("n" in sandbox).toBe(false); + }); + + it("supports top-level await", async () => { + expect(await runCells(["const v = await Promise.resolve(7);", "v"])).toBe(7); + }); + + it("throws a SyntaxError for unparseable code", () => { + expect(() => transformCell("const = ;")).toThrow(SyntaxError); + }); +}); diff --git a/packages/computer/src/repl/transform.ts b/packages/computer/src/repl/transform.ts new file mode 100644 index 00000000..1aecb42d --- /dev/null +++ b/packages/computer/src/repl/transform.ts @@ -0,0 +1,140 @@ +// REPL cell transform. +// +// A cell is one piece of agent-written JavaScript. Cells execute inside an +// async function in a fresh module graph, so their top-level lexical +// declarations would otherwise die with the function scope. To make bindings +// persist across cells (and across isolate reloads), top-level declarations +// are rewritten onto `globalThis`: +// +// const a = 1, { b } = obj; → (globalThis.a = 1), ({ b: globalThis.b } = obj); +// function f() {} → function f() {} globalThis.f = f; +// class C {} → class C {} globalThis.C = C; +// +// Later cells read those names through ordinary global lookup. The cell's +// value is an explicit top-level `return` if present, otherwise the trailing +// expression statement (rewritten to `return (expr)`), otherwise undefined. +// +// Only Program-level statements are rewritten. Loop heads, block-nested +// declarations, and function bodies keep normal scoping. +// Known ceiling: `var` inside a top-level block is not persisted — fine +// for model-written cells; the upgrade path is a scope-aware walk. + +import { parse } from "acorn"; +import type { Pattern, Program, Statement } from "acorn"; + +interface Edit { + start: number; + end: number; + text: string; +} + +export function transformCell(source: string): string { + const program: Program = parse(source, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + }); + const edits: Edit[] = []; + + for (const statement of program.body) { + collectStatementEdits(statement as Statement, source, edits); + } + + const last = program.body[program.body.length - 1]; + if (last !== undefined && last.type === "ExpressionStatement") { + const expression = source.slice(last.expression.start, last.expression.end); + edits.push({ start: last.start, end: last.end, text: `return (${expression});` }); + } + + return applyEdits(source, edits); +} + +function collectStatementEdits(statement: Statement, source: string, edits: Edit[]): void { + if (statement.type === "VariableDeclaration") { + const parts = statement.declarations.map((declarator) => { + if (declarator.init === null || declarator.init === undefined) { + // Destructuring requires an initializer, so this is a plain name. + const name = source.slice(declarator.id.start, declarator.id.end); + return `(globalThis.${name} = undefined)`; + } + const pattern = rewritePattern(declarator.id, source); + const init = source.slice(declarator.init.start, declarator.init.end); + return `(${pattern} = ${init})`; + }); + edits.push({ start: statement.start, end: statement.end, text: `${parts.join(", ")};` }); + return; + } + if ( + (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") && + statement.id !== null + ) { + const name = statement.id.name; + edits.push({ start: statement.end, end: statement.end, text: ` globalThis.${name} = ${name};` }); + } +} + +// Rewrite a binding pattern into an assignment-pattern targeting globalThis: +// bound identifiers become `globalThis.`, and object-pattern shorthand +// is expanded (`{ a }` → `{ a: globalThis.a }`) so the property key survives. +function rewritePattern(pattern: Pattern, source: string): string { + const edits: Edit[] = []; + collectPatternEdits(pattern, source, edits); + const applied = applyEdits(source.slice(pattern.start, pattern.end), shift(edits, pattern.start)); + return applied; +} + +function collectPatternEdits(pattern: Pattern, source: string, edits: Edit[]): void { + switch (pattern.type) { + case "Identifier": + edits.push({ start: pattern.start, end: pattern.end, text: `globalThis.${pattern.name}` }); + return; + case "ObjectPattern": + for (const property of pattern.properties) { + if (property.type === "RestElement") { + collectPatternEdits(property.argument, source, edits); + continue; + } + if (property.shorthand) { + // `{ a }` or `{ a = default }`: keep the key, retarget the value. + const key = source.slice(property.key.start, property.key.end); + const valueEdits: Edit[] = []; + collectPatternEdits(property.value as Pattern, source, valueEdits); + const value = applyEdits( + source.slice(property.value.start, property.value.end), + shift(valueEdits, property.value.start), + ); + edits.push({ start: property.start, end: property.end, text: `${key}: ${value}` }); + continue; + } + collectPatternEdits(property.value as Pattern, source, edits); + } + return; + case "ArrayPattern": + for (const element of pattern.elements) { + if (element !== null) collectPatternEdits(element, source, edits); + } + return; + case "AssignmentPattern": + collectPatternEdits(pattern.left, source, edits); + return; + case "RestElement": + collectPatternEdits(pattern.argument, source, edits); + return; + default: + // MemberExpression cannot appear in a declaration pattern. + return; + } +} + +function shift(edits: Edit[], offset: number): Edit[] { + return edits.map((edit) => ({ ...edit, start: edit.start - offset, end: edit.end - offset })); +} + +function applyEdits(source: string, edits: Edit[]): string { + let result = source; + for (const edit of [...edits].sort((a, b) => b.start - a.start)) { + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + } + return result; +} diff --git a/packages/computer/src/repl/types.ts b/packages/computer/src/repl/types.ts new file mode 100644 index 00000000..2b8f3fb2 --- /dev/null +++ b/packages/computer/src/repl/types.ts @@ -0,0 +1,75 @@ +// Public result shape of a REPL evaluation. +// +// Follows the common interpreter-result convention (`results[]`, `error`, +// per-cell logs), extended with: +// - `value`: the cell's actual JavaScript completion value (structured +// clone), for programs rather than humans. +// - `logs.entries`: one ordered stream with console levels preserved, +// instead of split stdout/stderr streams that lose interleaving. +// - structured error kinds that name their own remediation. + +export interface ReplResultData { + /** Inspect-style text rendering of an output. */ + text: string; +} + +export type ReplLogLevel = "log" | "info" | "debug" | "warn" | "error"; + +export interface ReplLogEntry { + level: ReplLogLevel; + text: string; +} + +export type ReplErrorKind = + /** + * Replaying the session's committed cells no longer reproduces the + * recorded effect log — the log and the code disagree (storage was + * altered, or an unrecorded nondeterminism source leaked in). The + * failing eval was not committed; earlier committed state is untouched. + */ + | "replay-divergence" + /** + * The cell exceeded its wall-clock or CPU budget and was destroyed. + * Nothing was committed; split the work or raise timeoutMs. + */ + | "timeout"; + +export interface ReplExecutionError { + name: string; + message: string; + /** Stack trace, when the failure came from running cell code. */ + traceback?: string; + /** + * Structured kind for failures with defined semantics. Absent for + * ordinary runtime errors thrown by cell code. + */ + kind?: ReplErrorKind; +} + +export interface ReplExecutionResult { + /** The cell source as submitted. */ + code: string; + /** + * The cell's completion value: explicit top-level `return` if present, + * else the trailing expression, else undefined. Omitted (key absent) + * when the value cannot cross the boundary as a structured clone — + * the cell still succeeds and `results` carries a rendering. + */ + value?: unknown; + /** + * Console output from the new cell, in emission order with levels + * preserved. `dropped` counts entries discarded past the cap. + */ + logs: { entries: ReplLogEntry[]; dropped?: number }; + /** Display renderings (e.g. of an unclonable value). */ + results: ReplResultData[]; + error?: ReplExecutionError; + /** 1-based cell sequence (the would-be sequence for failed cells). */ + executionCount: number; +} + +/** One recorded nondeterministic effect (host-boundary call result). */ +export interface ReplEffect { + kind: string; + value: unknown; +} diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 818942e4..8f078843 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -44,6 +44,7 @@ import { MountIndex } from "./mounts/index.js"; import { buildMountRegistry, type MountValue } from "./mounts/registry.js"; import type { Mount } from "./mounts/types.js"; import { noopObserver, safeErrorMessage, type WorkspaceObserver, withSpan } from "./observe.js"; +import { ReplSession, type ReplSessionOptions } from "./repl/session.js"; import { WorkspaceRuntime } from "./runtime/runtime.js"; import { isModuleBackend, @@ -321,6 +322,9 @@ export class Workspace { readonly #connectingModuleHandles = new Map>(); #connectionGeneration = 0; #runtime: WorkspaceRuntime | undefined; + // REPL sessions, cached per name so evals on one session serialize + // through a single instance for the DO's lifetime. + readonly #replSessions = new Map(); #readyPromise: Promise | undefined; // Per-backend FIFOs that serialize mutating entry points (push, // pull, and the shell exec bracket which goes through them) for @@ -590,6 +594,22 @@ export class Workspace { // across the Workers-RPC boundary (e.g. returned from a DO RPC // method). The stub is a lazy RpcTarget — it doesn't own any // resources itself; it just delegates back to this workspace. + /** + * Create-or-attach a durable REPL session by name. A session is a + * replayable log in this workspace's SQLite — it has no open/close + * lifecycle and costs nothing while idle. Evals on one session are + * serialized; use distinct names for parallel work. + */ + repl(name: string, options: Omit): ReplSession { + if (name.length === 0) throw new Error("Workspace repl session name must be non-empty."); + let session = this.#replSessions.get(name); + if (session === undefined) { + session = new ReplSession({ ...options, name, db: this.#db, now: this.#now }); + this.#replSessions.set(name, session); + } + return session; + } + stub(): WorkspaceStub { return new WorkspaceStub(this); } diff --git a/packages/computer/tests/repl-worker.ts b/packages/computer/tests/repl-worker.ts new file mode 100644 index 00000000..d431a582 --- /dev/null +++ b/packages/computer/tests/repl-worker.ts @@ -0,0 +1,79 @@ +// Test host for durable REPL sessions — the core eval loop. +// +// The DO is the session host by necessity: a plain worker cannot hold +// loader entrypoint stubs across per-request I/O contexts. `restart()` +// simulates a Durable Object eviction by rebuilding the Workspace over the +// same storage — exactly what a real restart does. + +import { DurableObject } from "cloudflare:workers"; +import type { + DurableObjectStorageLike, + ReplEvalOptions, + ReplExecutionResult, +} from "../src/index.js"; +import { Workspace } from "../src/index.js"; + +export interface Env { + HOST: DurableObjectNamespace; + LOADER: WorkerLoader; +} + +export class ReplHostDO extends DurableObject { + #workspace: Workspace | undefined; + + #ws(): Workspace { + this.#workspace ??= new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + }); + return this.#workspace; + } + + async replEval( + session: string, + code: string, + options?: ReplEvalOptions, + ): Promise { + return this.#ws().repl(session, { loader: this.env.LOADER }).eval(code, options); + } + + // Simulate DO eviction: drop every in-memory object. Storage survives. + restart(): void { + this.#workspace = undefined; + } + + // Fire two evals concurrently inside the DO (bypasses input-gate + // serialization of separate RPC calls) to exercise the session's + // internal eval queue. + async replEvalPair( + session: string, + codeA: string, + codeB: string, + ): Promise<[ReplExecutionResult, ReplExecutionResult]> { + const repl = this.#ws().repl(session, { loader: this.env.LOADER }); + const [a, b] = await Promise.all([repl.eval(codeA), repl.eval(codeB)]); + return [a, b]; + } + + // Corrupt the durable log out from under the session (divergence tests). + corruptEffectKinds(session: string): void { + this.ctx.storage.sql.exec( + "UPDATE repl_effects SET kind = 'corrupted' WHERE session = ?", + session, + ); + } + + injectExtraEffect(session: string, cellSeq: number): void { + this.ctx.storage.sql.exec( + `INSERT INTO repl_effects (session, cell_seq, call_seq, kind, value) + VALUES (?, ?, 999, 'random', '{"v":0.5}')`, + session, + cellSeq, + ); + } +} + +export default { + fetch(): Response { + return new Response("repl test host", { status: 200 }); + }, +}; diff --git a/packages/computer/tests/repl.test.ts b/packages/computer/tests/repl.test.ts new file mode 100644 index 00000000..f7886f15 --- /dev/null +++ b/packages/computer/tests/repl.test.ts @@ -0,0 +1,230 @@ +// Durable REPL sessions — core eval loop behavior tests. +// +// Seam under test: `workspace.repl(name).eval(code)` through a Durable +// Object host on real workerd with a real Worker Loader binding. + +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import type { Env, ReplHostDO } from "./repl-worker.js"; + +declare module "cloudflare:test" { + interface ProvidedEnv extends Env {} +} + +let hostSeq = 0; +function host(): DurableObjectStub { + return env.HOST.get(env.HOST.idFromName(`host-${++hostSeq}`)); +} + +describe("repl session eval", () => { + it("returns the trailing expression as value, with ordered leveled logs", async () => { + const h = host(); + const result = await h.replEval("main", [ + "console.log('starting', { n: 1 });", + "console.warn('careful');", + "console.log('resuming');", + "console.error('bad');", + "const doubled = 21 * 2;", + "doubled", + ].join("\n")); + expect(result.error).toBeUndefined(); + expect(result.value).toBe(42); + expect(result.executionCount).toBe(1); + // One stream: emission order and levels both survive. + expect(result.logs.entries).toEqual([ + { level: "log", text: "starting { n: 1 }" }, + { level: "warn", text: "careful" }, + { level: "log", text: "resuming" }, + { level: "error", text: "bad" }, + ]); + expect(result.logs.dropped).toBeUndefined(); + }); + + it("persists bindings across evals: const, let, function, class, destructuring", async () => { + const h = host(); + await h.replEval("main", "const base = 10; let count = 0;"); + await h.replEval("main", "function bump(n) { count += n; return count; }"); + await h.replEval("main", "class Acc { constructor() { this.total = 0; } add(n) { this.total += n; return this; } }"); + await h.replEval("main", "const { a, ...rest } = { a: 1, b: 2, c: 3 };"); + const result = await h.replEval( + "main", + "return { bumped: bump(5), acc: new Acc().add(base).total, a, rest };", + ); + expect(result.error).toBeUndefined(); + expect(result.value).toEqual({ bumped: 5, acc: 10, a: 1, rest: { b: 2, c: 3 } }); + expect(result.executionCount).toBe(5); + }); + + it("keeps sessions independent by name", async () => { + const h = host(); + await h.replEval("one", "const x = 1;"); + await h.replEval("two", "const x = 2;"); + expect((await h.replEval("one", "x")).value).toBe(1); + expect((await h.replEval("two", "x")).value).toBe(2); + }); + + it("survives a restart: state rebuilt exactly, recorded effects not re-rolled", async () => { + const h = host(); + await h.replEval("main", [ + "const rolls = [Math.random(), Math.random()];", + "const stamp = Date.now();", + "const id = crypto.randomUUID();", + "const when = new Date();", + "const iso = when.toISOString();", + "const dateStr = Date();", + "const perf = performance.now();", + "const bytes = Array.from(crypto.getRandomValues(new Uint8Array(8)));", + "const derived = rolls.map((r) => Math.floor(r * 1000));", + ].join("\n")); + const before = await h.replEval( + "main", + "return { rolls, stamp, id, iso, dateStr, perf, bytes, derived, whenMs: when.getTime() };", + ); + expect(before.error).toBeUndefined(); + + await h.restart(); + + const after = await h.replEval( + "main", + "return { rolls, stamp, id, iso, dateStr, perf, bytes, derived, whenMs: when.getTime() };", + ); + expect(after.error).toBeUndefined(); + expect(after.value).toEqual(before.value); + expect(after.executionCount).toBe(3); + }); + + it("keeps Date semantics intact around the clock shim", async () => { + const h = host(); + const result = await h.replEval("main", [ + "const epoch = new Date(0);", + "return {", + " isDate: new Date() instanceof Date,", + " epochIso: epoch.toISOString(),", + " parsed: Date.parse('2024-01-02T03:04:05.000Z'),", + " fromParts: new Date(2024, 0, 2).getFullYear(),", + "};", + ].join("\n")); + expect(result.error).toBeUndefined(); + expect(result.value).toEqual({ + isDate: true, + epochIso: "1970-01-01T00:00:00.000Z", + parsed: 1704164645000, + fromParts: 2024, + }); + }); + + it("removes unreplayable globals: WeakRef, FinalizationRegistry, caches", async () => { + const h = host(); + const result = await h.replEval( + "main", + "return [typeof WeakRef, typeof FinalizationRegistry, typeof caches];", + ); + expect(result.error).toBeUndefined(); + expect(result.value).toEqual(["undefined", "undefined", "undefined"]); + }); + + it("never commits a failed cell; the session continues from the last good state", async () => { + const h = host(); + await h.replEval("main", "const safe = 'intact'; let steps = 0;"); + const failed = await h.replEval("main", "steps += 1;\nthrow new Error('boom');"); + expect(failed.error?.name).toBe("Error"); + expect(failed.error?.message).toBe("boom"); + expect(failed.error?.traceback).toContain("boom"); + expect(failed.executionCount).toBe(2); + + // The failed cell's mutation must not survive (it never entered the log). + const result = await h.replEval("main", "return { safe, steps };"); + expect(result.value).toEqual({ safe: "intact", steps: 0 }); + expect(result.executionCount).toBe(2); + }); + + it("reports syntax errors as results and stays usable", async () => { + const h = host(); + const bad = await h.replEval("main", "const = nope;"); + expect(bad.error?.name).toBe("SyntaxError"); + const ok = await h.replEval("main", "1 + 1"); + expect(ok.value).toBe(2); + expect(ok.executionCount).toBe(1); + }); + + it("omits unclonable values but renders them, without failing the cell", async () => { + const h = host(); + const result = await h.replEval("main", "const fn = (x) => x + 1;\nfn"); + expect(result.error).toBeUndefined(); + expect("value" in result).toBe(false); + expect(result.results[0]?.text).toContain("Function"); + + // The binding itself still persists and works. + const use = await h.replEval("main", "fn(41)"); + expect(use.value).toBe(42); + }); + + // A `while (true) {}` spin would rely on the loader's cpuMs limit, which + // local workerd doesn't enforce (it wedges the test process). An async + // hang exercises the host-side wall-clock timeout honestly. + it("times out hung cells without committing them", async () => { + const h = host(); + await h.replEval("main", "const alive = true;"); + const result = await h.replEval("main", "await new Promise(() => {});", { timeoutMs: 500 }); + expect(result.error).toBeDefined(); + expect(result.error?.kind).toBe("timeout"); + + const ok = await h.replEval("main", "alive"); + expect(ok.value).toBe(true); + expect(ok.executionCount).toBe(2); + }); + + it("surfaces dropped console entries instead of losing them silently", async () => { + const h = host(); + const result = await h.replEval("main", "for (let i = 0; i < 1005; i++) console.log(i);"); + expect(result.error).toBeUndefined(); + expect(result.logs.entries).toHaveLength(1000); + expect(result.logs.entries[999]).toEqual({ level: "log", text: "999" }); + expect(result.logs.dropped).toBe(5); + }); + + it("reports console output only from the new cell, not replayed ones", async () => { + const h = host(); + await h.replEval("main", "console.log('cell one noise');"); + const result = await h.replEval("main", "console.log('cell two'); 7"); + expect(result.logs.entries).toEqual([{ level: "log", text: "cell two" }]); + expect(result.value).toBe(7); + }); + + it("serializes concurrent evals on one session in arrival order", async () => { + const h = host(); + // The second cell only works if the first fully committed before it ran. + const [a, b] = await h.replEvalPair("main", "let n = 1;\nn", "n += 1;\nn"); + expect(a.error).toBeUndefined(); + expect(b.error).toBeUndefined(); + expect(a.value).toBe(1); + expect(b.value).toBe(2); + expect(a.executionCount).toBe(1); + expect(b.executionCount).toBe(2); + }); + + it("fails loudly with replay-divergence when the log no longer matches (kind mismatch)", async () => { + const h = host(); + await h.replEval("main", "const roll = Math.random(); const tag = 'kept';"); + await h.restart(); // drop the cached in-memory log + await h.corruptEffectKinds("main"); + + const result = await h.replEval("main", "roll"); + expect(result.error?.kind).toBe("replay-divergence"); + expect(result.error?.message).toContain("replay"); + // The failing eval was not committed. + expect(result.executionCount).toBe(2); + }); + + it("fails loudly with replay-divergence when recorded effects go unconsumed", async () => { + const h = host(); + await h.replEval("main", "const roll = Math.random();"); + await h.restart(); + await h.injectExtraEffect("main", 1); + + const result = await h.replEval("main", "roll"); + expect(result.error?.kind).toBe("replay-divergence"); + expect(result.error?.message).toContain("never consumed"); + }); +}); diff --git a/packages/computer/tests/wrangler.repl.jsonc b/packages/computer/tests/wrangler.repl.jsonc new file mode 100644 index 00000000..95c29d6c --- /dev/null +++ b/packages/computer/tests/wrangler.repl.jsonc @@ -0,0 +1,12 @@ +// Config for durable REPL session tests (vitest.config.repl.ts). +{ + "name": "repl-tests", + "main": "./repl-worker.ts", + "compatibility_date": "2026-06-23", + "compatibility_flags": ["nodejs_compat", "experimental"], + "worker_loaders": [{ "binding": "LOADER" }], + "durable_objects": { + "bindings": [{ "name": "HOST", "class_name": "ReplHostDO" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ReplHostDO"] }] +} diff --git a/packages/computer/vitest.config.repl.ts b/packages/computer/vitest.config.repl.ts new file mode 100644 index 00000000..d17e8928 --- /dev/null +++ b/packages/computer/vitest.config.repl.ts @@ -0,0 +1,16 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +// Durable REPL session tests. Run: npm run test:repl +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./tests/wrangler.repl.jsonc" }, + }), + ], + test: { + globals: true, + include: ["tests/repl.test.ts"], + testTimeout: 60_000, + }, +});