Moving a host onto this executor
Hosts that run @cloudflare/codemode today call DynamicWorkerExecutor. It needs the Workers platform and the WorkerLoader binding it starts a Dynamic Worker through. @supolka/cloudflare-codemode-executor serves the same dialect over an executor that runs anywhere, in your own process or in a daemon beside it. Everything below assumes a host that already speaks the dialect. Hosts starting fresh read the executor protocol and write against the native contract instead, and the NestJS example shows that shape.
Swapping the executor
Section titled “Swapping the executor”CodemodeExecutor implements the dialect over any native executor, so only the source of the executor changes:
import { CodemodeExecutor } from '@supolka/cloudflare-codemode-executor'import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new CodemodeExecutor(new IsolateExecutor())IsolateExecutor keeps the pool in your own process, and the isolates in the children it supervises, with no network between them. To move the whole pool into a daemon instead, the native executor is a connection:
import { CodemodeExecutor, connect } from '@supolka/cloudflare-codemode-executor'
const executor = new CodemodeExecutor(await connect('ws://executor:8080'))connect builds the socket from the runtime’s own WebSocket. A runtime that has none, or a handshake that needs headers, passes an implementation as websocket instead. Everything else the host already wrote stays: the provider objects with their functions, the execute(code, providers, options) call, the {result, error, logs} it answers with, the log prefixes, the tool name sanitization and the collision text, the timeout sentence.
One extension exists beyond the dialect. DynamicWorkerExecutor takes the module map, the timeout and the outbound fetcher in its constructor, and its execute options carry none of the three. So a host that varies any of them builds a fresh executor per call, which here would mean a pool per call. ExtendedExecuteOptions carries those three per call onto one long-lived executor, so a host that bundles a fresh module graph per execution keeps its warm workers.
Outbound reverses on the swap
Section titled “Outbound reverses on the swap”Swapping the executor changes what the model’s code can reach on the network, and it changes it in the dangerous direction. Read this before you ship. DynamicWorkerExecutor blocks outbound traffic unless the host opts in. Its globalOutbound defaults to null, and its own types call that default runtime-enforced: fetch and connect throw inside the sandbox, and a host that wants a controlled path passes a Fetcher. But this executor’s outbound reads the other way round. Omitting it means native, so the executor performs whatever request the program asks for, from the machine the executor runs on, with the network position that machine has. null is the value that closes it.
One value ends up pointing in opposite directions, and a migrating host is most likely to leave exactly that one alone:
@cloudflare/codemode |
this executor | |
|---|---|---|
| Nothing passed | blocked | the executor performs the request |
null |
blocked | blocked |
A function or Fetcher |
routed through it | routed through it |
Swap two lines and leave the rest alone, and your model-written code moves from no network at all to full egress from the machine the executor runs on. Keep the behaviour it had by saying so:
const executor = new CodemodeExecutor(new IsolateExecutor({ outbound: null }))outbound: null restores what globalOutbound gave by default. Opening a controlled path instead means passing a function, and the network guide says what to put in it. That policy runs in your own process, so the decision stays with you.
Changes for the code the model writes
Section titled “Changes for the code the model writes”Inside the isolate the runtime is written from the standards, not borrowed, so the surface is close to what a Worker gives and not identical to it. Runtime deviations lists every place it answers differently from the standard that governs an interface. Migrating hosts meet the ones below in practice.
No Node exists in the sandbox, and no nodejs_compat to turn on. A bundle that imports node:async_hooks gets whatever your build step put in the module map under that name, and no other name resolves. Your build step decides, not the runtime, and a Worker bundle already makes the same decision.
WebAssembly is gone from the global, and a resizable buffer cannot grow past the length it was created with, shared or not. Each of those allocates memory the isolate’s limit does not meter. CompressionStream, DecompressionStream and caches are declared absences: the name exists, and the first touch answers a text saying why and what remains possible.
Every isolate is disposed when the run settles, and the dialect’s own executors do the same. retainHandles is the one negotiated exception. No field for it exists in the dialect’s execute options, and the persistence contract specifies it on the native contract.
Work that stays with the host
Section titled “Work that stays with the host”The dialect hands the executor a provider’s functions, and those functions stay in your process. Only data crosses the boundary, in both directions. The dialect turns away what its own declaration does not carry: a tool the provider never listed answers Tool "<tool>" not found from the adapter, whatever proxy source that provider supplied, and a provider nobody declared is not a name in the sandbox at all. But one shape stays open on purpose. A connector’s descriptor carries an open list, and its calls reach callTool under any name. For it, and for any host writing against the native contract, the function that serves a call decides, not the list the model was shown, per providers and the sandbox scope.
Instrumentation the host wrapped around the executed code also stays the host’s, and it moves without an executor change. That pattern is generated source: the host wraps the model’s arrow in its own. The wrapper captures the console, counts what it wants to count, and flushes at the end through a provider it declares for itself, while the dispatchers are still live. On every gate, a migrated host’s suite runs that whole shape against this executor, console capture, a record of the hosts the run fetched and an end-of-run flush included.
Bounds and failures the executor adds
Section titled “Bounds and failures the executor adds”A self-hosted executor has to bound what a platform used to bound for it. Memory is the engine’s limit, and the runtime closes the allocation paths V8 keeps outside its meter. Time is measured on the host, so a guest that cannot be interrupted still settles at its bound. A full queue answers a typed, retryable fault instead of growing, and what a run sends outward is bounded where it is built. The threat model says what each of those enforces and what stands behind it, and every run of the gate measures that they hold, under the method of the benchmark.
Failures the executor declares are messages written for the model to read and act on, not codes for a log. A run that filled its heap is told to work in smaller pieces; a run that met a closed network is told to ask through a tool call. The failure grammar declares each one. But the timeout is the exception on this path: the native text names the bound, and the adapter renders it back down to the sentence the dialect pins, so a migrating host reads what it read before.
Checking the move
Section titled “Checking the move”The differential suite replays one case set through the real DynamicWorkerExecutor in a real workerd and through this executor, then diffs the outcomes. It runs against every line the peer range admits: the installed release, and a pinned build of each older line still inside the range. Hosts that want the same confidence over their own corpus run their cases through both executors and compare the outcomes. The adapter chapter says how that suite’s case model is built for exactly that.
Swapping the executor keeps the dialect and changes what runs under it, so what your host already wrote goes on working. The executor protocol is that executor’s own contract. You reach for it directly to ask for what the dialect has no field for, a retained execution first among them. That contract also states the wire form. Hosts that would rather implement it than call it start there.