Files
fx/sdk/scripts/bundle-durable.mjs
T
Pranit b0d83eb894 Make libfx sessions durable
- `createFxAgent()` returns the agent at once instead of a promise, and does no I/O until a session runs a turn.
- `agent.session(id)` opens a session, and `session.prompt()` returns a turn view with `accepted`, `result`, async iteration over its events, and an NDJSON `readable`.
- Sessions are durable by default: `vercel()` on Vercel, `local()` files in Node.js elsewhere, and `memory()` in browsers. The `durability` option picks one.
- `libfx/durable-local` and `libfx/durable-vercel` bundle their World, so an app installs no other package.
- A turn whose process crashes, redeploys, or reaches its function deadline continues from its last record in the next process that gets work for the session.
- On Vercel, a turn stops at its next model request 30 seconds before the function deadline and continues in a new invocation from the same queue message.
- A model request or tool call still running 2 seconds before the deadline is cut off, and `onEvent` receives `session.deadline`.
- One process runs a session's turns at a time, and a prompt for a busy session queues behind the running turn.
- A process that froze and was replaced stops at its next write and never starts a tool with effects, and `onEvent` receives `session.fenced`.
- After an interruption, a call to an `idempotent: true` tool runs again. Any other call never reruns, and the model gets an error saying it may have partly run.
- `executionId` is the same each time a call runs, including after a crash.
- A prompt sent again with the same `messageId` starts nothing new: its view follows the original turn, or resolves with `repeated: true`.
- `session.stream(cursor)` replays a session's events from any cursor on any server, with `turn_start`, `turn_end`, `turn_resume`, and `turn_yield` lines.
- Every reader of a session's stream shows the same lines, because lines a replaced worker lands late are hidden.
- `session.steer()` and `session.cancel()` reach the session's running turn, and `session.resume()` continues a turn a stopped process left open.
- Every turn end and yield saves a checkpoint, so continuing a session reads one checkpoint and the records after it.
- Checkpoints record the libfx version, tool set, and model. A session or checkpoint saved under different ones still loads, and `onEvent` receives `checkpoint.mismatch`.
- `session.checkpoint()` exports a session's history, and the `checkpoint` option starts a new agent's session from it.
- `agent.wakeHandler()` is the queue delivery route an app mounts on Vercel and subscribes to `__libfx_wkf_workflow_session` in `vercel.json`.
- On Vercel, libfx authenticates to the AI Gateway with the deployment's OIDC token when neither `apiKey` nor `AI_GATEWAY_API_KEY` is set.
- `createFxEngine()` accepts a `persistence` store, and `createMemoryPersistence()` provides one in memory. A store that rejects a stale append with `FxFencedError` stops a second writer.
- A turn whose store append fails stops at once with `FX_JOURNAL_APPEND_FAILED`, and the agent refuses later prompts.
2026-10-07 01:47:00 -04:00

44 lines
1.9 KiB
JavaScript

// Bundles each durability module with the World it wraps into one
// self-contained ES module, so an app installs nothing more and never loads a
// World of its own. Run with Bun: `bun sdk/scripts/bundle-durable.mjs <out>`.
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const [outputDir] = process.argv.slice(2);
if (!outputDir) throw new Error("usage: bun sdk/scripts/bundle-durable.mjs <output-dir>");
const worlds = new URL("../durable/node_modules/@workflow/", import.meta.url);
const localWorld = await Bun.file(new URL("world-local/package.json", worlds)).json();
// world-local versions its data directory from the package.json beside its
// own module, which a bundle no longer has, so the version is pinned here.
const marker = "export async function getPackageInfo() {";
const pinWorldVersion = {
name: "pin-world-local-version",
setup(build) {
build.onLoad({ filter: /[\\/]world-local[\\/]dist[\\/]init\.js$/ }, async ({ path }) => {
const source = await Bun.file(path).text();
if (source.split(marker).length !== 2) throw new Error("world-local's getPackageInfo() changed; update the version pin");
const pinned = JSON.stringify({ name: localWorld.name, version: localWorld.version });
return { contents: source.replace(marker, `${marker}\n return ${pinned};`), loader: "js" };
});
},
};
for (const name of ["local", "vercel"]) {
const result = await Bun.build({
entrypoints: [fileURLToPath(new URL(`../durable/${name}.mjs`, import.meta.url))],
outdir: resolve(outputDir, "durable"),
naming: `${name}.mjs`,
target: "node",
format: "esm",
minify: true,
// cbor-x prefers an optional native addon and falls back without it.
external: ["cbor-extract"],
plugins: [pinWorldVersion],
});
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
}