UNPKG

eve

Version:

Filesystem-first framework for durable backend AI agents that run anywhere.

245 lines • 10.9 kB
/** * QuickJS WebAssembly (WASM) workflow VM. * * An alternative engine for the event-replay execution model: the workflow * code runs inside a QuickJS WASM VM (via quickjs-wasi) instead of a * `node:vm` context. Every invocation creates a fresh VM, re-executes the * workflow function from the top, and replays the recorded event log to * resolve awaited primitives: the same replay semantics as the `node:vm` * engine. * * The workflow primitives (useStep, sleep, createHook) are implemented as * JavaScript code running inside the QuickJS VM. The host communicates with * the VM by evaluating small JS snippets to read pending operations and * resolve/reject promises. * * The VM bootstrap is deliberately split into two phases: * 1. Static initialization (`initWorkflowVM`): run-independent setup: * VM creation and the workflow primitives. (Serialization lives on * the host (see quickjs-serde.ts) so no serde code is evaluated * in the VM.) * 2. Per-run initialization (inline in `runQuickJSWorkflow`): seeded * PRNG/ULID host functions, workflow bundle evaluation, run metadata, * workflow input, and start. * Keeping the phases separate is groundwork for VM-memory snapshotting: * a follow-up can persist/restore the VM at the phase boundary (e.g. a * build-time initial snapshot) without restructuring this module. Note * that bundle evaluation currently sits in the per-run phase so that * module-scope user code observes the seeded `Math.random`, matching the * `node:vm` engine's replay determinism. */ import { SerializationError } from '#compiled/@workflow/errors/index.js'; import { type Event, type RunInput, type WorkflowRun, type WorldCapabilities } from '#compiled/@workflow/world/index.js'; import { type AttributeChange } from '#compiled/@workflow/world/index.js'; import { type Snapshot } from '../_quickjs-wasi.js'; import type { DecryptionKey } from '../serialization/encryption.js'; export interface PendingStep { type: 'step'; correlationId: string; stepId: string; /** * Format-prefixed devalue-serialized step input (args + closureVars). * Absent when {@link serializationError} is set: the input is precisely * what refused to serialize. */ input?: Uint8Array; /** Whether a step_created event already exists for this step */ hasCreatedEvent: boolean; /** * Set when host-side serialization of the step's raw input failed while * dumping the VM's pending ops (see `dumpPendingOps`). The failure is * deterministic (replaying re-derives the same unserializable value), so * instead of failing the whole collection the op is surfaced with the * reframed error and no `input`; the entrypoint finalizes the step as * `step_created` (placeholder input) + `step_failed`, mirroring the * node:vm engine's `finalizeUnserializableStep`, so a try/catch around * the step call observes the SerializationError. */ serializationError?: SerializationError; } export interface PendingWait { type: 'wait'; correlationId: string; /** ISO string of when to resume */ resumeAt: string; /** Whether a wait_created event already exists for this wait */ hasCreatedEvent: boolean; } export interface PendingHook { type: 'hook'; correlationId: string; token: string; /** Earliest token reuse time, as milliseconds since the Unix epoch. */ tokenRetentionUntil?: number; isWebhook: boolean; metadata?: unknown; hasCreatedEvent: boolean; /** * True for internal system hooks (e.g. AbortController's hook), which * are exempt from user-hook token namespace conflict checks. */ isSystem?: boolean; /** * Set when the workflow called AbortController.abort() during this * invocation. The host must record the abort: create a hook_received * event carrying `abortPayload` and write/close the abort stream. */ abortRequested?: boolean; /** VM-serialized `{ aborted: true, reason }` payload for the abort. */ abortPayload?: Uint8Array; /** Set by the completion drain when a system hook is implicitly disposed. */ disposed?: boolean; /** * True when the workflow is awaiting hook.getConflict() for this hook. * The entrypoint re-invokes the workflow right after writing * hook_created so replay can confirm creation and resolve the awaiter. */ hasGetConflictAwaiter?: boolean; } export interface PendingAttribute { type: 'attribute'; correlationId: string; /** Normalized attribute changes (plain JSON-able objects) */ changes: AttributeChange[]; allowReservedAttributes?: boolean; /** Whether an attr_set event already exists for this write */ hasCreatedEvent: boolean; } export interface PendingHookDispose { type: 'hook_dispose'; correlationId: string; /** * Token of the hook being disposed. Used by the entrypoint to order * same-token hook operations sequentially in code order. */ token?: string; hasCreatedEvent: boolean; } export type PendingOperation = PendingStep | PendingWait | PendingHook | PendingAttribute | PendingHookDispose; export interface QuickJSRuntimeResult { /** The workflow completed: result is format-prefixed devalue bytes */ completed?: { result: Uint8Array; /** * Leftover pending operations that still need durable side effects at * completion: abort recordings, system-hook disposals, fire-and-forget * attribute/hook/step events. Mirrors the node:vm engine's * drainPendingQueueItems. The entrypoint dispatches these WITHOUT * queueing steps or requeuing the run. */ drainOperations?: PendingOperation[]; }; /** The workflow suspended with pending operations */ suspended?: { pendingOperations: PendingOperation[]; }; /** The workflow failed */ failed?: { message: string; stack?: string; name?: string; /** See completed.drainOperations: same semantics on failure. */ drainOperations?: PendingOperation[]; /** * Format-prefixed devalue bytes of the original thrown value * (Error subclass with cause chain, plain object, primitive, etc.). * Set when the VM-side rejection handler successfully serializes * the thrown value. The host uses these bytes to reconstruct the * original value through the standard error hydration pipeline, * preserving type identity (TypeError, FatalError) and non-Error * throws verbatim. Falls back to the message/stack/name fields * when this is undefined (e.g. extractError pseudo-failures). */ valueBytes?: Uint8Array; }; } export interface QuickJSRuntimeOptions { /** The compiled workflow bundle code (workflow mode output from SWC) */ workflowCode: string; /** The workflow ID (e.g. "workflow//./workflows/1_simple//simple") */ workflowId: string; /** The workflow run entity */ workflowRun: WorkflowRun; /** Features supported by the World executing this workflow. */ worldCapabilities?: WorldCapabilities; /** * The full event log for the run. Every invocation replays the complete * log from the start (same replay semantics as the `node:vm` engine). */ events: Event[]; /** Encryption key for decrypting event payloads (undefined if unencrypted) */ encryptionKey?: DecryptionKey; /** * The local port the workflow server is listening on, used to populate * `workflowMetadata.url`. Resolved at call time on the host side so the * VM doesn't have to probe the filesystem. Ignored on Vercel, where * VERCEL_URL takes precedence. */ port?: number; /** * Fallback workflow input from the queue message's resilient-start * payload. Used when the fetched event log lacks a `run_created` event * (eventually-consistent read after the parent's start() wrote it). */ runInput?: RunInput; } /** * Eval filename used when hydrating the baseline VM. The baseline is * shared by EVERY workflow in the bundle, so the filename baked into * its compiled code (and therefore into snapshot-path stack frames) * must be workflow-independent: hydrating under the first caller's * workflowId would break `remapErrorStack`'s filename matching for * every other workflow in the bundle. Remap call sites match this * constant IN ADDITION to the run's module specifier (which covers * fresh-path frames). */ export declare const BASELINE_BUNDLE_FILENAME = "workflow-bundle.js"; type BaselineEntry = { state: 'ready'; snapshot: Snapshot; /** * Raw box pointer of the serde capture root created BEFORE the * bundle evaluated (see captureSerdeRoot). The box lives in the * snapshot's memory image at this offset; every restored VM * re-adopts it so serde initialization executes no guest code * after user code has run: capture-before-user-code semantics, * identical to the fresh path. */ serdeRootPtr: number; } | { state: 'ineligible'; reason: string; }; /** Test-only: reset the baseline cache between test cases. */ export declare function __clearBaselineSnapshotCacheForTests(): void; /** Test-only: observe how a bundle was classified. */ export declare function __peekBaselineEntryForTests(workflowCode: string): Promise<BaselineEntry | undefined>; /** * A live QuickJS workflow invocation. When the initial `result` is * `suspended`, the VM is kept alive so the caller can feed newly recorded * events (e.g. terminal events of inline-executed steps) into the SAME VM * via `continueWithEvents`, resuming execution exactly where it left off * without a fresh-VM re-replay. Terminal results dispose the VM * automatically; `dispose()` must be called when abandoning a suspended * session (idempotent). */ export interface QuickJSWorkflowSession { result: QuickJSRuntimeResult; /** * Process newly recorded events in the live VM and re-evaluate the * workflow state. Only valid while the last result was `suspended`. * Resets the VM's interrupt budget for the new execution burst. */ continueWithEvents(newEvents: Event[]): Promise<QuickJSRuntimeResult>; /** Dispose the VM if it is still alive. Safe to call multiple times. */ dispose(): void; } /** * Run a workflow invocation to its first settled state and dispose the * VM. Convenience wrapper over {@link startQuickJSWorkflow} for callers * (and tests) that don't use live-VM continuation. */ export declare function runQuickJSWorkflow(options: QuickJSRuntimeOptions): Promise<QuickJSRuntimeResult>; export declare function startQuickJSWorkflow(options: QuickJSRuntimeOptions): Promise<QuickJSWorkflowSession>; export {}; //# sourceMappingURL=quickjs-runtime.d.ts.map